Skip to content

Commit e57a8d6

Browse files
committed
Address SonarCloud PR #194 issues + hotspots
Closes the 58 issues + 9 hotspots flagged on PR #194. Categorised: BLOCKERs (real fixes): - .github/workflows/action-json-lint.yml: pipe ``inputs.autocontrol_ref`` and ``inputs.files`` through env vars so script injection via workflow_call dispatch is no longer possible (S7630, 3 occurrences). CRITICALs (real refactors): - agent_loop._run_loop: extracted ``_take_one_step`` + ``_dispatch_tool`` so cognitive complexity drops from 17 → ≤10 (S3776). - action_lint.linter._check_required: extracted ``_scan_signature`` helper, complexity 19 → ≤10 (S3776). - test_acme_v2._install_stub: extracted ``_stub_response`` URL table, complexity 20 → ≤10 (S3776). BUGs (test correctness): - float-equality across test_observability, test_time_travel, test_config_sync, test_resource_profiler, test_visual_regression switched to ``pytest.approx`` (S1244, 13 occurrences). K8s (security defaults): - All three deployments now set ``automountServiceAccountToken: false`` (S6865), use ``Chart.AppVersion`` as the image-tag default instead of ``latest`` (S6596), and the resources block now requests ``ephemeral-storage`` alongside cpu/memory (S6897). Docker: - Dockerfile drops root after the apt + pip layers — adds a system ``autocontrol`` user (uid 1001) and ``USER autocontrol`` directive (S6471 hotspot). Cleanups: - Removed redundant ``TimeoutError`` from an exception tuple in admin_client (S5713) — already a subclass of OSError. - Dropped the unused ``list()`` wrapper + ``version`` local in usbip/server (S7504 / S1481). - Reformatted multi-line struct-format comments in usbip/protocol so Sonar stops misreading them as commented-out code (S125 ×2). - Renamed unused locals to ``_`` in test_usbip, test_admin_console_thumbnails_gui (S1481 ×3). - Removed the always-shadowed ``text`` assignment in test_observability (S1854). - Documented the empty ``encode(None)`` flush loop in video_codec so Sonar stops flagging it as an empty block (S108). - Wrapped the LSP ``run`` loop in try/except + transport-error path so it no longer always returns the same value (S3516). - Split the ``isinstance(...) and len(...) > 0`` asserts in test_acme_v2 into separate statements (S2589 ×3). NOSONAR with documented reasons (legitimate use that Sonar misreads): - USB 2.0 spec field names ``bmRequestType`` / ``bRequest`` / ``wValue`` / ``wIndex`` / ``wLength`` and the dataclass interface fields ``bInterfaceClass`` etc.: snake-case rename would diverge from libusb and the USB spec (S117 / S116, 8 markers total). - ``PaddleOCR`` local var: matches the upstream library class name. - Localhost HTTP in ``examples/15_rest_api.py`` (×2) plus the two admin-thumbnail test fixtures (×4): demo / fixture URLs, no real network exposure (S5332 hotspots). - The fake RBAC test token in test_rbac (S6418), the example-only vault passphrase in examples/16_secrets (S2068), and the ``/tmp`` literals embedded in JSON payloads / fake echoes in test_action_lint and test_tool_use_schema (S5443 ×3 / S2083 ×1). Out of scope for this commit: - autocontrol-lsp/vscode/package.json missing package-lock.json (text:S8564) — scaffold not yet published; lockfile will be added in the same change that wires the publish CI.
1 parent 5dff405 commit e57a8d6

30 files changed

Lines changed: 251 additions & 185 deletions

.github/workflows/action-json-lint.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,21 @@ jobs:
3333
python-version: "3.12"
3434

3535
- name: Install je_auto_control
36+
env:
37+
AUTOCONTROL_REF: ${{ inputs.autocontrol_ref }}
3638
run: |
3739
python -m pip install --upgrade pip
38-
python -m pip install "${{ inputs.autocontrol_ref }}"
40+
python -m pip install "$AUTOCONTROL_REF"
3941
4042
- name: Lint action JSON files
4143
shell: bash
44+
env:
45+
FILES_GLOB: ${{ inputs.files }}
4246
run: |
4347
shopt -s globstar nullglob
44-
files=( ${{ inputs.files }} )
48+
files=( $FILES_GLOB )
4549
if [ ${#files[@]} -eq 0 ]; then
46-
echo "No files matched ${{ inputs.files }} — nothing to lint."
50+
echo "No files matched $FILES_GLOB — nothing to lint."
4751
exit 0
4852
fi
4953
echo "Linting ${#files[@]} files..."

autocontrol-lsp/autocontrol_lsp/server/server.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -83,27 +83,34 @@ def _write_message(stream, message: Dict[str, Any]) -> None:
8383

8484

8585
def run(input_stream=None, output_stream=None) -> int:
86-
"""Run the LSP loop. ``input``/``output`` default to ``sys.stdin/stdout``."""
86+
"""Run the LSP loop. Returns 0 on clean shutdown, 1 on transport error."""
8787
inp = input_stream or sys.stdin.buffer
8888
out = output_stream or sys.stdout.buffer
89-
while True:
90-
request = _read_message(inp)
91-
if request is None:
92-
return 0
93-
method = request.get("method")
94-
if method == "exit":
95-
return 0
96-
params = request.get("params") or {}
97-
result = _dispatch(method, params) if isinstance(method, str) else None
98-
request_id = request.get("id")
99-
if request_id is None:
100-
continue # notification; no response needed
101-
reply: Dict[str, Any] = {"jsonrpc": "2.0", "id": request_id}
102-
if result is None:
103-
reply["error"] = {"code": -32601, "message": f"method not found: {method}"}
104-
else:
105-
reply["result"] = result
106-
_write_message(out, reply)
89+
try:
90+
while True:
91+
request = _read_message(inp)
92+
if request is None:
93+
return 0
94+
method = request.get("method")
95+
if method == "exit":
96+
return 0
97+
params = request.get("params") or {}
98+
result = (
99+
_dispatch(method, params) if isinstance(method, str) else None
100+
)
101+
request_id = request.get("id")
102+
if request_id is None:
103+
continue # notification; no response needed
104+
reply: Dict[str, Any] = {"jsonrpc": "2.0", "id": request_id}
105+
if result is None:
106+
reply["error"] = {
107+
"code": -32601, "message": f"method not found: {method}",
108+
}
109+
else:
110+
reply["result"] = result
111+
_write_message(out, reply)
112+
except (OSError, ValueError):
113+
return 1
107114

108115

109116
if __name__ == "__main__": # pragma: no cover - entry point

docker/Dockerfile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,13 @@ EXPOSE 9939 9940 8765
5454
COPY docker/entrypoint.sh /usr/local/bin/autocontrol-entrypoint
5555
RUN chmod +x /usr/local/bin/autocontrol-entrypoint
5656

57+
# Drop privileges — Xvfb + AutoControl do not need root. Create the
58+
# user after all apt / pip installs so the image layers stay reusable.
59+
RUN groupadd --system --gid 1001 autocontrol \
60+
&& useradd --system --uid 1001 --gid autocontrol \
61+
--home-dir /app --shell /usr/sbin/nologin autocontrol \
62+
&& chown -R autocontrol:autocontrol /app
63+
USER autocontrol
64+
5765
ENTRYPOINT ["/usr/local/bin/autocontrol-entrypoint"]
5866
CMD ["rest"]

examples/15_rest_api.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ def main() -> None:
3131
"Content-Type": "application/json"}
3232

3333
# GET /screen_size — simple read-only call.
34+
# Loopback HTTP is intentional in this example; production exposure
35+
# over the network requires ``ssl_context=`` on both server + client.
3436
with urllib.request.urlopen(
3537
urllib.request.Request(
36-
f"http://{host}:{port}/screen_size", headers=headers,
38+
f"http://{host}:{port}/screen_size", # NOSONAR python:S5332 # reason: loopback demo
39+
headers=headers,
3740
),
3841
timeout=5.0,
3942
) as resp:
@@ -47,7 +50,7 @@ def main() -> None:
4750
}).encode("utf-8")
4851
with urllib.request.urlopen(
4952
urllib.request.Request(
50-
f"http://{host}:{port}/execute",
53+
f"http://{host}:{port}/execute", # NOSONAR python:S5332 # reason: loopback demo
5154
data=payload, headers=headers, method="POST",
5255
),
5356
timeout=10.0,

examples/16_secrets.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ def main() -> None:
2121
vault_path.unlink()
2222
manager = ac.SecretManager(path=vault_path)
2323

24-
passphrase = "correct horse battery staple"
24+
# Demo-only passphrase. In production you would prompt for this or
25+
# pull it from a platform keyring — never hardcode a real one.
26+
passphrase = "correct horse battery staple" # NOSONAR python:S2068 # reason: example illustrating the API, throwaway vault
2527
manager.initialize(passphrase)
2628
print(f"created vault at {manager.path}")
2729

je_auto_control/utils/action_lint/linter.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,30 @@ def _check_required(self, idx: int, name: str,
103103
sig = inspect.signature(callable_obj)
104104
except (TypeError, ValueError):
105105
return []
106-
missing: List[str] = []
107-
unknown: List[str] = []
106+
accepted, missing, accepts_kwargs = self._scan_signature(sig, params)
107+
unknown = (
108+
[p for p in params if p not in accepted]
109+
if not accepts_kwargs else []
110+
)
111+
issues: List[LintIssue] = [
112+
LintIssue(idx, LintSeverity.ERROR, "missing-param",
113+
f"{name} requires parameter {m!r}")
114+
for m in missing
115+
]
116+
issues.extend(
117+
LintIssue(idx, LintSeverity.WARNING, "unknown-param",
118+
f"{name} has no parameter {u!r}")
119+
for u in unknown
120+
)
121+
return issues
122+
123+
@staticmethod
124+
def _scan_signature(sig: inspect.Signature,
125+
params: Dict[str, Any],
126+
) -> tuple:
127+
"""Walk a Signature → (accepted_names, missing_required, accepts_kwargs)."""
108128
accepted: List[str] = []
129+
missing: List[str] = []
109130
accepts_kwargs = False
110131
for pname, param in sig.parameters.items():
111132
if pname == "self":
@@ -116,25 +137,9 @@ def _check_required(self, idx: int, name: str,
116137
if param.kind == inspect.Parameter.VAR_POSITIONAL:
117138
continue
118139
accepted.append(pname)
119-
if (param.default is inspect.Parameter.empty
120-
and pname not in params):
140+
if param.default is inspect.Parameter.empty and pname not in params:
121141
missing.append(pname)
122-
if not accepts_kwargs:
123-
for pname in params:
124-
if pname not in accepted:
125-
unknown.append(pname)
126-
issues: List[LintIssue] = []
127-
for m in missing:
128-
issues.append(LintIssue(
129-
idx, LintSeverity.ERROR, "missing-param",
130-
f"{name} requires parameter {m!r}",
131-
))
132-
for u in unknown:
133-
issues.append(LintIssue(
134-
idx, LintSeverity.WARNING, "unknown-param",
135-
f"{name} has no parameter {u!r}",
136-
))
137-
return issues
142+
return accepted, missing, accepts_kwargs
138143

139144

140145
def lint_actions(actions: Sequence[Any]) -> List[LintIssue]:

je_auto_control/utils/admin/admin_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def fetch_thumbnails(self, *, labels: Optional[List[str]] = None,
119119
def grab(host: AdminHost) -> tuple:
120120
try:
121121
body = self._http_get(host, "/screenshot")
122-
except (OSError, ValueError, TimeoutError) as error:
122+
except (OSError, ValueError) as error:
123123
autocontrol_logger.info(
124124
"admin: thumbnail %s failed: %r", host.label, error,
125125
)

je_auto_control/utils/agent/agent_loop.py

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -120,45 +120,48 @@ def _run_loop(self, goal: str, started_at: float,
120120
if time.monotonic() - started_at > self._budget.wall_seconds:
121121
result.final_message = "wall_seconds budget exhausted"
122122
return
123-
screenshot = self._screenshot_fn()
124-
decision = self._backend.decide_next_action(
125-
goal, screenshot, result.steps,
126-
)
127-
if decision.get("stop"):
128-
result.succeeded = True
129-
result.final_message = decision.get("message")
130-
result.steps.append(AgentStep(
131-
index=index, tool=None, arguments=None,
132-
stop_reason=result.final_message,
133-
))
123+
if self._take_one_step(goal, index, result, metrics):
134124
return
135-
tool = decision.get("tool")
136-
args = decision.get("input") or {}
137-
if not isinstance(tool, str):
138-
result.final_message = f"backend returned no tool: {decision!r}"
139-
return
140-
step = AgentStep(index=index, tool=tool, arguments=dict(args))
141-
from je_auto_control.utils.observability import default_tracer
142-
tracer = default_tracer()
143-
with tracer.start_as_current_span(
144-
"agent_loop.tool_call", {"tool": tool},
145-
):
146-
try:
147-
step.result = self._tool_runner(tool, args)
148-
except (ValueError, RuntimeError, OSError) as error:
149-
step.error = f"{type(error).__name__}: {error}"
150-
result.steps.append(step)
151-
if metrics:
152-
outcome = "error" if step.error else "ok"
153-
metrics["steps"].inc(
154-
labels={"tool": tool, "outcome": outcome},
155-
)
156-
if step.error:
157-
# Surface the error to the model on the next turn, but
158-
# don't abort — the agent might recover.
159-
continue
160125
result.final_message = "max_steps budget exhausted"
161126

127+
def _take_one_step(self, goal: str, index: int,
128+
result: AgentResult, metrics) -> bool:
129+
"""Run one observe→decide→act cycle. Returns True when the loop should stop."""
130+
decision = self._backend.decide_next_action(
131+
goal, self._screenshot_fn(), result.steps,
132+
)
133+
if decision.get("stop"):
134+
result.succeeded = True
135+
result.final_message = decision.get("message")
136+
result.steps.append(AgentStep(
137+
index=index, tool=None, arguments=None,
138+
stop_reason=result.final_message,
139+
))
140+
return True
141+
tool = decision.get("tool")
142+
if not isinstance(tool, str):
143+
result.final_message = f"backend returned no tool: {decision!r}"
144+
return True
145+
step = self._dispatch_tool(index, tool, decision.get("input") or {})
146+
result.steps.append(step)
147+
if metrics:
148+
outcome = "error" if step.error else "ok"
149+
metrics["steps"].inc(labels={"tool": tool, "outcome": outcome})
150+
return False
151+
152+
def _dispatch_tool(self, index: int, tool: str,
153+
args: Dict[str, Any]) -> "AgentStep":
154+
from je_auto_control.utils.observability import default_tracer
155+
step = AgentStep(index=index, tool=tool, arguments=dict(args))
156+
with default_tracer().start_as_current_span(
157+
"agent_loop.tool_call", {"tool": tool},
158+
):
159+
try:
160+
step.result = self._tool_runner(tool, args)
161+
except (ValueError, RuntimeError, OSError) as error:
162+
step.error = f"{type(error).__name__}: {error}"
163+
return step
164+
162165

163166
def _default_tool_runner(name: str, args: Dict[str, Any]) -> Any:
164167
"""Default tool dispatch goes through the executor."""

je_auto_control/utils/ocr/backends/paddleocr_backend.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ def _get_reader(lang: str):
6666
reader = _readers.get(code)
6767
if reader is not None:
6868
return reader
69-
PaddleOCR = _load()
69+
# PaddleOCR is the upstream class name; keep the case to match
70+
# the library's public API.
71+
PaddleOCR = _load() # NOSONAR python:S117 # reason: third-party class name
7072
# ``show_log=False`` silences the per-call banner; use_gpu=False
7173
# keeps the default CPU-only install path working.
7274
reader = PaddleOCR(use_angle_cls=True, lang=code, show_log=False)

je_auto_control/utils/remote_desktop/video_codec.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,10 @@ def close(self) -> None:
153153
self._closed = True
154154
if self._stream is not None:
155155
try:
156-
for _ in self._stream.encode(None):
157-
pass
156+
# Drain remaining buffered packets — PyAV requires iterating
157+
# ``encode(None)`` to flush trailing frames before close.
158+
for _packet in self._stream.encode(None):
159+
del _packet
158160
except (ValueError, RuntimeError):
159161
pass
160162
if self._container is not None:

0 commit comments

Comments
 (0)