tapes_harnesses/plugin/codex_app.rs
1//! The Codex desktop app's hook-plugin manifests, as templates.
2//!
3//! Capturing the desktop app needs a Codex *plugin*: a manifest naming the
4//! plugin plus a hooks file subscribing a command to the five lifecycle
5//! boundaries [`crate::attribution::codex_app`] parses. Unlike pi's extension
6//! (a fixed file, installed by copying), a Codex plugin is installed by
7//! Codex's own plugin manager from a consumer-packaged source directory, and
8//! two of its ingredients are irreducibly the consumer's:
9//!
10//! * **The hook command line.** Each hook runs an executable that receives
11//! the lifecycle payload on stdin and reports it to the consumer's local
12//! runtime. Which executable, and how it is located without depending on
13//! the app's `PATH`, is deployment knowledge — the branded launcher script a
14//! consumer ships is exactly the part that cannot live here.
15//! * **The plugin's identity.** The name, description, and developer strings
16//! Codex shows the user must say who is actually asking for hook trust.
17//!
18//! So the crate ships the manifests as **templates**: the JSON structure and
19//! the event set are crate-owned (and pinned against the attribution module's
20//! event list), while the command and identity are slots the consumer fills
21//! through [`render_hooks_manifest`] and [`render_plugin_manifest`]. Both
22//! installers — a closed-source one today, a tapesctl installer later — render
23//! the same
24//! bytes around their own strings, which is the same anti-drift bargain the
25//! pi asset struck, adapted to a plugin that cannot be vendor-complete.
26//!
27//! The templates carry no endpoint and read no environment: a hook plugin is
28//! inert until the *rendered command* does something, so the inertness
29//! obligation [`crate::plugin::GATEWAY_URL_ENV`] discharges for pi rests here
30//! on the consumer's command instead.
31//!
32//! Rendered manifests are still not an *installed* plugin. [`manager`] owns
33//! the rest: the marketplace wrapper that makes them installable, and the
34//! `codex` CLI invocation that installs them.
35
36pub mod manager;
37
38use super::slots::render_slots;
39
40/// Slot in [`HOOKS_MANIFEST_TEMPLATE`] that a consumer's hook command line
41/// replaces. The slot is the entire JSON string value, so substitution is
42/// JSON-escaped by [`render_hooks_manifest`]; a consumer never edits the
43/// template text itself.
44pub const HOOK_COMMAND_SLOT: &str = "__TAPES_HOOK_COMMAND__";
45
46/// The hooks manifest template — `hooks/hooks.json` in the packaged plugin.
47///
48/// Structure is Codex's hook-file contract: one key per lifecycle event, each
49/// holding a single registration with a single `type: "command"` hook whose
50/// command is [`HOOK_COMMAND_SLOT`].
51pub const HOOKS_MANIFEST_TEMPLATE: &str = include_str!(concat!(
52 env!("CARGO_MANIFEST_DIR"),
53 "/assets/codex-app/hooks.json"
54));
55
56/// The plugin manifest template — `.codex-plugin/plugin.json` in the packaged
57/// plugin. Identity fields are slots for [`HookPluginIdentity`]; the manifest
58/// deliberately registers no tool, app, or skill, so an installed plugin is
59/// hook-only by construction.
60pub const PLUGIN_MANIFEST_TEMPLATE: &str = include_str!(concat!(
61 env!("CARGO_MANIFEST_DIR"),
62 "/assets/codex-app/plugin.json"
63));
64
65/// The two manifests a consumer packages into its plugin source directory,
66/// as the registry hands them out.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[non_exhaustive]
69pub struct HookPluginTemplates {
70 /// [`PLUGIN_MANIFEST_TEMPLATE`], destined for `.codex-plugin/plugin.json`.
71 pub plugin_manifest: &'static str,
72 /// [`HOOKS_MANIFEST_TEMPLATE`], destined for `hooks/hooks.json`.
73 pub hooks_manifest: &'static str,
74}
75
76/// The Codex desktop app's manifest templates.
77pub const CODEX_APP_TEMPLATES: HookPluginTemplates = HookPluginTemplates {
78 plugin_manifest: PLUGIN_MANIFEST_TEMPLATE,
79 hooks_manifest: HOOKS_MANIFEST_TEMPLATE,
80};
81
82/// The consumer-supplied identity a rendered plugin manifest presents to the
83/// user in Codex's plugin UI.
84///
85/// All fields are plain strings; [`render_plugin_manifest`] JSON-escapes them,
86/// so quotes and backslashes in any field are safe.
87///
88/// Build one with [`HookPluginIdentity::new`] and the `with_*` setters. The
89/// fields stay public — reading and patching one is useful, and the fixture
90/// oracle style elsewhere in the crate relies on it — but the type is
91/// `#[non_exhaustive]`, so a struct literal only compiles inside this crate.
92/// Without the constructor a downstream installer got E0639 and could not call
93/// [`render_plugin_manifest`] at all, which is the whole public point of the
94/// module.
95///
96/// # Examples
97///
98/// ```
99/// use tapes_harnesses::plugin::codex_app::{HookPluginIdentity, render_plugin_manifest};
100///
101/// let identity = HookPluginIdentity::new("acme-codex", "0.1.0")
102/// .with_display_name("Acme for Codex")
103/// .with_developer_name("Acme");
104/// let manifest = render_plugin_manifest(&identity);
105/// assert!(!manifest.contains("__TAPES_"));
106/// ```
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108#[non_exhaustive]
109pub struct HookPluginIdentity<'a> {
110 /// The plugin id — what Codex records trust and enablement against.
111 pub name: &'a str,
112 /// Plugin version. Bumping it is how a consumer invalidates the app's
113 /// cached copy of an installed plugin.
114 pub version: &'a str,
115 /// One-line description shown beside the plugin.
116 pub description: &'a str,
117 /// Display name in the plugin UI.
118 pub display_name: &'a str,
119 /// Short marketplace description.
120 pub short_description: &'a str,
121 /// Long marketplace description.
122 pub long_description: &'a str,
123 /// Developer/author name shown to the user granting hook trust.
124 pub developer_name: &'a str,
125}
126
127impl<'a> HookPluginIdentity<'a> {
128 /// A hook plugin's identity, from the two fields that carry meaning
129 /// beyond presentation.
130 ///
131 /// `name` is what Codex records trust and enablement against, and
132 /// `version` is how a consumer invalidates the app's cached copy of an
133 /// installed plugin — get either wrong and an install misbehaves, so they
134 /// are the arguments rather than defaults.
135 ///
136 /// The five remaining fields are strings Codex only *shows*, and each
137 /// starts as `name`. That default is deliberate: every slot in
138 /// [`PLUGIN_MANIFEST_TEMPLATE`] must be filled or a literal
139 /// `__TAPES_PLUGIN_…` string appears in the user-facing plugin UI, so the
140 /// worst outcome of a forgotten `with_*` call is a repetitive UI, never a
141 /// blank field and never a leaked slot. Override each with its setter.
142 #[must_use]
143 pub const fn new(name: &'a str, version: &'a str) -> Self {
144 Self {
145 name,
146 version,
147 description: name,
148 display_name: name,
149 short_description: name,
150 long_description: name,
151 developer_name: name,
152 }
153 }
154
155 /// Set the one-line description shown beside the plugin.
156 #[must_use]
157 pub const fn with_description(mut self, description: &'a str) -> Self {
158 self.description = description;
159 self
160 }
161
162 /// Set the display name shown in the plugin UI.
163 #[must_use]
164 pub const fn with_display_name(mut self, display_name: &'a str) -> Self {
165 self.display_name = display_name;
166 self
167 }
168
169 /// Set the short marketplace description.
170 #[must_use]
171 pub const fn with_short_description(mut self, short_description: &'a str) -> Self {
172 self.short_description = short_description;
173 self
174 }
175
176 /// Set the long marketplace description.
177 #[must_use]
178 pub const fn with_long_description(mut self, long_description: &'a str) -> Self {
179 self.long_description = long_description;
180 self
181 }
182
183 /// Set the developer/author name shown to the user granting hook trust.
184 #[must_use]
185 pub const fn with_developer_name(mut self, developer_name: &'a str) -> Self {
186 self.developer_name = developer_name;
187 self
188 }
189
190 /// The slot each field fills, paired with its value. One table so the
191 /// render loop and the template-coverage test share a single spelling.
192 fn slots(&self) -> [(&'static str, &str); 7] {
193 [
194 ("__TAPES_PLUGIN_NAME__", self.name),
195 ("__TAPES_PLUGIN_VERSION__", self.version),
196 ("__TAPES_PLUGIN_DESCRIPTION__", self.description),
197 ("__TAPES_PLUGIN_DISPLAY_NAME__", self.display_name),
198 ("__TAPES_PLUGIN_SHORT_DESCRIPTION__", self.short_description),
199 ("__TAPES_PLUGIN_LONG_DESCRIPTION__", self.long_description),
200 ("__TAPES_PLUGIN_DEVELOPER_NAME__", self.developer_name),
201 ]
202 }
203}
204
205/// Render the hooks manifest with the consumer's hook command line.
206///
207/// The command is substituted as a JSON string value, escaping included, so a
208/// command containing quotes, backslashes (a Windows path), or `${...}`
209/// expansions passes through byte-exact to Codex.
210#[must_use]
211pub fn render_hooks_manifest(hook_command: &str) -> String {
212 render_slots(
213 HOOKS_MANIFEST_TEMPLATE,
214 &[(HOOK_COMMAND_SLOT, hook_command)],
215 )
216}
217
218/// Render the plugin manifest with the consumer's identity strings.
219#[must_use]
220pub fn render_plugin_manifest(identity: &HookPluginIdentity) -> String {
221 render_slots(PLUGIN_MANIFEST_TEMPLATE, &identity.slots())
222}
223
224/// `value` as a single POSIX shell word.
225///
226/// Two places need this and they must not answer it differently: the hook
227/// command a consumer renders into [`HOOKS_MANIFEST_TEMPLATE`] is executed by
228/// a shell, and the recovery commands [`manager::PluginManager::manual_commands`]
229/// prints are copied into one. A home directory containing a space is
230/// ordinary, and either use getting it wrong silently changes the arguments —
231/// the executed hook runs against the wrong path, the pasted command registers
232/// the wrong directory.
233///
234/// Quoting is applied only when the value needs it, so ordinary paths and
235/// plugin specs print bare. The safe set is a deliberately short allowlist —
236/// ASCII alphanumerics plus `._-/@:+,=` — every member of which a POSIX shell
237/// leaves alone in a non-leading word. Everything else, including the empty
238/// string, is wrapped in single quotes, which suppress every expansion the
239/// shell performs; the only character then needing care is the closing quote
240/// itself, spliced out, escaped, and spliced back in.
241#[must_use]
242pub fn shell_quote(value: &str) -> String {
243 let safe =
244 |character: char| character.is_ascii_alphanumeric() || "._-/@:+,=".contains(character);
245 if !value.is_empty() && value.chars().all(safe) {
246 return value.to_owned();
247 }
248 format!("'{}'", value.replace('\'', r"'\''"))
249}
250
251#[cfg(test)]
252#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
253mod tests {
254 use super::*;
255 use crate::attribution::codex_app::LIFECYCLE_EVENTS;
256 use std::collections::BTreeMap;
257
258 fn identity() -> HookPluginIdentity<'static> {
259 // Built through the public constructor, not a struct literal: this is
260 // the shape a downstream installer is limited to, so the whole
261 // template-coverage suite below runs against it.
262 HookPluginIdentity::new("acme-codex", "0.1.0")
263 .with_description("Keeps Codex connected to acmed.")
264 .with_display_name("Acme for Codex")
265 .with_short_description("Keep Codex connected to Acme.")
266 .with_long_description("Forwards lifecycle metadata to local acmed.")
267 .with_developer_name("Acme")
268 }
269
270 /// A bare `new` fills every presentation slot with the plugin name. The
271 /// property that matters is not the choice of default but that no slot is
272 /// left unfilled: an unset field must never render as an empty string or
273 /// as a literal `__TAPES_…` placeholder in the plugin UI.
274 #[test]
275 fn a_minimal_identity_fills_every_slot_with_the_plugin_name() {
276 let rendered = render_plugin_manifest(&HookPluginIdentity::new("bare-codex", "2.0.0"));
277 let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
278
279 assert!(
280 !rendered.contains("__TAPES_"),
281 "a slot survived a minimal render: {rendered}"
282 );
283 assert_eq!(parsed["name"], "bare-codex");
284 assert_eq!(parsed["version"], "2.0.0");
285 assert_eq!(parsed["interface"]["displayName"], "bare-codex");
286 assert_eq!(parsed["interface"]["developerName"], "bare-codex");
287 assert_eq!(parsed["author"]["name"], "bare-codex");
288 }
289
290 /// Each setter reaches exactly one slot. Distinct values per field would
291 /// pass even if two setters wrote the same slot, so assert the whole
292 /// rendered mapping rather than one field at a time.
293 #[test]
294 fn each_setter_reaches_its_own_slot() {
295 let identity = HookPluginIdentity::new("n", "v")
296 .with_description("d")
297 .with_display_name("dn")
298 .with_short_description("sd")
299 .with_long_description("ld")
300 .with_developer_name("dev");
301
302 assert_eq!(
303 identity.slots().map(|(_, value)| value),
304 ["n", "v", "d", "dn", "sd", "ld", "dev"],
305 );
306 }
307
308 /// The shape Codex parses a hooks file into, mirrored here so the test
309 /// fails if the template stops being a valid hook file rather than only
310 /// if the JSON stops parsing.
311 #[derive(Debug, serde::Deserialize)]
312 #[serde(deny_unknown_fields)]
313 struct HookFile {
314 hooks: BTreeMap<String, Vec<HookRegistration>>,
315 }
316
317 #[derive(Debug, serde::Deserialize)]
318 #[serde(deny_unknown_fields)]
319 struct HookRegistration {
320 hooks: Vec<CommandHook>,
321 }
322
323 #[derive(Debug, serde::Deserialize)]
324 #[serde(deny_unknown_fields)]
325 struct CommandHook {
326 #[serde(rename = "type")]
327 kind: String,
328 command: String,
329 }
330
331 /// The rendered hooks file subscribes the supplied command to exactly the
332 /// lifecycle events the attribution module parses — the two ends of the
333 /// hook contract, pinned to one list.
334 #[test]
335 fn the_rendered_hooks_manifest_subscribes_the_command_to_every_lifecycle_event() {
336 let command = r#"/bin/sh "${PLUGIN_ROOT}/scripts/capture-hook""#;
337 let rendered = render_hooks_manifest(command);
338 let parsed: HookFile = serde_json::from_str(&rendered).unwrap();
339
340 let mut events: Vec<&str> = parsed.hooks.keys().map(String::as_str).collect();
341 let mut expected: Vec<&str> = LIFECYCLE_EVENTS.to_vec();
342 events.sort_unstable();
343 expected.sort_unstable();
344 assert_eq!(events, expected);
345
346 for (event, registrations) in &parsed.hooks {
347 assert_eq!(registrations.len(), 1, "{event} has multiple registrations");
348 assert_eq!(registrations[0].hooks.len(), 1);
349 assert_eq!(registrations[0].hooks[0].kind, "command");
350 assert_eq!(
351 registrations[0].hooks[0].command, command,
352 "{event}'s command did not survive rendering byte-exact"
353 );
354 }
355 }
356
357 /// Substituted values are output, not template: an identity value that
358 /// contains — or *is* — another slot's placeholder must survive
359 /// verbatim, not get substituted itself. The sharpest case is exact
360 /// equality: the value's own JSON-literal quotes complete the quoted
361 /// `"__SLOT__"` pattern, so a sequential per-slot `replace` re-scanning
362 /// its earlier insertions would swap the name for the version. Values
363 /// merely embedding the spelling ride along as regression cover.
364 #[test]
365 fn a_value_containing_another_slots_placeholder_survives_verbatim() {
366 let mut identity = identity();
367 identity.name = "__TAPES_PLUGIN_VERSION__";
368 identity.long_description = "mentions __TAPES_PLUGIN_NAME__ in prose";
369 let rendered = render_plugin_manifest(&identity);
370 let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
371
372 assert_eq!(
373 parsed["name"], "__TAPES_PLUGIN_VERSION__",
374 "the name was re-substituted as if it were template text"
375 );
376 assert_eq!(
377 parsed["interface"]["longDescription"],
378 "mentions __TAPES_PLUGIN_NAME__ in prose"
379 );
380 // And the real slots still rendered normally around them.
381 assert_eq!(parsed["version"], "0.1.0");
382 assert_eq!(parsed["interface"]["displayName"], "Acme for Codex");
383 }
384
385 /// Same property on the hooks side: a command containing the command
386 /// slot's own quoted spelling is emitted once, escaped, and the five
387 /// real slots are the only things substituted.
388 #[test]
389 fn a_command_containing_the_slot_spelling_survives_verbatim() {
390 let command = "run --note '\"__TAPES_HOOK_COMMAND__\"'";
391 let rendered = render_hooks_manifest(command);
392 let parsed: HookFile = serde_json::from_str(&rendered).unwrap();
393 for registrations in parsed.hooks.values() {
394 assert_eq!(registrations[0].hooks[0].command, command);
395 }
396 }
397
398 /// The substitution is real JSON escaping, not text splicing: quotes and
399 /// backslashes in the command round-trip through a JSON parse.
400 #[test]
401 fn rendering_escapes_the_command_as_a_json_string() {
402 let command = "C:\\tools\\hook.exe --label \"two words\"\twith\ncontrol\u{1}chars";
403 let rendered = render_hooks_manifest(command);
404 let parsed: HookFile = serde_json::from_str(&rendered).unwrap();
405 let registrations = parsed.hooks.get("Stop").unwrap();
406 assert_eq!(registrations[0].hooks[0].command, command);
407 }
408
409 #[test]
410 fn the_rendered_plugin_manifest_carries_the_identity_and_no_slots() {
411 let rendered = render_plugin_manifest(&identity());
412 let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
413
414 assert_eq!(parsed["name"], "acme-codex");
415 assert_eq!(parsed["version"], "0.1.0");
416 assert_eq!(parsed["author"]["name"], "Acme");
417 assert_eq!(parsed["interface"]["displayName"], "Acme for Codex");
418 assert_eq!(parsed["interface"]["developerName"], "Acme");
419 assert!(
420 !rendered.contains("__TAPES_"),
421 "an identity slot survived rendering: {rendered}"
422 );
423 // Hook-only by construction: the manifest points at no hooks path
424 // override (default discovery finds hooks/hooks.json) and registers
425 // no tool, app, or skill surface.
426 for absent in ["hooks", "tools", "apps", "skills"] {
427 assert!(
428 parsed.get(absent).is_none(),
429 "the manifest unexpectedly declares {absent:?}"
430 );
431 }
432 }
433
434 /// Every slot the identity fills exists in the template exactly once —
435 /// except the developer name, which the manifest shows in two places —
436 /// and no template carries a slot nothing fills. A drifted spelling
437 /// would otherwise render a manifest with a literal `__TAPES_...` string
438 /// in the user-facing plugin UI.
439 #[test]
440 fn identity_slots_and_template_slots_cover_each_other() {
441 for (slot, _) in identity().slots() {
442 assert!(
443 PLUGIN_MANIFEST_TEMPLATE.contains(&format!("\"{slot}\"")),
444 "template is missing slot {slot}"
445 );
446 }
447 assert_eq!(
448 PLUGIN_MANIFEST_TEMPLATE.matches("__TAPES_").count(),
449 identity().slots().len() + 1, // developerName repeats author.name's slot
450 );
451 assert_eq!(
452 HOOKS_MANIFEST_TEMPLATE.matches("__TAPES_").count(),
453 LIFECYCLE_EVENTS.len(),
454 "the hooks template must carry exactly one command slot per event"
455 );
456 assert!(HOOKS_MANIFEST_TEMPLATE.contains(&format!("\"{HOOK_COMMAND_SLOT}\"")));
457 }
458
459 /// The de-branding bar the pi asset set applies to these templates too:
460 /// the crate-owned halves name no vendor. Branding enters only through
461 /// the consumer's identity strings and command line.
462 #[test]
463 fn the_templates_carry_no_vendor_branding() {
464 for template in [PLUGIN_MANIFEST_TEMPLATE, HOOKS_MANIFEST_TEMPLATE] {
465 let lowered = template.to_ascii_lowercase();
466 for token in ["paper", "papercompute"] {
467 assert!(
468 !lowered.contains(token),
469 "a crate-owned template mentions {token:?}"
470 );
471 }
472 }
473 }
474
475 /// Like the pi asset's no-built-in-endpoint rule: a template must not
476 /// smuggle in a default destination. The only executable content in a
477 /// rendered plugin is the consumer's command.
478 #[test]
479 fn the_templates_have_no_built_in_endpoint() {
480 for template in [PLUGIN_MANIFEST_TEMPLATE, HOOKS_MANIFEST_TEMPLATE] {
481 for literal in ["127.0.0.1:", "localhost:", "http://"] {
482 assert!(
483 !template.contains(literal),
484 "a template hard-codes {literal:?}"
485 );
486 }
487 }
488 }
489
490 /// The registry hands out these exact templates; a drifted copy would
491 /// mean `find("codex-app")` and this module disagree about the bytes a
492 /// consumer packages.
493 #[test]
494 fn the_registry_reaches_these_templates() {
495 let harness = crate::harness::find("codex-app").expect("codex-app is registered");
496 match harness.plugin() {
497 crate::harness::PluginDelivery::HookManifestTemplates(templates) => {
498 assert_eq!(*templates, CODEX_APP_TEMPLATES);
499 }
500 other => panic!("codex-app declares {other:?}, not hook manifest templates"),
501 }
502 }
503
504 /// The quoter's contract, stated against a real shell rather than against
505 /// an expected string: whatever it returns must come back out of `/bin/sh`
506 /// as exactly one word equal to the input. Hard-coding the expected
507 /// quoting would pass even if both the quoter and the expectation were
508 /// wrong in the same way.
509 #[cfg(unix)]
510 #[test]
511 fn a_quoted_value_returns_from_the_shell_as_one_unchanged_word() {
512 for value in [
513 "/tmp/plain/path",
514 "acme-codex@acme",
515 "",
516 "/tmp/two words/plugin",
517 "/tmp/it's here/plugin",
518 "/tmp/$HOME/plugin",
519 "/tmp/`whoami`/plugin",
520 "/tmp/a;rm -rf b/plugin",
521 "/tmp/new\nline/plugin",
522 "/tmp/glob*?[x]/plugin",
523 "~/not-expanded",
524 "/tmp/\u{e9}t\u{e9}/plugin",
525 ] {
526 let output = std::process::Command::new("/bin/sh")
527 .arg("-c")
528 .arg(format!("printf '%s' {}", shell_quote(value)))
529 .output()
530 .unwrap();
531 assert!(
532 output.status.success(),
533 "{value:?} produced unparseable shell text: {}",
534 String::from_utf8_lossy(&output.stderr)
535 );
536 assert_eq!(
537 String::from_utf8(output.stdout).unwrap(),
538 value,
539 "{value:?} did not survive the shell"
540 );
541 }
542 }
543
544 /// Values that need nothing are left alone, so ordinary printed commands
545 /// stay readable. The allowlist is the whole reason this is safe, so it is
546 /// pinned rather than left to inspection.
547 #[test]
548 fn only_values_needing_quotes_get_them() {
549 for bare in ["plugin", "acme-codex@acme", "/a/b_c.d-e", "K=V", "a:b+c,d"] {
550 assert_eq!(shell_quote(bare), bare);
551 }
552 for quoted in [
553 "", " ", "a b", "a~b", "a*b", "a$b", "a'b", "a\\b", "a#b", "a%b",
554 ] {
555 assert!(
556 shell_quote(quoted).starts_with('\''),
557 "{quoted:?} was left unquoted"
558 );
559 }
560 }
561}