@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 +125 -33
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
1719
|
+
remaining = deadline - time.time()
|
|
1720
|
+
if remaining <= 0:
|
|
1715
1721
|
log("read timeout")
|
|
1716
1722
|
return ""
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
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
|
-
|
|
1725
|
-
if
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
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=
|
|
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=
|
|
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:
|
|
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.
|
|
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,
|
|
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,
|
|
4876
|
+
const scanCtx = await fetchScanContext(gatewayUrl, synkroApiKey, repo, activePrNumber, activeSha);
|
|
4785
4877
|
if (scanCtx.skip) {
|
|
4786
|
-
console.log(`Already scanned at ${
|
|
4878
|
+
console.log(`Already scanned at ${activeSha.slice(0, 7)}, skipping.
|
|
4787
4879
|
`);
|
|
4788
|
-
postCheckRun(repo,
|
|
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,
|
|
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,
|
|
4942
|
+
postPrReview(repo, activePrNumber, activeSha, review);
|
|
4851
4943
|
}
|
|
4852
4944
|
}
|
|
4853
4945
|
const conclusion = shouldFail(allFindings, failThreshold) ? "failure" : "success";
|
|
4854
|
-
postCheckRun(repo,
|
|
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
|