loki-mode 7.78.0 → 7.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/checkpoint_sync.py +189 -0
- package/autonomy/lib/config-map.sh +955 -0
- package/autonomy/loki +143 -8
- package/autonomy/run.sh +227 -195
- package/autonomy/sandbox.sh +98 -0
- package/autonomy/trigger-server.py +419 -43
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +63 -7
- package/docs/CONFIG-FILE-PLAN.md +459 -0
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# config-map.sh -- single-source config mapping + config-file loader (FEAT-CONFIG, #691)
|
|
3
|
+
#
|
|
4
|
+
# This library is the ONE home of the YAML/config-file key mapping and the
|
|
5
|
+
# parse-and-export logic. It is sourced by BOTH:
|
|
6
|
+
# - autonomy/loki main() pre-pass (loki_maybe_apply_config_file, override=1)
|
|
7
|
+
# - autonomy/run.sh YAML parsers (loki_config_export_key, override=0)
|
|
8
|
+
# collapsing the formerly-3 tables (parse_simple_yaml, parse_yaml_with_yq,
|
|
9
|
+
# config.example.yaml) to ONE canonical array.
|
|
10
|
+
#
|
|
11
|
+
# DESIGN CONSTRAINT: SIDE-EFFECT-FREE ON SOURCE. Sourcing this file only DEFINES
|
|
12
|
+
# the array + functions. It exports nothing and runs nothing. Callers invoke the
|
|
13
|
+
# functions explicitly. This lets unit tests source the lib and exercise the
|
|
14
|
+
# parser / expander / matcher in isolation.
|
|
15
|
+
#
|
|
16
|
+
# Precedence (the keystone): an `override` parameter on the shared per-key export
|
|
17
|
+
# decides config-vs-env. override=1 (pre-pass) -> config BEATS ambient env;
|
|
18
|
+
# override=0 (run.sh auto-discovery) -> ambient env wins (shipped contract).
|
|
19
|
+
|
|
20
|
+
# Double-source guard. Re-sourcing must not redefine or re-cost anything.
|
|
21
|
+
if [ -n "${_LOKI_CONFIG_MAP_SOURCED:-}" ]; then
|
|
22
|
+
return 0 2>/dev/null || true
|
|
23
|
+
fi
|
|
24
|
+
_LOKI_CONFIG_MAP_SOURCED=1
|
|
25
|
+
|
|
26
|
+
#===============================================================================
|
|
27
|
+
# 2a. Canonical mapping array (64 keys)
|
|
28
|
+
#
|
|
29
|
+
# Copied verbatim from parse_simple_yaml's set_from_yaml calls (run.sh:271-354)
|
|
30
|
+
# so env-var names are guaranteed correct. model.compaction_interval is EXCLUDED
|
|
31
|
+
# (no runtime consumer). This is the YAML/config-file surface ONLY; the
|
|
32
|
+
# settings.json schema (_load_json_settings) stays separate by design.
|
|
33
|
+
#===============================================================================
|
|
34
|
+
LOKI_CONFIG_MAP=(
|
|
35
|
+
# core
|
|
36
|
+
"core.max_retries:LOKI_MAX_RETRIES"
|
|
37
|
+
"core.base_wait:LOKI_BASE_WAIT"
|
|
38
|
+
"core.max_wait:LOKI_MAX_WAIT"
|
|
39
|
+
"core.skip_prereqs:LOKI_SKIP_PREREQS"
|
|
40
|
+
# dashboard
|
|
41
|
+
"dashboard.enabled:LOKI_DASHBOARD"
|
|
42
|
+
"dashboard.port:LOKI_DASHBOARD_PORT"
|
|
43
|
+
# resources
|
|
44
|
+
"resources.check_interval:LOKI_RESOURCE_CHECK_INTERVAL"
|
|
45
|
+
"resources.cpu_threshold:LOKI_RESOURCE_CPU_THRESHOLD"
|
|
46
|
+
"resources.mem_threshold:LOKI_RESOURCE_MEM_THRESHOLD"
|
|
47
|
+
# security
|
|
48
|
+
"security.staged_autonomy:LOKI_STAGED_AUTONOMY"
|
|
49
|
+
"security.audit_log:LOKI_AUDIT_LOG"
|
|
50
|
+
"security.max_parallel_agents:LOKI_MAX_PARALLEL_AGENTS"
|
|
51
|
+
"security.sandbox_mode:LOKI_SANDBOX_MODE"
|
|
52
|
+
"security.allowed_paths:LOKI_ALLOWED_PATHS"
|
|
53
|
+
"security.blocked_commands:LOKI_BLOCKED_COMMANDS"
|
|
54
|
+
# phases
|
|
55
|
+
"phases.unit_tests:LOKI_PHASE_UNIT_TESTS"
|
|
56
|
+
"phases.api_tests:LOKI_PHASE_API_TESTS"
|
|
57
|
+
"phases.e2e_tests:LOKI_PHASE_E2E_TESTS"
|
|
58
|
+
"phases.security:LOKI_PHASE_SECURITY"
|
|
59
|
+
"phases.integration:LOKI_PHASE_INTEGRATION"
|
|
60
|
+
"phases.code_review:LOKI_PHASE_CODE_REVIEW"
|
|
61
|
+
"phases.web_research:LOKI_PHASE_WEB_RESEARCH"
|
|
62
|
+
"phases.performance:LOKI_PHASE_PERFORMANCE"
|
|
63
|
+
"phases.accessibility:LOKI_PHASE_ACCESSIBILITY"
|
|
64
|
+
"phases.regression:LOKI_PHASE_REGRESSION"
|
|
65
|
+
"phases.uat:LOKI_PHASE_UAT"
|
|
66
|
+
# completion
|
|
67
|
+
"completion.promise:LOKI_COMPLETION_PROMISE"
|
|
68
|
+
"completion.max_iterations:LOKI_MAX_ITERATIONS"
|
|
69
|
+
"completion.perpetual_mode:LOKI_PERPETUAL_MODE"
|
|
70
|
+
# completion.council
|
|
71
|
+
"completion.council.enabled:LOKI_COUNCIL_ENABLED"
|
|
72
|
+
"completion.council.size:LOKI_COUNCIL_SIZE"
|
|
73
|
+
"completion.council.threshold:LOKI_COUNCIL_THRESHOLD"
|
|
74
|
+
"completion.council.check_interval:LOKI_COUNCIL_CHECK_INTERVAL"
|
|
75
|
+
"completion.council.min_iterations:LOKI_COUNCIL_MIN_ITERATIONS"
|
|
76
|
+
"completion.council.stagnation_limit:LOKI_COUNCIL_STAGNATION_LIMIT"
|
|
77
|
+
# completion.uncertainty
|
|
78
|
+
"completion.uncertainty.escalation:LOKI_UNCERTAINTY_ESCALATION"
|
|
79
|
+
"completion.uncertainty.rounds:LOKI_UNCERTAINTY_ROUNDS"
|
|
80
|
+
"completion.uncertainty.nochange_min:LOKI_UNCERTAINTY_NOCHANGE_MIN"
|
|
81
|
+
"completion.uncertainty.split_rounds:LOKI_UNCERTAINTY_SPLIT_ROUNDS"
|
|
82
|
+
# model (model.planning/development/fast are T1-only; verified env vars)
|
|
83
|
+
"model.prompt_repetition:LOKI_PROMPT_REPETITION"
|
|
84
|
+
"model.confidence_routing:LOKI_CONFIDENCE_ROUTING"
|
|
85
|
+
"model.autonomy_mode:LOKI_AUTONOMY_MODE"
|
|
86
|
+
"model.planning:LOKI_MODEL_PLANNING"
|
|
87
|
+
"model.development:LOKI_MODEL_DEVELOPMENT"
|
|
88
|
+
"model.fast:LOKI_MODEL_FAST"
|
|
89
|
+
# parallel
|
|
90
|
+
"parallel.enabled:LOKI_PARALLEL_MODE"
|
|
91
|
+
"parallel.max_worktrees:LOKI_MAX_WORKTREES"
|
|
92
|
+
"parallel.max_sessions:LOKI_MAX_PARALLEL_SESSIONS"
|
|
93
|
+
"parallel.testing:LOKI_PARALLEL_TESTING"
|
|
94
|
+
"parallel.docs:LOKI_PARALLEL_DOCS"
|
|
95
|
+
"parallel.blog:LOKI_PARALLEL_BLOG"
|
|
96
|
+
"parallel.auto_merge:LOKI_AUTO_MERGE"
|
|
97
|
+
# complexity
|
|
98
|
+
"complexity.tier:LOKI_COMPLEXITY"
|
|
99
|
+
# github
|
|
100
|
+
"github.import:LOKI_GITHUB_IMPORT"
|
|
101
|
+
"github.pr:LOKI_GITHUB_PR"
|
|
102
|
+
"github.sync:LOKI_GITHUB_SYNC"
|
|
103
|
+
"github.repo:LOKI_GITHUB_REPO"
|
|
104
|
+
"github.labels:LOKI_GITHUB_LABELS"
|
|
105
|
+
"github.milestone:LOKI_GITHUB_MILESTONE"
|
|
106
|
+
"github.assignee:LOKI_GITHUB_ASSIGNEE"
|
|
107
|
+
"github.limit:LOKI_GITHUB_LIMIT"
|
|
108
|
+
"github.pr_label:LOKI_GITHUB_PR_LABEL"
|
|
109
|
+
# notifications
|
|
110
|
+
"notifications.enabled:LOKI_NOTIFICATIONS"
|
|
111
|
+
"notifications.sound:LOKI_NOTIFICATION_SOUND"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Annotations for `config example|schema` generation. Maps a nested.path to a
|
|
115
|
+
# one-line human comment. Only used by the generators; absence is harmless.
|
|
116
|
+
LOKI_CONFIG_COMMENTS=(
|
|
117
|
+
"core.max_retries:Max retry attempts for rate limits and transient failures"
|
|
118
|
+
"core.base_wait:Base wait time in seconds for exponential backoff"
|
|
119
|
+
"core.max_wait:Maximum wait time in seconds"
|
|
120
|
+
"core.skip_prereqs:Skip prerequisite checks (not recommended)"
|
|
121
|
+
"dashboard.enabled:Enable web dashboard"
|
|
122
|
+
"dashboard.port:Dashboard port"
|
|
123
|
+
"resources.check_interval:Check resources every N seconds"
|
|
124
|
+
"resources.cpu_threshold:CPU percentage threshold to warn"
|
|
125
|
+
"resources.mem_threshold:Memory percentage threshold to warn"
|
|
126
|
+
"security.staged_autonomy:Require approval before execution (staged autonomy)"
|
|
127
|
+
"security.audit_log:Enable audit logging"
|
|
128
|
+
"security.max_parallel_agents:Limit concurrent agent spawning"
|
|
129
|
+
"security.sandbox_mode:Run in sandboxed container (requires Docker)"
|
|
130
|
+
"security.allowed_paths:Comma-separated paths agents can modify (empty = all)"
|
|
131
|
+
"security.blocked_commands:Comma-separated blocked shell commands"
|
|
132
|
+
"phases.unit_tests:Enable unit test phase"
|
|
133
|
+
"phases.api_tests:Enable API test phase"
|
|
134
|
+
"phases.e2e_tests:Enable end-to-end test phase"
|
|
135
|
+
"phases.security:Enable security phase"
|
|
136
|
+
"phases.integration:Enable integration phase"
|
|
137
|
+
"phases.code_review:Enable code review phase"
|
|
138
|
+
"phases.web_research:Enable web research phase"
|
|
139
|
+
"phases.performance:Enable performance phase"
|
|
140
|
+
"phases.accessibility:Enable accessibility phase"
|
|
141
|
+
"phases.regression:Enable regression phase"
|
|
142
|
+
"phases.uat:Enable UAT phase"
|
|
143
|
+
"completion.promise:Explicit stop condition text (empty = runs until stopped)"
|
|
144
|
+
"completion.max_iterations:Max loop iterations before exit"
|
|
145
|
+
"completion.perpetual_mode:Ignore ALL completion signals (runs forever)"
|
|
146
|
+
"completion.council.enabled:Enable completion council voting"
|
|
147
|
+
"completion.council.size:Number of council reviewers"
|
|
148
|
+
"completion.council.threshold:Approval threshold for council stop"
|
|
149
|
+
"completion.council.check_interval:Run the council every N iterations"
|
|
150
|
+
"completion.council.min_iterations:Minimum iterations before council can stop"
|
|
151
|
+
"completion.council.stagnation_limit:No-change rounds before circuit-breaker"
|
|
152
|
+
"completion.uncertainty.escalation:Uncertainty-gated escalation toggle (0/1)"
|
|
153
|
+
"completion.uncertainty.rounds:Consecutive rounds before escalating"
|
|
154
|
+
"completion.uncertainty.nochange_min:No-change proxy threshold"
|
|
155
|
+
"completion.uncertainty.split_rounds:Council-split proxy threshold"
|
|
156
|
+
"model.prompt_repetition:Enable prompt repetition for Haiku agents"
|
|
157
|
+
"model.confidence_routing:Enable confidence-based routing"
|
|
158
|
+
"model.autonomy_mode:Autonomy level: perpetual, checkpoint, or supervised"
|
|
159
|
+
"model.planning:Model for the planning tier"
|
|
160
|
+
"model.development:Model for the development tier"
|
|
161
|
+
"model.fast:Model for the fast tier"
|
|
162
|
+
"parallel.enabled:Enable git worktree-based parallelism"
|
|
163
|
+
"parallel.max_worktrees:Maximum parallel worktrees"
|
|
164
|
+
"parallel.max_sessions:Maximum concurrent sessions"
|
|
165
|
+
"parallel.testing:Run testing stream in parallel"
|
|
166
|
+
"parallel.docs:Run documentation stream in parallel"
|
|
167
|
+
"parallel.blog:Run blog stream if site has blog"
|
|
168
|
+
"parallel.auto_merge:Auto-merge completed features"
|
|
169
|
+
"complexity.tier:Force complexity tier: auto, simple, moderate, complex, enterprise"
|
|
170
|
+
"github.import:Import open issues as tasks"
|
|
171
|
+
"github.pr:Create PR when feature complete"
|
|
172
|
+
"github.sync:Sync status back to issues"
|
|
173
|
+
"github.repo:Override repo detection (owner/repo)"
|
|
174
|
+
"github.labels:Filter by labels (comma-separated)"
|
|
175
|
+
"github.milestone:Filter by milestone"
|
|
176
|
+
"github.assignee:Filter by assignee"
|
|
177
|
+
"github.limit:Max issues to import"
|
|
178
|
+
"github.pr_label:Label for PRs (empty = no label)"
|
|
179
|
+
"notifications.enabled:Enable desktop notifications"
|
|
180
|
+
"notifications.sound:Play sound with notifications"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
#===============================================================================
|
|
184
|
+
# 2b. Known-env-var set for the .env-format key allowlist.
|
|
185
|
+
#
|
|
186
|
+
# YAML/JSON are bound to LOKI_CONFIG_MAP (only mapped nested.path keys are read),
|
|
187
|
+
# so a typo or unadvertised key in those formats is simply ignored. The .env
|
|
188
|
+
# format reads keys directly, so without a membership check ANY LOKI_* key would
|
|
189
|
+
# be accepted -- a typo (LOKI_MAX_RETRIE) or unadvertised key would silently pass
|
|
190
|
+
# `config validate` and (for the loader) be exported. To bring .env to parity, an
|
|
191
|
+
# .env LOKI_ key is accepted only if it is either:
|
|
192
|
+
# (a) a config-map env var ("${LOKI_CONFIG_MAP[@]##*:}"), OR
|
|
193
|
+
# (b) listed in LOKI_ENV_EXTRA_ALLOWLIST below.
|
|
194
|
+
#
|
|
195
|
+
# (b) holds the LOKI_* vars that are NOT in the config map but have a real
|
|
196
|
+
# runtime consumer and are legitimately set via .env in enterprise/durable
|
|
197
|
+
# deployments. Each entry below cites its consumer; do NOT add a var here without
|
|
198
|
+
# one.
|
|
199
|
+
LOKI_ENV_EXTRA_ALLOWLIST=(
|
|
200
|
+
LOKI_DURABLE_STATE # run.sh:9583 (durable-state opt-in)
|
|
201
|
+
LOKI_STORAGE_BACKEND # checkpoint_sync.py:48, lokistore/factory.py:51
|
|
202
|
+
LOKI_STORAGE_BUCKET # lokistore/factory.py:54
|
|
203
|
+
LOKI_STORAGE_PREFIX # lokistore/factory.py:57
|
|
204
|
+
LOKI_STORAGE_REGION # lokistore/factory.py:60
|
|
205
|
+
LOKI_METADATA_BACKEND # lokistore/factory.py:164
|
|
206
|
+
LOKI_METADATA_URL # lokistore/factory.py:181
|
|
207
|
+
LOKI_DATA_DIR # lokistore/factory.py:169
|
|
208
|
+
LOKI_RUN_ID # checkpoint_sync.py:56 (per-run object-store namespace)
|
|
209
|
+
LOKI_SESSION_ID # checkpoint_sync.py:56, run.sh session paths
|
|
210
|
+
LOKI_DIR # checkpoint_sync.py:61, lokistore/factory.py:41
|
|
211
|
+
LOKI_BUDGET_LIMIT # run.sh:484 (cost cap)
|
|
212
|
+
LOKI_PROVIDER # run.sh:837 (provider select)
|
|
213
|
+
LOKI_ISSUE_PROVIDER # run.sh:455 settings mapping (issue provider)
|
|
214
|
+
LOKI_MAX_TIER # run.sh:448 settings mapping (model tier cap)
|
|
215
|
+
LOKI_SLACK_WEBHOOK # run.sh:452 settings mapping (notify)
|
|
216
|
+
LOKI_DISCORD_WEBHOOK # run.sh:453 settings mapping (notify)
|
|
217
|
+
LOKI_BLIND_VALIDATION # run.sh:456 settings mapping
|
|
218
|
+
LOKI_ADVERSARIAL_TESTING # run.sh:457 settings mapping
|
|
219
|
+
LOKI_SPAWN_TIMEOUT # run.sh:458 settings mapping
|
|
220
|
+
LOKI_SPAWN_RETRIES # run.sh:459 settings mapping
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Return 0 if an .env LOKI_ key is a recognized var (config-map member OR an
|
|
224
|
+
# explicitly-allowlisted non-map var with a real consumer), else 1.
|
|
225
|
+
loki_env_key_is_known() {
|
|
226
|
+
local key="$1"
|
|
227
|
+
local mapping extra
|
|
228
|
+
for mapping in "${LOKI_CONFIG_MAP[@]}"; do
|
|
229
|
+
[ "${mapping##*:}" = "$key" ] && return 0
|
|
230
|
+
done
|
|
231
|
+
for extra in "${LOKI_ENV_EXTRA_ALLOWLIST[@]}"; do
|
|
232
|
+
[ "$extra" = "$key" ] && return 0
|
|
233
|
+
done
|
|
234
|
+
return 1
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#===============================================================================
|
|
238
|
+
# Self-contained validators (the loki process has no run.sh validate_yaml_value)
|
|
239
|
+
#===============================================================================
|
|
240
|
+
|
|
241
|
+
# Validate a resolved config value before export. Rejects empty, over-length,
|
|
242
|
+
# newlines, and shell metacharacters.
|
|
243
|
+
#
|
|
244
|
+
# NOTE: this intentionally does NOT reuse run.sh's validate_yaml_value regex
|
|
245
|
+
# verbatim. That shipped regex (run.sh:358, a bash `[[ =~ [\$\`...] ]]` bracket
|
|
246
|
+
# expression) silently matches NOTHING -- the backslash-escapes inside the
|
|
247
|
+
# character class are taken literally, so it never rejects a metachar. On this
|
|
248
|
+
# NEW security-sensitive config-file surface, the injection contract (#691 SS7
|
|
249
|
+
# case 8) requires real rejection, so we use a `case` glob character class,
|
|
250
|
+
# which bash evaluates correctly. The allow-set mirrors validate_yaml_value's
|
|
251
|
+
# documented intent: alphanumerics plus spaces, dots, dashes, underscores,
|
|
252
|
+
# slashes, colons, commas, @.
|
|
253
|
+
loki_validate_value() {
|
|
254
|
+
local value="$1"
|
|
255
|
+
local max_length="${2:-1000}"
|
|
256
|
+
|
|
257
|
+
# Reject empty values
|
|
258
|
+
if [ -z "$value" ]; then
|
|
259
|
+
return 1
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
# Reject values with dangerous shell metacharacters: $ ` | ; & < > ( ) { }
|
|
263
|
+
# [ ] and backslash. (case glob bracket class -- evaluated reliably, unlike
|
|
264
|
+
# the shipped =~ bracket regex.)
|
|
265
|
+
# NOTE: single-quote and double-quote are NOT in the reject set, so a value
|
|
266
|
+
# containing a quote passes. That is intentional and benign here: config
|
|
267
|
+
# values are only ever quoted-exported (loki_config_export_key) or printed,
|
|
268
|
+
# never eval'd, so an embedded quote cannot break out into a command. The
|
|
269
|
+
# set above blocks the chars that WOULD matter for command/expansion
|
|
270
|
+
# injection on the surfaces that consume these values.
|
|
271
|
+
case "$value" in
|
|
272
|
+
*['$`|;&<>(){}[]\']*) return 1 ;;
|
|
273
|
+
esac
|
|
274
|
+
|
|
275
|
+
# Reject values that are too long (DoS protection)
|
|
276
|
+
if [ "${#value}" -gt "$max_length" ]; then
|
|
277
|
+
return 1
|
|
278
|
+
fi
|
|
279
|
+
|
|
280
|
+
# Reject values with newlines (could corrupt variables)
|
|
281
|
+
if [[ "$value" == *$'\n'* ]]; then
|
|
282
|
+
return 1
|
|
283
|
+
fi
|
|
284
|
+
|
|
285
|
+
return 0
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
# Mirror of run.sh escape_regex (run.sh:387) for the grep/sed YAML fallback.
|
|
289
|
+
loki_escape_regex() {
|
|
290
|
+
local input="$1"
|
|
291
|
+
printf '%s' "$input" | sed 's/[.[\*?+^${}|()\\]/\\&/g'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#===============================================================================
|
|
295
|
+
# 5a. ${VAR} env-ref expansion (NEVER eval)
|
|
296
|
+
#===============================================================================
|
|
297
|
+
# Expand full-value and embedded ${NAME} references via bash indirect expansion.
|
|
298
|
+
# Order is EXPAND-THEN-VALIDATE (validate rejects '$', so an unexpanded ${VAR}
|
|
299
|
+
# would always fail). An unset reference makes expansion FAIL (return non-zero)
|
|
300
|
+
# so the caller can skip the key and warn; it never exports an empty value.
|
|
301
|
+
#
|
|
302
|
+
# Echoes the expanded value on success. Returns 1 (and echoes the unresolved
|
|
303
|
+
# var name on stderr is the caller's job) when any referenced var is unset.
|
|
304
|
+
loki_expand_refs() {
|
|
305
|
+
local value="$1"
|
|
306
|
+
# Fast path: no ${...} at all.
|
|
307
|
+
if [[ "$value" != *'${'* ]]; then
|
|
308
|
+
printf '%s' "$value"
|
|
309
|
+
return 0
|
|
310
|
+
fi
|
|
311
|
+
local out=""
|
|
312
|
+
local rest="$value"
|
|
313
|
+
local prefix name resolved
|
|
314
|
+
while [[ "$rest" == *'${'* ]]; do
|
|
315
|
+
# literal text before the next ${
|
|
316
|
+
prefix="${rest%%\$\{*}"
|
|
317
|
+
out+="$prefix"
|
|
318
|
+
rest="${rest#"$prefix"}" # rest now starts with ${
|
|
319
|
+
rest="${rest#\$\{}" # strip leading ${
|
|
320
|
+
if [[ "$rest" != *'}'* ]]; then
|
|
321
|
+
# Unterminated ${ -- treat the remainder as literal and stop.
|
|
322
|
+
out+='${'
|
|
323
|
+
out+="$rest"
|
|
324
|
+
rest=""
|
|
325
|
+
break
|
|
326
|
+
fi
|
|
327
|
+
name="${rest%%\}*}" # name up to first }
|
|
328
|
+
rest="${rest#*\}}" # remainder after }
|
|
329
|
+
# Only [A-Za-z_][A-Za-z0-9_]* are valid var names; anything else is literal.
|
|
330
|
+
if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
|
|
331
|
+
if [ -z "${!name+x}" ]; then
|
|
332
|
+
# Unset reference -> signal failure with the offending name.
|
|
333
|
+
printf '%s' "$name"
|
|
334
|
+
return 1
|
|
335
|
+
fi
|
|
336
|
+
resolved="${!name}"
|
|
337
|
+
out+="$resolved"
|
|
338
|
+
else
|
|
339
|
+
# Not a valid name -- preserve literally.
|
|
340
|
+
out+='${'
|
|
341
|
+
out+="$name"
|
|
342
|
+
out+='}'
|
|
343
|
+
fi
|
|
344
|
+
done
|
|
345
|
+
out+="$rest"
|
|
346
|
+
printf '%s' "$out"
|
|
347
|
+
return 0
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
#===============================================================================
|
|
351
|
+
# 2a-0. The keystone: shared per-key export with override-mode precedence
|
|
352
|
+
#===============================================================================
|
|
353
|
+
# loki_config_export_key <env_var> <value> <override>
|
|
354
|
+
# override != 1 AND env already set -> return 0 (env-wins guard; run.sh path)
|
|
355
|
+
# else: ${VAR}-expand -> validate -> export
|
|
356
|
+
# Returns non-zero (without exporting) on unresolved ref or validation failure,
|
|
357
|
+
# so callers can warn. A "null"/empty post-strip value is skipped silently
|
|
358
|
+
# (matches run.sh's existing behavior).
|
|
359
|
+
loki_config_export_key() {
|
|
360
|
+
local env_var="$1"
|
|
361
|
+
local value="$2"
|
|
362
|
+
local override="${3:-0}"
|
|
363
|
+
|
|
364
|
+
# env-wins guard (only when NOT overriding)
|
|
365
|
+
if [ "$override" != "1" ] && [ -n "${!env_var:-}" ]; then
|
|
366
|
+
return 0
|
|
367
|
+
fi
|
|
368
|
+
|
|
369
|
+
# Skip empty / explicit null (parser sentinels, not an error).
|
|
370
|
+
if [ -z "$value" ] || [ "$value" = "null" ]; then
|
|
371
|
+
return 0
|
|
372
|
+
fi
|
|
373
|
+
|
|
374
|
+
# Expand ${VAR} refs BEFORE validation.
|
|
375
|
+
local expanded
|
|
376
|
+
if ! expanded="$(loki_expand_refs "$value")"; then
|
|
377
|
+
# expanded holds the unresolved var name here.
|
|
378
|
+
printf 'loki: config: skipping %s -- unresolved ${%s}\n' "$env_var" "$expanded" >&2
|
|
379
|
+
return 1
|
|
380
|
+
fi
|
|
381
|
+
|
|
382
|
+
if ! loki_validate_value "$expanded"; then
|
|
383
|
+
printf 'loki: config: rejected %s -- value failed validation (shell metachar / length / newline)\n' "$env_var" >&2
|
|
384
|
+
return 1
|
|
385
|
+
fi
|
|
386
|
+
|
|
387
|
+
export "$env_var=$expanded"
|
|
388
|
+
return 0
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
#===============================================================================
|
|
392
|
+
# 5b. Raw-secret literal detector (reuse the shipped scanner patterns)
|
|
393
|
+
#===============================================================================
|
|
394
|
+
# Returns 0 if VALUE (a single literal, NOT a ${VAR} ref) looks like a secret.
|
|
395
|
+
# A ${VAR}-ref value is correctly IGNORED (handled by the deny filter, same as
|
|
396
|
+
# run.sh _commit_scan_secret_file). Used by config-file load (warn) and
|
|
397
|
+
# `config validate` (error).
|
|
398
|
+
loki_value_looks_secret() {
|
|
399
|
+
local value="${1:-}"
|
|
400
|
+
[ -n "$value" ] || return 1
|
|
401
|
+
|
|
402
|
+
# TIER 1: specific formats. No deny filter -- a format match is a finding.
|
|
403
|
+
local tier1=(
|
|
404
|
+
'AKIA[0-9A-Z]{16}'
|
|
405
|
+
'ASIA[0-9A-Z]{16}'
|
|
406
|
+
'-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----'
|
|
407
|
+
'gh[pousr]_[A-Za-z0-9]{36,}'
|
|
408
|
+
'github_pat_[A-Za-z0-9_]{60,}'
|
|
409
|
+
'xox[baprs]-[A-Za-z0-9-]{10,}'
|
|
410
|
+
'sk-[A-Za-z0-9]{20,}'
|
|
411
|
+
'AIza[0-9A-Za-z_-]{35}'
|
|
412
|
+
'glpat-[A-Za-z0-9_-]{20,}'
|
|
413
|
+
)
|
|
414
|
+
local p
|
|
415
|
+
for p in "${tier1[@]}"; do
|
|
416
|
+
if printf '%s' "$value" | LC_ALL=C grep -Eq -e "$p" 2>/dev/null; then
|
|
417
|
+
return 0
|
|
418
|
+
fi
|
|
419
|
+
done
|
|
420
|
+
|
|
421
|
+
# Deny filter (same as run.sh): ${VAR} refs and placeholders are NOT secrets.
|
|
422
|
+
local deny='(\$\{|\$[A-Za-z_]|process\.env|os\.(environ|getenv)|%[A-Za-z_]+%|your[-_]|redacted|changeme|change[-_]me|placeholder|example|dummy|sample|fake|<[^>]*>|x{4,}|\*{4,})'
|
|
423
|
+
local tier2='(api[_-]?key|secret|token|password|passwd|access[_-]?key|client[_-]?secret|auth)[A-Za-z0-9_]*[[:space:]]*[:=][[:space:]]*["'"'"']?[A-Za-z0-9_/+.=-]{16,}'
|
|
424
|
+
local bearer='[Bb]earer[[:space:]]+[A-Za-z0-9_.\-]{20,}'
|
|
425
|
+
local uricred='[a-z][a-z0-9+.\-]*://[^/[:space:]:@]*:[^/[:space:]:@]+@'
|
|
426
|
+
|
|
427
|
+
local surviving
|
|
428
|
+
surviving="$( { printf '%s' "$value" | LC_ALL=C grep -EiI "$tier2|$bearer|$uricred" 2>/dev/null \
|
|
429
|
+
| LC_ALL=C grep -Eiv "$deny" 2>/dev/null; } || true)"
|
|
430
|
+
if [ -n "$surviving" ]; then return 0; fi
|
|
431
|
+
return 1
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
#===============================================================================
|
|
435
|
+
# 4. Format detection
|
|
436
|
+
#===============================================================================
|
|
437
|
+
# Echoes "env" | "yaml" | "json" | "" (unknown). Uses extension first, then a
|
|
438
|
+
# content sniff on the first non-blank, non-comment line.
|
|
439
|
+
loki_detect_config_format() {
|
|
440
|
+
local path="$1"
|
|
441
|
+
local base="${path##*/}"
|
|
442
|
+
case "$base" in
|
|
443
|
+
*.env|.env.*) printf 'env'; return 0 ;;
|
|
444
|
+
*.yaml|*.yml) printf 'yaml'; return 0 ;;
|
|
445
|
+
*.json) printf 'json'; return 0 ;;
|
|
446
|
+
esac
|
|
447
|
+
# Content sniff: first meaningful line.
|
|
448
|
+
local line
|
|
449
|
+
line="$( { grep -vE '^[[:space:]]*($|#)' "$path" 2>/dev/null | head -1; } || true)"
|
|
450
|
+
line="${line#"${line%%[![:space:]]*}"}" # ltrim
|
|
451
|
+
if [[ "$line" == \{* ]]; then
|
|
452
|
+
printf 'json'; return 0
|
|
453
|
+
elif [[ "$line" =~ ^[A-Z_][A-Z0-9_]*= ]]; then
|
|
454
|
+
printf 'env'; return 0
|
|
455
|
+
elif [[ "$line" =~ ^[a-zA-Z0-9_.-]+: ]]; then
|
|
456
|
+
printf 'yaml'; return 0
|
|
457
|
+
fi
|
|
458
|
+
printf ''
|
|
459
|
+
return 1
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
#===============================================================================
|
|
463
|
+
# 4a. Flat .env parser (FULL ~250-flag coverage, day one)
|
|
464
|
+
#===============================================================================
|
|
465
|
+
# Reads KEY=VALUE lines; key allowlist ^LOKI_[A-Z0-9_]+$; each value goes through
|
|
466
|
+
# loki_config_export_key with the given override. Non-allowlisted keys are
|
|
467
|
+
# rejected with a visible warning (never a silent skip).
|
|
468
|
+
loki_parse_env_file() {
|
|
469
|
+
local file="$1"
|
|
470
|
+
local override="${2:-1}"
|
|
471
|
+
local line key val
|
|
472
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
473
|
+
# Skip blank and comment lines.
|
|
474
|
+
case "$line" in
|
|
475
|
+
''|'#'*) continue ;;
|
|
476
|
+
esac
|
|
477
|
+
# Strip a leading "export " prefix (common in .env files).
|
|
478
|
+
line="${line#export }"
|
|
479
|
+
# Must contain '='.
|
|
480
|
+
case "$line" in
|
|
481
|
+
*=*) ;;
|
|
482
|
+
*) continue ;;
|
|
483
|
+
esac
|
|
484
|
+
key="${line%%=*}"
|
|
485
|
+
val="${line#*=}"
|
|
486
|
+
# Trim whitespace around key.
|
|
487
|
+
key="${key#"${key%%[![:space:]]*}"}"
|
|
488
|
+
key="${key%"${key##*[![:space:]]}"}"
|
|
489
|
+
# Trim leading whitespace on value (a quoted value preserves inner spaces).
|
|
490
|
+
val="${val#"${val%%[![:space:]]*}"}"
|
|
491
|
+
# Strip one layer of matching surrounding quotes.
|
|
492
|
+
if [[ "$val" == \"*\" && ${#val} -ge 2 ]]; then
|
|
493
|
+
val="${val:1:${#val}-2}"
|
|
494
|
+
elif [[ "$val" == \'*\' && ${#val} -ge 2 ]]; then
|
|
495
|
+
val="${val:1:${#val}-2}"
|
|
496
|
+
fi
|
|
497
|
+
# Key allowlist: must look like a LOKI_ key AND be a recognized var
|
|
498
|
+
# (config-map member or an allowlisted non-map var). An unknown LOKI_ key
|
|
499
|
+
# is a typo or an unadvertised key; warn and skip it rather than export.
|
|
500
|
+
if [[ ! "$key" =~ ^LOKI_[A-Z0-9_]+$ ]]; then
|
|
501
|
+
printf 'loki: config: ignoring non-allowlisted key %s (only LOKI_* keys are accepted)\n' "$key" >&2
|
|
502
|
+
continue
|
|
503
|
+
fi
|
|
504
|
+
if ! loki_env_key_is_known "$key"; then
|
|
505
|
+
printf 'loki: config: WARNING ignoring unknown key %s (not a recognized LOKI_ config var -- typo?)\n' "$key" >&2
|
|
506
|
+
continue
|
|
507
|
+
fi
|
|
508
|
+
# Raw-secret warning on a literal (non-ref) value.
|
|
509
|
+
if [[ "$val" != *'${'* ]] && loki_value_looks_secret "$val"; then
|
|
510
|
+
printf 'loki: config: WARNING %s appears to contain a raw secret literal -- use ${VAR} + env/Vault\n' "$key" >&2
|
|
511
|
+
fi
|
|
512
|
+
loki_config_export_key "$key" "$val" "$override" || true
|
|
513
|
+
done < "$file"
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
#===============================================================================
|
|
517
|
+
# 4. YAML parsing (yq if present, grep/sed fallback) over LOKI_CONFIG_MAP
|
|
518
|
+
#===============================================================================
|
|
519
|
+
loki_parse_yaml_file() {
|
|
520
|
+
local file="$1"
|
|
521
|
+
local override="${2:-1}"
|
|
522
|
+
local mapping yaml_path env_var value
|
|
523
|
+
local have_yq=0
|
|
524
|
+
command -v yq >/dev/null 2>&1 && have_yq=1
|
|
525
|
+
for mapping in "${LOKI_CONFIG_MAP[@]}"; do
|
|
526
|
+
yaml_path="${mapping%%:*}"
|
|
527
|
+
env_var="${mapping##*:}"
|
|
528
|
+
if [ "$have_yq" = "1" ]; then
|
|
529
|
+
value="$(yq eval ".$yaml_path // \"\"" "$file" 2>/dev/null || true)"
|
|
530
|
+
else
|
|
531
|
+
# grep/sed fallback: match the LAST path segment as a key.
|
|
532
|
+
# `|| true` keeps the no-match case (grep rc=1 under pipefail) from
|
|
533
|
+
# tripping set -e in the loki CLI (which runs set -euo pipefail).
|
|
534
|
+
local key escaped_key
|
|
535
|
+
key="${yaml_path##*.}"
|
|
536
|
+
escaped_key="$(loki_escape_regex "$key")"
|
|
537
|
+
value="$( { grep -E "^\s*${escaped_key}:" "$file" 2>/dev/null | head -1 \
|
|
538
|
+
| sed -E 's/.*:\s*//' | sed 's/#.*//' \
|
|
539
|
+
| sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\n' \
|
|
540
|
+
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; } || true)"
|
|
541
|
+
fi
|
|
542
|
+
if [ "$value" = "null" ]; then value=""; fi
|
|
543
|
+
if [ -z "$value" ]; then continue; fi
|
|
544
|
+
# Raw-secret warning on a literal value.
|
|
545
|
+
if [[ "$value" != *'${'* ]] && loki_value_looks_secret "$value"; then
|
|
546
|
+
printf 'loki: config: WARNING %s appears to contain a raw secret literal -- use ${VAR} + env/Vault\n' "$env_var" >&2
|
|
547
|
+
fi
|
|
548
|
+
loki_config_export_key "$env_var" "$value" "$override" || true
|
|
549
|
+
done
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
#===============================================================================
|
|
553
|
+
# 4. JSON parsing (yq native, else audited python3 pattern from _load_json_settings)
|
|
554
|
+
#===============================================================================
|
|
555
|
+
# Emits "env_var<TAB>value" lines for nested.path keys present in the file.
|
|
556
|
+
# Modeled on run.sh:_load_json_settings (json.load, isinstance(str) guard). The
|
|
557
|
+
# shell side then routes each value through loki_config_export_key, so ${VAR}
|
|
558
|
+
# expansion / validation / secret-warning happen uniformly with the other formats.
|
|
559
|
+
loki_parse_json_file() {
|
|
560
|
+
local file="$1"
|
|
561
|
+
local override="${2:-1}"
|
|
562
|
+
|
|
563
|
+
if command -v yq >/dev/null 2>&1; then
|
|
564
|
+
local mapping yaml_path env_var value
|
|
565
|
+
for mapping in "${LOKI_CONFIG_MAP[@]}"; do
|
|
566
|
+
yaml_path="${mapping%%:*}"
|
|
567
|
+
env_var="${mapping##*:}"
|
|
568
|
+
value="$(yq eval -p=json ".$yaml_path // \"\"" "$file" 2>/dev/null || true)"
|
|
569
|
+
if [ "$value" = "null" ]; then value=""; fi
|
|
570
|
+
if [ -z "$value" ]; then continue; fi
|
|
571
|
+
if [[ "$value" != *'${'* ]] && loki_value_looks_secret "$value"; then
|
|
572
|
+
printf 'loki: config: WARNING %s appears to contain a raw secret literal -- use ${VAR} + env/Vault\n' "$env_var" >&2
|
|
573
|
+
fi
|
|
574
|
+
loki_config_export_key "$env_var" "$value" "$override" || true
|
|
575
|
+
done
|
|
576
|
+
return 0
|
|
577
|
+
fi
|
|
578
|
+
|
|
579
|
+
# python3 fallback. The python side ONLY reads + emits tab-separated
|
|
580
|
+
# env_var/value pairs (NO export, NO eval). Values are scalars coerced to
|
|
581
|
+
# str; the shell does expansion/validation/export. The mapping is passed in
|
|
582
|
+
# via env so it cannot drift from LOKI_CONFIG_MAP.
|
|
583
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
584
|
+
printf 'loki: config: cannot parse JSON -- neither yq nor python3 found\n' >&2
|
|
585
|
+
return 1
|
|
586
|
+
fi
|
|
587
|
+
local map_str=""
|
|
588
|
+
local mapping
|
|
589
|
+
for mapping in "${LOKI_CONFIG_MAP[@]}"; do
|
|
590
|
+
map_str+="$mapping"$'\n'
|
|
591
|
+
done
|
|
592
|
+
local emitted
|
|
593
|
+
emitted="$(_LOKI_CFG_JSON="$file" _LOKI_CFG_MAP="$map_str" python3 -c '
|
|
594
|
+
import json, os, sys
|
|
595
|
+
|
|
596
|
+
def get_nested(d, key):
|
|
597
|
+
cur = d
|
|
598
|
+
for p in key.split("."):
|
|
599
|
+
if isinstance(cur, dict):
|
|
600
|
+
cur = cur.get(p)
|
|
601
|
+
else:
|
|
602
|
+
return None
|
|
603
|
+
return cur
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
with open(os.environ["_LOKI_CFG_JSON"]) as f:
|
|
607
|
+
data = json.load(f)
|
|
608
|
+
except Exception:
|
|
609
|
+
sys.exit(0)
|
|
610
|
+
|
|
611
|
+
for line in os.environ.get("_LOKI_CFG_MAP", "").splitlines():
|
|
612
|
+
line = line.strip()
|
|
613
|
+
if not line or ":" not in line:
|
|
614
|
+
continue
|
|
615
|
+
path, env_var = line.split(":", 1)
|
|
616
|
+
val = get_nested(data, path)
|
|
617
|
+
if val is None:
|
|
618
|
+
continue
|
|
619
|
+
if isinstance(val, bool):
|
|
620
|
+
val = "true" if val else "false"
|
|
621
|
+
elif isinstance(val, (int, float)):
|
|
622
|
+
val = repr(val) if isinstance(val, float) else str(val)
|
|
623
|
+
elif not isinstance(val, str):
|
|
624
|
+
continue
|
|
625
|
+
# Tab + newline would corrupt the record; skip such values (validation
|
|
626
|
+
# would reject newlines anyway).
|
|
627
|
+
if "\t" in val or "\n" in val:
|
|
628
|
+
continue
|
|
629
|
+
sys.stdout.write(env_var + "\t" + val + "\n")
|
|
630
|
+
' 2>/dev/null)" || true
|
|
631
|
+
|
|
632
|
+
local env_var value
|
|
633
|
+
while IFS=$'\t' read -r env_var value; do
|
|
634
|
+
[ -n "$env_var" ] || continue
|
|
635
|
+
if [[ "$value" != *'${'* ]] && loki_value_looks_secret "$value"; then
|
|
636
|
+
printf 'loki: config: WARNING %s appears to contain a raw secret literal -- use ${VAR} + env/Vault\n' "$env_var" >&2
|
|
637
|
+
fi
|
|
638
|
+
loki_config_export_key "$env_var" "$value" "$override" || true
|
|
639
|
+
done <<< "$emitted"
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
#===============================================================================
|
|
643
|
+
# 4 (top). loki_apply_config_file <path> [override]
|
|
644
|
+
#===============================================================================
|
|
645
|
+
# Validates the path, detects format, routes to the right parser. Default
|
|
646
|
+
# override=1 (the pre-pass case: config beats ambient env). Missing/unreadable
|
|
647
|
+
# path -> honest non-zero, NO silent fallback.
|
|
648
|
+
loki_apply_config_file() {
|
|
649
|
+
local path="$1"
|
|
650
|
+
local override="${2:-1}"
|
|
651
|
+
|
|
652
|
+
if [ -z "$path" ]; then
|
|
653
|
+
printf 'loki: config: no config path given\n' >&2
|
|
654
|
+
return 1
|
|
655
|
+
fi
|
|
656
|
+
if [ ! -e "$path" ]; then
|
|
657
|
+
printf 'loki: config: file not found: %s\n' "$path" >&2
|
|
658
|
+
return 1
|
|
659
|
+
fi
|
|
660
|
+
# Symlink guard for project-local relative paths (mirror load_config_file).
|
|
661
|
+
# Absolute paths (operator-mounted, e.g. /etc/loki/config.yaml) and paths in
|
|
662
|
+
# the user's HOME are trusted; a relative project path that is a symlink is
|
|
663
|
+
# rejected to prevent path-traversal via a planted link in the repo.
|
|
664
|
+
case "$path" in
|
|
665
|
+
/*|"$HOME"/*) : ;;
|
|
666
|
+
*)
|
|
667
|
+
if [ -L "$path" ]; then
|
|
668
|
+
printf 'loki: config: refusing symlinked project-local config: %s\n' "$path" >&2
|
|
669
|
+
return 1
|
|
670
|
+
fi
|
|
671
|
+
;;
|
|
672
|
+
esac
|
|
673
|
+
if [ ! -r "$path" ]; then
|
|
674
|
+
printf 'loki: config: file not readable: %s\n' "$path" >&2
|
|
675
|
+
return 1
|
|
676
|
+
fi
|
|
677
|
+
|
|
678
|
+
local fmt
|
|
679
|
+
fmt="$(loki_detect_config_format "$path")"
|
|
680
|
+
case "$fmt" in
|
|
681
|
+
env) loki_parse_env_file "$path" "$override" ;;
|
|
682
|
+
yaml) loki_parse_yaml_file "$path" "$override" ;;
|
|
683
|
+
json) loki_parse_json_file "$path" "$override" ;;
|
|
684
|
+
*)
|
|
685
|
+
printf 'loki: config: cannot detect format for %s (expected .env/.yaml/.yml/.json or recognizable content)\n' "$path" >&2
|
|
686
|
+
return 1
|
|
687
|
+
;;
|
|
688
|
+
esac
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
#===============================================================================
|
|
692
|
+
# 1. loki_maybe_apply_config_file "$@" (the loki pre-pass entry point)
|
|
693
|
+
#===============================================================================
|
|
694
|
+
# Honors LOKI_CONFIG_FILE env first, then scans "$@" for --config/--vars/
|
|
695
|
+
# --env-file (space and = forms) WITHOUT consuming. An explicit flag overrides
|
|
696
|
+
# the env var. If a path is found, applies it with override=1 (config beats env).
|
|
697
|
+
loki_maybe_apply_config_file() {
|
|
698
|
+
local path=""
|
|
699
|
+
# 1. env var first.
|
|
700
|
+
if [ -n "${LOKI_CONFIG_FILE:-}" ]; then
|
|
701
|
+
path="$LOKI_CONFIG_FILE"
|
|
702
|
+
fi
|
|
703
|
+
# 2. scan args (explicit flag overrides the env var).
|
|
704
|
+
local prev=""
|
|
705
|
+
local a
|
|
706
|
+
for a in "$@"; do
|
|
707
|
+
case "$prev" in
|
|
708
|
+
--config|--vars|--env-file)
|
|
709
|
+
path="$a"
|
|
710
|
+
;;
|
|
711
|
+
esac
|
|
712
|
+
case "$a" in
|
|
713
|
+
--config=*|--vars=*|--env-file=*)
|
|
714
|
+
path="${a#*=}"
|
|
715
|
+
;;
|
|
716
|
+
esac
|
|
717
|
+
prev="$a"
|
|
718
|
+
done
|
|
719
|
+
[ -n "$path" ] || return 0
|
|
720
|
+
loki_apply_config_file "$path" 1
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
#===============================================================================
|
|
724
|
+
# 6. Generators: config example / schema (from LOKI_CONFIG_MAP, never drift)
|
|
725
|
+
#===============================================================================
|
|
726
|
+
|
|
727
|
+
# Look up the comment for a path (LOKI_CONFIG_COMMENTS). Echoes "" if absent.
|
|
728
|
+
_loki_config_comment_for() {
|
|
729
|
+
local want="$1"
|
|
730
|
+
local entry
|
|
731
|
+
for entry in "${LOKI_CONFIG_COMMENTS[@]}"; do
|
|
732
|
+
if [ "${entry%%:*}" = "$want" ]; then
|
|
733
|
+
printf '%s' "${entry#*:}"
|
|
734
|
+
return 0
|
|
735
|
+
fi
|
|
736
|
+
done
|
|
737
|
+
printf ''
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
# Emit an annotated nested YAML skeleton generated from LOKI_CONFIG_MAP.
|
|
741
|
+
loki_config_generate_example() {
|
|
742
|
+
printf '# Loki Mode Configuration File (generated by: loki config example)\n'
|
|
743
|
+
printf '# Copy to .loki/config.yaml, or pass with: loki start --config <path>\n'
|
|
744
|
+
printf '# Precedence: CLI flags > --config file > ambient env > auto .loki/config.yaml > defaults\n'
|
|
745
|
+
printf '# Secrets: never inline. Reference an env var with ${VAR_NAME}.\n'
|
|
746
|
+
printf '\n'
|
|
747
|
+
local mapping path top sub leaf comment
|
|
748
|
+
local cur_top="" cur_sub=""
|
|
749
|
+
for mapping in "${LOKI_CONFIG_MAP[@]}"; do
|
|
750
|
+
path="${mapping%%:*}"
|
|
751
|
+
# Split into up to 3 segments: top.[sub.]leaf
|
|
752
|
+
local IFS_save="$IFS"
|
|
753
|
+
IFS='.' read -r p1 p2 p3 <<< "$path"
|
|
754
|
+
IFS="$IFS_save"
|
|
755
|
+
if [ -n "$p3" ]; then
|
|
756
|
+
top="$p1"; sub="$p2"; leaf="$p3"
|
|
757
|
+
else
|
|
758
|
+
top="$p1"; sub=""; leaf="$p2"
|
|
759
|
+
fi
|
|
760
|
+
comment="$(_loki_config_comment_for "$path")"
|
|
761
|
+
if [ "$top" != "$cur_top" ]; then
|
|
762
|
+
cur_top="$top"; cur_sub=""
|
|
763
|
+
printf '%s:\n' "$top"
|
|
764
|
+
fi
|
|
765
|
+
if [ -n "$sub" ]; then
|
|
766
|
+
if [ "$sub" != "$cur_sub" ]; then
|
|
767
|
+
cur_sub="$sub"
|
|
768
|
+
printf ' %s:\n' "$sub"
|
|
769
|
+
fi
|
|
770
|
+
[ -n "$comment" ] && printf ' # %s\n' "$comment"
|
|
771
|
+
printf ' %s:\n' "$leaf"
|
|
772
|
+
else
|
|
773
|
+
cur_sub=""
|
|
774
|
+
[ -n "$comment" ] && printf ' # %s\n' "$comment"
|
|
775
|
+
printf ' %s:\n' "$leaf"
|
|
776
|
+
fi
|
|
777
|
+
done
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
# Emit a machine-readable key -> LOKI_ENV_VAR table.
|
|
781
|
+
loki_config_generate_schema() {
|
|
782
|
+
printf '# key\tenv_var\n'
|
|
783
|
+
local mapping
|
|
784
|
+
for mapping in "${LOKI_CONFIG_MAP[@]}"; do
|
|
785
|
+
printf '%s\t%s\n' "${mapping%%:*}" "${mapping##*:}"
|
|
786
|
+
done
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
# Validate a config file for `loki config validate <file>`. Reports unresolved
|
|
790
|
+
# refs, raw-secret literals (ERROR), and per-value validation failures. Returns
|
|
791
|
+
# non-zero on ANY failure. Reads the file directly (format-aware) WITHOUT
|
|
792
|
+
# exporting anything into the environment.
|
|
793
|
+
loki_config_validate_file() {
|
|
794
|
+
local path="$1"
|
|
795
|
+
local rc=0
|
|
796
|
+
|
|
797
|
+
if [ -z "$path" ] || [ ! -e "$path" ]; then
|
|
798
|
+
printf 'loki: config validate: file not found: %s\n' "$path" >&2
|
|
799
|
+
return 1
|
|
800
|
+
fi
|
|
801
|
+
if [ ! -r "$path" ]; then
|
|
802
|
+
printf 'loki: config validate: file not readable: %s\n' "$path" >&2
|
|
803
|
+
return 1
|
|
804
|
+
fi
|
|
805
|
+
local fmt
|
|
806
|
+
fmt="$(loki_detect_config_format "$path")"
|
|
807
|
+
if [ -z "$fmt" ]; then
|
|
808
|
+
printf 'loki: config validate: cannot detect format for %s\n' "$path" >&2
|
|
809
|
+
return 1
|
|
810
|
+
fi
|
|
811
|
+
|
|
812
|
+
# Collect "env_var<TAB>value" pairs WITHOUT exporting. We reuse the parsers'
|
|
813
|
+
# extraction by capturing each candidate through a subshell that only prints.
|
|
814
|
+
local pairs
|
|
815
|
+
pairs="$(
|
|
816
|
+
_loki_cfg_collect_pairs() {
|
|
817
|
+
local f="$1" fm="$2"
|
|
818
|
+
case "$fm" in
|
|
819
|
+
env)
|
|
820
|
+
local line key val
|
|
821
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
822
|
+
case "$line" in ''|'#'*) continue ;; esac
|
|
823
|
+
line="${line#export }"
|
|
824
|
+
case "$line" in *=*) ;; *) continue ;; esac
|
|
825
|
+
key="${line%%=*}"; val="${line#*=}"
|
|
826
|
+
key="${key#"${key%%[![:space:]]*}"}"; key="${key%"${key##*[![:space:]]}"}"
|
|
827
|
+
val="${val#"${val%%[![:space:]]*}"}"
|
|
828
|
+
if [[ "$val" == \"*\" && ${#val} -ge 2 ]]; then val="${val:1:${#val}-2}";
|
|
829
|
+
elif [[ "$val" == \'*\' && ${#val} -ge 2 ]]; then val="${val:1:${#val}-2}"; fi
|
|
830
|
+
printf '%s\t%s\n' "$key" "$val"
|
|
831
|
+
done < "$f"
|
|
832
|
+
;;
|
|
833
|
+
yaml)
|
|
834
|
+
local mapping yaml_path env_var value key escaped_key have_yq=0
|
|
835
|
+
if command -v yq >/dev/null 2>&1; then have_yq=1; fi
|
|
836
|
+
for mapping in "${LOKI_CONFIG_MAP[@]}"; do
|
|
837
|
+
yaml_path="${mapping%%:*}"; env_var="${mapping##*:}"
|
|
838
|
+
if [ "$have_yq" = 1 ]; then
|
|
839
|
+
value="$(yq eval ".$yaml_path // \"\"" "$f" 2>/dev/null || true)"
|
|
840
|
+
else
|
|
841
|
+
key="${yaml_path##*.}"; escaped_key="$(loki_escape_regex "$key")"
|
|
842
|
+
value="$( { grep -E "^\s*${escaped_key}:" "$f" 2>/dev/null | head -1 \
|
|
843
|
+
| sed -E 's/.*:\s*//' | sed 's/#.*//' | sed 's/^["'\'']//;s/["'\'']$//' \
|
|
844
|
+
| tr -d '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; } || true)"
|
|
845
|
+
fi
|
|
846
|
+
if [ "$value" = "null" ]; then value=""; fi
|
|
847
|
+
if [ -z "$value" ]; then continue; fi
|
|
848
|
+
printf '%s\t%s\n' "$env_var" "$value"
|
|
849
|
+
done
|
|
850
|
+
;;
|
|
851
|
+
json)
|
|
852
|
+
# Reuse the json parser's emit path by calling a print-only variant.
|
|
853
|
+
_loki_cfg_json_emit "$f"
|
|
854
|
+
;;
|
|
855
|
+
esac
|
|
856
|
+
}
|
|
857
|
+
_loki_cfg_collect_pairs "$path" "$fmt"
|
|
858
|
+
)"
|
|
859
|
+
|
|
860
|
+
local env_var value expanded
|
|
861
|
+
while IFS=$'\t' read -r env_var value; do
|
|
862
|
+
[ -n "$env_var" ] || continue
|
|
863
|
+
# env key allowlist for .env format: must look like a LOKI_ key AND be a
|
|
864
|
+
# recognized var. YAML/JSON are bound to the config map by extraction, so
|
|
865
|
+
# this membership check brings .env to parity and catches typos /
|
|
866
|
+
# unadvertised keys that `config validate` should reject.
|
|
867
|
+
if [ "$fmt" = "env" ]; then
|
|
868
|
+
if [[ ! "$env_var" =~ ^LOKI_[A-Z0-9_]+$ ]]; then
|
|
869
|
+
printf 'loki: config validate: ERROR non-allowlisted key %s\n' "$env_var" >&2
|
|
870
|
+
rc=1
|
|
871
|
+
continue
|
|
872
|
+
fi
|
|
873
|
+
if ! loki_env_key_is_known "$env_var"; then
|
|
874
|
+
printf 'loki: config validate: ERROR unknown key %s (not a recognized LOKI_ config var -- typo?)\n' "$env_var" >&2
|
|
875
|
+
rc=1
|
|
876
|
+
continue
|
|
877
|
+
fi
|
|
878
|
+
fi
|
|
879
|
+
# Raw-secret literal is an ERROR in validate.
|
|
880
|
+
if [[ "$value" != *'${'* ]] && loki_value_looks_secret "$value"; then
|
|
881
|
+
printf 'loki: config validate: ERROR %s contains a raw secret literal -- use ${VAR}\n' "$env_var" >&2
|
|
882
|
+
rc=1
|
|
883
|
+
continue
|
|
884
|
+
fi
|
|
885
|
+
# Dry-expand refs; report unresolved.
|
|
886
|
+
if ! expanded="$(loki_expand_refs "$value")"; then
|
|
887
|
+
printf 'loki: config validate: ERROR %s references unresolved ${%s}\n' "$env_var" "$expanded" >&2
|
|
888
|
+
rc=1
|
|
889
|
+
continue
|
|
890
|
+
fi
|
|
891
|
+
# Per-value validation.
|
|
892
|
+
if ! loki_validate_value "$expanded"; then
|
|
893
|
+
printf 'loki: config validate: ERROR %s failed value validation (shell metachar / length / newline)\n' "$env_var" >&2
|
|
894
|
+
rc=1
|
|
895
|
+
continue
|
|
896
|
+
fi
|
|
897
|
+
done <<< "$pairs"
|
|
898
|
+
|
|
899
|
+
if [ "$rc" = 0 ]; then
|
|
900
|
+
printf 'loki: config validate: OK -- %s\n' "$path"
|
|
901
|
+
fi
|
|
902
|
+
return "$rc"
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
# Print-only JSON emit (env_var<TAB>value) without export. Used by validate.
|
|
906
|
+
_loki_cfg_json_emit() {
|
|
907
|
+
local file="$1"
|
|
908
|
+
if command -v yq >/dev/null 2>&1; then
|
|
909
|
+
local mapping yaml_path env_var value
|
|
910
|
+
for mapping in "${LOKI_CONFIG_MAP[@]}"; do
|
|
911
|
+
yaml_path="${mapping%%:*}"; env_var="${mapping##*:}"
|
|
912
|
+
value="$(yq eval -p=json ".$yaml_path // \"\"" "$file" 2>/dev/null || true)"
|
|
913
|
+
if [ "$value" = "null" ]; then value=""; fi
|
|
914
|
+
if [ -z "$value" ]; then continue; fi
|
|
915
|
+
printf '%s\t%s\n' "$env_var" "$value"
|
|
916
|
+
done
|
|
917
|
+
return 0
|
|
918
|
+
fi
|
|
919
|
+
command -v python3 >/dev/null 2>&1 || return 0
|
|
920
|
+
local map_str="" mapping
|
|
921
|
+
for mapping in "${LOKI_CONFIG_MAP[@]}"; do map_str+="$mapping"$'\n'; done
|
|
922
|
+
_LOKI_CFG_JSON="$file" _LOKI_CFG_MAP="$map_str" python3 -c '
|
|
923
|
+
import json, os, sys
|
|
924
|
+
def get_nested(d, key):
|
|
925
|
+
cur = d
|
|
926
|
+
for p in key.split("."):
|
|
927
|
+
if isinstance(cur, dict):
|
|
928
|
+
cur = cur.get(p)
|
|
929
|
+
else:
|
|
930
|
+
return None
|
|
931
|
+
return cur
|
|
932
|
+
try:
|
|
933
|
+
with open(os.environ["_LOKI_CFG_JSON"]) as f:
|
|
934
|
+
data = json.load(f)
|
|
935
|
+
except Exception:
|
|
936
|
+
sys.exit(0)
|
|
937
|
+
for line in os.environ.get("_LOKI_CFG_MAP", "").splitlines():
|
|
938
|
+
line = line.strip()
|
|
939
|
+
if not line or ":" not in line:
|
|
940
|
+
continue
|
|
941
|
+
path, env_var = line.split(":", 1)
|
|
942
|
+
val = get_nested(data, path)
|
|
943
|
+
if val is None:
|
|
944
|
+
continue
|
|
945
|
+
if isinstance(val, bool):
|
|
946
|
+
val = "true" if val else "false"
|
|
947
|
+
elif isinstance(val, (int, float)):
|
|
948
|
+
val = repr(val) if isinstance(val, float) else str(val)
|
|
949
|
+
elif not isinstance(val, str):
|
|
950
|
+
continue
|
|
951
|
+
if "\t" in val or "\n" in val:
|
|
952
|
+
continue
|
|
953
|
+
sys.stdout.write(env_var + "\t" + val + "\n")
|
|
954
|
+
' 2>/dev/null || true
|
|
955
|
+
}
|