@synkro-sh/cli 1.3.40 → 1.3.42

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
@@ -1988,7 +1988,7 @@ Commands:
1988
1988
  status - print "running"/"stopped"
1989
1989
  """
1990
1990
 
1991
- import os, sys, json, socket, time, signal, fcntl, re, select
1991
+ import os, sys, json, socket, time, signal, fcntl, re, select, queue
1992
1992
  import subprocess, threading, urllib.request, urllib.error
1993
1993
  from pathlib import Path
1994
1994
 
@@ -2119,13 +2119,18 @@ def log(msg):
2119
2119
 
2120
2120
  STALL_TIMEOUT_SEC = int(os.environ.get("SYNKRO_DAEMON_STALL_TIMEOUT", "10"))
2121
2121
 
2122
- def _read_response(proc, timeout=45):
2122
+ def _read_response(proc, timeout=45, stop_event=None):
2123
+ """Read stream-json from proc.stdout until a 'result' message arrives.
2124
+ If stop_event is set externally, returns immediately with empty string \u2014
2125
+ used by the parallel-race grade path to abort the loser."""
2123
2126
  acc = []
2124
2127
  deadline = time.time() + timeout
2125
2128
  last_data = time.time()
2126
2129
  fd = proc.stdout.fileno()
2127
2130
  buf = ""
2128
2131
  while True:
2132
+ if stop_event is not None and stop_event.is_set():
2133
+ return ""
2129
2134
  remaining = deadline - time.time()
2130
2135
  if remaining <= 0:
2131
2136
  log("read timeout")
@@ -2133,7 +2138,7 @@ def _read_response(proc, timeout=45):
2133
2138
  if time.time() - last_data > STALL_TIMEOUT_SEC:
2134
2139
  log(f"stall timeout: no data for {STALL_TIMEOUT_SEC}s")
2135
2140
  return ""
2136
- ready, _, _ = select.select([fd], [], [], min(remaining, 2.0))
2141
+ ready, _, _ = select.select([fd], [], [], min(remaining, 1.0))
2137
2142
  if not ready:
2138
2143
  if proc.poll() is not None:
2139
2144
  log("process exited during read")
@@ -2262,39 +2267,71 @@ class WarmGrader:
2262
2267
  self._warm_proc = None
2263
2268
  self._warm_ready_at = 0.0
2264
2269
 
2265
- # Discard warm processes that have been idle too long.
2266
2270
  WARM_TTL_SEC = int(os.environ.get("SYNKRO_DAEMON_WARM_TTL", "15"))
2267
- warm = True
2268
- if not proc or proc.poll() is not None:
2269
- log("no warm process, cold fallback")
2270
- proc = self._make_proc()
2271
- warm = False
2272
- elif proc.stdin.closed:
2273
- log("warm process stdin closed, cold fallback")
2271
+ # Decide if the warm process is usable for the race.
2272
+ warm_usable = bool(proc) and proc.poll() is None and not proc.stdin.closed
2273
+ if warm_usable and ready_at and (time.time() - ready_at) > WARM_TTL_SEC:
2274
2274
  self._kill_proc(proc)
2275
- proc = self._make_proc()
2276
- warm = False
2277
- elif ready_at and (time.time() - ready_at) > WARM_TTL_SEC:
2278
- age = time.time() - ready_at
2279
- log(f"warm process stale ({age:.0f}s > {WARM_TTL_SEC}s), cold fallback")
2275
+ warm_usable = False
2276
+ if not warm_usable and proc:
2280
2277
  self._kill_proc(proc)
2281
- proc = self._make_proc()
2282
- warm = False
2278
+ proc = None
2283
2279
 
2284
2280
  wall_limit = int(os.environ.get("SYNKRO_DAEMON_WALL_TIMEOUT", "12"))
2281
+ race_timeout = min(GRADE_TIMEOUT_SEC, wall_limit)
2282
+
2283
+ # Race a warm and a fresh cold process. Whichever returns a non-empty
2284
+ # response first wins; the other is killed. If we have no warm, just
2285
+ # run cold solo (no benefit to racing two cold spawns of the same age).
2286
+ cold_proc = self._make_proc() if warm_usable else proc or self._make_proc()
2287
+ warm_proc = proc if warm_usable else None
2288
+
2289
+ result_q = queue.Queue()
2290
+ stop_event = threading.Event()
2291
+
2292
+ def grade_worker(p, label):
2293
+ try:
2294
+ _send_msg(p, prompt)
2295
+ r = _read_response(p, timeout=race_timeout, stop_event=stop_event)
2296
+ if r:
2297
+ result_q.put((label, r))
2298
+ except Exception as e:
2299
+ log(f"grade {label} error: {e}")
2300
+
2301
+ threads = []
2302
+ if warm_proc is not None:
2303
+ t = threading.Thread(target=grade_worker, args=(warm_proc, "warm"), daemon=True)
2304
+ t.start()
2305
+ threads.append(t)
2306
+ t = threading.Thread(target=grade_worker, args=(cold_proc, "cold"), daemon=True)
2307
+ t.start()
2308
+ threads.append(t)
2309
+
2285
2310
  t0 = time.time()
2311
+ winner_label = None
2312
+ resp = ""
2286
2313
  try:
2287
- _send_msg(proc, prompt)
2288
- resp = _read_response(proc, timeout=min(GRADE_TIMEOUT_SEC, wall_limit))
2289
- except Exception as e:
2290
- log(f"grade error: {e}")
2291
- resp = ""
2314
+ deadline = time.time() + race_timeout + 2
2315
+ while time.time() < deadline:
2316
+ try:
2317
+ label, r = result_q.get(timeout=0.5)
2318
+ if r:
2319
+ resp = r
2320
+ winner_label = label
2321
+ break
2322
+ except queue.Empty:
2323
+ if all(not th.is_alive() for th in threads):
2324
+ break
2292
2325
  finally:
2293
- self._kill_proc(proc)
2326
+ stop_event.set()
2327
+ if warm_proc is not None:
2328
+ self._kill_proc(warm_proc)
2329
+ self._kill_proc(cold_proc)
2294
2330
 
2295
2331
  elapsed = (time.time() - t0) * 1000
2296
2332
  self._total_grades += 1
2297
- log(f"grade #{self._total_grades} {'warm' if warm else 'cold'} elapsed={elapsed:.0f}ms resp={len(resp)}ch")
2333
+ winner = winner_label or "none"
2334
+ log(f"grade #{self._total_grades} race={'warm+cold' if warm_proc else 'cold'} won={winner} elapsed={elapsed:.0f}ms resp={len(resp)}ch")
2298
2335
 
2299
2336
  self._start_prewarm()
2300
2337
  return resp
@@ -3746,7 +3783,7 @@ function writeConfigEnv(opts) {
3746
3783
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
3747
3784
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
3748
3785
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
3749
- `SYNKRO_VERSION=${shellQuoteSingle("1.3.40")}`
3786
+ `SYNKRO_VERSION=${shellQuoteSingle("1.3.42")}`
3750
3787
  ];
3751
3788
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
3752
3789
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
@@ -4793,54 +4830,9 @@ async function ensureOpenPr(repo, prNumber, sha) {
4793
4830
  try {
4794
4831
  pr = ghJson(["api", `/repos/${repo}/pulls/${prNumber}`]);
4795
4832
  } catch {
4796
- return { prNumber, sha, branched: false };
4797
- }
4798
- if (pr.state === "open") {
4799
- return { prNumber, sha, branched: false };
4800
- }
4801
- if (pr.merged) {
4802
- console.log(`PR #${prNumber} is merged. Scanning original diff and posting findings there.
4803
- `);
4804
- return { prNumber, sha: pr.head.sha, branched: false };
4805
- }
4806
- console.log(`PR #${prNumber} is closed. Creating a scan branch...
4807
- `);
4808
- const scanBranch = `synkro/scan-${pr.head.ref}-${Date.now()}`;
4809
- try {
4810
- execSync5(`gh api -X POST /repos/${repo}/git/refs --input -`, {
4811
- encoding: "utf-8",
4812
- input: JSON.stringify({ ref: `refs/heads/${scanBranch}`, sha: pr.head.sha }),
4813
- stdio: ["pipe", "pipe", "pipe"]
4814
- });
4815
- } catch (err) {
4816
- console.warn(`Failed to create scan branch: ${err.message}`);
4817
- return { prNumber, sha, branched: false };
4818
- }
4819
- try {
4820
- const newPrBody = `Security scan of closed PR #${prNumber} (\`${pr.head.ref}\`).
4821
-
4822
- This PR contains no code changes \u2014 it exists so Synkro can post review findings on an active PR.`;
4823
- const result = ghJson([
4824
- "api",
4825
- "-X",
4826
- "POST",
4827
- `/repos/${repo}/pulls`,
4828
- "-f",
4829
- `title=Synkro Scan: ${pr.title}`,
4830
- "-f",
4831
- `body=${newPrBody}`,
4832
- "-f",
4833
- `head=${scanBranch}`,
4834
- "-f",
4835
- `base=${pr.base.ref}`
4836
- ]);
4837
- console.log(`Opened PR #${result.number} for scan findings.
4838
- `);
4839
- return { prNumber: result.number, sha: result.head.sha, branched: true };
4840
- } catch (err) {
4841
- console.warn(`Failed to open scan PR: ${err.message}`);
4842
- return { prNumber, sha, branched: false };
4833
+ return { prNumber, sha, branched: false, prState: "unknown", merged: false };
4843
4834
  }
4835
+ return { prNumber, sha: pr.head.sha, branched: false, prState: pr.state, merged: pr.merged };
4844
4836
  }
4845
4837
  function getPrFiles(repo, prNumber) {
4846
4838
  const data = ghJson([
@@ -5117,7 +5109,28 @@ ${linesStr}: ${first.description}
5117
5109
  severity: maxSeverity
5118
5110
  };
5119
5111
  }
5120
- function postPrReview(repo, prNumber, sha, review) {
5112
+ function postPrReview(repo, prNumber, sha, review, skipLineReview = false) {
5113
+ function postIssueComment() {
5114
+ try {
5115
+ const body = `## \u{1F512} Synkro Security Review
5116
+
5117
+ ${review.summary}
5118
+
5119
+ ` + review.comments.map((c) => `**${c.path}:${c.line}** \u2014 ${c.body}`).join("\n\n");
5120
+ execSync5(`gh api -X POST /repos/${repo}/issues/${prNumber}/comments --input -`, {
5121
+ encoding: "utf-8",
5122
+ input: JSON.stringify({ body }),
5123
+ stdio: ["pipe", "ignore", "pipe"]
5124
+ });
5125
+ console.log(" \u2713 Posted issue comment with findings.");
5126
+ } catch (err) {
5127
+ console.warn("Failed to post issue comment:", err.message);
5128
+ }
5129
+ }
5130
+ if (skipLineReview) {
5131
+ postIssueComment();
5132
+ return;
5133
+ }
5121
5134
  const preferredEvent = review.severity === "critical" || review.severity === "high" ? "REQUEST_CHANGES" : "COMMENT";
5122
5135
  function tryPost(event) {
5123
5136
  const body = JSON.stringify({
@@ -5143,27 +5156,12 @@ ${review.summary}`,
5143
5156
  if (combined.includes("own pull request") && event === "REQUEST_CHANGES") {
5144
5157
  return false;
5145
5158
  }
5146
- console.warn(`Failed to post review: ${(stderr || stdout || err.message).slice(0, 200)}`);
5147
5159
  return false;
5148
5160
  }
5149
5161
  }
5150
5162
  if (tryPost(preferredEvent)) return;
5151
5163
  if (preferredEvent === "REQUEST_CHANGES" && tryPost("COMMENT")) return;
5152
- try {
5153
- const fallbackBody = `## \u{1F512} Synkro Security Review
5154
-
5155
- ${review.summary}
5156
-
5157
- ` + review.comments.map((c) => `**${c.path}:${c.line}** \u2014 ${c.body}`).join("\n\n");
5158
- execSync5(`gh api -X POST /repos/${repo}/issues/${prNumber}/comments --input -`, {
5159
- encoding: "utf-8",
5160
- input: JSON.stringify({ body: fallbackBody }),
5161
- stdio: ["pipe", "ignore", "pipe"]
5162
- });
5163
- console.log(" \u2713 Posted fallback issue comment.");
5164
- } catch (err2) {
5165
- console.warn("Failed to post fallback comment:", err2.message);
5166
- }
5164
+ postIssueComment();
5167
5165
  }
5168
5166
  function postCheckRun(repo, sha, conclusion, findings) {
5169
5167
  const summary = findings.length === 0 ? "No security findings." : `${findings.length} finding(s):
@@ -5318,7 +5316,8 @@ Total: ${allFindings.length} finding(s) across ${eligible.length} file(s) in ${t
5318
5316
  const review = await spawnOpusConsolidator(allFindings, claudeToken);
5319
5317
  console.log(` \u2192 ${review.comments.length} review comment(s), severity: ${review.severity}`);
5320
5318
  if (review.comments.length > 0) {
5321
- postPrReview(repo, activePrNumber, activeSha, review);
5319
+ const skipLineReview = prTarget.prState === "closed" && !prTarget.merged;
5320
+ postPrReview(repo, activePrNumber, activeSha, review, skipLineReview);
5322
5321
  }
5323
5322
  }
5324
5323
  const conclusion = shouldFail(allFindings, failThreshold) ? "failure" : "success";