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.
- sid_harness/__init__.py +13 -0
- sid_harness/__main__.py +4 -0
- sid_harness/_native.py +1639 -0
- sid_harness/brave_proof.py +246 -0
- sid_harness/bridge.py +851 -0
- sid_harness/cli.py +937 -0
- sid_harness/client.py +161 -0
- sid_harness/companion.py +151 -0
- sid_harness/config.py +68 -0
- sid_harness/context/__init__.py +40 -0
- sid_harness/context/index.py +267 -0
- sid_harness/context/retrieval.py +74 -0
- sid_harness/editor.py +231 -0
- sid_harness/gateway.py +106 -0
- sid_harness/git_write.py +598 -0
- sid_harness/local_workspace.py +421 -0
- sid_harness/loop.py +394 -0
- sid_harness/lsp.py +598 -0
- sid_harness/policy/__init__.py +44 -0
- sid_harness/policy/repo_policy.py +626 -0
- sid_harness/receipt.py +142 -0
- sid_harness/runner.py +468 -0
- sid_harness/selftest.py +149 -0
- sid_harness/session/__init__.py +33 -0
- sid_harness/session/bounds.py +140 -0
- sid_harness/session/diff_gate.py +93 -0
- sid_harness/session/session.py +427 -0
- sid_harness/session/store.py +88 -0
- sid_harness/tools/__init__.py +52 -0
- sid_harness/tools/mcp_client.py +285 -0
- sid_harness/tools/plan.py +138 -0
- sid_harness/tools/registry.py +159 -0
- sid_harness/tools/skills.py +185 -0
- sid_harness/tools/subagents.py +112 -0
- sid_harness/tui.py +96 -0
- sid_harness/workspace.py +107 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
- sid_harness-0.3.0.dist-info/METADATA +229 -0
- sid_harness-0.3.0.dist-info/RECORD +45 -0
- sid_harness-0.3.0.dist-info/WHEEL +5 -0
- sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
- 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())
|