Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/windows-shell.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Windows shell regressions

on:
push:
paths:
- 'app/data/action/run_shell.py'
- 'app/data/action/run_powershell.py'
- 'tests/windows_shell/**'
- '.github/workflows/windows-shell.yml'
pull_request:
paths:
- 'app/data/action/run_shell.py'
- 'app/data/action/run_powershell.py'
- 'tests/windows_shell/**'
- '.github/workflows/windows-shell.yml'

jobs:
shell:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Verify real cmd and Windows PowerShell execution
run: python -m unittest discover -s tests/windows_shell -v
9 changes: 7 additions & 2 deletions agent_core/core/action_framework/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,13 @@ def list_all_actions_as_json(self) -> List[Dict[str, Any]]:
return json_actions_list

def find_action_by_name(self, action_name: str) -> Optional[Dict[str, Any]]:
"""Find an action by name and return its JSON representation."""
if action_name not in self._registry:
"""Find an action by name and return its JSON representation.

An action with no implementation for this OS does not exist here.
Action sets already leave it out of the prompt; this keeps a name the
model produces anyway from resolving to another OS's code.
"""
if self.get_action_implementation(action_name) is None:
return None

platform_impls = self._registry[action_name]
Expand Down
17 changes: 17 additions & 0 deletions agent_core/core/impl/action/cancellation.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
trees included, and is safe to call at any time (missing/exited processes
are ignored). It is blocking (taskkill / killpg) — call it from a worker
thread, not the event loop.

``was_killed(proc)`` lets an action that holds a registered handle tell a
stop apart from its own command failing: both end in a non-zero exit code.
"""

from __future__ import annotations
Expand All @@ -36,6 +39,7 @@
import subprocess
import tempfile
import threading
import weakref
from pathlib import Path
from typing import Dict, Optional

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


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


def was_killed(proc: subprocess.Popen) -> bool:
"""Whether a user stop killed this process, so its exit is not a failure."""
with _lock:
return proc in _killed


# ─────────────────────── Cross-process pid markers ───────────────────────

# psutil's create_time can wobble by a fraction of a second between reads.
Expand Down Expand Up @@ -178,6 +191,10 @@ def kill_session_processes(session_id: str) -> int:
killed = 0
for proc in handles:
if proc.poll() is None:
# Marked BEFORE the kill: the action's communicate() returns the
# instant the process dies, and must already see the mark.
with _lock:
_killed.add(proc)
_kill_tree(proc.pid)
killed += 1
try:
Expand Down
3 changes: 3 additions & 0 deletions app/agent_app/construction_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"agent_app_notify_ready",
"agent_app_ops_verify",
"run_shell",
"run_powershell",
"spawn_subagent",
}
)
Expand Down Expand Up @@ -543,6 +544,8 @@ def _classify_activity(
)
elif name == "run_shell":
kind, area, label = "run", "other", f"Ran {_clip(inputs.get('command'), 40)}"
elif name == "run_powershell":
kind, area, label = "run", "other", f"Ran {_clip(inputs.get('script'), 40)}"
elif name == "spawn_subagent":
agent_type = str(inputs.get("agent_type", ""))
if agent_type == "walk_verify":
Expand Down
237 changes: 237 additions & 0 deletions app/data/action/run_powershell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
from agent_core import action


@action(
name="run_powershell",
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.",
platforms=["windows"],
default=True,
action_sets=["core"],
input_schema={
"script": {
"type": "string",
"example": "Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | Select-Object DeviceID, FreeSpace, Size",
"description": "The PowerShell script to run, exactly as typed at a PowerShell prompt. Can span multiple lines.",
},
"timeout": {
"type": "integer",
"example": 600,
"description": "Optional timeout (seconds). If exceeded, the process is terminated.",
},
"cwd": {
"type": "string",
"example": "C:\\\\Users\\\\me",
"description": "Optional working directory for the script.",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
"example": {"MY_VAR": "123"},
"description": "Optional environment variable overrides.",
},
"background": {
"type": "boolean",
"example": False,
"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'.",
},
},
output_schema={
"status": {"type": "string", "example": "success"},
"stdout": {"type": "string", "example": "Script output text"},
"stderr": {"type": "string", "example": ""},
"return_code": {"type": "integer", "example": 0},
"message": {"type": "string", "example": "Timed out after 30s."},
"pid": {
"type": "integer",
"example": 12345,
"description": "Process ID when running in background mode.",
},
},
test_payload={
"script": "Get-ChildItem C:\\\\Windows\\\\System32",
"timeout": 600,
"cwd": "C:\\\\Users\\\\me",
"env": {"MY_VAR": "123"},
"background": False,
"simulated_mode": True,
},
)
def powershell_exec_windows(input_data: dict) -> dict:
import os
import subprocess

simulated_mode = input_data.get("simulated_mode", False)

if simulated_mode:
# Return mock result for testing
return {
"status": "success",
"stdout": "Simulated script output",
"stderr": "",
"return_code": 0,
"message": "",
"pid": None,
}

script = str(input_data.get("script", "")).strip()
timeout_val = input_data.get("timeout")
cwd = input_data.get("cwd")
env_input = input_data.get("env") or {}
background = input_data.get("background", False)

timeout_seconds = float(timeout_val) if timeout_val is not None else 600.0

if not script:
return {
"status": "error",
"stdout": "",
"stderr": "",
"return_code": -1,
"message": "script is required.",
"pid": None,
}

if cwd and not os.path.isdir(cwd):
return {
"status": "error",
"stdout": "",
"stderr": "",
"return_code": -1,
"message": "Working directory does not exist.",
"pid": None,
}

# Same environment as run_shell: the resolved Node runtime leads PATH, so
# bare `node` is the major the agent-app CLI needs (app/node_runtime.py).
from app import node_runtime

env = node_runtime.child_env()
for k, v in env_input.items():
env[str(k)] = str(v)

# Windows PowerShell ships with every Windows install, so the dialect a
# script is written for is the same on every machine.
args = [
"powershell.exe",
"-NoLogo",
"-NonInteractive",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
]

creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)

# Background mode: start process and return immediately
if background:
try:
# Use CREATE_NEW_PROCESS_GROUP to detach from parent
bg_flags = creation_flags | subprocess.CREATE_NEW_PROCESS_GROUP
process = subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
cwd=cwd if cwd else None,
env=env,
creationflags=bg_flags,
)
return {
"status": "background",
"stdout": "",
"stderr": "",
"return_code": 0,
"message": f"Process started in background with PID {process.pid}",
"pid": process.pid,
}
except Exception as e:
return {
"status": "error",
"stdout": "",
"stderr": str(e),
"return_code": -1,
"message": str(e),
"pid": None,
}

# Foreground mode with proper timeout handling
try:
# Register with the run-cancel registry so a user force-stop can
# kill this process tree while communicate() blocks the pool thread.
from agent_core.core.impl.action.cancellation import (
register_process,
unregister_process,
was_killed,
)

run_session_id = input_data.get("_session_id") or ""

# Use CREATE_NEW_PROCESS_GROUP so we can kill the entire process tree
fg_flags = creation_flags | subprocess.CREATE_NEW_PROCESS_GROUP
process = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
cwd=cwd if cwd else None,
env=env,
text=True,
errors="replace",
creationflags=fg_flags,
)
register_process(run_session_id, process)

try:
stdout, stderr = process.communicate(timeout=timeout_seconds)
if was_killed(process):
# A user stop killed it; the exit code says nothing about
# the script itself.
return {
"status": "error",
"stdout": stdout.strip() if stdout else "",
"stderr": stderr.strip() if stderr else "",
"return_code": -1,
"message": "Stopped by the user before the script finished.",
"pid": None,
}
return {
"status": "success" if process.returncode == 0 else "error",
"stdout": stdout.strip() if stdout else "",
"stderr": stderr.strip() if stderr else "",
"return_code": process.returncode,
"message": "",
"pid": None,
}
except subprocess.TimeoutExpired:
# Kill the entire process tree on Windows using taskkill
try:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(process.pid)],
capture_output=True,
creationflags=creation_flags,
)
except Exception:
pass
process.kill()
stdout, stderr = process.communicate()
return {
"status": "error",
"stdout": (stdout or "").strip(),
"stderr": (stderr or "").strip(),
"return_code": -1,
"message": f"Timed out after {timeout_seconds}s.",
"pid": None,
}
finally:
unregister_process(run_session_id, process)
except Exception as e:
return {
"status": "error",
"stdout": "",
"stderr": str(e),
"return_code": -1,
"message": str(e),
"pid": None,
}
Loading
Loading