refactor: primitives-only client, drop create_ar_flow factory

This commit is contained in:
2026-03-04 17:36:02 +00:00
parent 55ad444c04
commit e827c18433

View File

@@ -1,10 +1,9 @@
#!/usr/bin/env python3
"""
st_client.py — SecureTransport AR Flow Client Library
st_client.py — SecureTransport REST API Primitives
Abstracts the ST REST API quirks so callers work with typed objects,
not raw JSON payloads. All known field requirements, mandatory defaults,
and undocumented formats are encoded here.
Thin, correct bindings to the ST v2.0 REST API.
No flow architecture opinions here — composition is the caller's responsibility.
Usage:
import sys
@@ -12,28 +11,37 @@ Usage:
from st_client import STClient, CompressStep, PgpEncryptStep, SendToPartnerStep
st = STClient("192.168.0.245", "admin", "openclaw")
flow = st.create_ar_flow(
name="floweng",
account="conan",
folder="/floweng",
steps=[
PgpEncryptStep(filter="*.jpg", key="test-pgp-public", key_owner="conan"),
snap = st.snapshot()
app_id = st.create_application("myflow-ar")
sub_id = st.create_subscription("conan", "/myflow", "myflow-ar")
simple_id = st.create_simple_route("myflow-simple", steps=[
PgpEncryptStep(filter="*.jpg", key="my-pgp-cert", key_owner="conan"),
CompressStep(filter="*.txt"),
SendToPartnerStep(site="clawdbox-partner-site-1771557836"),
]
)
st.delete_ar_flow("floweng")
SendToPartnerStep(site="my-site", target_account="partner"),
])
template_id = st.create_template_route("myflow-template", simple_id)
composite_id = st.create_composite_route("myflow-composite", template_id)
st.link_subscription(composite_id, sub_id)
Known API quirks encoded here (so you don't rediscover them):
- TEMPLATE route: must use data= not json= to avoid newline parse issue in conditionType
- conditionType must be explicit on every route and step; null causes NPE at runtime
- SendToPartner: transferSiteExpression requires #!#CVD#!# suffix for LIST type
- PgpEncryption: encryptKeyExpressionType only accepts ALIAS or EXPRESSION_WILDCARD
- PgpEncryption: cert must have accessLevel=PUBLIC and account=<owner> or routing engine can't see it
- Compress: singleArchiveEnabled=null treated as true by UI; always set explicitly
- Compress: compressionType "0" on PgpEncryptStep disables PGP-level compression (not a bug)
- SendToPartner: target_account controls whose sites are used — must match site owner's account
- POST /applications returns empty body on success; follow-up GET required for ID
"""
import sys
import json
import time
import io
import logging
sys.path.insert(0, '/home/node/.openclaw/site-packages')
import requests
import paramiko
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
@@ -42,7 +50,7 @@ log = logging.getLogger("st_client")
# ---------------------------------------------------------------------------
# Step definitions
# Step definitions — encode all field quirks, expose only meaningful params
# ---------------------------------------------------------------------------
class CompressStep:
@@ -55,7 +63,7 @@ class CompressStep:
self.on_failure = on_failure
def to_api(self):
payload = {
return {
"type": "Compress",
"status": "ENABLED",
"conditionType": "ALWAYS",
@@ -64,22 +72,22 @@ class CompressStep:
"fileFilterExpressionType": "GLOB",
"usePrecedingStepFiles": False,
"compressionType": "ZIP",
# Empirically validated values: STORE FASTEST FAST NORMAL GOOD BETTER BEST
# Validated values: STORE FASTEST FAST NORMAL GOOD BETTER BEST
"compressionLevel": self.level,
# null is treated as true by the UI — always set explicitly
"singleArchiveEnabled": self.single_archive,
"zipPassword": "",
}
return payload
class PgpEncryptStep:
"""PGP-encrypt matching files. Non-matching files pass through."""
"""PGP-encrypt matching files. Cert must be PUBLIC and owned by key_owner."""
def __init__(self, filter="*", key=None, key_owner=None,
ascii_armour=False, on_failure="PROCEED"):
if not key:
raise ValueError("PgpEncryptStep requires key (cert alias name)")
if not key_owner:
raise ValueError("PgpEncryptStep requires key_owner (account name)")
raise ValueError("PgpEncryptStep requires key_owner (account name that owns the cert)")
self.filter = filter
self.key = key
self.key_owner = key_owner
@@ -96,27 +104,36 @@ class PgpEncryptStep:
"fileFilterExpressionType": "GLOB",
"usePrecedingStepFiles": False,
# ALIAS = use cert name directly; EXPRESSION_WILDCARD = EL expression
# No other values accepted — will silently fail with PROCEED
"encryptKeyExpression": self.key,
"encryptKeyExpressionType": "ALIAS",
"encryptKeyOwnerExpression": self.key_owner,
"encryptKeyOwnerExpressionType": "NAME",
# "0" disables PGP-level compression (use Compress step instead)
# "0" disables PGP-level compression (ZIP handled by CompressStep)
"compressionType": "0",
"useAsciiArmour": self.ascii_armour,
}
class SendToPartnerStep:
"""Deliver all files in route payload to a partner transfer site."""
def __init__(self, site=None, filter="*", on_failure="FAIL"):
"""Deliver files to a partner transfer site.
target_account: the account that owns the transfer site. The AR flow runs
in the inbound account's context — if the site belongs to a different account
(e.g. a dedicated partner account), set target_account to that account name.
Omit if the site is owned by the same account running the flow.
"""
def __init__(self, site=None, filter="*", target_account=None,
on_failure="FAIL"):
if not site:
raise ValueError("SendToPartnerStep requires site (transfer site name)")
self.site = site
self.filter = filter
self.target_account = target_account
self.on_failure = on_failure
def to_api(self):
return {
payload = {
"type": "SendToPartner",
"status": "ENABLED",
"conditionType": "ALWAYS",
@@ -124,98 +141,68 @@ class SendToPartnerStep:
"fileFilterExpression": self.filter,
"fileFilterExpressionType": "GLOB",
"usePrecedingStepFiles": False,
# API requires LIST type with #!#CVD#!# suffix — not documented publicly
# LIST type requires #!#CVD#!# suffix — not in public docs
"transferSiteExpression": f"{self.site}#!#CVD#!#",
"transferSiteExpressionType": "LIST",
}
if self.target_account:
payload["targetAccountExpression"] = self.target_account
payload["targetAccountExpressionType"] = "NAME"
return payload
# ---------------------------------------------------------------------------
# AR Flow result object
# ---------------------------------------------------------------------------
class ARFlow:
"""Holds IDs for all objects in a created AR flow."""
def __init__(self, name, app_id, sub_id, simple_id, template_id, composite_id):
self.name = name
self.app_id = app_id
self.sub_id = sub_id
self.simple_id = simple_id
self.template_id = template_id
self.composite_id = composite_id
def __repr__(self):
return (f"ARFlow({self.name!r}, app={self.app_id}, sub={self.sub_id}, "
f"simple={self.simple_id}, template={self.template_id}, "
f"composite={self.composite_id})")
# ---------------------------------------------------------------------------
# Main client
# Main client — primitives only
# ---------------------------------------------------------------------------
class STClient:
"""
SecureTransport REST API client.
SecureTransport REST API client — primitive operations only.
Handles auth, TLS, empty-body responses, and the AR object graph.
All API quirks are encoded here — callers work with typed objects.
Handles auth, TLS quirks, and empty-body responses.
Callers are responsible for object ordering and composition.
"""
def __init__(self, host, user, password, port=444, verify=False):
self.base = f"https://{host}:{port}/api/v2.0"
self.base_v14 = f"https://{host}:{port}/api/v1.4"
self.session = requests.Session()
self.session.auth = (user, password)
self.session.verify = verify
self.session.headers.update({"Accept": "application/json",
"Content-Type": "application/json"})
self._host = host
self._port = port
# -- Low-level helpers --------------------------------------------------
# -- Low-level HTTP helpers ---------------------------------------------
def _get(self, path, v14=False):
base = self.base_v14 if v14 else self.base
r = self.session.get(f"{base}{path}")
def _get(self, path):
r = self.session.get(f"{self.base}{path}")
r.raise_for_status()
return r.json()
def _post(self, path, body):
r = self.session.post(f"{self.base}{path}", json=body)
if r.status_code not in (200, 201, 204):
try:
detail = r.json()
except Exception:
detail = r.text
raise RuntimeError(f"POST {path} failed {r.status_code}: {detail}")
# Some endpoints return empty body on success
if r.content.strip():
return r.json()
return {}
raise RuntimeError(f"POST {path}{r.status_code}: {_err(r)}")
return r.json() if r.content.strip() else {}
def _post_raw(self, path, raw_str):
"""POST with pre-serialised string body — avoids requests re-serialising."""
r = self.session.post(f"{self.base}{path}", data=raw_str)
if r.status_code not in (200, 201, 204):
raise RuntimeError(f"POST {path}{r.status_code}: {_err(r)}")
return r.json() if r.content.strip() else {}
def _put(self, path, body):
r = self.session.put(f"{self.base}{path}", json=body)
if r.status_code not in (200, 201, 204):
try:
detail = r.json()
except Exception:
detail = r.text
raise RuntimeError(f"PUT {path} failed {r.status_code}: {detail}")
if r.content.strip():
return r.json()
return {}
raise RuntimeError(f"PUT {path}{r.status_code}: {_err(r)}")
return r.json() if r.content.strip() else {}
def _delete(self, path):
r = self.session.delete(f"{self.base}{path}")
if r.status_code not in (200, 204):
try:
detail = r.json()
except Exception:
detail = r.text
raise RuntimeError(f"DELETE {path} failed {r.status_code}: {detail}")
raise RuntimeError(f"DELETE {path}{r.status_code}: {_err(r)}")
def _find_by_name(self, path, name):
def _find(self, path, name):
"""Return first result with matching name, or None."""
data = self._get(f"{path}?name={requests.utils.quote(name)}&limit=10")
for item in data.get("result", []):
@@ -223,281 +210,242 @@ class STClient:
return item
return None
# -- Snapshot -----------------------------------------------------------
# -- Introspection ------------------------------------------------------
def snapshot(self):
"""Return a summary of the live ST environment."""
accounts_raw = self._get("/accounts?limit=200").get("result", [])
sites_raw = self._get("/sites?limit=100").get("result", [])
certs_raw = self._get("/certificates?limit=200").get("result", [])
apps_raw = self._get("/applications?limit=100").get("result", [])
subs_raw = self._get("/subscriptions?limit=100").get("result", [])
"""Live environment summary — run before every build."""
accounts = self._get("/accounts?limit=200").get("result", [])
sites = self._get("/sites?limit=100").get("result", [])
certs = self._get("/certificates?limit=200").get("result", [])
apps = self._get("/applications?limit=100").get("result", [])
subs = self._get("/subscriptions?limit=100").get("result", [])
return {
"accounts": [
{"name": a["name"], "type": a.get("type"),
"locked": a.get("user", {}).get("locked", False)}
for a in accounts_raw if a.get("type") in ("user",)
for a in accounts if a.get("type") == "user"
],
"sites": [
{"name": s["name"], "protocol": s.get("protocol"),
{"name": s["name"], "account": s.get("account"),
"protocol": s.get("protocol"),
"host": s.get("host"), "port": s.get("port")}
for s in sites_raw
for s in sites
],
"certificates": [
{"name": c["name"], "type": c.get("type"),
"usage": c.get("usage"), "account": c.get("account"),
"accessLevel": c.get("accessLevel")}
for c in certs_raw
if c.get("type") in ("pgp", "ssh")
for c in certs if c.get("type") in ("pgp", "ssh")
],
"ar_applications": [
{"name": a["name"], "id": a["id"]}
for a in apps_raw
if a.get("type") == "AdvancedRouting"
for a in apps if a.get("type") == "AdvancedRouting"
],
"subscriptions": [
{"id": s["id"], "account": s.get("account"),
"folder": s.get("folder"), "application": s.get("application")}
for s in subs_raw
for s in subs
],
}
# -- AR Flow lifecycle --------------------------------------------------
def create_ar_flow(self, name, account, folder, steps,
pta_trigger=True):
def get_transfer_logs(self, limit=20, offset=0):
"""
Create a complete AR flow: Application → Subscription →
SIMPLE route → TEMPLATE route → COMPOSITE route → link.
Returns an ARFlow object with all created IDs.
Raises on any step failure — no partial state left unchecked.
Fetch transfer log entries. Correct endpoint: /logs/transfers
(not /transfers, /filetracking, or /logs/files — all 404).
Returns raw result list.
"""
app_name = f"{name}-ar"
simple_name = f"{name}-simple"
template_name = f"{name}-template"
composite_name = f"{name}-composite"
data = self._get(f"/logs/transfers?limit={limit}&offset={offset}")
return data.get("result", [])
log.info(f"Creating AR Application: {app_name}")
existing_app = self._find_by_name("/applications", app_name)
if existing_app:
log.info(f" Application already exists: {existing_app['id']}")
app_id = existing_app["id"]
else:
self._post("/applications", {"name": app_name, "type": "AdvancedRouting"})
app_id = self._find_by_name("/applications", app_name)["id"]
log.info(f" app_id={app_id}")
def get_transfer_detail(self, url_representation):
"""Fetch full transfer detail including subtransmission step breakdown."""
return self._get(f"/logs/transfers/{url_representation}")
log.info(f"Creating Subscription: account={account} folder={folder}")
existing_sub = None
# -- Applications -------------------------------------------------------
def create_application(self, name, app_type="AdvancedRouting"):
"""Create an AR application. Returns the application ID."""
existing = self._find("/applications", name)
if existing:
log.info(f"Application '{name}' already exists: {existing['id']}")
return existing["id"]
self._post("/applications", {"name": name, "type": app_type})
app = self._find("/applications", name)
log.info(f"Created application '{name}': {app['id']}")
return app["id"]
def delete_application(self, name):
"""Delete an application by name."""
app = self._find("/applications", name)
if app:
self._delete(f"/applications/{app['id']}")
log.info(f"Deleted application '{name}'")
# -- Subscriptions ------------------------------------------------------
def create_subscription(self, account, folder, app_name):
"""
Create an AR subscription linking account+folder to an application.
The PTA block below is required for client SFTP upload to trigger routing.
triggerFileOption='fail' is standard; submitFilenamePatternExpression
only affects server-initiated pulls, not client uploads.
Returns the subscription ID.
"""
subs = self._get(f"/subscriptions?account={account}&limit=100").get("result", [])
for s in subs:
if s.get("folder") == folder and s.get("application") == app_name:
existing_sub = s
break
if existing_sub:
log.info(f" Subscription already exists: {existing_sub['id']}")
sub_id = existing_sub["id"]
else:
pta = {
"ptaOnSuccessDoInAdvancedRoutingWildcardPull": pta_trigger,
"submitFilenamePatternExpression": "*",
"submitFilterType": "FILENAME_PATTERN",
"triggerFileOption": "fail",
"triggerOnSuccessfulWildcardPull": pta_trigger,
}
log.info(f"Subscription '{account}{folder}{app_name}' already exists: {s['id']}")
return s["id"]
self._post("/subscriptions", {
"type": "AdvancedRouting",
"folder": folder,
"account": account,
"application": app_name,
"postTransmissionActions": pta,
"postTransmissionActions": {
"ptaOnSuccessDoInAdvancedRoutingWildcardPull": True,
"submitFilenamePatternExpression": "*",
"submitFilterType": "FILENAME_PATTERN",
"triggerFileOption": "fail",
"triggerOnSuccessfulWildcardPull": True,
},
})
subs = self._get(f"/subscriptions?account={account}&limit=100").get("result", [])
sub_id = next(s["id"] for s in subs
sub = next(s for s in subs
if s.get("folder") == folder and s.get("application") == app_name)
log.info(f" sub_id={sub_id}")
log.info(f"Created subscription '{account}{folder}{app_name}': {sub['id']}")
return sub["id"]
log.info(f"Creating SIMPLE route: {simple_name}")
existing_simple = self._find_by_name("/routes", simple_name)
if existing_simple:
log.info(f" SIMPLE already exists: {existing_simple['id']}")
simple_id = existing_simple["id"]
else:
step_payloads = [s.to_api() for s in steps]
self._post("/routes", {
"name": simple_name,
def delete_subscription(self, sub_id):
"""Delete a subscription by ID."""
self._delete(f"/subscriptions/{sub_id}")
log.info(f"Deleted subscription {sub_id}")
# -- Routes -------------------------------------------------------------
def create_simple_route(self, name, steps, condition_type="ALWAYS", condition=None):
"""
Create a SIMPLE route with the given steps.
Steps are auto-chained in array order.
Returns the route ID.
condition_type: ALWAYS | EL
condition: EL expression string (only when condition_type=EL)
e.g. "${transfer.target.matches('.*\\\\.txt')}"
Note: double-escape the dot in Java regex within Python strings.
"""
existing = self._find("/routes", name)
if existing:
log.info(f"SIMPLE route '{name}' already exists: {existing['id']}")
return existing["id"]
body = {
"name": name,
"type": "SIMPLE",
"conditionType": "ALWAYS",
"steps": step_payloads,
})
simple_id = self._find_by_name("/routes", simple_name)["id"]
log.info(f" simple_id={simple_id}")
"conditionType": condition_type,
"steps": [s.to_api() for s in steps],
}
if condition:
body["condition"] = condition
self._post("/routes", body)
route = self._find("/routes", name)
log.info(f"Created SIMPLE route '{name}': {route['id']}")
return route["id"]
log.info(f"Creating TEMPLATE route: {template_name}")
existing_template = self._find_by_name("/routes", template_name)
if existing_template:
log.info(f" TEMPLATE already exists: {existing_template['id']}")
template_id = existing_template["id"]
else:
# TEMPLATE conditionType quirk: must be in payload but API rejects
# it as "unsupported parameter" in multiline payloads. Send as a
# pre-serialised string to avoid requests library re-serialising with
# newlines. Use data= not json= to control the wire format exactly.
template_payload = json.dumps({
"name": template_name,
def create_template_route(self, name, simple_route_id,
condition_type="MATCH_ALL"):
"""
Create a TEMPLATE route that executes a SIMPLE route.
Returns the route ID.
Quirk: must be sent as pre-serialised string (data= not json=) to
avoid requests adding newlines that break the conditionType parser.
"""
existing = self._find("/routes", name)
if existing:
log.info(f"TEMPLATE route '{name}' already exists: {existing['id']}")
return existing["id"]
payload = json.dumps({
"name": name,
"type": "TEMPLATE",
"conditionType": "MATCH_ALL",
"steps": [{"type": "ExecuteRoute", "status": "ENABLED",
"executeRoute": simple_id, "autostart": False}]
"conditionType": condition_type,
"steps": [{
"type": "ExecuteRoute",
"status": "ENABLED",
"executeRoute": simple_route_id,
"autostart": False,
}]
})
r = self.session.post(f"{self.base}/routes", data=template_payload)
if r.status_code not in (200, 201, 204):
raise RuntimeError(
f"POST TEMPLATE failed {r.status_code}: {r.text}")
template_id = self._find_by_name("/routes", template_name)["id"]
log.info(f" template_id={template_id}")
self._post_raw("/routes", payload)
route = self._find("/routes", name)
log.info(f"Created TEMPLATE route '{name}': {route['id']}")
return route["id"]
log.info(f"Creating COMPOSITE route: {composite_name}")
existing_composite = self._find_by_name("/routes", composite_name)
if existing_composite:
log.info(f" COMPOSITE already exists: {existing_composite['id']}")
composite_id = existing_composite["id"]
else:
def create_composite_route(self, name, template_id,
condition_type="MATCH_ALL"):
"""
Create a COMPOSITE route bound to a TEMPLATE.
Returns the route ID.
Subscriptions are linked separately via link_subscription().
"""
existing = self._find("/routes", name)
if existing:
log.info(f"COMPOSITE route '{name}' already exists: {existing['id']}")
return existing["id"]
self._post("/routes", {
"name": composite_name,
"name": name,
"type": "COMPOSITE",
"conditionType": "MATCH_ALL",
"conditionType": condition_type,
"routeTemplate": template_id,
"steps": [],
})
composite_id = self._find_by_name("/routes", composite_name)["id"]
log.info(f" composite_id={composite_id}")
route = self._find("/routes", name)
log.info(f"Created COMPOSITE route '{name}': {route['id']}")
return route["id"]
log.info(f"Linking COMPOSITE to subscription")
comp_full = self._get(f"/routes/{composite_id}")
if sub_id not in comp_full.get("subscriptions", []):
comp_full["subscriptions"] = [sub_id]
for field in ("id", "metadata", "steps", "routeTemplateName", "account"):
comp_full.pop(field, None)
self._put(f"/routes/{composite_id}", comp_full)
log.info(f" Linked.")
else:
log.info(f" Already linked.")
flow = ARFlow(name, app_id, sub_id, simple_id, template_id, composite_id)
log.info(f"Flow created: {flow}")
return flow
def delete_ar_flow(self, name):
"""
Delete a complete AR flow by name prefix.
Deletion order: unlink COMPOSITE → delete COMPOSITE →
delete TEMPLATE → delete SIMPLE → delete subscription → delete application.
"""
composite_name = f"{name}-composite"
template_name = f"{name}-template"
simple_name = f"{name}-simple"
app_name = f"{name}-ar"
# 1. Unlink and delete COMPOSITE
composite = self._find_by_name("/routes", composite_name)
if composite:
cid = composite["id"]
full = self._get(f"/routes/{cid}")
if full.get("subscriptions"):
full["subscriptions"] = []
def link_subscription(self, composite_id, sub_id):
"""Link a subscription to a COMPOSITE route."""
full = self._get(f"/routes/{composite_id}")
current = full.get("subscriptions", [])
if sub_id in current:
log.info(f"Subscription {sub_id} already linked to composite {composite_id}")
return
full["subscriptions"] = current + [sub_id]
# Remove read-only fields that cause PUT rejection
for field in ("id", "metadata", "steps", "routeTemplateName", "account"):
full.pop(field, None)
self._put(f"/routes/{cid}", full)
log.info(f"Unlinked COMPOSITE {cid}")
self._delete(f"/routes/{cid}")
log.info(f"Deleted COMPOSITE {cid}")
self._put(f"/routes/{composite_id}", full)
log.info(f"Linked subscription {sub_id} → composite {composite_id}")
# 2. Delete TEMPLATE
template = self._find_by_name("/routes", template_name)
if template:
self._delete(f"/routes/{template['id']}")
log.info(f"Deleted TEMPLATE {template['id']}")
def delete_route(self, route_id):
"""Delete a route by ID."""
self._delete(f"/routes/{route_id}")
log.info(f"Deleted route {route_id}")
# 3. Delete SIMPLE
simple = self._find_by_name("/routes", simple_name)
if simple:
self._delete(f"/routes/{simple['id']}")
log.info(f"Deleted SIMPLE {simple['id']}")
def get_route(self, name):
"""Fetch a route by name. Returns full object or None."""
return self._find("/routes", name)
# 4. Delete subscription
subs = self._get("/subscriptions?limit=100").get("result", [])
for s in subs:
if s.get("application") == app_name:
self._delete(f"/subscriptions/{s['id']}")
log.info(f"Deleted subscription {s['id']}")
# 5. Delete application
app = self._find_by_name("/applications", app_name)
if app:
self._delete(f"/applications/{app['id']}")
log.info(f"Deleted application {app['id']}")
log.info(f"Flow '{name}' deleted.")
# -- TM health check ----------------------------------------------------
def verify_routing(self, sftp_host, sftp_port, sftp_user, sftp_password,
upload_folder, dest_host, dest_port, dest_user, dest_key,
dest_folder, probe_filename="tm-probe.txt",
timeout=30):
def update_route(self, route_id, updates):
"""
Upload a probe file via SFTP, wait for routing to deliver it,
verify arrival at destination. Returns True on success.
Raises TMHealthError if TM does not process within timeout.
Apply a dict of field updates to a route via GET → merge → PUT.
Handles read-only field stripping automatically.
"""
probe_content = f"tm-probe {time.time()}\n".encode()
full = self._get(f"/routes/{route_id}")
full.update(updates)
for field in ("id", "metadata", "steps", "routeTemplateName", "account"):
full.pop(field, None)
return self._put(f"/routes/{route_id}", full)
log.info(f"Uploading probe: {upload_folder}/{probe_filename}")
transport = paramiko.Transport((sftp_host, sftp_port))
transport.connect(username=sftp_user, password=sftp_password)
sftp = paramiko.SFTPClient.from_transport(transport)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _err(response):
try:
sftp.chdir(upload_folder)
except IOError:
sftp.mkdir(upload_folder)
sftp.chdir(upload_folder)
sftp.putfo(io.BytesIO(probe_content), probe_filename)
sftp.close()
transport.close()
log.info(" Probe uploaded.")
log.info(f"Waiting for delivery to {dest_folder}/{probe_filename}")
deadline = time.time() + timeout
key = paramiko.RSAKey.from_private_key_file(dest_key) if dest_key else None
while time.time() < deadline:
time.sleep(3)
try:
t2 = paramiko.Transport((dest_host, dest_port))
if key:
t2.connect(username=dest_user, pkey=key)
else:
t2.connect(username=dest_user)
s2 = paramiko.SFTPClient.from_transport(t2)
files = s2.listdir(dest_folder)
s2.close(); t2.close()
if probe_filename in files:
log.info(f" Probe arrived. TM healthy.")
return True
except Exception as e:
log.debug(f" Destination check: {e}")
raise TMHealthError(
f"Probe '{probe_filename}' not found in '{dest_folder}' after {timeout}s. "
f"TM may not be running or subscription not registered. "
f"Try: ST Admin UI → Server Configuration → Transaction Manager → Restart"
)
class TMHealthError(Exception):
pass
return response.json()
except Exception:
return response.text
# ---------------------------------------------------------------------------
@@ -514,18 +462,26 @@ if __name__ == "__main__":
parser.add_argument("--user", default="admin")
parser.add_argument("--pass", dest="password", default="openclaw")
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("snapshot")
d = sub.add_parser("delete-flow")
d.add_argument("name")
log_p = sub.add_parser("logs")
log_p.add_argument("--limit", type=int, default=20)
log_p.add_argument("--offset", type=int, default=0)
args = parser.parse_args()
st = STClient(args.host, args.user, args.password, args.port)
if args.cmd == "snapshot":
print(json.dumps(st.snapshot(), indent=2))
elif args.cmd == "delete-flow":
st.delete_ar_flow(args.name)
elif args.cmd == "logs":
entries = st.get_transfer_logs(limit=args.limit, offset=args.offset)
for e in entries:
print(json.dumps({
"time": e.get("startTime"),
"account": e.get("account"),
"file": e.get("filename"),
"status": e.get("status"),
"dir": e.get("remoteDir"),
"app": e.get("application"),
}))
else:
parser.print_help()