agent-workflow-ui 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ """Localhost HTTP server for accepting form submits.
2
+
3
+ Runs in a daemon thread, started at plugin startup. Listens on 127.0.0.1 only.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import html
8
+ import json
9
+ import logging
10
+ import socket
11
+ import threading
12
+ import urllib.parse
13
+ from datetime import datetime, timezone
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from uuid import uuid4
18
+
19
+ from .config import Config
20
+ from .state import FormRegistry
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ MAX_BODY_BYTES = 1 * 1024 * 1024 # 1MB
25
+
26
+
27
+ def _find_free_port() -> int:
28
+ """Ask OS for a free port by binding to port 0."""
29
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
30
+ s.bind(("127.0.0.1", 0))
31
+ return s.getsockname()[1]
32
+
33
+
34
+ def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None:
35
+ """Write YAML atomically (temp file + rename)."""
36
+ import yaml
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+ tmp = path.with_suffix(f".{uuid4().hex}.tmp")
39
+ content = yaml.safe_dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
40
+ tmp.write_text(content, encoding="utf-8")
41
+ tmp.replace(path)
42
+
43
+
44
+ def _is_valid_form_id(form_id: str) -> bool:
45
+ """Check if form_id looks valid (FORM-* prefix, reasonable length).
46
+
47
+ Defense-in-depth against path traversal: form_id builds the submit file
48
+ path (``inputs_dir / f"{form_id}.yaml"``). The registry lookup is the
49
+ primary guard (IDs are server-generated), but reject path separators and
50
+ ``..`` here too so a crafted ID can never escape inputs_dir even if a
51
+ future code path bypasses the registry check.
52
+ """
53
+ if not form_id.startswith("FORM-") or not (6 < len(form_id) < 100):
54
+ return False
55
+ # P1: ASCII-only (prevents unicode filenames in inputs_dir)
56
+ if not form_id.isascii():
57
+ return False
58
+ # Legit IDs are FORM-<alphanumeric/hyphen> only.
59
+ if "/" in form_id or "\\" in form_id or ".." in form_id:
60
+ return False
61
+ return True
62
+
63
+
64
+ def _is_origin_allowed(origin: str, referer: str) -> bool:
65
+ """A2/KAUD-3: CSRF check — is this Origin/Referer combination allowed?
66
+
67
+ Uses urlparse to check hostname, not startswith (which allowed bypass
68
+ via http://127.0.0.1.evil.com).
69
+
70
+ Forms opened via file:// (opencode temp HTML) send Origin: "null"
71
+ (browser standard for sandboxed/local file origins).
72
+ """
73
+ from urllib.parse import urlparse
74
+
75
+ ALLOWED_HOSTS = {"127.0.0.1", "localhost"}
76
+
77
+ def _check_url(url: str) -> bool:
78
+ if not url:
79
+ return False
80
+ try:
81
+ parsed = urlparse(url)
82
+ return parsed.hostname in ALLOWED_HOSTS
83
+ except (ValueError, TypeError):
84
+ return False
85
+
86
+ # Origin "null" — local file:// page or sandboxed iframe. Server is
87
+ # 127.0.0.1-only, so this is always safe.
88
+ if origin == "null":
89
+ return True
90
+ if origin:
91
+ return _check_url(origin)
92
+ # No Origin header — check Referer if present (curl/non-browser has neither)
93
+ if referer:
94
+ if referer.startswith("file://"):
95
+ return True
96
+ return _check_url(referer)
97
+ return True # no Origin, no Referer — backward compat for curl
98
+
99
+
100
+ def _ack_page(form_id: str, already_submitted: bool) -> str:
101
+ """Generate HTML acknowledgement page shown after submit.
102
+
103
+ A3: rendered from render/default_templates/ack.html.j2 (was inline f-string).
104
+ """
105
+ from .render.engine import render_template
106
+ from .state import get_jinja_env
107
+
108
+ message = "Эта форма уже была отправлена ранее." if already_submitted else "Форма отправлена!"
109
+ try:
110
+ env = get_jinja_env()
111
+ return render_template(env, "ack", {
112
+ "form_id": form_id,
113
+ "message": message,
114
+ })
115
+ except Exception as e:
116
+ # Fallback: minimal HTML if template engine fails
117
+ return (
118
+ f"<!DOCTYPE html><html><body>"
119
+ f"<h1>{html.escape(message)}</h1>"
120
+ f"<p>ID: {html.escape(form_id)}</p>"
121
+ f"<!-- template render failed: {html.escape(str(e))} -->"
122
+ f"</body></html>"
123
+ )
124
+
125
+
126
+ class SubmitHandler(BaseHTTPRequestHandler):
127
+ """HTTP request handler for /submit/<form_id> and /health."""
128
+
129
+ # Injected via handler_class factory (see _make_handler_class)
130
+ inputs_dir: Path = None # type: ignore[assignment]
131
+ registry: FormRegistry = None # type: ignore[assignment]
132
+
133
+ def do_POST(self):
134
+ """Handle POST /submit/<form_id>."""
135
+ path = urllib.parse.urlparse(self.path).path
136
+ if not path.startswith("/submit/"):
137
+ self._send_text(404, "Not Found")
138
+ return
139
+
140
+ form_id = path[len("/submit/"):]
141
+ if not _is_valid_form_id(form_id):
142
+ self._send_text(404, f"Invalid form_id: {html.escape(form_id)}")
143
+ return
144
+
145
+ record = self.registry.get(form_id)
146
+ if record is None:
147
+ self._send_text(404, f"Form not found: {html.escape(form_id)}")
148
+ return
149
+
150
+ if record.status == "submitted":
151
+ self._send_html(200, _ack_page(form_id, already_submitted=True))
152
+ return
153
+
154
+ if record.status == "cancelled":
155
+ self._send_text(410, f"Form {form_id} was cancelled.")
156
+ return
157
+
158
+ # A2: CSRF protection — verify Origin/Referer is localhost or local file.
159
+ origin = self.headers.get("Origin", "")
160
+ referer = self.headers.get("Referer", "")
161
+ if not _is_origin_allowed(origin, referer):
162
+ self._send_text(403, f"Forbidden: Origin '{origin}' not allowed")
163
+ return
164
+
165
+ try:
166
+ content_length = int(self.headers.get("Content-Length", 0))
167
+ except (ValueError, TypeError):
168
+ self._send_text(400, "Invalid Content-Length header")
169
+ return
170
+ if content_length > MAX_BODY_BYTES:
171
+ # Drain request body before responding, otherwise client gets
172
+ # BrokenPipeError when server closes connection mid-write.
173
+ remaining = content_length
174
+ while remaining > 0:
175
+ chunk = self.rfile.read(min(remaining, 65536))
176
+ if not chunk:
177
+ break
178
+ remaining -= len(chunk)
179
+ self._send_text(413, "Payload Too Large (max 1MB)")
180
+ return
181
+ if content_length == 0:
182
+ self._send_text(400, "Empty body")
183
+ return
184
+
185
+ body = self.rfile.read(content_length).decode("utf-8", errors="replace")
186
+ parsed = urllib.parse.parse_qs(body, keep_blank_values=True)
187
+ data: dict[str, Any] = {}
188
+ for key, values in parsed.items():
189
+ if len(values) == 1:
190
+ data[key] = values[0]
191
+ else:
192
+ data[key] = values
193
+
194
+ payload = {
195
+ "form_id": form_id,
196
+ "template": record.template,
197
+ "submitted_at": datetime.now(timezone.utc).isoformat(),
198
+ "data": data,
199
+ }
200
+
201
+ inputs_dir = (record.project_dir / ".agentic" / "inputs") if record.project_dir else self.inputs_dir
202
+ inputs_dir.mkdir(parents=True, exist_ok=True)
203
+ target = inputs_dir / f"{form_id}.yaml"
204
+ # H4 fix: atomic check-and-set — claim the form before writing.
205
+ # Without this, two parallel POSTs could both pass the status check.
206
+ if not self.registry.claim_for_submit(form_id):
207
+ self._send_text(409, f"Form {form_id} is being submitted by another request or already processed.")
208
+ return
209
+
210
+ # QA-FIX: if _atomic_write_yaml fails (disk full, permission, etc.),
211
+ # rollback form status to "pending" — otherwise it stays stuck in
212
+ # "submitting" forever and user can't resubmit (claim_for_submit
213
+ # rejects anything != "pending"). finalize_submit below only runs
214
+ # on success.
215
+ try:
216
+ _atomic_write_yaml(target, payload)
217
+ import os as _os
218
+ _os.chmod(target, 0o600)
219
+ except OSError as e:
220
+ self.registry.update_status(form_id, "pending")
221
+ log.error("Failed to write submit file %s: %s", target, e)
222
+ self._send_text(500, "Server error: failed to persist submit. Please retry.")
223
+ return
224
+
225
+ # H4 fix: finalize submit status
226
+ self.registry.finalize_submit(form_id)
227
+
228
+ # Persist custom roles if requested (delegates to roles_processor)
229
+ # QA-D: only project-setup submit has role fields. Other templates
230
+ # (increment-planning, etc.) called process_role_saves/deletions as
231
+ # no-op but wasted cycles + log noise. Gate explicitly.
232
+ from .roles_processor import process_role_deletions, process_role_saves
233
+
234
+ if record.template == "project-setup":
235
+ process_role_saves(data, project_dir=record.project_dir)
236
+ process_role_deletions(data, project_dir=record.project_dir)
237
+
238
+ # Dogfood-7: increment-planning submit → persist via api.apply_increment_plan
239
+ if record.template == "increment-planning" and record.project_dir:
240
+ selected_variant_id = (data.get("selected_variant", "") or "").strip()
241
+ variants_json = data.get("variants_json", "") or "[]"
242
+ if selected_variant_id and selected_variant_id != "__reject__":
243
+ try:
244
+ import json as _json
245
+ variants = _json.loads(variants_json) if isinstance(variants_json, str) else variants_json
246
+ selected = next(
247
+ (v for v in variants if isinstance(v, dict) and v.get("id") == selected_variant_id),
248
+ None,
249
+ )
250
+ if selected:
251
+ # Build plan.md body from selected variant
252
+ plan_body = _build_plan_md_from_variant(selected, variants)
253
+ from awf.api import apply_increment_plan
254
+ apply_increment_plan(
255
+ record.project_dir,
256
+ plan_body,
257
+ selected_variant_id=selected_variant_id,
258
+ variants=variants,
259
+ )
260
+ log.info(
261
+ "Increment plan persisted: variant=%s → plan.md",
262
+ selected_variant_id,
263
+ )
264
+ except Exception as e:
265
+ log.error("apply_increment_plan failed: %s", e)
266
+
267
+ log.info("Submit received for %s, written to %s", form_id, target)
268
+
269
+ self._send_html(200, _ack_page(form_id, already_submitted=False))
270
+
271
+
272
+ def do_GET(self):
273
+ """Handle GET /health."""
274
+ path = urllib.parse.urlparse(self.path).path
275
+ if path == "/health":
276
+ self._send_json(200, {"status": "ok"})
277
+ return
278
+ self._send_text(404, "Not Found")
279
+
280
+ # Silence default logging to stdout (would corrupt MCP stdio protocol if it leaked)
281
+ def log_message(self, format, *args):
282
+ log.debug("HTTP %s - %s", self.address_string(), format % args)
283
+
284
+ # Helpers
285
+ def _send_html(self, code: int, body: str) -> None:
286
+ body_bytes = body.encode("utf-8")
287
+ self.send_response(code)
288
+ self.send_header("Content-Type", "text/html; charset=utf-8")
289
+ self.send_header("Content-Length", str(len(body_bytes)))
290
+ self.end_headers()
291
+ self.wfile.write(body_bytes)
292
+
293
+ def _send_text(self, code: int, body: str) -> None:
294
+ body_bytes = body.encode("utf-8")
295
+ self.send_response(code)
296
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
297
+ self.send_header("Content-Length", str(len(body_bytes)))
298
+ self.end_headers()
299
+ self.wfile.write(body_bytes)
300
+
301
+ def _send_json(self, code: int, data: dict) -> None:
302
+ body = json.dumps(data).encode("utf-8")
303
+ self.send_response(code)
304
+ self.send_header("Content-Type", "application/json; charset=utf-8")
305
+ self.send_header("Content-Length", str(len(body)))
306
+ self.end_headers()
307
+ self.wfile.write(body)
308
+
309
+
310
+ def _make_handler_class(inputs_dir: Path, registry: FormRegistry):
311
+ """Create a handler class with bound inputs_dir and registry."""
312
+ cls = type(
313
+ "BoundSubmitHandler",
314
+ (SubmitHandler,),
315
+ {"inputs_dir": inputs_dir, "registry": registry},
316
+ )
317
+ return cls
318
+
319
+
320
+ def start_http_server(config: Config, registry: FormRegistry) -> tuple[ThreadingHTTPServer, int]:
321
+ """Start HTTP server in background thread.
322
+
323
+ Args:
324
+ config: Plugin config (provides port, inputs_dir).
325
+ registry: Form registry (for form_id validation).
326
+
327
+ Returns:
328
+ Tuple (server, actual_port). actual_port == config.http_port if non-zero, else auto-selected.
329
+ """
330
+ port = config.http_port if config.http_port > 0 else _find_free_port()
331
+ handler_class = _make_handler_class(config.inputs_dir, registry)
332
+ server = ThreadingHTTPServer(("127.0.0.1", port), handler_class)
333
+ thread = threading.Thread(target=server.serve_forever, daemon=True, name="awf-ui-http")
334
+ thread.start()
335
+ log.info("HTTP server listening on http://127.0.0.1:%d", port)
336
+ return server, port
337
+
338
+
339
+
340
+ def _build_plan_md_from_variant(
341
+ selected: dict[str, Any],
342
+ all_variants: list[dict[str, Any]],
343
+ ) -> str:
344
+ """Build plan.md body from user-selected increment variant.
345
+
346
+ Supervisor's variant carries the decomposition. We render it as
347
+ markdown so plan.md is human-readable and supervisor can edit later.
348
+ """
349
+ title = selected.get("title", "Untitled plan")
350
+ strategy = selected.get("strategy", "")
351
+ description = selected.get("description", "")
352
+ increments = selected.get("increments", []) or []
353
+ pros = selected.get("pros", []) or []
354
+ cons = selected.get("cons", []) or []
355
+ est_todos = selected.get("estimated_todos")
356
+ est_time = selected.get("estimated_time")
357
+
358
+ lines: list[str] = [f"# Plan: {title}", ""]
359
+ if strategy:
360
+ lines.append(f"**Strategy:** {strategy}")
361
+ if description:
362
+ lines.append("")
363
+ lines.append(description)
364
+ lines.append("")
365
+ if est_todos or est_time:
366
+ meta_parts = []
367
+ if est_todos:
368
+ meta_parts.append(f"~{est_todos} TODOs")
369
+ if est_time:
370
+ meta_parts.append(f"~{est_time}")
371
+ lines.append(f"_Estimated: {', '.join(meta_parts)}_")
372
+ lines.append("")
373
+
374
+ lines.append("## Increments")
375
+ lines.append("")
376
+ for i, inc in enumerate(increments, start=1):
377
+ name = inc.get("name", f"Increment {i}")
378
+ goal = inc.get("goal", "")
379
+ artefacts = inc.get("artefacts", []) or []
380
+ lines.append(f"### {i}. {name}")
381
+ if goal:
382
+ lines.append("")
383
+ lines.append(goal)
384
+ if artefacts:
385
+ lines.append("")
386
+ lines.append(f"**Artefacts:** {', '.join(artefacts)}")
387
+ lines.append("")
388
+
389
+ if pros or cons:
390
+ lines.append("## Trade-offs")
391
+ lines.append("")
392
+ if pros:
393
+ lines.append("**Pros:**")
394
+ for p in pros:
395
+ lines.append(f"- {p}")
396
+ lines.append("")
397
+ if cons:
398
+ lines.append("**Cons:**")
399
+ for c in cons:
400
+ lines.append(f"- {c}")
401
+ lines.append("")
402
+
403
+ # Other variants as historical reference
404
+ if len(all_variants) > 1:
405
+ other = [v for v in all_variants if v.get("id") != selected.get("id")]
406
+ lines.append("## Other variants considered")
407
+ lines.append("")
408
+ for v in other:
409
+ lines.append(f"- **{v.get('id', '?')}**: {v.get('title', 'untitled')}")
410
+ lines.append("")
411
+
412
+ return "\n".join(lines)