@synkro-sh/cli 1.3.25 → 1.3.27

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.
package/dist/bootstrap.js CHANGED
@@ -1677,7 +1677,7 @@ Commands:
1677
1677
  status - print "running"/"stopped"
1678
1678
  """
1679
1679
 
1680
- import os, sys, json, socket, time, signal, fcntl, re
1680
+ import os, sys, json, socket, time, signal, fcntl, re, select
1681
1681
  import subprocess, threading
1682
1682
  from pathlib import Path
1683
1683
 
@@ -1707,27 +1707,49 @@ def log(msg):
1707
1707
  pass
1708
1708
 
1709
1709
 
1710
+ STALL_TIMEOUT_SEC = int(os.environ.get("SYNKRO_DAEMON_STALL_TIMEOUT", "10"))
1711
+
1710
1712
  def _read_response(proc, timeout=45):
1711
1713
  acc = []
1712
1714
  deadline = time.time() + timeout
1715
+ last_data = time.time()
1716
+ fd = proc.stdout.fileno()
1717
+ buf = ""
1713
1718
  while True:
1714
- if time.time() > deadline:
1719
+ remaining = deadline - time.time()
1720
+ if remaining <= 0:
1715
1721
  log("read timeout")
1716
1722
  return ""
1717
- line = proc.stdout.readline()
1718
- if not line:
1723
+ if time.time() - last_data > STALL_TIMEOUT_SEC:
1724
+ log(f"stall timeout: no data for {STALL_TIMEOUT_SEC}s")
1719
1725
  return ""
1720
- try:
1721
- obj = json.loads(line)
1722
- except json.JSONDecodeError:
1726
+ ready, _, _ = select.select([fd], [], [], min(remaining, 2.0))
1727
+ if not ready:
1728
+ if proc.poll() is not None:
1729
+ log("process exited during read")
1730
+ return ""
1723
1731
  continue
1724
- t = obj.get("type")
1725
- if t == "assistant":
1726
- for c in obj.get("message", {}).get("content", []):
1727
- if c.get("type") == "text":
1728
- acc.append(c["text"])
1729
- elif t == "result":
1730
- return "".join(acc)
1732
+ chunk = os.read(fd, 65536)
1733
+ if not chunk:
1734
+ return ""
1735
+ last_data = time.time()
1736
+ buf += chunk.decode("utf-8", errors="replace")
1737
+ while "\\n" in buf:
1738
+ line, buf = buf.split("\\n", 1)
1739
+ line = line.strip()
1740
+ if not line:
1741
+ continue
1742
+ try:
1743
+ obj = json.loads(line)
1744
+ except json.JSONDecodeError:
1745
+ continue
1746
+ t = obj.get("type")
1747
+ if t == "assistant":
1748
+ for c in obj.get("message", {}).get("content", []):
1749
+ if c.get("type") == "text":
1750
+ acc.append(c["text"])
1751
+ elif t == "result":
1752
+ return "".join(acc)
1731
1753
 
1732
1754
 
1733
1755
  def _send_msg(proc, text):
@@ -1757,6 +1779,7 @@ class WarmGrader:
1757
1779
  self._warm_thread = None
1758
1780
  self._lock = threading.Lock()
1759
1781
  self._total_grades = 0
1782
+ self._prewarm_ok = True
1760
1783
  self._start_prewarm()
1761
1784
 
1762
1785
  def _make_proc(self):
@@ -1786,27 +1809,32 @@ class WarmGrader:
1786
1809
  log("pre-warming process")
1787
1810
  proc = self._make_proc()
1788
1811
  _send_msg(proc, "Ready")
1789
- resp = _read_response(proc, timeout=30)
1812
+ resp = _read_response(proc, timeout=15)
1790
1813
  if resp:
1791
1814
  with self._lock:
1792
1815
  old = self._warm_proc
1793
1816
  self._warm_proc = proc
1817
+ self._prewarm_ok = True
1794
1818
  if old:
1795
1819
  self._kill_proc(old)
1796
1820
  log(f"pre-warm ready ({len(resp)} chars)")
1797
1821
  else:
1798
1822
  log("pre-warm response empty")
1799
1823
  self._kill_proc(proc)
1824
+ self._prewarm_ok = False
1800
1825
  except Exception as e:
1801
1826
  log(f"pre-warm failed: {e}")
1827
+ self._prewarm_ok = False
1802
1828
 
1803
1829
  def _start_prewarm(self):
1804
1830
  self._warm_thread = threading.Thread(target=self._prewarm, daemon=True)
1805
1831
  self._warm_thread.start()
1806
1832
 
1807
1833
  def grade(self, prompt):
1808
- if self._warm_thread:
1809
- self._warm_thread.join(timeout=60)
1834
+ if self._warm_thread and self._prewarm_ok:
1835
+ self._warm_thread.join(timeout=8)
1836
+ elif self._warm_thread and not self._prewarm_ok:
1837
+ log("skipping prewarm join (last prewarm failed)")
1810
1838
 
1811
1839
  with self._lock:
1812
1840
  proc = self._warm_proc
@@ -1817,11 +1845,17 @@ class WarmGrader:
1817
1845
  log("no warm process, cold fallback")
1818
1846
  proc = self._make_proc()
1819
1847
  warm = False
1848
+ elif proc.stdin.closed:
1849
+ log("warm process stdin closed, cold fallback")
1850
+ self._kill_proc(proc)
1851
+ proc = self._make_proc()
1852
+ warm = False
1820
1853
 
1854
+ wall_limit = int(os.environ.get("SYNKRO_DAEMON_WALL_TIMEOUT", "12"))
1821
1855
  t0 = time.time()
1822
1856
  try:
1823
1857
  _send_msg(proc, prompt)
1824
- resp = _read_response(proc, timeout=GRADE_TIMEOUT_SEC)
1858
+ resp = _read_response(proc, timeout=min(GRADE_TIMEOUT_SEC, wall_limit))
1825
1859
  except Exception as e:
1826
1860
  log(f"grade error: {e}")
1827
1861
  resp = ""
@@ -2448,6 +2482,7 @@ async function callApi(method, endpoint, body) {
2448
2482
  throw new Error("API URL not configured. Run `synkro install` first.");
2449
2483
  }
2450
2484
  const url = `${API_URL}${endpoint}`;
2485
+ await ensureValidToken();
2451
2486
  const accessToken = getAccessToken();
2452
2487
  const headers = {
2453
2488
  "Content-Type": "application/json"
@@ -2508,7 +2543,7 @@ jobs:
2508
2543
  scan:
2509
2544
  runs-on: ubuntu-latest
2510
2545
  permissions:
2511
- contents: read
2546
+ contents: write
2512
2547
  pull-requests: write
2513
2548
  checks: write
2514
2549
  steps:
@@ -3333,7 +3368,7 @@ function writeConfigEnv(opts) {
3333
3368
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
3334
3369
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
3335
3370
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
3336
- `SYNKRO_VERSION=${shellQuoteSingle("1.3.25")}`
3371
+ `SYNKRO_VERSION=${shellQuoteSingle("1.3.27")}`
3337
3372
  ];
3338
3373
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
3339
3374
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
@@ -4374,6 +4409,60 @@ function ghJson(args2) {
4374
4409
  });
4375
4410
  return JSON.parse(out);
4376
4411
  }
4412
+ async function ensureOpenPr(repo, prNumber, sha) {
4413
+ let pr;
4414
+ try {
4415
+ pr = ghJson(["api", `/repos/${repo}/pulls/${prNumber}`]);
4416
+ } catch {
4417
+ return { prNumber, sha, branched: false };
4418
+ }
4419
+ if (pr.state === "open") {
4420
+ return { prNumber, sha, branched: false };
4421
+ }
4422
+ if (pr.merged) {
4423
+ console.log(`PR #${prNumber} is merged. Scanning original diff and posting findings there.
4424
+ `);
4425
+ return { prNumber, sha: pr.head.sha, branched: false };
4426
+ }
4427
+ console.log(`PR #${prNumber} is closed. Creating a scan branch...
4428
+ `);
4429
+ const scanBranch = `synkro/scan-${pr.head.ref}-${Date.now()}`;
4430
+ try {
4431
+ execSync5(`gh api -X POST /repos/${repo}/git/refs --input -`, {
4432
+ encoding: "utf-8",
4433
+ input: JSON.stringify({ ref: `refs/heads/${scanBranch}`, sha: pr.head.sha }),
4434
+ stdio: ["pipe", "pipe", "pipe"]
4435
+ });
4436
+ } catch (err) {
4437
+ console.warn(`Failed to create scan branch: ${err.message}`);
4438
+ return { prNumber, sha, branched: false };
4439
+ }
4440
+ try {
4441
+ const newPrBody = `Security scan of closed PR #${prNumber} (\`${pr.head.ref}\`).
4442
+
4443
+ This PR contains no code changes \u2014 it exists so Synkro can post review findings on an active PR.`;
4444
+ const result = ghJson([
4445
+ "api",
4446
+ "-X",
4447
+ "POST",
4448
+ `/repos/${repo}/pulls`,
4449
+ "-f",
4450
+ `title=Synkro Scan: ${pr.title}`,
4451
+ "-f",
4452
+ `body=${newPrBody}`,
4453
+ "-f",
4454
+ `head=${scanBranch}`,
4455
+ "-f",
4456
+ `base=${pr.base.ref}`
4457
+ ]);
4458
+ console.log(`Opened PR #${result.number} for scan findings.
4459
+ `);
4460
+ return { prNumber: result.number, sha: result.head.sha, branched: true };
4461
+ } catch (err) {
4462
+ console.warn(`Failed to open scan PR: ${err.message}`);
4463
+ return { prNumber, sha, branched: false };
4464
+ }
4465
+ }
4377
4466
  function getPrFiles(repo, prNumber) {
4378
4467
  const data = ghJson([
4379
4468
  "api",
@@ -4766,6 +4855,9 @@ async function scanPrCommand() {
4766
4855
  }
4767
4856
  console.log(`Synkro scan-pr: ${repo}#${prNumber} @ ${sha.slice(0, 7)}
4768
4857
  `);
4858
+ const prTarget = await ensureOpenPr(repo, prNumber, sha);
4859
+ const activePrNumber = prTarget.prNumber;
4860
+ const activeSha = prTarget.sha;
4769
4861
  const orgRules = await fetchOrgRules(gatewayUrl, synkroApiKey);
4770
4862
  const auditRules = orgRules.filter((r) => r.mode === "audit");
4771
4863
  const literalNegativeRules = orgRules.filter((r) => {
@@ -4776,22 +4868,22 @@ async function scanPrCommand() {
4776
4868
  const promptHeader = buildPrPrompt(auditRules);
4777
4869
  let files;
4778
4870
  try {
4779
- files = getPrFiles(repo, prNumber);
4871
+ files = getPrFiles(repo, activePrNumber);
4780
4872
  } catch (err) {
4781
4873
  console.error("Failed to fetch PR files:", err.message);
4782
4874
  process.exit(2);
4783
4875
  }
4784
- const scanCtx = await fetchScanContext(gatewayUrl, synkroApiKey, repo, prNumber, sha);
4876
+ const scanCtx = await fetchScanContext(gatewayUrl, synkroApiKey, repo, activePrNumber, activeSha);
4785
4877
  if (scanCtx.skip) {
4786
- console.log(`Already scanned at ${sha.slice(0, 7)}, skipping.
4878
+ console.log(`Already scanned at ${activeSha.slice(0, 7)}, skipping.
4787
4879
  `);
4788
- postCheckRun(repo, sha, "success", []);
4880
+ postCheckRun(repo, activeSha, "success", []);
4789
4881
  await postEventToBackend({
4790
4882
  gatewayUrl,
4791
4883
  apiKey: synkroApiKey,
4792
4884
  repo,
4793
- prNumber,
4794
- sha,
4885
+ prNumber: activePrNumber,
4886
+ sha: activeSha,
4795
4887
  findings: [],
4796
4888
  filesScanned: 0,
4797
4889
  totalLatencyMs: 0
@@ -4814,13 +4906,13 @@ async function scanPrCommand() {
4814
4906
  console.log(`${files.length} files in PR, ${eligible.length} eligible for scan.
4815
4907
  `);
4816
4908
  if (eligible.length === 0) {
4817
- postCheckRun(repo, sha, "success", []);
4909
+ postCheckRun(repo, activeSha, "success", []);
4818
4910
  await postEventToBackend({
4819
4911
  gatewayUrl,
4820
4912
  apiKey: synkroApiKey,
4821
4913
  repo,
4822
- prNumber,
4823
- sha,
4914
+ prNumber: activePrNumber,
4915
+ sha: activeSha,
4824
4916
  findings: [],
4825
4917
  filesScanned: 0,
4826
4918
  totalLatencyMs: 0
@@ -4847,17 +4939,17 @@ Total: ${allFindings.length} finding(s) across ${eligible.length} file(s) in ${t
4847
4939
  const review = await spawnOpusConsolidator(allFindings, claudeToken);
4848
4940
  console.log(` \u2192 ${review.comments.length} review comment(s), severity: ${review.severity}`);
4849
4941
  if (review.comments.length > 0) {
4850
- postPrReview(repo, prNumber, sha, review);
4942
+ postPrReview(repo, activePrNumber, activeSha, review);
4851
4943
  }
4852
4944
  }
4853
4945
  const conclusion = shouldFail(allFindings, failThreshold) ? "failure" : "success";
4854
- postCheckRun(repo, sha, conclusion, allFindings);
4946
+ postCheckRun(repo, activeSha, conclusion, allFindings);
4855
4947
  await postEventToBackend({
4856
4948
  gatewayUrl,
4857
4949
  apiKey: synkroApiKey,
4858
4950
  repo,
4859
- prNumber,
4860
- sha,
4951
+ prNumber: activePrNumber,
4952
+ sha: activeSha,
4861
4953
  findings: allFindings,
4862
4954
  filesScanned: eligible.length,
4863
4955
  totalLatencyMs