1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
#![allow(
clippy::wildcard_imports,
clippy::too_many_arguments,
clippy::needless_pass_by_value,
dead_code,
reason = "split-out module preserves existing code while keeping files under the linecheck limit"
)]
use super::*;
/// Slugify `title` into a filesystem- and tmux-safe path identifier.
///
/// Lowercases, replaces each run of non-alphanumeric characters *inside a path segment* with `-`,
/// preserves `/` as the segment separator, trims empty segments, and returns `"routine"` if empty.
///
/// Unicode-aware: uses [`char::is_alphanumeric`] / [`char::to_lowercase`] rather than the ASCII-only
/// variants, so non-Latin titles (Hebrew, CJK, Cyrillic) and Latin letters with diacritics (`é`,
/// `ü`) keep their content instead of collapsing to the `"routine"` fallback (#262). The path is
/// still shell-quoted wherever it is embedded.
pub(crate) fn slugify(title: &str) -> String {
let segments: Vec<String> = title
.split('/')
.filter_map(|segment| {
let mut out = String::new();
let mut prev_dash = false;
for ch in segment.chars() {
if ch.is_alphanumeric() {
out.extend(ch.to_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
let trimmed = out.trim_matches('-').to_string();
(!trimmed.is_empty()).then_some(trimmed)
})
.collect();
if segments.is_empty() {
"routine".to_string()
} else {
segments.join("/")
}
}
/// Literal prefix every routine fire's tmux session name begins with; the remainder is
/// `{slug}-{fire's $TS}` (see the `SESS=` line in [`build_routine_command`]).
pub(crate) const TMUX_SESSION_PREFIX: &str = "moadim-";
/// The tmux session-name prefix shared by every fire of the routine identified by `slug` —
/// `{TMUX_SESSION_PREFIX}{slug}-`, matching every session name [`build_routine_command`] can
/// produce for it regardless of `$TS`. Used by the overlap guard (#514) to detect whether *any*
/// fire of this routine already has a live session, not just one exact `$TS`.
pub(crate) fn tmux_session_prefix(slug: &str) -> String {
format!("{TMUX_SESSION_PREFIX}{slug}-")
}
/// Wrap `s` in single quotes for safe inclusion in a POSIX shell command.
pub(crate) fn shell_quote(text: &str) -> String {
let mut out = String::from("'");
for ch in text.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
/// `true` when `key` is a POSIX-portable shell identifier: `[A-Za-z_][A-Za-z0-9_]*`.
///
/// Shared by `service_validate::validate_env` (the API create/update path, tracked `[env]`) and
/// [`env_export_stmts`] below (defense in depth against a hand-edited, never-API-validated
/// `routine.local.toml`) — see issue #408.
pub(crate) fn is_valid_env_key(key: &str) -> bool {
let mut chars = key.chars();
matches!(chars.next(), Some(first) if first.is_ascii_alphabetic() || first == '_')
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}
/// Build the `export KEY=<shell-quoted value>` statements for `routine`'s resolved environment:
/// the tracked `routine.toml` `[env]` table, overlaid with the untracked `routine.local.toml`
/// sidecar (secrets) — whose keys win on conflict (#408).
///
/// A `BTreeMap` merge keeps the merged set in a deterministic, sorted-by-key order (stable test
/// assertions, stable output for anyone reading `launch.log`). Every entry — from either source —
/// is re-checked with [`is_valid_env_key`] and scanned for newlines: `routine.toml` was already
/// validated at create/update time (`service_validate::validate_env`), but `routine.local.toml` is
/// a file a human edits directly on disk and never passes through that check, so a malformed entry
/// there is dropped (with a warning) rather than corrupting the single-line, `;`-joined launch
/// command.
fn merged_env_vars(routine: &Routine) -> BTreeMap<String, String> {
let rel_dir = crate::routine_storage::routine_rel_dir(routine);
let local_env = read_local_env(&rel_dir);
let mut merged: BTreeMap<String, String> = BTreeMap::new();
for (key, value) in routine.env.iter().chain(local_env.iter()) {
if is_valid_env_key(key) && !value.contains('\n') && !value.contains('\r') {
merged.insert(key.clone(), value.clone());
} else {
log::warn!(
"routine {:?}: skipping invalid env var {key:?} (from routine.toml or \
routine.local.toml — invalid key or a newline in the value)",
routine.id
);
}
}
merged
}
/// The process-environment carrier name [`routine_env_carrier_vars`] sets `key`'s value under, so
/// [`env_export_stmts`]'s indirection line can read it back without the value ever appearing in
/// the composed command string (#1515). Prefixed so it cannot collide with a routine's own `[env]`
/// key of the same name; [`is_valid_env_key`] already restricts `key` to a shell identifier, so
/// the prefixed result is always a valid one too.
fn carrier_env_var(key: &str) -> String {
format!("MOADIM_ROUTINE_ENV__{key}")
}
/// Build the `export KEY="$MOADIM_ROUTINE_ENV__KEY"` indirection statements for `routine`'s
/// resolved environment (see [`merged_env_vars`]).
///
/// The statement names each variable but never embeds its *value*: the value instead travels
/// privately on the spawned process's own environment (`Command::envs`, set by
/// [`routine_env_carrier_vars`] in `spawn_routine_command`), which the kernel keeps in
/// `/proc/<pid>/environ` (owner-only, `0400`) rather than in the world-readable `ps`/
/// `/proc/<pid>/cmdline` argv this statement is inlined into (#1515). Immediately `unset`ting the
/// carrier after copying it to the real name limits how long the redundant copy lives in the
/// process environment. Placed after `sh -l`'s profile sourcing (see `build_routine_command`), so
/// a routine env var still overrides a same-named profile-inherited one, exactly as before.
pub(crate) fn env_export_stmts(routine: &Routine) -> Vec<String> {
merged_env_vars(routine)
.into_keys()
.map(|key| {
let carrier = carrier_env_var(&key);
format!("export {key}=\"${carrier}\"; unset {carrier}")
})
.collect()
}
/// The carrier environment variables (name -> value, see [`carrier_env_var`]) that must be set on
/// the spawned launcher process's own environment (via `Command::envs` in
/// `spawn_routine_command`) so the indirection statements [`env_export_stmts`] embeds in the
/// command string can resolve them without ever inlining a value into that string (#1515).
pub(crate) fn routine_env_carrier_vars(routine: &Routine) -> BTreeMap<String, String> {
merged_env_vars(routine)
.into_iter()
.map(|(key, value)| (carrier_env_var(&key), value))
.collect()
}
/// Who is launching the routine command — which decides whether the run records a *scheduled*
/// firing.
///
/// Only the OS crontab firing on schedule should append to `scheduled.log`; a manual (on-demand)
/// trigger reuses the very same launch script but must not masquerade as a scheduled fire, or
/// `last_scheduled_trigger_at` would be overwritten every time an operator hits "run now".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TriggerSource {
/// The OS crontab firing on schedule — records the fire time into `scheduled.log`.
Scheduled,
/// An on-demand trigger (UI/API/CLI) — runs the agent but leaves `scheduled.log` untouched, so
/// the manual run is tracked only via `last_manual_trigger_at` (recorded in-process by
/// `svc_trigger`, not by this script).
Manual,
}