Skip to main content

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}