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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
//! Typed errors for manifest parse + validation.
//!
//! `ManifestError` encodes failure modes operators encounter when
//! shipping a plugin: invalid TOML, regex-violating ids, semver
//! mismatch, namespace drift, unsafe paths, broken cross-field
//! invariants. Every variant's `Display` impl carries enough
//! context for the operator to fix the manifest without checking
//! source.
//!
//! Validation paths NEVER bail on first error — `PluginManifest::
//! validate(...)` collects every issue into a `Vec<ManifestError>`
//! so the operator fixes everything in one pass.
use crate::manifest::Capability;
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("manifest io error: {0}")]
Io(#[from] std::io::Error),
#[error("manifest parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("plugin id `{id}` invalid: {reason}")]
IdInvalid { id: String, reason: &'static str },
#[error("field `{field}` version `{value}` invalid")]
VersionInvalid { field: &'static str, value: String },
#[error(
"min_nexo_version `{required}` does not match current daemon version `{current}`; \
upgrade the daemon or downgrade the plugin"
)]
MinNexoVersionMismatch { required: String, current: String },
#[error(
"tool `{tool_name}` violates namespace policy: must start with `{plugin_id}_`. \
Rename to `{plugin_id}_<descriptive>`."
)]
ToolNamespaceViolation {
plugin_id: String,
tool_name: String,
},
#[error(
"path field `{field}` `{path}` rejected: contains `..` (traversal not allowed). \
Use a path inside the plugin root."
)]
PathTraversal { field: &'static str, path: String },
#[error(
"path field `{field}` `{path}` rejected: must be relative, not absolute. \
Paths resolve relative to the plugin root."
)]
PathAbsoluteForbidden { field: &'static str, path: String },
#[error(
"capability `{capability:?}` declared in `capabilities.provides` but the corresponding \
section is empty. {hint}"
)]
CapabilityWithoutImpl {
capability: Capability,
hint: &'static str,
},
#[error(
"tool `{tool_name}` listed in `tools.deferred` but not in `tools.expose`. \
Deferred tools must be exposed."
)]
DeferredNotInExpose { tool_name: String },
#[error(
"outbound tool `{tool_name}` in `[[plugin.tools.outbound]]` is not listed in \
`tools.expose`. Every outbound entry's `name` must also appear in `expose` so the \
namespace + deferred passes see it. Plugin: `{plugin_id}`."
)]
OutboundNotExposed {
plugin_id: String,
tool_name: String,
},
#[error(
"outbound tool `{tool_name}` has invalid `input_schema`: {reason}. \
Plugin: `{plugin_id}`. The schema must be a non-empty JSON object with \
`\"type\":\"object\"` at the root."
)]
OutboundInvalidSchema {
plugin_id: String,
tool_name: String,
reason: String,
},
#[error(
"plugin `{plugin_id}` has invalid `[plugin.config_schema]`: {reason}. \
The `schema` field must be a non-empty JSON object with \
`\"type\":\"object\"` at the root."
)]
PluginConfigInvalidSchema { plugin_id: String, reason: String },
#[error("plugin `{plugin_id}` has invalid `[plugin.admin_ui]`: {reason}")]
AdminUiInvalid { plugin_id: String, reason: String },
#[error("duplicate capability gate env_var `{env_var}` (each gate must be unique)")]
DuplicateGateEnvVar { env_var: String },
#[error("invalid channel kind `{kind}`: must match `^[a-z][a-z0-9_]{{0,31}}$`")]
ChannelKindInvalid { kind: String },
#[error("plugin name must not be empty")]
NameEmpty,
#[error("plugin description must not be empty")]
DescriptionEmpty,
#[error(
"supervisor.stderr_tail_lines `{value}` exceeds cap `{max}`. \
Lower the value to prevent unbounded ring-buffer memory."
)]
SupervisorStderrTailExceedsCap { value: usize, max: usize },
/// `max_attempts = 0` is silently
/// equivalent to `respawn = false` (the supervisor publishes
/// `gave_up` with `attempts: 0` on the very first crash). Use
/// `respawn = false` for "do not auto-respawn"; reserve
/// `max_attempts` for the bounded retry count.
#[error(
"supervisor.max_attempts must be >= 1 when respawn = true; \
set respawn = false to disable auto-respawn"
)]
SupervisorMaxAttemptsZero,
/// `backoff_ms = 0` produces a tight
/// retry loop (the documented exponential schedule starts from
/// the base; with base = 0 every attempt's wait is 0). Force a
/// minimum that gives the failing dependency time to recover.
#[error(
"supervisor.backoff_ms must be >= {min}; \
a smaller base produces a tight retry loop \
that bypasses the documented exponential schedule"
)]
SupervisorBackoffMsBelowFloor { value: u64, min: u64 },
/// `backoff_ms` upper bound. The
/// reset-counter heuristic (`base * max_attempts * 2`) saturates
/// at very large bases, effectively disabling the per-window
/// counter reset. Cap so the heuristic stays meaningful.
#[error(
"supervisor.backoff_ms `{value}` exceeds cap `{max}`. \
A larger base saturates the reset-counter heuristic and \
disables per-window recovery."
)]
SupervisorBackoffMsExceedsCap { value: u64, max: u64 },
/// Entry in `[plugin.extends].<section>` does
/// not match the id regex (`^[a-z][a-z0-9_]{0,31}$`).
#[error("[plugin.extends].{section} id `{id}` invalid: {reason}")]
ExtendsIdInvalid {
section: &'static str,
id: String,
reason: &'static str,
},
/// Same id appears more than once within a
/// single `[plugin.extends].<section>` list.
#[error("[plugin.extends].{section} contains duplicate id `{id}`")]
ExtendsDuplicate { section: &'static str, id: String },
/// Same id appears in two or more
/// `[plugin.extends]` lists. Each id must occupy at most one
/// list within a plugin to keep operator-visible declarations
/// unambiguous.
#[error(
"id `{id}` appears in multiple [plugin.extends] lists ({}); each id must occupy at most one list",
sections.join(", ")
)]
ExtendsCrossListConflict {
id: String,
sections: Vec<&'static str>,
},
/// Sandbox allowlist entry equals or contains a
/// host path on the hard denylist. `path` is the manifest
/// allowlist entry, `denylisted` is the denylist match,
/// `kind` distinguishes `fs_read_paths` from `fs_write_paths`.
#[error(
"[plugin.sandbox].{kind:?} entry `{path}` rejected: equals or covers denylisted host path `{denylisted}`. \
Narrow the allowlist to a sibling that does not include `{denylisted}`."
)]
SandboxAllowlistTouchesDenylist {
path: String,
denylisted: String,
kind: crate::sandbox::SandboxPathKind,
},
/// Sandbox allowlist entry must be an absolute
/// path. Relative paths are ambiguous (relative to plugin
/// root? cwd? state_dir?) and bwrap binds need absolute
/// host paths anyway.
#[error(
"[plugin.sandbox].{kind:?} entry `{path}` rejected: must be an absolute path starting with `/`."
)]
SandboxRelativePath {
path: String,
kind: crate::sandbox::SandboxPathKind,
},
/// `${state_dir}` token appears in
/// `fs_read_paths`. State dir is the plugin's per-instance
/// owned write space; reading from it is meaningless.
/// Operators using this token are usually trying to declare
/// a write mount and put it in the wrong list.
#[error(
"[plugin.sandbox].fs_read_paths entry `{path}` rejected: `${{state_dir}}` token only allowed in fs_write_paths."
)]
SandboxInvalidStateDirInterpolation { path: String },
/// Manifest declares `network = \"host\"` but
/// the operator-side capability `NEXO_PLUGIN_SANDBOX_HOST_NET_ALLOW`
/// is not set. Sharing the host network namespace defeats
/// most of the sandbox; operator must opt in explicitly.
#[error(
"[plugin.sandbox].network = \"host\" rejected: requires operator-side capability \
NEXO_PLUGIN_SANDBOX_HOST_NET_ALLOW=1. Either set that env var or switch to network = \"deny\"."
)]
SandboxHostNetworkWithoutCapability,
/// `[plugin.pairing] kind = "form"` declared but the manifest
/// did not ship any `fields`. Without fields the admin
/// renders an empty form — almost certainly an authoring
/// mistake. Phase 81.30 follow-up #5.
#[error(
"[plugin.pairing] kind = \"form\" requires at least one entry under [[plugin.pairing.fields]] — empty form would render nothing."
)]
PairingFormWithoutFields,
/// `[plugin.pairing] kind = "custom"` requires the plugin to
/// declare `rpc_namespace = "..."` so the admin knows which
/// notify method to subscribe to. Phase 81.30 follow-up #5.
#[error(
"[plugin.pairing] kind = \"custom\" requires `rpc_namespace = \"...\"` — admin would not know which `nexo/notify/<rpc_namespace>/status_changed` channel to listen on."
)]
PairingCustomWithoutRpcNamespace,
/// `[plugin.pairing]` declared `fields` but `kind` is not
/// `form`. Fields are only consumed by the form-flow modal;
/// any other kind silently ignores them. Reject at boot so
/// the operator notices the dead config. Phase 81.30 follow-
/// up #5.
#[error(
"[plugin.pairing] fields = [...] only valid with kind = \"form\" (got kind = {kind:?})."
)]
PairingFieldsWithoutFormKind {
/// Kind that was declared instead of `form`.
kind: String,
},
/// `[plugin.pairing.trigger]` shipped a blank `start_method`
/// or `cancel_method`. Daemon would forward a JSON-RPC to an
/// empty method name — refuse at boot. Phase 81.20.x Stage 7
/// Phase 2.
#[error(
"[plugin.pairing.trigger].{field} must not be empty — daemon needs a real admin method name to forward to."
)]
PairingTriggerEmptyMethod {
/// Field name that was blank (`start_method` or `cancel_method`).
field: &'static str,
},
/// `[plugin.pairing.trigger]` declared on a non-QR pairing.
/// Trigger forwarding only makes sense for kinds whose flow
/// the daemon orchestrates with start/cancel (QR pump). Form
/// and Info kinds are operator-driven and need no remote
/// pump. Phase 81.20.x Stage 7 Phase 2.
#[error(
"[plugin.pairing.trigger] only valid with kind = \"qr\" (got kind = {kind:?}) — form and info kinds have no remote pump to start/cancel."
)]
PairingTriggerOnlyWithQr {
/// Kind that was declared instead of `qr`.
kind: String,
},
/// `[plugin.public_tunnel]` shipped a blank `close_on_event`
/// subject. Daemon would subscribe to an empty subject and
/// either reject (broker validation) or match nothing —
/// refuse at boot. Phase 81.20.x Stage 7 Phase 2.
#[error(
"[plugin.public_tunnel].close_on_event must not be empty — daemon needs a real broker subject to subscribe to."
)]
PublicTunnelCloseEventEmpty,
/// `[plugin.public_tunnel].close_on_event` contained a NATS
/// wildcard (`*` or `>`). Wildcards would let a stray plugin
/// event race-close a healthy tunnel; only literal subjects
/// are accepted. Phase 81.20.x Stage 7 Phase 2.
#[error(
"[plugin.public_tunnel].close_on_event = `{subject}` contains a wildcard (`*` or `>`) — only literal broker subjects are accepted."
)]
PublicTunnelCloseEventWildcard {
/// Offending subject.
subject: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_display_messages_are_actionable() {
// Each variant Display includes operator-facing context
// (the offending value + enough hint to fix). Renderer is
// implicit via thiserror; this test guards the surface.
let cases: Vec<Box<dyn std::fmt::Display>> = vec![
Box::new(ManifestError::IdInvalid {
id: "Bad-Id".into(),
reason: "uppercase not allowed",
}),
Box::new(ManifestError::VersionInvalid {
field: "plugin.version",
value: "abc".into(),
}),
Box::new(ManifestError::MinNexoVersionMismatch {
required: ">=1.0.0".into(),
current: "0.1.5".into(),
}),
Box::new(ManifestError::ToolNamespaceViolation {
plugin_id: "marketing".into(),
tool_name: "lead_classify".into(),
}),
Box::new(ManifestError::PathTraversal {
field: "skills.contributes_dir",
path: "../../etc".into(),
}),
Box::new(ManifestError::PathAbsoluteForbidden {
field: "agents.contributes_dir",
path: "/etc/secrets".into(),
}),
Box::new(ManifestError::DeferredNotInExpose {
tool_name: "ghost_tool".into(),
}),
Box::new(ManifestError::DuplicateGateEnvVar {
env_var: "MARKETING_API_KEY".into(),
}),
];
for err in cases {
let s = err.to_string();
assert!(!s.is_empty(), "error Display must produce text");
assert!(s.len() > 20, "error Display must include context: {s:?}");
}
}
}