loki-mode 11.6.0 → 11.8.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 (88) hide show
  1. package/README.md +19 -2
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/lib/backlog.py +48 -4
  5. package/autonomy/lib/chat.sh +60 -3
  6. package/autonomy/lib/loki_yaml.py +8 -2
  7. package/autonomy/lib/workspace.py +267 -11
  8. package/autonomy/loki +315 -21
  9. package/autonomy/trigger-server.py +454 -4
  10. package/autonomy/verify.sh +9 -0
  11. package/completions/_loki +4 -0
  12. package/completions/loki.bash +20 -2
  13. package/dashboard/__init__.py +1 -1
  14. package/docs/CLI-REFERENCE.md +2 -1
  15. package/docs/DEPLOY-PLAN.md +17 -8
  16. package/docs/EVALUATING.md +8 -0
  17. package/docs/INSTALLATION.md +1 -1
  18. package/docs/WORKSPACES.md +15 -2
  19. package/docs/dev/architecture-reference.md +8 -0
  20. package/docs/sentry-intake.md +33 -0
  21. package/docs/v10/CLI-MODERN.md +1 -1
  22. package/docs/v10/D61-SPEED.md +12 -0
  23. package/docs/v10/PROGRESS.md +10 -0
  24. package/loki-ts/dist/loki.js +1055 -1004
  25. package/mcp/__init__.py +1 -1
  26. package/package.json +2 -2
  27. package/packages/control-plane/dist/server.js +789 -634
  28. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  29. package/schemas/loki-yaml.schema.json +5 -2
  30. package/src/policies/engine.js +41 -2
  31. package/src/policies/types.js +49 -0
  32. package/web-app/dist/assets/{AdminPage-DhXdJNQk.js → AdminPage-BnIg9ilA.js} +1 -1
  33. package/web-app/dist/assets/{Avatar-ixVj76fM.js → Avatar-0BTVsQ2A.js} +1 -1
  34. package/web-app/dist/assets/{Badge-BGMSp22s.js → Badge-Bbq8CXgn.js} +1 -1
  35. package/web-app/dist/assets/{Button-pYJc32tG.js → Button-CKD4n0ld.js} +1 -1
  36. package/web-app/dist/assets/{CockpitPage-C1JBBouA.js → CockpitPage-DJhLNLAz.js} +1 -1
  37. package/web-app/dist/assets/{ComparePage-qD0NV4RK.js → ComparePage-Di2akuII.js} +1 -1
  38. package/web-app/dist/assets/{ErrorBoundary-C-yxO58j.js → ErrorBoundary-9lhsYG-z.js} +1 -1
  39. package/web-app/dist/assets/{EvidenceReceiptPanel-ByPzuE1S.js → EvidenceReceiptPanel-CQDGXvDr.js} +1 -1
  40. package/web-app/dist/assets/{GitHubIssuesPanel-BNv_2BEJ.js → GitHubIssuesPanel-Y_w2k9Ej.js} +1 -1
  41. package/web-app/dist/assets/{GitHubPRsPanel-CuLZQ9CS.js → GitHubPRsPanel-pMSQedCv.js} +1 -1
  42. package/web-app/dist/assets/{HomePage-CS_FW8Je.js → HomePage-CIAkiGdt.js} +2 -2
  43. package/web-app/dist/assets/{LoginPage-DwiNf1Wm.js → LoginPage-BES4Pk6k.js} +1 -1
  44. package/web-app/dist/assets/{MagicPage-CmQybLPZ.js → MagicPage-B154fMhi.js} +1 -1
  45. package/web-app/dist/assets/{MetricsPage-D9ERdpv8.js → MetricsPage-BcNyaiUO.js} +1 -1
  46. package/web-app/dist/assets/{NotFoundPage-D9ccgdbF.js → NotFoundPage-D28FqYxG.js} +1 -1
  47. package/web-app/dist/assets/{ProjectPage-B9Jslmgv.js → ProjectPage-C_O3u8Jx.js} +3 -3
  48. package/web-app/dist/assets/{ProjectsPage-DnU99Go2.js → ProjectsPage-uqdahuPA.js} +1 -1
  49. package/web-app/dist/assets/{SettingsPage-DcTyx6wo.js → SettingsPage-DLvVA9OZ.js} +1 -1
  50. package/web-app/dist/assets/{ShowcasePage-jEY9ghSr.js → ShowcasePage-BAZP8vsm.js} +1 -1
  51. package/web-app/dist/assets/{SystemSettingsPage-CSGltW-8.js → SystemSettingsPage-DPYZkLWh.js} +1 -1
  52. package/web-app/dist/assets/{TeamsPage-gUuJXb9l.js → TeamsPage-BgSyb5Pd.js} +1 -1
  53. package/web-app/dist/assets/{TemplatesPage-37Zhe--v.js → TemplatesPage-D_Jh5VU1.js} +1 -1
  54. package/web-app/dist/assets/{TerminalOutput-B09hRwaR.js → TerminalOutput-4v8poDKb.js} +1 -1
  55. package/web-app/dist/assets/{activity-q_4MY9J0.js → activity-CE2b6kdw.js} +1 -1
  56. package/web-app/dist/assets/{bell-X8OC_PFP.js → bell-CxJDlLSR.js} +1 -1
  57. package/web-app/dist/assets/{bot-C-N1PUOD.js → bot-C2WuK4zw.js} +1 -1
  58. package/web-app/dist/assets/{check-B2c76Jmp.js → check-81epVldw.js} +1 -1
  59. package/web-app/dist/assets/{chevron-left-BF76YLNw.js → chevron-left-B6EXh_3C.js} +1 -1
  60. package/web-app/dist/assets/{circle-alert-AeFKBD8h.js → circle-alert-0iqoo-iy.js} +1 -1
  61. package/web-app/dist/assets/{clock-CiXRf-FH.js → clock-CFrBpNUp.js} +1 -1
  62. package/web-app/dist/assets/{cloud-DKQ8SRJ5.js → cloud-BBSFhUT_.js} +1 -1
  63. package/web-app/dist/assets/{code-xml-DEb6jXWP.js → code-xml-BCU2V1rq.js} +1 -1
  64. package/web-app/dist/assets/{database-6W7e-E9c.js → database-D2hvO52K.js} +1 -1
  65. package/web-app/dist/assets/{dollar-sign-Ds2GOuyW.js → dollar-sign-CXFA9b6T.js} +1 -1
  66. package/web-app/dist/assets/{file-code-corner-CZU0jg52.js → file-code-corner-CqTCEEfS.js} +1 -1
  67. package/web-app/dist/assets/{file-plus-DTVYnXCN.js → file-plus-DYFbkGbs.js} +1 -1
  68. package/web-app/dist/assets/{globe-0mUzKYDe.js → globe-DpYHLobk.js} +1 -1
  69. package/web-app/dist/assets/{hammer-BMHZg3YX.js → hammer-CZ97SEYo.js} +1 -1
  70. package/web-app/dist/assets/{index-BvOTvjax.js → index-DzswgpRo.js} +3 -3
  71. package/web-app/dist/assets/{layers-Df7TdcWB.js → layers-2Tq0edx4.js} +1 -1
  72. package/web-app/dist/assets/{loader-circle-kPsVfrSY.js → loader-circle-BFP4kUX-.js} +1 -1
  73. package/web-app/dist/assets/{lock-CXYcbEc5.js → lock-DkKdZH4d.js} +1 -1
  74. package/web-app/dist/assets/{package-Cqhua1ID.js → package-ChOGD8yf.js} +1 -1
  75. package/web-app/dist/assets/{plus-lseXhsn-.js → plus-hggB1o6b.js} +1 -1
  76. package/web-app/dist/assets/{refresh-cw-OZriI6gD.js → refresh-cw-DYwVLOeF.js} +1 -1
  77. package/web-app/dist/assets/{rotate-ccw-CKCfjPg8.js → rotate-ccw-CUdY506q.js} +1 -1
  78. package/web-app/dist/assets/{scroll-text-DoVyyfKb.js → scroll-text-Wvidz5uN.js} +1 -1
  79. package/web-app/dist/assets/{server-DGy7TflW.js → server-O11QmkCN.js} +1 -1
  80. package/web-app/dist/assets/{shield-alert-BCzRcMXi.js → shield-alert-DXXx27uV.js} +1 -1
  81. package/web-app/dist/assets/{thumbs-up-C_znJd5g.js → thumbs-up-DF8ivs3A.js} +1 -1
  82. package/web-app/dist/assets/{trash-2-BosCbeeA.js → trash-2-LA6PW0Nf.js} +1 -1
  83. package/web-app/dist/assets/{trending-down-CkR5zDkV.js → trending-down-Bw_vLKHT.js} +1 -1
  84. package/web-app/dist/assets/{trending-up-DpUIfOH4.js → trending-up-tX8motha.js} +1 -1
  85. package/web-app/dist/assets/{upload-CH9aKc8L.js → upload-CxtTWJd2.js} +1 -1
  86. package/web-app/dist/assets/{usePolling-pzxrHJeg.js → usePolling-D-P5Iofq.js} +1 -1
  87. package/web-app/dist/assets/{user-B7OHa-VQ.js → user-dKTcuUFq.js} +1 -1
  88. package/web-app/dist/index.html +1 -1
@@ -26,6 +26,12 @@ GitHub, the bearer token authenticates a human operator -- so holding one must
26
26
  never grant the other. If no API token is configured the server still starts but
27
27
  every /jobs request is rejected with 503 and an audit log line.
28
28
 
29
+ Self-healing entry point: POST /sentry accepts a Sentry integration webhook
30
+ authenticated by Sentry's own HMAC (sentry-hook-signature) under a THIRD,
31
+ separate secret (LOKI_SENTRY_WEBHOOK_SECRET or SENTRY_CLIENT_SECRET). A valid
32
+ alert enqueues `loki start "<normalized brief>" --pr --detach`: the output is a
33
+ receipt-gated PR only, never a deploy. No secret means the route answers 503.
34
+
29
35
  Usage:
30
36
  python3 autonomy/trigger-server.py [--port PORT] [--secret SECRET] [--dry-run]
31
37
  [--workers N] [--queue-size N]
@@ -49,8 +55,10 @@ import subprocess
49
55
  import sys
50
56
  import threading
51
57
  import time
58
+ import urllib.parse
52
59
  from datetime import datetime
53
60
  from pathlib import Path
61
+ from urllib.request import Request, urlopen
54
62
 
55
63
  # Receipt attestation. Imported by path because trigger-server.py runs as a
56
64
  # script from an arbitrary cwd, so a bare `import receipt_jwt` would depend on
@@ -398,6 +406,25 @@ OUTCOME_FAILED = JOB_STATUS_FAILED # launch failed, or exited non-zero
398
406
  OUTCOME_UNKNOWN = JOB_STATUS_UNKNOWN # detached; exit code never observed
399
407
 
400
408
 
409
+ # Secrets this server holds that a spawned `loki start` must never inherit: a
410
+ # prompt-injected run could otherwise forge /sentry, /webhook or /jobs requests.
411
+ CHILD_ENV_DENYLIST = (
412
+ "GITHUB_WEBHOOK_SECRET",
413
+ "LOKI_API_TOKEN",
414
+ "LOKI_API_TOKEN_FILE",
415
+ "LOKI_SENTRY_WEBHOOK_SECRET",
416
+ "SENTRY_CLIENT_SECRET",
417
+ "SLACK_SIGNING_SECRET",
418
+ "SLACK_BOT_TOKEN",
419
+ "LINEAR_WEBHOOK_SECRET",
420
+ "LINEAR_API_KEY",
421
+ )
422
+
423
+
424
+ def _scoped_child_env():
425
+ return {k: v for k, v in os.environ.items() if k not in CHILD_ENV_DENYLIST}
426
+
427
+
401
428
  def run_loki_outcome(args, dry_run=False):
402
429
  """Run a loki command and report WHICH of the three outcomes occurred.
403
430
 
@@ -414,6 +441,7 @@ def run_loki_outcome(args, dry_run=False):
414
441
  cmd,
415
442
  stdout=subprocess.PIPE,
416
443
  stderr=subprocess.PIPE,
444
+ env=_scoped_child_env(),
417
445
  )
418
446
  except (FileNotFoundError, OSError) as e:
419
447
  logging.error("Failed to launch %s: %s", " ".join(cmd), e)
@@ -577,6 +605,208 @@ def handle_job_event(payload, dry_run=False):
577
605
  return summary, status
578
606
 
579
607
 
608
+ # Pseudo-event for a Sentry alert. Like JOB_EVENT it is NOT a GitHub event and is
609
+ # refused on /webhook: only /sentry, under its own HMAC secret, may enqueue it.
610
+ SENTRY_EVENT = "sentry_issue"
611
+ INTERNAL_EVENTS = (JOB_EVENT, SENTRY_EVENT)
612
+
613
+ # Sentry actions that mean "a new problem needs a fix". resolved/assigned/etc.
614
+ # are skipped so a state change never starts a build.
615
+ SENTRY_ACTIONS = ("triggered", "created")
616
+ SENTRY_BRIEF_MAX = 1500
617
+ ISSUE_PROVIDERS_SH = Path(__file__).resolve().parent / "issue-providers.sh"
618
+
619
+
620
+ def validate_sentry_signature(secret, body, signature_header):
621
+ """Validate Sentry's `sentry-hook-signature` (hex HMAC-SHA256, no prefix).
622
+
623
+ Fails closed: no secret or no header is never valid.
624
+ """
625
+ if not secret or not signature_header:
626
+ return False
627
+ expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
628
+ return constant_time_equals(expected, signature_header.strip().lower())
629
+
630
+
631
+ # Slack and Linear delegation. Same signed-body contract as
632
+ # loki-ts/src/contrib/slack_inbound.ts (signSlackBody/verifySlackSignature).
633
+ SIGNATURE_MAX_SKEW_S = 300
634
+ SLACK_REPLY_PREFIX = "https://hooks.slack.com/"
635
+ LINEAR_API_URL = "https://api.linear.app/graphql"
636
+ LINEAR_IDENTIFIER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$")
637
+ NOTIFY_TIMEOUT_SECONDS = 10
638
+ NOTIFY_TEXT_MAX = 1000
639
+
640
+
641
+ def sign_slack_body(secret, timestamp, body):
642
+ """`v0=` + hex HMAC-SHA256 over `v0:<timestamp>:<raw body>` (bytes)."""
643
+ base = b"v0:" + str(timestamp).encode("utf-8") + b":" + body
644
+ return "v0=" + hmac.new(secret.encode("utf-8"), base, hashlib.sha256).hexdigest()
645
+
646
+
647
+ def verify_slack_signature(secret, timestamp, body, signature, now=None):
648
+ """Fail closed: no secret, header, numeric timestamp or fresh window -> False."""
649
+ if not secret or not timestamp or not signature:
650
+ return False
651
+ if not re.fullmatch(r"[0-9]+", timestamp):
652
+ return False
653
+ now = time.time() if now is None else now
654
+ if abs(now - int(timestamp)) > SIGNATURE_MAX_SKEW_S:
655
+ return False
656
+ return constant_time_equals(sign_slack_body(secret, timestamp, body), signature)
657
+
658
+
659
+ def verify_linear_signature(secret, body, signature):
660
+ """Linear-Signature: hex HMAC-SHA256 of the raw body. Freshness is checked
661
+ separately against the signed body's webhookTimestamp (linear_fresh)."""
662
+ if not secret or not signature:
663
+ return False
664
+ expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
665
+ return constant_time_equals(expected, signature.strip().lower())
666
+
667
+
668
+ def linear_fresh(payload, now=None):
669
+ ts_val = payload.get("webhookTimestamp")
670
+ if isinstance(ts_val, bool) or not isinstance(ts_val, (int, float)):
671
+ return False
672
+ secs = ts_val / 1000.0 if ts_val > 1e11 else float(ts_val)
673
+ now = time.time() if now is None else now
674
+ return abs(now - secs) <= SIGNATURE_MAX_SKEW_S
675
+
676
+
677
+ def resolve_chat_secrets(env, secret, api_token, sentry_secret):
678
+ """Return (slack_secret, linear_secret), blanking any that equals another
679
+ credential: each authenticates a different party and MUST differ."""
680
+ slack = env.get("SLACK_SIGNING_SECRET", "")
681
+ linear = env.get("LINEAR_WEBHOOK_SECRET", "")
682
+ others = [x for x in (secret, api_token, sentry_secret) if x]
683
+ slack_bad = bool(slack) and any(
684
+ constant_time_equals(slack, o) for o in others + ([linear] if linear else []))
685
+ linear_bad = bool(linear) and any(
686
+ constant_time_equals(linear, o) for o in others + ([slack] if slack else []))
687
+ if slack_bad:
688
+ logging.error("The Slack secret is identical to another credential. "
689
+ "They MUST differ; disabling /slack.")
690
+ if linear_bad:
691
+ logging.error("The Linear secret is identical to another credential. "
692
+ "They MUST differ; disabling /linear.")
693
+ return ("" if slack_bad else slack), ("" if linear_bad else linear)
694
+
695
+
696
+ def _post_json(url, data, headers):
697
+ """POST JSON with a bounded timeout. Raises on any failure; callers log."""
698
+ req = Request(url, data=json.dumps(data).encode("utf-8"),
699
+ headers=dict(headers, **{"Content-Type": "application/json"}),
700
+ method="POST")
701
+ with urlopen(req, timeout=NOTIFY_TIMEOUT_SECONDS) as resp:
702
+ resp.read()
703
+
704
+
705
+ def notify_job_complete(target, job_id, status, summary):
706
+ """Send a completion summary to a reply target captured after verification.
707
+
708
+ Never raises: a notifier failure must not fail the job. The Slack URL is
709
+ re-checked here as defense in depth.
710
+ """
711
+ if not isinstance(target, dict):
712
+ return
713
+ text = ("loki job %s finished: %s. %s" % (job_id, status, summary or "")).strip()
714
+ text = CONTROL_CHARS_RE.sub(" ", text)[:NOTIFY_TEXT_MAX]
715
+ try:
716
+ if target.get("kind") == "slack":
717
+ url = target.get("response_url", "")
718
+ if not (isinstance(url, str) and url.startswith(SLACK_REPLY_PREFIX)):
719
+ return
720
+ _post_json(url, {"response_type": "in_channel", "text": text}, {})
721
+ elif target.get("kind") == "linear":
722
+ key = os.environ.get("LINEAR_API_KEY", "")
723
+ issue_id = target.get("issue_id", "")
724
+ if not key or not isinstance(issue_id, str) or not issue_id:
725
+ return
726
+ _post_json(LINEAR_API_URL, {
727
+ "query": "mutation($i:String!,$b:String!){commentCreate(input:"
728
+ "{issueId:$i,body:$b}){success}}",
729
+ "variables": {"i": issue_id, "b": text},
730
+ }, {"Authorization": key})
731
+ except Exception as e: # notifier errors never fail a job
732
+ logging.warning("Completion notify for job %s failed: %s", job_id, e)
733
+
734
+
735
+ def normalize_sentry_payload(payload):
736
+ """Run the existing normalize_sentry_json over a Sentry webhook payload.
737
+
738
+ Reuses autonomy/issue-providers.sh (single owner of the Sentry shape) via a
739
+ scoped-env bash call. Returns the normalized dict or None.
740
+ """
741
+ data = payload.get("data")
742
+ if not isinstance(data, dict):
743
+ return None
744
+ raw = {k: data[k] for k in ("issue", "event") if isinstance(data.get(k), dict)}
745
+ if not raw:
746
+ return None
747
+ env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin")}
748
+ ev = raw.get("event") or {}
749
+ env["_LOKI_SENTRY_URL"] = str(ev.get("web_url") or ev.get("url") or "")
750
+ try:
751
+ proc = subprocess.run(
752
+ ["bash", "-c", 'source "$1" && normalize_sentry_json', "_",
753
+ str(ISSUE_PROVIDERS_SH)],
754
+ input=json.dumps(raw).encode("utf-8"), capture_output=True,
755
+ env=env, timeout=20,
756
+ )
757
+ except (OSError, subprocess.SubprocessError):
758
+ return None
759
+ if proc.returncode != 0:
760
+ return None
761
+ try:
762
+ out = json.loads(proc.stdout.decode("utf-8", errors="replace").strip().splitlines()[-1])
763
+ except (ValueError, IndexError):
764
+ return None
765
+ return out if isinstance(out, dict) else None
766
+
767
+
768
+ def build_sentry_brief(norm):
769
+ """One-line, control-char-free brief that can never parse as a CLI flag."""
770
+ culprit = ""
771
+ for line in str(norm.get("body", "")).splitlines():
772
+ if line.startswith("- Culprit: "):
773
+ culprit = line[len("- Culprit: "):].strip()
774
+ break
775
+ parts = ["Fix Sentry issue %s: %s" % (norm.get("number", "?"), norm.get("title", ""))]
776
+ if culprit:
777
+ parts.append("(culprit %s)" % culprit)
778
+ if norm.get("url"):
779
+ parts.append("See %s." % norm["url"])
780
+ parts.append("Reproduce with a regression test, then fix the root cause.")
781
+ brief = CONTROL_CHARS_RE.sub(" ", " ".join(parts))
782
+ brief = " ".join(brief.split())
783
+ return brief.encode("utf-8")[:SENTRY_BRIEF_MAX].decode("utf-8", errors="ignore")
784
+
785
+
786
+ def handle_sentry_event(payload, dry_run=False):
787
+ """Handle a verified Sentry alert: loki start <brief> --pr --detach.
788
+
789
+ PR only. Never passes a deploy argument: a fix still has to earn a VERIFIED
790
+ receipt, and shipping it stays a separate, gated human step.
791
+ """
792
+ action = payload.get("action", "")
793
+ if action not in SENTRY_ACTIONS:
794
+ return None, "skipped (action=%s)" % action
795
+ norm = normalize_sentry_payload(payload)
796
+ if norm is None:
797
+ return None, "rejected (unparseable sentry payload)"
798
+ brief = build_sentry_brief(norm)
799
+ if not valid_spec(brief):
800
+ return None, "rejected (invalid brief)"
801
+ args = ["start", brief, "--pr", "--detach"]
802
+ summary = "sentry issue %s" % norm.get("number", "?")
803
+ success = run_loki_command(args, dry_run=dry_run)
804
+ status = "fired" if success else "error"
805
+ if success:
806
+ send_notification("Trigger fired: %s" % summary)
807
+ return summary, status
808
+
809
+
580
810
  # Map event name -> handler. Keeps do_POST routing declarative. JOB_EVENT rides
581
811
  # the same table (and therefore the same bounded queue and worker pool) but is
582
812
  # explicitly refused on the /webhook path.
@@ -585,6 +815,7 @@ EVENT_HANDLERS = {
585
815
  "pull_request": handle_pull_request_event,
586
816
  "workflow_run": handle_workflow_run_event,
587
817
  JOB_EVENT: handle_job_event,
818
+ SENTRY_EVENT: handle_sentry_event,
588
819
  }
589
820
 
590
821
 
@@ -629,6 +860,9 @@ class Dispatcher:
629
860
  self._job_max = max(1, job_history)
630
861
  self._jobs = collections.OrderedDict()
631
862
  self._jobs_lock = threading.Lock()
863
+ # Reply targets (Slack response_url / Linear issue) keyed by job_id,
864
+ # written only by the verified /slack and /linear handlers.
865
+ self._reply_targets = collections.OrderedDict()
632
866
  # Proof-attribution bookkeeping. The proof pointer is global to the
633
867
  # working directory, so a receipt only identifies a job when that job
634
868
  # was the only build that could have written it. All three are updated
@@ -766,6 +1000,17 @@ class Dispatcher:
766
1000
  if summary:
767
1001
  entry["summary"] = summary
768
1002
 
1003
+ def set_reply_target(self, job_id, target):
1004
+ """Store a verified reply target server side, bounded like _jobs."""
1005
+ with self._jobs_lock:
1006
+ self._reply_targets[job_id] = target
1007
+ while len(self._reply_targets) > self._job_max:
1008
+ self._reply_targets.popitem(last=False)
1009
+
1010
+ def pop_reply_target(self, job_id):
1011
+ with self._jobs_lock:
1012
+ return self._reply_targets.pop(job_id, None)
1013
+
769
1014
  def get_job(self, job_id):
770
1015
  """Return a copy of the job record, or None if unknown/evicted.
771
1016
 
@@ -859,6 +1104,9 @@ class Dispatcher:
859
1104
  )
860
1105
  if job_id:
861
1106
  self.record_job(job_id, status, summary or "")
1107
+ target = self.pop_reply_target(job_id)
1108
+ if target is not None:
1109
+ notify_job_complete(target, job_id, status, summary)
862
1110
  finally:
863
1111
  self.queue.task_done()
864
1112
 
@@ -875,6 +1123,12 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
875
1123
  # Bearer token for the /jobs API. MUST be a different secret from `secret`
876
1124
  # above: one authenticates GitHub, the other authenticates a human.
877
1125
  api_token = ""
1126
+ # Sentry integration client secret for /sentry. Distinct from both above.
1127
+ sentry_secret = ""
1128
+ # Slack signing secret (/slack) and Linear webhook secret (/linear), each
1129
+ # distinct from every other credential.
1130
+ slack_secret = ""
1131
+ linear_secret = ""
878
1132
  dispatcher = None
879
1133
  # Ed25519 key that attests receipts, and the retired public keys that keep
880
1134
  # pre-rotation receipts verifiable. Both default to unconfigured, which
@@ -1155,8 +1409,19 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1155
1409
  self._send_json(400, {"error": "invalid spec"})
1156
1410
  return
1157
1411
 
1412
+ job_id = self._enqueue_job(spec.strip())
1413
+ if job_id is None:
1414
+ return
1415
+ self._send_json(202, {"id": job_id, "status": "queued"})
1416
+
1417
+ def _enqueue_job(self, spec, reply_target=None):
1418
+ """Record and enqueue one validated job; shared by /jobs, /slack, /linear.
1419
+
1420
+ Returns the job id, or None after sending 429 when the queue is full.
1421
+ reply_target must come from an already-verified request.
1422
+ """
1158
1423
  job_id = secrets.token_urlsafe(12)
1159
- job = {"spec": spec.strip(), "job_id": job_id}
1424
+ job = {"spec": spec, "job_id": job_id}
1160
1425
 
1161
1426
  # Record BEFORE enqueueing. A worker can pick the job up the instant
1162
1427
  # submit() returns and write "running"/"fired"; record_job is
@@ -1165,6 +1430,8 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1165
1430
  # forever. On the 429 path below the record is unreachable (no id is
1166
1431
  # handed out) and the store is bounded, so nothing leaks.
1167
1432
  self.dispatcher.record_job(job_id, "queued", job["spec"])
1433
+ if reply_target is not None:
1434
+ self.dispatcher.set_reply_target(job_id, reply_target)
1168
1435
 
1169
1436
  # Enforce the existing queue bound so a submit storm cannot fork
1170
1437
  # unbounded builds. 429 (not a silent drop) tells the client to retry.
@@ -1172,18 +1439,164 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1172
1439
  logging.warning(
1173
1440
  "Queue full; shedding job submit from %s", self.address_string()
1174
1441
  )
1442
+ self.dispatcher.pop_reply_target(job_id)
1175
1443
  log_event(JOB_EVENT, "", "", "rejected (queue full)")
1176
1444
  self._send_json(429, {"error": "server busy, retry later"})
1177
- return
1445
+ return None
1178
1446
 
1179
1447
  log_event(JOB_EVENT, "", job["spec"], "queued")
1448
+ return job_id
1449
+
1450
+ def _handle_slack(self):
1451
+ """POST /slack -- signed slash command; enqueues a job, ephemeral reply."""
1452
+ body = self._read_body()
1453
+ if body is None:
1454
+ return
1455
+ if not self.slack_secret:
1456
+ log_event(JOB_EVENT, "", "", "rejected (no slack signing secret configured)")
1457
+ self._send_json(503, {"error": "SLACK_SIGNING_SECRET not configured"})
1458
+ return
1459
+ if not verify_slack_signature(
1460
+ self.slack_secret,
1461
+ self.headers.get("X-Slack-Request-Timestamp", ""),
1462
+ body, self.headers.get("X-Slack-Signature", "")):
1463
+ logging.warning("Invalid Slack signature from %s", self.address_string())
1464
+ log_event(JOB_EVENT, "", "", "rejected (invalid slack signature)")
1465
+ self._send_json(401, {"error": "invalid signature"})
1466
+ return
1467
+ # Verified from here on.
1468
+ ctype = (self.headers.get("Content-Type", "") or "").lower()
1469
+ if "json" in ctype:
1470
+ try:
1471
+ data = json.loads(body)
1472
+ except ValueError:
1473
+ self._send_json(400, {"error": "invalid JSON"})
1474
+ return
1475
+ if isinstance(data, dict) and data.get("type") == "url_verification":
1476
+ self._send_json(200, {"challenge": str(data.get("challenge", ""))})
1477
+ return
1478
+ self._send_json(400, {"error": "unsupported slack payload"})
1479
+ return
1480
+ form = urllib.parse.parse_qs(body.decode("utf-8", errors="replace"))
1481
+ text = (form.get("text") or [""])[0]
1482
+ if not valid_spec(text):
1483
+ log_event(JOB_EVENT, "", "", "rejected (invalid slack spec)")
1484
+ self._send_json(200, {"response_type": "ephemeral",
1485
+ "text": "Usage: /loki <spec>. Spec must be a "
1486
+ "one-line brief or ref, not starting with '-'."})
1487
+ return
1488
+ response_url = (form.get("response_url") or [""])[0]
1489
+ target = {"kind": "slack"}
1490
+ if response_url.startswith(SLACK_REPLY_PREFIX):
1491
+ target["response_url"] = response_url
1492
+ job_id = self._enqueue_job(text.strip(), target)
1493
+ if job_id is None:
1494
+ return
1495
+ self._send_json(200, {"response_type": "ephemeral",
1496
+ "text": "Queued loki job %s" % job_id})
1497
+
1498
+ def _handle_linear(self):
1499
+ """POST /linear -- signed Linear issue webhook; enqueues linear:<identifier>."""
1500
+ body = self._read_body()
1501
+ if body is None:
1502
+ return
1503
+ if not self.linear_secret:
1504
+ log_event(JOB_EVENT, "", "", "rejected (no linear webhook secret configured)")
1505
+ self._send_json(503, {"error": "LINEAR_WEBHOOK_SECRET not configured"})
1506
+ return
1507
+ if not verify_linear_signature(
1508
+ self.linear_secret, body, self.headers.get("Linear-Signature", "")):
1509
+ logging.warning("Invalid Linear signature from %s", self.address_string())
1510
+ log_event(JOB_EVENT, "", "", "rejected (invalid linear signature)")
1511
+ self._send_json(401, {"error": "invalid signature"})
1512
+ return
1513
+ try:
1514
+ payload = json.loads(body)
1515
+ except ValueError:
1516
+ self._send_json(400, {"error": "invalid JSON"})
1517
+ return
1518
+ if not isinstance(payload, dict):
1519
+ self._send_json(400, {"error": "payload must be a JSON object"})
1520
+ return
1521
+ if not linear_fresh(payload):
1522
+ log_event(JOB_EVENT, "", "", "rejected (stale linear timestamp)")
1523
+ self._send_json(401, {"error": "stale or missing webhookTimestamp"})
1524
+ return
1525
+ data = payload.get("data")
1526
+ if (payload.get("type") != "Issue" or payload.get("action") != "create"
1527
+ or not isinstance(data, dict)):
1528
+ self._send_json(200, {"status": "skipped"})
1529
+ return
1530
+ ident = data.get("identifier")
1531
+ if not isinstance(ident, str) or not LINEAR_IDENTIFIER_RE.match(ident):
1532
+ self._send_json(400, {"error": "invalid issue identifier"})
1533
+ return
1534
+ if self.dispatcher.seen_delivery(self.headers.get("Linear-Delivery", "")):
1535
+ self._send_json(200, {"status": "duplicate"})
1536
+ return
1537
+ spec = "linear:%s" % ident
1538
+ if not valid_spec(spec):
1539
+ self._send_json(400, {"error": "invalid spec"})
1540
+ return
1541
+ issue_id = data.get("id")
1542
+ target = {"kind": "linear",
1543
+ "issue_id": issue_id if isinstance(issue_id, str) else ""}
1544
+ job_id = self._enqueue_job(spec, target)
1545
+ if job_id is None:
1546
+ return
1180
1547
  self._send_json(202, {"id": job_id, "status": "queued"})
1181
1548
 
1549
+ def _handle_sentry(self):
1550
+ """POST /sentry -- Sentry alert, HMAC-verified, enqueues a fix run."""
1551
+ body = self._read_body()
1552
+ if body is None:
1553
+ return
1554
+ event_type = SENTRY_EVENT
1555
+ if not self.sentry_secret:
1556
+ logging.warning("Rejecting /sentry from %s: no Sentry secret configured",
1557
+ self.address_string())
1558
+ log_event(event_type, "", "", "rejected (no sentry secret configured)")
1559
+ self._send_json(503, {"error": "sentry webhook secret not configured"})
1560
+ return
1561
+ sig = self.headers.get("sentry-hook-signature", "")
1562
+ if not validate_sentry_signature(self.sentry_secret, body, sig):
1563
+ logging.warning("Invalid Sentry signature from %s", self.address_string())
1564
+ log_event(event_type, "", "", "rejected (invalid signature)")
1565
+ self._send_json(401, {"error": "invalid signature"})
1566
+ return
1567
+ try:
1568
+ payload = json.loads(body)
1569
+ except json.JSONDecodeError:
1570
+ self._send_json(400, {"error": "invalid JSON"})
1571
+ return
1572
+ if not isinstance(payload, dict):
1573
+ self._send_json(400, {"error": "payload must be a JSON object"})
1574
+ return
1575
+ action = payload.get("action", "")
1576
+ if self.dispatcher.seen_delivery(self.headers.get("Request-ID", "")):
1577
+ log_event(event_type, action, "", "duplicate")
1578
+ self._send_json(200, {"event": event_type, "status": "duplicate"})
1579
+ return
1580
+ if not self.dispatcher.submit(event_type, payload):
1581
+ log_event(event_type, action, "", "rejected (queue full)")
1582
+ self._send_json(503, {"error": "server busy, retry later"})
1583
+ return
1584
+ self._send_json(202, {"event": event_type, "action": action, "status": "queued"})
1585
+
1182
1586
  def do_POST(self):
1183
1587
  path = self.path.split("?", 1)[0]
1184
1588
  if path == "/jobs":
1185
1589
  self._handle_job_submit()
1186
1590
  return
1591
+ if path == "/sentry":
1592
+ self._handle_sentry()
1593
+ return
1594
+ if path == "/slack":
1595
+ self._handle_slack()
1596
+ return
1597
+ if path == "/linear":
1598
+ self._handle_linear()
1599
+ return
1187
1600
  if path != "/webhook":
1188
1601
  self._send_json(404, {"error": "not found"})
1189
1602
  return
@@ -1201,10 +1614,10 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1201
1614
  # never be reachable with the webhook HMAC. Without this, a holder of
1202
1615
  # the webhook secret could POST X-GitHub-Event: loki_job and submit an
1203
1616
  # arbitrary spec, defeating the separate-credential requirement.
1204
- if event_type == JOB_EVENT:
1617
+ if event_type in INTERNAL_EVENTS:
1205
1618
  logging.warning(
1206
1619
  "Rejecting %s on /webhook from %s: submits require the /jobs "
1207
- "API and its own bearer token",
1620
+ "API and its own bearer token (Sentry uses /sentry)",
1208
1621
  JOB_EVENT,
1209
1622
  self.address_string(),
1210
1623
  )
@@ -1350,6 +1763,25 @@ def main():
1350
1763
  )
1351
1764
  api_token = ""
1352
1765
 
1766
+ # Sentry secret: env only (never argv). Must differ from the other two
1767
+ # credentials, since each authenticates a different party.
1768
+ sentry_secret = (os.environ.get("LOKI_SENTRY_WEBHOOK_SECRET")
1769
+ or os.environ.get("SENTRY_CLIENT_SECRET") or "")
1770
+ if sentry_secret and (
1771
+ (secret and constant_time_equals(sentry_secret, secret))
1772
+ or (api_token and constant_time_equals(sentry_secret, api_token))
1773
+ ):
1774
+ logging.error(
1775
+ "The Sentry secret is identical to the webhook secret or API "
1776
+ "token. They MUST differ; disabling /sentry."
1777
+ )
1778
+ sentry_secret = ""
1779
+
1780
+ # Slack/Linear secrets: env only. Each must differ from every other
1781
+ # credential, since each authenticates a different party.
1782
+ slack_secret, linear_secret = resolve_chat_secrets(
1783
+ os.environ, secret, api_token, sentry_secret)
1784
+
1353
1785
  # Persist resolved values, but never write the secret to disk.
1354
1786
  config["port"] = port
1355
1787
  config["dry_run"] = dry_run
@@ -1363,6 +1795,9 @@ def main():
1363
1795
  WebhookHandler.dry_run = dry_run
1364
1796
  WebhookHandler.secret = secret
1365
1797
  WebhookHandler.api_token = api_token
1798
+ WebhookHandler.sentry_secret = sentry_secret
1799
+ WebhookHandler.slack_secret = slack_secret
1800
+ WebhookHandler.linear_secret = linear_secret
1366
1801
  WebhookHandler.dispatcher = dispatcher
1367
1802
 
1368
1803
  signing_key, signing_kid = load_signing_key(auto_generate=False)
@@ -1404,6 +1839,21 @@ def main():
1404
1839
  "503. Set LOKI_API_TOKEN or LOKI_API_TOKEN_FILE to enable remote "
1405
1840
  "submits."
1406
1841
  )
1842
+ if not sentry_secret:
1843
+ logging.warning(
1844
+ "No Sentry secret configured: POST /sentry is rejected with 503. "
1845
+ "Set LOKI_SENTRY_WEBHOOK_SECRET or SENTRY_CLIENT_SECRET."
1846
+ )
1847
+ if not slack_secret:
1848
+ logging.warning(
1849
+ "No Slack signing secret configured: POST /slack is rejected with "
1850
+ "503. Set SLACK_SIGNING_SECRET."
1851
+ )
1852
+ if not linear_secret:
1853
+ logging.warning(
1854
+ "No Linear webhook secret configured: POST /linear is rejected with "
1855
+ "503. Set LINEAR_WEBHOOK_SECRET."
1856
+ )
1407
1857
  if not secret:
1408
1858
  logging.warning(
1409
1859
  "No webhook secret configured: ALL webhooks will be rejected with "
@@ -2584,6 +2584,15 @@ OPTIONS:
2584
2584
  is exactly as without --hosted (fail-open, never a silent
2585
2585
  pass). Without --hosted, output is byte-identical to prior
2586
2586
  releases.
2587
+ --seal-composite <run-dir>
2588
+ Seal a workspace run into one signed composite receipt
2589
+ (composite.json). `loki verify <composite.json>` then
2590
+ re-checks it offline. Routed to the engine10 verifier.
2591
+ --max-age <DUR> With a receipt path: refuse a receipt whose signed issue
2592
+ time is older than DUR (Ns, Nm, Nh, Nd or seconds), has
2593
+ no signed issue time, or is more than 300s in the
2594
+ future. Exits 2 (UNCHECKED). Routed to the engine10
2595
+ verifier.
2587
2596
  -h, --help Show this help.
2588
2597
 
2589
2598
  GATES (deterministic, reproducible by construction):
package/completions/_loki CHANGED
@@ -313,6 +313,10 @@ function _loki_start {
313
313
  _arguments \
314
314
  '--provider[AI provider]:provider name:(claude cline codex aider opencode)' \
315
315
  '--parallel[Parallel mode]' \
316
+ '--workspace[Run a loki.yaml workspace for the brief]:workspace name:' \
317
+ '--deploy[Deploy after a VERIFIED run via loki deploy --execute]' \
318
+ '--health-url[With --deploy: health probe URL]:url:' \
319
+ '--auto-rollback[With --deploy: roll back on a failed health probe]' \
316
320
  '--background[Background mode]' \
317
321
  '--bg[Background mode]' \
318
322
  '--simple[Force simple complexity]' \
@@ -27,7 +27,7 @@ _loki_completion() {
27
27
 
28
28
  # If the word starts with a dash, show flags
29
29
  if [[ "$cur" == -* ]]; then
30
- local flags="--provider --max-iterations --parallel --background --bg --simple --complex --github --no-dashboard --sandbox --skip-memory --spec --spec-first --yes --budget --attempts --model --isolation --max-cost --no-pr --explain-route --help"
30
+ local flags="--provider --max-iterations --parallel --background --bg --simple --complex --github --no-dashboard --sandbox --skip-memory --spec --spec-first --yes --budget --attempts --model --isolation --max-cost --no-pr --explain-route --workspace --deploy --health-url --auto-rollback --help"
31
31
  COMPREPLY=( $(compgen -W "${flags}" -- "$cur") )
32
32
  return 0
33
33
  fi
@@ -57,7 +57,11 @@ _loki_completion() {
57
57
  ;;
58
58
 
59
59
  memory)
60
- local memory_cmds="list show search stats export clear dedupe index timeline consolidate economics retrieve lessons learn forget episode pattern skill vectors help"
60
+ local memory_cmds="list show search stats export clear dedupe index timeline consolidate economics retrieve lessons learn forget episode pattern skill vectors brief help"
61
+ if [[ "$cur" == -* ]]; then
62
+ COMPREPLY=( $(compgen -W "--task --help" -- "$cur") )
63
+ return 0
64
+ fi
61
65
  COMPREPLY=( $(compgen -W "${memory_cmds}" -- "$cur") )
62
66
  ;;
63
67
 
@@ -136,6 +140,20 @@ _loki_completion() {
136
140
  fi
137
141
  ;;
138
142
 
143
+ deploy)
144
+ if [[ "$cur" == -* ]]; then
145
+ COMPREPLY=( $(compgen -W "--execute --health-url --auto-rollback --dir --no-clip --allow-unanchored --help" -- "$cur") )
146
+ return 0
147
+ fi
148
+ ;;
149
+
150
+ verify)
151
+ if [[ "$cur" == -* ]]; then
152
+ COMPREPLY=( $(compgen -W "--base --out --block-on --no-llm --json --explain --check-fresh --max-age --hosted --seal-composite --help" -- "$cur") )
153
+ return 0
154
+ fi
155
+ ;;
156
+
139
157
  chat)
140
158
  if [[ "$cur" == -* ]]; then
141
159
  COMPREPLY=( $(compgen -W "--attach --tui --help" -- "$cur") )
@@ -7,6 +7,6 @@ Modules:
7
7
  control: Session control helpers (status, atomic writes, origin check)
8
8
  """
9
9
 
10
- __version__ = "11.6.0"
10
+ __version__ = "11.8.1"
11
11
 
12
12
  __all__ = ["__version__"]