loki-mode 11.7.1 → 11.9.1

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.
Files changed (92) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/lib/backlog.py +40 -4
  4. package/autonomy/lib/chat.sh +3 -2
  5. package/autonomy/lib/workspace.py +95 -4
  6. package/autonomy/loki +185 -106
  7. package/autonomy/trigger-server.py +550 -5
  8. package/autonomy/verify.sh +5 -0
  9. package/bin/loki +1 -1
  10. package/completions/_loki +34 -1
  11. package/completions/loki.bash +10 -3
  12. package/dashboard/__init__.py +1 -1
  13. package/docs/ARCHITECTURE.md +0 -1
  14. package/docs/CLI-REFERENCE.md +1 -1
  15. package/docs/COMPONENTS.md +1 -1
  16. package/docs/DEPLOY-PLAN.md +13 -3
  17. package/docs/INSTALLATION.md +1 -1
  18. package/docs/WORKSPACES.md +1 -1
  19. package/docs/dev/architecture-reference.md +1 -1
  20. package/docs/sentry-intake.md +36 -0
  21. package/docs/v10/D61-SPEED.md +14 -0
  22. package/docs/v10/PROGRESS.md +22 -0
  23. package/loki-ts/dist/loki.js +962 -879
  24. package/mcp/__init__.py +1 -1
  25. package/package.json +2 -2
  26. package/packages/control-plane/dist/ask-tools-server.js +13 -4
  27. package/packages/control-plane/dist/server.js +614 -456
  28. package/packages/control-plane/src/server/diffstat.ts +10 -2
  29. package/packages/control-plane/src/server/legacy/routes-control.ts +8 -4
  30. package/packages/control-plane/src/server/routes/checkpoints.ts +11 -2
  31. package/packages/control-plane/ui/dist/assets/index-u8cd6TRQ.js +190 -0
  32. package/packages/control-plane/ui/dist/index.html +1 -1
  33. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  34. package/web-app/dist/assets/{AdminPage-S3BYeapm.js → AdminPage-BdH9iL2I.js} +1 -1
  35. package/web-app/dist/assets/{Avatar-C1rHwLgn.js → Avatar-C-WixxR6.js} +1 -1
  36. package/web-app/dist/assets/{Badge-CzlnN5aQ.js → Badge-BFWpRd_L.js} +1 -1
  37. package/web-app/dist/assets/{Button-B1w5AeGC.js → Button-CE5vG66Y.js} +1 -1
  38. package/web-app/dist/assets/{CockpitPage-Dyeqjfkc.js → CockpitPage-BAVn10bY.js} +1 -1
  39. package/web-app/dist/assets/{ComparePage-C6y2GkGR.js → ComparePage-DOecIGfM.js} +1 -1
  40. package/web-app/dist/assets/{ErrorBoundary-Dg2fcGkR.js → ErrorBoundary-1rNA5v7f.js} +1 -1
  41. package/web-app/dist/assets/{EvidenceReceiptPanel-CxbyjvTj.js → EvidenceReceiptPanel-DjOaWfdH.js} +1 -1
  42. package/web-app/dist/assets/{GitHubIssuesPanel-Dpys1xDb.js → GitHubIssuesPanel-NNPBuRPD.js} +1 -1
  43. package/web-app/dist/assets/{GitHubPRsPanel-CRVjWoFl.js → GitHubPRsPanel-CL1b--tV.js} +1 -1
  44. package/web-app/dist/assets/{HomePage-BKnv-oYw.js → HomePage-BsLDN6MW.js} +2 -2
  45. package/web-app/dist/assets/{LoginPage-BGksmFRd.js → LoginPage-CVIv5bCS.js} +1 -1
  46. package/web-app/dist/assets/{MagicPage-OCvhp16M.js → MagicPage-BgMBB9lK.js} +1 -1
  47. package/web-app/dist/assets/{MetricsPage-CJOJpXWC.js → MetricsPage-BHeJlqPC.js} +1 -1
  48. package/web-app/dist/assets/{NotFoundPage-C04p3QvN.js → NotFoundPage-DDzwfyDK.js} +1 -1
  49. package/web-app/dist/assets/{ProjectPage-BRB8txsY.js → ProjectPage-CuQWEYBF.js} +3 -3
  50. package/web-app/dist/assets/{ProjectsPage-CoUYeQEh.js → ProjectsPage-OoD0RFDZ.js} +1 -1
  51. package/web-app/dist/assets/{SettingsPage-BkjmJGfM.js → SettingsPage-tMFMKkK5.js} +1 -1
  52. package/web-app/dist/assets/{ShowcasePage-B4vU9TjQ.js → ShowcasePage-DVfLoWdz.js} +1 -1
  53. package/web-app/dist/assets/{SystemSettingsPage-Z2jiVsni.js → SystemSettingsPage-CThV9N4l.js} +1 -1
  54. package/web-app/dist/assets/{TeamsPage-Ulm8SISq.js → TeamsPage-CV0nvXI8.js} +1 -1
  55. package/web-app/dist/assets/{TemplatesPage-C_7MM6Ey.js → TemplatesPage-D81EI1L8.js} +1 -1
  56. package/web-app/dist/assets/{TerminalOutput-Cumd7Tgs.js → TerminalOutput-Bj7uKRIe.js} +1 -1
  57. package/web-app/dist/assets/{activity-CZAUkbiY.js → activity-DoNfNkOL.js} +1 -1
  58. package/web-app/dist/assets/{bell-BOvSXpEr.js → bell-BWO3OU50.js} +1 -1
  59. package/web-app/dist/assets/{bot-DOTZxBYj.js → bot-DcvWv_zD.js} +1 -1
  60. package/web-app/dist/assets/{check-BxpE0B5u.js → check-CfG09Z18.js} +1 -1
  61. package/web-app/dist/assets/{chevron-left-D6KPvBOA.js → chevron-left-z0fh4x2h.js} +1 -1
  62. package/web-app/dist/assets/{circle-alert-Bajq86oe.js → circle-alert-C_7GlYul.js} +1 -1
  63. package/web-app/dist/assets/{clock-CnlHAVJc.js → clock-BsYUp070.js} +1 -1
  64. package/web-app/dist/assets/{cloud-DjaymjwM.js → cloud-BVWAJ7Rv.js} +1 -1
  65. package/web-app/dist/assets/{code-xml-BADEw2Ld.js → code-xml-CdZT-6zu.js} +1 -1
  66. package/web-app/dist/assets/{database-B4aKb5Cq.js → database-Dbkm9t2D.js} +1 -1
  67. package/web-app/dist/assets/{dollar-sign-DrldXpja.js → dollar-sign-CfIUM5Pd.js} +1 -1
  68. package/web-app/dist/assets/{file-code-corner-DVebDVR-.js → file-code-corner-CrUKhnj-.js} +1 -1
  69. package/web-app/dist/assets/{file-plus-BD-N_-Tl.js → file-plus-Cf6u0xHY.js} +1 -1
  70. package/web-app/dist/assets/{globe-C7ieInC-.js → globe-DsIqaI7w.js} +1 -1
  71. package/web-app/dist/assets/{hammer-BSugKIji.js → hammer-CA14BeMp.js} +1 -1
  72. package/web-app/dist/assets/{index-nLQgfvOn.js → index-DpJ_GJuZ.js} +3 -3
  73. package/web-app/dist/assets/{layers-CHXxU2on.js → layers-Dh5Oqhrj.js} +1 -1
  74. package/web-app/dist/assets/{loader-circle-BEEz0LbD.js → loader-circle-B0WHIGqt.js} +1 -1
  75. package/web-app/dist/assets/{lock-CzQGfcnE.js → lock-DkgfEGYF.js} +1 -1
  76. package/web-app/dist/assets/{package-CcHYQLGd.js → package-LzjmwD1S.js} +1 -1
  77. package/web-app/dist/assets/{plus-DdzgFEXn.js → plus-BNjA0KSV.js} +1 -1
  78. package/web-app/dist/assets/{refresh-cw-7x0HbH9Y.js → refresh-cw-k3IacuDX.js} +1 -1
  79. package/web-app/dist/assets/{rotate-ccw-DBahX1Sp.js → rotate-ccw-BFON_xv-.js} +1 -1
  80. package/web-app/dist/assets/{scroll-text-BB8y8a9Z.js → scroll-text-CaIlSPZB.js} +1 -1
  81. package/web-app/dist/assets/{server-CfkbW2MH.js → server-CKFUccCG.js} +1 -1
  82. package/web-app/dist/assets/{shield-alert-DUgD8rBF.js → shield-alert-Q4-rPo5r.js} +1 -1
  83. package/web-app/dist/assets/{thumbs-up-DGoyY6QC.js → thumbs-up-BYMeh8oV.js} +1 -1
  84. package/web-app/dist/assets/{trash-2-B6xrSUMr.js → trash-2-BHSUEIHw.js} +1 -1
  85. package/web-app/dist/assets/{trending-down-B6RGKTOx.js → trending-down-BC_FR2VT.js} +1 -1
  86. package/web-app/dist/assets/{trending-up-B44DCrt1.js → trending-up-CmA-WmLW.js} +1 -1
  87. package/web-app/dist/assets/{upload-4kWm33ZY.js → upload-BxLgBa1B.js} +1 -1
  88. package/web-app/dist/assets/{usePolling-zkUtRQG6.js → usePolling-DKNgwt5n.js} +1 -1
  89. package/web-app/dist/assets/{user-04cEVIe2.js → user-CA2DlmLc.js} +1 -1
  90. package/web-app/dist/index.html +1 -1
  91. package/memory/rag_injector.py +0 -161
  92. package/packages/control-plane/ui/dist/assets/index-DbLzr87u.js +0 -190
@@ -38,6 +38,7 @@ Usage:
38
38
  """
39
39
 
40
40
  import argparse
41
+ import base64
41
42
  import collections
42
43
  import hashlib
43
44
  import hmac
@@ -55,8 +56,10 @@ import subprocess
55
56
  import sys
56
57
  import threading
57
58
  import time
58
- from datetime import datetime
59
+ import urllib.parse
60
+ from datetime import datetime, timezone
59
61
  from pathlib import Path
62
+ from urllib.request import Request, urlopen
60
63
 
61
64
  # Receipt attestation. Imported by path because trigger-server.py runs as a
62
65
  # script from an arbitrary cwd, so a bare `import receipt_jwt` would depend on
@@ -412,6 +415,12 @@ CHILD_ENV_DENYLIST = (
412
415
  "LOKI_API_TOKEN_FILE",
413
416
  "LOKI_SENTRY_WEBHOOK_SECRET",
414
417
  "SENTRY_CLIENT_SECRET",
418
+ "SLACK_SIGNING_SECRET",
419
+ "SLACK_BOT_TOKEN",
420
+ "LINEAR_WEBHOOK_SECRET",
421
+ "LINEAR_API_KEY",
422
+ "JIRA_WEBHOOK_SECRET",
423
+ "TEAMS_SECURITY_TOKEN",
415
424
  )
416
425
 
417
426
 
@@ -577,6 +586,14 @@ def handle_workflow_run_event(payload, dry_run=False):
577
586
  return summary, status
578
587
 
579
588
 
589
+ def brief_start_args(brief):
590
+ """argv for a delegated PR run. `--brief` forces brief mode so the text is
591
+ never reclassified as a PRD path or issue URL, and `--pr --detach` are only
592
+ honored by `loki start` in brief mode because cmd_start routes them through
593
+ the worktree + PR path."""
594
+ return ["start", "--brief", brief, "--pr", "--detach"]
595
+
596
+
580
597
  def handle_job_event(payload, dry_run=False):
581
598
  """Handle a remotely-submitted job: loki start <spec> --detach.
582
599
 
@@ -588,7 +605,11 @@ def handle_job_event(payload, dry_run=False):
588
605
  spec = payload.get("spec")
589
606
  if not valid_spec(spec):
590
607
  return None, "rejected (invalid spec)"
591
- args = ["start", spec.strip(), "--detach"]
608
+ # Jira/Teams delegation sets pr=True: receipt-gated PR run, never a deploy.
609
+ if payload.get("pr") is True:
610
+ args = brief_start_args(spec.strip())
611
+ else:
612
+ args = ["start", spec.strip(), "--detach"]
592
613
  summary = "job %s: %s" % (payload.get("job_id", "?"), spec.strip())
593
614
  # A remote submitter gates CI on this, so report the BUILD's outcome, not
594
615
  # merely that a build was launched. "fired" (launched) and "passed" must
@@ -622,6 +643,250 @@ def validate_sentry_signature(secret, body, signature_header):
622
643
  return constant_time_equals(expected, signature_header.strip().lower())
623
644
 
624
645
 
646
+ # Slack and Linear delegation. Same signed-body contract as
647
+ # loki-ts/src/contrib/slack_inbound.ts (signSlackBody/verifySlackSignature).
648
+ SIGNATURE_MAX_SKEW_S = 300
649
+ SLACK_REPLY_PREFIX = "https://hooks.slack.com/"
650
+ LINEAR_API_URL = "https://api.linear.app/graphql"
651
+ LINEAR_IDENTIFIER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$")
652
+ NOTIFY_TIMEOUT_SECONDS = 10
653
+ NOTIFY_TEXT_MAX = 1000
654
+
655
+
656
+ def sign_slack_body(secret, timestamp, body):
657
+ """`v0=` + hex HMAC-SHA256 over `v0:<timestamp>:<raw body>` (bytes)."""
658
+ base = b"v0:" + str(timestamp).encode("utf-8") + b":" + body
659
+ return "v0=" + hmac.new(secret.encode("utf-8"), base, hashlib.sha256).hexdigest()
660
+
661
+
662
+ def verify_slack_signature(secret, timestamp, body, signature, now=None):
663
+ """Fail closed: no secret, header, numeric timestamp or fresh window -> False."""
664
+ if not secret or not timestamp or not signature:
665
+ return False
666
+ if not re.fullmatch(r"[0-9]+", timestamp):
667
+ return False
668
+ now = time.time() if now is None else now
669
+ if abs(now - int(timestamp)) > SIGNATURE_MAX_SKEW_S:
670
+ return False
671
+ return constant_time_equals(sign_slack_body(secret, timestamp, body), signature)
672
+
673
+
674
+ def verify_linear_signature(secret, body, signature):
675
+ """Linear-Signature: hex HMAC-SHA256 of the raw body. Freshness is checked
676
+ separately against the signed body's webhookTimestamp (linear_fresh)."""
677
+ if not secret or not signature:
678
+ return False
679
+ expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
680
+ return constant_time_equals(expected, signature.strip().lower())
681
+
682
+
683
+ def linear_fresh(payload, now=None):
684
+ ts_val = payload.get("webhookTimestamp")
685
+ if isinstance(ts_val, bool) or not isinstance(ts_val, (int, float)):
686
+ return False
687
+ secs = ts_val / 1000.0 if ts_val > 1e11 else float(ts_val)
688
+ now = time.time() if now is None else now
689
+ return abs(now - secs) <= SIGNATURE_MAX_SKEW_S
690
+
691
+
692
+ def resolve_chat_secrets(env, secret, api_token, sentry_secret):
693
+ """Return (slack_secret, linear_secret), blanking any that equals another
694
+ credential: each authenticates a different party and MUST differ."""
695
+ slack = env.get("SLACK_SIGNING_SECRET", "")
696
+ linear = env.get("LINEAR_WEBHOOK_SECRET", "")
697
+ others = [x for x in (secret, api_token, sentry_secret) if x]
698
+ slack_bad = bool(slack) and any(
699
+ constant_time_equals(slack, o) for o in others + ([linear] if linear else []))
700
+ linear_bad = bool(linear) and any(
701
+ constant_time_equals(linear, o) for o in others + ([slack] if slack else []))
702
+ if slack_bad:
703
+ logging.error("The Slack secret is identical to another credential. "
704
+ "They MUST differ; disabling /slack.")
705
+ if linear_bad:
706
+ logging.error("The Linear secret is identical to another credential. "
707
+ "They MUST differ; disabling /linear.")
708
+ return ("" if slack_bad else slack), ("" if linear_bad else linear)
709
+
710
+
711
+ # Jira Cloud and Microsoft Teams delegation. Both enqueue a PR-only run.
712
+ JIRA_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*-\d+$")
713
+ JIRA_EVENTS = ("jira:issue_created", "jira:issue_updated")
714
+ JIRA_TRIGGER_FIELDS = ("assignee", "labels")
715
+ DELEGATED_BRIEF_MAX = 1500
716
+ HTML_TAG_RE = re.compile(r"<[^>]*>")
717
+
718
+
719
+ def verify_jira_signature(secret, body, signature):
720
+ """X-Hub-Signature: `sha256=` + hex HMAC-SHA256 of the raw body. Jira sends
721
+ no timestamp, so replay is bounded by delivery-id dedup in the handler."""
722
+ if not secret or not signature:
723
+ return False
724
+ sig = signature.strip().lower()
725
+ if not sig.startswith("sha256="):
726
+ return False
727
+ expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
728
+ return constant_time_equals(expected, sig[len("sha256="):])
729
+
730
+
731
+ def sign_teams_body(token, body):
732
+ """Base64 HMAC-SHA256 of the raw body, keyed by the base64-decoded token."""
733
+ key = base64.b64decode(token, validate=True)
734
+ return base64.b64encode(hmac.new(key, body, hashlib.sha256).digest()).decode("ascii")
735
+
736
+
737
+ def verify_teams_signature(token, body, authorization):
738
+ """Authorization: `HMAC <base64>`. Fails closed on a bad token or header."""
739
+ if not token or not authorization:
740
+ return False
741
+ scheme, _, value = authorization.strip().partition(" ")
742
+ if scheme.upper() != "HMAC" or not value.strip():
743
+ return False
744
+ try:
745
+ expected = sign_teams_body(token, body)
746
+ except ValueError:
747
+ return False
748
+ return constant_time_equals(expected, value.strip())
749
+
750
+
751
+ def teams_fresh(activity, now=None):
752
+ """The activity's ISO-8601 `timestamp` must be within the skew window."""
753
+ raw = activity.get("timestamp")
754
+ if not isinstance(raw, str) or not raw:
755
+ return False
756
+ try:
757
+ dt = datetime.fromisoformat(raw.strip().replace("Z", "+00:00"))
758
+ except ValueError:
759
+ return False
760
+ if dt.tzinfo is None:
761
+ dt = dt.replace(tzinfo=timezone.utc)
762
+ now = time.time() if now is None else now
763
+ return abs(now - dt.timestamp()) <= SIGNATURE_MAX_SKEW_S
764
+
765
+
766
+ def _csv_set(value):
767
+ return frozenset(x.strip() for x in value.split(",") if x.strip())
768
+
769
+
770
+ def _one_line(text, limit=DELEGATED_BRIEF_MAX):
771
+ text = " ".join(CONTROL_CHARS_RE.sub(" ", str(text or "")).split())
772
+ return text.encode("utf-8")[:limit].decode("utf-8", errors="ignore")
773
+
774
+
775
+ def jira_trigger_matches(payload, assignees, labels):
776
+ """True when the issue is assigned to a configured user or carries a
777
+ configured label. An update only counts if it changed assignee or labels,
778
+ so unrelated edits never re-fire a run."""
779
+ issue = payload.get("issue")
780
+ fields = issue.get("fields") if isinstance(issue, dict) else None
781
+ if not isinstance(fields, dict):
782
+ return False
783
+ if payload.get("webhookEvent") == "jira:issue_updated":
784
+ items = (payload.get("changelog") or {}).get("items")
785
+ changed = ({i.get("field") for i in items if isinstance(i, dict)}
786
+ if isinstance(items, list) else set())
787
+ if not changed.intersection(JIRA_TRIGGER_FIELDS):
788
+ return False
789
+ who = fields.get("assignee")
790
+ ids = set()
791
+ if isinstance(who, dict):
792
+ for k in ("accountId", "emailAddress", "displayName"):
793
+ if isinstance(who.get(k), str):
794
+ ids.add(who[k])
795
+ if ids.intersection(assignees):
796
+ return True
797
+ have = fields.get("labels")
798
+ return isinstance(have, list) and bool(
799
+ {x for x in have if isinstance(x, str)}.intersection(labels))
800
+
801
+
802
+ def adf_text(node, out=None):
803
+ """Flatten an Atlassian Document Format tree to its text nodes."""
804
+ top = out is None
805
+ out = [] if top else out
806
+ if isinstance(node, dict):
807
+ if isinstance(node.get("text"), str):
808
+ out.append(node["text"])
809
+ for child in node.get("content") or []:
810
+ adf_text(child, out)
811
+ elif isinstance(node, list):
812
+ for child in node:
813
+ adf_text(child, out)
814
+ return " ".join(out) if top else None
815
+
816
+
817
+ def build_jira_brief(issue):
818
+ fields = issue.get("fields") or {}
819
+ desc = fields.get("description")
820
+ if isinstance(desc, dict):
821
+ desc = adf_text(desc)
822
+ parts = ["Resolve Jira issue %s: %s." % (issue.get("key"), fields.get("summary") or "")]
823
+ if isinstance(desc, str) and desc.strip():
824
+ parts.append(desc)
825
+ parts.append("Reproduce with a regression test, then fix the root cause.")
826
+ return _one_line(" ".join(parts))
827
+
828
+
829
+ def build_teams_brief(activity):
830
+ text = re.sub(r"<at>.*?</at>", " ", str(activity.get("text") or ""), flags=re.S)
831
+ return _one_line(HTML_TAG_RE.sub(" ", text))
832
+
833
+
834
+ def resolve_delegation_secrets(env, others):
835
+ """Return (jira_secret, teams_token), blanking any equal to another
836
+ credential in `others` or to each other."""
837
+ jira = env.get("JIRA_WEBHOOK_SECRET", "")
838
+ teams = env.get("TEAMS_SECURITY_TOKEN", "")
839
+ others = [x for x in others if x]
840
+ out = []
841
+ for name, val, peer in (("Jira", jira, teams), ("Teams", teams, jira)):
842
+ bad = bool(val) and any(constant_time_equals(val, o)
843
+ for o in others + ([peer] if peer else []))
844
+ if bad:
845
+ logging.error("The %s secret is identical to another credential. "
846
+ "They MUST differ; disabling its route.", name)
847
+ out.append("" if bad else val)
848
+ return tuple(out)
849
+
850
+
851
+ def _post_json(url, data, headers):
852
+ """POST JSON with a bounded timeout. Raises on any failure; callers log."""
853
+ req = Request(url, data=json.dumps(data).encode("utf-8"),
854
+ headers=dict(headers, **{"Content-Type": "application/json"}),
855
+ method="POST")
856
+ with urlopen(req, timeout=NOTIFY_TIMEOUT_SECONDS) as resp:
857
+ resp.read()
858
+
859
+
860
+ def notify_job_complete(target, job_id, status, summary):
861
+ """Send a completion summary to a reply target captured after verification.
862
+
863
+ Never raises: a notifier failure must not fail the job. The Slack URL is
864
+ re-checked here as defense in depth.
865
+ """
866
+ if not isinstance(target, dict):
867
+ return
868
+ text = ("loki job %s finished: %s. %s" % (job_id, status, summary or "")).strip()
869
+ text = CONTROL_CHARS_RE.sub(" ", text)[:NOTIFY_TEXT_MAX]
870
+ try:
871
+ if target.get("kind") == "slack":
872
+ url = target.get("response_url", "")
873
+ if not (isinstance(url, str) and url.startswith(SLACK_REPLY_PREFIX)):
874
+ return
875
+ _post_json(url, {"response_type": "in_channel", "text": text}, {})
876
+ elif target.get("kind") == "linear":
877
+ key = os.environ.get("LINEAR_API_KEY", "")
878
+ issue_id = target.get("issue_id", "")
879
+ if not key or not isinstance(issue_id, str) or not issue_id:
880
+ return
881
+ _post_json(LINEAR_API_URL, {
882
+ "query": "mutation($i:String!,$b:String!){commentCreate(input:"
883
+ "{issueId:$i,body:$b}){success}}",
884
+ "variables": {"i": issue_id, "b": text},
885
+ }, {"Authorization": key})
886
+ except Exception as e: # notifier errors never fail a job
887
+ logging.warning("Completion notify for job %s failed: %s", job_id, e)
888
+
889
+
625
890
  def normalize_sentry_payload(payload):
626
891
  """Run the existing normalize_sentry_json over a Sentry webhook payload.
627
892
 
@@ -688,7 +953,7 @@ def handle_sentry_event(payload, dry_run=False):
688
953
  brief = build_sentry_brief(norm)
689
954
  if not valid_spec(brief):
690
955
  return None, "rejected (invalid brief)"
691
- args = ["start", brief, "--pr", "--detach"]
956
+ args = brief_start_args(brief)
692
957
  summary = "sentry issue %s" % norm.get("number", "?")
693
958
  success = run_loki_command(args, dry_run=dry_run)
694
959
  status = "fired" if success else "error"
@@ -750,6 +1015,9 @@ class Dispatcher:
750
1015
  self._job_max = max(1, job_history)
751
1016
  self._jobs = collections.OrderedDict()
752
1017
  self._jobs_lock = threading.Lock()
1018
+ # Reply targets (Slack response_url / Linear issue) keyed by job_id,
1019
+ # written only by the verified /slack and /linear handlers.
1020
+ self._reply_targets = collections.OrderedDict()
753
1021
  # Proof-attribution bookkeeping. The proof pointer is global to the
754
1022
  # working directory, so a receipt only identifies a job when that job
755
1023
  # was the only build that could have written it. All three are updated
@@ -887,6 +1155,17 @@ class Dispatcher:
887
1155
  if summary:
888
1156
  entry["summary"] = summary
889
1157
 
1158
+ def set_reply_target(self, job_id, target):
1159
+ """Store a verified reply target server side, bounded like _jobs."""
1160
+ with self._jobs_lock:
1161
+ self._reply_targets[job_id] = target
1162
+ while len(self._reply_targets) > self._job_max:
1163
+ self._reply_targets.popitem(last=False)
1164
+
1165
+ def pop_reply_target(self, job_id):
1166
+ with self._jobs_lock:
1167
+ return self._reply_targets.pop(job_id, None)
1168
+
890
1169
  def get_job(self, job_id):
891
1170
  """Return a copy of the job record, or None if unknown/evicted.
892
1171
 
@@ -980,6 +1259,9 @@ class Dispatcher:
980
1259
  )
981
1260
  if job_id:
982
1261
  self.record_job(job_id, status, summary or "")
1262
+ target = self.pop_reply_target(job_id)
1263
+ if target is not None:
1264
+ notify_job_complete(target, job_id, status, summary)
983
1265
  finally:
984
1266
  self.queue.task_done()
985
1267
 
@@ -998,6 +1280,16 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
998
1280
  api_token = ""
999
1281
  # Sentry integration client secret for /sentry. Distinct from both above.
1000
1282
  sentry_secret = ""
1283
+ # Slack signing secret (/slack) and Linear webhook secret (/linear), each
1284
+ # distinct from every other credential.
1285
+ slack_secret = ""
1286
+ linear_secret = ""
1287
+ # Jira webhook secret (/jira), Teams outgoing-webhook token (/teams), and
1288
+ # the Jira trigger filters (assignee ids/emails/names, labels).
1289
+ jira_secret = ""
1290
+ teams_secret = ""
1291
+ jira_assignees = frozenset()
1292
+ jira_labels = frozenset()
1001
1293
  dispatcher = None
1002
1294
  # Ed25519 key that attests receipts, and the retired public keys that keep
1003
1295
  # pre-rotation receipts verifiable. Both default to unconfigured, which
@@ -1278,8 +1570,21 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1278
1570
  self._send_json(400, {"error": "invalid spec"})
1279
1571
  return
1280
1572
 
1573
+ job_id = self._enqueue_job(spec.strip())
1574
+ if job_id is None:
1575
+ return
1576
+ self._send_json(202, {"id": job_id, "status": "queued"})
1577
+
1578
+ def _enqueue_job(self, spec, reply_target=None, pr=False):
1579
+ """Record and enqueue one validated job; shared by /jobs, /slack, /linear.
1580
+
1581
+ Returns the job id, or None after sending 429 when the queue is full.
1582
+ reply_target must come from an already-verified request.
1583
+ """
1281
1584
  job_id = secrets.token_urlsafe(12)
1282
- job = {"spec": spec.strip(), "job_id": job_id}
1585
+ job = {"spec": spec, "job_id": job_id}
1586
+ if pr:
1587
+ job["pr"] = True
1283
1588
 
1284
1589
  # Record BEFORE enqueueing. A worker can pick the job up the instant
1285
1590
  # submit() returns and write "running"/"fired"; record_job is
@@ -1288,6 +1593,8 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1288
1593
  # forever. On the 429 path below the record is unreachable (no id is
1289
1594
  # handed out) and the store is bounded, so nothing leaks.
1290
1595
  self.dispatcher.record_job(job_id, "queued", job["spec"])
1596
+ if reply_target is not None:
1597
+ self.dispatcher.set_reply_target(job_id, reply_target)
1291
1598
 
1292
1599
  # Enforce the existing queue bound so a submit storm cannot fork
1293
1600
  # unbounded builds. 429 (not a silent drop) tells the client to retry.
@@ -1295,13 +1602,207 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1295
1602
  logging.warning(
1296
1603
  "Queue full; shedding job submit from %s", self.address_string()
1297
1604
  )
1605
+ self.dispatcher.pop_reply_target(job_id)
1298
1606
  log_event(JOB_EVENT, "", "", "rejected (queue full)")
1299
1607
  self._send_json(429, {"error": "server busy, retry later"})
1300
- return
1608
+ return None
1301
1609
 
1302
1610
  log_event(JOB_EVENT, "", job["spec"], "queued")
1611
+ return job_id
1612
+
1613
+ def _handle_slack(self):
1614
+ """POST /slack -- signed slash command; enqueues a job, ephemeral reply."""
1615
+ body = self._read_body()
1616
+ if body is None:
1617
+ return
1618
+ if not self.slack_secret:
1619
+ log_event(JOB_EVENT, "", "", "rejected (no slack signing secret configured)")
1620
+ self._send_json(503, {"error": "SLACK_SIGNING_SECRET not configured"})
1621
+ return
1622
+ if not verify_slack_signature(
1623
+ self.slack_secret,
1624
+ self.headers.get("X-Slack-Request-Timestamp", ""),
1625
+ body, self.headers.get("X-Slack-Signature", "")):
1626
+ logging.warning("Invalid Slack signature from %s", self.address_string())
1627
+ log_event(JOB_EVENT, "", "", "rejected (invalid slack signature)")
1628
+ self._send_json(401, {"error": "invalid signature"})
1629
+ return
1630
+ # Verified from here on.
1631
+ ctype = (self.headers.get("Content-Type", "") or "").lower()
1632
+ if "json" in ctype:
1633
+ try:
1634
+ data = json.loads(body)
1635
+ except ValueError:
1636
+ self._send_json(400, {"error": "invalid JSON"})
1637
+ return
1638
+ if isinstance(data, dict) and data.get("type") == "url_verification":
1639
+ self._send_json(200, {"challenge": str(data.get("challenge", ""))})
1640
+ return
1641
+ self._send_json(400, {"error": "unsupported slack payload"})
1642
+ return
1643
+ form = urllib.parse.parse_qs(body.decode("utf-8", errors="replace"))
1644
+ text = (form.get("text") or [""])[0]
1645
+ if not valid_spec(text):
1646
+ log_event(JOB_EVENT, "", "", "rejected (invalid slack spec)")
1647
+ self._send_json(200, {"response_type": "ephemeral",
1648
+ "text": "Usage: /loki <spec>. Spec must be a "
1649
+ "one-line brief or ref, not starting with '-'."})
1650
+ return
1651
+ response_url = (form.get("response_url") or [""])[0]
1652
+ target = {"kind": "slack"}
1653
+ if response_url.startswith(SLACK_REPLY_PREFIX):
1654
+ target["response_url"] = response_url
1655
+ job_id = self._enqueue_job(text.strip(), target)
1656
+ if job_id is None:
1657
+ return
1658
+ self._send_json(200, {"response_type": "ephemeral",
1659
+ "text": "Queued loki job %s" % job_id})
1660
+
1661
+ def _handle_linear(self):
1662
+ """POST /linear -- signed Linear issue webhook; enqueues linear:<identifier>."""
1663
+ body = self._read_body()
1664
+ if body is None:
1665
+ return
1666
+ if not self.linear_secret:
1667
+ log_event(JOB_EVENT, "", "", "rejected (no linear webhook secret configured)")
1668
+ self._send_json(503, {"error": "LINEAR_WEBHOOK_SECRET not configured"})
1669
+ return
1670
+ if not verify_linear_signature(
1671
+ self.linear_secret, body, self.headers.get("Linear-Signature", "")):
1672
+ logging.warning("Invalid Linear signature from %s", self.address_string())
1673
+ log_event(JOB_EVENT, "", "", "rejected (invalid linear signature)")
1674
+ self._send_json(401, {"error": "invalid signature"})
1675
+ return
1676
+ try:
1677
+ payload = json.loads(body)
1678
+ except ValueError:
1679
+ self._send_json(400, {"error": "invalid JSON"})
1680
+ return
1681
+ if not isinstance(payload, dict):
1682
+ self._send_json(400, {"error": "payload must be a JSON object"})
1683
+ return
1684
+ if not linear_fresh(payload):
1685
+ log_event(JOB_EVENT, "", "", "rejected (stale linear timestamp)")
1686
+ self._send_json(401, {"error": "stale or missing webhookTimestamp"})
1687
+ return
1688
+ data = payload.get("data")
1689
+ if (payload.get("type") != "Issue" or payload.get("action") != "create"
1690
+ or not isinstance(data, dict)):
1691
+ self._send_json(200, {"status": "skipped"})
1692
+ return
1693
+ ident = data.get("identifier")
1694
+ if not isinstance(ident, str) or not LINEAR_IDENTIFIER_RE.match(ident):
1695
+ self._send_json(400, {"error": "invalid issue identifier"})
1696
+ return
1697
+ if self.dispatcher.seen_delivery(self.headers.get("Linear-Delivery", "")):
1698
+ self._send_json(200, {"status": "duplicate"})
1699
+ return
1700
+ spec = "linear:%s" % ident
1701
+ if not valid_spec(spec):
1702
+ self._send_json(400, {"error": "invalid spec"})
1703
+ return
1704
+ issue_id = data.get("id")
1705
+ target = {"kind": "linear",
1706
+ "issue_id": issue_id if isinstance(issue_id, str) else ""}
1707
+ job_id = self._enqueue_job(spec, target)
1708
+ if job_id is None:
1709
+ return
1710
+ self._send_json(202, {"id": job_id, "status": "queued"})
1711
+
1712
+ def _handle_jira(self):
1713
+ """POST /jira -- signed Jira Cloud webhook; enqueues a PR-only run."""
1714
+ body = self._read_body()
1715
+ if body is None:
1716
+ return
1717
+ if not self.jira_secret:
1718
+ log_event(JOB_EVENT, "", "", "rejected (no jira webhook secret configured)")
1719
+ self._send_json(503, {"error": "JIRA_WEBHOOK_SECRET not configured"})
1720
+ return
1721
+ if not verify_jira_signature(
1722
+ self.jira_secret, body, self.headers.get("X-Hub-Signature", "")):
1723
+ logging.warning("Invalid Jira signature from %s", self.address_string())
1724
+ log_event(JOB_EVENT, "", "", "rejected (invalid jira signature)")
1725
+ self._send_json(401, {"error": "invalid signature"})
1726
+ return
1727
+ try:
1728
+ payload = json.loads(body)
1729
+ except ValueError:
1730
+ self._send_json(400, {"error": "invalid JSON"})
1731
+ return
1732
+ if not isinstance(payload, dict):
1733
+ self._send_json(400, {"error": "payload must be a JSON object"})
1734
+ return
1735
+ if (payload.get("webhookEvent") not in JIRA_EVENTS
1736
+ or not jira_trigger_matches(
1737
+ payload, self.jira_assignees, self.jira_labels)):
1738
+ self._send_json(200, {"status": "skipped"})
1739
+ return
1740
+ issue = payload["issue"]
1741
+ key = issue.get("key")
1742
+ if not isinstance(key, str) or not JIRA_KEY_RE.match(key):
1743
+ self._send_json(400, {"error": "invalid issue key"})
1744
+ return
1745
+ brief = build_jira_brief(issue)
1746
+ if not valid_spec(brief):
1747
+ self._send_json(400, {"error": "invalid brief"})
1748
+ return
1749
+ # Jira has no signed timestamp, and the delivery id header is not covered
1750
+ # by the HMAC, so also dedup on a hash of the signed body: a captured body
1751
+ # cannot be replayed by changing or dropping the header.
1752
+ dup_id = self.dispatcher.seen_delivery(
1753
+ self.headers.get("X-Atlassian-Webhook-Identifier", ""))
1754
+ dup_body = self.dispatcher.seen_delivery(
1755
+ "jira-body:" + hashlib.sha256(body).hexdigest())
1756
+ if dup_id or dup_body:
1757
+ self._send_json(200, {"status": "duplicate"})
1758
+ return
1759
+ job_id = self._enqueue_job(brief, pr=True)
1760
+ if job_id is None:
1761
+ return
1303
1762
  self._send_json(202, {"id": job_id, "status": "queued"})
1304
1763
 
1764
+ def _handle_teams(self):
1765
+ """POST /teams -- Teams outgoing webhook; enqueues a PR-only run."""
1766
+ body = self._read_body()
1767
+ if body is None:
1768
+ return
1769
+ if not self.teams_secret:
1770
+ log_event(JOB_EVENT, "", "", "rejected (no teams security token configured)")
1771
+ self._send_json(503, {"error": "TEAMS_SECURITY_TOKEN not configured"})
1772
+ return
1773
+ if not verify_teams_signature(
1774
+ self.teams_secret, body, self.headers.get("Authorization", "")):
1775
+ logging.warning("Invalid Teams signature from %s", self.address_string())
1776
+ log_event(JOB_EVENT, "", "", "rejected (invalid teams signature)")
1777
+ self._send_json(401, {"error": "invalid signature"})
1778
+ return
1779
+ try:
1780
+ activity = json.loads(body)
1781
+ except ValueError:
1782
+ self._send_json(400, {"error": "invalid JSON"})
1783
+ return
1784
+ if not isinstance(activity, dict):
1785
+ self._send_json(400, {"error": "payload must be a JSON object"})
1786
+ return
1787
+ if not teams_fresh(activity):
1788
+ log_event(JOB_EVENT, "", "", "rejected (stale teams timestamp)")
1789
+ self._send_json(401, {"error": "stale or missing timestamp"})
1790
+ return
1791
+ brief = build_teams_brief(activity)
1792
+ if not valid_spec(brief):
1793
+ self._send_json(200, {"type": "message",
1794
+ "text": "Usage: @loki <brief>. One-line brief, "
1795
+ "not starting with '-'."})
1796
+ return
1797
+ act_id = activity.get("id")
1798
+ if self.dispatcher.seen_delivery(act_id if isinstance(act_id, str) else ""):
1799
+ self._send_json(200, {"type": "message", "text": "Duplicate, already queued."})
1800
+ return
1801
+ job_id = self._enqueue_job(brief, pr=True)
1802
+ if job_id is None:
1803
+ return
1804
+ self._send_json(200, {"type": "message", "text": "Queued loki job %s" % job_id})
1805
+
1305
1806
  def _handle_sentry(self):
1306
1807
  """POST /sentry -- Sentry alert, HMAC-verified, enqueues a fix run."""
1307
1808
  body = self._read_body()
@@ -1347,6 +1848,18 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1347
1848
  if path == "/sentry":
1348
1849
  self._handle_sentry()
1349
1850
  return
1851
+ if path == "/slack":
1852
+ self._handle_slack()
1853
+ return
1854
+ if path == "/linear":
1855
+ self._handle_linear()
1856
+ return
1857
+ if path == "/jira":
1858
+ self._handle_jira()
1859
+ return
1860
+ if path == "/teams":
1861
+ self._handle_teams()
1862
+ return
1350
1863
  if path != "/webhook":
1351
1864
  self._send_json(404, {"error": "not found"})
1352
1865
  return
@@ -1527,6 +2040,16 @@ def main():
1527
2040
  )
1528
2041
  sentry_secret = ""
1529
2042
 
2043
+ # Slack/Linear secrets: env only. Each must differ from every other
2044
+ # credential, since each authenticates a different party.
2045
+ slack_secret, linear_secret = resolve_chat_secrets(
2046
+ os.environ, secret, api_token, sentry_secret)
2047
+
2048
+ jira_secret, teams_secret = resolve_delegation_secrets(
2049
+ os.environ, [secret, api_token, sentry_secret, slack_secret, linear_secret])
2050
+ WebhookHandler.jira_assignees = _csv_set(os.environ.get("JIRA_ASSIGNEES", ""))
2051
+ WebhookHandler.jira_labels = _csv_set(os.environ.get("JIRA_LABELS", ""))
2052
+
1530
2053
  # Persist resolved values, but never write the secret to disk.
1531
2054
  config["port"] = port
1532
2055
  config["dry_run"] = dry_run
@@ -1541,6 +2064,10 @@ def main():
1541
2064
  WebhookHandler.secret = secret
1542
2065
  WebhookHandler.api_token = api_token
1543
2066
  WebhookHandler.sentry_secret = sentry_secret
2067
+ WebhookHandler.slack_secret = slack_secret
2068
+ WebhookHandler.linear_secret = linear_secret
2069
+ WebhookHandler.jira_secret = jira_secret
2070
+ WebhookHandler.teams_secret = teams_secret
1544
2071
  WebhookHandler.dispatcher = dispatcher
1545
2072
 
1546
2073
  signing_key, signing_kid = load_signing_key(auto_generate=False)
@@ -1587,6 +2114,24 @@ def main():
1587
2114
  "No Sentry secret configured: POST /sentry is rejected with 503. "
1588
2115
  "Set LOKI_SENTRY_WEBHOOK_SECRET or SENTRY_CLIENT_SECRET."
1589
2116
  )
2117
+ if not slack_secret:
2118
+ logging.warning(
2119
+ "No Slack signing secret configured: POST /slack is rejected with "
2120
+ "503. Set SLACK_SIGNING_SECRET."
2121
+ )
2122
+ if not linear_secret:
2123
+ logging.warning(
2124
+ "No Linear webhook secret configured: POST /linear is rejected with "
2125
+ "503. Set LINEAR_WEBHOOK_SECRET."
2126
+ )
2127
+ if not jira_secret:
2128
+ logging.warning(
2129
+ "No Jira webhook secret configured: POST /jira is rejected with "
2130
+ "503. Set JIRA_WEBHOOK_SECRET.")
2131
+ if not teams_secret:
2132
+ logging.warning(
2133
+ "No Teams security token configured: POST /teams is rejected with "
2134
+ "503. Set TEAMS_SECURITY_TOKEN.")
1590
2135
  if not secret:
1591
2136
  logging.warning(
1592
2137
  "No webhook secret configured: ALL webhooks will be rejected with "