Fixed Quick Jobs & Scheduled Jobs

This commit is contained in:
2025-11-04 04:03:14 -07:00
parent bdf5457de0
commit 86ba656862
7 changed files with 365 additions and 235 deletions

View File

@@ -293,56 +293,44 @@ class Role:
return return
job_label = job_id if job_id is not None else 'unknown' job_label = job_id if job_id is not None else 'unknown'
_log(f"quick_job_run(currentuser) received payload job_id={job_label}") _log(f"quick_job_run(currentuser) received payload job_id={job_label}")
context = payload.get('context') if isinstance(payload, dict) else None
def _result_payload(job_value, status_value, stdout_value="", stderr_value=""):
result = {
'job_id': job_value,
'status': status_value,
'stdout': stdout_value,
'stderr': stderr_value,
}
if isinstance(context, dict):
result['context'] = context
return result
script_bytes = decode_script_bytes(payload.get('script_content'), payload.get('script_encoding')) script_bytes = decode_script_bytes(payload.get('script_content'), payload.get('script_encoding'))
if script_bytes is None: if script_bytes is None:
_log(f"quick_job_run(currentuser) invalid script payload job_id={job_label}", error=True) _log(f"quick_job_run(currentuser) invalid script payload job_id={job_label}", error=True)
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', 'Invalid script payload (unable to decode)'))
'job_id': job_id,
'status': 'Failed',
'stdout': '',
'stderr': 'Invalid script payload (unable to decode)',
})
return return
signature_b64 = payload.get('signature') signature_b64 = payload.get('signature')
sig_alg = (payload.get('sig_alg') or 'ed25519').lower() sig_alg = (payload.get('sig_alg') or 'ed25519').lower()
signing_key = payload.get('signing_key') signing_key = payload.get('signing_key')
if sig_alg and sig_alg not in ('ed25519', 'eddsa'): if sig_alg and sig_alg not in ('ed25519', 'eddsa'):
_log(f"quick_job_run(currentuser) unsupported signature algorithm job_id={job_label} alg={sig_alg}", error=True) _log(f"quick_job_run(currentuser) unsupported signature algorithm job_id={job_label} alg={sig_alg}", error=True)
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', f'Unsupported script signature algorithm: {sig_alg}'))
'job_id': job_id,
'status': 'Failed',
'stdout': '',
'stderr': f'Unsupported script signature algorithm: {sig_alg}',
})
return return
if not isinstance(signature_b64, str) or not signature_b64.strip(): if not isinstance(signature_b64, str) or not signature_b64.strip():
_log(f"quick_job_run(currentuser) missing signature job_id={job_label}", error=True) _log(f"quick_job_run(currentuser) missing signature job_id={job_label}", error=True)
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', 'Missing script signature; rejecting payload'))
'job_id': job_id,
'status': 'Failed',
'stdout': '',
'stderr': 'Missing script signature; rejecting payload',
})
return return
http_client_fn = getattr(self.ctx, 'hooks', {}).get('http_client') if hasattr(self.ctx, 'hooks') else None http_client_fn = getattr(self.ctx, 'hooks', {}).get('http_client') if hasattr(self.ctx, 'hooks') else None
client = http_client_fn() if callable(http_client_fn) else None client = http_client_fn() if callable(http_client_fn) else None
if client is None: if client is None:
_log(f"quick_job_run(currentuser) missing http_client hook job_id={job_label}", error=True) _log(f"quick_job_run(currentuser) missing http_client hook job_id={job_label}", error=True)
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', 'Signature verification unavailable (client missing)'))
'job_id': job_id,
'status': 'Failed',
'stdout': '',
'stderr': 'Signature verification unavailable (client missing)',
})
return return
if not verify_and_store_script_signature(client, script_bytes, signature_b64, signing_key): if not verify_and_store_script_signature(client, script_bytes, signature_b64, signing_key):
_log(f"quick_job_run(currentuser) signature verification failed job_id={job_label}", error=True) _log(f"quick_job_run(currentuser) signature verification failed job_id={job_label}", error=True)
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', 'Rejected script payload due to invalid signature'))
'job_id': job_id,
'status': 'Failed',
'stdout': '',
'stderr': 'Rejected script payload due to invalid signature',
})
return return
_log(f"quick_job_run(currentuser) signature verified job_id={job_label}") _log(f"quick_job_run(currentuser) signature verified job_id={job_label}")
content = script_bytes.decode('utf-8', errors='replace') content = script_bytes.decode('utf-8', errors='replace')
@@ -371,37 +359,26 @@ class Role:
except Exception: except Exception:
timeout_seconds = 0 timeout_seconds = 0
if script_type != 'powershell': if script_type != 'powershell':
await sio.emit('quick_job_result', { 'job_id': job_id, 'status': 'Failed', 'stdout': '', 'stderr': f"Unsupported type: {script_type}" }) await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', f'Unsupported type: {script_type}'))
return return
if run_mode == 'admin':
rc, out, err = -1, '', 'Admin credentialed runs are disabled; use SYSTEM or Current User.' rc, out, err = await _run_powershell_via_user_task(content, env_map, timeout_seconds)
else: if rc == -999:
rc, out, err = await _run_powershell_via_user_task(content, env_map, timeout_seconds) rc, out, err = _run_powershell_script_content(content, env_map, timeout_seconds)
if rc == -999:
path = _write_temp_script(content, '.ps1', env_map, timeout_seconds)
try:
rc, out, err = await _run_powershell_local(path)
finally:
try:
if path and os.path.isfile(path):
os.remove(path)
except Exception:
pass
status = 'Success' if rc == 0 else 'Failed' status = 'Success' if rc == 0 else 'Failed'
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, status, out, err))
'job_id': job_id,
'status': status,
'stdout': out,
'stderr': err,
})
except Exception as e: except Exception as e:
try: try:
await sio.emit('quick_job_result', { context = payload.get('context') if isinstance(payload, dict) else None
result = {
'job_id': payload.get('job_id') if isinstance(payload, dict) else None, 'job_id': payload.get('job_id') if isinstance(payload, dict) else None,
'status': 'Failed', 'status': 'Failed',
'stdout': '', 'stdout': '',
'stderr': str(e), 'stderr': str(e),
}) }
if isinstance(context, dict):
result['context'] = context
await sio.emit('quick_job_result', result)
except Exception: except Exception:
pass pass
@@ -451,3 +428,5 @@ class Role:
QtWidgets.QApplication.instance().quit() QtWidgets.QApplication.instance().quit()
except Exception: except Exception:
os._exit(0) os._exit(0)

View File

@@ -308,56 +308,44 @@ class Role:
script_type = (payload.get('script_type') or '').lower() script_type = (payload.get('script_type') or '').lower()
job_label = job_id if job_id is not None else 'unknown' job_label = job_id if job_id is not None else 'unknown'
_log(f"quick_job_run(system) received payload job_id={job_label}") _log(f"quick_job_run(system) received payload job_id={job_label}")
context = payload.get('context') if isinstance(payload, dict) else None
def _result_payload(job_value, status_value, stdout_value="", stderr_value=""):
result = {
'job_id': job_value,
'status': status_value,
'stdout': stdout_value,
'stderr': stderr_value,
}
if isinstance(context, dict):
result['context'] = context
return result
script_bytes = decode_script_bytes(payload.get('script_content'), payload.get('script_encoding')) script_bytes = decode_script_bytes(payload.get('script_content'), payload.get('script_encoding'))
if script_bytes is None: if script_bytes is None:
_log(f"quick_job_run(system) invalid script payload job_id={job_label}", error=True) _log(f"quick_job_run(system) invalid script payload job_id={job_label}", error=True)
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', 'Invalid script payload (unable to decode)'))
'job_id': job_id,
'status': 'Failed',
'stdout': '',
'stderr': 'Invalid script payload (unable to decode)',
})
return return
signature_b64 = payload.get('signature') signature_b64 = payload.get('signature')
sig_alg = (payload.get('sig_alg') or 'ed25519').lower() sig_alg = (payload.get('sig_alg') or 'ed25519').lower()
signing_key = payload.get('signing_key') signing_key = payload.get('signing_key')
if sig_alg and sig_alg not in ('ed25519', 'eddsa'): if sig_alg and sig_alg not in ('ed25519', 'eddsa'):
_log(f"quick_job_run(system) unsupported signature algorithm job_id={job_label} alg={sig_alg}", error=True) _log(f"quick_job_run(system) unsupported signature algorithm job_id={job_label} alg={sig_alg}", error=True)
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', f'Unsupported script signature algorithm: {sig_alg}'))
'job_id': job_id,
'status': 'Failed',
'stdout': '',
'stderr': f'Unsupported script signature algorithm: {sig_alg}',
})
return return
if not isinstance(signature_b64, str) or not signature_b64.strip(): if not isinstance(signature_b64, str) or not signature_b64.strip():
_log(f"quick_job_run(system) missing signature job_id={job_label}", error=True) _log(f"quick_job_run(system) missing signature job_id={job_label}", error=True)
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', 'Missing script signature; rejecting payload'))
'job_id': job_id,
'status': 'Failed',
'stdout': '',
'stderr': 'Missing script signature; rejecting payload',
})
return return
http_client_fn = getattr(self.ctx, 'hooks', {}).get('http_client') if hasattr(self.ctx, 'hooks') else None http_client_fn = getattr(self.ctx, 'hooks', {}).get('http_client') if hasattr(self.ctx, 'hooks') else None
client = http_client_fn() if callable(http_client_fn) else None client = http_client_fn() if callable(http_client_fn) else None
if client is None: if client is None:
_log(f"quick_job_run(system) missing http_client hook job_id={job_label}", error=True) _log(f"quick_job_run(system) missing http_client hook job_id={job_label}", error=True)
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', 'Signature verification unavailable (client missing)'))
'job_id': job_id,
'status': 'Failed',
'stdout': '',
'stderr': 'Signature verification unavailable (client missing)',
})
return return
if not verify_and_store_script_signature(client, script_bytes, signature_b64, signing_key): if not verify_and_store_script_signature(client, script_bytes, signature_b64, signing_key):
_log(f"quick_job_run(system) signature verification failed job_id={job_label}", error=True) _log(f"quick_job_run(system) signature verification failed job_id={job_label}", error=True)
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', 'Rejected script payload due to invalid signature'))
'job_id': job_id,
'status': 'Failed',
'stdout': '',
'stderr': 'Rejected script payload due to invalid signature',
})
return return
_log(f"quick_job_run(system) signature verified job_id={job_label}") _log(f"quick_job_run(system) signature verified job_id={job_label}")
content = script_bytes.decode('utf-8', errors='replace') content = script_bytes.decode('utf-8', errors='replace')
@@ -386,30 +374,26 @@ class Role:
except Exception: except Exception:
timeout_seconds = 0 timeout_seconds = 0
if script_type != 'powershell': if script_type != 'powershell':
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, 'Failed', '', f"Unsupported type: {script_type}"))
'job_id': job_id,
'status': 'Failed',
'stdout': '',
'stderr': f"Unsupported type: {script_type}"
})
return return
rc, out, err = _run_powershell_via_system_task(content, env_map, timeout_seconds) rc, out, err = _run_powershell_via_system_task(content, env_map, timeout_seconds)
if rc == -999: if rc == -999:
rc, out, err = _run_powershell_script_content(content, env_map, timeout_seconds) rc, out, err = _run_powershell_script_content(content, env_map, timeout_seconds)
status = 'Success' if rc == 0 else 'Failed' status = 'Success' if rc == 0 else 'Failed'
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', _result_payload(job_id, status, out, err))
'job_id': job_id,
'status': status,
'stdout': out,
'stderr': err,
})
except Exception as e: except Exception as e:
try: context = payload.get('context') if isinstance(payload, dict) else None
await sio.emit('quick_job_result', { def _error_payload(job_value, message):
'job_id': payload.get('job_id') if isinstance(payload, dict) else None, result = {
'job_id': job_value,
'status': 'Failed', 'status': 'Failed',
'stdout': '', 'stdout': '',
'stderr': str(e), 'stderr': message,
}) }
if isinstance(context, dict):
result['context'] = context
return result
try:
await sio.emit('quick_job_result', _error_payload(payload.get('job_id') if isinstance(payload, dict) else None, str(e)))
except Exception: except Exception:
pass pass

View File

@@ -3200,14 +3200,18 @@ if __name__=='__main__':
script_bytes = _decode_script_bytes(payload.get('script_content'), encoding_hint) script_bytes = _decode_script_bytes(payload.get('script_content'), encoding_hint)
run_mode = (payload.get('run_mode') or 'current_user').lower() run_mode = (payload.get('run_mode') or 'current_user').lower()
_log_agent(f"quick_job_run received payload job_id={job_label} run_mode={run_mode}") _log_agent(f"quick_job_run received payload job_id={job_label} run_mode={run_mode}")
context = payload.get('context') if isinstance(payload, dict) else None
if script_bytes is None: if script_bytes is None:
err = 'Invalid script payload (unable to decode)' err = 'Invalid script payload (unable to decode)'
await sio.emit('quick_job_result', { result_payload = {
'job_id': job_id, 'job_id': job_id,
'status': 'Failed', 'status': 'Failed',
'stdout': '', 'stdout': '',
'stderr': err, 'stderr': err,
}) }
if isinstance(context, dict):
result_payload['context'] = context
await sio.emit('quick_job_result', result_payload)
_log_agent(err) _log_agent(err)
_log_agent(err, fname='agent.error.log') _log_agent(err, fname='agent.error.log')
return return
@@ -3216,35 +3220,44 @@ if __name__=='__main__':
signing_key = payload.get('signing_key') signing_key = payload.get('signing_key')
if sig_alg and sig_alg not in ('ed25519', 'eddsa'): if sig_alg and sig_alg not in ('ed25519', 'eddsa'):
err = f"Unsupported script signature algorithm: {sig_alg}" err = f"Unsupported script signature algorithm: {sig_alg}"
await sio.emit('quick_job_result', { result_payload = {
'job_id': job_id, 'job_id': job_id,
'status': 'Failed', 'status': 'Failed',
'stdout': '', 'stdout': '',
'stderr': err, 'stderr': err,
}) }
if isinstance(context, dict):
result_payload['context'] = context
await sio.emit('quick_job_result', result_payload)
_log_agent(err) _log_agent(err)
_log_agent(err, fname='agent.error.log') _log_agent(err, fname='agent.error.log')
return return
if not isinstance(signature_b64, str) or not signature_b64.strip(): if not isinstance(signature_b64, str) or not signature_b64.strip():
err = 'Missing script signature; rejecting payload' err = 'Missing script signature; rejecting payload'
await sio.emit('quick_job_result', { result_payload = {
'job_id': job_id, 'job_id': job_id,
'status': 'Failed', 'status': 'Failed',
'stdout': '', 'stdout': '',
'stderr': err, 'stderr': err,
}) }
if isinstance(context, dict):
result_payload['context'] = context
await sio.emit('quick_job_result', result_payload)
_log_agent(err) _log_agent(err)
_log_agent(err, fname='agent.error.log') _log_agent(err, fname='agent.error.log')
return return
client = http_client() client = http_client()
if not _verify_and_store_script_signature(client, script_bytes, signature_b64, signing_key): if not _verify_and_store_script_signature(client, script_bytes, signature_b64, signing_key):
err = 'Rejected script payload due to invalid signature' err = 'Rejected script payload due to invalid signature'
await sio.emit('quick_job_result', { result_payload = {
'job_id': job_id, 'job_id': job_id,
'status': 'Failed', 'status': 'Failed',
'stdout': '', 'stdout': '',
'stderr': err, 'stderr': err,
}) }
if isinstance(context, dict):
result_payload['context'] = context
await sio.emit('quick_job_result', result_payload)
_log_agent(err) _log_agent(err)
_log_agent(err, fname='agent.error.log') _log_agent(err, fname='agent.error.log')
return return
@@ -3279,21 +3292,28 @@ if __name__=='__main__':
# Fallback to plain local run # Fallback to plain local run
rc, out, err = _run_powershell_script_content_local(content) rc, out, err = _run_powershell_script_content_local(content)
status = 'Success' if rc == 0 else 'Failed' status = 'Success' if rc == 0 else 'Failed'
await sio.emit('quick_job_result', { result_payload = {
'job_id': job_id, 'job_id': job_id,
'status': status, 'status': status,
'stdout': out or '', 'stdout': out or '',
'stderr': err or '', 'stderr': err or '',
}) }
if isinstance(context, dict):
result_payload['context'] = context
await sio.emit('quick_job_result', result_payload)
_log_agent(f"quick_job_result sent: job_id={job_id} status={status}") _log_agent(f"quick_job_result sent: job_id={job_id} status={status}")
except Exception as e: except Exception as e:
try: try:
await sio.emit('quick_job_result', { result_payload = {
'job_id': payload.get('job_id') if isinstance(payload, dict) else None, 'job_id': payload.get('job_id') if isinstance(payload, dict) else None,
'status': 'Failed', 'status': 'Failed',
'stdout': '', 'stdout': '',
'stderr': str(e), 'stderr': str(e),
}) }
context = payload.get('context') if isinstance(payload, dict) else None
if isinstance(context, dict):
result_payload['context'] = context
await sio.emit('quick_job_result', result_payload)
except Exception: except Exception:
pass pass
_log_agent(f"quick_job_run handler error: {e}", fname='agent.error.log') _log_agent(f"quick_job_run handler error: {e}", fname='agent.error.log')

View File

@@ -27,6 +27,8 @@ if TYPE_CHECKING: # pragma: no cover - typing aide
from .. import EngineServiceAdapters from .. import EngineServiceAdapters
from ...assemblies.service import AssemblyRuntimeService
def _assemblies_root() -> Path: def _assemblies_root() -> Path:
base = Path(__file__).resolve() base = Path(__file__).resolve()
@@ -255,8 +257,12 @@ def rewrite_powershell_script(content: str, literal_lookup: Dict[str, str]) -> s
return _ENV_VAR_PATTERN.sub(_replace, content) return _ENV_VAR_PATTERN.sub(_replace, content)
def _load_assembly_document(abs_path: str, default_type: str) -> Dict[str, Any]: def _load_assembly_document(
abs_path_str = os.fspath(abs_path) source_identifier: str,
default_type: str,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
abs_path_str = os.fspath(source_identifier)
base_name = os.path.splitext(os.path.basename(abs_path_str))[0] base_name = os.path.splitext(os.path.basename(abs_path_str))[0]
doc: Dict[str, Any] = { doc: Dict[str, Any] = {
"name": base_name, "name": base_name,
@@ -267,110 +273,114 @@ def _load_assembly_document(abs_path: str, default_type: str) -> Dict[str, Any]:
"variables": [], "variables": [],
"files": [], "files": [],
"timeout_seconds": 3600, "timeout_seconds": 3600,
"metadata": {},
} }
if abs_path_str.lower().endswith(".json") and os.path.isfile(abs_path_str): data: Dict[str, Any] = {}
if isinstance(payload, dict):
data = payload
elif abs_path_str.lower().endswith(".json") and os.path.isfile(abs_path_str):
try: try:
with open(abs_path_str, "r", encoding="utf-8") as fh: with open(abs_path_str, "r", encoding="utf-8") as fh:
data = json.load(fh) data = json.load(fh)
except Exception: except Exception:
data = {} data = {}
if isinstance(data, dict): if isinstance(data, dict) and data:
doc["name"] = str(data.get("name") or doc["name"]) doc["name"] = str(data.get("name") or doc["name"])
doc["description"] = str(data.get("description") or "") doc["description"] = str(data.get("description") or "")
cat = str(data.get("category") or doc["category"]).strip().lower() doc["metadata"] = data.get("metadata") if isinstance(data.get("metadata"), dict) else {}
if cat in {"application", "script"}: cat = str(data.get("category") or doc["category"]).strip().lower()
doc["category"] = cat if cat in {"application", "script"}:
typ = str(data.get("type") or data.get("script_type") or default_type).strip().lower() doc["category"] = cat
if typ in {"powershell", "batch", "bash", "ansible"}: typ = str(data.get("type") or data.get("script_type") or default_type).strip().lower()
doc["type"] = typ if typ in {"powershell", "batch", "bash", "ansible"}:
script_val = data.get("script") doc["type"] = typ
content_val = data.get("content") script_val = data.get("script")
script_lines = data.get("script_lines") content_val = data.get("content")
if isinstance(script_lines, list): script_lines = data.get("script_lines")
try: if isinstance(script_lines, list):
doc["script"] = "\n".join(str(line) for line in script_lines)
except Exception:
doc["script"] = ""
elif isinstance(script_val, str):
doc["script"] = script_val
else:
if isinstance(content_val, str):
doc["script"] = content_val
encoding_hint = str(
data.get("script_encoding") or data.get("scriptEncoding") or ""
).strip().lower()
doc["script"] = _decode_script_content(doc.get("script"), encoding_hint)
if encoding_hint in {"base64", "b64", "base-64"}:
doc["script_encoding"] = "base64"
else:
probe_source = ""
if isinstance(script_val, str) and script_val:
probe_source = script_val
elif isinstance(content_val, str) and content_val:
probe_source = content_val
decoded_probe = _decode_base64_text(probe_source) if probe_source else None
if decoded_probe is not None:
doc["script_encoding"] = "base64"
doc["script"] = decoded_probe.replace("\r\n", "\n")
else:
doc["script_encoding"] = "plain"
try: try:
timeout_raw = data.get("timeout_seconds", data.get("timeout")) doc["script"] = "\n".join(str(line) for line in script_lines)
if timeout_raw is None:
doc["timeout_seconds"] = 3600
else:
doc["timeout_seconds"] = max(0, int(timeout_raw))
except Exception: except Exception:
doc["script"] = ""
elif isinstance(script_val, str):
doc["script"] = script_val
elif isinstance(content_val, str):
doc["script"] = content_val
encoding_hint = str(data.get("script_encoding") or data.get("scriptEncoding") or "").strip().lower()
doc["script"] = _decode_script_content(doc.get("script"), encoding_hint)
if encoding_hint in {"base64", "b64", "base-64"}:
doc["script_encoding"] = "base64"
else:
probe_source = ""
if isinstance(script_val, str) and script_val:
probe_source = script_val
elif isinstance(content_val, str) and content_val:
probe_source = content_val
decoded_probe = _decode_base64_text(probe_source) if probe_source else None
if decoded_probe is not None:
doc["script_encoding"] = "base64"
doc["script"] = decoded_probe.replace("\r\n", "\n")
else:
doc["script_encoding"] = "plain"
try:
timeout_raw = data.get("timeout_seconds", data.get("timeout"))
if timeout_raw is None:
doc["timeout_seconds"] = 3600 doc["timeout_seconds"] = 3600
vars_in = data.get("variables") if isinstance(data.get("variables"), list) else [] else:
doc["variables"] = [] doc["timeout_seconds"] = max(0, int(timeout_raw))
for item in vars_in: except Exception:
if not isinstance(item, dict): doc["timeout_seconds"] = 3600
continue vars_in = data.get("variables") if isinstance(data.get("variables"), list) else []
name = str(item.get("name") or item.get("key") or "").strip() doc["variables"] = []
if not name: for item in vars_in:
continue if not isinstance(item, dict):
vtype = str(item.get("type") or "string").strip().lower() continue
if vtype not in {"string", "number", "boolean", "credential"}: name = str(item.get("name") or item.get("key") or "").strip()
vtype = "string" if not name:
doc["variables"].append( continue
{ vtype = str(item.get("type") or "string").strip().lower()
"name": name, if vtype not in {"string", "number", "boolean", "credential"}:
"label": str(item.get("label") or ""), vtype = "string"
"type": vtype, doc["variables"].append(
"default": item.get("default", item.get("default_value")), {
"required": bool(item.get("required")), "name": name,
"description": str(item.get("description") or ""), "label": str(item.get("label") or ""),
} "type": vtype,
) "default": item.get("default", item.get("default_value")),
files_in = data.get("files") if isinstance(data.get("files"), list) else [] "required": bool(item.get("required")),
doc["files"] = [] "description": str(item.get("description") or ""),
for file_item in files_in: }
if not isinstance(file_item, dict): )
continue files_in = data.get("files") if isinstance(data.get("files"), list) else []
fname = file_item.get("file_name") or file_item.get("name") doc["files"] = []
if not fname or not isinstance(file_item.get("data"), str): for file_item in files_in:
continue if not isinstance(file_item, dict):
try: continue
size_val = int(file_item.get("size") or 0) fname = file_item.get("file_name") or file_item.get("name")
except Exception: if not fname or not isinstance(file_item.get("data"), str):
size_val = 0 continue
doc["files"].append( try:
{ size_val = int(file_item.get("size") or 0)
"file_name": str(fname), except Exception:
"size": size_val, size_val = 0
"mime_type": str(file_item.get("mime_type") or file_item.get("mimeType") or ""), doc["files"].append(
"data": file_item.get("data"), {
} "file_name": str(fname),
) "size": size_val,
"mime_type": str(file_item.get("mime_type") or file_item.get("mimeType") or ""),
"data": file_item.get("data"),
}
)
return doc return doc
try: if os.path.isfile(abs_path_str):
with open(abs_path_str, "r", encoding="utf-8", errors="replace") as fh: try:
content = fh.read() with open(abs_path_str, "r", encoding="utf-8", errors="replace") as fh:
except Exception: content = fh.read()
content = "" except Exception:
normalized_script = (content or "").replace("\r\n", "\n") content = ""
doc["script"] = normalized_script doc["script"] = (content or "").replace("\r\n", "\n")
else:
doc["script"] = ""
return doc return doc
@@ -390,6 +400,10 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None:
blueprint = Blueprint("assemblies_execution", __name__) blueprint = Blueprint("assemblies_execution", __name__)
service_log = adapters.service_log service_log = adapters.service_log
assembly_cache = adapters.context.assembly_cache
if assembly_cache is None:
raise RuntimeError("Assembly cache is not initialised; ensure Engine bootstrap executed.")
assembly_runtime = AssemblyRuntimeService(assembly_cache, logger=adapters.context.logger)
@blueprint.route("/api/scripts/quick_run", methods=["POST"]) @blueprint.route("/api/scripts/quick_run", methods=["POST"])
def scripts_quick_run(): def scripts_quick_run():
@@ -406,34 +420,65 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None:
rel_path_canonical = rel_path_normalized rel_path_canonical = rel_path_normalized
assembly_source = "runtime"
assembly_guid: Optional[str] = None
abs_path_str = rel_path_canonical
doc: Optional[Dict[str, Any]] = None
record: Optional[Dict[str, Any]] = None
try: try:
scripts_root = _scripts_root() record = assembly_runtime.resolve_document_by_source_path(rel_path_canonical)
assemblies_root = scripts_root.parent.resolve() except Exception:
abs_path = (assemblies_root / rel_path_canonical).resolve() record = None
except Exception as exc: # pragma: no cover - defensive guard if record:
service_log( payload_doc = record.get("payload_json")
"assemblies", if not isinstance(payload_doc, dict):
f"quick job failed to resolve script path={rel_path_input!r}: {exc}", raw_payload = record.get("payload")
level="ERROR", if isinstance(raw_payload, str):
) try:
return jsonify({"error": "Failed to resolve script path"}), 500 payload_doc = json.loads(raw_payload)
except Exception:
payload_doc = None
if isinstance(payload_doc, dict):
doc = _load_assembly_document(rel_path_canonical, "powershell", payload=payload_doc)
if doc:
metadata_block = doc.get("metadata") if isinstance(doc.get("metadata"), dict) else {}
if isinstance(metadata_block, dict):
assembly_guid = metadata_block.get("assembly_guid")
if not doc.get("name"):
doc["name"] = record.get("display_name") or doc.get("name")
if doc is None:
assembly_source = "filesystem"
try:
scripts_root = _scripts_root()
assemblies_root = scripts_root.parent.resolve()
abs_path = (assemblies_root / rel_path_canonical).resolve()
except Exception as exc: # pragma: no cover - defensive guard
service_log(
"assemblies",
f"quick job failed to resolve script path={rel_path_input!r}: {exc}",
level="ERROR",
)
return jsonify({"error": "Failed to resolve script path"}), 500
scripts_root_str = str(scripts_root) scripts_root_str = str(scripts_root)
abs_path_str = str(abs_path) abs_path_str = str(abs_path)
try: try:
within_scripts = os.path.commonpath([scripts_root_str, abs_path_str]) == scripts_root_str within_scripts = os.path.commonpath([scripts_root_str, abs_path_str]) == scripts_root_str
except ValueError: except ValueError:
within_scripts = False within_scripts = False
if not within_scripts or not os.path.isfile(abs_path_str): if not within_scripts or not os.path.isfile(abs_path_str):
service_log( service_log(
"assemblies", "assemblies",
f"quick job requested missing or out-of-scope script input={rel_path_input!r} normalized={rel_path_canonical}", f"quick job requested missing or out-of-scope script input={rel_path_input!r} normalized={rel_path_canonical}",
level="WARNING", level="WARNING",
) )
return jsonify({"error": "Script not found"}), 404
doc = _load_assembly_document(abs_path_str, "powershell")
if not doc:
return jsonify({"error": "Script not found"}), 404 return jsonify({"error": "Script not found"}), 404
doc = _load_assembly_document(abs_path, "powershell")
script_type = (doc.get("type") or "powershell").lower() script_type = (doc.get("type") or "powershell").lower()
if script_type != "powershell": if script_type != "powershell":
return jsonify({"error": f"Unsupported script type '{script_type}'. Only PowerShell is supported."}), 400 return jsonify({"error": f"Unsupported script type '{script_type}'. Only PowerShell is supported."}), 400
@@ -476,7 +521,9 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None:
except Exception: except Exception:
timeout_seconds = 0 timeout_seconds = 0
friendly_name = (doc.get("name") or "").strip() or os.path.basename(abs_path) friendly_name = (doc.get("name") or "").strip()
if not friendly_name:
friendly_name = os.path.basename(rel_path_canonical)
now = int(time.time()) now = int(time.time())
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
socketio = getattr(adapters.context, "socketio", None) socketio = getattr(adapters.context, "socketio", None)
@@ -528,6 +575,10 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None:
payload["sig_alg"] = "ed25519" payload["sig_alg"] = "ed25519"
if signing_key_b64: if signing_key_b64:
payload["signing_key"] = signing_key_b64 payload["signing_key"] = signing_key_b64
context_block = payload.setdefault("context", {})
context_block["assembly_source"] = assembly_source
if assembly_guid:
context_block["assembly_guid"] = assembly_guid
socketio.emit("quick_job_run", payload) socketio.emit("quick_job_run", payload)
try: try:
@@ -546,7 +597,7 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None:
results.append({"hostname": host, "job_id": job_id, "status": "Running"}) results.append({"hostname": host, "job_id": job_id, "status": "Running"})
service_log( service_log(
"assemblies", "assemblies",
f"quick job queued hostname={host} path={rel_path_canonical} run_mode={run_mode}", f"quick job queued hostname={host} path={rel_path_canonical} run_mode={run_mode} source={assembly_source}",
) )
except Exception as exc: except Exception as exc:
if conn is not None: if conn is not None:

View File

@@ -20,12 +20,15 @@ from __future__ import annotations
import time import time
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List
from ...assemblies.service import AssemblyRuntimeService
from . import job_scheduler from . import job_scheduler
if TYPE_CHECKING: # pragma: no cover - typing aide if TYPE_CHECKING: # pragma: no cover - typing aide
from flask import Flask from flask import Flask
from .. import EngineServiceAdapters from .. import EngineServiceAdapters
def ensure_scheduler(app: "Flask", adapters: "EngineServiceAdapters"): def ensure_scheduler(app: "Flask", adapters: "EngineServiceAdapters"):
"""Instantiate the Engine job scheduler and attach it to the Engine context.""" """Instantiate the Engine job scheduler and attach it to the Engine context."""
@@ -36,6 +39,11 @@ def ensure_scheduler(app: "Flask", adapters: "EngineServiceAdapters"):
if socketio is None: if socketio is None:
raise RuntimeError("Socket.IO instance is required to initialise the scheduled job service.") raise RuntimeError("Socket.IO instance is required to initialise the scheduled job service.")
assembly_cache = adapters.context.assembly_cache
if assembly_cache is None:
raise RuntimeError("Assembly cache is required to initialise the scheduled job service.")
assembly_runtime = AssemblyRuntimeService(assembly_cache, logger=adapters.context.logger)
database_path = adapters.context.database_path database_path = adapters.context.database_path
script_signer = adapters.script_signer script_signer = adapters.script_signer
@@ -87,6 +95,7 @@ def ensure_scheduler(app: "Flask", adapters: "EngineServiceAdapters"):
database_path, database_path,
script_signer=script_signer, script_signer=script_signer,
service_logger=adapters.service_log, service_logger=adapters.service_log,
assembly_runtime=assembly_runtime,
) )
job_scheduler.set_online_lookup(scheduler, _online_hostnames_snapshot) job_scheduler.set_online_lookup(scheduler, _online_hostnames_snapshot)
scheduler.start() scheduler.start()

View File

@@ -85,6 +85,9 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
cursor = None cursor = None
broadcast_payload: Optional[Dict[str, Any]] = None broadcast_payload: Optional[Dict[str, Any]] = None
ctx_payload = data.get("context")
context_info: Optional[Dict[str, Any]] = ctx_payload if isinstance(ctx_payload, dict) else None
try: try:
conn = adapters.db_conn_factory() conn = adapters.db_conn_factory()
cursor = conn.cursor() cursor = conn.cursor()
@@ -105,10 +108,30 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
except sqlite3.Error: except sqlite3.Error:
link = None link = None
run_id: Optional[int] = None
scheduled_ts_ctx: Optional[int] = None
if link: if link:
try: try:
run_id = int(link[0]) run_id = int(link[0])
ts_now = _now_ts() except Exception:
run_id = None
if run_id is None and context_info:
ctx_run = context_info.get("scheduled_job_run_id") or context_info.get("run_id")
try:
if ctx_run is not None:
run_id = int(ctx_run)
except (TypeError, ValueError):
run_id = None
try:
if context_info.get("scheduled_ts") is not None:
scheduled_ts_ctx = int(context_info.get("scheduled_ts"))
except (TypeError, ValueError):
scheduled_ts_ctx = None
if run_id is not None:
ts_now = _now_ts()
try:
if status.lower() == "running": if status.lower() == "running":
cursor.execute( cursor.execute(
"UPDATE scheduled_job_runs SET status='Running', updated_at=? WHERE id=?", "UPDATE scheduled_job_runs SET status='Running', updated_at=? WHERE id=?",
@@ -125,13 +148,29 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
""", """,
(status, ts_now, ts_now, run_id), (status, ts_now, ts_now, run_id),
) )
if scheduled_ts_ctx is not None:
cursor.execute(
"UPDATE scheduled_job_runs SET scheduled_ts=COALESCE(scheduled_ts, ?) WHERE id=?",
(scheduled_ts_ctx, run_id),
)
conn.commit() conn.commit()
adapters.service_log(
"scheduled_jobs",
f"scheduled run update run_id={run_id} activity_id={job_id} status={status}",
)
except Exception as exc: # pragma: no cover - defensive guard except Exception as exc: # pragma: no cover - defensive guard
logger.debug( logger.debug(
"quick_job_result failed to update scheduled_job_runs for job_id=%s: %s", "quick_job_result failed to update scheduled_job_runs for job_id=%s run_id=%s: %s",
job_id, job_id,
run_id,
exc, exc,
) )
elif context_info:
adapters.service_log(
"scheduled_jobs",
f"scheduled run update skipped (no run_id) activity_id={job_id} status={status} context={context_info}",
level="WARNING",
)
try: try:
cursor.execute( cursor.execute(

View File

@@ -63,6 +63,33 @@ class AssemblyRuntimeService:
data = self._serialize_entry(entry, include_payload=True, payload_text=payload_text) data = self._serialize_entry(entry, include_payload=True, payload_text=payload_text)
return data return data
def resolve_document_by_source_path(
self,
source_path: str,
*,
include_payload: bool = True,
) -> Optional[Dict[str, Any]]:
"""Return an assembly record whose metadata source_path matches the provided value."""
normalized = _normalize_source_path(source_path)
if not normalized:
return None
lookup_key = normalized.lower()
try:
entries = self._cache.list_entries()
except Exception:
entries = []
for entry in entries:
metadata = entry.record.metadata or {}
candidate = _normalize_source_path(metadata.get("source_path"))
if not candidate:
continue
if candidate.lower() != lookup_key:
continue
payload_text = self._read_payload_text(entry.record.assembly_guid) if include_payload else None
return self._serialize_entry(entry, include_payload=include_payload, payload_text=payload_text)
return None
def export_assembly(self, assembly_guid: str) -> Dict[str, Any]: def export_assembly(self, assembly_guid: str) -> Dict[str, Any]:
entry = self._cache.get_entry(assembly_guid) entry = self._cache.get_entry(assembly_guid)
if not entry: if not entry:
@@ -328,6 +355,27 @@ def _payload_type_from_kind(kind: str) -> PayloadType:
return PayloadType.UNKNOWN return PayloadType.UNKNOWN
def _normalize_source_path(value: Any) -> str:
"""Normalise metadata source_path for comparison."""
if value is None:
return ""
text = str(value).replace("\\", "/").strip()
if not text:
return ""
segments = []
for part in text.split("/"):
candidate = part.strip()
if not candidate or candidate == ".":
continue
if candidate == "..":
return ""
segments.append(candidate)
if not segments:
return ""
return "/".join(segments)
def _serialize_payload(value: Any) -> str: def _serialize_payload(value: Any) -> str:
if isinstance(value, (dict, list)): if isinstance(value, (dict, list)):
return json.dumps(value, indent=2, sort_keys=True) return json.dumps(value, indent=2, sort_keys=True)