Skip to content

Commit fb5df85

Browse files
author
CraftBot
committed
added run powershell action for windows only to prevent run shell encoding issue
1 parent c27d142 commit fb5df85

8 files changed

Lines changed: 654 additions & 317 deletions

File tree

‎.github/workflows/windows-shell.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ on:
44
push:
55
paths:
66
- 'app/data/action/run_shell.py'
7+
- 'app/data/action/run_powershell.py'
78
- 'tests/windows_shell/**'
89
- '.github/workflows/windows-shell.yml'
910
pull_request:
1011
paths:
1112
- 'app/data/action/run_shell.py'
13+
- 'app/data/action/run_powershell.py'
1214
- 'tests/windows_shell/**'
1315
- '.github/workflows/windows-shell.yml'
1416

@@ -20,5 +22,5 @@ jobs:
2022
- uses: actions/setup-python@v5
2123
with:
2224
python-version: '3.10'
23-
- name: Verify real cmd, Windows PowerShell and pwsh execution
25+
- name: Verify real cmd and Windows PowerShell execution
2426
run: python -m unittest discover -s tests/windows_shell -v

‎agent_core/core/action_framework/registry.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,13 @@ def list_all_actions_as_json(self) -> List[Dict[str, Any]]:
220220
return json_actions_list
221221

222222
def find_action_by_name(self, action_name: str) -> Optional[Dict[str, Any]]:
223-
"""Find an action by name and return its JSON representation."""
224-
if action_name not in self._registry:
223+
"""Find an action by name and return its JSON representation.
224+
225+
An action with no implementation for this OS does not exist here.
226+
Action sets already leave it out of the prompt; this keeps a name the
227+
model produces anyway from resolving to another OS's code.
228+
"""
229+
if self.get_action_implementation(action_name) is None:
225230
return None
226231

227232
platform_impls = self._registry[action_name]

‎agent_core/core/impl/action/cancellation.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
trees included, and is safe to call at any time (missing/exited processes
2828
are ignored). It is blocking (taskkill / killpg) — call it from a worker
2929
thread, not the event loop.
30+
31+
``was_killed(proc)`` lets an action that holds a registered handle tell a
32+
stop apart from its own command failing: both end in a non-zero exit code.
3033
"""
3134

3235
from __future__ import annotations
@@ -36,6 +39,7 @@
3639
import subprocess
3740
import tempfile
3841
import threading
42+
import weakref
3943
from pathlib import Path
4044
from typing import Dict, Optional
4145

@@ -44,6 +48,9 @@
4448
_lock = threading.Lock()
4549
# session_id -> {pid: Popen}. Popen handles registered by in-process actions.
4650
_procs: Dict[str, Dict[int, subprocess.Popen]] = {}
51+
# Handles a stop killed. Weak: an entry lasts only as long as its action
52+
# still holds the handle, so nothing here needs clearing.
53+
_killed: "weakref.WeakSet[subprocess.Popen]" = weakref.WeakSet()
4754

4855

4956
def _marker_dir(session_id: str) -> Path:
@@ -73,6 +80,12 @@ def unregister_process(session_id: str, proc: subprocess.Popen) -> None:
7380
_procs.pop(session_id, None)
7481

7582

83+
def was_killed(proc: subprocess.Popen) -> bool:
84+
"""Whether a user stop killed this process, so its exit is not a failure."""
85+
with _lock:
86+
return proc in _killed
87+
88+
7689
# ─────────────────────── Cross-process pid markers ───────────────────────
7790

7891
# psutil's create_time can wobble by a fraction of a second between reads.
@@ -178,6 +191,10 @@ def kill_session_processes(session_id: str) -> int:
178191
killed = 0
179192
for proc in handles:
180193
if proc.poll() is None:
194+
# Marked BEFORE the kill: the action's communicate() returns the
195+
# instant the process dies, and must already see the mark.
196+
with _lock:
197+
_killed.add(proc)
181198
_kill_tree(proc.pid)
182199
killed += 1
183200
try:

‎app/agent_app/construction_events.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"agent_app_notify_ready",
4848
"agent_app_ops_verify",
4949
"run_shell",
50+
"run_powershell",
5051
"spawn_subagent",
5152
}
5253
)
@@ -543,6 +544,8 @@ def _classify_activity(
543544
)
544545
elif name == "run_shell":
545546
kind, area, label = "run", "other", f"Ran {_clip(inputs.get('command'), 40)}"
547+
elif name == "run_powershell":
548+
kind, area, label = "run", "other", f"Ran {_clip(inputs.get('script'), 40)}"
546549
elif name == "spawn_subagent":
547550
agent_type = str(inputs.get("agent_type", ""))
548551
if agent_type == "walk_verify":

‎app/data/action/run_powershell.py‎

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
from agent_core import action
2+
3+
4+
@action(
5+
name="run_powershell",
6+
description="Runs a PowerShell script with Windows PowerShell, capturing stdout, stderr, and exit code. Stdin is closed (EOF) by default. IMPORTANT: For long-running scripts that don't terminate (e.g., dev servers, watch processes), you MUST set background=true. Otherwise, the script will block the entire task until timeout and may not capture any output.",
7+
platforms=["windows"],
8+
default=True,
9+
action_sets=["core"],
10+
input_schema={
11+
"script": {
12+
"type": "string",
13+
"example": "Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | Select-Object DeviceID, FreeSpace, Size",
14+
"description": "The PowerShell script to run, exactly as typed at a PowerShell prompt. Can span multiple lines.",
15+
},
16+
"timeout": {
17+
"type": "integer",
18+
"example": 600,
19+
"description": "Optional timeout (seconds). If exceeded, the process is terminated.",
20+
},
21+
"cwd": {
22+
"type": "string",
23+
"example": "C:\\\\Users\\\\me",
24+
"description": "Optional working directory for the script.",
25+
},
26+
"env": {
27+
"type": "object",
28+
"additionalProperties": {"type": "string"},
29+
"example": {"MY_VAR": "123"},
30+
"description": "Optional environment variable overrides.",
31+
},
32+
"background": {
33+
"type": "boolean",
34+
"example": False,
35+
"description": "Set to true for long-running processes (dev servers, watchers, etc.). The script will start in the background and return immediately with the process ID. Required for commands like 'npm run dev', 'npm start', 'python -m http.server'.",
36+
},
37+
},
38+
output_schema={
39+
"status": {"type": "string", "example": "success"},
40+
"stdout": {"type": "string", "example": "Script output text"},
41+
"stderr": {"type": "string", "example": ""},
42+
"return_code": {"type": "integer", "example": 0},
43+
"message": {"type": "string", "example": "Timed out after 30s."},
44+
"pid": {
45+
"type": "integer",
46+
"example": 12345,
47+
"description": "Process ID when running in background mode.",
48+
},
49+
},
50+
test_payload={
51+
"script": "Get-ChildItem C:\\\\Windows\\\\System32",
52+
"timeout": 600,
53+
"cwd": "C:\\\\Users\\\\me",
54+
"env": {"MY_VAR": "123"},
55+
"background": False,
56+
"simulated_mode": True,
57+
},
58+
)
59+
def powershell_exec_windows(input_data: dict) -> dict:
60+
import os
61+
import subprocess
62+
63+
simulated_mode = input_data.get("simulated_mode", False)
64+
65+
if simulated_mode:
66+
# Return mock result for testing
67+
return {
68+
"status": "success",
69+
"stdout": "Simulated script output",
70+
"stderr": "",
71+
"return_code": 0,
72+
"message": "",
73+
"pid": None,
74+
}
75+
76+
script = str(input_data.get("script", "")).strip()
77+
timeout_val = input_data.get("timeout")
78+
cwd = input_data.get("cwd")
79+
env_input = input_data.get("env") or {}
80+
background = input_data.get("background", False)
81+
82+
timeout_seconds = float(timeout_val) if timeout_val is not None else 600.0
83+
84+
if not script:
85+
return {
86+
"status": "error",
87+
"stdout": "",
88+
"stderr": "",
89+
"return_code": -1,
90+
"message": "script is required.",
91+
"pid": None,
92+
}
93+
94+
if cwd and not os.path.isdir(cwd):
95+
return {
96+
"status": "error",
97+
"stdout": "",
98+
"stderr": "",
99+
"return_code": -1,
100+
"message": "Working directory does not exist.",
101+
"pid": None,
102+
}
103+
104+
# Same environment as run_shell: the resolved Node runtime leads PATH, so
105+
# bare `node` is the major the agent-app CLI needs (app/node_runtime.py).
106+
from app import node_runtime
107+
108+
env = node_runtime.child_env()
109+
for k, v in env_input.items():
110+
env[str(k)] = str(v)
111+
112+
# Windows PowerShell ships with every Windows install, so the dialect a
113+
# script is written for is the same on every machine.
114+
args = [
115+
"powershell.exe",
116+
"-NoLogo",
117+
"-NonInteractive",
118+
"-NoProfile",
119+
"-ExecutionPolicy",
120+
"Bypass",
121+
"-Command",
122+
script,
123+
]
124+
125+
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
126+
127+
# Background mode: start process and return immediately
128+
if background:
129+
try:
130+
# Use CREATE_NEW_PROCESS_GROUP to detach from parent
131+
bg_flags = creation_flags | subprocess.CREATE_NEW_PROCESS_GROUP
132+
process = subprocess.Popen(
133+
args,
134+
stdout=subprocess.DEVNULL,
135+
stderr=subprocess.DEVNULL,
136+
stdin=subprocess.DEVNULL,
137+
cwd=cwd if cwd else None,
138+
env=env,
139+
creationflags=bg_flags,
140+
)
141+
return {
142+
"status": "background",
143+
"stdout": "",
144+
"stderr": "",
145+
"return_code": 0,
146+
"message": f"Process started in background with PID {process.pid}",
147+
"pid": process.pid,
148+
}
149+
except Exception as e:
150+
return {
151+
"status": "error",
152+
"stdout": "",
153+
"stderr": str(e),
154+
"return_code": -1,
155+
"message": str(e),
156+
"pid": None,
157+
}
158+
159+
# Foreground mode with proper timeout handling
160+
try:
161+
# Register with the run-cancel registry so a user force-stop can
162+
# kill this process tree while communicate() blocks the pool thread.
163+
from agent_core.core.impl.action.cancellation import (
164+
register_process,
165+
unregister_process,
166+
was_killed,
167+
)
168+
169+
run_session_id = input_data.get("_session_id") or ""
170+
171+
# Use CREATE_NEW_PROCESS_GROUP so we can kill the entire process tree
172+
fg_flags = creation_flags | subprocess.CREATE_NEW_PROCESS_GROUP
173+
process = subprocess.Popen(
174+
args,
175+
stdout=subprocess.PIPE,
176+
stderr=subprocess.PIPE,
177+
stdin=subprocess.DEVNULL,
178+
cwd=cwd if cwd else None,
179+
env=env,
180+
text=True,
181+
errors="replace",
182+
creationflags=fg_flags,
183+
)
184+
register_process(run_session_id, process)
185+
186+
try:
187+
stdout, stderr = process.communicate(timeout=timeout_seconds)
188+
if was_killed(process):
189+
# A user stop killed it; the exit code says nothing about
190+
# the script itself.
191+
return {
192+
"status": "error",
193+
"stdout": stdout.strip() if stdout else "",
194+
"stderr": stderr.strip() if stderr else "",
195+
"return_code": -1,
196+
"message": "Stopped by the user before the script finished.",
197+
"pid": None,
198+
}
199+
return {
200+
"status": "success" if process.returncode == 0 else "error",
201+
"stdout": stdout.strip() if stdout else "",
202+
"stderr": stderr.strip() if stderr else "",
203+
"return_code": process.returncode,
204+
"message": "",
205+
"pid": None,
206+
}
207+
except subprocess.TimeoutExpired:
208+
# Kill the entire process tree on Windows using taskkill
209+
try:
210+
subprocess.run(
211+
["taskkill", "/F", "/T", "/PID", str(process.pid)],
212+
capture_output=True,
213+
creationflags=creation_flags,
214+
)
215+
except Exception:
216+
pass
217+
process.kill()
218+
stdout, stderr = process.communicate()
219+
return {
220+
"status": "error",
221+
"stdout": (stdout or "").strip(),
222+
"stderr": (stderr or "").strip(),
223+
"return_code": -1,
224+
"message": f"Timed out after {timeout_seconds}s.",
225+
"pid": None,
226+
}
227+
finally:
228+
unregister_process(run_session_id, process)
229+
except Exception as e:
230+
return {
231+
"status": "error",
232+
"stdout": "",
233+
"stderr": str(e),
234+
"return_code": -1,
235+
"message": str(e),
236+
"pid": None,
237+
}

0 commit comments

Comments
 (0)