lockwire 0.2.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.
Files changed (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/serve.py ADDED
@@ -0,0 +1,1256 @@
1
+ """lockwire's sidecar: the same core served over local HTTP.
2
+
3
+ **Private to the app, verbatim:** *"Token-bound, localhost
4
+ only, undocumented, may change with every release. The Python library and MCP are
5
+ the public doors. Can be opened later without breaking anyone."* Nothing
6
+ here carries a compatibility promise; a caller outside the app uses
7
+ ``lockwire.api``, the CLI's ``--json``, or MCP.
8
+
9
+ Two addresses, always: ``GET /v1/hello`` (are you alive, what can you do) and
10
+ ``POST /v1/run`` (everything else — one discriminated-union body naming the
11
+ command). The free commands are ``start``, ``check``, ``page``, ``declare-form``,
12
+ ``declare``, ``add``, ``review``, ``commit``, ``undo``, ``place``, ``move``,
13
+ ``rotate``, ``reparent``, ``mount-shape``, ``describe``, ``what-if``, ``plan``,
14
+ ``diff``, ``log`` and ``report``. A plugin adds its own through
15
+ ``plugins.SERVE_COMMANDS``; this module names none of them, and a command it does
16
+ not know is a 422, refused by the discriminated union before any lockwire code runs.
17
+ Robot mode lists the write commands but refuses each by name.
18
+
19
+ **The sidecar computes nothing of its own.** Each handler calls the
20
+ matching ``lockwire.api`` function and wraps the document it returns. ``report``
21
+ and ``page``'s scene lookup is ``api.load_scene`` (E1-at); ``page`` has no CLI
22
+ command (``api.page`` is not in ``FUNCTIONS`` — see its docstring), so it is not
23
+ one of the CLI-named twins either; it wraps ``api.page``, which wraps
24
+ ``page.build_payload``, unchanged.
25
+
26
+ The reply is always ``{version, cli_line, exit_code, verdict, payload,
27
+ refusal}``: a refusal (bad path, parse error, the CLI's own refusal) comes back
28
+ as HTTP 200 with ``exit_code``/``refusal`` set, never a traceback.
29
+
30
+ ``fastapi`` and ``uvicorn`` (the ``serve`` extra) are imported when the app is built, so
31
+ this module's request models import without them.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import datetime as dt
37
+ import hmac
38
+ import json
39
+ import logging
40
+ import logging.handlers
41
+ import os
42
+ import secrets
43
+ import sys
44
+ import threading
45
+ from pathlib import Path
46
+ from typing import TYPE_CHECKING, Annotated, Any, Callable, Literal, Union
47
+
48
+ from pydantic import BaseModel, ConfigDict, Field
49
+ from ruamel.yaml import YAMLError
50
+
51
+ from lockwire import __version__, api, plugins, vcs
52
+ from lockwire.cli_line import cli_line as _build_cli_line
53
+ from lockwire.edit import Change, contract_revision
54
+ from lockwire.plan import load_plan, validate_plan
55
+ from lockwire.report_html import write_html_report
56
+ from lockwire.report_html import _graph as _graph_view
57
+ from lockwire.scene import SceneRefusal
58
+ from lockwire.scene_urdf import load_scene_any
59
+ from lockwire.scene_view import scene_hash as _scene_hash
60
+ from lockwire.scene_view import scene_payload as _scene_payload
61
+ from lockwire.schema import ArtifactRef, load_contract
62
+
63
+ if TYPE_CHECKING:
64
+ import uvicorn
65
+ from fastapi import FastAPI
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # The reply shape (plan §3): always this, whichever command ran.
69
+
70
+
71
+ class RunReply(BaseModel):
72
+ version: str
73
+ cli_line: str
74
+ exit_code: int
75
+ verdict: str | None = None
76
+ payload: Any = None
77
+ refusal: str | None = None
78
+
79
+
80
+ class CommandInfo(BaseModel):
81
+ name: str
82
+ args_schema: dict[str, Any]
83
+
84
+
85
+ class HelloReply(BaseModel):
86
+ version: str
87
+ commands: list[CommandInfo]
88
+
89
+
90
+ def _ok(cli_line: str, *, exit_code: int, verdict: str | None, payload: Any) -> RunReply:
91
+ return RunReply(
92
+ version=__version__,
93
+ cli_line=cli_line,
94
+ exit_code=exit_code,
95
+ verdict=verdict,
96
+ payload=payload,
97
+ refusal=None,
98
+ )
99
+
100
+
101
+ def _refusal(cli_line: str, exit_code: int, message: str) -> RunReply:
102
+ return RunReply(
103
+ version=__version__,
104
+ cli_line=cli_line,
105
+ exit_code=exit_code,
106
+ verdict=None,
107
+ payload=None,
108
+ refusal=message,
109
+ )
110
+
111
+
112
+ def _document_reply(cli_line: str, document: dict[str, Any]) -> RunReply:
113
+ """Wrap a ``lockwire.api`` document. The verdict and the exit code are the
114
+ library's, read out of the document — the sidecar never decides either."""
115
+ return _ok(
116
+ cli_line,
117
+ exit_code=document["exit_code"],
118
+ verdict=document["verdict"],
119
+ payload=document,
120
+ )
121
+
122
+
123
+ def _no_contract(path: Path) -> str | None:
124
+ """The CLI's own words for a missing contract, so a refusal reads the same
125
+ through every door. ``None`` when the file is there and the failure is real."""
126
+ return None if path.exists() else f"no contract at {path}"
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # The command union. Each member's ``args`` fields mirror the matching typer
131
+ # command's arguments/options (names, defaults) from cli.py; a flag that only
132
+ # matters for a terminal session (``--open/--no-open``) is left off — the
133
+ # sidecar is headless and never opens a browser.
134
+
135
+
136
+ class _StrictArgs(BaseModel):
137
+ model_config = ConfigDict(extra="forbid")
138
+
139
+
140
+ class CheckArgs(_StrictArgs):
141
+ contract: str = "lockwire.yaml"
142
+
143
+
144
+ class SceneRequest(_StrictArgs):
145
+ """The caller's half of the geometry-behind-a-hash negotiation (e1-b2):
146
+ the sha256 (``scene_view.scene_hash``) of the geometry it already has."""
147
+
148
+ have: str | None = None
149
+
150
+
151
+ class PlanArgs(_StrictArgs):
152
+ contract: str
153
+ plan: str
154
+
155
+
156
+ class DescribeArgs(_StrictArgs):
157
+ contract: str = "lockwire.yaml"
158
+ out: str | None = None
159
+
160
+
161
+ class DeclareFormArgs(_StrictArgs):
162
+ contract: str = "lockwire.yaml"
163
+
164
+
165
+ class ReportArgs(_StrictArgs):
166
+ contract: str = "lockwire.yaml"
167
+ scene: str | None = None
168
+ package_root: list[str] = Field(default_factory=list)
169
+ out: str
170
+
171
+
172
+ class PageArgs(_StrictArgs):
173
+ """Same shape as ``GuiArgs`` minus ``out``: the page has nowhere to write to
174
+ here, it comes back in the reply's ``payload`` (e1-pg)."""
175
+
176
+ contract: str = "lockwire.yaml"
177
+ scene: str | None = None
178
+ package_root: list[str] = Field(default_factory=list)
179
+ plan: str | None = None
180
+
181
+
182
+ class LogArgs(_StrictArgs):
183
+ contract: str = "lockwire.yaml"
184
+ revision_range: str | None = None
185
+
186
+
187
+ class DiffArgs(_StrictArgs):
188
+ contract: str = "lockwire.yaml"
189
+ a: str = "HEAD"
190
+ b: str | None = None
191
+
192
+
193
+ class DeclareArgs(_StrictArgs):
194
+ contract: str
195
+ path: str
196
+ value: Any
197
+ source: str
198
+ witness: str
199
+ note: str | None = None
200
+ locked_at: str | None = None
201
+ dry_run: bool = False
202
+ expected_revision: str | None = None
203
+
204
+
205
+ class AddArgs(_StrictArgs):
206
+ contract: str
207
+ kind: str
208
+ id: str | None = None
209
+ fields: dict[str, Any] = Field(default_factory=dict)
210
+ source: str
211
+ witness: str
212
+ note: str | None = None
213
+ locked_at: str | None = None
214
+ dry_run: bool = False
215
+ expected_revision: str | None = None
216
+
217
+
218
+ class ReviewArgs(_StrictArgs):
219
+ contract: str
220
+ path: str
221
+ state: Literal["approved", "questioned"]
222
+ witness: str
223
+ note: str | None = None
224
+ locked_at: str | None = None
225
+ dry_run: bool = False
226
+ expected_revision: str | None = None
227
+
228
+
229
+ class CommitArgs(_StrictArgs):
230
+ contract: str
231
+ message: str
232
+ paths: list[str]
233
+ dry_run: bool = False
234
+ expected_revision: str | None = None
235
+
236
+
237
+ class UndoArgs(_StrictArgs):
238
+ contract: str
239
+ source: str = "HEAD"
240
+ apply: bool = False
241
+ expected_revision: str | None = None
242
+
243
+
244
+ class PlaceArgs(_StrictArgs):
245
+ contract: str
246
+ target: str
247
+ on: str
248
+ offset: tuple[float, float, float]
249
+ unit: str
250
+ source: str
251
+ witness: str
252
+ note: str | None = None
253
+ locked_at: str | None = None
254
+ dry_run: bool = False
255
+ expected_revision: str | None = None
256
+
257
+
258
+ class MoveArgs(_StrictArgs):
259
+ contract: str
260
+ target: str
261
+ offset: tuple[float, float, float]
262
+ unit: str
263
+ source: str
264
+ witness: str
265
+ note: str | None = None
266
+ locked_at: str | None = None
267
+ dry_run: bool = False
268
+ expected_revision: str | None = None
269
+
270
+
271
+ class RotateArgs(_StrictArgs):
272
+ contract: str
273
+ target: str
274
+ rpy: tuple[float, float, float]
275
+ unit: str
276
+ source: str
277
+ witness: str
278
+ note: str | None = None
279
+ locked_at: str | None = None
280
+ dry_run: bool = False
281
+ expected_revision: str | None = None
282
+
283
+
284
+ class ReparentArgs(_StrictArgs):
285
+ contract: str
286
+ target: str
287
+ to: str
288
+ source: str
289
+ witness: str
290
+ note: str | None = None
291
+ locked_at: str | None = None
292
+ dry_run: bool = False
293
+ expected_revision: str | None = None
294
+
295
+
296
+ class MountShape(_StrictArgs):
297
+ kind: Literal["plate", "bracket", "tube", "custom-mesh"]
298
+ dimensions_mm: tuple[float, float, float]
299
+ fastener_pattern: str | None = None
300
+ mesh_path: str | None = None
301
+
302
+
303
+ class MountShapeArgs(_StrictArgs):
304
+ contract: str
305
+ target: str
306
+ shape: MountShape
307
+ source: str
308
+ witness: str
309
+ note: str | None = None
310
+ locked_at: str | None = None
311
+ dry_run: bool = False
312
+ expected_revision: str | None = None
313
+
314
+
315
+ class StartArgs(_StrictArgs):
316
+ model_config = ConfigDict(extra="forbid", strict=True)
317
+
318
+ urdf: str
319
+ overwrite: bool = False
320
+ expected_revision: str | None = None
321
+
322
+
323
+ class StartCommand(BaseModel):
324
+ model_config = ConfigDict(extra="forbid")
325
+ command: Literal["start"] = "start"
326
+ args: StartArgs
327
+
328
+
329
+ class CheckCommand(BaseModel):
330
+ model_config = ConfigDict(extra="forbid")
331
+ command: Literal["check"] = "check"
332
+ args: CheckArgs = Field(default_factory=CheckArgs)
333
+ #: Sibling of ``args``, not nested in it: the scene negotiation is a
334
+ #: transport concern, not a `check` argument the CLI shares (e1-b2).
335
+ scene: SceneRequest | None = None
336
+ actuator_evidence: dict[str, ArtifactRef] | None = None
337
+ evidence_root: str | None = None
338
+ expected_revision: str | None = None
339
+
340
+
341
+ class WhatIfChange(_StrictArgs):
342
+ model_config = ConfigDict(extra="forbid", strict=True)
343
+
344
+ path: str
345
+ value: str
346
+ source: str
347
+ witness: str
348
+
349
+
350
+ class WhatIfArgs(_StrictArgs):
351
+ model_config = ConfigDict(extra="forbid", strict=True)
352
+
353
+ contract: str
354
+ expected_revision: str
355
+ change: WhatIfChange
356
+
357
+
358
+ class WhatIfCommand(BaseModel):
359
+ model_config = ConfigDict(extra="forbid")
360
+ command: Literal["what-if"] = "what-if"
361
+ args: WhatIfArgs
362
+
363
+
364
+ class PlanCommand(BaseModel):
365
+ command: Literal["plan"] = "plan"
366
+ args: PlanArgs
367
+
368
+
369
+ class DescribeCommand(BaseModel):
370
+ command: Literal["describe"] = "describe"
371
+ args: DescribeArgs = Field(default_factory=DescribeArgs)
372
+
373
+
374
+ class DeclareFormCommand(BaseModel):
375
+ command: Literal["declare-form"] = "declare-form"
376
+ args: DeclareFormArgs = Field(default_factory=DeclareFormArgs)
377
+
378
+
379
+ class ReportCommand(BaseModel):
380
+ command: Literal["report"] = "report"
381
+ args: ReportArgs
382
+
383
+
384
+
385
+
386
+ class PageCommand(BaseModel):
387
+ command: Literal["page"] = "page"
388
+ args: PageArgs = Field(default_factory=PageArgs)
389
+
390
+
391
+ class LogCommand(BaseModel):
392
+ command: Literal["log"] = "log"
393
+ args: LogArgs = Field(default_factory=LogArgs)
394
+
395
+
396
+ class DiffCommand(BaseModel):
397
+ command: Literal["diff"] = "diff"
398
+ args: DiffArgs = Field(default_factory=DiffArgs)
399
+
400
+
401
+ class DeclareCommand(BaseModel):
402
+ command: Literal["declare"] = "declare"
403
+ args: DeclareArgs
404
+
405
+
406
+ class AddCommand(BaseModel):
407
+ command: Literal["add"] = "add"
408
+ args: AddArgs
409
+
410
+
411
+ class ReviewCommand(BaseModel):
412
+ command: Literal["review"] = "review"
413
+ args: ReviewArgs
414
+
415
+
416
+ class CommitCommand(BaseModel):
417
+ command: Literal["commit"] = "commit"
418
+ args: CommitArgs
419
+
420
+
421
+ class UndoCommand(BaseModel):
422
+ command: Literal["undo"] = "undo"
423
+ args: UndoArgs
424
+
425
+
426
+ class PlaceCommand(BaseModel):
427
+ command: Literal["place"] = "place"
428
+ args: PlaceArgs
429
+
430
+
431
+ class MoveCommand(BaseModel):
432
+ command: Literal["move"] = "move"
433
+ args: MoveArgs
434
+
435
+
436
+ class RotateCommand(BaseModel):
437
+ command: Literal["rotate"] = "rotate"
438
+ args: RotateArgs
439
+
440
+
441
+ class ReparentCommand(BaseModel):
442
+ command: Literal["reparent"] = "reparent"
443
+ args: ReparentArgs
444
+
445
+
446
+ class MountShapeCommand(BaseModel):
447
+ command: Literal["mount-shape"] = "mount-shape"
448
+ args: MountShapeArgs
449
+
450
+
451
+ _LOAD_ERRORS = (FileNotFoundError, KeyError, OSError, TypeError, ValueError, YAMLError)
452
+
453
+ #: The parsed-scene cache (e1-b2): keyed by ``(path, root-file digest)`` so a scene
454
+ #: edited on disk is re-parsed and a re-request for the same file is not.
455
+ #: Single-process, in-memory, unbounded — the sidecar serves one robot's
456
+ #: contract tree per run, so the key space is small by construction.
457
+ #: ponytail: no eviction; add an LRU cap if a session ever loads many scenes.
458
+ _SCENE_CACHE: dict[tuple[str, str], Any] = {}
459
+
460
+
461
+ def _cached_scene(contract_path: Path) -> tuple[Any, str | None]:
462
+ """Discover the scene beside ``contract_path`` (T2 — ``check`` takes no
463
+ ``--scene`` flag, `cli.py:454`) and load it, caching the parse by
464
+ ``(path, root-file digest)``. Discovery and the actual parse (``load_scene_any``)
465
+ are the same calls ``api.load_scene`` makes — this wraps them with a
466
+ cache in front, which ``api.load_scene`` cannot do itself since it never
467
+ exposes the scene's path/stat to key on.
468
+ """
469
+ from lockwire.cli import _find_scene # noqa: PLC0415 - cli's tail loads plugins
470
+
471
+ scene_path, _discovered = _find_scene(contract_path, None)
472
+ if scene_path is None:
473
+ return None, None
474
+ loaded_contract = load_contract(contract_path)
475
+ external_assets = api._selected_external_meshes(
476
+ loaded_contract, contract_path, scene_path
477
+ )
478
+ from hashlib import sha256
479
+ digest = sha256(scene_path.read_bytes()).hexdigest()
480
+ key = (str(scene_path.resolve()), digest)
481
+ # An external mesh can change without changing the selected root's digest.
482
+ # Reparse every selected model: `{}` means its root is selected but it
483
+ # declares no mesh identities, which must still refuse an external mesh.
484
+ # `None` alone means this scene is unselected and can use the root cache.
485
+ cached = None if external_assets is not None else _SCENE_CACHE.get(key)
486
+ if cached is None:
487
+ cached = load_scene_any(scene_path, external_assets=external_assets)
488
+ if cached.asset_sha256 != digest:
489
+ raise SceneRefusal(f"{scene_path}: selected asset changed while loading.")
490
+ if external_assets is None:
491
+ _SCENE_CACHE[key] = cached
492
+ return cached, f"{scene_path.name} (found beside the contract)"
493
+
494
+
495
+ # ---------------------------------------------------------------------------
496
+ # Handlers. Each reuses the same functions cli.py's matching command calls.
497
+
498
+
499
+ def _handle_check(command: CheckCommand) -> RunReply:
500
+ args, scene_request = command.args, command.scene
501
+ line: list[object] = ["lockwire", "check", args.contract]
502
+ if command.actuator_evidence is not None:
503
+ for case_id in sorted(command.actuator_evidence):
504
+ ref = command.actuator_evidence[case_id]
505
+ line += ["--actuator-evidence", f"{case_id}={ref.path}@{ref.sha256}"]
506
+ if command.evidence_root is not None:
507
+ line += ["--evidence-root", command.evidence_root]
508
+ if command.expected_revision is not None:
509
+ line += ["--expected-revision", command.expected_revision]
510
+ cli_line = _build_cli_line(*line)
511
+ try:
512
+ document = api.check(
513
+ args.contract,
514
+ actuator_evidence=command.actuator_evidence,
515
+ evidence_root=command.evidence_root,
516
+ expected_revision=command.expected_revision,
517
+ )
518
+ scene, scene_source = _cached_scene(Path(args.contract))
519
+ loaded_contract = load_contract(Path(args.contract))
520
+ from lockwire.scene_view import scene_asset_identity
521
+ identity = (scene_asset_identity(loaded_contract, scene, Path(args.contract))
522
+ if scene is not None else {})
523
+ except (*_LOAD_ERRORS, SceneRefusal) as error:
524
+ return _refusal(cli_line, 1, _no_contract(Path(args.contract)) or str(error))
525
+ if scene is not None:
526
+ current_hash = _scene_hash(scene)
527
+ if scene_request is not None and scene_request.have == current_hash:
528
+ document["scene"] = {"unchanged": True, **identity}
529
+ else:
530
+ document["scene"] = _scene_payload(
531
+ loaded_contract,
532
+ scene,
533
+ source=scene_source,
534
+ contract_path=Path(args.contract),
535
+ cards=[],
536
+ scored_records=[],
537
+ labels=[],
538
+ decimate=False,
539
+ )
540
+ # The lens bar's words ride beside the scene they label (E2-j2), on the same
541
+ # condition as `scene` above — `_graph_view` is `report_html._graph`, the one
542
+ # function the page payload already uses for this (`page.py`), not a second
543
+ # computation of it.
544
+ document["graph"] = _graph_view(loaded_contract)
545
+ return _document_reply(cli_line, document)
546
+
547
+
548
+ def _handle_what_if(args: WhatIfArgs) -> RunReply:
549
+ """Compare one observed proposal through the existing non-mutating API door."""
550
+ cli_line = _build_cli_line(
551
+ "lockwire",
552
+ "what-if",
553
+ args.contract,
554
+ "--change",
555
+ f"{args.change.path}={args.change.value}",
556
+ "--source",
557
+ args.change.source,
558
+ "--witness",
559
+ args.change.witness,
560
+ )
561
+ try:
562
+ if (
563
+ len(args.expected_revision) != 64
564
+ or args.expected_revision != args.expected_revision.lower()
565
+ or any(character not in "0123456789abcdef" for character in args.expected_revision)
566
+ ):
567
+ raise ValueError("expected_revision must be the full lowercase raw contract sha256")
568
+ if contract_revision(args.contract) != args.expected_revision:
569
+ raise ValueError("contract changed since it was opened; reload before comparing")
570
+ document = plugins.pick("what_if", api.what_if)(
571
+ args.contract,
572
+ [
573
+ Change(
574
+ path=args.change.path,
575
+ value=args.change.value,
576
+ source=args.change.source,
577
+ witness=args.change.witness,
578
+ )
579
+ ],
580
+ )
581
+ except _LOAD_ERRORS as error:
582
+ return _refusal(cli_line, 2, _no_contract(Path(args.contract)) or str(error))
583
+ return _ok(cli_line, exit_code=0, verdict=None, payload=document)
584
+
585
+
586
+ def _label_findings(document: dict[str, Any], contract: str) -> None:
587
+ """Sidecar-only: name each finding for the app's rows (the CLI documents stay as they are)."""
588
+ from lockwire import page as gui # noqa: PLC0415
589
+ from lockwire.report import Finding # noqa: PLC0415
590
+
591
+ findings = [Finding.model_validate(f) for f in document["findings"]]
592
+ document["finding_labels"] = gui.finding_labels(api.load(contract), findings)
593
+
594
+
595
+ def _handle_plan(args: PlanArgs) -> RunReply:
596
+ cli_line = f"lockwire plan {args.contract} {args.plan}"
597
+ try:
598
+ document = api.plan(args.contract, args.plan)
599
+ _label_findings(document, args.contract)
600
+ from lockwire import page as gui # noqa: PLC0415
601
+
602
+ by_step = gui.step_targets(api.load(args.contract), args.plan)
603
+ for step in document.get("steps", []):
604
+ index = step["path"].split("/")[2:3] # plan/steps/<i>/...
605
+ if index and index[0].isdigit() and int(index[0]) < len(by_step):
606
+ step["targets"] = by_step[int(index[0])]
607
+ except _LOAD_ERRORS as error:
608
+ return _refusal(cli_line, 2, str(error))
609
+ return _document_reply(cli_line, document)
610
+
611
+
612
+ def _handle_describe(args: DescribeArgs) -> RunReply:
613
+ cli_line_parts = ["lockwire", "describe", args.contract]
614
+ if args.out is not None:
615
+ cli_line_parts += ["--out", args.out]
616
+ cli_line = " ".join(cli_line_parts)
617
+ try:
618
+ files = api.describe(args.contract)["files"]
619
+ except _LOAD_ERRORS as error:
620
+ return _refusal(cli_line, 2, str(error))
621
+ if args.out is None:
622
+ return _ok(cli_line, exit_code=0, verdict=None, payload="\n\n".join(files.values()))
623
+ out_dir = Path(args.out)
624
+ try:
625
+ out_dir.mkdir(parents=True, exist_ok=True)
626
+ for name, content in files.items():
627
+ (out_dir / name).write_text(content, encoding="utf-8")
628
+ except OSError as error:
629
+ return _refusal(cli_line, 2, f"could not write to {out_dir}: {error}")
630
+ text = "\n".join(f"wrote {out_dir / name}" for name in sorted(files))
631
+ return _ok(cli_line, exit_code=0, verdict=None, payload=text)
632
+
633
+
634
+ def _handle_report(args: ReportArgs) -> RunReply:
635
+ cli_line_parts = ["lockwire", "report", args.contract]
636
+ if args.scene is not None:
637
+ cli_line_parts += ["--scene", args.scene]
638
+ for root in args.package_root:
639
+ cli_line_parts += ["--package-root", root]
640
+ cli_line_parts += ["--out", args.out]
641
+ cli_line = " ".join(cli_line_parts)
642
+ contract_path = Path(args.contract)
643
+ if not contract_path.is_file():
644
+ return _refusal(cli_line, 1, f"no contract at {contract_path}")
645
+ try:
646
+ loaded = load_contract(contract_path)
647
+ except _LOAD_ERRORS as error:
648
+ return _refusal(cli_line, 1, str(error))
649
+ try:
650
+ api.load_scene(contract_path, args.scene, args.package_root)
651
+ except SceneRefusal as error:
652
+ return _refusal(cli_line, 2, str(error))
653
+ out_path = Path(args.out)
654
+ try:
655
+ write_html_report(loaded, out_path)
656
+ except OSError as error:
657
+ return _refusal(cli_line, 1, f"could not write {out_path}: {error}")
658
+ return _ok(cli_line, exit_code=0, verdict=None, payload=f"wrote {out_path}")
659
+
660
+
661
+ def page_reply(
662
+ args: PageArgs, run_of: Callable[[Any], tuple[list[Any], str]] | None = None
663
+ ) -> RunReply:
664
+ """The window's document: the payload ``page.build_payload`` computes, returned in
665
+ the reply (e1-pg). ``page`` has no CLI command of its own (a wrapper needs a
666
+ twin only when one exists), so the ``cli_line`` names ``lockwire check``, the
667
+ nearest command that reads the same contract. ``run_of(contract)`` is a plugin's
668
+ way to hand the page extra findings; the free handler passes none."""
669
+ cli_line = f"lockwire check {args.contract}"
670
+ contract_path = Path(args.contract)
671
+ if not contract_path.exists():
672
+ return _refusal(cli_line, 1, f"no contract at {contract_path}")
673
+ try:
674
+ loaded = load_contract(contract_path)
675
+ except _LOAD_ERRORS as error:
676
+ return _refusal(cli_line, 1, str(error))
677
+ run = None
678
+ if run_of is not None:
679
+ try:
680
+ run = run_of(loaded)
681
+ except _LOAD_ERRORS as error:
682
+ return _refusal(cli_line, 1, str(error))
683
+ try:
684
+ loaded_scene, scene_source = api.load_scene(contract_path, args.scene, args.package_root)
685
+ except SceneRefusal as error:
686
+ return _refusal(cli_line, 2, str(error))
687
+ plan_outcomes = None
688
+ plan_path = Path(args.plan) if args.plan is not None else None
689
+ if plan_path is not None:
690
+ try:
691
+ plan_report = validate_plan(loaded, load_plan(plan_path))
692
+ except _LOAD_ERRORS as error:
693
+ return _refusal(cli_line, 1, str(error))
694
+ plan_outcomes = plan_report.outcomes
695
+ document = api.page(
696
+ loaded,
697
+ preflight=run,
698
+ scene=loaded_scene,
699
+ scene_source=scene_source,
700
+ contract_path=contract_path,
701
+ plan_path=plan_path,
702
+ plan_outcomes=plan_outcomes,
703
+ cli_line=cli_line,
704
+ )
705
+ # No `exit_code`/`verdict` in the document itself (`PagePayload` carries
706
+ # neither, seam 1 in api_payload.py) — the page's own convention: 0 whenever
707
+ # the page was computed, never a decision gate.
708
+ return _ok(cli_line, exit_code=0, verdict=None, payload=document)
709
+
710
+
711
+ def _handle_page(args: PageArgs) -> RunReply:
712
+ return page_reply(args)
713
+
714
+
715
+ def _handle_log(args: LogArgs) -> RunReply:
716
+ cli_line_parts = ["lockwire", "log"]
717
+ if args.revision_range is not None:
718
+ cli_line_parts.append(args.revision_range)
719
+ cli_line_parts += ["--", args.contract]
720
+ return _ok(
721
+ " ".join(cli_line_parts),
722
+ exit_code=0,
723
+ verdict=None,
724
+ payload=api.log(args.contract, args.revision_range),
725
+ )
726
+
727
+
728
+ def _handle_diff(args: DiffArgs) -> RunReply:
729
+ cli_line = f"lockwire diff {args.a}"
730
+ if args.b is not None:
731
+ cli_line += f" {args.b}"
732
+ cli_line += f" --file {args.contract}"
733
+ return _ok(
734
+ cli_line,
735
+ exit_code=0,
736
+ verdict=None,
737
+ payload=api.diff(args.contract, args.a, args.b),
738
+ )
739
+
740
+
741
+ def _parse_locked_at(value: str | None) -> dt.date | None:
742
+ return dt.date.fromisoformat(value) if value is not None else None
743
+
744
+
745
+ def _handle_declare(args: DeclareArgs) -> RunReply:
746
+ cli_line = f"lockwire declare {args.contract} {args.path}"
747
+ document = api.declare(
748
+ args.contract,
749
+ args.path,
750
+ args.value,
751
+ source=args.source,
752
+ witness=args.witness,
753
+ note=args.note,
754
+ locked_at=_parse_locked_at(args.locked_at),
755
+ dry_run=args.dry_run,
756
+ expected_revision=args.expected_revision,
757
+ )
758
+ return _ok(cli_line, exit_code=0, verdict=None, payload=document)
759
+
760
+
761
+ def _handle_declare_form(args: DeclareFormArgs) -> RunReply:
762
+ cli_line = f"lockwire declare-form {args.contract}"
763
+ return _ok(
764
+ cli_line,
765
+ exit_code=0,
766
+ verdict=None,
767
+ payload=api.declare_form(args.contract),
768
+ )
769
+
770
+
771
+ def _handle_add(args: AddArgs) -> RunReply:
772
+ cli_line = f"lockwire add {args.kind} {args.contract}"
773
+ document = api.add(
774
+ args.contract,
775
+ args.kind,
776
+ id=args.id,
777
+ source=args.source,
778
+ witness=args.witness,
779
+ note=args.note,
780
+ locked_at=_parse_locked_at(args.locked_at),
781
+ dry_run=args.dry_run,
782
+ expected_revision=args.expected_revision,
783
+ **args.fields,
784
+ )
785
+ return _ok(cli_line, exit_code=0, verdict=None, payload=document)
786
+
787
+
788
+ def _handle_review(args: ReviewArgs) -> RunReply:
789
+ cli_line = f"lockwire review {args.contract} {args.path} {args.state}"
790
+ document = api.review(
791
+ args.contract,
792
+ args.path,
793
+ args.state,
794
+ witness=args.witness,
795
+ note=args.note,
796
+ locked_at=_parse_locked_at(args.locked_at),
797
+ dry_run=args.dry_run,
798
+ expected_revision=args.expected_revision,
799
+ )
800
+ return _ok(cli_line, exit_code=0, verdict=None, payload=document)
801
+
802
+
803
+ def _handle_commit(args: CommitArgs) -> RunReply:
804
+ cli_line = f"lockwire commit {args.contract}"
805
+ document = vcs.commit_contract(
806
+ args.contract,
807
+ message=args.message,
808
+ paths=args.paths,
809
+ dry_run=args.dry_run,
810
+ expected_revision=args.expected_revision,
811
+ )
812
+ return _ok(cli_line, exit_code=0, verdict=None, payload=document)
813
+
814
+
815
+ def _handle_undo(args: UndoArgs) -> RunReply:
816
+ cli_line = f"lockwire undo {args.contract} --source {args.source}"
817
+ plan = vcs.undo_plan(args.contract, source=args.source)
818
+ if args.apply:
819
+ if args.expected_revision is None:
820
+ vcs.apply_undo(plan)
821
+ else:
822
+ vcs.apply_undo(plan, contract=args.contract, expected_revision=args.expected_revision)
823
+ return _ok(cli_line, exit_code=0, verdict=None, payload=plan)
824
+
825
+
826
+ def _handle_place(args: PlaceArgs) -> RunReply:
827
+ document = api.place(
828
+ args.contract,
829
+ args.target,
830
+ args.on,
831
+ args.offset,
832
+ unit=args.unit,
833
+ source=args.source,
834
+ witness=args.witness,
835
+ note=args.note,
836
+ locked_at=_parse_locked_at(args.locked_at),
837
+ dry_run=args.dry_run,
838
+ expected_revision=args.expected_revision,
839
+ )
840
+ return _ok(
841
+ f"lockwire place {args.contract} {args.target}", exit_code=0, verdict=None, payload=document
842
+ )
843
+
844
+
845
+ def _handle_move(args: MoveArgs) -> RunReply:
846
+ document = api.move(
847
+ args.contract,
848
+ args.target,
849
+ args.offset,
850
+ unit=args.unit,
851
+ source=args.source,
852
+ witness=args.witness,
853
+ note=args.note,
854
+ locked_at=_parse_locked_at(args.locked_at),
855
+ dry_run=args.dry_run,
856
+ expected_revision=args.expected_revision,
857
+ )
858
+ return _ok(
859
+ f"lockwire move {args.contract} {args.target}", exit_code=0, verdict=None, payload=document
860
+ )
861
+
862
+
863
+ def _handle_rotate(args: RotateArgs) -> RunReply:
864
+ document = api.rotate(
865
+ args.contract,
866
+ args.target,
867
+ args.rpy,
868
+ unit=args.unit,
869
+ source=args.source,
870
+ witness=args.witness,
871
+ note=args.note,
872
+ locked_at=_parse_locked_at(args.locked_at),
873
+ dry_run=args.dry_run,
874
+ expected_revision=args.expected_revision,
875
+ )
876
+ return _ok(
877
+ f"lockwire rotate {args.contract} {args.target}",
878
+ exit_code=0,
879
+ verdict=None,
880
+ payload=document,
881
+ )
882
+
883
+
884
+ def _handle_reparent(args: ReparentArgs) -> RunReply:
885
+ document = api.reparent(
886
+ args.contract,
887
+ args.target,
888
+ args.to,
889
+ source=args.source,
890
+ witness=args.witness,
891
+ note=args.note,
892
+ locked_at=_parse_locked_at(args.locked_at),
893
+ dry_run=args.dry_run,
894
+ expected_revision=args.expected_revision,
895
+ )
896
+ return _ok(
897
+ f"lockwire reparent {args.contract} {args.target}",
898
+ exit_code=0,
899
+ verdict=None,
900
+ payload=document,
901
+ )
902
+
903
+
904
+ def _handle_mount_shape(args: MountShapeArgs) -> RunReply:
905
+ document = api.mount_shape(
906
+ args.contract,
907
+ args.target,
908
+ args.shape.model_dump(exclude_none=True),
909
+ source=args.source,
910
+ witness=args.witness,
911
+ note=args.note,
912
+ locked_at=_parse_locked_at(args.locked_at),
913
+ dry_run=args.dry_run,
914
+ expected_revision=args.expected_revision,
915
+ )
916
+ return _ok(
917
+ f"lockwire mount-shape {args.contract} {args.target}",
918
+ exit_code=0,
919
+ verdict=None,
920
+ payload=document,
921
+ )
922
+
923
+
924
+ def _handle_start(args: StartArgs) -> RunReply:
925
+ cli_line = f"lockwire start {args.urdf} --out {Path(args.urdf).resolve().parent / 'lockwire.yaml'}"
926
+ try:
927
+ result = api.start(
928
+ args.urdf, overwrite=args.overwrite, expected_revision=args.expected_revision
929
+ )
930
+ except _LOAD_ERRORS as error:
931
+ return _refusal(cli_line, 2, str(error))
932
+ return _ok(cli_line, exit_code=0, verdict=None, payload=result)
933
+
934
+
935
+ _FREE_COMMANDS = {
936
+ "start": plugins.ServeCommand(_handle_start, StartArgs, StartCommand),
937
+ "check": plugins.ServeCommand(_handle_check, CheckArgs, CheckCommand),
938
+ "what-if": plugins.ServeCommand(_handle_what_if, WhatIfArgs, WhatIfCommand),
939
+ "plan": plugins.ServeCommand(_handle_plan, PlanArgs, PlanCommand),
940
+ "describe": plugins.ServeCommand(_handle_describe, DescribeArgs, DescribeCommand),
941
+ "declare-form": plugins.ServeCommand(_handle_declare_form, DeclareFormArgs, DeclareFormCommand),
942
+ "report": plugins.ServeCommand(_handle_report, ReportArgs, ReportCommand),
943
+ "page": plugins.ServeCommand(_handle_page, PageArgs, PageCommand),
944
+ "log": plugins.ServeCommand(_handle_log, LogArgs, LogCommand),
945
+ "diff": plugins.ServeCommand(_handle_diff, DiffArgs, DiffCommand),
946
+ "declare": plugins.ServeCommand(_handle_declare, DeclareArgs, DeclareCommand),
947
+ "add": plugins.ServeCommand(_handle_add, AddArgs, AddCommand),
948
+ "review": plugins.ServeCommand(_handle_review, ReviewArgs, ReviewCommand),
949
+ "commit": plugins.ServeCommand(_handle_commit, CommitArgs, CommitCommand),
950
+ "undo": plugins.ServeCommand(_handle_undo, UndoArgs, UndoCommand),
951
+ "place": plugins.ServeCommand(_handle_place, PlaceArgs, PlaceCommand),
952
+ "move": plugins.ServeCommand(_handle_move, MoveArgs, MoveCommand),
953
+ "rotate": plugins.ServeCommand(_handle_rotate, RotateArgs, RotateCommand),
954
+ "reparent": plugins.ServeCommand(_handle_reparent, ReparentArgs, ReparentCommand),
955
+ "mount-shape": plugins.ServeCommand(_handle_mount_shape, MountShapeArgs, MountShapeCommand),
956
+ }
957
+
958
+
959
+ def commands() -> dict[str, plugins.ServeCommand]:
960
+ """The commands this sidecar serves: the free ones, then whatever a plugin registered."""
961
+ return {**_FREE_COMMANDS, **plugins.SERVE_COMMANDS}
962
+
963
+
964
+ def request_type(table: dict[str, plugins.ServeCommand] | None = None) -> Any:
965
+ """The `/v1/run` body: a discriminated union of the table's command models, built
966
+ when the app is, so a plugin's commands are in it and nothing else is."""
967
+ table = commands() if table is None else table
968
+ return Annotated[Union[tuple(c.command for c in table.values())], Field(discriminator="command")]
969
+
970
+
971
+ _REVISION_GUARDED_COMMANDS = frozenset(
972
+ {"declare", "add", "review", "commit", "undo", "place", "move", "rotate", "reparent", "mount-shape"}
973
+ )
974
+
975
+
976
+ def _sidecar_write_needs_revision(body: Any) -> bool:
977
+ """The app must name the exact bytes it opened before changing them."""
978
+ if body.command not in _REVISION_GUARDED_COMMANDS:
979
+ return False
980
+ return body.args.apply if body.command == "undo" else not body.args.dry_run
981
+
982
+
983
+ def dispatch(body: Any, state: Any = None) -> RunReply:
984
+ """Run one parsed request. A plugin's command gets `(body, state)`, `state` being
985
+ the app's `app.state`; a free handler gets its `args` (`check` its whole command)."""
986
+ try:
987
+ if _sidecar_write_needs_revision(body) and body.args.expected_revision is None:
988
+ return _refusal(
989
+ f"lockwire {body.command}",
990
+ 2,
991
+ "expected_revision is required before applying a sidecar edit; reload first",
992
+ )
993
+ if body.command in plugins.SERVE_COMMANDS:
994
+ return plugins.SERVE_COMMANDS[body.command].handler(body, state)
995
+ handler = _FREE_COMMANDS[body.command].handler
996
+ return handler(body) if isinstance(body, CheckCommand) else handler(body.args)
997
+ except Exception as error: # noqa: BLE001 - never a traceback over the wire
998
+ return _refusal(f"lockwire {body.command}", 2, str(error))
999
+
1000
+
1001
+ # ---------------------------------------------------------------------------
1002
+ # The app. Bound to 127.0.0.1 only by ``lockwire serve``; every route needs the
1003
+ # per-launch token, hello included.
1004
+ #
1005
+ # The token is checked in **middleware**, not in a route dependency, and that is
1006
+ # the whole point: FastAPI reads and validates a request body *before* it solves
1007
+ # a route's dependencies, so a `Depends(_auth)` guard lets an unauthenticated
1008
+ # caller reach pydantic with an arbitrary body and read a 422 back. Middleware
1009
+ # runs before routing, so an unauthenticated request is a 401 and nothing of it
1010
+ # is ever parsed (`test_token_checked_before_body_is_parsed`).
1011
+ #
1012
+ # There is deliberately **no CORS middleware**: a browser page must not be able
1013
+ # to reach this sidecar at all. The Host check below is the other half of that —
1014
+ # without it a page on any site can point a name it owns at 127.0.0.1 (DNS
1015
+ # rebinding) and the same-origin rules would let it through.
1016
+
1017
+ #: Commands that remain discoverable but are refused by name on the robot.
1018
+ _WRITE_COMMANDS = frozenset(
1019
+ {
1020
+ "start",
1021
+ "declare",
1022
+ "add",
1023
+ "review",
1024
+ "commit",
1025
+ "undo",
1026
+ "place",
1027
+ "move",
1028
+ "rotate",
1029
+ "reparent",
1030
+ "mount-shape",
1031
+ "report",
1032
+ }
1033
+ )
1034
+ _HOST_ONLY_COMMANDS = frozenset({"diff", "log"})
1035
+
1036
+ _LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
1037
+
1038
+
1039
+ def _host_is_loopback(host_header: str | None) -> bool:
1040
+ """``Host`` must name the loopback we bound. Absent is fine (a raw HTTP/1.0
1041
+ client); anything else is someone else's name."""
1042
+ if host_header is None:
1043
+ return True
1044
+ host = host_header.rsplit(":", 1)[0] if not host_header.startswith("[") else "::1"
1045
+ return host in _LOOPBACK_HOSTS
1046
+
1047
+
1048
+ def _token_ok(offered: str | None, expected: str) -> bool:
1049
+ """Constant time, always — a length or prefix leak is a token oracle."""
1050
+ if offered is None:
1051
+ return False
1052
+ return hmac.compare_digest(offered.encode("utf-8"), expected.encode("utf-8"))
1053
+
1054
+
1055
+ _ACCESS_LOG = logging.getLogger("lockwire.serve")
1056
+
1057
+
1058
+ def create_app(
1059
+ token: str,
1060
+ *,
1061
+ robot: bool = False,
1062
+ policy: Callable[[Any], Any] | None = None,
1063
+ ) -> FastAPI:
1064
+ """The app. `policy`, if given, rewrites each parsed request before it runs (a plugin's
1065
+ server-side limits); `plugins.load()` runs first so the command union includes its commands."""
1066
+ from fastapi import FastAPI, Request # noqa: PLC0415 - the `serve` extra
1067
+ from fastapi.responses import JSONResponse # noqa: PLC0415
1068
+
1069
+ plugins.ensure()
1070
+ table = commands()
1071
+ app = FastAPI(title="lockwire sidecar", version=__version__)
1072
+ app.state.token = token
1073
+ app.state.robot = robot
1074
+ for setup in plugins.SERVE_SETUP:
1075
+ setup(app.state)
1076
+
1077
+ @app.middleware("http")
1078
+ async def guard(request: Request, call_next): # noqa: ANN001, ANN202 - starlette's shape
1079
+ # Method, path and status only — never headers, so the token (carried
1080
+ # only in the X-Lockwire-Token header) never reaches the log,
1081
+ # on any of the three exits below.
1082
+ if not _host_is_loopback(request.headers.get("host")):
1083
+ _ACCESS_LOG.info("%s %s -> 400", request.method, request.url.path)
1084
+ return JSONResponse({"detail": "Host is not this loopback"}, status_code=400)
1085
+ if not _token_ok(request.headers.get("X-Lockwire-Token"), app.state.token):
1086
+ _ACCESS_LOG.info("%s %s -> 401", request.method, request.url.path)
1087
+ return JSONResponse({"detail": "missing or invalid X-Lockwire-Token"}, status_code=401)
1088
+ response = await call_next(request)
1089
+ _ACCESS_LOG.info("%s %s -> %s", request.method, request.url.path, response.status_code)
1090
+ return response
1091
+
1092
+ @app.get("/v1/hello", response_model=HelloReply)
1093
+ def hello() -> HelloReply:
1094
+ listed = [
1095
+ CommandInfo(name=name, args_schema=entry.args.model_json_schema())
1096
+ for name, entry in table.items()
1097
+ ]
1098
+ return HelloReply(version=__version__, commands=listed)
1099
+
1100
+ def run(body): # noqa: ANN001, ANN202 - annotated below, once the union exists
1101
+ if policy is not None:
1102
+ body = policy(body)
1103
+ if app.state.robot and body.command in _WRITE_COMMANDS:
1104
+ return _refusal(
1105
+ f"lockwire {body.command}",
1106
+ 2,
1107
+ f"write commands are not served on the robot: {body.command}",
1108
+ )
1109
+ if app.state.robot and body.command in _HOST_ONLY_COMMANDS:
1110
+ return _refusal(
1111
+ f"lockwire {body.command}",
1112
+ 2,
1113
+ f"host-only commands are not served on the robot: {body.command}",
1114
+ )
1115
+ return dispatch(body, app.state)
1116
+
1117
+ run.__annotations__ = {"body": request_type(table), "return": RunReply}
1118
+ app.post("/v1/run", response_model=RunReply)(run)
1119
+ return app
1120
+
1121
+
1122
+ # ---------------------------------------------------------------------------
1123
+ # ``lockwire serve``: bind, mint a token, print the one ready line, run until
1124
+ # stdin closes (the app was force-killed) or SIGTERM (uvicorn's own handler,
1125
+ # installed because this runs on the main thread).
1126
+
1127
+
1128
+ def _watch_stdin(server: uvicorn.Server) -> None:
1129
+ """A closed stdin is the whole orphan story: no stray process survives Electron."""
1130
+ while True:
1131
+ line = sys.stdin.readline()
1132
+ if line == "":
1133
+ server.should_exit = True
1134
+ return
1135
+
1136
+
1137
+ def _log_dir() -> Path:
1138
+ """The platform's real log location — never the cwd (a packaged app's
1139
+ working directory is wherever it happened to be launched from) and never a
1140
+ location the release checklist's uninstall promise ("only the log folder"
1141
+ survives, docs/release_checklist.md §2) would have to special-case.
1142
+
1143
+ macOS (the only shipping platform today): ``~/Library/Logs/Lockwire`` —
1144
+ the OS's own per-app log convention, shown by Console.app and untouched by an
1145
+ app bundle's own uninstall (dragging the .app to Trash). Linux/Windows follow
1146
+ each platform's equivalent convention so the same code holds if E6 ships them;
1147
+ no third-party dependency (stdlib + environment variables only).
1148
+
1149
+ ``LOCKWIRE_LOG_DIR`` overrides everything above. The test suite sets it via an
1150
+ autouse fixture (``tests/conftest.py``) so no test run — including the
1151
+ subprocess-launched ``lockwire serve`` cases — ever writes into a developer's
1152
+ real ``~/Library/Logs``.
1153
+ """
1154
+ override = os.environ.get("LOCKWIRE_LOG_DIR")
1155
+ if override:
1156
+ base = Path(override)
1157
+ base.mkdir(parents=True, exist_ok=True)
1158
+ return base
1159
+ name = "Lockwire"
1160
+ if sys.platform == "darwin":
1161
+ base = Path.home() / "Library" / "Logs" / name
1162
+ elif sys.platform == "win32":
1163
+ base = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")))
1164
+ base = base / name / "logs"
1165
+ else:
1166
+ base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state")))
1167
+ base = base / "lockwire" / "log"
1168
+ base.mkdir(parents=True, exist_ok=True)
1169
+ return base
1170
+
1171
+
1172
+ def _configure_sidecar_logging(log_dir: Path) -> Path:
1173
+ """Rotating file, stdlib only (``logging.handlers.RotatingFileHandler`` — no
1174
+ dependency, no hand-rolled rotator): a size bound and a small number of kept
1175
+ files, oldest discarded, so a long-running sidecar cannot fill a disk and a
1176
+ user who reports "it stopped working" has real files to send.
1177
+
1178
+ Formats only level/time/logger/message — request logging (the ``guard``
1179
+ middleware above) passes method, path and status, never headers, so the
1180
+ per-launch token never reaches this file.
1181
+ """
1182
+ log_path = log_dir / "sidecar.log"
1183
+ handler = logging.handlers.RotatingFileHandler(
1184
+ log_path, maxBytes=200_000, backupCount=5, encoding="utf-8"
1185
+ )
1186
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
1187
+ _ACCESS_LOG.handlers = [handler]
1188
+ _ACCESS_LOG.setLevel(logging.INFO)
1189
+ _ACCESS_LOG.propagate = False
1190
+ return log_path
1191
+
1192
+
1193
+ def run_server(
1194
+ host: str = "127.0.0.1",
1195
+ port: int = 0,
1196
+ *,
1197
+ robot: bool = False,
1198
+ exit_on_stdin_close: bool = False,
1199
+ profile: str | None = None,
1200
+ required_evidence: list[str] | None = None,
1201
+ ) -> int:
1202
+ """Serve the sidecar; returns a process exit code (never raises for a bind refusal).
1203
+
1204
+ ``exit_on_stdin_close`` is opt-in, not always-on: the Electron parent passes it
1205
+ so no sidecar outlives a force-killed app, while a human running
1206
+ ``lockwire serve`` in a terminal (or CI redirecting stdin from /dev/null)
1207
+ keeps a server that does not vanish the moment stdin is not a live pipe.
1208
+ """
1209
+ if host not in _LOOPBACK_HOSTS:
1210
+ print(f"lockwire serve refuses to bind {host!r} — loopback only", file=sys.stderr)
1211
+ return 2
1212
+
1213
+ policy = None
1214
+ if profile is not None or required_evidence:
1215
+ plugins.ensure()
1216
+ try:
1217
+ if plugins.SERVE_POLICY is None:
1218
+ raise ValueError("this install has no server policy to apply")
1219
+ policy = plugins.SERVE_POLICY(profile, required_evidence)
1220
+ except ValueError as error:
1221
+ print(f"lockwire serve refused: {error}", file=sys.stderr)
1222
+ return 2
1223
+ log_path = _configure_sidecar_logging(_log_dir())
1224
+ token = secrets.token_urlsafe(32)
1225
+ import uvicorn # noqa: PLC0415 - the `serve` extra
1226
+
1227
+ app = create_app(token, robot=robot, policy=policy)
1228
+ config = uvicorn.Config(app, host=host, port=port, log_level="warning")
1229
+ server = uvicorn.Server(config)
1230
+ original_startup = server.startup
1231
+
1232
+ async def startup(sockets=None) -> None: # noqa: ANN001 - matches uvicorn's own signature
1233
+ await original_startup(sockets=sockets)
1234
+ if server.started:
1235
+ bound_port = server.servers[0].sockets[0].getsockname()[1]
1236
+ # Written straight to stdout, never through the rich console: the
1237
+ # parent parses this line, and one ANSI escape makes it unparseable.
1238
+ sys.stdout.write(
1239
+ json.dumps(
1240
+ {
1241
+ "lockwire": "ready",
1242
+ "version": __version__,
1243
+ "port": bound_port,
1244
+ "token": token,
1245
+ "log": str(log_path),
1246
+ }
1247
+ )
1248
+ + "\n"
1249
+ )
1250
+ sys.stdout.flush()
1251
+ if exit_on_stdin_close:
1252
+ threading.Thread(target=_watch_stdin, args=(server,), daemon=True).start()
1253
+
1254
+ server.startup = startup # type: ignore[method-assign]
1255
+ server.run()
1256
+ return 0