@synkro-sh/cli 1.1.7 → 1.2.0
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 +272 -174
- package/dist/bootstrap.js.map +1 -1
- package/package.json +3 -3
package/dist/bootstrap.js
CHANGED
|
@@ -301,6 +301,8 @@ var init_hookScripts = __esm({
|
|
|
301
301
|
# Auth: reads access_token from ~/.synkro/credentials.json, sends Authorization: Bearer.
|
|
302
302
|
set -e
|
|
303
303
|
|
|
304
|
+
synkro_log() { echo "[synkro] $1" >&2; }
|
|
305
|
+
|
|
304
306
|
# Load config
|
|
305
307
|
CONFIG_FILE="$HOME/.synkro/config.env"
|
|
306
308
|
if [ -f "$CONFIG_FILE" ]; then
|
|
@@ -315,11 +317,13 @@ CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
|
|
|
315
317
|
|
|
316
318
|
# Fail open if not authed
|
|
317
319
|
if [ ! -f "$CREDS_PATH" ]; then
|
|
320
|
+
|
|
318
321
|
echo '{}'
|
|
319
322
|
exit 0
|
|
320
323
|
fi
|
|
321
324
|
JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
|
|
322
325
|
if [ -z "$JWT" ]; then
|
|
326
|
+
|
|
323
327
|
echo '{}'
|
|
324
328
|
exit 0
|
|
325
329
|
fi
|
|
@@ -344,6 +348,9 @@ if [ -z "$COMMAND" ]; then
|
|
|
344
348
|
exit 0
|
|
345
349
|
fi
|
|
346
350
|
|
|
351
|
+
CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
|
|
352
|
+
synkro_log "bashGuard checking: $CMD_SHORT"
|
|
353
|
+
|
|
347
354
|
# NO hard regex gate \u2014 server-side bashShapes runs the universal pattern set
|
|
348
355
|
# (including HTTP-payload destructive shapes like graphql_destructive_mutation
|
|
349
356
|
# and http_method_delete) and returns a "trivial" fast-path verdict for boring
|
|
@@ -357,6 +364,14 @@ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
|
357
364
|
TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
|
|
358
365
|
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
359
366
|
TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
|
|
367
|
+
# Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
|
|
368
|
+
GIT_REPO=""
|
|
369
|
+
if command -v git >/dev/null 2>&1; then
|
|
370
|
+
_REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
|
|
371
|
+
if [ -n "$_REMOTE" ]; then
|
|
372
|
+
GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
|
|
373
|
+
fi
|
|
374
|
+
fi
|
|
360
375
|
# Headless detection \u2014 when no human is in the loop, ASK is a no-op so we
|
|
361
376
|
# fail-closed by upgrading high-tier findings to deny.
|
|
362
377
|
PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
|
|
@@ -404,6 +419,7 @@ BODY=$(jq -n \\
|
|
|
404
419
|
--arg session_id "$SESSION_ID" \\
|
|
405
420
|
--arg tool_use_id "$TOOL_USE_ID" \\
|
|
406
421
|
--arg cwd "$CWD" \\
|
|
422
|
+
--arg repo "$GIT_REPO" \\
|
|
407
423
|
'{
|
|
408
424
|
kind: "bash_judge",
|
|
409
425
|
tool_input: $tool_input,
|
|
@@ -412,7 +428,8 @@ BODY=$(jq -n \\
|
|
|
412
428
|
recent_actions: $recent_actions,
|
|
413
429
|
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
414
430
|
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
415
|
-
cwd: (if ($cwd | length) > 0 then $cwd else null end)
|
|
431
|
+
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
432
|
+
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
416
433
|
}')
|
|
417
434
|
|
|
418
435
|
# Helper: refresh JWT via /api/auth/refresh and rewrite credentials.json.
|
|
@@ -464,28 +481,45 @@ if [ -z "$SYNKRO_INFERENCE_TIER" ]; then
|
|
|
464
481
|
fi
|
|
465
482
|
SYNKRO_INFERENCE_TIER="\${SYNKRO_INFERENCE_TIER:-free}"
|
|
466
483
|
|
|
484
|
+
|
|
467
485
|
if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; then
|
|
468
486
|
# \u2500\u2500\u2500 FREE TIER: grade via the persistent claude daemon (mode=bash). \u2500\u2500\u2500
|
|
487
|
+
|
|
488
|
+
# Fetch org guardrail rules relevant to this command (same as edit hook).
|
|
489
|
+
ORG_RULES=$(printf '%s' "$COMMAND" | head -c 4000 \\
|
|
490
|
+
| jq -Rs '{content: .}' \\
|
|
491
|
+
| curl -sS "\${GATEWAY_URL}/api/v1/cli/pr-rules?top_k=15" \\
|
|
492
|
+
-X POST -H "Content-Type: application/json" \\
|
|
493
|
+
-H "Authorization: Bearer $JWT" \\
|
|
494
|
+
-d @- --max-time 2 2>/dev/null \\
|
|
495
|
+
| jq -c '[.rules[]? | {rule_id, text, severity, category}]' 2>/dev/null || echo "[]")
|
|
496
|
+
if [ -z "$ORG_RULES" ] || [ "$ORG_RULES" = "null" ]; then ORG_RULES="[]"; fi
|
|
497
|
+
|
|
469
498
|
GRADER_PROMPT_FILE=$(mktemp -t synkro-bash-prompt.XXXXXX)
|
|
470
499
|
trap "rm -f \\"$GRADER_PROMPT_FILE\\"" EXIT
|
|
471
500
|
printf 'Proposed command: %s\\n' "$COMMAND" > "$GRADER_PROMPT_FILE"
|
|
472
501
|
printf 'User intent: %s\\n' "\${USER_INTENT:-none stated}" >> "$GRADER_PROMPT_FILE"
|
|
473
502
|
printf 'Recent user messages: %s\\n' "$RECENT_USER_MESSAGES" >> "$GRADER_PROMPT_FILE"
|
|
474
503
|
printf 'Recent actions: %s\\n' "$RECENT_ACTIONS" >> "$GRADER_PROMPT_FILE"
|
|
504
|
+
printf 'Org rules: %s\\n\\n' "$ORG_RULES" >> "$GRADER_PROMPT_FILE"
|
|
475
505
|
|
|
476
506
|
if [ -x "$HOME/.synkro/bin/grader_daemon.py" ] && [ -f "$HOME/.synkro/grader-primer-bash.txt" ] && command -v python3 >/dev/null 2>&1; then
|
|
507
|
+
|
|
477
508
|
CC_RESP=$(python3 "$HOME/.synkro/bin/grader_daemon.py" --mode bash grade "$HOME/.synkro/grader-primer-bash.txt" < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
|
|
478
509
|
else
|
|
510
|
+
|
|
479
511
|
CC_RESP=$(claude --print --model claude-sonnet-4-6 --no-session-persistence < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
|
|
480
512
|
fi
|
|
481
513
|
V_INNER=$(printf '%s' "$CC_RESP" | tr '\\n' ' ' | grep -oE '<synkro-verdict>[^<]*</synkro-verdict>' | tail -1 | sed -E 's|^<synkro-verdict>||; s|</synkro-verdict>$||')
|
|
482
514
|
if [ -z "$V_INNER" ] || ! echo "$V_INNER" | jq -e '.severity' >/dev/null 2>&1; then
|
|
515
|
+
synkro_log "bashGuard $CMD_SHORT \u2192 error (no verdict)"
|
|
483
516
|
echo '{}'
|
|
484
517
|
exit 0
|
|
485
518
|
fi
|
|
486
519
|
VERDICT="$V_INNER"
|
|
487
520
|
else
|
|
488
521
|
# \u2500\u2500\u2500 FAST TIER: server-side Cerebras grading. \u2500\u2500\u2500
|
|
522
|
+
|
|
489
523
|
VERDICT=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/judge" \\
|
|
490
524
|
-H "Content-Type: application/json" \\
|
|
491
525
|
-H "Authorization: Bearer $JWT" \\
|
|
@@ -493,6 +527,7 @@ else
|
|
|
493
527
|
--max-time 6 2>/dev/null || echo "")
|
|
494
528
|
|
|
495
529
|
if echo "$VERDICT" | grep -qE '"detail":"Token has expired|"detail":"Invalid or expired token'; then
|
|
530
|
+
|
|
496
531
|
if refresh_jwt; then
|
|
497
532
|
VERDICT=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/judge" \\
|
|
498
533
|
-H "Content-Type: application/json" \\
|
|
@@ -504,6 +539,7 @@ else
|
|
|
504
539
|
fi
|
|
505
540
|
|
|
506
541
|
if [ -z "$VERDICT" ]; then
|
|
542
|
+
synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
|
|
507
543
|
echo '{}'
|
|
508
544
|
exit 0
|
|
509
545
|
fi
|
|
@@ -529,6 +565,7 @@ CATEGORY=$(echo "$VERDICT" | jq -r '.category // "destructive_command"' 2>/dev/n
|
|
|
529
565
|
|
|
530
566
|
case "$SEVERITY" in
|
|
531
567
|
critical)
|
|
568
|
+
synkro_log "bashGuard $CMD_SHORT \u2192 BLOCKED ($CATEGORY): $REASONING"
|
|
532
569
|
SYS_MSG="[synkro] bashGuard \u2192 critical: \${REASONING}"
|
|
533
570
|
if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
|
|
534
571
|
SYS_MSG="\${SYS_MSG}\\n[synkro] suggested \u2192 \${ALTERNATIVE}"
|
|
@@ -553,6 +590,7 @@ case "$SEVERITY" in
|
|
|
553
590
|
}'
|
|
554
591
|
;;
|
|
555
592
|
high)
|
|
593
|
+
synkro_log "bashGuard $CMD_SHORT \u2192 FLAGGED ($CATEGORY): $REASONING"
|
|
556
594
|
SYS_MSG="[synkro] bashGuard \u2192 high: \${REASONING}"
|
|
557
595
|
if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
|
|
558
596
|
SYS_MSG="\${SYS_MSG}\\n[synkro] suggested \u2192 \${ALTERNATIVE}"
|
|
@@ -584,6 +622,7 @@ case "$SEVERITY" in
|
|
|
584
622
|
;;
|
|
585
623
|
*)
|
|
586
624
|
# low / medium / anything else \u2192 silent allow
|
|
625
|
+
synkro_log "bashGuard $CMD_SHORT \u2192 pass"
|
|
587
626
|
echo '{}'
|
|
588
627
|
;;
|
|
589
628
|
esac
|
|
@@ -605,6 +644,8 @@ exit 0
|
|
|
605
644
|
# Always exits 0 with valid JSON. Fails open on any error.
|
|
606
645
|
set -e
|
|
607
646
|
|
|
647
|
+
synkro_log() { echo "[synkro] $1" >&2; }
|
|
648
|
+
|
|
608
649
|
CONFIG_FILE="$HOME/.synkro/config.env"
|
|
609
650
|
if [ -f "$CONFIG_FILE" ]; then
|
|
610
651
|
set -a
|
|
@@ -643,6 +684,14 @@ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
|
643
684
|
TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
|
|
644
685
|
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
645
686
|
TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
687
|
+
# Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
|
|
688
|
+
GIT_REPO=""
|
|
689
|
+
if command -v git >/dev/null 2>&1; then
|
|
690
|
+
_REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
|
|
691
|
+
if [ -n "$_REMOTE" ]; then
|
|
692
|
+
GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
|
|
693
|
+
fi
|
|
694
|
+
fi
|
|
646
695
|
# Headless / non-interactive detection \u2014 when CC won't actually prompt the
|
|
647
696
|
# human, our "ask" verdict is a no-op. Server uses these to fall back to
|
|
648
697
|
# "deny" so we fail-closed instead of silently letting findings through.
|
|
@@ -655,6 +704,9 @@ if [ -z "$FILE_PATH" ]; then
|
|
|
655
704
|
exit 0
|
|
656
705
|
fi
|
|
657
706
|
|
|
707
|
+
FILE_SHORT=$(basename "$FILE_PATH")
|
|
708
|
+
synkro_log "editGuard checking: $FILE_SHORT"
|
|
709
|
+
|
|
658
710
|
# Pull conversation context from the transcript file. CC writes one JSON line
|
|
659
711
|
# per message; we read the tail and extract the most recent user message + the
|
|
660
712
|
# last 5 tool_use blocks. The server uses these as anchors for cosine ranking
|
|
@@ -758,6 +810,7 @@ BODY=$(jq -n \\
|
|
|
758
810
|
--arg cwd "$CWD" \\
|
|
759
811
|
--arg permission_mode "$PERMISSION_MODE" \\
|
|
760
812
|
--arg headless_flag "$HEADLESS_FLAG" \\
|
|
813
|
+
--arg repo "$GIT_REPO" \\
|
|
761
814
|
'{
|
|
762
815
|
file_path: $file_path,
|
|
763
816
|
tool_name: $tool_name,
|
|
@@ -770,7 +823,8 @@ BODY=$(jq -n \\
|
|
|
770
823
|
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
771
824
|
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
772
825
|
permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
|
|
773
|
-
headless: ($headless_flag == "1")
|
|
826
|
+
headless: ($headless_flag == "1"),
|
|
827
|
+
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
774
828
|
}')
|
|
775
829
|
|
|
776
830
|
# Refresh JWT on 401 (mirrors the bash judge pattern).
|
|
@@ -799,25 +853,15 @@ refresh_jwt() {
|
|
|
799
853
|
return 0
|
|
800
854
|
}
|
|
801
855
|
|
|
856
|
+
|
|
802
857
|
if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; then
|
|
803
858
|
# \u2500\u2500\u2500 FREE TIER: grade via the persistent claude daemon (Python helper).
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
# ~14s for cold \`claude --print\`. Falls back to direct \`claude --print\`
|
|
809
|
-
# if the daemon binary or primer is missing.
|
|
810
|
-
|
|
811
|
-
# Fetch the caller's visible org rules and inject into the grader prompt.
|
|
812
|
-
# On-demand MCP retrieval inside the grader was the cleaner architecture
|
|
813
|
-
# but added 20+ seconds of latency per grade because the model has to
|
|
814
|
-
# call get_guardrails, wait for cosine ranking, then reason. For free-tier
|
|
815
|
-
# local CC inference that's unacceptable. Pre-stuffing the rules costs
|
|
816
|
-
# tokens but keeps grade latency in the 1-3s range. Bounded at 1.5s; on
|
|
817
|
-
# failure proceed with empty rules (degrades to baseline-only judging).
|
|
818
|
-
ORG_RULES=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/pr-rules" \\
|
|
859
|
+
ORG_RULES=$(printf '%s' "$PROPOSED" | head -c 8000 \\
|
|
860
|
+
| jq -Rs '{content: .}' \\
|
|
861
|
+
| curl -sS "\${GATEWAY_URL}/api/v1/cli/pr-rules?top_k=20" \\
|
|
862
|
+
-X POST -H "Content-Type: application/json" \\
|
|
819
863
|
-H "Authorization: Bearer $JWT" \\
|
|
820
|
-
--max-time
|
|
864
|
+
-d @- --max-time 2 2>/dev/null \\
|
|
821
865
|
| jq -c '[.rules[]? | {rule_id, text, severity, category, mode}]' 2>/dev/null || echo "[]")
|
|
822
866
|
if [ -z "$ORG_RULES" ] || [ "$ORG_RULES" = "null" ]; then ORG_RULES="[]"; fi
|
|
823
867
|
|
|
@@ -864,6 +908,7 @@ if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; t
|
|
|
864
908
|
--arg cwd "$CWD" \\
|
|
865
909
|
--arg permission_mode "$PERMISSION_MODE" \\
|
|
866
910
|
--arg headless_flag "$HEADLESS_FLAG" \\
|
|
911
|
+
--arg repo "$GIT_REPO" \\
|
|
867
912
|
'{
|
|
868
913
|
verdict: $verdict,
|
|
869
914
|
file_path: $file_path,
|
|
@@ -877,7 +922,8 @@ if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; t
|
|
|
877
922
|
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
878
923
|
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
879
924
|
permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
|
|
880
|
-
headless: ($headless_flag == "1")
|
|
925
|
+
headless: ($headless_flag == "1"),
|
|
926
|
+
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
881
927
|
}')
|
|
882
928
|
|
|
883
929
|
RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/precheck-edit/local-verdict" \\
|
|
@@ -914,20 +960,26 @@ else
|
|
|
914
960
|
fi
|
|
915
961
|
fi
|
|
916
962
|
|
|
917
|
-
# Server returns the literal CC hook JSON shape ({} for allow, or
|
|
918
|
-
# hookSpecificOutput.permissionDecision: "deny" + reason). If the call failed
|
|
919
|
-
# entirely or returned non-JSON, fail open with empty {}.
|
|
920
963
|
if [ -z "$RESP" ]; then
|
|
964
|
+
synkro_log "editGuard $FILE_SHORT \u2192 error (timeout)"
|
|
921
965
|
echo '{}'
|
|
922
966
|
exit 0
|
|
923
967
|
fi
|
|
924
968
|
|
|
925
|
-
# Cheap validation \u2014 only forward if it parses as a JSON object.
|
|
926
969
|
if ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
|
|
970
|
+
synkro_log "editGuard $FILE_SHORT \u2192 error (bad response)"
|
|
927
971
|
echo '{}'
|
|
928
972
|
exit 0
|
|
929
973
|
fi
|
|
930
974
|
|
|
975
|
+
DECISION=$(echo "$RESP" | jq -r '.hookSpecificOutput.permissionDecision // "allow"' 2>/dev/null)
|
|
976
|
+
if [ "$DECISION" = "deny" ]; then
|
|
977
|
+
DENY_REASON=$(echo "$RESP" | jq -r '.hookSpecificOutput.permissionDecisionReason // ""' 2>/dev/null)
|
|
978
|
+
synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED: $DENY_REASON"
|
|
979
|
+
else
|
|
980
|
+
synkro_log "editGuard $FILE_SHORT \u2192 pass"
|
|
981
|
+
fi
|
|
982
|
+
|
|
931
983
|
echo "$RESP"
|
|
932
984
|
exit 0
|
|
933
985
|
`;
|
|
@@ -947,6 +999,8 @@ exit 0
|
|
|
947
999
|
# Always exits 0 with valid JSON \u2014 never breaks CC's flow.
|
|
948
1000
|
set -e
|
|
949
1001
|
|
|
1002
|
+
synkro_log() { echo "[synkro] $1" >&2; }
|
|
1003
|
+
|
|
950
1004
|
CONFIG_FILE="$HOME/.synkro/config.env"
|
|
951
1005
|
if [ -f "$CONFIG_FILE" ]; then
|
|
952
1006
|
set -a
|
|
@@ -984,6 +1038,14 @@ TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
|
|
|
984
1038
|
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
985
1039
|
TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
|
|
986
1040
|
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
1041
|
+
# Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
|
|
1042
|
+
GIT_REPO=""
|
|
1043
|
+
if command -v git >/dev/null 2>&1; then
|
|
1044
|
+
_REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
|
|
1045
|
+
if [ -n "$_REMOTE" ]; then
|
|
1046
|
+
GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
|
|
1047
|
+
fi
|
|
1048
|
+
fi
|
|
987
1049
|
|
|
988
1050
|
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
|
|
989
1051
|
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
|
|
@@ -992,6 +1054,7 @@ if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
|
|
|
992
1054
|
fi
|
|
993
1055
|
|
|
994
1056
|
BASENAME=$(basename "$FILE_PATH")
|
|
1057
|
+
synkro_log "editScan checking: $BASENAME"
|
|
995
1058
|
|
|
996
1059
|
# Read post-edit file content (cap 64KB).
|
|
997
1060
|
FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
|
|
@@ -1012,13 +1075,15 @@ BODY=$(jq -n \\
|
|
|
1012
1075
|
--arg session_id "$SESSION_ID" \\
|
|
1013
1076
|
--arg tool_use_id "$TOOL_USE_ID" \\
|
|
1014
1077
|
--arg cwd "$CWD" \\
|
|
1078
|
+
--arg repo "$GIT_REPO" \\
|
|
1015
1079
|
'{
|
|
1016
1080
|
file_path: $file_path,
|
|
1017
1081
|
content: $content,
|
|
1018
1082
|
diff: $diff,
|
|
1019
1083
|
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
1020
1084
|
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
1021
|
-
cwd: (if ($cwd | length) > 0 then $cwd else null end)
|
|
1085
|
+
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
1086
|
+
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1022
1087
|
}')
|
|
1023
1088
|
|
|
1024
1089
|
refresh_jwt() {
|
|
@@ -1078,9 +1143,22 @@ SYNKRO_INFERENCE_TIER="\${SYNKRO_INFERENCE_TIER:-free}"
|
|
|
1078
1143
|
|
|
1079
1144
|
if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; then
|
|
1080
1145
|
# \u2500\u2500\u2500 FREE TIER: grade via the persistent claude daemon (mode=edit). \u2500\u2500\u2500
|
|
1146
|
+
|
|
1147
|
+
# Fetch org guardrail rules relevant to this file content.
|
|
1148
|
+
ORG_RULES=$(printf '%s' "$FILE_CONTENT" | head -c 8000 \\
|
|
1149
|
+
| jq -Rs '{content: .}' \\
|
|
1150
|
+
| curl -sS "\${GATEWAY_URL}/api/v1/cli/pr-rules?top_k=20" \\
|
|
1151
|
+
-X POST -H "Content-Type: application/json" \\
|
|
1152
|
+
-H "Authorization: Bearer $JWT" \\
|
|
1153
|
+
-d @- --max-time 2 2>/dev/null \\
|
|
1154
|
+
| jq -c '[.rules[]? | {rule_id, text, severity, category}]' 2>/dev/null || echo "[]")
|
|
1155
|
+
if [ -z "$ORG_RULES" ] || [ "$ORG_RULES" = "null" ]; then ORG_RULES="[]"; fi
|
|
1156
|
+
|
|
1081
1157
|
GRADER_PROMPT_FILE=$(mktemp -t synkro-edit-capture.XXXXXX)
|
|
1082
1158
|
trap "rm -f \\"$GRADER_PROMPT_FILE\\"" EXIT
|
|
1083
|
-
printf 'File: %s\\n
|
|
1159
|
+
printf 'File: %s\\n' "$FILE_PATH" > "$GRADER_PROMPT_FILE"
|
|
1160
|
+
printf 'Org rules: %s\\n\\n' "$ORG_RULES" >> "$GRADER_PROMPT_FILE"
|
|
1161
|
+
printf 'Content:\\n' >> "$GRADER_PROMPT_FILE"
|
|
1084
1162
|
printf '%s\\n' "$FILE_CONTENT" >> "$GRADER_PROMPT_FILE"
|
|
1085
1163
|
|
|
1086
1164
|
if [ -x "$HOME/.synkro/bin/grader_daemon.py" ] && [ -f "$HOME/.synkro/grader-primer-edit.txt" ] && command -v python3 >/dev/null 2>&1; then
|
|
@@ -1114,6 +1192,7 @@ else
|
|
|
1114
1192
|
fi
|
|
1115
1193
|
|
|
1116
1194
|
if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
|
|
1195
|
+
synkro_log "editScan $BASENAME \u2192 error (no response)"
|
|
1117
1196
|
if [ "\${SYNKRO_VERBOSE:-0}" = "1" ]; then
|
|
1118
1197
|
SYS_MSG="[synkro] afterFileEdit \u2192 scanned \${BASENAME} (grader unavailable)"
|
|
1119
1198
|
jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
|
|
@@ -1129,6 +1208,7 @@ CATEGORY=$(echo "$RESP" | jq -r '.category // "unspecified"' 2>/dev/null)
|
|
|
1129
1208
|
REASON=$(echo "$RESP" | jq -r '.reason // ""' 2>/dev/null)
|
|
1130
1209
|
|
|
1131
1210
|
if [ "$OK" = "false" ] && [ -n "$REASON" ]; then
|
|
1211
|
+
synkro_log "editScan $BASENAME \u2192 FAIL ($CATEGORY): $REASON"
|
|
1132
1212
|
SYS_MSG="[synkro] afterFileEdit \u2192 Finding in \${BASENAME}: \${REASON}"
|
|
1133
1213
|
ADDITIONAL_CTX="Synkro post-edit grader flagged \${BASENAME} (severity: \${SEVERITY}, category: \${CATEGORY}). Re-edit the file applying the retry guidance: \${REASON}"
|
|
1134
1214
|
jq -n \\
|
|
@@ -1144,6 +1224,8 @@ if [ "$OK" = "false" ] && [ -n "$REASON" ]; then
|
|
|
1144
1224
|
exit 0
|
|
1145
1225
|
fi
|
|
1146
1226
|
|
|
1227
|
+
synkro_log "editScan $BASENAME \u2192 pass"
|
|
1228
|
+
|
|
1147
1229
|
if [ "\${SYNKRO_VERBOSE:-0}" = "1" ]; then
|
|
1148
1230
|
SYS_MSG="[synkro] afterFileEdit \u2192 no issues in \${BASENAME}"
|
|
1149
1231
|
jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
|
|
@@ -1262,16 +1344,18 @@ if [ -z "$RESP" ]; then
|
|
|
1262
1344
|
exit 0
|
|
1263
1345
|
fi
|
|
1264
1346
|
|
|
1347
|
+
PLAN_NUDGE="Before implementing any multi-step plan, call the synkro-guardrails analyze_plan tool with your implementation plan to check for relevant org coding rules."
|
|
1348
|
+
|
|
1265
1349
|
OPEN=$(echo "$RESP" | jq -r '.open_count // 0' 2>/dev/null)
|
|
1266
1350
|
if [ "$OPEN" = "0" ] || [ -z "$OPEN" ]; then
|
|
1267
|
-
|
|
1351
|
+
jq -n --arg sys_msg "[synkro] $PLAN_NUDGE" '{ systemMessage: $sys_msg }'
|
|
1268
1352
|
exit 0
|
|
1269
1353
|
fi
|
|
1270
1354
|
|
|
1271
1355
|
if [ "$OPEN" = "1" ]; then
|
|
1272
|
-
SYS_MSG="[synkro] session start \u2192 1 open finding in this repo from a prior session"
|
|
1356
|
+
SYS_MSG="[synkro] session start \u2192 1 open finding in this repo from a prior session. $PLAN_NUDGE"
|
|
1273
1357
|
else
|
|
1274
|
-
SYS_MSG="[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions"
|
|
1358
|
+
SYS_MSG="[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions. $PLAN_NUDGE"
|
|
1275
1359
|
fi
|
|
1276
1360
|
|
|
1277
1361
|
jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
|
|
@@ -1329,15 +1413,14 @@ var init_graderDaemon = __esm({
|
|
|
1329
1413
|
"use strict";
|
|
1330
1414
|
GRADER_DAEMON_PY = `#!/usr/bin/env python3
|
|
1331
1415
|
"""
|
|
1332
|
-
Synkro grader
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
across N gradings.
|
|
1416
|
+
Synkro warm-pool grader \u2014 pre-warmed \`claude --print --system-prompt\` process
|
|
1417
|
+
pool fronted by a Unix socket. Each grade uses one warm process and kills it;
|
|
1418
|
+
a replacement is pre-warmed in the background.
|
|
1336
1419
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1420
|
+
Zero context bloat: 1 grade per process, system prompt via --system-prompt flag
|
|
1421
|
+
(single inference call, no primer-as-conversation-turn overhead).
|
|
1422
|
+
|
|
1423
|
+
Warm steady-state: ~2-3s per grade. Cold fallback: ~5-6s if pre-warm not ready.
|
|
1341
1424
|
|
|
1342
1425
|
Commands:
|
|
1343
1426
|
start [primer-path] - bring up daemon if not running
|
|
@@ -1346,13 +1429,10 @@ Commands:
|
|
|
1346
1429
|
status - print "running"/"stopped"
|
|
1347
1430
|
"""
|
|
1348
1431
|
|
|
1349
|
-
import os, sys, json, socket, time,
|
|
1432
|
+
import os, sys, json, socket, time, signal, fcntl, re
|
|
1350
1433
|
import subprocess, threading
|
|
1351
1434
|
from pathlib import Path
|
|
1352
1435
|
|
|
1353
|
-
# Each "mode" gets its own daemon process: separate socket, pid, log.
|
|
1354
|
-
# Modes: "edit" (precheck + post-edit, schema {ok, severity, ...}) and "bash"
|
|
1355
|
-
# (schema {verdict, severity, ...}). Selected via --mode <name>; default "edit".
|
|
1356
1436
|
ALLOWED_MODE_RE = re.compile(r"^[a-z][a-z0-9_-]{0,30}$")
|
|
1357
1437
|
DAEMON_BASE = Path.home() / ".synkro" / "daemon"
|
|
1358
1438
|
DAEMON_BASE.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
@@ -1365,11 +1445,10 @@ def mode_paths(mode):
|
|
|
1365
1445
|
MODE = "edit"
|
|
1366
1446
|
PID_FILE, SOCK_PATH, LOG_FILE = mode_paths(MODE)
|
|
1367
1447
|
|
|
1368
|
-
|
|
1369
|
-
ROTATION_AGE_SEC = int(os.environ.get("SYNKRO_DAEMON_ROTATE_AGE", "3600"))
|
|
1370
|
-
GRADE_TIMEOUT_SEC = int(os.environ.get("SYNKRO_DAEMON_GRADE_TIMEOUT", "10"))
|
|
1448
|
+
GRADE_TIMEOUT_SEC = int(os.environ.get("SYNKRO_DAEMON_GRADE_TIMEOUT", "45"))
|
|
1371
1449
|
DEFAULT_MODEL = os.environ.get("SYNKRO_DAEMON_MODEL", "claude-sonnet-4-6")
|
|
1372
1450
|
MAX_PROMPT_BYTES = 4 * 1024 * 1024
|
|
1451
|
+
IDLE_SHUTDOWN_SEC = int(os.environ.get("SYNKRO_DAEMON_IDLE_TIMEOUT", "600"))
|
|
1373
1452
|
|
|
1374
1453
|
|
|
1375
1454
|
def log(msg):
|
|
@@ -1380,146 +1459,139 @@ def log(msg):
|
|
|
1380
1459
|
pass
|
|
1381
1460
|
|
|
1382
1461
|
|
|
1383
|
-
|
|
1462
|
+
def _read_response(proc, timeout=45):
|
|
1463
|
+
acc = []
|
|
1464
|
+
deadline = time.time() + timeout
|
|
1465
|
+
while True:
|
|
1466
|
+
if time.time() > deadline:
|
|
1467
|
+
log("read timeout")
|
|
1468
|
+
return ""
|
|
1469
|
+
line = proc.stdout.readline()
|
|
1470
|
+
if not line:
|
|
1471
|
+
return ""
|
|
1472
|
+
try:
|
|
1473
|
+
obj = json.loads(line)
|
|
1474
|
+
except json.JSONDecodeError:
|
|
1475
|
+
continue
|
|
1476
|
+
t = obj.get("type")
|
|
1477
|
+
if t == "assistant":
|
|
1478
|
+
for c in obj.get("message", {}).get("content", []):
|
|
1479
|
+
if c.get("type") == "text":
|
|
1480
|
+
acc.append(c["text"])
|
|
1481
|
+
elif t == "result":
|
|
1482
|
+
return "".join(acc)
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def _send_msg(proc, text):
|
|
1486
|
+
msg = json.dumps({
|
|
1487
|
+
"type": "user",
|
|
1488
|
+
"message": {"role": "user", "content": [{"type": "text", "text": text}]},
|
|
1489
|
+
"parent_tool_use_id": None,
|
|
1490
|
+
"session_id": "",
|
|
1491
|
+
})
|
|
1492
|
+
proc.stdin.write(msg + "\\n")
|
|
1493
|
+
proc.stdin.flush()
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
class WarmGrader:
|
|
1497
|
+
"""
|
|
1498
|
+
Keeps one pre-warmed claude process ready. Each grade pulls the warm
|
|
1499
|
+
process, sends one prompt, reads the verdict, kills the process, and
|
|
1500
|
+
starts pre-warming a replacement in the background.
|
|
1384
1501
|
|
|
1385
|
-
|
|
1502
|
+
The warm process has the system prompt loaded via --system-prompt and
|
|
1503
|
+
its KV cache primed by a warmup turn. The actual grade is a single
|
|
1504
|
+
inference call that benefits from the cached system prompt tokens.
|
|
1505
|
+
"""
|
|
1386
1506
|
def __init__(self, primer):
|
|
1387
1507
|
self.primer = primer or ""
|
|
1388
|
-
self.
|
|
1389
|
-
self.
|
|
1390
|
-
self.
|
|
1391
|
-
self.
|
|
1392
|
-
self.
|
|
1393
|
-
self._prewarm_thread = None
|
|
1394
|
-
self._spawn()
|
|
1508
|
+
self._warm_proc = None
|
|
1509
|
+
self._warm_thread = None
|
|
1510
|
+
self._lock = threading.Lock()
|
|
1511
|
+
self._total_grades = 0
|
|
1512
|
+
self._start_prewarm()
|
|
1395
1513
|
|
|
1396
1514
|
def _make_proc(self):
|
|
1515
|
+
cmd = [
|
|
1516
|
+
"claude", "--print", "--model", DEFAULT_MODEL,
|
|
1517
|
+
"--input-format=stream-json",
|
|
1518
|
+
"--output-format=stream-json",
|
|
1519
|
+
"--verbose",
|
|
1520
|
+
"--no-session-persistence",
|
|
1521
|
+
]
|
|
1522
|
+
if self.primer:
|
|
1523
|
+
cmd += ["--system-prompt", self.primer]
|
|
1397
1524
|
return subprocess.Popen(
|
|
1398
|
-
|
|
1399
|
-
"claude", "--print", "--model", DEFAULT_MODEL,
|
|
1400
|
-
"--input-format=stream-json",
|
|
1401
|
-
"--output-format=stream-json",
|
|
1402
|
-
"--verbose",
|
|
1403
|
-
"--no-session-persistence",
|
|
1404
|
-
],
|
|
1525
|
+
cmd,
|
|
1405
1526
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
1406
1527
|
stderr=subprocess.DEVNULL, text=True, bufsize=1,
|
|
1407
1528
|
)
|
|
1408
1529
|
|
|
1409
|
-
def
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
proc.stdin.write(msg + "\\n")
|
|
1417
|
-
proc.stdin.flush()
|
|
1418
|
-
|
|
1419
|
-
def _recv_from(self, proc):
|
|
1420
|
-
acc = []
|
|
1421
|
-
deadline = time.time() + GRADE_TIMEOUT_SEC
|
|
1422
|
-
while True:
|
|
1423
|
-
if time.time() > deadline:
|
|
1424
|
-
log("recv timeout")
|
|
1425
|
-
return ""
|
|
1426
|
-
line = proc.stdout.readline()
|
|
1427
|
-
if not line:
|
|
1428
|
-
return ""
|
|
1429
|
-
try:
|
|
1430
|
-
obj = json.loads(line)
|
|
1431
|
-
except json.JSONDecodeError:
|
|
1432
|
-
continue
|
|
1433
|
-
t = obj.get("type")
|
|
1434
|
-
if t == "assistant":
|
|
1435
|
-
for c in obj.get("message", {}).get("content", []):
|
|
1436
|
-
if c.get("type") == "text":
|
|
1437
|
-
acc.append(c["text"])
|
|
1438
|
-
elif t == "result":
|
|
1439
|
-
return "".join(acc)
|
|
1440
|
-
|
|
1441
|
-
def _spawn(self):
|
|
1442
|
-
if self.proc and self.proc.poll() is None:
|
|
1443
|
-
try: self.proc.terminate(); self.proc.wait(timeout=3)
|
|
1444
|
-
except Exception: self.proc.kill()
|
|
1445
|
-
log("spawning claude subprocess")
|
|
1446
|
-
self.proc = self._make_proc()
|
|
1447
|
-
if self.primer:
|
|
1448
|
-
self._send_to(self.proc, self.primer)
|
|
1449
|
-
primer_resp = self._recv_from(self.proc)
|
|
1450
|
-
log(f"primer ack: {primer_resp[:80]!r}")
|
|
1451
|
-
self.calls = 0
|
|
1452
|
-
self.start_time = time.time()
|
|
1453
|
-
self._next_proc = None
|
|
1454
|
-
|
|
1455
|
-
def _prewarm_worker(self):
|
|
1530
|
+
def _kill_proc(self, proc):
|
|
1531
|
+
try: proc.stdin.close()
|
|
1532
|
+
except Exception: pass
|
|
1533
|
+
try: proc.kill(); proc.wait(timeout=2)
|
|
1534
|
+
except Exception: pass
|
|
1535
|
+
|
|
1536
|
+
def _prewarm(self):
|
|
1456
1537
|
try:
|
|
1457
|
-
log("pre-warming
|
|
1538
|
+
log("pre-warming process")
|
|
1458
1539
|
proc = self._make_proc()
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1540
|
+
_send_msg(proc, "Ready")
|
|
1541
|
+
resp = _read_response(proc, timeout=30)
|
|
1542
|
+
if resp:
|
|
1543
|
+
with self._lock:
|
|
1544
|
+
old = self._warm_proc
|
|
1545
|
+
self._warm_proc = proc
|
|
1546
|
+
if old:
|
|
1547
|
+
self._kill_proc(old)
|
|
1548
|
+
log(f"pre-warm ready ({len(resp)} chars)")
|
|
1549
|
+
else:
|
|
1550
|
+
log("pre-warm response empty")
|
|
1551
|
+
self._kill_proc(proc)
|
|
1465
1552
|
except Exception as e:
|
|
1466
1553
|
log(f"pre-warm failed: {e}")
|
|
1467
|
-
self._next_proc = None
|
|
1468
|
-
|
|
1469
|
-
def _maybe_prewarm(self):
|
|
1470
|
-
if self._prewarm_thread and self._prewarm_thread.is_alive():
|
|
1471
|
-
return
|
|
1472
|
-
if self._next_proc is not None:
|
|
1473
|
-
return
|
|
1474
|
-
if self.calls >= ROTATION_CALLS - PREWARM_HEADROOM:
|
|
1475
|
-
self._prewarm_thread = threading.Thread(target=self._prewarm_worker, daemon=True)
|
|
1476
|
-
self._prewarm_thread.start()
|
|
1477
|
-
|
|
1478
|
-
def _try_rotate(self):
|
|
1479
|
-
if self._next_proc and self._next_proc.poll() is None:
|
|
1480
|
-
log("hot-swapping to pre-warmed subprocess")
|
|
1481
|
-
old = self.proc
|
|
1482
|
-
self.proc = self._next_proc
|
|
1483
|
-
self._next_proc = None
|
|
1484
|
-
self._prewarm_thread = None
|
|
1485
|
-
self.calls = 0
|
|
1486
|
-
self.start_time = time.time()
|
|
1487
|
-
if old and old.poll() is None:
|
|
1488
|
-
threading.Thread(target=lambda: (old.terminate(), old.wait()), daemon=True).start()
|
|
1489
|
-
return True
|
|
1490
|
-
log("pre-warm not ready, deferring rotation")
|
|
1491
|
-
return False
|
|
1492
1554
|
|
|
1493
|
-
def
|
|
1555
|
+
def _start_prewarm(self):
|
|
1556
|
+
self._warm_thread = threading.Thread(target=self._prewarm, daemon=True)
|
|
1557
|
+
self._warm_thread.start()
|
|
1558
|
+
|
|
1559
|
+
def grade(self, prompt):
|
|
1560
|
+
if self._warm_thread:
|
|
1561
|
+
self._warm_thread.join(timeout=60)
|
|
1562
|
+
|
|
1563
|
+
with self._lock:
|
|
1564
|
+
proc = self._warm_proc
|
|
1565
|
+
self._warm_proc = None
|
|
1566
|
+
|
|
1567
|
+
warm = True
|
|
1568
|
+
if not proc or proc.poll() is not None:
|
|
1569
|
+
log("no warm process, cold fallback")
|
|
1570
|
+
proc = self._make_proc()
|
|
1571
|
+
warm = False
|
|
1572
|
+
|
|
1573
|
+
t0 = time.time()
|
|
1494
1574
|
try:
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1575
|
+
_send_msg(proc, prompt)
|
|
1576
|
+
resp = _read_response(proc, timeout=GRADE_TIMEOUT_SEC)
|
|
1577
|
+
except Exception as e:
|
|
1578
|
+
log(f"grade error: {e}")
|
|
1579
|
+
resp = ""
|
|
1580
|
+
finally:
|
|
1581
|
+
self._kill_proc(proc)
|
|
1582
|
+
|
|
1583
|
+
elapsed = (time.time() - t0) * 1000
|
|
1584
|
+
self._total_grades += 1
|
|
1585
|
+
log(f"grade #{self._total_grades} {'warm' if warm else 'cold'} elapsed={elapsed:.0f}ms resp={len(resp)}ch")
|
|
1586
|
+
|
|
1587
|
+
self._start_prewarm()
|
|
1506
1588
|
return resp
|
|
1507
1589
|
|
|
1508
|
-
def
|
|
1509
|
-
with self.
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
elapsed = (time.time() - t0) * 1000
|
|
1514
|
-
self.calls += 1
|
|
1515
|
-
log(f"grade #{self.calls} elapsed={elapsed:.0f}ms resp_chars={len(resp)}")
|
|
1516
|
-
age = time.time() - self.start_time
|
|
1517
|
-
if self.calls >= ROTATION_CALLS or age >= ROTATION_AGE_SEC:
|
|
1518
|
-
if not self._try_rotate():
|
|
1519
|
-
self._maybe_prewarm()
|
|
1520
|
-
else:
|
|
1521
|
-
self._maybe_prewarm()
|
|
1522
|
-
return resp
|
|
1590
|
+
def shutdown(self):
|
|
1591
|
+
with self._lock:
|
|
1592
|
+
if self._warm_proc:
|
|
1593
|
+
self._kill_proc(self._warm_proc)
|
|
1594
|
+
self._warm_proc = None
|
|
1523
1595
|
|
|
1524
1596
|
|
|
1525
1597
|
def serve(primer):
|
|
@@ -1533,7 +1605,7 @@ def serve(primer):
|
|
|
1533
1605
|
os.write(pid_fd, f"{os.getpid()}\\n".encode())
|
|
1534
1606
|
os.fsync(pid_fd)
|
|
1535
1607
|
|
|
1536
|
-
|
|
1608
|
+
grader = WarmGrader(primer)
|
|
1537
1609
|
|
|
1538
1610
|
if SOCK_PATH.exists():
|
|
1539
1611
|
SOCK_PATH.unlink()
|
|
@@ -1547,24 +1619,30 @@ def serve(primer):
|
|
|
1547
1619
|
except Exception: pass
|
|
1548
1620
|
try: PID_FILE.unlink()
|
|
1549
1621
|
except Exception: pass
|
|
1550
|
-
|
|
1551
|
-
except Exception: pass
|
|
1622
|
+
grader.shutdown()
|
|
1552
1623
|
sys.exit(0)
|
|
1553
1624
|
signal.signal(signal.SIGTERM, cleanup)
|
|
1554
1625
|
signal.signal(signal.SIGINT, cleanup)
|
|
1555
1626
|
|
|
1556
|
-
log(f"daemon ready model={DEFAULT_MODEL} sock={SOCK_PATH}")
|
|
1627
|
+
log(f"daemon ready model={DEFAULT_MODEL} idle_shutdown={IDLE_SHUTDOWN_SEC}s sock={SOCK_PATH}")
|
|
1557
1628
|
|
|
1629
|
+
last_activity = time.time()
|
|
1630
|
+
sock.settimeout(30)
|
|
1558
1631
|
while True:
|
|
1559
1632
|
try:
|
|
1560
1633
|
conn, _ = sock.accept()
|
|
1561
|
-
|
|
1634
|
+
last_activity = time.time()
|
|
1635
|
+
threading.Thread(target=_handle_conn, args=(conn, grader), daemon=True).start()
|
|
1636
|
+
except socket.timeout:
|
|
1637
|
+
if time.time() - last_activity > IDLE_SHUTDOWN_SEC:
|
|
1638
|
+
log(f"idle for {IDLE_SHUTDOWN_SEC}s, shutting down")
|
|
1639
|
+
cleanup()
|
|
1562
1640
|
except Exception as e:
|
|
1563
1641
|
log(f"accept error: {e}")
|
|
1564
1642
|
time.sleep(0.1)
|
|
1565
1643
|
|
|
1566
1644
|
|
|
1567
|
-
def _handle_conn(conn,
|
|
1645
|
+
def _handle_conn(conn, grader):
|
|
1568
1646
|
try:
|
|
1569
1647
|
with conn:
|
|
1570
1648
|
length_bytes = b""
|
|
@@ -1581,7 +1659,7 @@ def _handle_conn(conn, daemon):
|
|
|
1581
1659
|
chunk = conn.recv(min(65536, length - len(prompt)))
|
|
1582
1660
|
if not chunk: break
|
|
1583
1661
|
prompt += chunk
|
|
1584
|
-
response =
|
|
1662
|
+
response = grader.grade(prompt.decode("utf-8", errors="replace"))
|
|
1585
1663
|
resp_bytes = response.encode("utf-8")
|
|
1586
1664
|
conn.sendall(len(resp_bytes).to_bytes(8, "big"))
|
|
1587
1665
|
conn.sendall(resp_bytes)
|
|
@@ -1708,7 +1786,7 @@ JUDGING PRIORITY:
|
|
|
1708
1786
|
2. BASELINE security issues \u2014 hardcoded real-looking secrets, eval/exec on user input, SQL string concat with untrusted input, MD5/SHA1 for security-sensitive purposes, unsafe deserialization, command injection, path traversal, missing auth on routes that mutate user/billing data, weak random for tokens, broken JWT verification, CORS misconfig, env-dump logging. Flag these even if no org rule covers them \u2014 they're universally bad. Use a sensible snake_case rule_id like \`no-hardcoded-secrets\`, \`eval-on-user-input\`, \`sql-string-concat\`.
|
|
1709
1787
|
3. Stylistic issues, placeholder fixtures, test files (path under /tests/, /__tests__/, *.test.*), and config-only files are NOT security issues \u2014 return ok=true.
|
|
1710
1788
|
|
|
1711
|
-
INDEPENDENCE: Each grade request is INDEPENDENT.
|
|
1789
|
+
INDEPENDENCE: Each grade request is INDEPENDENT. Judge ONLY the current request's File / User intent / Org rules / Diff. Prior "allows" do NOT authorize the current request \u2014 re-evaluate fresh against the rules in THIS prompt.
|
|
1712
1790
|
|
|
1713
1791
|
OUTPUT RULES \u2014 strictest possible, no exceptions:
|
|
1714
1792
|
|
|
@@ -2144,7 +2222,7 @@ function writeConfigEnv(opts) {
|
|
|
2144
2222
|
`SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
|
|
2145
2223
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
2146
2224
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
2147
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.
|
|
2225
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.2.0")}`
|
|
2148
2226
|
];
|
|
2149
2227
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
2150
2228
|
if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
|
|
@@ -2269,6 +2347,17 @@ async function installCommand(opts = {}) {
|
|
|
2269
2347
|
console.log(` ${scripts.sessionStartScript}
|
|
2270
2348
|
`);
|
|
2271
2349
|
writeGraderDaemon();
|
|
2350
|
+
for (const mode of ["edit", "bash"]) {
|
|
2351
|
+
const pidFile = join4(SYNKRO_DIR, "daemon", mode, "daemon.pid");
|
|
2352
|
+
try {
|
|
2353
|
+
const pid = parseInt(readFileSync4(pidFile, "utf-8").trim(), 10);
|
|
2354
|
+
if (pid > 0) {
|
|
2355
|
+
process.kill(pid, "SIGTERM");
|
|
2356
|
+
console.log(`Stopped stale ${mode} daemon (pid ${pid})`);
|
|
2357
|
+
}
|
|
2358
|
+
} catch {
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2272
2361
|
console.log("Wrote local-tier grader daemon:");
|
|
2273
2362
|
console.log(` ${GRADER_DAEMON_PATH}`);
|
|
2274
2363
|
console.log(` ${GRADER_PRIMER_EDIT_PATH}`);
|
|
@@ -2463,6 +2552,15 @@ function statusCommand() {
|
|
|
2463
2552
|
console.log(` gateway: ${config.SYNKRO_GATEWAY_URL ?? "(unset)"}`);
|
|
2464
2553
|
console.log(` credentials: ${config.SYNKRO_CREDENTIALS_PATH ?? "(unset)"}`);
|
|
2465
2554
|
console.log(` tier: ${config.SYNKRO_TIER ?? "(unset)"}`);
|
|
2555
|
+
const info2 = getUserInfo();
|
|
2556
|
+
const userId = info2?.id ?? config.SYNKRO_USER_ID ?? "default";
|
|
2557
|
+
const tierCacheFile = join5(SYNKRO_DIR2, `.tier-cache-${userId}`);
|
|
2558
|
+
let inferenceTier = config.SYNKRO_INFERENCE_TIER || null;
|
|
2559
|
+
if (!inferenceTier && existsSync6(tierCacheFile)) {
|
|
2560
|
+
inferenceTier = readFileSync5(tierCacheFile, "utf-8").trim() || null;
|
|
2561
|
+
}
|
|
2562
|
+
const tierLabel = inferenceTier === "fast" ? "'fast' (server-side grading)" : inferenceTier === "free" ? "'free' (local daemon grading)" : "(unknown \u2014 fires on next hook)";
|
|
2563
|
+
console.log(` inference: ${tierLabel}`);
|
|
2466
2564
|
console.log(` version: ${config.SYNKRO_VERSION ?? "(unset)"}`);
|
|
2467
2565
|
console.log();
|
|
2468
2566
|
const agents = detectAgents();
|