forgexa-cli 1.63.0__tar.gz → 1.64.0__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.
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/PKG-INFO +1 -1
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli/agent_core.py +4 -2
- forgexa_cli-1.64.0/forgexa_cli/aws_evidence.py +279 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli/daemon.py +169 -57
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli.egg-info/SOURCES.txt +1 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/pyproject.toml +1 -1
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/README.md +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/setup.cfg +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.63.0 → forgexa_cli-1.64.0}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.64.0"
|
|
@@ -2871,6 +2871,7 @@ def mimo_base_env(env: dict[str, str]) -> dict[str, str]:
|
|
|
2871
2871
|
},
|
|
2872
2872
|
"external_directory": "deny",
|
|
2873
2873
|
"task": "allow",
|
|
2874
|
+
"actor": "allow",
|
|
2874
2875
|
"skill": {"*": "allow", "compose:*": "deny"},
|
|
2875
2876
|
"webfetch": "deny",
|
|
2876
2877
|
"websearch": "deny",
|
|
@@ -3663,8 +3664,9 @@ def build_copilot_command(
|
|
|
3663
3664
|
stdin instead - verified live on CLI 1.0.86, where piped stdin is a full
|
|
3664
3665
|
non-interactive prompt source. The daemon uses it for native launchers
|
|
3665
3666
|
to avoid single-argument and total command-line limits; Windows batch
|
|
3666
|
-
wrappers retain task-file redirection. The host uses it
|
|
3667
|
-
|
|
3667
|
+
wrappers retain task-file redirection. The host uses it for every prompt
|
|
3668
|
+
so task text never passes through Windows batch argument parsing or
|
|
3669
|
+
command-line limits, including when attachments are inlined.
|
|
3668
3670
|
"""
|
|
3669
3671
|
cmd = [
|
|
3670
3672
|
binary,
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
PAYLOAD_MAX_BYTES = 10 * 1024 * 1024
|
|
12
|
+
SUMMARY_MAX_BYTES = 32 * 1024
|
|
13
|
+
REFERENCE_FIELDS = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"issuer",
|
|
16
|
+
"workspace_id",
|
|
17
|
+
"project_id",
|
|
18
|
+
"user_id",
|
|
19
|
+
"snapshot_id",
|
|
20
|
+
"payload_sha256",
|
|
21
|
+
"summary_sha256",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
SECRET_FIELDS = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"access_key_id",
|
|
27
|
+
"secret_access_key",
|
|
28
|
+
"session_token",
|
|
29
|
+
"private_key",
|
|
30
|
+
"encrypted_material",
|
|
31
|
+
"aws_access_key_id",
|
|
32
|
+
"aws_secret_access_key",
|
|
33
|
+
"aws_session_token",
|
|
34
|
+
"authorization",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EvidenceError(ValueError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def canonical_json(value: Any) -> str:
|
|
44
|
+
return json.dumps(
|
|
45
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def digest(value: Any) -> str:
|
|
50
|
+
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def model_destination(driver_id: str, environment: dict) -> dict:
|
|
54
|
+
if any(
|
|
55
|
+
environment.get(key) not in (None, "", "0", "false")
|
|
56
|
+
for key in (
|
|
57
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
58
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
59
|
+
"CLAUDE_CODE_USE_FOUNDRY",
|
|
60
|
+
)
|
|
61
|
+
):
|
|
62
|
+
raise EvidenceError("aws_evidence_unknown_provider")
|
|
63
|
+
defaults = {
|
|
64
|
+
"claude": ("anthropic", "https://api.anthropic.com"),
|
|
65
|
+
"copilot": ("github", "https://api.githubcopilot.com"),
|
|
66
|
+
"gemini": ("google", "https://generativelanguage.googleapis.com"),
|
|
67
|
+
}
|
|
68
|
+
explicit_keys = {
|
|
69
|
+
"claude": ("ANTHROPIC_BASE_URL",),
|
|
70
|
+
"mimo": ("ANTHROPIC_BASE_URL",),
|
|
71
|
+
"deepseek": ("DEEPSEEK_BASE_URL", "ANTHROPIC_BASE_URL"),
|
|
72
|
+
"copilot": ("COPILOT_API_URL", "GITHUB_COPILOT_BASE_URL", "GH_HOST"),
|
|
73
|
+
"gemini": ("GOOGLE_GEMINI_BASE_URL", "GEMINI_API_BASE_URL"),
|
|
74
|
+
"kimi": ("KIMI_BASE_URL", "OPENAI_BASE_URL"),
|
|
75
|
+
}
|
|
76
|
+
if driver_id not in explicit_keys:
|
|
77
|
+
raise EvidenceError("aws_evidence_unknown_provider")
|
|
78
|
+
endpoints = [environment[key] for key in explicit_keys[driver_id] if environment.get(key)]
|
|
79
|
+
if len(set(endpoints)) > 1 or (not endpoints and driver_id not in defaults):
|
|
80
|
+
raise EvidenceError("aws_evidence_unknown_provider")
|
|
81
|
+
endpoint = endpoints[0] if endpoints else defaults[driver_id][1]
|
|
82
|
+
parsed = urlsplit(endpoint)
|
|
83
|
+
known_hosts = {
|
|
84
|
+
"api.anthropic.com": "anthropic",
|
|
85
|
+
"api.githubcopilot.com": "github",
|
|
86
|
+
"api.individual.githubcopilot.com": "github",
|
|
87
|
+
"api.business.githubcopilot.com": "github",
|
|
88
|
+
"api.enterprise.githubcopilot.com": "github",
|
|
89
|
+
"generativelanguage.googleapis.com": "google",
|
|
90
|
+
"api.kimi.com": "moonshot",
|
|
91
|
+
"api.moonshot.cn": "moonshot",
|
|
92
|
+
"api.moonshot.ai": "moonshot",
|
|
93
|
+
"api.xiaomimimo.com": "xiaomi",
|
|
94
|
+
"api.deepseek.com": "deepseek",
|
|
95
|
+
}
|
|
96
|
+
provider = known_hosts.get(parsed.hostname or "")
|
|
97
|
+
if (
|
|
98
|
+
parsed.scheme != "https"
|
|
99
|
+
or parsed.username
|
|
100
|
+
or parsed.password
|
|
101
|
+
or not provider
|
|
102
|
+
or parsed.port not in (None, 443)
|
|
103
|
+
):
|
|
104
|
+
raise EvidenceError("aws_evidence_unknown_provider")
|
|
105
|
+
return {"model_provider": provider, "endpoint": endpoint.rstrip("/")}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_reference(reference: Any) -> dict:
|
|
109
|
+
if not isinstance(reference, dict) or set(reference) != REFERENCE_FIELDS:
|
|
110
|
+
raise EvidenceError("aws_evidence_invalid_reference")
|
|
111
|
+
for field in ("workspace_id", "project_id", "user_id", "snapshot_id"):
|
|
112
|
+
value = reference[field]
|
|
113
|
+
if not isinstance(value, str):
|
|
114
|
+
raise EvidenceError("aws_evidence_invalid_reference")
|
|
115
|
+
try:
|
|
116
|
+
if str(UUID(value)) != value:
|
|
117
|
+
raise ValueError("Noncanonical identifier")
|
|
118
|
+
except ValueError:
|
|
119
|
+
raise EvidenceError("aws_evidence_invalid_reference") from None
|
|
120
|
+
for field in ("payload_sha256", "summary_sha256"):
|
|
121
|
+
if not isinstance(reference[field], str) or not re.fullmatch(
|
|
122
|
+
r"[0-9a-f]{64}", reference[field]
|
|
123
|
+
):
|
|
124
|
+
raise EvidenceError("aws_evidence_invalid_reference")
|
|
125
|
+
issuer = reference["issuer"]
|
|
126
|
+
if (
|
|
127
|
+
not isinstance(issuer, str)
|
|
128
|
+
or len(issuer) > 2048
|
|
129
|
+
or not issuer.startswith(("https://", "http://"))
|
|
130
|
+
):
|
|
131
|
+
raise EvidenceError("aws_evidence_invalid_reference")
|
|
132
|
+
return dict(reference)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _unique_object(pairs: list) -> dict:
|
|
136
|
+
result = {}
|
|
137
|
+
for key, value in pairs:
|
|
138
|
+
if key in result or key.lower() in SECRET_FIELDS:
|
|
139
|
+
raise EvidenceError("aws_evidence_invalid_payload")
|
|
140
|
+
result[key] = value
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _reject_constant(value: str) -> None:
|
|
145
|
+
raise EvidenceError("aws_evidence_invalid_payload")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def validate_payload(reference: Any, metadata: Any, payload: Any, model_provider: str) -> dict:
|
|
149
|
+
reference = validate_reference(reference)
|
|
150
|
+
if not isinstance(payload, str) or len(payload.encode("utf-8")) > PAYLOAD_MAX_BYTES:
|
|
151
|
+
raise EvidenceError("aws_evidence_size_limit")
|
|
152
|
+
if hashlib.sha256(payload.encode("utf-8")).hexdigest() != reference["payload_sha256"]:
|
|
153
|
+
raise EvidenceError("aws_evidence_integrity_failed")
|
|
154
|
+
if not isinstance(metadata, dict) or metadata.get("status") not in ("ready", "partial"):
|
|
155
|
+
raise EvidenceError("aws_evidence_unavailable")
|
|
156
|
+
for field in REFERENCE_FIELDS - {"issuer"}:
|
|
157
|
+
if metadata.get(field) != reference[field]:
|
|
158
|
+
raise EvidenceError("aws_evidence_identity_mismatch")
|
|
159
|
+
policy = metadata.get("data_policy")
|
|
160
|
+
if (
|
|
161
|
+
not isinstance(policy, dict)
|
|
162
|
+
or policy.get("allow_agent_transfer") is not True
|
|
163
|
+
or not isinstance(policy.get("allowed_model_providers"), list)
|
|
164
|
+
or not model_provider
|
|
165
|
+
or model_provider not in policy.get("allowed_model_providers", [])
|
|
166
|
+
or type(policy.get("policy_version")) is not int
|
|
167
|
+
or policy["policy_version"] < 1
|
|
168
|
+
):
|
|
169
|
+
raise EvidenceError("aws_evidence_policy_denied")
|
|
170
|
+
try:
|
|
171
|
+
expires = datetime.fromisoformat(str(metadata["expires_at"]).replace("Z", "+00:00"))
|
|
172
|
+
if expires.tzinfo is None or expires <= datetime.now(expires.tzinfo):
|
|
173
|
+
raise ValueError("Expired")
|
|
174
|
+
evidence = json.loads(
|
|
175
|
+
payload, object_pairs_hook=_unique_object, parse_constant=_reject_constant
|
|
176
|
+
)
|
|
177
|
+
except (ValueError, TypeError, KeyError, RecursionError):
|
|
178
|
+
raise EvidenceError("aws_evidence_invalid_payload") from None
|
|
179
|
+
if (
|
|
180
|
+
not isinstance(evidence, dict)
|
|
181
|
+
or evidence.get("schema_version") != 1
|
|
182
|
+
or evidence.get("provider") != "aws_metrics"
|
|
183
|
+
):
|
|
184
|
+
raise EvidenceError("aws_evidence_invalid_payload")
|
|
185
|
+
if evidence.get("snapshot_id") != reference["snapshot_id"]:
|
|
186
|
+
raise EvidenceError("aws_evidence_identity_mismatch")
|
|
187
|
+
origin = evidence.get("origin")
|
|
188
|
+
if not isinstance(origin, dict) or any(
|
|
189
|
+
origin.get(key) != reference[field]
|
|
190
|
+
for key, field in (
|
|
191
|
+
("issuer", "issuer"),
|
|
192
|
+
("workspace_id", "workspace_id"),
|
|
193
|
+
("project_id", "project_id"),
|
|
194
|
+
("created_by_user_id", "user_id"),
|
|
195
|
+
)
|
|
196
|
+
):
|
|
197
|
+
raise EvidenceError("aws_evidence_identity_mismatch")
|
|
198
|
+
sources, resources, metrics = (
|
|
199
|
+
evidence.get("sources"),
|
|
200
|
+
evidence.get("resources"),
|
|
201
|
+
evidence.get("metrics"),
|
|
202
|
+
)
|
|
203
|
+
if not isinstance(sources, list) or not 1 <= len(sources) <= 8:
|
|
204
|
+
raise EvidenceError("aws_evidence_invalid_sources")
|
|
205
|
+
source_ids = set()
|
|
206
|
+
for source in sources:
|
|
207
|
+
if (
|
|
208
|
+
not isinstance(source, dict)
|
|
209
|
+
or not isinstance(source.get("source_id"), str)
|
|
210
|
+
or source["source_id"] in source_ids
|
|
211
|
+
or not re.fullmatch(r"\d{12}", str(source.get("account_id", "")))
|
|
212
|
+
or source.get("partition") not in ("aws", "aws-cn", "aws-us-gov")
|
|
213
|
+
or not isinstance(source.get("region"), str)
|
|
214
|
+
or not isinstance(source.get("aws_profile_id"), str)
|
|
215
|
+
or type(source.get("credential_version")) is not int
|
|
216
|
+
or source["credential_version"] < 1
|
|
217
|
+
):
|
|
218
|
+
raise EvidenceError("aws_evidence_invalid_sources")
|
|
219
|
+
source_ids.add(source["source_id"])
|
|
220
|
+
if (
|
|
221
|
+
not isinstance(resources, list)
|
|
222
|
+
or len(resources) > 20
|
|
223
|
+
or not isinstance(metrics, list)
|
|
224
|
+
or len(metrics) > 100
|
|
225
|
+
):
|
|
226
|
+
raise EvidenceError("aws_evidence_size_limit")
|
|
227
|
+
resource_sources = {}
|
|
228
|
+
for resource in resources:
|
|
229
|
+
if (
|
|
230
|
+
not isinstance(resource, dict)
|
|
231
|
+
or not isinstance(resource.get("resource_ref"), str)
|
|
232
|
+
or not isinstance(resource.get("source_id"), str)
|
|
233
|
+
or resource["resource_ref"] in resource_sources
|
|
234
|
+
or resource.get("source_id") not in source_ids
|
|
235
|
+
):
|
|
236
|
+
raise EvidenceError("aws_evidence_invalid_resources")
|
|
237
|
+
resource_sources[resource["resource_ref"]] = resource["source_id"]
|
|
238
|
+
metric_ids = set()
|
|
239
|
+
for metric in metrics:
|
|
240
|
+
if (
|
|
241
|
+
not isinstance(metric, dict)
|
|
242
|
+
or not isinstance(metric.get("evidence_ref"), str)
|
|
243
|
+
or not isinstance(metric.get("source_id"), str)
|
|
244
|
+
or not isinstance(metric.get("resource_ref"), str)
|
|
245
|
+
or metric["evidence_ref"] in metric_ids
|
|
246
|
+
or metric.get("source_id") not in source_ids
|
|
247
|
+
or resource_sources.get(metric.get("resource_ref")) != metric["source_id"]
|
|
248
|
+
or not isinstance(metric.get("data_status"), str)
|
|
249
|
+
):
|
|
250
|
+
raise EvidenceError("aws_evidence_invalid_metrics")
|
|
251
|
+
metric_ids.add(metric["evidence_ref"])
|
|
252
|
+
selection = evidence.get("selection")
|
|
253
|
+
if not isinstance(selection, dict) or selection.get("scope_mode") not in ("common", "market"):
|
|
254
|
+
raise EvidenceError("aws_evidence_invalid_selection")
|
|
255
|
+
if selection["scope_mode"] == "common" and "country_codes" in selection:
|
|
256
|
+
raise EvidenceError("aws_evidence_invalid_selection")
|
|
257
|
+
summary = {
|
|
258
|
+
**evidence,
|
|
259
|
+
"metrics": [
|
|
260
|
+
{key: value for key, value in metric.items() if key != "datapoints"}
|
|
261
|
+
for metric in metrics
|
|
262
|
+
],
|
|
263
|
+
}
|
|
264
|
+
try:
|
|
265
|
+
summary_json = canonical_json(summary)
|
|
266
|
+
except (ValueError, TypeError):
|
|
267
|
+
raise EvidenceError("aws_evidence_invalid_payload") from None
|
|
268
|
+
if len(summary_json.encode("utf-8")) > SUMMARY_MAX_BYTES:
|
|
269
|
+
raise EvidenceError("aws_evidence_size_limit")
|
|
270
|
+
if digest(summary) != reference["summary_sha256"]:
|
|
271
|
+
raise EvidenceError("aws_evidence_integrity_failed")
|
|
272
|
+
return {
|
|
273
|
+
"reference": reference,
|
|
274
|
+
"summary": summary,
|
|
275
|
+
"summary_json": summary_json,
|
|
276
|
+
"data_policy": dict(policy),
|
|
277
|
+
"expires_at": metadata["expires_at"],
|
|
278
|
+
"model_provider": model_provider,
|
|
279
|
+
}
|
|
@@ -114,7 +114,6 @@ _redirect_prompt_through_task_file = agent_core._redirect_prompt_through_task_fi
|
|
|
114
114
|
_agent_process_group_kwargs = agent_core._agent_process_group_kwargs
|
|
115
115
|
_parse_semver = agent_core._parse_semver
|
|
116
116
|
_check_agent_min_version = agent_core._check_agent_min_version
|
|
117
|
-
_kill_proc = agent_core._kill_proc
|
|
118
117
|
_extract_output_signals = agent_core._extract_output_signals
|
|
119
118
|
_should_scan_short_success_stdout = agent_core._should_scan_short_success_stdout
|
|
120
119
|
_copilot_completed_without_result = agent_core._copilot_completed_without_result
|
|
@@ -126,6 +125,23 @@ _parse_kimi_wire_metrics = agent_core._parse_kimi_wire_metrics
|
|
|
126
125
|
_parse_copilot_output = agent_core._parse_copilot_output
|
|
127
126
|
_extract_copilot_exit_code = agent_core._extract_copilot_exit_code
|
|
128
127
|
|
|
128
|
+
# Only the fixed, admitted entry can select managed mode, never an env flag.
|
|
129
|
+
_wsl_policy_module = sys.modules.get("app.wsl_runtime_policy") or sys.modules.get("wsl_runtime_policy")
|
|
130
|
+
_MANAGED_WSL_POLICY = getattr(_wsl_policy_module, "ACTIVE_POLICY", None)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _create_subprocess_exec(*command, **kwargs):
|
|
134
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
135
|
+
return await _MANAGED_WSL_POLICY.spawn(*command, **kwargs)
|
|
136
|
+
return await asyncio.create_subprocess_exec(*command, **kwargs)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _kill_proc(proc) -> None:
|
|
140
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
141
|
+
_MANAGED_WSL_POLICY.cancel_process(proc)
|
|
142
|
+
else:
|
|
143
|
+
agent_core._kill_proc(proc)
|
|
144
|
+
|
|
129
145
|
# ── httpx dependency — robust auto-install for standalone environments ──
|
|
130
146
|
# When running inside the backend package, httpx is a declared dependency and
|
|
131
147
|
# always available. In standalone contexts (desktop AppImage/DMG/MSI, CLI
|
|
@@ -136,10 +152,13 @@ _extract_copilot_exit_code = agent_core._extract_copilot_exit_code
|
|
|
136
152
|
# 3. Auto-install via pip --target to the cached deps dir
|
|
137
153
|
# (bypasses PEP 668 / externally-managed-environment on modern distros)
|
|
138
154
|
# 4. Friendly error with OS-specific instructions if all else fails
|
|
139
|
-
_HTTPX_DEPS_DIR =
|
|
155
|
+
_HTTPX_DEPS_DIR = (str(_MANAGED_WSL_POLICY.state / "deps") if _MANAGED_WSL_POLICY is not None
|
|
156
|
+
else os.path.join(str(Path.home()), ".forgexa", "daemon", "deps"))
|
|
140
157
|
|
|
141
158
|
|
|
142
159
|
def _cli_config_path() -> Path:
|
|
160
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
161
|
+
return _MANAGED_WSL_POLICY.state / "config.json"
|
|
143
162
|
return Path.home() / ".forgexa" / "config"
|
|
144
163
|
|
|
145
164
|
|
|
@@ -230,10 +249,14 @@ def _local_runtime_registration_path() -> Path:
|
|
|
230
249
|
nor workspace paths, so desktop clients can safely use it to avoid
|
|
231
250
|
binding a local folder to a runtime on another machine.
|
|
232
251
|
"""
|
|
252
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
253
|
+
return _MANAGED_WSL_POLICY.state / "runtime-registrations.json"
|
|
233
254
|
return Path.home() / ".forgexa" / "daemon" / "runtime-registrations.json"
|
|
234
255
|
|
|
235
256
|
|
|
236
257
|
def _desktop_daemon_restart_request_path(instance_id: str) -> Path:
|
|
258
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
259
|
+
return _MANAGED_WSL_POLICY.state / "restart-requests" / f"{instance_id}.json"
|
|
237
260
|
return (
|
|
238
261
|
Path.home()
|
|
239
262
|
/ ".forgexa"
|
|
@@ -324,6 +347,8 @@ def _clear_local_runtime_registration_snapshot(instance_id: str) -> None:
|
|
|
324
347
|
|
|
325
348
|
|
|
326
349
|
def _cli_credentials_path() -> Path:
|
|
350
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
351
|
+
return _MANAGED_WSL_POLICY.state / "credentials.json"
|
|
327
352
|
return Path.home() / ".forgexa" / "credentials.json"
|
|
328
353
|
|
|
329
354
|
|
|
@@ -374,6 +399,11 @@ def _migrate_legacy_cli_credentials(*, write_lock_held: bool = False) -> dict:
|
|
|
374
399
|
|
|
375
400
|
def _load_cli_credentials(*, write_lock_held: bool = False) -> dict:
|
|
376
401
|
"""Load issuer-scoped credentials, migrating legacy config on first use."""
|
|
402
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
403
|
+
return {_normalize_issuer_url(_MANAGED_WSL_POLICY.config.serverUrl): {
|
|
404
|
+
"runtime_credential": _MANAGED_WSL_POLICY.credential,
|
|
405
|
+
"runtime_id": _MANAGED_WSL_POLICY.runtime_id,
|
|
406
|
+
}}
|
|
377
407
|
path = _cli_credentials_path()
|
|
378
408
|
if path.exists():
|
|
379
409
|
try:
|
|
@@ -920,6 +950,9 @@ def _validate_httpx_imports() -> tuple[bool, str]:
|
|
|
920
950
|
# Actual import sequence
|
|
921
951
|
_httpx_ok, _httpx_missing = _validate_httpx_imports()
|
|
922
952
|
|
|
953
|
+
if not _httpx_ok and _MANAGED_WSL_POLICY is not None:
|
|
954
|
+
raise RuntimeError("Managed WSL runtime dependency closure is incomplete")
|
|
955
|
+
|
|
923
956
|
if not _httpx_ok:
|
|
924
957
|
# Check cached deps directory (previous auto-install)
|
|
925
958
|
if _HTTPX_DEPS_DIR not in sys.path:
|
|
@@ -997,6 +1030,8 @@ def _make_httpx_ssl_context():
|
|
|
997
1030
|
|
|
998
1031
|
# ── Settings: graceful fallback when running standalone (outside backend package) ──
|
|
999
1032
|
try:
|
|
1033
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
1034
|
+
raise ModuleNotFoundError("Managed WSL uses admitted standalone settings")
|
|
1000
1035
|
from app.config import settings
|
|
1001
1036
|
except (ImportError, ModuleNotFoundError):
|
|
1002
1037
|
# Running standalone (e.g., from desktop app bundle or forgexa-cli).
|
|
@@ -1196,7 +1231,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1196
1231
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1197
1232
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1198
1233
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1199
|
-
DAEMON_VERSION = "1.
|
|
1234
|
+
DAEMON_VERSION = "1.64.0"
|
|
1200
1235
|
|
|
1201
1236
|
|
|
1202
1237
|
def _detect_client_type() -> str:
|
|
@@ -1234,7 +1269,8 @@ _CLIENT_TYPE = _detect_client_type()
|
|
|
1234
1269
|
# so logs are visible on the terminal without duplication.
|
|
1235
1270
|
# Set DAEMON_NO_CONSOLE_LOG=1 to suppress the stream handler even on a TTY
|
|
1236
1271
|
# (used by `forgexa daemon start` foreground mode for clean UX).
|
|
1237
|
-
_log_dir =
|
|
1272
|
+
_log_dir = (_MANAGED_WSL_POLICY.state / "logs" if _MANAGED_WSL_POLICY is not None
|
|
1273
|
+
else Path.home() / ".forgexa" / "daemon")
|
|
1238
1274
|
_log_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
1239
1275
|
# Ensure directory has restricted permissions even if it already existed
|
|
1240
1276
|
_log_dir.chmod(0o700)
|
|
@@ -1512,6 +1548,8 @@ def _agent_dicts_for_wire(agents: "list[DiscoveredAgent]") -> list[dict]:
|
|
|
1512
1548
|
|
|
1513
1549
|
def _resolve_git_command() -> str | None:
|
|
1514
1550
|
"""Return Git's executable, including common Windows installer locations."""
|
|
1551
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
1552
|
+
return _MANAGED_WSL_POLICY.executable("git")
|
|
1515
1553
|
resolved = shutil.which("git")
|
|
1516
1554
|
if resolved or sys.platform != "win32":
|
|
1517
1555
|
return resolved
|
|
@@ -2478,6 +2516,8 @@ def _local_workspaces_mapping_path() -> Path:
|
|
|
2478
2516
|
|
|
2479
2517
|
|
|
2480
2518
|
def _load_local_workspaces_mapping() -> dict:
|
|
2519
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2520
|
+
return _MANAGED_WSL_POLICY.mapping()
|
|
2481
2521
|
path = _local_workspaces_mapping_path()
|
|
2482
2522
|
if not path.exists():
|
|
2483
2523
|
return {}
|
|
@@ -2519,6 +2559,8 @@ _LOCAL_BIND_SHARED_OUTPUT_MAX_LINES = 5_000
|
|
|
2519
2559
|
|
|
2520
2560
|
|
|
2521
2561
|
def _local_bind_output_log_path(task_id: str) -> Path:
|
|
2562
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2563
|
+
return _MANAGED_WSL_POLICY.output_path(task_id)
|
|
2522
2564
|
return Path.home() / ".forgexa" / "local-bind-output" / f"{task_id}.log"
|
|
2523
2565
|
|
|
2524
2566
|
|
|
@@ -2530,6 +2572,9 @@ def _append_local_bind_output_log(task_id: str, lines: list[str]) -> None:
|
|
|
2530
2572
|
"""
|
|
2531
2573
|
if not lines:
|
|
2532
2574
|
return
|
|
2575
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2576
|
+
_MANAGED_WSL_POLICY.append_output(task_id, lines)
|
|
2577
|
+
return
|
|
2533
2578
|
try:
|
|
2534
2579
|
path = _local_bind_output_log_path(task_id)
|
|
2535
2580
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -2544,6 +2589,8 @@ def _append_local_bind_output_log(task_id: str, lines: list[str]) -> None:
|
|
|
2544
2589
|
|
|
2545
2590
|
def _local_bind_output_sharing_enabled(binding_id: str | None) -> bool:
|
|
2546
2591
|
"""Return the private binding's automatic transcript-sharing preference."""
|
|
2592
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2593
|
+
return False
|
|
2547
2594
|
if not binding_id:
|
|
2548
2595
|
return False
|
|
2549
2596
|
entry = _load_local_workspaces_mapping().get(binding_id)
|
|
@@ -2588,6 +2635,8 @@ def _read_local_bind_shared_output_snapshot(task_id: str) -> dict | None:
|
|
|
2588
2635
|
|
|
2589
2636
|
def _prepare_local_bind_completion(task: "TaskInfo", result: "TaskResult") -> None:
|
|
2590
2637
|
"""Attach the automatic private snapshot before stripping normal local output."""
|
|
2638
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2639
|
+
result.shared_local_output = None
|
|
2591
2640
|
if _local_bind_output_sharing_enabled(task.local_workspace_binding_id):
|
|
2592
2641
|
result.shared_local_output = _read_local_bind_shared_output_snapshot(task.task_id)
|
|
2593
2642
|
_filter_local_bind_task_result(result)
|
|
@@ -2719,6 +2768,11 @@ def _normalize_repo_identity(repo_url: str) -> str | None:
|
|
|
2719
2768
|
|
|
2720
2769
|
|
|
2721
2770
|
async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id: str) -> str | None:
|
|
2771
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
2772
|
+
_MANAGED_WSL_POLICY.check_mapping()
|
|
2773
|
+
expected = _MANAGED_WSL_POLICY.config.binding
|
|
2774
|
+
if runtime_id != _MANAGED_WSL_POLICY.runtime_id or not expected or any(binding.get(key) != value for key, value in expected.items()):
|
|
2775
|
+
return "mapping_mismatch"
|
|
2722
2776
|
binding_id = str(binding.get("id") or "")
|
|
2723
2777
|
entry = _load_local_workspaces_mapping().get(binding_id)
|
|
2724
2778
|
if not binding_id or not isinstance(entry, dict):
|
|
@@ -2746,8 +2800,8 @@ async def _validate_local_workspace_binding(binding: dict[str, Any], runtime_id:
|
|
|
2746
2800
|
|
|
2747
2801
|
proc = None
|
|
2748
2802
|
try:
|
|
2749
|
-
proc = await
|
|
2750
|
-
"git", "remote", "get-url", "origin",
|
|
2803
|
+
proc = await _create_subprocess_exec(
|
|
2804
|
+
_resolve_git_command() if _MANAGED_WSL_POLICY is not None else "git", "remote", "get-url", "origin",
|
|
2751
2805
|
cwd=str(local_path),
|
|
2752
2806
|
stdout=asyncio.subprocess.PIPE,
|
|
2753
2807
|
stderr=asyncio.subprocess.DEVNULL,
|
|
@@ -2826,6 +2880,8 @@ def _try_acquire_local_workspace_file_lock(binding_id: str, workspace_path: Path
|
|
|
2826
2880
|
|
|
2827
2881
|
|
|
2828
2882
|
def _release_local_workspace_file_lock(lock_file) -> None:
|
|
2883
|
+
if _MANAGED_WSL_POLICY is not None and lock_file is _MANAGED_WSL_POLICY.task_lock:
|
|
2884
|
+
return # Only verified empty-tree cleanup releases the inherited locks.
|
|
2829
2885
|
agent_core.release_file_lock(lock_file)
|
|
2830
2886
|
|
|
2831
2887
|
|
|
@@ -3573,6 +3629,9 @@ class WorkspaceManager:
|
|
|
3573
3629
|
fails closed with a "local_binding_unavailable:" prefixed error. See
|
|
3574
3630
|
docs/designs/forgexa-ide-design.md §6.2.
|
|
3575
3631
|
"""
|
|
3632
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
3633
|
+
_MANAGED_WSL_POLICY.validate_task(task, runtime_id)
|
|
3634
|
+
_MANAGED_WSL_POLICY.check_mapping()
|
|
3576
3635
|
binding_id = task.local_workspace_binding_id
|
|
3577
3636
|
if not binding_id:
|
|
3578
3637
|
raise RuntimeError("local_binding_unavailable: task has no local_workspace_binding_id")
|
|
@@ -4069,7 +4128,7 @@ class WorkspaceManager:
|
|
|
4069
4128
|
# Warn if the clone has uncommitted changes so the operator knows
|
|
4070
4129
|
# what will be lost when it is removed (L3 fix).
|
|
4071
4130
|
try:
|
|
4072
|
-
st_proc = await
|
|
4131
|
+
st_proc = await _create_subprocess_exec(
|
|
4073
4132
|
"git", "status", "--porcelain",
|
|
4074
4133
|
cwd=str(ws_path),
|
|
4075
4134
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -4242,7 +4301,7 @@ class WorkspaceManager:
|
|
|
4242
4301
|
"""
|
|
4243
4302
|
try:
|
|
4244
4303
|
# Get the local HEAD SHA
|
|
4245
|
-
local_proc = await
|
|
4304
|
+
local_proc = await _create_subprocess_exec(
|
|
4246
4305
|
"git", "rev-parse", "HEAD",
|
|
4247
4306
|
cwd=str(repo_path),
|
|
4248
4307
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -4271,7 +4330,7 @@ class WorkspaceManager:
|
|
|
4271
4330
|
# If HEAD is ahead of the merge-base, we count local-only commits — any
|
|
4272
4331
|
# count > 0 means we should NOT call the branch-r check.
|
|
4273
4332
|
try:
|
|
4274
|
-
ahead_proc = await
|
|
4333
|
+
ahead_proc = await _create_subprocess_exec(
|
|
4275
4334
|
"git", "rev-list", "--count", "--right-only", "origin...HEAD",
|
|
4276
4335
|
cwd=str(repo_path),
|
|
4277
4336
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -4293,7 +4352,7 @@ class WorkspaceManager:
|
|
|
4293
4352
|
except Exception:
|
|
4294
4353
|
pass # If the ahead count fails, proceed with the branch-r check
|
|
4295
4354
|
|
|
4296
|
-
check_proc = await
|
|
4355
|
+
check_proc = await _create_subprocess_exec(
|
|
4297
4356
|
"git", "branch", "-r", "--contains", local_head,
|
|
4298
4357
|
cwd=str(repo_path),
|
|
4299
4358
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -5184,7 +5243,7 @@ class WorkspaceManager:
|
|
|
5184
5243
|
last_branch_fetch_error: str | None = None
|
|
5185
5244
|
for _check_attempt in range(3 if not fresh_start else 1):
|
|
5186
5245
|
try:
|
|
5187
|
-
check_proc = await
|
|
5246
|
+
check_proc = await _create_subprocess_exec(
|
|
5188
5247
|
"git", "rev-parse", "--verify", f"refs/remotes/origin/{branch_name}",
|
|
5189
5248
|
cwd=str(main_repo),
|
|
5190
5249
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -5684,7 +5743,7 @@ class WorkspaceManager:
|
|
|
5684
5743
|
# orphaned processes from keeping pipes alive.
|
|
5685
5744
|
# Windows note: start_new_session creates a new console process group;
|
|
5686
5745
|
# we use taskkill /T there instead of killpg.
|
|
5687
|
-
proc = await
|
|
5746
|
+
proc = await _create_subprocess_exec(
|
|
5688
5747
|
git_command, *longpath_args, *git_prefix_args, *args,
|
|
5689
5748
|
stdin=asyncio.subprocess.DEVNULL,
|
|
5690
5749
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -5709,7 +5768,9 @@ class WorkspaceManager:
|
|
|
5709
5768
|
# ssh grandchild becomes orphaned, keeps the stderr pipe open,
|
|
5710
5769
|
# and proc.communicate() can never return EOF.
|
|
5711
5770
|
try:
|
|
5712
|
-
if
|
|
5771
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
5772
|
+
_kill_proc(proc)
|
|
5773
|
+
elif sys.platform != "win32":
|
|
5713
5774
|
import signal as _signal
|
|
5714
5775
|
try:
|
|
5715
5776
|
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
|
@@ -6752,7 +6813,7 @@ class ProcessManager:
|
|
|
6752
6813
|
env.update(self._prepare_claude_environment(sandbox_root=Path(_claude_home)))
|
|
6753
6814
|
|
|
6754
6815
|
try:
|
|
6755
|
-
proc = await
|
|
6816
|
+
proc = await _create_subprocess_exec(
|
|
6756
6817
|
*cmd,
|
|
6757
6818
|
stdout=asyncio.subprocess.PIPE,
|
|
6758
6819
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -7828,7 +7889,7 @@ class ProcessManager:
|
|
|
7828
7889
|
pass
|
|
7829
7890
|
|
|
7830
7891
|
try:
|
|
7831
|
-
proc = await
|
|
7892
|
+
proc = await _create_subprocess_exec(
|
|
7832
7893
|
*cmd,
|
|
7833
7894
|
stdout=asyncio.subprocess.PIPE,
|
|
7834
7895
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8221,7 +8282,7 @@ class ProcessManager:
|
|
|
8221
8282
|
proc = None
|
|
8222
8283
|
try:
|
|
8223
8284
|
cmd = _prepare_agent_command(cmd)
|
|
8224
|
-
proc = await
|
|
8285
|
+
proc = await _create_subprocess_exec(
|
|
8225
8286
|
*cmd,
|
|
8226
8287
|
stdout=asyncio.subprocess.PIPE,
|
|
8227
8288
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8313,7 +8374,7 @@ class ProcessManager:
|
|
|
8313
8374
|
"""Collect git diff stats from workspace."""
|
|
8314
8375
|
info: dict[str, Any] = {}
|
|
8315
8376
|
try:
|
|
8316
|
-
proc = await
|
|
8377
|
+
proc = await _create_subprocess_exec(
|
|
8317
8378
|
"git", "diff", "--stat", "--cached",
|
|
8318
8379
|
stdout=asyncio.subprocess.PIPE,
|
|
8319
8380
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8324,7 +8385,7 @@ class ProcessManager:
|
|
|
8324
8385
|
stat_output = stdout.decode()
|
|
8325
8386
|
|
|
8326
8387
|
# Also check unstaged
|
|
8327
|
-
proc2 = await
|
|
8388
|
+
proc2 = await _create_subprocess_exec(
|
|
8328
8389
|
"git", "diff", "--stat",
|
|
8329
8390
|
stdout=asyncio.subprocess.PIPE,
|
|
8330
8391
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8335,7 +8396,7 @@ class ProcessManager:
|
|
|
8335
8396
|
stat_output += stdout2.decode()
|
|
8336
8397
|
|
|
8337
8398
|
# Parse changed files
|
|
8338
|
-
proc3 = await
|
|
8399
|
+
proc3 = await _create_subprocess_exec(
|
|
8339
8400
|
"git", "diff", "--name-only", "HEAD",
|
|
8340
8401
|
stdout=asyncio.subprocess.PIPE,
|
|
8341
8402
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8347,7 +8408,7 @@ class ProcessManager:
|
|
|
8347
8408
|
info["files_changed"] = files
|
|
8348
8409
|
|
|
8349
8410
|
# Parse insertions/deletions
|
|
8350
|
-
proc4 = await
|
|
8411
|
+
proc4 = await _create_subprocess_exec(
|
|
8351
8412
|
"git", "diff", "--shortstat", "HEAD",
|
|
8352
8413
|
stdout=asyncio.subprocess.PIPE,
|
|
8353
8414
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8362,7 +8423,7 @@ class ProcessManager:
|
|
|
8362
8423
|
info["lines_removed"] = int(removed.group(1)) if removed else 0
|
|
8363
8424
|
|
|
8364
8425
|
# Get current branch
|
|
8365
|
-
proc5 = await
|
|
8426
|
+
proc5 = await _create_subprocess_exec(
|
|
8366
8427
|
"git", "rev-parse", "--abbrev-ref", "HEAD",
|
|
8367
8428
|
stdout=asyncio.subprocess.PIPE,
|
|
8368
8429
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8373,7 +8434,7 @@ class ProcessManager:
|
|
|
8373
8434
|
info["branch"] = stdout5.decode().strip()
|
|
8374
8435
|
|
|
8375
8436
|
# Get latest commit
|
|
8376
|
-
proc6 = await
|
|
8437
|
+
proc6 = await _create_subprocess_exec(
|
|
8377
8438
|
"git", "rev-parse", "HEAD",
|
|
8378
8439
|
stdout=asyncio.subprocess.PIPE,
|
|
8379
8440
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8419,7 +8480,7 @@ class ProcessManager:
|
|
|
8419
8480
|
candidate_refs.append(ref)
|
|
8420
8481
|
|
|
8421
8482
|
for candidate in candidate_refs:
|
|
8422
|
-
proc_mb = await
|
|
8483
|
+
proc_mb = await _create_subprocess_exec(
|
|
8423
8484
|
"git", "merge-base", candidate, "HEAD",
|
|
8424
8485
|
stdout=asyncio.subprocess.PIPE,
|
|
8425
8486
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8432,7 +8493,7 @@ class ProcessManager:
|
|
|
8432
8493
|
break
|
|
8433
8494
|
|
|
8434
8495
|
# Changed files since branch point
|
|
8435
|
-
proc = await
|
|
8496
|
+
proc = await _create_subprocess_exec(
|
|
8436
8497
|
"git", "diff", "--name-only", base_ref, "HEAD",
|
|
8437
8498
|
stdout=asyncio.subprocess.PIPE,
|
|
8438
8499
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8444,7 +8505,7 @@ class ProcessManager:
|
|
|
8444
8505
|
info["files_changed"] = files
|
|
8445
8506
|
|
|
8446
8507
|
# Lines added/removed since branch point
|
|
8447
|
-
proc2 = await
|
|
8508
|
+
proc2 = await _create_subprocess_exec(
|
|
8448
8509
|
"git", "diff", "--shortstat", base_ref, "HEAD",
|
|
8449
8510
|
stdout=asyncio.subprocess.PIPE,
|
|
8450
8511
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8459,7 +8520,7 @@ class ProcessManager:
|
|
|
8459
8520
|
info["lines_removed"] = int(removed.group(1)) if removed else 0
|
|
8460
8521
|
|
|
8461
8522
|
# Branch name
|
|
8462
|
-
proc3 = await
|
|
8523
|
+
proc3 = await _create_subprocess_exec(
|
|
8463
8524
|
"git", "rev-parse", "--abbrev-ref", "HEAD",
|
|
8464
8525
|
stdout=asyncio.subprocess.PIPE,
|
|
8465
8526
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8470,7 +8531,7 @@ class ProcessManager:
|
|
|
8470
8531
|
info["branch"] = stdout3.decode().strip()
|
|
8471
8532
|
|
|
8472
8533
|
# Commit SHA
|
|
8473
|
-
proc4 = await
|
|
8534
|
+
proc4 = await _create_subprocess_exec(
|
|
8474
8535
|
"git", "rev-parse", "HEAD",
|
|
8475
8536
|
stdout=asyncio.subprocess.PIPE,
|
|
8476
8537
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -8787,7 +8848,7 @@ class RuntimePreflightMonitor:
|
|
|
8787
8848
|
process group SIGKILL on POSIX) and waits for reaping before returning.
|
|
8788
8849
|
"""
|
|
8789
8850
|
try:
|
|
8790
|
-
proc = await
|
|
8851
|
+
proc = await _create_subprocess_exec(
|
|
8791
8852
|
*cmd,
|
|
8792
8853
|
stdin=asyncio.subprocess.DEVNULL,
|
|
8793
8854
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -8812,6 +8873,9 @@ class RuntimePreflightMonitor:
|
|
|
8812
8873
|
@staticmethod
|
|
8813
8874
|
def _kill_process_tree(proc):
|
|
8814
8875
|
"""Best-effort kill of the entire process subtree."""
|
|
8876
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
8877
|
+
_kill_proc(proc)
|
|
8878
|
+
return
|
|
8815
8879
|
try:
|
|
8816
8880
|
if sys.platform == "win32":
|
|
8817
8881
|
# Must be a BLOCKING call here: this is a plain (non-async)
|
|
@@ -9014,6 +9078,8 @@ class HeartbeatService:
|
|
|
9014
9078
|
self._agents = [dict(agent) for agent in agents]
|
|
9015
9079
|
|
|
9016
9080
|
async def _sync_local_workspace_bindings(self) -> None:
|
|
9081
|
+
if _MANAGED_WSL_POLICY is not None and _MANAGED_WSL_POLICY.task is not None:
|
|
9082
|
+
return # The task already retains mapping admission and the physical lock.
|
|
9017
9083
|
try:
|
|
9018
9084
|
response = await self.client.get(
|
|
9019
9085
|
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/local-workspace-bindings/verification",
|
|
@@ -9033,6 +9099,9 @@ class HeartbeatService:
|
|
|
9033
9099
|
for binding in bindings:
|
|
9034
9100
|
if not isinstance(binding, dict) or not binding.get("id"):
|
|
9035
9101
|
continue
|
|
9102
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
9103
|
+
await _MANAGED_WSL_POLICY.sync_binding(self, binding)
|
|
9104
|
+
continue
|
|
9036
9105
|
failure_code = await _validate_local_workspace_binding(binding, self.runtime_id)
|
|
9037
9106
|
if failure_code is None and binding.get("status") == "active":
|
|
9038
9107
|
continue
|
|
@@ -9159,6 +9228,8 @@ class LogUploader:
|
|
|
9159
9228
|
|
|
9160
9229
|
async def _upload(self):
|
|
9161
9230
|
"""Read daemon log tail and upload to server."""
|
|
9231
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
9232
|
+
return # Private WSL logs are never uploaded, even when no task is active.
|
|
9162
9233
|
try:
|
|
9163
9234
|
if not DAEMON_LOG_PATH.exists():
|
|
9164
9235
|
return
|
|
@@ -9352,6 +9423,11 @@ class ServerConnection:
|
|
|
9352
9423
|
# honored here, which otherwise turns loopback calls into 502s.
|
|
9353
9424
|
trust_env=False,
|
|
9354
9425
|
)
|
|
9426
|
+
if _MANAGED_WSL_POLICY is not None:
|
|
9427
|
+
if (server_url != _MANAGED_WSL_POLICY.config.serverUrl or api_token != _MANAGED_WSL_POLICY.credential
|
|
9428
|
+
or daemon_id != _MANAGED_WSL_POLICY.daemon_id or hardware_id != _MANAGED_WSL_POLICY.hardware_id):
|
|
9429
|
+
raise ValueError("wsl_runtime_connection_invalid")
|
|
9430
|
+
self.client = _wsl_policy_module.PrivateClient(self.client, _MANAGED_WSL_POLICY)
|
|
9355
9431
|
self.heartbeat: HeartbeatService | None = None
|
|
9356
9432
|
self.poller: TaskPoller | None = None
|
|
9357
9433
|
self.reporter: ProgressReporter | None = None
|
|
@@ -9909,6 +9985,8 @@ class ServerConnection:
|
|
|
9909
9985
|
raise DaemonCredentialUpgradeRequiredError(code)
|
|
9910
9986
|
resp.raise_for_status()
|
|
9911
9987
|
data = resp.json()
|
|
9988
|
+
if _MANAGED_WSL_POLICY is not None and data.get("runtime_id") != _MANAGED_WSL_POLICY.runtime_id:
|
|
9989
|
+
raise DaemonCredentialUpgradeRequiredError("runtime_credential_runtime_mismatch")
|
|
9912
9990
|
self.runtime_id = data["runtime_id"]
|
|
9913
9991
|
logger.info("[%s] Registered with server. runtime_id=%s", self.label, self.runtime_id)
|
|
9914
9992
|
self._publish_local_runtime_registration()
|
|
@@ -10607,23 +10685,26 @@ class RuntimeDaemon:
|
|
|
10607
10685
|
_TASK_ATTACHMENT_MAX_COUNT = 20
|
|
10608
10686
|
_TASK_ATTACHMENT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
|
|
10609
10687
|
|
|
10610
|
-
def __init__(self):
|
|
10688
|
+
def __init__(self, *, managed_policy=None):
|
|
10689
|
+
if managed_policy is not _MANAGED_WSL_POLICY:
|
|
10690
|
+
raise ValueError("wsl_runtime_entry_not_admitted")
|
|
10691
|
+
self.managed_policy = managed_policy
|
|
10611
10692
|
# Compute stable hardware fingerprint from machine-id / MAC address.
|
|
10612
10693
|
# This allows the server to identify the same physical machine even when
|
|
10613
10694
|
# its IP address changes (e.g., DHCP reassignment, home vs. office).
|
|
10614
|
-
self.hardware_id = get_hardware_id()
|
|
10695
|
+
self.hardware_id = managed_policy.hardware_id if managed_policy is not None else get_hardware_id()
|
|
10615
10696
|
|
|
10616
10697
|
# daemon_id: prefer explicit config, fall back to hardware_id (stable),
|
|
10617
10698
|
# then hostname. Using hardware_id as default instead of platform.node()
|
|
10618
10699
|
# prevents duplicate registrations when the hostname changes.
|
|
10619
|
-
self.daemon_id = settings.DAEMON_ID or self.hardware_id or platform.node()
|
|
10620
|
-
self.server_urls = settings.get_daemon_server_urls()
|
|
10621
|
-
self.api_token = _configured_daemon_api_token()
|
|
10700
|
+
self.daemon_id = managed_policy.daemon_id if managed_policy is not None else settings.DAEMON_ID or self.hardware_id or platform.node()
|
|
10701
|
+
self.server_urls = [managed_policy.config.serverUrl] if managed_policy is not None else settings.get_daemon_server_urls()
|
|
10702
|
+
self.api_token = managed_policy.credential if managed_policy is not None else _configured_daemon_api_token()
|
|
10622
10703
|
self._has_explicit_api_token = bool(self.api_token)
|
|
10623
|
-
self.max_concurrent = settings.DAEMON_MAX_CONCURRENT
|
|
10704
|
+
self.max_concurrent = 1 if managed_policy is not None else settings.DAEMON_MAX_CONCURRENT
|
|
10624
10705
|
self.poll_interval = settings.DAEMON_POLL_INTERVAL
|
|
10625
10706
|
self.heartbeat_interval = settings.DAEMON_HEARTBEAT_INTERVAL
|
|
10626
|
-
self.workspaces_root = settings.get_daemon_workspaces_root()
|
|
10707
|
+
self.workspaces_root = str(managed_policy.state / "workspaces") if managed_policy is not None else settings.get_daemon_workspaces_root()
|
|
10627
10708
|
|
|
10628
10709
|
self.agents: list[DiscoveredAgent] = []
|
|
10629
10710
|
self.active_tasks: dict[str, asyncio.Task] = {}
|
|
@@ -10676,12 +10757,16 @@ class RuntimeDaemon:
|
|
|
10676
10757
|
|
|
10677
10758
|
async def _refresh_agents(self) -> None:
|
|
10678
10759
|
"""Re-discover local CLIs so upgrades reach the runtime heartbeat."""
|
|
10760
|
+
if self.managed_policy is not None and self.active_tasks:
|
|
10761
|
+
return
|
|
10679
10762
|
try:
|
|
10680
10763
|
refreshed_agents = await self.agent_discovery.discover()
|
|
10681
10764
|
except Exception as exc:
|
|
10682
10765
|
logger.warning("Agent inventory refresh failed: %s", exc)
|
|
10683
10766
|
return
|
|
10684
10767
|
|
|
10768
|
+
if self.managed_policy is not None:
|
|
10769
|
+
refreshed_agents = [agent for agent in refreshed_agents if agent.agent_id in self.managed_policy.drivers]
|
|
10685
10770
|
if refreshed_agents == self.agents:
|
|
10686
10771
|
return
|
|
10687
10772
|
|
|
@@ -10725,6 +10810,8 @@ class RuntimeDaemon:
|
|
|
10725
10810
|
)
|
|
10726
10811
|
|
|
10727
10812
|
def _begin_desktop_restart_drain_if_requested(self) -> bool:
|
|
10813
|
+
if self.managed_policy is not None:
|
|
10814
|
+
return False
|
|
10728
10815
|
target_version = _desktop_daemon_restart_target_version(
|
|
10729
10816
|
self._local_runtime_instance_id
|
|
10730
10817
|
)
|
|
@@ -11492,13 +11579,14 @@ class RuntimeDaemon:
|
|
|
11492
11579
|
async def start(self):
|
|
11493
11580
|
"""Main entry point."""
|
|
11494
11581
|
# Prevent multiple daemon instances on the same machine
|
|
11495
|
-
self.
|
|
11582
|
+
if self.managed_policy is None:
|
|
11583
|
+
self._acquire_lock()
|
|
11496
11584
|
_write_local_runtime_registration_snapshot(
|
|
11497
11585
|
self._local_runtime_instance_id,
|
|
11498
11586
|
"starting",
|
|
11499
11587
|
{},
|
|
11500
11588
|
)
|
|
11501
|
-
cleaned_processes = self._cleanup_stale_agent_processes()
|
|
11589
|
+
cleaned_processes = self._cleanup_stale_agent_processes() if self.managed_policy is None else 0
|
|
11502
11590
|
if cleaned_processes:
|
|
11503
11591
|
logger.warning(
|
|
11504
11592
|
"Cleaned up %d orphaned Forgexa agent process group(s)",
|
|
@@ -11515,6 +11603,8 @@ class RuntimeDaemon:
|
|
|
11515
11603
|
|
|
11516
11604
|
# 1. Discover agents
|
|
11517
11605
|
self.agents = await self.agent_discovery.discover()
|
|
11606
|
+
if self.managed_policy is not None:
|
|
11607
|
+
self.agents = [agent for agent in self.agents if agent.agent_id in self.managed_policy.drivers]
|
|
11518
11608
|
if not self.agents:
|
|
11519
11609
|
logger.warning("No agent CLIs found. Daemon will run but cannot execute tasks.")
|
|
11520
11610
|
else:
|
|
@@ -11609,7 +11699,8 @@ class RuntimeDaemon:
|
|
|
11609
11699
|
|
|
11610
11700
|
# Clean up stale Copilot/Claude temp directories left by prior OOM-killed
|
|
11611
11701
|
# daemon processes. This runs once at startup so /tmp doesn't fill up.
|
|
11612
|
-
|
|
11702
|
+
if self.managed_policy is None:
|
|
11703
|
+
await self._cleanup_stale_tmp_dirs()
|
|
11613
11704
|
|
|
11614
11705
|
# 3. Main loop
|
|
11615
11706
|
try:
|
|
@@ -11657,9 +11748,10 @@ class RuntimeDaemon:
|
|
|
11657
11748
|
|
|
11658
11749
|
# Opportunistic GC: remove old deploy-merge worktrees (throttled internally).
|
|
11659
11750
|
try:
|
|
11660
|
-
|
|
11661
|
-
|
|
11662
|
-
|
|
11751
|
+
if self.managed_policy is None:
|
|
11752
|
+
await self._cleanup_old_merge_worktrees(
|
|
11753
|
+
self.connections[0] if self.connections else None
|
|
11754
|
+
)
|
|
11663
11755
|
except Exception:
|
|
11664
11756
|
pass # GC failure must never block the poll loop
|
|
11665
11757
|
|
|
@@ -11715,7 +11807,7 @@ class RuntimeDaemon:
|
|
|
11715
11807
|
)
|
|
11716
11808
|
# Always poll AI jobs so server-requested cancellations can stop a
|
|
11717
11809
|
# process that is itself consuming the last available slot.
|
|
11718
|
-
ai_jobs = await conn.poller.poll_ai_jobs() if accept_new_tasks else []
|
|
11810
|
+
ai_jobs = await conn.poller.poll_ai_jobs() if accept_new_tasks and self.managed_policy is None else []
|
|
11719
11811
|
_cancel_server_requested_execution_tasks(conn)
|
|
11720
11812
|
_cancel_server_requested_ai_jobs(conn)
|
|
11721
11813
|
total_active = len(self.active_tasks)
|
|
@@ -12059,8 +12151,23 @@ class RuntimeDaemon:
|
|
|
12059
12151
|
async def report_task_complete(result: TaskResult) -> None:
|
|
12060
12152
|
if is_local_bind:
|
|
12061
12153
|
_prepare_local_bind_completion(task, result)
|
|
12154
|
+
if self.managed_policy is not None and not await self.managed_policy.finish_task():
|
|
12155
|
+
result.status = "failed"
|
|
12156
|
+
result.failure_code = "wsl_runtime_recovery_required"
|
|
12157
|
+
result.error = "Managed local writer cleanup is unconfirmed"
|
|
12062
12158
|
await reporter.report_complete(task.task_id, result)
|
|
12063
12159
|
|
|
12160
|
+
if self.managed_policy is not None:
|
|
12161
|
+
try:
|
|
12162
|
+
await self.managed_policy.authorize_task(task, conn)
|
|
12163
|
+
except ValueError as exc:
|
|
12164
|
+
await report_task_complete(TaskResult(
|
|
12165
|
+
status="failed", exit_code=-1, stdout="", stderr="",
|
|
12166
|
+
error="Managed local dispatch was refused", failure_code=getattr(exc, "code", "wsl_runtime_dispatch_refused"),
|
|
12167
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
12168
|
+
))
|
|
12169
|
+
return
|
|
12170
|
+
|
|
12064
12171
|
default_branch = str((task.project or {}).get("default_branch") or "").strip()
|
|
12065
12172
|
node_before_sha = ""
|
|
12066
12173
|
# Use workspace-level granularity (requirement_workflow_id or graph_id) so that
|
|
@@ -12090,9 +12197,8 @@ class RuntimeDaemon:
|
|
|
12090
12197
|
# but this is a separate, complementary guard against two
|
|
12091
12198
|
# DAEMON PROCESSES (e.g. a zombie/restarted instance) operating
|
|
12092
12199
|
# on the same real directory at the same time.
|
|
12093
|
-
local_bind_file_lock =
|
|
12094
|
-
|
|
12095
|
-
)
|
|
12200
|
+
local_bind_file_lock = (self.managed_policy.task_lock if self.managed_policy is not None
|
|
12201
|
+
else _try_acquire_local_workspace_file_lock(task.local_workspace_binding_id))
|
|
12096
12202
|
if local_bind_file_lock is None:
|
|
12097
12203
|
project_lock.release()
|
|
12098
12204
|
await report_task_complete(TaskResult(
|
|
@@ -12978,13 +13084,19 @@ class RuntimeDaemon:
|
|
|
12978
13084
|
logger.info("Task %s completed: %s (agent=%s)", task.task_id, result.status, agent.agent_id)
|
|
12979
13085
|
|
|
12980
13086
|
except Exception as e:
|
|
12981
|
-
|
|
13087
|
+
if self.managed_policy is not None:
|
|
13088
|
+
_append_local_bind_output_log(task.task_id, [str(e)])
|
|
13089
|
+
logger.warning("Managed local task failed: %s", getattr(e, "code", "local_task_failed"))
|
|
13090
|
+
else:
|
|
13091
|
+
logger.exception("Task %s failed with exception", task.task_id)
|
|
12982
13092
|
await report_task_complete(TaskResult(
|
|
12983
13093
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
12984
13094
|
error=str(e),
|
|
12985
13095
|
execution_attempt_id=task.execution_attempt_id,
|
|
12986
13096
|
))
|
|
12987
13097
|
finally:
|
|
13098
|
+
if self.managed_policy is not None:
|
|
13099
|
+
await self.managed_policy.finish_task()
|
|
12988
13100
|
if attachment_staging_dir is not None:
|
|
12989
13101
|
shutil.rmtree(attachment_staging_dir, ignore_errors=True)
|
|
12990
13102
|
if workspace_execution_lock is not None:
|
|
@@ -15641,7 +15753,7 @@ class RuntimeDaemon:
|
|
|
15641
15753
|
# Unstage if it was already staged
|
|
15642
15754
|
if is_staged_new:
|
|
15643
15755
|
try:
|
|
15644
|
-
proc = await
|
|
15756
|
+
proc = await _create_subprocess_exec(
|
|
15645
15757
|
"git", "restore", "--staged", file_path,
|
|
15646
15758
|
cwd=str(workspace_path),
|
|
15647
15759
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -15696,7 +15808,7 @@ class RuntimeDaemon:
|
|
|
15696
15808
|
lost). That is legitimate, intentional work — not noise — so it
|
|
15697
15809
|
must be allowed to commit; we only log it for audit visibility.
|
|
15698
15810
|
"""
|
|
15699
|
-
proc = await
|
|
15811
|
+
proc = await _create_subprocess_exec(
|
|
15700
15812
|
"git", "diff", "--raw", "--no-abbrev", "--no-renames", "-z", *diff_args,
|
|
15701
15813
|
cwd=str(workspace_path),
|
|
15702
15814
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -15954,7 +16066,7 @@ class RuntimeDaemon:
|
|
|
15954
16066
|
except RuntimeError:
|
|
15955
16067
|
return {"commit_error": "Refusing to commit: the task baseline changed while preparing the commit"}
|
|
15956
16068
|
_git_name, _git_email = _resolve_git_author(task.project)
|
|
15957
|
-
proc = await
|
|
16069
|
+
proc = await _create_subprocess_exec(
|
|
15958
16070
|
"git", "commit", "-m", commit_msg,
|
|
15959
16071
|
cwd=str(workspace_path),
|
|
15960
16072
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16118,7 +16230,7 @@ class RuntimeDaemon:
|
|
|
16118
16230
|
return {"commit_error": mode_change_error}
|
|
16119
16231
|
|
|
16120
16232
|
# Check for uncommitted changes
|
|
16121
|
-
proc = await
|
|
16233
|
+
proc = await _create_subprocess_exec(
|
|
16122
16234
|
"git", "status", "--porcelain",
|
|
16123
16235
|
cwd=str(workspace_path),
|
|
16124
16236
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16137,7 +16249,7 @@ class RuntimeDaemon:
|
|
|
16137
16249
|
await self._remove_root_scratch_files(workspace_path, task, stdout.decode())
|
|
16138
16250
|
|
|
16139
16251
|
# Stage all changes
|
|
16140
|
-
proc = await
|
|
16252
|
+
proc = await _create_subprocess_exec(
|
|
16141
16253
|
"git", "add", "-A",
|
|
16142
16254
|
cwd=str(workspace_path),
|
|
16143
16255
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16186,7 +16298,7 @@ class RuntimeDaemon:
|
|
|
16186
16298
|
logger.warning("Failed to build rich commit message: %s — using fallback", msg_err)
|
|
16187
16299
|
commit_msg = f"{task.node_type}({task.requirement_key or task.task_id}): {display_title}"
|
|
16188
16300
|
_git_name, _git_email = _resolve_git_author(task.project)
|
|
16189
|
-
proc = await
|
|
16301
|
+
proc = await _create_subprocess_exec(
|
|
16190
16302
|
"git", "commit", "-m", commit_msg,
|
|
16191
16303
|
cwd=str(workspace_path),
|
|
16192
16304
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16913,7 +17025,7 @@ class RuntimeDaemon:
|
|
|
16913
17025
|
"""Collect staged diff stats for building a rich commit message."""
|
|
16914
17026
|
status_map: dict[str, str] = {}
|
|
16915
17027
|
|
|
16916
|
-
proc = await
|
|
17028
|
+
proc = await _create_subprocess_exec(
|
|
16917
17029
|
"git", "diff", "--cached", "--name-status",
|
|
16918
17030
|
cwd=str(cwd),
|
|
16919
17031
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16927,7 +17039,7 @@ class RuntimeDaemon:
|
|
|
16927
17039
|
status_map[parts[1].strip()] = parts[0].strip()
|
|
16928
17040
|
|
|
16929
17041
|
files: list[dict] = []
|
|
16930
|
-
proc = await
|
|
17042
|
+
proc = await _create_subprocess_exec(
|
|
16931
17043
|
"git", "diff", "--cached", "--numstat",
|
|
16932
17044
|
cwd=str(cwd),
|
|
16933
17045
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16956,7 +17068,7 @@ class RuntimeDaemon:
|
|
|
16956
17068
|
for file_info in files[:20]:
|
|
16957
17069
|
file_info["symbols"] = await self._extract_staged_file_symbols(cwd, file_info["path"])
|
|
16958
17070
|
|
|
16959
|
-
proc = await
|
|
17071
|
+
proc = await _create_subprocess_exec(
|
|
16960
17072
|
"git", "diff", "--cached", "--stat",
|
|
16961
17073
|
cwd=str(cwd),
|
|
16962
17074
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -16985,7 +17097,7 @@ class RuntimeDaemon:
|
|
|
16985
17097
|
)
|
|
16986
17098
|
|
|
16987
17099
|
try:
|
|
16988
|
-
proc = await
|
|
17100
|
+
proc = await _create_subprocess_exec(
|
|
16989
17101
|
"git", "diff", "--cached", "--unified=0", "--", filepath,
|
|
16990
17102
|
cwd=str(cwd),
|
|
16991
17103
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -18030,7 +18142,7 @@ class RuntimeDaemon:
|
|
|
18030
18142
|
resolve_result = await self.process_manager.run_agent(agent, _cr_task, workspace_path)
|
|
18031
18143
|
|
|
18032
18144
|
# 6. Check if conflicts are resolved
|
|
18033
|
-
proc = await
|
|
18145
|
+
proc = await _create_subprocess_exec(
|
|
18034
18146
|
"git", "diff", "--name-only", "--diff-filter=U",
|
|
18035
18147
|
cwd=str(workspace_path),
|
|
18036
18148
|
stdout=asyncio.subprocess.PIPE,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|