sid-harness 0.3.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 (45) hide show
  1. sid_harness/__init__.py +13 -0
  2. sid_harness/__main__.py +4 -0
  3. sid_harness/_native.py +1639 -0
  4. sid_harness/brave_proof.py +246 -0
  5. sid_harness/bridge.py +851 -0
  6. sid_harness/cli.py +937 -0
  7. sid_harness/client.py +161 -0
  8. sid_harness/companion.py +151 -0
  9. sid_harness/config.py +68 -0
  10. sid_harness/context/__init__.py +40 -0
  11. sid_harness/context/index.py +267 -0
  12. sid_harness/context/retrieval.py +74 -0
  13. sid_harness/editor.py +231 -0
  14. sid_harness/gateway.py +106 -0
  15. sid_harness/git_write.py +598 -0
  16. sid_harness/local_workspace.py +421 -0
  17. sid_harness/loop.py +394 -0
  18. sid_harness/lsp.py +598 -0
  19. sid_harness/policy/__init__.py +44 -0
  20. sid_harness/policy/repo_policy.py +626 -0
  21. sid_harness/receipt.py +142 -0
  22. sid_harness/runner.py +468 -0
  23. sid_harness/selftest.py +149 -0
  24. sid_harness/session/__init__.py +33 -0
  25. sid_harness/session/bounds.py +140 -0
  26. sid_harness/session/diff_gate.py +93 -0
  27. sid_harness/session/session.py +427 -0
  28. sid_harness/session/store.py +88 -0
  29. sid_harness/tools/__init__.py +52 -0
  30. sid_harness/tools/mcp_client.py +285 -0
  31. sid_harness/tools/plan.py +138 -0
  32. sid_harness/tools/registry.py +159 -0
  33. sid_harness/tools/skills.py +185 -0
  34. sid_harness/tools/subagents.py +112 -0
  35. sid_harness/tui.py +96 -0
  36. sid_harness/workspace.py +107 -0
  37. sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
  38. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
  39. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
  40. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
  41. sid_harness-0.3.0.dist-info/METADATA +229 -0
  42. sid_harness-0.3.0.dist-info/RECORD +45 -0
  43. sid_harness-0.3.0.dist-info/WHEEL +5 -0
  44. sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
  45. sid_harness-0.3.0.dist-info/top_level.txt +1 -0
sid_harness/bridge.py ADDED
@@ -0,0 +1,851 @@
1
+ """sid bridge — a localhost filesystem companion for the Studio web UI.
2
+
3
+ SID's web Studio runs in a browser, which cannot read the local disk (Brave also
4
+ blocks the File System Access API by default). Claude Code / Codex / OpenCode
5
+ avoid that limit by running a local process with filesystem tools. This bridge is
6
+ that local process: a tiny stdlib-only HTTP server bound to 127.0.0.1 that runs
7
+ SID's own native loop over a directory the user explicitly grants, with tools
8
+ backed by the REAL tree (list/read on demand), and reports the proposed change
9
+ back to the Studio.
10
+
11
+ Security model:
12
+ * binds 127.0.0.1 only (never a public interface);
13
+ * a per-launch bearer token, handed to the Studio by a one-click pairing code
14
+ instead of a manual paste;
15
+ * accepts browser requests only from the SID origins (CORS + Private Network
16
+ Access preflight headers); a disallowed Origin is refused server-side, not
17
+ merely hidden by CORS;
18
+ * the workspace root is fixed at launch (``--dir``) and every path goes
19
+ through the shared denylist (``native_agent._safe_path`` / safe_rel_path);
20
+ * READ-ONLY by default. ``--write`` makes write mode *available*, but a write
21
+ still requires an explicit, per-session user grant (``POST /grant-write``)
22
+ before ``POST /apply`` will touch disk;
23
+ * an applied change is only ever a proposal the bridge itself computed, bound
24
+ to a digest the user reviewed, validated all-or-nothing against the shared
25
+ denylist, written atomically, and recorded in a receipt under the bridge
26
+ state directory.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import hashlib
33
+ import json
34
+ import os
35
+ import secrets
36
+ import threading
37
+ import time
38
+ from dataclasses import dataclass, field
39
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
40
+ from pathlib import Path
41
+ from typing import Any
42
+
43
+ from .client import SIDClient
44
+ from .gateway import native_agent
45
+ from .local_workspace import LocalWorkspace, LocalWorkspaceError, safe_rel_path
46
+ from .loop import run_loop
47
+ from .receipt import utc_now
48
+
49
+ DEFAULT_PORT = 8787
50
+ MAX_LIST_PATHS = 20000
51
+ MAX_READ_CHARS = 40000
52
+ SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".next", "dist", "build"}
53
+ ALLOWED_ORIGINS = (
54
+ "https://app.superintelligencedog.com",
55
+ "https://superintelligencedog.com",
56
+ )
57
+ SEED_PATH = "WORKSPACE.md"
58
+ SEED_CONTENT = (
59
+ "This is a disk-backed workspace. Use list_files to discover paths and "
60
+ "read_file to read any file on demand. Files are read from the real tree; "
61
+ "edits are returned as a proposal, not written."
62
+ )
63
+
64
+ PREVIEW_MAX_BYTES = 2_000_000
65
+ PAIR_TTL_SECONDS = 600
66
+ PROPOSAL_TTL_SECONDS = 3600
67
+ MAX_PENDING_PROPOSALS = 20
68
+ MAX_BODY_BYTES = 1_000_000
69
+ MAX_SMALL_BODY_BYTES = 16_384
70
+ # Paths the write path refuses in addition to the shared secret/VCS denylist.
71
+ WRITE_BLOCKED_PARTS = frozenset({".sid"})
72
+ WRITE_GRANT_CONFIRM = "enable-write"
73
+
74
+
75
+ def bridge_state_dir() -> Path:
76
+ """Per-user bridge state (receipts, pairing metadata) — never the workspace."""
77
+ return Path.home() / ".sid" / "bridge"
78
+
79
+
80
+ def _sha256_bytes(data: bytes) -> str:
81
+ return hashlib.sha256(data).hexdigest()
82
+
83
+
84
+ def _sha256_text(text: str) -> str:
85
+ return _sha256_bytes(text.encode("utf-8"))
86
+
87
+
88
+ def _write_json(path: Path, payload: dict[str, Any]) -> Path:
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ tmp = path.with_name(path.name + ".tmp")
91
+ tmp.write_text(
92
+ json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
93
+ encoding="utf-8",
94
+ )
95
+ os.replace(tmp, path)
96
+ return path
97
+
98
+
99
+ def _decode_text(data: bytes) -> str | None:
100
+ if b"\x00" in data:
101
+ return None
102
+ try:
103
+ return data.decode("utf-8")
104
+ except UnicodeDecodeError:
105
+ return None
106
+
107
+
108
+ def _preview_for_directory(root: str | os.PathLike[str]) -> dict[str, Any] | None:
109
+ base = Path(root)
110
+ files: list[dict[str, str]] = []
111
+ total = 0
112
+ for dirpath, dirnames, filenames in os.walk(base):
113
+ dirnames[:] = [d for d in dirnames if d.lower() not in SKIP_DIRS]
114
+ for name in filenames:
115
+ full = Path(dirpath) / name
116
+ try:
117
+ rel = full.relative_to(base).as_posix()
118
+ except ValueError:
119
+ continue
120
+ try:
121
+ safe = safe_rel_path(rel)
122
+ data = full.read_bytes()
123
+ except (LocalWorkspaceError, OSError):
124
+ continue
125
+ text = _decode_text(data)
126
+ if text is None:
127
+ continue
128
+ if total + len(text) > PREVIEW_MAX_BYTES:
129
+ return _finish_preview(files)
130
+ files.append({"path": safe, "content": text})
131
+ total += len(text)
132
+ return _finish_preview(files)
133
+
134
+
135
+ def _finish_preview(files: list[dict[str, str]]) -> dict[str, Any] | None:
136
+ paths = [f["path"] for f in files]
137
+ entry = "index.html" if "index.html" in paths else next(
138
+ (p for p in sorted(paths) if p.lower().endswith((".html", ".htm"))), ""
139
+ )
140
+ if not entry:
141
+ return None
142
+ return {"entry": entry, "files": files}
143
+
144
+
145
+ class DiskTools:
146
+ """The loop's ONE dispatch seam, backed by the real tree.
147
+
148
+ list_files / read_file hit disk on demand (no snapshot cap). write_file uses
149
+ the base contract so the loop still computes changed/readback; the loop never
150
+ writes disk — ``POST /apply`` is the only write path.
151
+ """
152
+
153
+ def __init__(self, workspace: LocalWorkspace) -> None:
154
+ self.ws = workspace
155
+ self.root = workspace.root
156
+
157
+ def schemas(self) -> list[dict[str, Any]]:
158
+ return native_agent.TOOLS
159
+
160
+ def _iter_paths(self) -> list[str]:
161
+ out: list[str] = []
162
+ for dirpath, dirnames, filenames in os.walk(self.root):
163
+ dirnames[:] = [d for d in dirnames if d.lower() not in SKIP_DIRS]
164
+ for name in filenames:
165
+ full = Path(dirpath) / name
166
+ try:
167
+ rel = full.relative_to(self.root).as_posix()
168
+ except ValueError:
169
+ continue
170
+ try:
171
+ safe = safe_rel_path(rel)
172
+ except LocalWorkspaceError:
173
+ continue
174
+ if self.ws.gitignore.ignored(safe):
175
+ continue
176
+ out.append(safe)
177
+ if len(out) >= MAX_LIST_PATHS:
178
+ return sorted(out)
179
+ return sorted(out)
180
+
181
+ def dispatch(self, name: str, arguments: dict[str, Any], files: dict[str, str], run_tests: Any) -> Any:
182
+ if name == "list_files":
183
+ return {"files": self._iter_paths()}
184
+ if name == "read_file":
185
+ try:
186
+ rel = safe_rel_path(arguments.get("path"))
187
+ content = self.ws.read(rel)
188
+ except LocalWorkspaceError as exc:
189
+ return {"error": str(exc)}
190
+ truncated = len(content) > MAX_READ_CHARS
191
+ return {"path": rel, "content": content[:MAX_READ_CHARS], "truncated": truncated}
192
+ if name == "write_file":
193
+ # Base contract: mutate the in-memory workspace so changed/readback
194
+ # are computed; nothing is written to disk here.
195
+ return native_agent._tool_result(
196
+ name, arguments, files, run_tests,
197
+ max_files=10**9, max_file_bytes=10**9, max_workspace_bytes=10**12,
198
+ )
199
+ if name == "run_approved_tests":
200
+ return run_tests(files)
201
+ return {"error": f"unknown tool: {name}"}
202
+
203
+
204
+ def run_task(
205
+ *,
206
+ directory: str | os.PathLike[str],
207
+ task: str,
208
+ api_key: str,
209
+ base_url: str | None = None,
210
+ model: str = "auto",
211
+ test_argv: list[str] | None = None,
212
+ ) -> dict[str, Any]:
213
+ """Run one loop turn over the real directory; return the receipt."""
214
+ ws = LocalWorkspace.open(directory)
215
+ client = SIDClient(base_url=base_url, api_key=api_key)
216
+ seed = {SEED_PATH: SEED_CONTENT}
217
+ # SUB-94: the bridge's loop workspace is disk-backed (DiskTools), but the
218
+ # project policy files must travel IN the workspace map so the ONE loop
219
+ # loader sees them exactly as the CLI, Studio and workbench do. Reading is
220
+ # bounded by LocalWorkspace.read; a missing file is simply absent.
221
+ for rel in (*native_agent.PROJECT_RULE_FILES, native_agent.PROJECT_CONFIG_PATH):
222
+ try:
223
+ seed[rel] = ws.read(rel)
224
+ except (LocalWorkspaceError, OSError):
225
+ continue
226
+ return asyncio.run(
227
+ run_loop(
228
+ client=client,
229
+ task=task,
230
+ workspace_files=seed,
231
+ acceptance_criteria=[f"Satisfy the user's request: {task}"],
232
+ out_of_scope=[],
233
+ test_argv=list(test_argv or []),
234
+ workspace_root=ws.root,
235
+ model=model,
236
+ registry=DiskTools(ws),
237
+ )
238
+ )
239
+
240
+
241
+ @dataclass
242
+ class Proposal:
243
+ """One reviewed, in-memory change set. Content never leaves the process."""
244
+
245
+ id: str
246
+ digest: str
247
+ changed: list[str]
248
+ files: dict[str, str]
249
+ diff: str
250
+ task: str
251
+ created_at: float
252
+ public: dict[str, Any] = field(default_factory=dict)
253
+
254
+ def expired(self, ttl: float = PROPOSAL_TTL_SECONDS) -> bool:
255
+ return (time.time() - self.created_at) > ttl
256
+
257
+
258
+ def proposal_digest(changed: list[str], files: dict[str, str]) -> str:
259
+ """Bind the reviewed paths and their exact bytes to one digest."""
260
+ h = hashlib.sha256()
261
+ for path in sorted(changed):
262
+ h.update(path.encode("utf-8"))
263
+ h.update(b"\x00")
264
+ h.update(bytes.fromhex(_sha256_text(files[path])))
265
+ return h.hexdigest()
266
+
267
+
268
+ def _prepare_write_targets(workspace: LocalWorkspace, paths: list[str]) -> list[tuple[str, Path]]:
269
+ """Validate EVERY target before a single byte is written (all-or-nothing)."""
270
+ if not paths:
271
+ raise LocalWorkspaceError("no changed paths in the proposal")
272
+ root = workspace.root.resolve()
273
+ prepared: list[tuple[str, Path]] = []
274
+ seen: set[str] = set()
275
+ for raw in paths:
276
+ if not isinstance(raw, str):
277
+ raise LocalWorkspaceError("proposal paths must be strings")
278
+ safe = safe_rel_path(raw)
279
+ if safe != raw.replace("\\", "/"):
280
+ raise LocalWorkspaceError(f"path is not normalized: {raw!r}")
281
+ if safe in seen:
282
+ raise LocalWorkspaceError(f"duplicate path in proposal: {safe!r}")
283
+ if any(part.lower() in WRITE_BLOCKED_PARTS for part in safe.split("/")):
284
+ raise LocalWorkspaceError(f"bridge refuses to write reserved path: {safe!r}")
285
+ target = (root / safe).resolve()
286
+ if target == root or root not in target.parents:
287
+ raise LocalWorkspaceError(f"path escapes the workspace root: {safe!r}")
288
+ if target.is_dir():
289
+ raise LocalWorkspaceError(f"path is a directory: {safe!r}")
290
+ seen.add(safe)
291
+ prepared.append((safe, target))
292
+ return prepared
293
+
294
+
295
+ def apply_proposal(
296
+ workspace: LocalWorkspace,
297
+ proposal: Proposal,
298
+ *,
299
+ receipts_dir: str | os.PathLike[str],
300
+ origin: str | None,
301
+ grant_id: str,
302
+ ) -> dict[str, Any]:
303
+ """Write a stored proposal after full validation; roll back on IO failure."""
304
+ prepared = _prepare_write_targets(workspace, list(proposal.changed))
305
+ for path in proposal.changed:
306
+ if path not in proposal.files:
307
+ raise LocalWorkspaceError(f"proposal changed {path!r} but has no content for it")
308
+
309
+ backups: dict[str, bytes | None] = {}
310
+ written: list[str] = []
311
+ started = utc_now()
312
+ try:
313
+ for safe, target in prepared:
314
+ backups[safe] = target.read_bytes() if target.is_file() else None
315
+ workspace.write(safe, proposal.files[safe])
316
+ written.append(safe)
317
+ except Exception:
318
+ for safe, old in backups.items():
319
+ target = workspace.root.resolve() / safe
320
+ try:
321
+ if old is None:
322
+ target.unlink(missing_ok=True)
323
+ else:
324
+ target.write_bytes(old)
325
+ except OSError:
326
+ pass
327
+ raise
328
+ finished = utc_now()
329
+
330
+ receipt = {
331
+ "receipt_version": 1,
332
+ "kind": "bridge-apply",
333
+ "proposal_id": proposal.id,
334
+ "proposal_digest": proposal.digest,
335
+ "task": proposal.task,
336
+ "dir": str(workspace.root),
337
+ "origin": origin or "cli",
338
+ "grant_id": grant_id,
339
+ "started_at": started,
340
+ "finished_at": finished,
341
+ "written": written,
342
+ "files": {
343
+ safe: {
344
+ "bytes_before": len(backups[safe] or b""),
345
+ "bytes_after": len(proposal.files[safe].encode("utf-8")),
346
+ "sha256_before": _sha256_bytes(backups[safe]) if backups[safe] is not None else None,
347
+ "sha256_after": _sha256_text(proposal.files[safe]),
348
+ }
349
+ for safe in written
350
+ },
351
+ "diff": proposal.diff,
352
+ }
353
+ stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
354
+ target = Path(receipts_dir) / f"apply_{stamp}_{proposal.id}.json"
355
+ receipt["receipt_path"] = str(_write_json(target, receipt))
356
+ return receipt
357
+
358
+
359
+ def _cors_headers(origin: str | None, allowed_origins: tuple[str, ...] = ALLOWED_ORIGINS) -> dict[str, str]:
360
+ headers = {
361
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
362
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-SID-Bridge-Token, X-SID-Api-Key, X-SID-Origin",
363
+ "Access-Control-Allow-Private-Network": "true",
364
+ "Access-Control-Max-Age": "600",
365
+ }
366
+ if origin and origin in allowed_origins:
367
+ headers["Access-Control-Allow-Origin"] = origin
368
+ headers["Vary"] = "Origin"
369
+ return headers
370
+
371
+
372
+ class BridgeServer(ThreadingHTTPServer):
373
+ """Loopback-only bridge server with pairing, write grants, and proposals."""
374
+
375
+ daemon_threads = True
376
+ allow_reuse_address = True
377
+
378
+ def __init__(
379
+ self,
380
+ address: tuple[str, int],
381
+ handler: type[BaseHTTPRequestHandler],
382
+ *,
383
+ directory: Path,
384
+ token: str,
385
+ write_capable: bool,
386
+ pair_code: str,
387
+ pair_ttl: float,
388
+ state_dir: Path,
389
+ allowed_origins: tuple[str, ...],
390
+ base_url: str | None,
391
+ ) -> None:
392
+ super().__init__(address, handler)
393
+ self.bridge_dir = directory
394
+ self.bridge_token = token
395
+ self.bridge_write_capable = bool(write_capable)
396
+ self.bridge_pair_code = pair_code
397
+ self.bridge_pair_expires_at = time.time() + max(0.0, float(pair_ttl))
398
+ self.bridge_state_dir = state_dir
399
+ self.bridge_allowed_origins = tuple(allowed_origins)
400
+ self.bridge_base_url = base_url
401
+ self.bridge_paired = False
402
+ self.bridge_paired_at: str | None = None
403
+ self.bridge_write_grant: dict[str, Any] | None = None
404
+ self.bridge_proposals: dict[str, Proposal] = {}
405
+ self.bridge_lock = threading.Lock()
406
+
407
+ @property
408
+ def read_only(self) -> bool:
409
+ return self.bridge_write_grant is None
410
+
411
+ def prune_proposals(self) -> None:
412
+ with self.bridge_lock:
413
+ dead = [key for key, value in self.bridge_proposals.items() if value.expired()]
414
+ for key in dead:
415
+ self.bridge_proposals.pop(key, None)
416
+ while len(self.bridge_proposals) > MAX_PENDING_PROPOSALS:
417
+ oldest = min(self.bridge_proposals.items(), key=lambda item: item[1].created_at)[0]
418
+ self.bridge_proposals.pop(oldest, None)
419
+
420
+
421
+ class BridgeHandler(BaseHTTPRequestHandler):
422
+ server_version = "sid-bridge/2"
423
+
424
+ def log_message(self, fmt: str, *args: Any) -> None: # quiet by default
425
+ pass
426
+
427
+ @property
428
+ def bridge(self) -> BridgeServer:
429
+ return self.server # type: ignore[return-value]
430
+
431
+ def _send(self, status: int, payload: dict[str, Any]) -> None:
432
+ body = json.dumps(payload).encode("utf-8")
433
+ try:
434
+ self.send_response(status)
435
+ self.send_header("Content-Type", "application/json")
436
+ self.send_header("Content-Length", str(len(body)))
437
+ for key, value in _cors_headers(
438
+ self.headers.get("Origin"), self.bridge.bridge_allowed_origins
439
+ ).items():
440
+ self.send_header(key, value)
441
+ self.end_headers()
442
+ self.wfile.write(body)
443
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
444
+ pass # a browser that navigates away mid-response is not an error
445
+
446
+ def _authed(self) -> bool:
447
+ token = self.bridge.bridge_token
448
+ supplied = (self.headers.get("X-SID-Bridge-Token") or "").strip()
449
+ auth = (self.headers.get("Authorization") or "").strip()
450
+ if auth.lower().startswith("bearer "):
451
+ supplied = supplied or auth[7:].strip()
452
+ return bool(token) and secrets.compare_digest(supplied, token)
453
+
454
+ def _origin_ok(self) -> bool:
455
+ """Server-side origin allowlist (CORS alone cannot protect a POST)."""
456
+ origin = self.headers.get("Origin")
457
+ return not origin or origin in self.bridge.bridge_allowed_origins
458
+
459
+ def _read_json(self, *, max_bytes: int = MAX_SMALL_BODY_BYTES) -> dict[str, Any] | None:
460
+ length = int(self.headers.get("Content-Length") or 0)
461
+ if length <= 0 or length > max_bytes:
462
+ self._send(400, {"error": "invalid body"})
463
+ return None
464
+ try:
465
+ body = json.loads(self.rfile.read(length).decode("utf-8"))
466
+ except Exception:
467
+ self._send(400, {"error": "invalid JSON"})
468
+ return None
469
+ if not isinstance(body, dict):
470
+ self._send(400, {"error": "JSON body must be an object"})
471
+ return None
472
+ return body
473
+
474
+ def _guard(self, *, write: bool = False) -> dict[str, Any] | None:
475
+ """Origin + token (+ write grant) gate shared by every state change."""
476
+ if not self._origin_ok():
477
+ self._send(403, {"error": "origin is not allowed by this bridge"})
478
+ return None
479
+ if not self._authed():
480
+ self._send(401, {"error": "missing or invalid bridge token"})
481
+ return None
482
+ if write:
483
+ grant = self.bridge.bridge_write_grant
484
+ if not self.bridge.bridge_write_capable:
485
+ self._send(403, {"error": "bridge was started read-only; restart it with --write to enable write mode"})
486
+ return None
487
+ if grant is None:
488
+ self._send(403, {"error": "write mode has not been granted for this session"})
489
+ return None
490
+ return grant
491
+ return {}
492
+
493
+ def do_OPTIONS(self) -> None: # noqa: N802 (stdlib naming)
494
+ self.send_response(204)
495
+ for key, value in _cors_headers(
496
+ self.headers.get("Origin"), self.bridge.bridge_allowed_origins
497
+ ).items():
498
+ self.send_header(key, value)
499
+ self.send_header("Content-Length", "0")
500
+ self.end_headers()
501
+
502
+ def do_GET(self) -> None: # noqa: N802
503
+ if self.path == "/health":
504
+ self._send(200, {
505
+ "ok": True,
506
+ "bridge": "sid",
507
+ "version": 2,
508
+ "dir": str(self.bridge.bridge_dir),
509
+ "read_only": self.bridge.read_only,
510
+ "write_capable": self.bridge.bridge_write_capable,
511
+ "write_enabled": not self.bridge.read_only,
512
+ "paired": self.bridge.bridge_paired,
513
+ "paired_at": self.bridge.bridge_paired_at,
514
+ })
515
+ return
516
+ self._send(404, {"error": "not found"})
517
+
518
+ def do_POST(self) -> None: # noqa: N802
519
+ route = self.path.split("?", 1)[0]
520
+ if route == "/pair":
521
+ self._post_pair()
522
+ return
523
+ if route == "/grant-write":
524
+ self._post_grant_write()
525
+ return
526
+ if route == "/revoke-write":
527
+ self._post_revoke_write()
528
+ return
529
+ if route == "/run":
530
+ self._post_run()
531
+ return
532
+ if route == "/apply":
533
+ self._post_apply()
534
+ return
535
+ self._send(404, {"error": "not found"})
536
+
537
+ def _post_pair(self) -> None:
538
+ if not self._origin_ok():
539
+ self._send(403, {"error": "origin is not allowed by this bridge"})
540
+ return
541
+ body = self._read_json()
542
+ if body is None:
543
+ return
544
+ supplied = str(body.get("code") or "").strip()
545
+ if (
546
+ time.time() > self.bridge.bridge_pair_expires_at
547
+ or not supplied
548
+ or not secrets.compare_digest(supplied, self.bridge.bridge_pair_code)
549
+ ):
550
+ self._send(401, {"error": "invalid or expired pairing code"})
551
+ return
552
+ self.bridge.bridge_paired = True
553
+ self.bridge.bridge_paired_at = utc_now()
554
+ self._send(200, {
555
+ "ok": True,
556
+ "token": self.bridge.bridge_token,
557
+ "dir": str(self.bridge.bridge_dir),
558
+ "read_only": self.bridge.read_only,
559
+ "write_capable": self.bridge.bridge_write_capable,
560
+ "write_enabled": not self.bridge.read_only,
561
+ })
562
+
563
+ def _post_grant_write(self) -> None:
564
+ if not self._origin_ok():
565
+ self._send(403, {"error": "origin is not allowed by this bridge"})
566
+ return
567
+ if not self._authed():
568
+ self._send(401, {"error": "missing or invalid bridge token"})
569
+ return
570
+ if not self.bridge.bridge_write_capable:
571
+ self._send(403, {"error": "bridge was started read-only; restart it with --write to enable write mode"})
572
+ return
573
+ body = self._read_json()
574
+ if body is None:
575
+ return
576
+ if body.get("granted") is not True or body.get("confirm") != WRITE_GRANT_CONFIRM:
577
+ self._send(400, {"error": "write mode needs an explicit grant (granted=true, confirm='enable-write')"})
578
+ return
579
+ grant = {
580
+ "id": "grant_" + secrets.token_hex(8),
581
+ "granted_at": utc_now(),
582
+ "origin": self.headers.get("Origin") or "cli",
583
+ "mode": "session",
584
+ }
585
+ self.bridge.bridge_write_grant = grant
586
+ _write_json(
587
+ self.bridge.bridge_state_dir / "grants" / f"{grant['id']}.json",
588
+ {"kind": "bridge-write-grant", "dir": str(self.bridge.bridge_dir), **grant},
589
+ )
590
+ self._send(200, {"ok": True, "read_only": False, "grant": grant})
591
+
592
+ def _post_revoke_write(self) -> None:
593
+ if not self._origin_ok():
594
+ self._send(403, {"error": "origin is not allowed by this bridge"})
595
+ return
596
+ if not self._authed():
597
+ self._send(401, {"error": "missing or invalid bridge token"})
598
+ return
599
+ grant = self.bridge.bridge_write_grant
600
+ self.bridge.bridge_write_grant = None
601
+ self._send(200, {"ok": True, "read_only": True, "revoked": grant["id"] if grant else None})
602
+
603
+ def _post_run(self) -> None:
604
+ if not self._origin_ok():
605
+ self._send(403, {"error": "origin is not allowed by this bridge"})
606
+ return
607
+ if not self._authed():
608
+ self._send(401, {"error": "missing or invalid bridge token"})
609
+ return
610
+ body = self._read_json(max_bytes=MAX_BODY_BYTES)
611
+ if body is None:
612
+ return
613
+ task = str(body.get("task") or "").strip()
614
+ if not task:
615
+ self._send(400, {"error": "task is required"})
616
+ return
617
+ api_key = (self.headers.get("X-SID-Api-Key") or "").strip()
618
+ if not api_key:
619
+ self._send(400, {"error": "missing X-SID-Api-Key (the Studio passes your SID key)"})
620
+ return
621
+ test_argv = body.get("test_argv") if isinstance(body.get("test_argv"), list) else None
622
+ if test_argv and self.bridge.bridge_write_grant is None:
623
+ self._send(403, {"error": "running tests changes the machine; grant write mode first"})
624
+ return
625
+ # The browser may not choose the backend: an attacker-controlled
626
+ # base_url would receive the user's SID key.
627
+ try:
628
+ receipt = run_task(
629
+ directory=self.bridge.bridge_dir,
630
+ task=task,
631
+ api_key=api_key,
632
+ base_url=self.bridge.bridge_base_url,
633
+ model=str(body.get("model") or "auto"),
634
+ test_argv=test_argv,
635
+ )
636
+ except Exception as exc: # fail closed, name it
637
+ self._send(502, {"error": f"{type(exc).__name__}: {exc}"})
638
+ return
639
+
640
+ readback = receipt.get("readback") if isinstance(receipt.get("readback"), dict) else {}
641
+ changed = sorted(
642
+ path for path in receipt.get("changed") or []
643
+ if isinstance(path, str) and path in readback
644
+ )
645
+ proposal_public: dict[str, Any] | None = None
646
+ if changed:
647
+ files = {path: readback[path] for path in changed}
648
+ proposal = Proposal(
649
+ id="prop_" + secrets.token_hex(8),
650
+ digest=proposal_digest(changed, files),
651
+ changed=changed,
652
+ files=files,
653
+ diff=str(receipt.get("diff") or ""),
654
+ task=task,
655
+ created_at=time.time(),
656
+ public={},
657
+ )
658
+ proposal.public = {
659
+ "id": proposal.id,
660
+ "digest": proposal.digest,
661
+ "changed": changed,
662
+ "diff": proposal.diff,
663
+ "files": [
664
+ {"path": path, "bytes": len(files[path].encode("utf-8"))}
665
+ for path in changed
666
+ ],
667
+ "created_at": utc_now(),
668
+ }
669
+ self.bridge.prune_proposals()
670
+ with self.bridge.bridge_lock:
671
+ self.bridge.bridge_proposals[proposal.id] = proposal
672
+ proposal_public = proposal.public
673
+
674
+ # The response describes the review surface; raw file contents and the
675
+ # full artifact map stay in the process (apply reads the stored proposal).
676
+ safe_receipt = {
677
+ key: value for key, value in receipt.items()
678
+ if key not in ("readback", "artifact_files", "tested_artifact_hash")
679
+ }
680
+ run_receipt = {
681
+ "kind": "bridge-run",
682
+ "task": task,
683
+ "dir": str(self.bridge.bridge_dir),
684
+ "created_at": utc_now(),
685
+ "read_only": self.bridge.read_only,
686
+ "changed": changed,
687
+ "proposal": proposal_public,
688
+ "diff": safe_receipt.get("diff"),
689
+ "model": safe_receipt.get("model"),
690
+ "steps": safe_receipt.get("steps"),
691
+ "tool_calls": safe_receipt.get("tool_calls"),
692
+ "project_rules": safe_receipt.get("project_rules"),
693
+ }
694
+ _write_json(
695
+ self.bridge.bridge_state_dir / "runs" / f"run_{secrets.token_hex(8)}.json",
696
+ run_receipt,
697
+ )
698
+ self._send(200, {
699
+ "ok": True,
700
+ "receipt": safe_receipt,
701
+ "proposal": proposal_public,
702
+ "preview": _preview_for_directory(self.bridge.bridge_dir),
703
+ "read_only": self.bridge.read_only,
704
+ })
705
+
706
+ def _post_apply(self) -> None:
707
+ grant = self._guard(write=True)
708
+ if grant is None:
709
+ return
710
+ body = self._read_json()
711
+ if body is None:
712
+ return
713
+ proposal_id = str(body.get("proposal_id") or "").strip()
714
+ digest = str(body.get("digest") or "").strip()
715
+ if not proposal_id or not digest:
716
+ self._send(400, {"error": "proposal_id and digest are required"})
717
+ return
718
+ with self.bridge.bridge_lock:
719
+ proposal = self.bridge.bridge_proposals.get(proposal_id)
720
+ if proposal is not None and proposal.expired():
721
+ self.bridge.bridge_proposals.pop(proposal_id, None)
722
+ proposal = None
723
+ if proposal is None:
724
+ self._send(409, {"error": "unknown or expired proposal; run the task again"})
725
+ return
726
+ if not secrets.compare_digest(digest, proposal.digest):
727
+ self._send(409, {"error": "proposal digest does not match the reviewed change"})
728
+ return
729
+ with self.bridge.bridge_lock:
730
+ if self.bridge.bridge_proposals.pop(proposal_id, None) is None:
731
+ self._send(409, {"error": "proposal was already applied"})
732
+ return
733
+ try:
734
+ workspace = LocalWorkspace.open(self.bridge.bridge_dir)
735
+ receipt = apply_proposal(
736
+ workspace,
737
+ proposal,
738
+ receipts_dir=self.bridge.bridge_state_dir / "receipts",
739
+ origin=self.headers.get("Origin"),
740
+ grant_id=str(grant.get("id") or ""),
741
+ )
742
+ except LocalWorkspaceError as exc:
743
+ self._send(403, {"error": str(exc), "written": []})
744
+ return
745
+ except Exception as exc: # fail closed, name it
746
+ self._send(500, {"error": f"{type(exc).__name__}: {exc}", "written": []})
747
+ return
748
+ self._send(200, {
749
+ "ok": True,
750
+ "written": receipt["written"],
751
+ "receipt": receipt,
752
+ "receipt_path": receipt["receipt_path"],
753
+ "read_only": False,
754
+ })
755
+
756
+
757
+ def serve(
758
+ directory: str | os.PathLike[str],
759
+ *,
760
+ port: int = DEFAULT_PORT,
761
+ token: str | None = None,
762
+ write_capable: bool = False,
763
+ pair_code: str | None = None,
764
+ pair_ttl: float = PAIR_TTL_SECONDS,
765
+ state_dir: str | os.PathLike[str] | None = None,
766
+ allowed_origins: tuple[str, ...] | list[str] | None = None,
767
+ base_url: str | None = None,
768
+ ) -> BridgeServer:
769
+ """Create the loopback-only bridge. Read-only unless ``write_capable``.
770
+
771
+ ``write_capable`` only makes write mode available; an explicit per-session
772
+ grant (``POST /grant-write``) is still required before ``POST /apply``.
773
+ """
774
+ root = Path(directory).expanduser().resolve()
775
+ if not root.is_dir():
776
+ raise SystemExit(f"sid bridge: --dir is not a directory: {root}")
777
+ token = token or secrets.token_urlsafe(24)
778
+ pair_code = pair_code or secrets.token_urlsafe(18)
779
+ state = Path(state_dir).expanduser().resolve() if state_dir else bridge_state_dir()
780
+ origins = tuple(allowed_origins) if allowed_origins is not None else ALLOWED_ORIGINS
781
+ return BridgeServer(
782
+ ("127.0.0.1", port),
783
+ BridgeHandler,
784
+ directory=root,
785
+ token=token,
786
+ write_capable=write_capable,
787
+ pair_code=pair_code,
788
+ pair_ttl=pair_ttl,
789
+ state_dir=state,
790
+ allowed_origins=origins,
791
+ base_url=base_url,
792
+ )
793
+
794
+
795
+ def _pair_url(base_url: str, studio_url: str, code: str) -> str:
796
+ from urllib.parse import urlencode
797
+
798
+ fragment = urlencode({"sid-pair": code, "sid-bridge": base_url})
799
+ return f"{studio_url}#{fragment}"
800
+
801
+
802
+ def main(argv: list[str] | None = None) -> int:
803
+ import argparse
804
+
805
+ ap = argparse.ArgumentParser(prog="sid bridge", description="Run the localhost filesystem bridge for the SID Studio.")
806
+ ap.add_argument("--dir", required=True, help="Directory the Studio may read (fixed at launch).")
807
+ ap.add_argument("--port", type=int, default=DEFAULT_PORT)
808
+ ap.add_argument("--token", default=None, help="Fixed token (default: generated per launch).")
809
+ ap.add_argument("--pair-code", default=None, help="Fixed pairing code (default: generated per launch).")
810
+ ap.add_argument("--pair-ttl", type=float, default=PAIR_TTL_SECONDS)
811
+ ap.add_argument("--write", action="store_true", help="Make write mode available (still needs an explicit grant).")
812
+ ap.add_argument("--state-dir", default=None, help="Receipts/grant state directory (default: ~/.sid/bridge).")
813
+ ap.add_argument("--studio-url", default="https://app.superintelligencedog.com/studio.html")
814
+ ap.add_argument(
815
+ "--allow-origin",
816
+ action="append",
817
+ default=None,
818
+ metavar="ORIGIN",
819
+ help="Add a trusted Studio origin (self-hosted deployment); repeatable.",
820
+ )
821
+ ap.add_argument("--base-url", default=None, help="SID /v1 base URL used for runs (never taken from the browser).")
822
+ args = ap.parse_args(argv)
823
+ origins = ALLOWED_ORIGINS + tuple(args.allow_origin or [])
824
+ httpd = serve(
825
+ args.dir,
826
+ port=args.port,
827
+ token=args.token,
828
+ write_capable=args.write,
829
+ pair_code=args.pair_code,
830
+ pair_ttl=args.pair_ttl,
831
+ state_dir=args.state_dir,
832
+ allowed_origins=origins,
833
+ base_url=args.base_url,
834
+ )
835
+ base = f"http://127.0.0.1:{httpd.server_address[1]}"
836
+ print(f"sid bridge: {base}")
837
+ print(f"mode: {'write available (grant required)' if args.write else 'read-only'}")
838
+ print(f"workspace: {httpd.bridge_dir}")
839
+ print(f"pair this browser (one click, no token paste): {_pair_url(base, args.studio_url, httpd.bridge_pair_code)}")
840
+ print(f"pairing code expires: {int(max(0, httpd.bridge_pair_expires_at - time.time()))}s")
841
+ try:
842
+ httpd.serve_forever()
843
+ except KeyboardInterrupt:
844
+ print("\nsid bridge: stopped")
845
+ finally:
846
+ httpd.server_close()
847
+ return 0
848
+
849
+
850
+ if __name__ == "__main__": # pragma: no cover
851
+ raise SystemExit(main())