loki-mode 9.22.12 → 9.23.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 +3 -3
  2. package/VERSION +1 -1
  3. package/autonomy/lib/claude-flags.sh +69 -5
  4. package/autonomy/lib/contract-scaffold/scaffold.sh +1 -1
  5. package/autonomy/loki +324 -74
  6. package/autonomy/run.sh +120 -39
  7. package/autonomy/verify.sh +16 -3
  8. package/bin/loki +25 -1
  9. package/dashboard/__init__.py +1 -1
  10. package/dashboard/control.py +88 -4
  11. package/dashboard/server.py +50 -48
  12. package/docs/INSTALLATION.md +2 -2
  13. package/docs/RARV-C-100X-PLAN.md +9 -1
  14. package/docs/enterprise/performance.md +51 -9
  15. package/docs/exit-codes.md +4 -0
  16. package/loki-ts/dist/cockpit.js +1 -1
  17. package/loki-ts/dist/loki.js +634 -549
  18. package/loki-ts/package.json +2 -2
  19. package/mcp/__init__.py +1 -1
  20. package/package.json +6 -5
  21. package/plugins/loki-mode/.claude-plugin/plugin.json +2 -3
  22. package/providers/loader.sh +2 -2
  23. package/providers/model_catalog.json +26 -8
  24. package/providers/models.sh +1 -1
  25. package/providers/opencode.sh +1 -1
  26. package/skills/providers.md +42 -14
  27. package/tools/normalize-package-permissions.mjs +34 -0
  28. package/web-app/dist/assets/{AdminPage-DLhWX6si.js → AdminPage-PyyVXg4b.js} +1 -1
  29. package/web-app/dist/assets/{Avatar-CqyDcUxl.js → Avatar-CTUlr_Xf.js} +1 -1
  30. package/web-app/dist/assets/{Badge-BKGSt1n2.js → Badge-BQeXvmg8.js} +1 -1
  31. package/web-app/dist/assets/{Button-DuuKHXre.js → Button-Dc6-P6pz.js} +1 -1
  32. package/web-app/dist/assets/{CockpitPage-DdClYERC.js → CockpitPage-BspOJ3Fv.js} +1 -1
  33. package/web-app/dist/assets/{ComparePage-CUWlqBxv.js → ComparePage-CFXIM--Z.js} +1 -1
  34. package/web-app/dist/assets/{ErrorBoundary-RwfGKyRJ.js → ErrorBoundary-C7vQYDW9.js} +1 -1
  35. package/web-app/dist/assets/{EvidenceReceiptPanel-CzIgk9sg.js → EvidenceReceiptPanel-CqjsjTOs.js} +1 -1
  36. package/web-app/dist/assets/{GitHubIssuesPanel-CsXVKuyJ.js → GitHubIssuesPanel-DyuNDMgD.js} +1 -1
  37. package/web-app/dist/assets/{GitHubPRsPanel-CYRp1eKS.js → GitHubPRsPanel-BtAq20xF.js} +1 -1
  38. package/web-app/dist/assets/{HomePage-CVJcI5pQ.js → HomePage-BuVFOrO8.js} +1 -1
  39. package/web-app/dist/assets/{LoginPage-BBJNV9Qb.js → LoginPage-CuhhrxGb.js} +1 -1
  40. package/web-app/dist/assets/{MagicPage-CIi9P8jK.js → MagicPage-D2KZPX3F.js} +1 -1
  41. package/web-app/dist/assets/{MetricsPage-Bm34ipiy.js → MetricsPage-DFEYJl9z.js} +1 -1
  42. package/web-app/dist/assets/{NotFoundPage-DpTbcC2P.js → NotFoundPage-CTnnaWlL.js} +1 -1
  43. package/web-app/dist/assets/{ProjectPage-CY2SMGx9.js → ProjectPage-uJFaoM8-.js} +55 -55
  44. package/web-app/dist/assets/{ProjectsPage-DSrXUUKJ.js → ProjectsPage-8lKAOBIW.js} +1 -1
  45. package/web-app/dist/assets/{SettingsPage-jvDyOqYi.js → SettingsPage-D1BBd5qy.js} +1 -1
  46. package/web-app/dist/assets/{ShowcasePage-C8tFWKau.js → ShowcasePage-CmQ0n8iO.js} +1 -1
  47. package/web-app/dist/assets/{SystemSettingsPage-DH4FTH3K.js → SystemSettingsPage-CSHaVyuD.js} +1 -1
  48. package/web-app/dist/assets/{TeamsPage-C1fQYauF.js → TeamsPage-BS0TtVUa.js} +1 -1
  49. package/web-app/dist/assets/{TemplatesPage-CSTFt-4P.js → TemplatesPage-C2zPnnf2.js} +1 -1
  50. package/web-app/dist/assets/{TerminalOutput-Cu4NsyN6.js → TerminalOutput-Dle1klqq.js} +1 -1
  51. package/web-app/dist/assets/{activity-CtjsDzmh.js → activity-DQp5U9f9.js} +1 -1
  52. package/web-app/dist/assets/{bell-C5es1xAs.js → bell-BqkTmgL7.js} +1 -1
  53. package/web-app/dist/assets/{bot-DE04U5Lc.js → bot-DILhaIGH.js} +1 -1
  54. package/web-app/dist/assets/{check-DSKKA9EU.js → check-BC9x7oPm.js} +1 -1
  55. package/web-app/dist/assets/{chevron-left-DJ13W5AR.js → chevron-left--yqvAfjx.js} +1 -1
  56. package/web-app/dist/assets/{circle-alert-CS0BfqAd.js → circle-alert-DOH6PcgZ.js} +1 -1
  57. package/web-app/dist/assets/{clock-D1Y5nruk.js → clock-DKSDFsE6.js} +1 -1
  58. package/web-app/dist/assets/{cloud-nU6LBoZL.js → cloud-DRBU3JYs.js} +1 -1
  59. package/web-app/dist/assets/{code-xml-BZfvIdFo.js → code-xml-D2LqszR9.js} +1 -1
  60. package/web-app/dist/assets/{copy-CK_2O1Xv.js → copy-BKW4U4Qr.js} +1 -1
  61. package/web-app/dist/assets/{database-DJiwznAm.js → database-D7ceedlh.js} +1 -1
  62. package/web-app/dist/assets/{dollar-sign-pO20A3QY.js → dollar-sign-D0A47egY.js} +1 -1
  63. package/web-app/dist/assets/{file-code-corner-Besy6vek.js → file-code-corner-CH_Qbtjo.js} +1 -1
  64. package/web-app/dist/assets/{file-plus-DeaswxYY.js → file-plus-CUNMjHk9.js} +1 -1
  65. package/web-app/dist/assets/{folder-open-cr1UvQLI.js → folder-open-CQ_SVq94.js} +1 -1
  66. package/web-app/dist/assets/{git-commit-horizontal-BLxnfbSn.js → git-commit-horizontal-DQUBe0oD.js} +1 -1
  67. package/web-app/dist/assets/{globe-DrhG1NfX.js → globe-gZnaGg8N.js} +1 -1
  68. package/web-app/dist/assets/{hammer-CeZtPBfE.js → hammer-BhJC5DMK.js} +1 -1
  69. package/web-app/dist/assets/{index-CLvjO22Z.js → index-BMmYYws0.js} +2 -2
  70. package/web-app/dist/assets/{layers-fldADzyE.js → layers-glXeoUbJ.js} +1 -1
  71. package/web-app/dist/assets/{lightbulb-CznOfsPb.js → lightbulb-gXn8H0Lf.js} +1 -1
  72. package/web-app/dist/assets/{loader-circle---lPB1Fs.js → loader-circle-Do2ke8xm.js} +1 -1
  73. package/web-app/dist/assets/{lock-D5jVF-tO.js → lock-Bgplb1_n.js} +1 -1
  74. package/web-app/dist/assets/{mail-D7HkLnlW.js → mail-j4Lo8hfB.js} +1 -1
  75. package/web-app/dist/assets/{minus-C-dT19Ct.js → minus-dhrzKSK5.js} +1 -1
  76. package/web-app/dist/assets/{package-Cm9B-cKE.js → package-CInWQQ97.js} +1 -1
  77. package/web-app/dist/assets/{plus-CQwOffLD.js → plus-BpYvoLlZ.js} +1 -1
  78. package/web-app/dist/assets/{refresh-cw-CvWJZLbN.js → refresh-cw-F6koqEgF.js} +1 -1
  79. package/web-app/dist/assets/{rotate-ccw-C2Q1LYgM.js → rotate-ccw-CQ2W5S8f.js} +1 -1
  80. package/web-app/dist/assets/{save-BP0MvpoQ.js → save-DMCYGm06.js} +1 -1
  81. package/web-app/dist/assets/{server-4i4VZXix.js → server-Bj8EX4Z1.js} +1 -1
  82. package/web-app/dist/assets/{shield-alert-D2_bgb7j.js → shield-alert-CiYuulCU.js} +1 -1
  83. package/web-app/dist/assets/{thumbs-up-vACbzeXj.js → thumbs-up-BqEh1puP.js} +1 -1
  84. package/web-app/dist/assets/{trash-2-EYjdESLs.js → trash-2-CCwouZJJ.js} +1 -1
  85. package/web-app/dist/assets/{trending-down-BWBdcvE3.js → trending-down-Cx1vhE4o.js} +1 -1
  86. package/web-app/dist/assets/{trending-up-Bix6V_-Y.js → trending-up-BUhVYJ2T.js} +1 -1
  87. package/web-app/dist/assets/{upload-B7hh6ERF.js → upload-D5IsZeFM.js} +1 -1
  88. package/web-app/dist/assets/{usePolling-b_e-k-IN.js → usePolling-CJbfhFcH.js} +1 -1
  89. package/web-app/dist/assets/{user-pmjZKWEg.js → user-BRLhezsD.js} +1 -1
  90. package/web-app/dist/index.html +1 -1
  91. package/web-app/requirements.txt +1 -0
  92. package/web-app/server.py +160 -31
package/autonomy/run.sh CHANGED
@@ -967,6 +967,36 @@ _loki_provider_pipeline_exit_code() {
967
967
  fi
968
968
  }
969
969
 
970
+ # Invoke through a provider's argv-builder seam while preserving the main
971
+ # loop's deadline, fan-out logging, and provider-stage exit status.
972
+ _loki_invoke_argv_provider() {
973
+ local tier="${1:-development}"
974
+ local prompt="${2:-}"
975
+ local log_file="${3:-}"
976
+ local agent_log="${4:-}"
977
+ local iter_output="${5:-}"
978
+ local -a _loki_argv_pipe_status=()
979
+
980
+ if ! type provider_invoke_argv >/dev/null 2>&1; then
981
+ log_error "Provider ${PROVIDER_NAME:-unknown} does not implement provider_invoke_argv"
982
+ return 125
983
+ fi
984
+ if ! provider_invoke_argv "$tier" "$prompt" \
985
+ || [ "${#_LOKI_INVOKE_ARGV[@]}" -eq 0 ]; then
986
+ log_error "Provider ${PROVIDER_NAME:-unknown} failed to build invocation argv"
987
+ return 125
988
+ fi
989
+
990
+ LOKI_DEADLINE_IDLE_TIMEOUT="${LOKI_PROVIDER_IDLE_TIMEOUT:-0}" \
991
+ _loki_with_deadline "${LOKI_PROVIDER_CALL_TIMEOUT:-0}" \
992
+ "${_LOKI_INVOKE_ARGV[@]}" 2>&1 \
993
+ | tee -a "$log_file" "$agent_log" "$iter_output"
994
+ _loki_argv_pipe_status=("${PIPESTATUS[@]}")
995
+ return "$(_loki_provider_pipeline_exit_code \
996
+ "${_loki_argv_pipe_status[0]:-125}" \
997
+ "${_loki_argv_pipe_status[1]:-125}" 0)"
998
+ }
999
+
970
1000
  _LOKI_DEPENDENCY_SETUP_LIB="${SCRIPT_DIR}/lib/dependency-setup.sh"
971
1001
  if [ -r "$_LOKI_DEPENDENCY_SETUP_LIB" ]; then
972
1002
  # shellcheck source=lib/dependency-setup.sh
@@ -1519,20 +1549,20 @@ PYREG
1519
1549
  # if any other project is still running (KEEP) it is left up. NEVER uses a
1520
1550
  # blanket pkill and NEVER touches another folder's pids. Best-effort and
1521
1551
  # failure-swallowed: teardown bookkeeping must never block a clean exit.
1522
- # Is this pid plausibly OUR dashboard, or a recycled number now naming something
1523
- # else? Fails OPEN (returns 0) whenever ps cannot tell us, so the only behavior
1524
- # change is refusing to kill a process that is positively identified as NOT a
1525
- # dashboard. See the call site for the measured stale-file evidence.
1552
+ # Is this pid positively identified as our dashboard, rather than a recycled
1553
+ # number now naming something else? Ownership-sensitive cleanup must fail closed:
1554
+ # an absent `ps` result is not evidence, and a generic python/loki substring is
1555
+ # not specific enough to authorize a signal.
1526
1556
  _loki_pid_looks_like_dashboard() {
1527
1557
  local _p="$1" _cmd
1528
1558
  case "$_p" in ''|*[!0-9]*) return 1 ;; esac
1529
1559
  [ "$_p" -gt 1 ] 2>/dev/null || return 1 # never signal pid 0/1
1530
1560
  _cmd="$(ps -o command= -p "$_p" 2>/dev/null)"
1531
- [ -n "$_cmd" ] || return 0 # ps unavailable: behave as before
1532
- case "$_cmd" in
1533
- *dashboard*|*uvicorn*|*loki*) return 0 ;;
1534
- esac
1535
- return 1
1561
+ [ -n "$_cmd" ] || return 1
1562
+ # run.sh and `loki dashboard start` both launch this exact module form.
1563
+ # Anchor the interpreter and module tokens so an unrelated command merely
1564
+ # mentioning "dashboard.server" in an argument cannot forge identity.
1565
+ [[ "$_cmd" =~ ^[[:space:]]*([^[:space:]]*/)?[Pp]ython([0-9]+([.][0-9]+)*)?[[:space:]]+-m[[:space:]]+dashboard[.]server([[:space:]]|$) ]]
1536
1566
  }
1537
1567
 
1538
1568
  loki_mark_project_stopped_and_maybe_kill_shared_dashboard() {
@@ -1591,9 +1621,10 @@ PYCHECK
1591
1621
  #
1592
1622
  # kill -0 is NOT sufficient: a recycled pid is alive by definition,
1593
1623
  # which is exactly the insufficiency the loki.pgid self-check had.
1594
- # Mirrors _app_runner_pid_is_ours (app-runner.sh:242) and fails OPEN
1595
- # the same way -- if `ps` says nothing we signal as before, so a
1596
- # legitimate dashboard is never left running by this check.
1624
+ # This guard fails CLOSED: if `ps` says nothing, or says something
1625
+ # that is not the dashboard's own command, we do not signal. The
1626
+ # cost of failing closed is a leaked dashboard the next explicit
1627
+ # stop reaps; the cost of failing open was signalling a stranger.
1597
1628
  if [ -n "$_shared_pid" ] && _loki_pid_looks_like_dashboard "$_shared_pid"; then
1598
1629
  kill "$_shared_pid" 2>/dev/null || true
1599
1630
  sleep 0.5
@@ -1601,27 +1632,11 @@ PYCHECK
1601
1632
  fi
1602
1633
  rm -f "$_shared_pidfile" 2>/dev/null || true
1603
1634
  fi
1604
- # (d) Defense-in-depth: reclaim the dashboard port only in the CLEAR
1605
- # case, so we never kill a shared dashboard another project owns.
1606
- # BUT never kill a HEALTHY dashboard already serving on the port: that is
1607
- # almost always the user's own live dashboard (open in their browser), and
1608
- # killing it mid-use drops their session (ERR_CONNECTION_REFUSED, WS fail).
1609
- # A healthy server is reusable by every project, so probe /api/status first
1610
- # and only reclaim the port when nothing is answering (a genuinely stale
1611
- # listener). Opt out of the probe with LOKI_DASHBOARD_FORCE_RECLAIM=1.
1612
- if command -v lsof >/dev/null 2>&1; then
1613
- local _dash_port="${DASHBOARD_PORT:-57374}"
1614
- local _dash_alive=""
1615
- if [ "${LOKI_DASHBOARD_FORCE_RECLAIM:-}" != "1" ] && command -v curl >/dev/null 2>&1; then
1616
- _dash_alive=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 \
1617
- "http://127.0.0.1:${_dash_port}/api/status" 2>/dev/null || true)
1618
- fi
1619
- if [ "$_dash_alive" = "200" ]; then
1620
- log_info "Reusing the healthy dashboard already serving on port ${_dash_port} (not reclaiming)."
1621
- else
1622
- lsof -ti:"${_dash_port}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
1623
- fi
1624
- fi
1635
+ # A port number and an HTTP response are not ownership evidence. In
1636
+ # particular, a 404 can be an unrelated healthy service. The shared PID
1637
+ # file plus positive command identity above is the only authority to
1638
+ # signal a process; absent or forged ownership leaves every port holder
1639
+ # untouched.
1625
1640
  fi
1626
1641
  }
1627
1642
  # Register as running now. We deliberately do NOT install an EXIT trap to
@@ -3207,7 +3222,7 @@ validate_api_keys() {
3207
3222
  # "loggedin" or "unknown" -> proceed (fail open on uncertainty).
3208
3223
  fi
3209
3224
 
3210
- # CLI tools (claude, codex, cline, aider) use their own login sessions.
3225
+ # CLI tools (claude, codex, cline, aider, opencode) use their own login sessions.
3211
3226
  # Only require API keys inside Docker/K8s where CLI login isn't available.
3212
3227
  if [[ ! -f "/.dockerenv" ]] && [[ -z "${KUBERNETES_SERVICE_HOST:-}" ]]; then
3213
3228
  return 0
@@ -13425,6 +13440,43 @@ _loki_requirements_contract_emit() {
13425
13440
  "${LOKI_REVIEW_REQUIREMENTS_IDENTITY:-}"
13426
13441
  }
13427
13442
 
13443
+ # A reviewer dispatch can die BEFORE the provider is ever invoked -- the
13444
+ # prompt rematerialization or the shard-identity lookup below both exit 125
13445
+ # while LOKI_REVIEW_STDERR_FILE is still unset and no timing sidecar exists
13446
+ # yet. That left a lost shard structurally invisible: the logical aggregation
13447
+ # (REQUIREMENTS_LOGICAL_TIMING) substituted an anonymous placeholder and the
13448
+ # only symptom was a bare call count that could not name WHICH shard vanished.
13449
+ # Emit the same sidecar the completed path emits, plus one bounded stderr
13450
+ # line, so the existing aggregation carries a shard-identifying cause.
13451
+ # ponytail: fixed-format single line, no truncation helper needed.
13452
+ _dispatch_reviewer_prestub_failure() {
13453
+ local review_output="$1" stderr_output="$2" stage="$3" shard_index="${4:-0}"
13454
+ printf 'loki-review-prestub-failure stage=%s shard_index=%s reviewer=%s\n' \
13455
+ "$stage" "$shard_index" "${5:-unknown}" >> "$stderr_output" 2>/dev/null || true
13456
+ chmod 600 "$stderr_output" 2>/dev/null || true
13457
+ _LOKI_RDT_PATH="${review_output%.txt}-timing.json" \
13458
+ _LOKI_RDT_STAGE="$stage" _LOKI_RDT_SHARD="$shard_index" \
13459
+ _LOKI_RDT_STDERR="$stderr_output" \
13460
+ _LOKI_RDT_BUDGET="${LOKI_REVIEW_CALL_TIMEOUT:-0}" python3 - <<'REVIEW_PRESTUB_TIMING' 2>/dev/null || true
13461
+ import json
13462
+ import os
13463
+
13464
+ record = {
13465
+ "schema": "loki-review-dispatch/v1",
13466
+ "budget_seconds": int(os.environ.get("_LOKI_RDT_BUDGET", "0") or 0),
13467
+ "elapsed_ms": 0,
13468
+ "exit_code": 125,
13469
+ "outcome": "prestub_error",
13470
+ "deadline_scope": "pre_dispatch",
13471
+ "stage": os.environ.get("_LOKI_RDT_STAGE", "unknown"),
13472
+ "shard_index": int(os.environ.get("_LOKI_RDT_SHARD", "0") or 0),
13473
+ "stderr_bytes": os.path.getsize(os.environ["_LOKI_RDT_STDERR"]),
13474
+ }
13475
+ with open(os.environ["_LOKI_RDT_PATH"], "w", encoding="utf-8") as handle:
13476
+ json.dump(record, handle, sort_keys=True)
13477
+ REVIEW_PRESTUB_TIMING
13478
+ }
13479
+
13428
13480
  # Persist one compact record per provider review. The sidecar is written by the
13429
13481
  # same process that owns the dispatch, so parallel reviewer timings do not get
13430
13482
  # distorted by the parent's ordered wait loop.
@@ -15373,7 +15425,13 @@ BUILD_PROMPT
15373
15425
  prompt_text=$(python3 "$requirements_helper" read-bound \
15374
15426
  "$review_prompt_file" "$prompt_sha" \
15375
15427
  "$_review_max_prompt_bytes" "$prompt_identity" 2>/dev/null) \
15376
- || exit 125
15428
+ || {
15429
+ _dispatch_reviewer_prestub_failure \
15430
+ "$review_stage_file" \
15431
+ "$review_dir/$review_id/${reviewer_name}-stderr.log" \
15432
+ prompt_read_bound "$reviewer_shard_index" "$reviewer_name"
15433
+ exit 125
15434
+ }
15377
15435
  if [ "$reviewer_logical_name" = "requirements-verifier" ]; then
15378
15436
  local shard_label shard_identity
15379
15437
  printf -v shard_label '%03d' "$reviewer_shard_index"
@@ -15381,7 +15439,13 @@ BUILD_PROMPT
15381
15439
  _LOKI_REVIEW_SHARD_INDEX="$reviewer_shard_index" python3 -c '
15382
15440
  import json, os
15383
15441
  print(json.loads(os.environ["_LOKI_REVIEW_SHARD_IDENTITIES"])[int(os.environ["_LOKI_REVIEW_SHARD_INDEX"]) - 1])
15384
- ') || exit 125
15442
+ ') || {
15443
+ _dispatch_reviewer_prestub_failure \
15444
+ "$review_stage_file" \
15445
+ "$review_dir/$review_id/${reviewer_name}-stderr.log" \
15446
+ shard_identity "$reviewer_shard_index" "$reviewer_name"
15447
+ exit 125
15448
+ }
15385
15449
  LOKI_REVIEW_REQUIREMENTS_HELPER="$requirements_helper" \
15386
15450
  LOKI_REVIEW_REQUIREMENTS_SOURCE="$requirements_source" \
15387
15451
  LOKI_REVIEW_REQUIREMENTS_SNAPSHOT="$requirements_file" \
@@ -16557,9 +16621,16 @@ start_dashboard() {
16557
16621
  # Check if it's our own dashboard
16558
16622
  local existing_pid=$(lsof -ti :$DASHBOARD_PORT 2>/dev/null | head -1)
16559
16623
  if [ -n "$existing_pid" ]; then
16560
- # Only kill if it's a Python/uvicorn dashboard process
16624
+ # Only kill a process positively identified as OUR dashboard.
16625
+ # `-o comm=` yields just the executable name ("python3"), so the old
16626
+ # *python*/*uvicorn* test matched ANY python process that happened to
16627
+ # hold this port -- an unrelated http.server answering 404 was killed
16628
+ # as a "stuck dashboard". Reuse the same full-command-line guard the
16629
+ # teardown path uses: it fails closed on an unreadable or foreign
16630
+ # command, so a stranger on this port is stepped over (port++) rather
16631
+ # than signalled.
16561
16632
  local proc_cmd=$(ps -p "$existing_pid" -o comm= 2>/dev/null || true)
16562
- if [[ "$proc_cmd" == *python* ]] || [[ "$proc_cmd" == *uvicorn* ]]; then
16633
+ if _loki_pid_looks_like_dashboard "$existing_pid"; then
16563
16634
  # Never kill a HEALTHY dashboard already serving here: it is almost
16564
16635
  # always the user's own live dashboard (open in their browser), and
16565
16636
  # killing it on `loki start` drops their session mid-use. A healthy
@@ -23266,6 +23337,16 @@ if __name__ == "__main__":
23266
23337
  } && exit_code=0 || exit_code=$?
23267
23338
  ;;
23268
23339
 
23340
+ opencode)
23341
+ # Keep argv construction provider-owned: its prompt is
23342
+ # positional, --auto is mandatory, and the model is provider
23343
+ # scoped. The wrapper preserves the main-loop deadline/tee
23344
+ # contract and returns the provider stage's nonzero status.
23345
+ _loki_invoke_argv_provider "$CURRENT_TIER" "$prompt" \
23346
+ "$log_file" "$agent_log" "$iter_output"
23347
+ exit_code=$?
23348
+ ;;
23349
+
23269
23350
  *)
23270
23351
  log_error "Unknown provider: ${PROVIDER_NAME:-unknown}"
23271
23352
  local exit_code=1
@@ -25745,7 +25826,7 @@ main() {
25745
25826
  fi
25746
25827
  shift 2
25747
25828
  else
25748
- log_error "--provider requires a value (claude, codex, cline, aider)"
25829
+ log_error "--provider requires a value (claude, codex, cline, aider, opencode)"
25749
25830
  exit 1
25750
25831
  fi
25751
25832
  ;;
@@ -368,15 +368,28 @@ _verify_zero_tests_executed() {
368
368
  [ -n "$_zt_f" ] || continue
369
369
  _zt_bases="${_zt_bases}${_zt_f}"$'\n'"$(basename "$_zt_f")"$'\n'
370
370
  done
371
- local _zt_real=0 _zt_line _zt_label
371
+ local _zt_real=0 _zt_line _zt_label _zt_matches
372
+ _zt_matches=$(printf '%s\n' "$_zt_out" | grep -E '^(ok|not ok) [0-9]+ - ' 2>/dev/null || true)
372
373
  while IFS= read -r _zt_line; do
374
+ [ -n "$_zt_line" ] || continue
373
375
  _zt_label="${_zt_line#* - }"
374
376
  case "$_zt_bases" in
375
377
  *$'\n'"$_zt_label"$'\n'*) continue ;;
376
378
  esac
377
379
  _zt_real=$((_zt_real + 1))
378
- done < <(printf '%s\n' "$_zt_out" | grep -E '^(ok|not ok) [0-9]+ - ' 2>/dev/null)
379
- [ "$_zt_real" -eq 0 ] && return 0
380
+ done << ZT_MATCHES_EOF
381
+ $_zt_matches
382
+ ZT_MATCHES_EOF
383
+ [ "$_zt_real" -gt 0 ] && return 1
384
+
385
+ # Node 26 defaults to the human-readable spec reporter under npm
386
+ # scripts. It has no TAP ok-lines, so an absent match is not proof
387
+ # that zero tests ran. Only classify zero tests when the output is
388
+ # positively identifiable as TAP; unknown/custom reporter shapes
389
+ # remain a passing run instead of becoming a false CONCERN.
390
+ case "$_zt_out" in
391
+ *"TAP version"*|*$'\n# tests '*|"# tests "*) return 0 ;;
392
+ esac
380
393
  return 1
381
394
  ;;
382
395
  jest)
package/bin/loki CHANGED
@@ -188,7 +188,7 @@ fi
188
188
  # error regardless of which subcommand they invoke.
189
189
  if [ "${LOKI_PROVIDER:-}" = "gemini" ]; then
190
190
  echo "Error: Provider 'gemini' is deprecated as of v7.5.18 and has been removed." >&2
191
- echo "Active providers: claude, codex, cline, aider" >&2
191
+ echo "Active providers: claude, codex, cline, aider, opencode" >&2
192
192
  echo "Unset LOKI_PROVIDER or use: LOKI_PROVIDER=claude" >&2
193
193
  exit 1
194
194
  fi
@@ -273,8 +273,19 @@ _loki_start_needs_bash() {
273
273
  # $@ = the start args (already know $1 = start). Return 0 (divert to bash) if
274
274
  # any bash-only orchestration flag or an issue-ref spec is present.
275
275
  local a
276
+ local explicit_provider=""
277
+ local provider_value_next=0
276
278
  for a in "$@"; do
279
+ if [ "$provider_value_next" = "1" ]; then
280
+ explicit_provider="$a"
281
+ provider_value_next=0
282
+ continue
283
+ fi
277
284
  case "$a" in
285
+ --provider)
286
+ provider_value_next=1 ;;
287
+ --provider=*)
288
+ explicit_provider="${a#--provider=}" ;;
278
289
  --parallel|--bg|--background|--github|--api|--sandbox \
279
290
  |--fast|--repo-fast|--full|--repo-full \
280
291
  |--bmad-project|--openspec \
@@ -296,6 +307,19 @@ _loki_start_needs_bash() {
296
307
  esac
297
308
  done
298
309
  unset _loki_iso_next
310
+
311
+ # The Bun RARV loop has not ported the opencode invoker yet. Keep the
312
+ # released, autonomous shell adapter reachable when SDK mode is enabled
313
+ # instead of accepting the selection and then failing after intake. CLI
314
+ # selection wins over env, which wins over the project-level saved choice.
315
+ local effective_provider="$explicit_provider"
316
+ if [ -z "$effective_provider" ] && [ -n "${LOKI_PROVIDER:-}" ]; then
317
+ effective_provider="$LOKI_PROVIDER"
318
+ fi
319
+ if [ -z "$effective_provider" ] && [ -f "${PWD}/.loki/state/provider" ]; then
320
+ effective_provider="$(sed -n '1p' "${PWD}/.loki/state/provider" 2>/dev/null || true)"
321
+ fi
322
+ [ "$effective_provider" = "opencode" ] && return 0
299
323
  return 1
300
324
  }
301
325
  if [ "${1:-}" = "start" ] && { [ "${LOKI_SDK_LOOP:-}" = "1" ] || [ "${LOKI_SDK_LOOP:-}" = "true" ]; }; then
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.22.12"
10
+ __version__ = "9.23.1"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -20,10 +20,11 @@ import tempfile
20
20
  from datetime import datetime, timezone
21
21
  from pathlib import Path
22
22
  from typing import Optional
23
+ from urllib.parse import urlsplit
23
24
 
24
- from fastapi import Depends, FastAPI, HTTPException
25
+ from fastapi import Depends, FastAPI, HTTPException, Request
25
26
  from fastapi.middleware.cors import CORSMiddleware
26
- from fastapi.responses import StreamingResponse
27
+ from fastapi.responses import JSONResponse, StreamingResponse
27
28
  from pydantic import BaseModel
28
29
 
29
30
  # Auth gating for the standalone control app.
@@ -56,6 +57,63 @@ except Exception: # pragma: no cover - defensive fallback for non-package runs
56
57
 
57
58
  _CONTROL_DEP = _require_control_scope()
58
59
 
60
+
61
+ def _normalized_http_origin(value: str) -> tuple[str, str, int] | None:
62
+ """Return a comparable browser origin, or ``None`` when malformed.
63
+
64
+ Origin is scheme + host + effective port. Paths, credentials, queries and
65
+ fragments are never valid in an Origin header. Normalising default ports
66
+ makes ``https://host`` and ``https://host:443`` equivalent without doing
67
+ loose prefix/suffix matching on attacker-controlled text.
68
+ """
69
+ try:
70
+ parsed = urlsplit(value.strip())
71
+ if (
72
+ parsed.scheme.lower() not in ("http", "https")
73
+ or not parsed.hostname
74
+ or parsed.username is not None
75
+ or parsed.password is not None
76
+ or parsed.path not in ("", "/")
77
+ or parsed.query
78
+ or parsed.fragment
79
+ ):
80
+ return None
81
+ port = parsed.port
82
+ except (TypeError, ValueError):
83
+ return None
84
+ if port is None:
85
+ port = 443 if parsed.scheme.lower() == "https" else 80
86
+ return parsed.scheme.lower(), parsed.hostname.lower(), port
87
+
88
+
89
+ def browser_mutation_origin_allowed(
90
+ request: Request, allowed_origins: list[str]
91
+ ) -> bool:
92
+ """Allow non-browser clients, same-origin pages and explicit CORS origins.
93
+
94
+ CORS controls whether a browser may *read* a response; it does not stop a
95
+ simple cross-origin POST from being sent. State-changing routes therefore
96
+ need their own check. Browsers always attach Origin to cross-origin fetches,
97
+ while CLI/SDK/local health clients commonly omit it, so absence preserves
98
+ the existing local-first API. A present malformed or unrelated origin is
99
+ refused before route code can touch disk or processes.
100
+ """
101
+ supplied = request.headers.get("origin")
102
+ if supplied is None:
103
+ return True
104
+ if "*" in allowed_origins:
105
+ return True
106
+ supplied_origin = _normalized_http_origin(supplied)
107
+ if supplied_origin is None:
108
+ return False
109
+ request_origin = _normalized_http_origin(str(request.base_url))
110
+ if supplied_origin == request_origin:
111
+ return True
112
+ return any(
113
+ supplied_origin == _normalized_http_origin(candidate)
114
+ for candidate in allowed_origins
115
+ )
116
+
59
117
  # Configuration
60
118
  LOKI_DIR = Path(os.environ.get("LOKI_DIR", ".loki"))
61
119
  STATE_DIR = LOKI_DIR / "state"
@@ -202,16 +260,42 @@ app = FastAPI(
202
260
  # CORS middleware for dashboard frontend - restricted to localhost by default.
203
261
  # Set LOKI_DASHBOARD_CORS to override (comma-separated origins).
204
262
  _cors_default = "http://localhost:57374,http://127.0.0.1:57374"
205
- _cors_origins = os.environ.get("LOKI_DASHBOARD_CORS", _cors_default).split(",")
263
+ _cors_raw = os.environ.get("LOKI_DASHBOARD_CORS", _cors_default)
264
+ _cors_origins = [o.strip() for o in _cors_raw.split(",") if o.strip()]
265
+ if "*" in _cors_origins or _cors_raw.strip() == "*":
266
+ if os.environ.get("LOKI_ENV") == "production":
267
+ raise RuntimeError(
268
+ "Wildcard CORS ('*') is not allowed in production. "
269
+ "Set LOKI_DASHBOARD_CORS to a specific origin list, "
270
+ "or set LOKI_ENV != production."
271
+ )
272
+ import logging
273
+
274
+ logging.getLogger(__name__).warning(
275
+ "LOKI_DASHBOARD_CORS is set to '*' -- all origins are allowed. "
276
+ "This is insecure for production deployments."
277
+ )
206
278
  app.add_middleware(
207
279
  CORSMiddleware,
208
- allow_origins=[o.strip() for o in _cors_origins if o.strip()],
280
+ allow_origins=_cors_origins,
209
281
  allow_credentials=True,
210
282
  allow_methods=["*"],
211
283
  allow_headers=["*"],
212
284
  )
213
285
 
214
286
 
287
+ @app.middleware("http")
288
+ async def browser_mutation_boundary(request: Request, call_next):
289
+ """Refuse cross-origin browser mutations before control side effects."""
290
+ if request.method in ("POST", "PUT", "PATCH", "DELETE"):
291
+ if not browser_mutation_origin_allowed(request, _cors_origins):
292
+ return JSONResponse(
293
+ status_code=403,
294
+ content={"detail": "cross-origin mutation refused"},
295
+ )
296
+ return await call_next(request)
297
+
298
+
215
299
  # Request/Response models
216
300
  class StartRequest(BaseModel):
217
301
  """Request body for starting a Loki Mode session."""
@@ -58,7 +58,12 @@ from . import audit
58
58
  from . import app_secrets as secrets_mod
59
59
  from . import telemetry as _telemetry
60
60
  from . import build_supervisor as _build_execution
61
- from .control import atomic_write_json, find_skill_dir, is_process_running
61
+ from .control import (
62
+ atomic_write_json,
63
+ browser_mutation_origin_allowed,
64
+ find_skill_dir,
65
+ is_process_running,
66
+ )
62
67
  from .activity_logger import get_activity_logger
63
68
  from .api_v2 import (
64
69
  TenantContext,
@@ -187,53 +192,46 @@ def _rate_key(base: str, request: Optional[Request]) -> str:
187
192
  logger = logging.getLogger(__name__)
188
193
 
189
194
 
190
- # Reads that expose operational or credential-adjacent state. These stay open
191
- # to a LOCAL caller (zero-config use is the point) but must not be readable by
192
- # an anonymous remote caller when the dashboard is bound to 0.0.0.0.
195
+ # Public API metadata needed before a caller can authenticate or before the UI
196
+ # can choose a provider. Everything else under an API namespace is
197
+ # state-bearing unless it is explicitly admitted here.
193
198
  #
194
- # Measured before this list existed, from a routable remote address with auth
195
- # off: /api/logs, /api/secrets/status, /api/github/status, /api/tasks,
196
- # /api/council/transcripts and /api/proofs all returned 200.
199
+ # This is deliberately an ALLOWLIST, not the old list of sensitive prefixes.
200
+ # That list silently started every new read family public. Measured from a
201
+ # routable remote address with auth off, routes added after the list --
202
+ # /api/context, /api/notifications, /api/agents, /api/usage,
203
+ # /api/prd-observations and every /api/v2/* read -- all reached their handlers
204
+ # and returned project, tenant or audit state.
197
205
  #
198
- # /health and /metrics are deliberately ABSENT: a container health probe and a
199
- # Prometheus scrape must keep working with no configuration, and neither
200
- # carries workspace content.
201
- _SENSITIVE_READ_PREFIXES = (
202
- "/api/logs",
203
- "/api/secrets",
204
- "/api/github",
205
- "/api/tasks",
206
- "/api/projects",
207
- "/api/council",
208
- "/api/proofs",
209
- "/api/phases",
210
- "/api/memory",
211
- "/api/learnings",
212
- "/api/learning",
213
- "/api/escalations",
214
- "/api/spec",
215
- "/api/checkpoints",
216
- "/api/enterprise",
217
- "/api/collab",
218
- "/api/cost",
219
- "/api/budget",
220
- "/api/findings",
221
- "/api/operator",
222
- "/api/fleet",
223
- "/api/registry",
224
- "/api/wiki",
225
- "/api/activity",
226
- "/api/session",
227
- "/api/failures",
228
- "/api/prompt",
229
- "/api/quality",
230
- "/api/migration",
231
- "/api/managed",
232
- "/api/app-runner",
233
- "/api/playwright",
234
- "/api/checklist",
235
- "/api/control",
236
- )
206
+ # /health, /metrics, /.well-known/*, docs and static/UI routes are outside the
207
+ # API namespaces and remain public. The three entries below contain only
208
+ # capability/auth bootstrap metadata and are already pinned as public by the
209
+ # dashboard auth inventory tests.
210
+ _PUBLIC_API_GET_PATHS = frozenset({
211
+ "/api/auth/info",
212
+ "/api/enterprise/status",
213
+ "/api/providers/models",
214
+ })
215
+
216
+
217
+ def _is_state_bearing_get(path: str) -> bool:
218
+ """Classify dashboard reads at the namespace boundary.
219
+
220
+ A future GET under /api or the mounted Purple Lab's /lab/api namespace is
221
+ private by default. Exact public metadata is admitted above; probes,
222
+ discovery documents and UI/static files live outside these namespaces.
223
+ Normalize one trailing slash so FastAPI's redirect spelling cannot turn a
224
+ public metadata request into a remote-only failure.
225
+ """
226
+ normalized = path.rstrip("/") or "/"
227
+ if normalized in _PUBLIC_API_GET_PATHS:
228
+ return False
229
+ return (
230
+ normalized == "/api"
231
+ or normalized.startswith("/api/")
232
+ or normalized == "/lab/api"
233
+ or normalized.startswith("/lab/api/")
234
+ )
237
235
 
238
236
 
239
237
  def _trusted_proxies() -> frozenset:
@@ -1263,9 +1261,13 @@ async def dashboard_control_boundary(request: Request, call_next):
1263
1261
  # /health and /metrics are intentionally NOT in the sensitive list, so a
1264
1262
  # container health probe and a Prometheus scrape keep working unconfigured.
1265
1263
  gated = request.method in ("POST", "PUT", "PATCH", "DELETE")
1264
+ if gated and not browser_mutation_origin_allowed(request, _cors_origins):
1265
+ return JSONResponse(
1266
+ status_code=403,
1267
+ content={"detail": "cross-origin mutation refused"},
1268
+ )
1266
1269
  if not gated:
1267
- path = request.url.path
1268
- gated = any(path.startswith(pfx) for pfx in _SENSITIVE_READ_PREFIXES)
1270
+ gated = _is_state_bearing_get(request.url.path)
1269
1271
  if gated:
1270
1272
  try:
1271
1273
  require_local_or_authenticated(request)
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v9.22.12
5
+ **Version:** v9.23.1
6
6
 
7
7
  ---
8
8
 
@@ -343,7 +343,7 @@ The `HUMAN_INPUT.md` file has security controls:
343
343
 
344
344
  ## Multi-Provider Support
345
345
 
346
- Loki Mode supports four active providers across three tiers, plus historical/upcoming entries. Pick by capability + cost.
346
+ Loki Mode supports five active providers across three capability modes, plus historical/upcoming entries. Pick by capability + cost.
347
347
 
348
348
  ### Supported Providers
349
349
 
@@ -220,7 +220,15 @@ Every commit proven on BOTH routes (`bin/loki` + `LOKI_LEGACY_BASH=1 bin/loki`).
220
220
  - **Post-release smoke from SHIPPED artifacts on BOTH routes**: `npm pack loki-mode@8.0.0` + `bun run bin/loki
221
221
  version` and `LOKI_LEGACY_BASH=1 ... version`; `docker run --rm asklokesh/loki-mode:8.0.0 doctor --json`;
222
222
  WebFetch brew formula version+sha.
223
- - **Cleanup after every local-ci/validation**: `lsof -ti:57374 | xargs kill -9; rm -rf /tmp/loki-* /tmp/test-* /tmp/package /tmp/*.tgz`.
223
+ - **Cleanup after every local-ci/validation**: create one exact run-owned directory with `mktemp -d`
224
+ under the canonical `${TMPDIR:-/tmp}` root and keep all validation artifacts and recorded child
225
+ PIDs inside it. Use the executable operator-side sequence in
226
+ [`enterprise/performance.md`](enterprise/performance.md#cleanup); it does not depend on a shipped
227
+ cleanup helper. Stop only the exact verified child PIDs. Before removing the exact saved path,
228
+ require its `.loki-run-owned` marker to contain that path, require the target and marker to belong
229
+ to the current UID, require the target to be a canonical direct child of the temp root, refuse
230
+ symlinks, and refuse any target with `.git` worktree metadata. A failed guard leaves the path in
231
+ place for inspection.
224
232
 
225
233
  ---
226
234