tapes-harnesses 0.1.0

Shared, open-source client-side harness knowledge for Tapes capture: the harness registry, launch recipes, config patch grammars, plugin artifacts, per-harness session attribution, and transcript discovery.
Documentation
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
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
//! The Codex desktop app's lifecycle-hook evidence lane.
//!
//! The desktop app is a Codex host a consumer *configures* rather than
//! launches: provider traffic is redirected by the app's own `config.toml`,
//! and there is no launched PID for [`tapes_capture::peer_trust`] to
//! anchor on and no per-launch environment to carry a capture nonce. What the
//! app does offer is a hook surface — a plugin the user installs and trusts
//! runs a consumer-supplied command at session, prompt, stop, and subagent
//! lifecycle boundaries, feeding that command an allowlisted JSON description
//! of the boundary on stdin.
//!
//! This module owns the harness half of that contract: the shape of the JSON
//! Codex writes to the hook command, parsed into an allowlisted
//! [`LifecycleObservation`]. What a consumer *does* with an observation —
//! which process it reports to, how the report is authenticated, what runtime
//! state it updates — is deployment knowledge and stays with the consumer,
//! exactly as delivery and retry do for transcripts.
//!
//! # The identity vocabulary
//!
//! The fields here are the lifecycle-boundary spelling of the same identities
//! the wire lane reads from request headers:
//!
//! * `session_id` is the **root** Codex session — the identity
//!   [`crate::attribution::codex::session::CODEX_ROLLOUT_ID_HEADERS`] narrows
//!   to on a request, and the one a captured session is keyed by. On
//!   `SubagentStart`/`SubagentStop` it stays pinned to the root; the child is
//!   named separately.
//! * `agent_id` is the child thread's own identity — the lifecycle counterpart
//!   of a sub-thread request's `thread-id` header, the value
//!   [`tapes_capture::envelope::thread_id`] resolves and ingest lands in
//!   `meta.thread_id`.
//! * `turn_id` bounds one root turn; `SubagentStart`/`SubagentStop` carry the
//!   root turn their child ran under, which is what joins a child's wire
//!   traffic back to the prompt that spawned it.
//!
//! Every identifier is preserved as an exact, opaque string: matching is
//! equality against evidence from the other lanes, never interpretation.
//!
//! # Allowlist, not schema
//!
//! Hook payloads also carry the user's prompt, assistant output, and arbitrary
//! extension JSON. None of that may survive parsing — an observation exists to
//! attribute traffic, not to duplicate its content into consumer logs and
//! control sockets. [`parse_observation`] therefore deserializes into structs
//! that name only the allowlisted fields and silently discard the rest, and
//! the tests pin that sensitive neighbours do not outlive the parse.

use serde::{Deserialize, Serialize};
use snafu::{ResultExt, Snafu};

/// The lifecycle events the hook contract subscribes to, in the order the
/// manifest template declares them.
///
/// These are Codex's own `hook_event_name` spellings. The list is the single
/// source for [`crate::plugin::codex_app`]'s manifest template — a test there
/// pins the template's keys to exactly this set, so the events a rendered
/// plugin subscribes to and the events [`parse_observation`] accepts cannot
/// drift apart.
pub const LIFECYCLE_EVENTS: &[&str] = &[
    "SessionStart",
    "SubagentStart",
    "SubagentStop",
    "UserPromptSubmit",
    "Stop",
];

/// One allowlisted lifecycle boundary, as reported by a hook invocation.
///
/// The common fields describe the *root* session the boundary belongs to;
/// [`Self::event`] carries the boundary-specific identity. Everything is kept
/// as the exact opaque string Codex supplied.
///
/// `Deserialize` as well as `Serialize`, because an observation is parsed in
/// one process and acted on in another: the hook Codex runs is a short-lived
/// child, and the capture that needs the boundary is the long-lived proxy. A
/// consumer that could only serialize had to declare a mirror of this type on
/// the receiving end and hand-maintain the translation, which is one
/// vocabulary in two spellings — exactly what this crate exists to prevent.
/// The round trip is part of the contract; the tests pin it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LifecycleObservation {
    /// The root Codex session id. Opaque; equality-matched against rollout
    /// `session_meta` ids and the wire lane's session evidence.
    pub session_id: String,
    /// The root session's rollout path, when the hook supplies one. Absence
    /// is normal and must read as "no evidence", not "no transcript".
    pub transcript_path: Option<String>,
    /// Working directory reported by Codex.
    pub cwd: String,
    /// Active model reported by Codex, when present.
    pub model: Option<String>,
    /// Which boundary this is, with its event-specific identity.
    pub event: LifecycleEvent,
}

/// The boundary-specific half of a [`LifecycleObservation`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LifecycleEvent {
    /// A session began (or resumed, cleared, or compacted — see
    /// [`SessionStartSource`]).
    SessionStart {
        /// Why the session-start boundary fired.
        source: SessionStartSource,
    },
    /// The user submitted a prompt, opening root turn `turn_id`.
    UserPromptSubmit {
        /// The opened turn's opaque id.
        turn_id: String,
    },
    /// Root turn `turn_id` completed.
    Stop {
        /// The completed turn's opaque id.
        turn_id: String,
    },
    /// A subagent spawned under root turn `turn_id`.
    SubagentStart {
        /// The root turn the child runs under.
        turn_id: String,
        /// The child thread's own opaque id — the lifecycle counterpart of a
        /// sub-thread request's `thread-id` header.
        agent_id: String,
        /// Codex-reported agent type, opaque.
        agent_type: String,
    },
    /// A subagent finished under root turn `turn_id`.
    SubagentStop {
        /// The root turn the child ran under.
        turn_id: String,
        /// The child thread's own opaque id.
        agent_id: String,
        /// Codex-reported agent type, opaque.
        agent_type: String,
        /// The child's own rollout path, when Codex supplies one. A missing
        /// path is valid; request/transcript matching can still join later.
        agent_transcript_path: Option<String>,
    },
}

/// Codex-reported reason a `SessionStart` hook ran.
///
/// An allowlist like everything else here: a source spelling this crate does
/// not know is a parse error, not a passthrough — new lifecycle semantics
/// should be adopted deliberately, with this enum extended, rather than
/// flowing through as an uninterpreted string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SessionStartSource {
    /// A session the user began fresh.
    Startup,
    /// An earlier session picked up again. The session id is the original's,
    /// so a resumed session is not a new one to attribute.
    Resume,
    /// The user cleared the conversation. Codex keeps the session and starts
    /// the context over, which is why this is a start event at all.
    Clear,
    /// Codex compacted the context and restarted the session around the
    /// summary. Same session, same id — the boundary is Codex's, not the
    /// user's.
    Compact,
}

/// The raw hook stdin shape: Codex tags the payload with `hook_event_name`
/// and flattens the common fields beside the event-specific ones. Unknown
/// fields — including the prompt and assistant output — are dropped here by
/// construction, because no struct names them.
#[derive(Deserialize)]
#[serde(tag = "hook_event_name")]
enum HookInput {
    SessionStart {
        session_id: String,
        transcript_path: Option<String>,
        cwd: String,
        model: Option<String>,
        source: SessionStartSource,
    },
    UserPromptSubmit {
        session_id: String,
        transcript_path: Option<String>,
        cwd: String,
        model: Option<String>,
        turn_id: String,
    },
    Stop {
        session_id: String,
        transcript_path: Option<String>,
        cwd: String,
        model: Option<String>,
        turn_id: String,
    },
    SubagentStart {
        session_id: String,
        transcript_path: Option<String>,
        cwd: String,
        model: Option<String>,
        turn_id: String,
        agent_id: String,
        agent_type: String,
    },
    SubagentStop {
        session_id: String,
        transcript_path: Option<String>,
        cwd: String,
        model: Option<String>,
        turn_id: String,
        agent_id: String,
        agent_type: String,
        agent_transcript_path: Option<String>,
    },
}

/// Parse one hook invocation's stdin into an allowlisted observation.
///
/// Fails on anything that is not a recognised lifecycle event — a hook
/// command receiving an event outside [`LIFECYCLE_EVENTS`], or a payload
/// missing a required identity field, has nothing safe to report and the
/// consumer's hook must stay non-blocking about it (surface a warning,
/// exit successfully).
pub fn parse_observation(input: &[u8]) -> Result<LifecycleObservation, ParseObservationError> {
    let parsed: HookInput =
        serde_json::from_slice(input).context(parse_observation_error::ParseSnafu)?;
    Ok(match parsed {
        HookInput::SessionStart {
            session_id,
            transcript_path,
            cwd,
            model,
            source,
        } => LifecycleObservation {
            session_id,
            transcript_path,
            cwd,
            model,
            event: LifecycleEvent::SessionStart { source },
        },
        HookInput::UserPromptSubmit {
            session_id,
            transcript_path,
            cwd,
            model,
            turn_id,
        } => LifecycleObservation {
            session_id,
            transcript_path,
            cwd,
            model,
            event: LifecycleEvent::UserPromptSubmit { turn_id },
        },
        HookInput::Stop {
            session_id,
            transcript_path,
            cwd,
            model,
            turn_id,
        } => LifecycleObservation {
            session_id,
            transcript_path,
            cwd,
            model,
            event: LifecycleEvent::Stop { turn_id },
        },
        HookInput::SubagentStart {
            session_id,
            transcript_path,
            cwd,
            model,
            turn_id,
            agent_id,
            agent_type,
        } => LifecycleObservation {
            session_id,
            transcript_path,
            cwd,
            model,
            event: LifecycleEvent::SubagentStart {
                turn_id,
                agent_id,
                agent_type,
            },
        },
        HookInput::SubagentStop {
            session_id,
            transcript_path,
            cwd,
            model,
            turn_id,
            agent_id,
            agent_type,
            agent_transcript_path,
        } => LifecycleObservation {
            session_id,
            transcript_path,
            cwd,
            model,
            event: LifecycleEvent::SubagentStop {
                turn_id,
                agent_id,
                agent_type,
                agent_transcript_path,
            },
        },
    })
}

/// Hook stdin could not be decoded into an allowlisted lifecycle observation.
#[derive(Debug, Snafu)]
#[snafu(module)]
#[non_exhaustive]
pub enum ParseObservationError {
    /// The input was not a recognised lifecycle event payload.
    ///
    /// Covers both malformed JSON and well-formed JSON naming an event or a
    /// field spelling this crate does not allowlist. The two are deliberately
    /// one variant: a hook payload this crate cannot fully interpret must not
    /// be partially believed, and telling a caller *which* kind of
    /// uninterpretable it was would invite exactly that.
    #[snafu(display("hook input is not a recognised Codex lifecycle event"))]
    Parse {
        /// The decode failure underneath.
        source: serde_json::Error,
    },
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
    use super::*;

    #[test]
    fn a_session_start_parses_with_its_source() {
        let input = serde_json::json!({
            "hook_event_name": "SessionStart",
            "session_id": "session/opaque-🧭",
            "transcript_path": "/tmp/codex sessions/opaque.jsonl",
            "cwd": "/tmp/work tree",
            "model": "gpt-5-codex",
            "source": "startup"
        })
        .to_string();

        let observation = parse_observation(input.as_bytes()).unwrap();
        assert_eq!(observation.session_id, "session/opaque-🧭");
        assert_eq!(
            observation.transcript_path.as_deref(),
            Some("/tmp/codex sessions/opaque.jsonl")
        );
        assert_eq!(observation.cwd, "/tmp/work tree");
        assert_eq!(observation.model.as_deref(), Some("gpt-5-codex"));
        assert_eq!(
            observation.event,
            LifecycleEvent::SessionStart {
                source: SessionStartSource::Startup
            }
        );
    }

    /// The prompt is the canonical sensitive neighbour: it arrives in the same
    /// payload and must not survive into anything an observation serializes.
    #[test]
    fn a_prompt_submit_keeps_the_turn_id_and_discards_the_prompt() {
        let input = serde_json::json!({
            "hook_event_name": "UserPromptSubmit",
            "session_id": "session/opaque-🧭",
            "transcript_path": "/tmp/codex sessions/opaque.jsonl",
            "cwd": "/tmp/work tree",
            "model": "gpt-5-codex",
            "turn_id": "turn/opaque-🧵",
            "prompt": "prompt-secret-must-never-escape"
        })
        .to_string();

        let observation = parse_observation(input.as_bytes()).unwrap();
        assert_eq!(
            observation.event,
            LifecycleEvent::UserPromptSubmit {
                turn_id: "turn/opaque-🧵".to_owned()
            }
        );
        let retained = serde_json::to_string(&observation).unwrap();
        assert!(!retained.contains("prompt-secret-must-never-escape"));
    }

    /// On subagent boundaries the root/child split is the whole point:
    /// `session_id` stays the parent, `agent_id` names the child, and
    /// assistant output plus arbitrary extension JSON disappear.
    #[test]
    fn a_subagent_stop_keeps_root_and_child_identity_and_nothing_else() {
        let input = serde_json::json!({
            "hook_event_name": "SubagentStop",
            "session_id": "parent/opaque-🧭",
            "transcript_path": "/tmp/codex sessions/parent.jsonl",
            "cwd": "/tmp/work tree",
            "model": "gpt-5-codex",
            "turn_id": "turn/opaque-🧵",
            "agent_id": "agent/opaque-🛡️",
            "agent_type": "guardian/custom",
            "agent_transcript_path": "/tmp/codex children/agent.jsonl",
            "last_assistant_message": "sensitive assistant output must disappear",
            "arbitrary": {"nested": "sensitive arbitrary value"}
        })
        .to_string();

        let observation = parse_observation(input.as_bytes()).unwrap();
        assert_eq!(observation.session_id, "parent/opaque-🧭");
        assert_eq!(
            observation.event,
            LifecycleEvent::SubagentStop {
                turn_id: "turn/opaque-🧵".to_owned(),
                agent_id: "agent/opaque-🛡️".to_owned(),
                agent_type: "guardian/custom".to_owned(),
                agent_transcript_path: Some("/tmp/codex children/agent.jsonl".to_owned()),
            }
        );
        let retained = serde_json::to_string(&observation).unwrap();
        assert!(!retained.contains("sensitive assistant output"));
        assert!(!retained.contains("sensitive arbitrary value"));
    }

    /// A missing child transcript path is a valid stop, not an error — the
    /// join can still happen through request/transcript matching later.
    #[test]
    fn a_subagent_stop_accepts_a_null_child_transcript_path() {
        let input = serde_json::json!({
            "hook_event_name": "SubagentStop",
            "session_id": "parent/opaque-🧭",
            "cwd": "/tmp/work tree",
            "turn_id": "turn/opaque-🧵",
            "agent_id": "agent/opaque-🛡️",
            "agent_type": "guardian/custom",
            "agent_transcript_path": null
        })
        .to_string();

        let observation = parse_observation(input.as_bytes()).unwrap();
        let LifecycleEvent::SubagentStop {
            agent_transcript_path,
            ..
        } = observation.event
        else {
            panic!("expected a subagent stop");
        };
        assert_eq!(agent_transcript_path, None);
        assert_eq!(observation.transcript_path, None);
        assert_eq!(observation.model, None);
    }

    #[test]
    fn a_subagent_start_keeps_the_parent_session_and_exact_child_fields() {
        let input = serde_json::json!({
            "hook_event_name": "SubagentStart",
            "session_id": "parent/opaque-🧭",
            "cwd": "/tmp/work tree",
            "turn_id": "turn/opaque-🧵",
            "agent_id": "agent/opaque-🛡️",
            "agent_type": "guardian/custom",
            "permission_mode": "default"
        })
        .to_string();

        let observation = parse_observation(input.as_bytes()).unwrap();
        assert_eq!(observation.session_id, "parent/opaque-🧭");
        assert_eq!(
            observation.event,
            LifecycleEvent::SubagentStart {
                turn_id: "turn/opaque-🧵".to_owned(),
                agent_id: "agent/opaque-🛡️".to_owned(),
                agent_type: "guardian/custom".to_owned(),
            }
        );
    }

    /// The allowlist is closed in both directions: an event this crate does
    /// not know, or a session-start source it does not know, is a parse
    /// error rather than a silently degraded observation.
    #[test]
    fn unknown_events_and_sources_are_refused() {
        let unknown_event = serde_json::json!({
            "hook_event_name": "PreToolUse",
            "session_id": "session/opaque",
            "cwd": "/tmp"
        })
        .to_string();
        assert!(parse_observation(unknown_event.as_bytes()).is_err());

        let unknown_source = serde_json::json!({
            "hook_event_name": "SessionStart",
            "session_id": "session/opaque",
            "cwd": "/tmp",
            "source": "telepathy"
        })
        .to_string();
        assert!(parse_observation(unknown_source.as_bytes()).is_err());

        assert!(parse_observation(b"not json at all").is_err());
    }

    /// Every event the manifest template subscribes to must be one this
    /// parser accepts, spelled identically — [`LIFECYCLE_EVENTS`] is the
    /// bridge, so it has to cover the enum exactly.
    #[test]
    fn the_lifecycle_event_list_matches_the_parsed_events() {
        for event in LIFECYCLE_EVENTS {
            let mut payload = serde_json::json!({
                "hook_event_name": event,
                "session_id": "s",
                "cwd": "/tmp",
                "turn_id": "t",
                "agent_id": "a",
                "agent_type": "kind",
            });
            if *event == "SessionStart" {
                payload["source"] = serde_json::json!("startup");
            }
            assert!(
                parse_observation(payload.to_string().as_bytes()).is_ok(),
                "{event} is listed but not parseable"
            );
        }
        assert_eq!(LIFECYCLE_EVENTS.len(), 5);
    }

    /// Every boundary survives a serialize/deserialize round trip unchanged.
    ///
    /// The observation is produced by a short-lived hook process and consumed
    /// by a long-lived capture, so the transport is not optional — and a
    /// consumer that has to restate the vocabulary to cross that gap has a
    /// second place for it to drift. Asserting equality over the whole
    /// enum rather than field-by-field is what makes a newly added variant or
    /// field fail here instead of silently dropping in transit.
    #[test]
    fn an_observation_survives_a_round_trip_through_json() {
        for event in LIFECYCLE_EVENTS {
            let mut payload = serde_json::json!({
                "hook_event_name": event,
                "session_id": "s",
                "transcript_path": "/tmp/rollout.jsonl",
                "cwd": "/tmp",
                "model": "gpt-5",
                "turn_id": "t",
                "agent_id": "a",
                "agent_type": "kind",
                "agent_transcript_path": "/tmp/child.jsonl",
            });
            if *event == "SessionStart" {
                payload["source"] = serde_json::json!("compact");
            }
            let observation = parse_observation(payload.to_string().as_bytes()).unwrap();

            let wire = serde_json::to_string(&observation).unwrap();
            let returned: LifecycleObservation = serde_json::from_str(&wire).unwrap();

            assert_eq!(returned, observation, "{event} did not survive the trip");
        }
    }
}