ace-herdr 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/herdr/config.yml +0 -4
  3. data/CHANGELOG.md +30 -0
  4. data/docs/usage.md +65 -32
  5. data/lib/ace/herdr/cli/commands/inbox.rb +81 -31
  6. data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
  7. data/lib/ace/herdr/cli/commands/support.rb +2 -6
  8. data/lib/ace/herdr/cli.rb +3 -0
  9. data/lib/ace/herdr/errors.rb +23 -0
  10. data/lib/ace/herdr/molecules/bounded_process.rb +266 -51
  11. data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -5
  12. data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
  13. data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
  14. data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
  15. data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
  16. data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
  17. data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
  18. data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
  19. data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
  20. data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
  21. data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
  22. data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
  23. data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
  24. data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
  25. data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
  26. data/lib/ace/herdr/molecules/native_source.rb +189 -0
  27. data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
  28. data/lib/ace/herdr/molecules/protected_native_control.rb +259 -0
  29. data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
  30. data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
  31. data/lib/ace/herdr/native_source/selection.json +24 -0
  32. data/lib/ace/herdr/organisms/deliverer.rb +2 -3
  33. data/lib/ace/herdr/organisms/inbox.rb +384 -137
  34. data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
  35. data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
  36. data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
  37. data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
  38. data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
  39. data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
  40. data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
  41. data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
  42. data/lib/ace/herdr/organisms/runtime_adapter.rb +30 -1
  43. data/lib/ace/herdr/version.rb +1 -1
  44. data/lib/ace/herdr.rb +5 -28
  45. metadata +69 -8
@@ -0,0 +1,3493 @@
1
+ From 7a2e78b4e92d2b694bd8a06c1ef4943915c399b2 Mon Sep 17 00:00:00 2001
2
+ From: Miguel <mc@cs3b.com>
3
+ Date: Wed, 7 Oct 2026 00:43:20 +0100
4
+ Subject: [PATCH] feat: guard and permanently drain original-terminal input
5
+
6
+ ---
7
+ docs/next/api/herdr-api.schema.json | 338 ++++++
8
+ .../src/content/docs/agent-automation.mdx | 25 +
9
+ src/api/mod.rs | 1 +
10
+ src/api/schema.rs | 2 +
11
+ src/api/schema/agents.rs | 39 +
12
+ src/api/schema/panes.rs | 6 +
13
+ src/api/schema/response.rs | 59 +
14
+ src/api/schema/tests.rs | 58 +
15
+ src/api/server.rs | 19 +
16
+ src/api/wait.rs | 9 +-
17
+ src/app/api.rs | 6 +
18
+ src/app/api/agents.rs | 203 +++-
19
+ src/app/api/panes.rs | 10 +
20
+ src/app/terminal_targets.rs | 55 +
21
+ src/cli/agent.rs | 1 +
22
+ src/pane.rs | 66 ++
23
+ src/platform/guarded_prompt.rs | 531 +++++++++
24
+ src/platform/linux.rs | 3 +
25
+ src/platform/linux/guarded_origin.rs | 87 ++
26
+ src/platform/mod.rs | 12 +
27
+ src/pty/actor/unix.rs | 1052 ++++++++++++++++-
28
+ src/pty/backend/unix.rs | 5 +
29
+ src/server/headless.rs | 5 +-
30
+ src/terminal/runtime.rs | 35 +
31
+ 24 files changed, 2561 insertions(+), 66 deletions(-)
32
+ create mode 100644 src/platform/guarded_prompt.rs
33
+ create mode 100644 src/platform/linux/guarded_origin.rs
34
+
35
+ diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json
36
+ index e2553f7..022c899 100644
37
+ --- a/docs/next/api/herdr-api.schema.json
38
+ +++ b/docs/next/api/herdr-api.schema.json
39
+ @@ -1226,10 +1226,113 @@
40
+ "title": "EventEnvelope",
41
+ "type": "object"
42
+ },
43
+ + "guarded_prompt_error_response": {
44
+ + "$defs": {
45
+ + "GuardedPromptErrorBody": {
46
+ + "additionalProperties": false,
47
+ + "properties": {
48
+ + "code": {
49
+ + "type": "string"
50
+ + },
51
+ + "message": {
52
+ + "type": "string"
53
+ + },
54
+ + "phase": {
55
+ + "type": "string"
56
+ + }
57
+ + },
58
+ + "required": [
59
+ + "code",
60
+ + "message",
61
+ + "phase"
62
+ + ],
63
+ + "type": "object"
64
+ + }
65
+ + },
66
+ + "$schema": "https://json-schema.org/draft/2020-12/schema",
67
+ + "additionalProperties": false,
68
+ + "description": "Closed guarded error envelope; phase is never inferred from a generic error.",
69
+ + "properties": {
70
+ + "error": {
71
+ + "$ref": "#/schemas/guarded_prompt_error_response/$defs/GuardedPromptErrorBody"
72
+ + },
73
+ + "id": {
74
+ + "type": "string"
75
+ + }
76
+ + },
77
+ + "required": [
78
+ + "id",
79
+ + "error"
80
+ + ],
81
+ + "title": "GuardedPromptErrorResponse",
82
+ + "type": "object"
83
+ + },
84
+ + "input_drain_error_response": {
85
+ + "$defs": {
86
+ + "InputDrainCode": {
87
+ + "enum": [
88
+ + "invalid_params",
89
+ + "guard_mismatch",
90
+ + "origin_unavailable",
91
+ + "input_drain_unavailable"
92
+ + ],
93
+ + "type": "string"
94
+ + },
95
+ + "InputDrainErrorBody": {
96
+ + "additionalProperties": false,
97
+ + "properties": {
98
+ + "code": {
99
+ + "$ref": "#/schemas/input_drain_error_response/$defs/InputDrainCode"
100
+ + },
101
+ + "phase": {
102
+ + "$ref": "#/schemas/input_drain_error_response/$defs/InputDrainPhase"
103
+ + }
104
+ + },
105
+ + "required": [
106
+ + "code",
107
+ + "phase"
108
+ + ],
109
+ + "type": "object"
110
+ + },
111
+ + "InputDrainPhase": {
112
+ + "enum": [
113
+ + "unconfirmed"
114
+ + ],
115
+ + "type": "string"
116
+ + }
117
+ + },
118
+ + "$schema": "https://json-schema.org/draft/2020-12/schema",
119
+ + "additionalProperties": false,
120
+ + "properties": {
121
+ + "error": {
122
+ + "$ref": "#/schemas/input_drain_error_response/$defs/InputDrainErrorBody"
123
+ + },
124
+ + "id": {
125
+ + "type": "string"
126
+ + }
127
+ + },
128
+ + "required": [
129
+ + "id",
130
+ + "error"
131
+ + ],
132
+ + "title": "InputDrainErrorResponse",
133
+ + "type": "object"
134
+ + },
135
+ "request": {
136
+ "$defs": {
137
+ "AgentPromptParams": {
138
+ + "additionalProperties": false,
139
+ "properties": {
140
+ + "expected_origin": {
141
+ + "anyOf": [
142
+ + {
143
+ + "$ref": "#/schemas/request/$defs/GuardedPromptOrigin"
144
+ + },
145
+ + {
146
+ + "type": "null"
147
+ + }
148
+ + ]
149
+ + },
150
+ "target": {
151
+ "type": "string"
152
+ },
153
+ @@ -2151,6 +2254,75 @@
154
+ ],
155
+ "type": "object"
156
+ },
157
+ + "GuardedChildIdentity": {
158
+ + "additionalProperties": false,
159
+ + "properties": {
160
+ + "gid": {
161
+ + "format": "uint32",
162
+ + "minimum": 0,
163
+ + "type": "integer"
164
+ + },
165
+ + "groups": {
166
+ + "items": {
167
+ + "format": "uint32",
168
+ + "minimum": 0,
169
+ + "type": "integer"
170
+ + },
171
+ + "type": "array"
172
+ + },
173
+ + "host": {
174
+ + "type": "string"
175
+ + },
176
+ + "parent_pid": {
177
+ + "format": "uint32",
178
+ + "minimum": 0,
179
+ + "type": "integer"
180
+ + },
181
+ + "pid": {
182
+ + "format": "uint32",
183
+ + "minimum": 0,
184
+ + "type": "integer"
185
+ + },
186
+ + "started_at": {
187
+ + "type": "string"
188
+ + },
189
+ + "uid": {
190
+ + "format": "uint32",
191
+ + "minimum": 0,
192
+ + "type": "integer"
193
+ + }
194
+ + },
195
+ + "required": [
196
+ + "pid",
197
+ + "uid",
198
+ + "gid",
199
+ + "groups",
200
+ + "parent_pid",
201
+ + "started_at",
202
+ + "host"
203
+ + ],
204
+ + "type": "object"
205
+ + },
206
+ + "GuardedPromptOrigin": {
207
+ + "additionalProperties": false,
208
+ + "properties": {
209
+ + "child": {
210
+ + "$ref": "#/schemas/request/$defs/GuardedChildIdentity"
211
+ + },
212
+ + "runtime_incarnation": {
213
+ + "type": "string"
214
+ + },
215
+ + "terminal_id": {
216
+ + "type": "string"
217
+ + }
218
+ + },
219
+ + "required": [
220
+ + "terminal_id",
221
+ + "runtime_incarnation",
222
+ + "child"
223
+ + ],
224
+ + "type": "object"
225
+ + },
226
+ "IntegrationInstallParams": {
227
+ "properties": {
228
+ "target": {
229
+ @@ -4357,6 +4529,22 @@
230
+ ],
231
+ "type": "object"
232
+ },
233
+ + "TerminalInhibitInputParams": {
234
+ + "additionalProperties": false,
235
+ + "properties": {
236
+ + "expected_origin": {
237
+ + "$ref": "#/schemas/request/$defs/GuardedPromptOrigin"
238
+ + },
239
+ + "target": {
240
+ + "type": "string"
241
+ + }
242
+ + },
243
+ + "required": [
244
+ + "target",
245
+ + "expected_origin"
246
+ + ],
247
+ + "type": "object"
248
+ + },
249
+ "ToastHerdrPosition": {
250
+ "enum": [
251
+ "top-left",
252
+ @@ -5417,6 +5605,22 @@
253
+ ],
254
+ "type": "object"
255
+ },
256
+ + {
257
+ + "properties": {
258
+ + "method": {
259
+ + "const": "terminal.inhibit_input",
260
+ + "type": "string"
261
+ + },
262
+ + "params": {
263
+ + "$ref": "#/schemas/request/$defs/TerminalInhibitInputParams"
264
+ + }
265
+ + },
266
+ + "required": [
267
+ + "method",
268
+ + "params"
269
+ + ],
270
+ + "type": "object"
271
+ + },
272
+ {
273
+ "properties": {
274
+ "method": {
275
+ @@ -7485,6 +7689,81 @@
276
+ ],
277
+ "type": "string"
278
+ },
279
+ + "GuardedChildIdentity": {
280
+ + "additionalProperties": false,
281
+ + "properties": {
282
+ + "gid": {
283
+ + "format": "uint32",
284
+ + "minimum": 0,
285
+ + "type": "integer"
286
+ + },
287
+ + "groups": {
288
+ + "items": {
289
+ + "format": "uint32",
290
+ + "minimum": 0,
291
+ + "type": "integer"
292
+ + },
293
+ + "type": "array"
294
+ + },
295
+ + "host": {
296
+ + "type": "string"
297
+ + },
298
+ + "parent_pid": {
299
+ + "format": "uint32",
300
+ + "minimum": 0,
301
+ + "type": "integer"
302
+ + },
303
+ + "pid": {
304
+ + "format": "uint32",
305
+ + "minimum": 0,
306
+ + "type": "integer"
307
+ + },
308
+ + "started_at": {
309
+ + "type": "string"
310
+ + },
311
+ + "uid": {
312
+ + "format": "uint32",
313
+ + "minimum": 0,
314
+ + "type": "integer"
315
+ + }
316
+ + },
317
+ + "required": [
318
+ + "pid",
319
+ + "uid",
320
+ + "gid",
321
+ + "groups",
322
+ + "parent_pid",
323
+ + "started_at",
324
+ + "host"
325
+ + ],
326
+ + "type": "object"
327
+ + },
328
+ + "GuardedPromptOrigin": {
329
+ + "additionalProperties": false,
330
+ + "properties": {
331
+ + "child": {
332
+ + "$ref": "#/schemas/success_response/$defs/GuardedChildIdentity"
333
+ + },
334
+ + "runtime_incarnation": {
335
+ + "type": "string"
336
+ + },
337
+ + "terminal_id": {
338
+ + "type": "string"
339
+ + }
340
+ + },
341
+ + "required": [
342
+ + "terminal_id",
343
+ + "runtime_incarnation",
344
+ + "child"
345
+ + ],
346
+ + "type": "object"
347
+ + },
348
+ + "InputState": {
349
+ + "enum": [
350
+ + "inhibited"
351
+ + ],
352
+ + "type": "string"
353
+ + },
354
+ "InstalledPluginInfo": {
355
+ "properties": {
356
+ "actions": {
357
+ @@ -8275,6 +8554,22 @@
358
+ },
359
+ "type": "array"
360
+ },
361
+ + "guarded_input_drain": {
362
+ + "type": "boolean"
363
+ + },
364
+ + "guarded_prompt": {
365
+ + "type": "boolean"
366
+ + },
367
+ + "guarded_prompt_origin": {
368
+ + "anyOf": [
369
+ + {
370
+ + "$ref": "#/schemas/success_response/$defs/GuardedPromptOrigin"
371
+ + },
372
+ + {
373
+ + "type": "null"
374
+ + }
375
+ + ]
376
+ + },
377
+ "pane_id": {
378
+ "type": "string"
379
+ },
380
+ @@ -9481,6 +9776,22 @@
381
+ "agent": {
382
+ "$ref": "#/schemas/success_response/$defs/AgentInfo"
383
+ },
384
+ + "origin": {
385
+ + "anyOf": [
386
+ + {
387
+ + "$ref": "#/schemas/success_response/$defs/GuardedPromptOrigin"
388
+ + },
389
+ + {
390
+ + "type": "null"
391
+ + }
392
+ + ]
393
+ + },
394
+ + "submission": {
395
+ + "type": [
396
+ + "string",
397
+ + "null"
398
+ + ]
399
+ + },
400
+ "type": {
401
+ "const": "agent_prompted",
402
+ "type": "string"
403
+ @@ -9492,6 +9803,33 @@
404
+ ],
405
+ "type": "object"
406
+ },
407
+ + {
408
+ + "properties": {
409
+ + "input_state": {
410
+ + "$ref": "#/schemas/success_response/$defs/InputState"
411
+ + },
412
+ + "origin": {
413
+ + "$ref": "#/schemas/success_response/$defs/GuardedPromptOrigin"
414
+ + },
415
+ + "pending_input": {
416
+ + "format": "uint8",
417
+ + "maximum": 255,
418
+ + "minimum": 0,
419
+ + "type": "integer"
420
+ + },
421
+ + "type": {
422
+ + "const": "terminal_input_drained",
423
+ + "type": "string"
424
+ + }
425
+ + },
426
+ + "required": [
427
+ + "type",
428
+ + "origin",
429
+ + "input_state",
430
+ + "pending_input"
431
+ + ],
432
+ + "type": "object"
433
+ + },
434
+ {
435
+ "properties": {
436
+ "agents": {
437
+ diff --git a/docs/next/website/src/content/docs/agent-automation.mdx b/docs/next/website/src/content/docs/agent-automation.mdx
438
+ index def306f..6f0e9c1 100644
439
+ --- a/docs/next/website/src/content/docs/agent-automation.mdx
440
+ +++ b/docs/next/website/src/content/docs/agent-automation.mdx
441
+ @@ -81,6 +81,17 @@ Startup readiness, restoring an idle conversation, and switching agent conversat
442
+
443
+ A timeout or `agent_prompt_stalled` does not prove that no input was sent. Read the agent before retrying to avoid submitting the same prompt twice. IDs and agent names are scoped to one server; selecting another machine in the TUI does not retarget CLI commands running in an existing pane.
444
+
445
+ +### Guarded original-terminal submission (API)
446
+ +
447
+ +An integration that requires an original spawn must use a source build supporting guarded prompts and require `guarded_prompt: true` plus `guarded_prompt_origin` from `pane.process-info`. This capability is available only for an attached Linux terminal whose original child was captured with a live pidfd. Imported or handed-off terminals and unsupported capture do not advertise it. Select and authenticate the intended server/build before reading its capability; an older server's ordinary prompt acknowledgment is not guarded evidence.
448
+ +
449
+ +Call `agent.prompt` with `target` equal to that exact opaque `terminal_id`, `text`, and the complete `expected_origin` object unchanged. The object contains `terminal_id`, `runtime_incarnation`, and `child` (`pid`, `uid`, `gid`, `groups`, `parent_pid`, `started_at`, `host`). Unknown fields, aliases, malformed identities, `wait`, blank text, and text above 16384 UTF-8 bytes are refused. Guarded calls do not use pane names or agent labels.
450
+ +
451
+ +The original child and runtime must still match when admitted and immediately before each focus/text/Enter write to the captured PTY. A complete acknowledgment contains `submission: "submitted"` and the exact `origin`; it proves ordered terminal submission, not agent consumption. Refusals report `error.phase: "not_issued"` when no guarded input was written. Partial writes, write errors, or uncertain completion report `"issued_or_unknown"`. A lost acknowledgment is also uncertain. Never automatically resend an uncertain prompt. The same original terminal may deliver to its foreground descendant; this protocol does not authenticate which descendant consumed the text.
452
+ +
453
+ +Queued guarded submissions prevent PTY handoff until completed or refused. Replacement or original-child exit before the first write has no focus/text effect. Exit after partial input suppresses further text/Enter and remains uncertain. Protected stop and whole-scope writer/settlement proofs belong to the external canonical owner; guarded prompt acknowledgment does not authorize cleanup.
454
+ +
455
+ +
456
+ `pane wait-output` does not interpret agent lifecycle. It polls the selected terminal snapshot and searches it immediately, so text that was already present can match. The default source name is `recent`; matching treats that source as unwrapped recent output from the latest 80 rendered terminal rows. `--lines` changes that row limit, and `--regex` uses Rust regular-expression syntax and matches one line at a time.
457
+
458
+ At the CLI, both `pane read` and `agent read` print terminal text directly. The default is UTF-8 text with ANSI escapes stripped; use `--format ansi` or `--ansi` to preserve terminal escapes where the source exposes them. The `detection` source is always plain text. For recent sources, `--lines N` selects the last N rendered terminal rows before optional unwrapping; without it, reads default to 80 rows. For `visible` and `detection`, omitting `--lines` returns the full snapshot, while specifying it keeps the last N newline-delimited lines. The socket API returns the text at `.result.read.text`.
459
+ @@ -125,3 +136,17 @@ herdr pane wait-output w1:p3 --regex "passed|failed" --timeout 120000
460
+ ```
461
+
462
+ See the [CLI reference](/docs/cli-reference/) for the complete command and option list. Shell completions expose the same command tree interactively.
463
+ +
464
+ +### Inhibit and drain original terminal input
465
+ +
466
+ +An actor advertising `guarded_input_drain: true` accepts `terminal.inhibit_input` with only
467
+ +`target` and `expected_origin`, using the same opaque terminal ID and captured origin as guarded prompts.
468
+ +The original child may have exited; a replacement, imported, or released actor cannot satisfy this request.
469
+ +The operation permanently inhibits that actor's input. Its `terminal_input_drained` reply returns the
470
+ +exact origin, `input_state: "inhibited"`, and `pending_input: 0` only after received generic input,
471
+ +guarded submissions, terminal replies, and pending input controls have been canceled or settled.
472
+ +Queued handoff or resume cannot reopen the gate. A lost reply can be retried for the exact same actor while it remains attached.
473
+ +A parseable but inconsistent target/origin pair reports `guard_mismatch`.
474
+ +The fixed acknowledgement deadline is five seconds; timeout grants no drained proof.
475
+ +Errors carry only a fixed code and `phase: "unconfirmed"`; channel loss leaves input inhibited without
476
+ +claiming drain. This proves no further PTY input from this actor, not child exit or external scope cleanup.
477
+ diff --git a/src/api/mod.rs b/src/api/mod.rs
478
+ index 86f90da..39638a0 100644
479
+ --- a/src/api/mod.rs
480
+ +++ b/src/api/mod.rs
481
+ @@ -51,6 +51,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
482
+ | Method::AgentFocus(_)
483
+ | Method::AgentStart(_)
484
+ | Method::AgentPrompt(_)
485
+ + | Method::TerminalInhibitInput(_)
486
+ | Method::AgentSendKeys(_)
487
+ | Method::PaneSplit(_)
488
+ | Method::PaneSwap(_)
489
+ diff --git a/src/api/schema.rs b/src/api/schema.rs
490
+ index 439ed33..d7ea332 100644
491
+ --- a/src/api/schema.rs
492
+ +++ b/src/api/schema.rs
493
+ @@ -137,6 +137,8 @@ pub enum Method {
494
+ AgentStart(AgentStartParams),
495
+ #[serde(rename = "agent.prompt")]
496
+ AgentPrompt(AgentPromptParams),
497
+ + #[serde(rename = "terminal.inhibit_input")]
498
+ + TerminalInhibitInput(TerminalInhibitInputParams),
499
+ #[serde(rename = "agent.wait")]
500
+ AgentWait(AgentWaitParams),
501
+ #[serde(rename = "pane.split")]
502
+ diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs
503
+ index 48450c3..a5c3007 100644
504
+ --- a/src/api/schema/agents.rs
505
+ +++ b/src/api/schema/agents.rs
506
+ @@ -176,13 +176,40 @@ pub struct AgentStartParams {
507
+ }
508
+
509
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
510
+ +#[serde(deny_unknown_fields)]
511
+ pub struct AgentPromptParams {
512
+ pub target: String,
513
+ pub text: String,
514
+ #[serde(default, skip_serializing_if = "Option::is_none")]
515
+ + pub expected_origin: Option<crate::platform::guarded_prompt::GuardedPromptOrigin>,
516
+ + #[serde(default, skip_serializing_if = "Option::is_none")]
517
+ pub wait: Option<AgentPromptWaitOptions>,
518
+ }
519
+
520
+ +impl AgentPromptParams {
521
+ + pub(crate) fn validate_guarded(
522
+ + &self,
523
+ + ) -> Result<
524
+ + &crate::platform::guarded_prompt::GuardedPromptOrigin,
525
+ + crate::platform::guarded_prompt::GuardedPromptError,
526
+ + > {
527
+ + use crate::platform::guarded_prompt::GuardedPromptError;
528
+ + let expected = self
529
+ + .expected_origin
530
+ + .as_ref()
531
+ + .ok_or(GuardedPromptError::GuardMismatch)?;
532
+ + if self.wait.is_some()
533
+ + || !expected.valid()
534
+ + || expected.terminal_id != self.target
535
+ + || self.text.trim().is_empty()
536
+ + || self.text.len() > 16_384
537
+ + {
538
+ + return Err(GuardedPromptError::GuardMismatch);
539
+ + }
540
+ + Ok(expected)
541
+ + }
542
+ +}
543
+ +
544
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
545
+ pub struct AgentInfo {
546
+ pub terminal_id: String,
547
+ @@ -235,3 +262,15 @@ pub struct AgentSessionInfo {
548
+ pub kind: crate::agent_resume::AgentSessionRefKind,
549
+ pub value: String,
550
+ }
551
+ +
552
+ +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
553
+ +#[serde(deny_unknown_fields)]
554
+ +pub struct TerminalInhibitInputParams {
555
+ + pub target: String,
556
+ + pub expected_origin: crate::platform::guarded_prompt::GuardedPromptOrigin,
557
+ +}
558
+ +impl TerminalInhibitInputParams {
559
+ + pub(crate) fn valid(&self) -> bool {
560
+ + self.expected_origin.valid() && self.target == self.expected_origin.terminal_id
561
+ + }
562
+ +}
563
+ diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs
564
+ index 9dbe022..a758547 100644
565
+ --- a/src/api/schema/panes.rs
566
+ +++ b/src/api/schema/panes.rs
567
+ @@ -492,6 +492,12 @@ pub struct PaneScrollInfo {
568
+
569
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
570
+ pub struct PaneProcessInfo {
571
+ + #[serde(default, skip_serializing_if = "Option::is_none")]
572
+ + pub guarded_prompt_origin: Option<crate::platform::guarded_prompt::GuardedPromptOrigin>,
573
+ + #[serde(default, skip_serializing_if = "super::is_false")]
574
+ + pub guarded_prompt: bool,
575
+ + #[serde(default, skip_serializing_if = "super::is_false")]
576
+ + pub guarded_input_drain: bool,
577
+ pub pane_id: String,
578
+ #[serde(default, skip_serializing_if = "Option::is_none")]
579
+ pub shell_pid: Option<u32>,
580
+ diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs
581
+ index e92e495..13fd15c 100644
582
+ --- a/src/api/schema/response.rs
583
+ +++ b/src/api/schema/response.rs
584
+ @@ -103,6 +103,15 @@ pub enum ResponseResult {
585
+ },
586
+ AgentPrompted {
587
+ agent: AgentInfo,
588
+ + #[serde(default, skip_serializing_if = "Option::is_none")]
589
+ + origin: Option<crate::platform::guarded_prompt::GuardedPromptOrigin>,
590
+ + #[serde(default, skip_serializing_if = "Option::is_none")]
591
+ + submission: Option<String>,
592
+ + },
593
+ + TerminalInputDrained {
594
+ + origin: crate::platform::guarded_prompt::GuardedPromptOrigin,
595
+ + input_state: InputState,
596
+ + pending_input: u8,
597
+ },
598
+ AgentList {
599
+ agents: Vec<AgentInfo>,
600
+ @@ -299,3 +308,53 @@ pub struct AgentManifestInfo {
601
+ #[serde(default, skip_serializing_if = "Option::is_none")]
602
+ pub warning: Option<String>,
603
+ }
604
+ +
605
+ +/// Closed guarded error envelope; phase is never inferred from a generic error.
606
+ +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
607
+ +#[serde(deny_unknown_fields)]
608
+ +pub struct GuardedPromptErrorResponse {
609
+ + pub id: String,
610
+ + pub error: GuardedPromptErrorBody,
611
+ +}
612
+ +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
613
+ +#[serde(deny_unknown_fields)]
614
+ +pub struct GuardedPromptErrorBody {
615
+ + pub code: String,
616
+ + pub message: String,
617
+ + pub phase: String,
618
+ +}
619
+ +
620
+ +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
621
+ +#[serde(rename_all = "snake_case")]
622
+ +pub enum InputState {
623
+ + Inhibited,
624
+ +}
625
+ +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
626
+ +#[serde(deny_unknown_fields)]
627
+ +pub enum InputDrainCode {
628
+ + #[serde(rename = "invalid_params")]
629
+ + InvalidParams,
630
+ + #[serde(rename = "guard_mismatch")]
631
+ + GuardMismatch,
632
+ + #[serde(rename = "origin_unavailable")]
633
+ + OriginUnavailable,
634
+ + #[serde(rename = "input_drain_unavailable")]
635
+ + Unavailable,
636
+ +}
637
+ +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
638
+ +#[serde(deny_unknown_fields)]
639
+ +pub struct InputDrainErrorBody {
640
+ + pub code: InputDrainCode,
641
+ + pub phase: InputDrainPhase,
642
+ +}
643
+ +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
644
+ +#[serde(rename_all = "snake_case")]
645
+ +pub enum InputDrainPhase {
646
+ + Unconfirmed,
647
+ +}
648
+ +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
649
+ +#[serde(deny_unknown_fields)]
650
+ +pub struct InputDrainErrorResponse {
651
+ + pub id: String,
652
+ + pub error: InputDrainErrorBody,
653
+ +}
654
+ diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
655
+ index 3b43133..2199451 100644
656
+ --- a/src/api/schema/tests.rs
657
+ +++ b/src/api/schema/tests.rs
658
+ @@ -39,6 +39,8 @@ fn protocol_schema_document() -> serde_json::Value {
659
+ "request": protocol_schema_entry::<Request>("request"),
660
+ "success_response": protocol_schema_entry::<SuccessResponse>("success_response"),
661
+ "error_response": protocol_schema_entry::<ErrorResponse>("error_response"),
662
+ + "input_drain_error_response": protocol_schema_entry::<InputDrainErrorResponse>("input_drain_error_response"),
663
+ + "guarded_prompt_error_response": protocol_schema_entry::<GuardedPromptErrorResponse>("guarded_prompt_error_response"),
664
+ "event": protocol_schema_entry::<EventEnvelope>("event"),
665
+ "subscription_event": protocol_schema_entry::<SubscriptionEventEnvelope>("subscription_event"),
666
+ },
667
+ @@ -113,6 +115,7 @@ fn agent_start_and_prompt_requests_round_trip() {
668
+ let prompt = Request {
669
+ id: "prompt".into(),
670
+ method: Method::AgentPrompt(AgentPromptParams {
671
+ + expected_origin: None,
672
+ target: "reviewer".into(),
673
+ text: "review this".into(),
674
+ wait: None,
675
+ @@ -128,6 +131,7 @@ fn agent_start_and_prompt_requests_round_trip() {
676
+ let prompt_and_wait = Request {
677
+ id: "prompt-and-wait".into(),
678
+ method: Method::AgentPrompt(AgentPromptParams {
679
+ + expected_origin: None,
680
+ target: "reviewer".into(),
681
+ text: "review this".into(),
682
+ wait: Some(AgentPromptWaitOptions {
683
+ @@ -1459,3 +1463,57 @@ fn pane_link_resolve_round_trips() {
684
+ result
685
+ );
686
+ }
687
+ +
688
+ +#[test]
689
+ +fn input_drain_pure_tests_closed_wire_and_exact_origin() {
690
+ + let origin = serde_json::json!({
691
+ + "terminal_id": "term_ab", "runtime_incarnation": "12345678-1234-1234-1234-123456789abc",
692
+ + "child": {"pid": 12, "uid": 42, "gid": 42, "groups": [42], "parent_pid": 11,
693
+ + "started_at": "linux:12345678-1234-1234-1234-123456789abc:123", "host": "fixture"}
694
+ + });
695
+ + let request = serde_json::json!({"target": "term_ab", "expected_origin": origin});
696
+ + let params: TerminalInhibitInputParams =
697
+ + serde_json::from_value(request.clone()).expect("closed request");
698
+ + assert!(params.valid());
699
+ + let mut changed = request.clone();
700
+ + changed["target"] = serde_json::json!("pane_ab");
701
+ + assert!(
702
+ + !serde_json::from_value::<TerminalInhibitInputParams>(changed)
703
+ + .expect("typed alias")
704
+ + .valid()
705
+ + );
706
+ + let mut changed = request.clone();
707
+ + changed["text"] = serde_json::json!("unrelated");
708
+ + assert!(serde_json::from_value::<TerminalInhibitInputParams>(changed).is_err());
709
+ + let mut changed = request;
710
+ + changed["expected_origin"]["child"]["pid"] = serde_json::json!(12.0);
711
+ + assert!(serde_json::from_value::<TerminalInhibitInputParams>(changed).is_err());
712
+ + let response = ResponseResult::TerminalInputDrained {
713
+ + origin: params.expected_origin,
714
+ + input_state: InputState::Inhibited,
715
+ + pending_input: 0,
716
+ + };
717
+ + assert_eq!(
718
+ + serde_json::to_value(response).expect("reply"),
719
+ + serde_json::json!({
720
+ + "type": "terminal_input_drained", "origin": origin, "input_state": "inhibited", "pending_input": 0
721
+ + })
722
+ + );
723
+ + for code in [
724
+ + InputDrainCode::InvalidParams,
725
+ + InputDrainCode::GuardMismatch,
726
+ + InputDrainCode::OriginUnavailable,
727
+ + InputDrainCode::Unavailable,
728
+ + ] {
729
+ + let reply = InputDrainErrorResponse {
730
+ + id: "request".into(),
731
+ + error: InputDrainErrorBody {
732
+ + code,
733
+ + phase: InputDrainPhase::Unconfirmed,
734
+ + },
735
+ + };
736
+ + let encoded = serde_json::to_value(reply).expect("bounded refusal");
737
+ + assert_eq!(encoded["error"]["phase"], "unconfirmed");
738
+ + assert_eq!(encoded["error"].as_object().expect("closed error").len(), 2);
739
+ + }
740
+ +}
741
+ diff --git a/src/api/server.rs b/src/api/server.rs
742
+ index dbd7d3b..5f698c0 100644
743
+ --- a/src/api/server.rs
744
+ +++ b/src/api/server.rs
745
+ @@ -331,6 +331,23 @@ fn handle_connection_with_stop(
746
+ } else {
747
+ String::new()
748
+ };
749
+ + #[derive(serde::Deserialize)]
750
+ + struct InvalidMethod {
751
+ + method: String,
752
+ + }
753
+ + if serde_json::from_str::<InvalidMethod>(line)
754
+ + .is_ok_and(|request| request.method == "terminal.inhibit_input")
755
+ + {
756
+ + let response = crate::api::schema::InputDrainErrorResponse {
757
+ + id,
758
+ + error: crate::api::schema::InputDrainErrorBody {
759
+ + code: crate::api::schema::InputDrainCode::InvalidParams,
760
+ + phase: crate::api::schema::InputDrainPhase::Unconfirmed,
761
+ + },
762
+ + };
763
+ + write_json_line_allow_disconnect(&mut stream, &response)?;
764
+ + return Ok(());
765
+ + }
766
+ let response =
767
+ retired_pane_graphics_method_error(line, &id).unwrap_or_else(|| ErrorResponse {
768
+ id,
769
+ @@ -610,6 +627,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str {
770
+ Method::AgentFocus(_) => "agent.focus",
771
+ Method::AgentStart(_) => "agent.start",
772
+ Method::AgentPrompt(_) => "agent.prompt",
773
+ + Method::TerminalInhibitInput(_) => "terminal.inhibit_input",
774
+ Method::AgentWait(_) => "agent.wait",
775
+ Method::PaneSplit(_) => "pane.split",
776
+ Method::PaneSwap(_) => "pane.swap",
777
+ @@ -1083,6 +1101,7 @@ fn caller_timeout_dispatch_uses_timeout_error() {
778
+ Request {
779
+ id: "prompt-timeout".into(),
780
+ method: Method::AgentPrompt(crate::api::schema::AgentPromptParams {
781
+ + expected_origin: None,
782
+ target: "reviewer".into(),
783
+ text: "review this".into(),
784
+ wait: None,
785
+ diff --git a/src/api/wait.rs b/src/api/wait.rs
786
+ index 541779f..9d366d2 100644
787
+ --- a/src/api/wait.rs
788
+ +++ b/src/api/wait.rs
789
+ @@ -182,6 +182,9 @@ pub(super) fn prompt_agent(
790
+ event_hub: &EventHub,
791
+ running: &Arc<AtomicBool>,
792
+ ) -> std::io::Result<Option<String>> {
793
+ + if params.expected_origin.is_some() && params.validate_guarded().is_err() {
794
+ + return Ok(Some(serde_json::json!({"id": request_id, "error": {"code": "guard_mismatch", "phase": "not_issued", "message": "invalid guarded prompt"}}).to_string()));
795
+ + }
796
+ let Some(wait) = params.wait.clone() else {
797
+ return Ok(Some(dispatch_to_app_with_timeout(
798
+ Request {
799
+ @@ -334,7 +337,11 @@ fn agent_prompt_success(
800
+ ) -> std::io::Result<String> {
801
+ serde_json::to_string(&SuccessResponse {
802
+ id: request_id,
803
+ - result: ResponseResult::AgentPrompted { agent },
804
+ + result: ResponseResult::AgentPrompted {
805
+ + agent,
806
+ + origin: None,
807
+ + submission: None,
808
+ + },
809
+ })
810
+ .map_err(std::io::Error::other)
811
+ }
812
+ diff --git a/src/app/api.rs b/src/app/api.rs
813
+ index 20ca9d0..54132f2 100644
814
+ --- a/src/app/api.rs
815
+ +++ b/src/app/api.rs
816
+ @@ -1087,6 +1087,12 @@ impl App {
817
+ return self.handle_agent_view_clear(request.id, params);
818
+ }
819
+ Method::AgentStart(params) => return self.handle_agent_start(request.id, params),
820
+ + Method::TerminalInhibitInput(_) => {
821
+ + return agents::input_drain_error(
822
+ + request.id,
823
+ + crate::platform::guarded_prompt::InputDrainError::Unavailable,
824
+ + );
825
+ + }
826
+ Method::AgentPrompt(_) => {
827
+ return responses::encode_error(
828
+ request.id,
829
+ diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs
830
+ index 9289ae0..17ce8c1 100644
831
+ --- a/src/app/api/agents.rs
832
+ +++ b/src/app/api/agents.rs
833
+ @@ -7,10 +7,13 @@ use crate::api::schema::{
834
+ PaneReadResult, ResponseResult,
835
+ };
836
+ use crate::app::App;
837
+ +use crate::platform::guarded_prompt::{GuardedPromptError, GuardedPromptOrigin};
838
+
839
+ use super::responses::{encode_error, encode_error_body, encode_success};
840
+
841
+ const AGENT_PROMPT_SUBMIT_DELAY: Duration = Duration::from_millis(300);
842
+ +// A missing completion never authorizes drain; inhibition remains installed.
843
+ +const INPUT_DRAIN_ACK_TIMEOUT: Duration = Duration::from_secs(5);
844
+
845
+ // Codex's Windows input reader does not surface bracketed paste. It detects the prompt as a
846
+ // "paste burst" and, while that burst is buffered, rewrites a following Enter into a newline
847
+ @@ -84,14 +87,50 @@ impl App {
848
+ request: crate::api::schema::Request,
849
+ respond_to: std::sync::mpsc::Sender<String>,
850
+ ) -> bool {
851
+ + if let crate::api::schema::Method::TerminalInhibitInput(params) = request.method.clone() {
852
+ + self.defer_input_inhibit(request.id, params, respond_to);
853
+ + return true;
854
+ + }
855
+ let crate::api::schema::Method::AgentPrompt(params) = request.method else {
856
+ return false;
857
+ };
858
+ + if params.expected_origin.is_some() {
859
+ + match self.queue_guarded_agent_prompt(request.id, params) {
860
+ + Ok((id, agent, origin, completion)) => {
861
+ + std::thread::spawn(move || {
862
+ + let response = match completion.recv() {
863
+ + Ok(Ok(())) => encode_success(
864
+ + id,
865
+ + ResponseResult::AgentPrompted {
866
+ + agent,
867
+ + origin: Some(origin),
868
+ + submission: Some("submitted".to_owned()),
869
+ + },
870
+ + ),
871
+ + Ok(Err(error)) => guarded_error(id, error),
872
+ + Err(_) => guarded_error(id, GuardedPromptError::SubmissionUncertain),
873
+ + };
874
+ + let _ = respond_to.send(response);
875
+ + });
876
+ + }
877
+ + Err(response) => {
878
+ + let _ = respond_to.send(response);
879
+ + }
880
+ + }
881
+ + return true;
882
+ + }
883
+ match self.queue_agent_prompt(request.id, params) {
884
+ Ok((id, agent, completion)) => {
885
+ std::thread::spawn(move || {
886
+ let response = match completion.recv() {
887
+ - Ok(Ok(())) => encode_success(id, ResponseResult::AgentPrompted { agent }),
888
+ + Ok(Ok(())) => encode_success(
889
+ + id,
890
+ + ResponseResult::AgentPrompted {
891
+ + agent,
892
+ + origin: None,
893
+ + submission: None,
894
+ + },
895
+ + ),
896
+ Ok(Err(err)) if err.kind() == std::io::ErrorKind::TimedOut => {
897
+ encode_error(id, "timeout", err.to_string())
898
+ }
899
+ @@ -108,6 +147,125 @@ impl App {
900
+ true
901
+ }
902
+
903
+ + fn defer_input_inhibit(
904
+ + &mut self,
905
+ + id: String,
906
+ + params: crate::api::schema::TerminalInhibitInputParams,
907
+ + respond_to: std::sync::mpsc::Sender<String>,
908
+ + ) {
909
+ + use crate::platform::guarded_prompt::InputDrainError;
910
+ + let selected = (|| {
911
+ + if !params.valid() {
912
+ + return Err(InputDrainError::GuardMismatch);
913
+ + }
914
+ + let resolved = self
915
+ + .resolve_guarded_agent_target(&params.target)
916
+ + .map_err(|_| InputDrainError::OriginUnavailable)?;
917
+ + let runtime = self
918
+ + .lookup_runtime_sender(resolved.ws_idx, resolved.pane_id)
919
+ + .ok_or(InputDrainError::OriginUnavailable)?;
920
+ + if runtime.captured_input_origin(&params.target).as_ref()
921
+ + != Some(&params.expected_origin)
922
+ + {
923
+ + return Err(InputDrainError::GuardMismatch);
924
+ + }
925
+ + runtime.inhibit_input(&params.expected_origin)
926
+ + })();
927
+ + match selected {
928
+ + Ok(completion) => {
929
+ + std::thread::spawn(move || {
930
+ + let response = match completion.recv_timeout(INPUT_DRAIN_ACK_TIMEOUT) {
931
+ + Ok(Ok(())) => encode_success(
932
+ + id,
933
+ + ResponseResult::TerminalInputDrained {
934
+ + origin: params.expected_origin,
935
+ + input_state: crate::api::schema::InputState::Inhibited,
936
+ + pending_input: 0,
937
+ + },
938
+ + ),
939
+ + Ok(Err(error)) => input_drain_error(id, error),
940
+ + Err(_) => input_drain_error(id, InputDrainError::Unavailable),
941
+ + };
942
+ + let _ = respond_to.send(response);
943
+ + });
944
+ + }
945
+ + Err(error) => {
946
+ + let _ = respond_to.send(input_drain_error(id, error));
947
+ + }
948
+ + }
949
+ + }
950
+ +
951
+ + fn queue_guarded_agent_prompt(
952
+ + &mut self,
953
+ + id: String,
954
+ + params: AgentPromptParams,
955
+ + ) -> Result<
956
+ + (
957
+ + String,
958
+ + crate::api::schema::AgentInfo,
959
+ + GuardedPromptOrigin,
960
+ + std::sync::mpsc::Receiver<Result<(), GuardedPromptError>>,
961
+ + ),
962
+ + String,
963
+ + > {
964
+ + let expected = params
965
+ + .validate_guarded()
966
+ + .map_err(|error| guarded_error(id.clone(), error))?;
967
+ + // The guarded branch never uses public pane/name or generic resolver fallback.
968
+ + let resolved = self
969
+ + .resolve_guarded_agent_target(&params.target)
970
+ + .map_err(|_| guarded_error(id.clone(), GuardedPromptError::TargetMissing))?;
971
+ + let terminal = self
972
+ + .state
973
+ + .terminals
974
+ + .get(params.target.as_str())
975
+ + .ok_or_else(|| guarded_error(id.clone(), GuardedPromptError::TargetMissing))?;
976
+ + if terminal.state == crate::detect::AgentState::Blocked {
977
+ + return Err(guarded_error(id, GuardedPromptError::AgentBlocked));
978
+ + }
979
+ + let expected_agent = terminal
980
+ + .effective_known_agent()
981
+ + .ok_or_else(|| guarded_error(id.clone(), GuardedPromptError::AgentNotReady))?;
982
+ + if terminal.managed_agent_launch_pending() {
983
+ + return Err(guarded_error(id, GuardedPromptError::AgentNotReady));
984
+ + }
985
+ + let runtime = self
986
+ + .lookup_runtime_sender(resolved.ws_idx, resolved.pane_id)
987
+ + .ok_or_else(|| guarded_error(id.clone(), GuardedPromptError::OriginUnavailable))?;
988
+ + let actual = runtime
989
+ + .guarded_prompt_origin(&params.target)
990
+ + .ok_or_else(|| guarded_error(id.clone(), GuardedPromptError::OriginUnavailable))?;
991
+ + if actual != *expected {
992
+ + return Err(guarded_error(id, GuardedPromptError::GuardMismatch));
993
+ + }
994
+ + if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) {
995
+ + return Err(guarded_error(id, GuardedPromptError::AgentNotReady));
996
+ + }
997
+ + let (mut text, enter) =
998
+ + crate::app::api_helpers::encode_api_submission_parts(runtime, &params.text);
999
+ + if enter.is_empty() || text.is_empty() {
1000
+ + return Err(guarded_error(id, GuardedPromptError::OriginUnavailable));
1001
+ + }
1002
+ + if expected_agent == crate::detect::Agent::GithubCopilot {
1003
+ + let mut focus = crate::ghostty::encode_focus(crate::ghostty::FocusEvent::Gained)
1004
+ + .map_err(|_| guarded_error(id.clone(), GuardedPromptError::OriginUnavailable))?;
1005
+ + focus.append(&mut text);
1006
+ + text = focus;
1007
+ + }
1008
+ + let agent = self
1009
+ + .agent_info(resolved.ws_idx, resolved.pane_id)
1010
+ + .ok_or_else(|| guarded_error(id.clone(), GuardedPromptError::AgentNotReady))?;
1011
+ + let completion = runtime
1012
+ + .queue_guarded_submission(
1013
+ + Bytes::from(text),
1014
+ + Bytes::from(enter),
1015
+ + AGENT_PROMPT_SUBMIT_DELAY,
1016
+ + expected.clone(),
1017
+ + )
1018
+ + .map_err(|error| guarded_error(id.clone(), error))?;
1019
+ + Ok((id, agent, expected.clone(), completion))
1020
+ + }
1021
+ +
1022
+ fn queue_agent_prompt(
1023
+ &mut self,
1024
+ id: String,
1025
+ @@ -457,6 +615,7 @@ mod tests {
1026
+ &mut app,
1027
+ "req",
1028
+ AgentPromptParams {
1029
+ + expected_origin: None,
1030
+ target: "reviewer".into(),
1031
+ text: "A != B".into(),
1032
+ wait: None,
1033
+ @@ -557,6 +716,7 @@ mod tests {
1034
+ &mut app,
1035
+ "req",
1036
+ AgentPromptParams {
1037
+ + expected_origin: None,
1038
+ target: public_pane_id,
1039
+ text: "A != B".into(),
1040
+ wait: None,
1041
+ @@ -586,6 +746,7 @@ mod tests {
1042
+ &mut app,
1043
+ "req-raw",
1044
+ AgentPromptParams {
1045
+ + expected_origin: None,
1046
+ target: "reviewer".into(),
1047
+ text: "A != B".into(),
1048
+ wait: None,
1049
+ @@ -601,6 +762,7 @@ mod tests {
1050
+ &mut app,
1051
+ "req-label",
1052
+ AgentPromptParams {
1053
+ + expected_origin: None,
1054
+ target: "opencode".into(),
1055
+ text: "wrong target".into(),
1056
+ wait: None,
1057
+ @@ -628,6 +790,7 @@ mod tests {
1058
+ &mut app,
1059
+ "req",
1060
+ AgentPromptParams {
1061
+ + expected_origin: None,
1062
+ target: "reviewer".into(),
1063
+ text: "unrelated prompt".into(),
1064
+ wait: None,
1065
+ @@ -668,6 +831,7 @@ mod tests {
1066
+ &mut app,
1067
+ "req",
1068
+ AgentPromptParams {
1069
+ + expected_origin: None,
1070
+ target: "reviewer".into(),
1071
+ text: "A != B".into(),
1072
+ wait: None,
1073
+ @@ -747,6 +911,7 @@ mod tests {
1074
+ &mut app,
1075
+ "req-pending",
1076
+ AgentPromptParams {
1077
+ + expected_origin: None,
1078
+ target: "reviewer".into(),
1079
+ text: "A != B".into(),
1080
+ wait: None,
1081
+ @@ -821,3 +986,39 @@ mod tests {
1082
+ }
1083
+ }
1084
+ }
1085
+ +
1086
+ +fn guarded_error(id: String, error: GuardedPromptError) -> String {
1087
+ + serde_json::json!(crate::api::schema::GuardedPromptErrorResponse {
1088
+ + id,
1089
+ + error: crate::api::schema::GuardedPromptErrorBody {
1090
+ + code: error.code().to_owned(),
1091
+ + message: "guarded prompt did not complete".to_owned(),
1092
+ + phase: error.phase().to_owned(),
1093
+ + },
1094
+ + })
1095
+ + .to_string()
1096
+ +}
1097
+ +
1098
+ +pub(super) fn input_drain_error(
1099
+ + id: String,
1100
+ + error: crate::platform::guarded_prompt::InputDrainError,
1101
+ +) -> String {
1102
+ + serde_json::json!(crate::api::schema::InputDrainErrorResponse {
1103
+ + id,
1104
+ + error: crate::api::schema::InputDrainErrorBody {
1105
+ + code: match error {
1106
+ + crate::platform::guarded_prompt::InputDrainError::GuardMismatch => {
1107
+ + crate::api::schema::InputDrainCode::GuardMismatch
1108
+ + }
1109
+ + crate::platform::guarded_prompt::InputDrainError::OriginUnavailable => {
1110
+ + crate::api::schema::InputDrainCode::OriginUnavailable
1111
+ + }
1112
+ + crate::platform::guarded_prompt::InputDrainError::Unavailable => {
1113
+ + crate::api::schema::InputDrainCode::Unavailable
1114
+ + }
1115
+ + },
1116
+ + phase: crate::api::schema::InputDrainPhase::Unconfirmed,
1117
+ + },
1118
+ + })
1119
+ + .to_string()
1120
+ +}
1121
+ diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
1122
+ index e4e03e7..5ca1830 100644
1123
+ --- a/src/app/api/panes.rs
1124
+ +++ b/src/app/api/panes.rs
1125
+ @@ -550,10 +550,20 @@ impl App {
1126
+ })
1127
+ .unwrap_or_default();
1128
+
1129
+ + let terminal_id = self.state.workspaces[ws_idx].terminal_id(pane_id);
1130
+ + let guarded_prompt_origin = terminal_id
1131
+ + .as_ref()
1132
+ + .and_then(|id| runtime.guarded_prompt_origin(id.as_str()));
1133
+ +
1134
+ encode_success(
1135
+ id,
1136
+ ResponseResult::PaneProcessInfo {
1137
+ process_info: PaneProcessInfo {
1138
+ + guarded_prompt: guarded_prompt_origin.is_some(),
1139
+ + guarded_input_drain: terminal_id
1140
+ + .as_ref()
1141
+ + .is_some_and(|id| runtime.captured_input_origin(id.as_str()).is_some()),
1142
+ + guarded_prompt_origin,
1143
+ pane_id: public_pane_id,
1144
+ shell_pid,
1145
+ foreground_process_group_id,
1146
+ diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs
1147
+ index bbaaaf7..658eeaa 100644
1148
+ --- a/src/app/terminal_targets.rs
1149
+ +++ b/src/app/terminal_targets.rs
1150
+ @@ -29,7 +29,25 @@ pub(crate) enum TerminalTargetError {
1151
+ },
1152
+ }
1153
+
1154
+ +fn guarded_terminal_matches(target: &str, terminals: Vec<TerminalTarget>) -> Vec<TerminalTarget> {
1155
+ + terminals
1156
+ + .into_iter()
1157
+ + .filter(|candidate| candidate.terminal_id == target)
1158
+ + .collect()
1159
+ +}
1160
+ +
1161
+ impl App {
1162
+ + pub(crate) fn resolve_guarded_agent_target(
1163
+ + &self,
1164
+ + target: &str,
1165
+ + ) -> Result<TerminalTarget, TerminalTargetError> {
1166
+ + let matches = guarded_terminal_matches(target, self.terminal_targets());
1167
+ + self.single_terminal_match(target, matches)?
1168
+ + .ok_or_else(|| TerminalTargetError::NotFound {
1169
+ + target: target.to_owned(),
1170
+ + })
1171
+ + }
1172
+ +
1173
+ pub(crate) fn resolve_terminal_target(
1174
+ &self,
1175
+ target: &str,
1176
+ @@ -327,3 +345,40 @@ mod tests {
1177
+ }
1178
+ }
1179
+ }
1180
+ +
1181
+ +#[cfg(test)]
1182
+ +mod guarded_prompt_pure_tests {
1183
+ + use super::*;
1184
+ + fn target(id: &str) -> TerminalTarget {
1185
+ + TerminalTarget {
1186
+ + ws_idx: 0,
1187
+ + tab_idx: 0,
1188
+ + pane_id: crate::layout::PaneId::from_raw(1),
1189
+ + terminal_id: id.into(),
1190
+ + }
1191
+ + }
1192
+ + #[test]
1193
+ + fn guarded_app_selection_accepts_only_exact_terminal_id_never_pane_or_label() {
1194
+ + let terminals = vec![target("term_aa11"), target("term_bb22")];
1195
+ + assert_eq!(
1196
+ + guarded_terminal_matches("term_aa11", terminals.clone()),
1197
+ + vec![target("term_aa11")]
1198
+ + );
1199
+ + for alias in [
1200
+ + "pane:1",
1201
+ + "1",
1202
+ + "codex",
1203
+ + "TERM_AA11",
1204
+ + "term_AA11",
1205
+ + "term_aa11 ",
1206
+ + ] {
1207
+ + assert!(guarded_terminal_matches(alias, terminals.clone()).is_empty());
1208
+ + }
1209
+ + assert!(guarded_terminal_matches("term_aa11", vec![target("term_bb22")]).is_empty());
1210
+ + assert_eq!(
1211
+ + guarded_terminal_matches("term_aa11", vec![target("term_aa11"), target("term_aa11")])
1212
+ + .len(),
1213
+ + 2
1214
+ + );
1215
+ + }
1216
+ +}
1217
+ diff --git a/src/cli/agent.rs b/src/cli/agent.rs
1218
+ index 0904346..5a941b7 100644
1219
+ --- a/src/cli/agent.rs
1220
+ +++ b/src/cli/agent.rs
1221
+ @@ -838,6 +838,7 @@ fn agent_prompt(args: &[String]) -> std::io::Result<i32> {
1222
+ let response = super::send_request(&Request {
1223
+ id: "cli:agent:prompt".into(),
1224
+ method: Method::AgentPrompt(AgentPromptParams {
1225
+ + expected_origin: None,
1226
+ target: target.clone(),
1227
+ text: text.clone(),
1228
+ wait: wait.then_some(AgentPromptWaitOptions {
1229
+ diff --git a/src/pane.rs b/src/pane.rs
1230
+ index 8fd8e39..7b296da 100644
1231
+ --- a/src/pane.rs
1232
+ +++ b/src/pane.rs
1233
+ @@ -2494,6 +2494,7 @@ impl PaneRuntime {
1234
+ });
1235
+ PaneRuntimeIo::Actor(PtyIoActor::spawn(PtyIoActorConfig {
1236
+ pane_id: pane_id.raw(),
1237
+ + origin: None,
1238
+ master_fd,
1239
+ initially_quiesced: true,
1240
+ on_read,
1241
+ @@ -2581,6 +2582,8 @@ impl PaneRuntime {
1242
+ let spawned = crate::pty::backend::spawn_with_portable_pty(rows, cols, cmd)
1243
+ .inspect_err(|err| error!(pane = pane_id.raw(), err = %err, "{spawn_error_message}"))?;
1244
+
1245
+ + #[cfg(unix)]
1246
+ + let guarded_origin = spawned.origin.clone();
1247
+ // --- Child watcher task ---
1248
+ let child_pid = Arc::new(AtomicU32::new(0));
1249
+ let reported_cwd = Arc::new(Mutex::new(None));
1250
+ @@ -2594,6 +2597,8 @@ impl PaneRuntime {
1251
+ let child_wait_completed = child_wait_completed.clone();
1252
+ let events = events.clone();
1253
+ let rt = tokio::runtime::Handle::current();
1254
+ + #[cfg(unix)]
1255
+ + let guarded_origin = guarded_origin.clone();
1256
+ let mut child = spawned.child;
1257
+ if let Some(pid) = child.process_id() {
1258
+ child_pid.store(pid, Ordering::Release);
1259
+ @@ -2612,6 +2617,10 @@ impl PaneRuntime {
1260
+ crate::platform::ChildExitReason::WaitFailed
1261
+ }
1262
+ };
1263
+ + #[cfg(unix)]
1264
+ + if let Some(origin) = guarded_origin {
1265
+ + origin.mark_exited();
1266
+ + }
1267
+ child_wait_completed.store(true, Ordering::Release);
1268
+ // Use blocking send — PaneDied is critical, must not be dropped
1269
+ if let Err(e) = rt.block_on(events.send(AppEvent::PaneDied {
1270
+ @@ -2688,6 +2697,8 @@ impl PaneRuntime {
1271
+ pane_id: pane_id.raw(),
1272
+ #[cfg(unix)]
1273
+ master_fd: spawned.master_fd,
1274
+ + #[cfg(unix)]
1275
+ + origin: guarded_origin,
1276
+ #[cfg(windows)]
1277
+ master: spawned.master,
1278
+ initially_quiesced: false,
1279
+ @@ -3520,6 +3531,61 @@ impl PaneRuntime {
1280
+ self.io.try_send_bytes(bytes)
1281
+ }
1282
+
1283
+ + pub(crate) fn guarded_prompt_origin(
1284
+ + &self,
1285
+ + terminal_id: &str,
1286
+ + ) -> Option<crate::platform::guarded_prompt::GuardedPromptOrigin> {
1287
+ + #[cfg(unix)]
1288
+ + if let PaneRuntimeIo::Actor(actor) = &self.io {
1289
+ + return actor.guarded_origin(terminal_id);
1290
+ + }
1291
+ + let _ = terminal_id;
1292
+ + None
1293
+ + }
1294
+ +
1295
+ + pub(crate) fn captured_input_origin(
1296
+ + &self,
1297
+ + terminal_id: &str,
1298
+ + ) -> Option<crate::platform::guarded_prompt::GuardedPromptOrigin> {
1299
+ + #[cfg(unix)]
1300
+ + if let PaneRuntimeIo::Actor(actor) = &self.io {
1301
+ + return actor.captured_input_origin(terminal_id);
1302
+ + }
1303
+ + let _ = terminal_id;
1304
+ + None
1305
+ + }
1306
+ + pub(crate) fn inhibit_input(
1307
+ + &self,
1308
+ + expected: &crate::platform::guarded_prompt::GuardedPromptOrigin,
1309
+ + ) -> Result<
1310
+ + std::sync::mpsc::Receiver<Result<(), crate::platform::guarded_prompt::InputDrainError>>,
1311
+ + crate::platform::guarded_prompt::InputDrainError,
1312
+ + > {
1313
+ + #[cfg(unix)]
1314
+ + if let PaneRuntimeIo::Actor(actor) = &self.io {
1315
+ + return actor.inhibit_input(expected);
1316
+ + }
1317
+ + let _ = expected;
1318
+ + Err(crate::platform::guarded_prompt::InputDrainError::OriginUnavailable)
1319
+ + }
1320
+ + pub(crate) fn queue_guarded_submission(
1321
+ + &self,
1322
+ + text: Bytes,
1323
+ + enter: Bytes,
1324
+ + delay: std::time::Duration,
1325
+ + expected: crate::platform::guarded_prompt::GuardedPromptOrigin,
1326
+ + ) -> Result<
1327
+ + std::sync::mpsc::Receiver<Result<(), crate::platform::guarded_prompt::GuardedPromptError>>,
1328
+ + crate::platform::guarded_prompt::GuardedPromptError,
1329
+ + > {
1330
+ + #[cfg(unix)]
1331
+ + if let PaneRuntimeIo::Actor(actor) = &self.io {
1332
+ + return actor.queue_guarded_submission(text, enter, delay, expected);
1333
+ + }
1334
+ + let _ = (text, enter, delay, expected);
1335
+ + Err(crate::platform::guarded_prompt::GuardedPromptError::OriginUnavailable)
1336
+ + }
1337
+ +
1338
+ pub fn queue_user_input_submission(
1339
+ &self,
1340
+ text: Bytes,
1341
+ diff --git a/src/platform/guarded_prompt.rs b/src/platform/guarded_prompt.rs
1342
+ new file mode 100644
1343
+ index 0000000..d8c2848
1344
+ --- /dev/null
1345
+ +++ b/src/platform/guarded_prompt.rs
1346
+ @@ -0,0 +1,531 @@
1347
+ +//! Shared guarded-submission values and pure write-admission state.
1348
+ +//! Kernel origin capture stays in the Linux platform owner.
1349
+ +
1350
+ +use serde::{Deserialize, Serialize};
1351
+ +use std::sync::{
1352
+ + atomic::{AtomicBool, Ordering},
1353
+ + Arc,
1354
+ +};
1355
+ +
1356
+ +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1357
+ +#[serde(deny_unknown_fields)]
1358
+ +pub struct GuardedChildIdentity {
1359
+ + pub pid: u32,
1360
+ + pub uid: u32,
1361
+ + pub gid: u32,
1362
+ + pub groups: Vec<u32>,
1363
+ + pub parent_pid: u32,
1364
+ + pub started_at: String,
1365
+ + pub host: String,
1366
+ +}
1367
+ +
1368
+ +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1369
+ +#[serde(deny_unknown_fields)]
1370
+ +pub struct GuardedPromptOrigin {
1371
+ + pub terminal_id: String,
1372
+ + pub runtime_incarnation: String,
1373
+ + pub child: GuardedChildIdentity,
1374
+ +}
1375
+ +
1376
+ +pub(crate) fn canonical_uuid(value: &str) -> bool {
1377
+ + value.len() == 36
1378
+ + && value.bytes().enumerate().all(|(i, b)| {
1379
+ + if [8, 13, 18, 23].contains(&i) {
1380
+ + b == b'-'
1381
+ + } else {
1382
+ + b.is_ascii_digit() || (b'a'..=b'f').contains(&b)
1383
+ + }
1384
+ + })
1385
+ +}
1386
+ +
1387
+ +impl GuardedPromptOrigin {
1388
+ + pub(crate) fn valid(&self) -> bool {
1389
+ + let child = &self.child;
1390
+ + let mut birth = child.started_at.split(':');
1391
+ + let valid_birth = birth.next() == Some("linux")
1392
+ + && birth.next().is_some_and(canonical_uuid)
1393
+ + && birth.next().is_some_and(|ticks| {
1394
+ + !ticks.is_empty()
1395
+ + && ticks.bytes().all(|b| b.is_ascii_digit())
1396
+ + && ticks.parse::<u64>().is_ok_and(|n| n > 0)
1397
+ + })
1398
+ + && birth.next().is_none();
1399
+ + self.terminal_id.strip_prefix("term_").is_some_and(|id| {
1400
+ + (2..=48).contains(&id.len())
1401
+ + && id
1402
+ + .bytes()
1403
+ + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1404
+ + }) && canonical_uuid(&self.runtime_incarnation)
1405
+ + && child.pid > 0
1406
+ + && child.parent_pid > 0
1407
+ + && child.groups.len() <= 256
1408
+ + && child.groups.windows(2).all(|pair| pair[0] < pair[1])
1409
+ + && valid_birth
1410
+ + && !child.host.is_empty()
1411
+ + && child.host.len() <= 255
1412
+ + && !child.host.chars().any(char::is_control)
1413
+ + }
1414
+ +}
1415
+ +
1416
+ +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1417
+ +pub(crate) enum GuardedPromptError {
1418
+ + GuardMismatch,
1419
+ + OriginUnavailable,
1420
+ + OriginExited,
1421
+ + AgentBlocked,
1422
+ + AgentNotReady,
1423
+ + TargetMissing,
1424
+ + SubmissionBusy,
1425
+ + SubmissionUncertain,
1426
+ +}
1427
+ +
1428
+ +impl GuardedPromptError {
1429
+ + pub(crate) fn code(self) -> &'static str {
1430
+ + match self {
1431
+ + Self::GuardMismatch => "guard_mismatch",
1432
+ + Self::OriginUnavailable => "origin_unavailable",
1433
+ + Self::OriginExited => "origin_exited",
1434
+ + Self::AgentBlocked => "agent_blocked",
1435
+ + Self::AgentNotReady => "agent_not_ready",
1436
+ + Self::TargetMissing => "target_missing",
1437
+ + Self::SubmissionBusy => "submission_busy",
1438
+ + Self::SubmissionUncertain => "submission_uncertain",
1439
+ + }
1440
+ + }
1441
+ + pub(crate) fn phase(self) -> &'static str {
1442
+ + if self == Self::SubmissionUncertain {
1443
+ + "issued_or_unknown"
1444
+ + } else {
1445
+ + "not_issued"
1446
+ + }
1447
+ + }
1448
+ +}
1449
+ +
1450
+ +pub(crate) trait OriginLiveness: Send + Sync {
1451
+ + fn is_live(&self) -> std::io::Result<bool>;
1452
+ +}
1453
+ +
1454
+ +pub(crate) struct GuardedChildOrigin {
1455
+ + pub(crate) runtime_incarnation: String,
1456
+ + pub(crate) child: GuardedChildIdentity,
1457
+ + pub(crate) exited: AtomicBool,
1458
+ + pub(crate) handle: Arc<dyn OriginLiveness>,
1459
+ +}
1460
+ +
1461
+ +impl GuardedChildOrigin {
1462
+ + pub(crate) fn projection(&self, terminal_id: &str) -> GuardedPromptOrigin {
1463
+ + GuardedPromptOrigin {
1464
+ + terminal_id: terminal_id.to_owned(),
1465
+ + runtime_incarnation: self.runtime_incarnation.clone(),
1466
+ + child: self.child.clone(),
1467
+ + }
1468
+ + }
1469
+ + pub(crate) fn matches_captured(&self, expected: &GuardedPromptOrigin) -> bool {
1470
+ + expected.valid()
1471
+ + && expected.runtime_incarnation == self.runtime_incarnation
1472
+ + && expected.child == self.child
1473
+ + }
1474
+ + pub(crate) fn check(&self, expected: &GuardedPromptOrigin) -> Result<(), GuardedPromptError> {
1475
+ + if !expected.valid()
1476
+ + || expected.runtime_incarnation != self.runtime_incarnation
1477
+ + || expected.child != self.child
1478
+ + {
1479
+ + return Err(GuardedPromptError::GuardMismatch);
1480
+ + }
1481
+ + if self.exited.load(Ordering::Acquire) {
1482
+ + return Err(GuardedPromptError::OriginExited);
1483
+ + }
1484
+ + match self.handle.is_live() {
1485
+ + Ok(true) => Ok(()),
1486
+ + Ok(false) => Err(GuardedPromptError::OriginExited),
1487
+ + Err(_) => Err(GuardedPromptError::OriginUnavailable),
1488
+ + }
1489
+ + }
1490
+ + pub(crate) fn mark_exited(&self) {
1491
+ + self.exited.store(true, Ordering::Release);
1492
+ + }
1493
+ +}
1494
+ +
1495
+ +/// Once a guarded write has been attempted, every failure is indeterminate.
1496
+ +/// This does not claim atomicity between kernel exit and the subsequent write.
1497
+ +#[derive(Default)]
1498
+ +pub(crate) struct GuardedWriteState {
1499
+ + attempted: bool,
1500
+ + written: bool,
1501
+ +}
1502
+ +impl GuardedWriteState {
1503
+ + pub(crate) fn before_write(
1504
+ + &mut self,
1505
+ + admission: Result<(), GuardedPromptError>,
1506
+ + ) -> Result<(), GuardedPromptError> {
1507
+ + admission.map_err(|error| self.failure(error))?;
1508
+ + self.attempted = true;
1509
+ + Ok(())
1510
+ + }
1511
+ + pub(crate) fn write(
1512
+ + &mut self,
1513
+ + admission: Result<(), GuardedPromptError>,
1514
+ + write: impl FnOnce() -> std::io::Result<usize>,
1515
+ + ) -> Result<Option<usize>, GuardedPromptError> {
1516
+ + self.before_write(admission)?;
1517
+ + match write() {
1518
+ + Ok(0) => Err(GuardedPromptError::SubmissionUncertain),
1519
+ + Ok(bytes) => {
1520
+ + self.written(bytes);
1521
+ + Ok(Some(bytes))
1522
+ + }
1523
+ + Err(error)
1524
+ + if matches!(
1525
+ + error.kind(),
1526
+ + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
1527
+ + ) =>
1528
+ + {
1529
+ + self.deferred();
1530
+ + Ok(None)
1531
+ + }
1532
+ + Err(_) => Err(GuardedPromptError::SubmissionUncertain),
1533
+ + }
1534
+ + }
1535
+ + pub(crate) fn written(&mut self, bytes: usize) {
1536
+ + self.written |= bytes > 0;
1537
+ + }
1538
+ + pub(crate) fn deferred(&mut self) {
1539
+ + self.attempted = self.written;
1540
+ + }
1541
+ + pub(crate) fn failure(&self, error: GuardedPromptError) -> GuardedPromptError {
1542
+ + if self.attempted {
1543
+ + GuardedPromptError::SubmissionUncertain
1544
+ + } else {
1545
+ + error
1546
+ + }
1547
+ + }
1548
+ +}
1549
+ +
1550
+ +pub(crate) fn parse_linux_child_identity(
1551
+ + pid: u32,
1552
+ + status: &str,
1553
+ + stat: &str,
1554
+ + boot: &str,
1555
+ + host: &str,
1556
+ +) -> std::io::Result<GuardedChildIdentity> {
1557
+ + let invalid = || std::io::Error::other("invalid child origin");
1558
+ + if !canonical_uuid(boot) || host.is_empty() || host.len() > 255 {
1559
+ + return Err(invalid());
1560
+ + }
1561
+ + let (prefix, fields) = stat.rsplit_once(") ").ok_or_else(invalid)?;
1562
+ + if prefix
1563
+ + .split_once(" (")
1564
+ + .and_then(|(n, _)| n.parse::<u32>().ok())
1565
+ + != Some(pid)
1566
+ + {
1567
+ + return Err(invalid());
1568
+ + }
1569
+ + let stat_fields: Vec<_> = fields.split_whitespace().collect();
1570
+ + if stat_fields.len() < 20 || matches!(stat_fields[0], "Z" | "X" | "x") {
1571
+ + return Err(invalid());
1572
+ + }
1573
+ + let ticks = stat_fields[19].parse::<u64>().map_err(|_| invalid())?;
1574
+ + let parent_pid = stat_fields[1].parse::<u32>().map_err(|_| invalid())?;
1575
+ + let mut uid = None;
1576
+ + let mut gid = None;
1577
+ + let mut groups = None;
1578
+ + let mut ppid = None;
1579
+ + for line in status.lines() {
1580
+ + let Some((key, raw)) = line.split_once(':') else {
1581
+ + continue;
1582
+ + };
1583
+ + if matches!(key, "Uid" | "Gid" | "Groups" | "PPid") {
1584
+ + let values: Vec<u32> = raw
1585
+ + .split_whitespace()
1586
+ + .map(str::parse)
1587
+ + .collect::<Result<_, _>>()
1588
+ + .map_err(|_| invalid())?;
1589
+ + match key {
1590
+ + "Uid" | "Gid" => {
1591
+ + if values.len() != 4 || values.iter().any(|v| *v != values[0]) {
1592
+ + return Err(invalid());
1593
+ + }
1594
+ + let cell = if key == "Uid" { &mut uid } else { &mut gid };
1595
+ + if cell.replace(values[0]).is_some() {
1596
+ + return Err(invalid());
1597
+ + }
1598
+ + }
1599
+ + "Groups" => {
1600
+ + if groups.is_some() || values.len() > 256 {
1601
+ + return Err(invalid());
1602
+ + }
1603
+ + let mut values = values;
1604
+ + values.sort_unstable();
1605
+ + values.dedup();
1606
+ + groups = Some(values);
1607
+ + }
1608
+ + "PPid" => {
1609
+ + if values.len() != 1 || ppid.replace(values[0]).is_some() {
1610
+ + return Err(invalid());
1611
+ + }
1612
+ + }
1613
+ + _ => {}
1614
+ + }
1615
+ + }
1616
+ + }
1617
+ + if pid == 0 || parent_pid == 0 || ppid != Some(parent_pid) || ticks == 0 {
1618
+ + return Err(invalid());
1619
+ + }
1620
+ + Ok(GuardedChildIdentity {
1621
+ + pid,
1622
+ + uid: uid.ok_or_else(invalid)?,
1623
+ + gid: gid.ok_or_else(invalid)?,
1624
+ + groups: groups.ok_or_else(invalid)?,
1625
+ + parent_pid,
1626
+ + started_at: format!("linux:{boot}:{ticks}"),
1627
+ + host: host.to_owned(),
1628
+ + })
1629
+ +}
1630
+ +
1631
+ +#[cfg(test)]
1632
+ +mod guarded_prompt_pure_tests {
1633
+ + use super::*;
1634
+ + use std::sync::atomic::AtomicBool;
1635
+ +
1636
+ + struct ControlledLive(AtomicBool);
1637
+ + impl OriginLiveness for ControlledLive {
1638
+ + fn is_live(&self) -> std::io::Result<bool> {
1639
+ + Ok(self.0.load(Ordering::Acquire))
1640
+ + }
1641
+ + }
1642
+ + fn fixture() -> (Arc<ControlledLive>, GuardedChildOrigin, GuardedPromptOrigin) {
1643
+ + let live = Arc::new(ControlledLive(AtomicBool::new(true)));
1644
+ + let origin = GuardedChildOrigin {
1645
+ + runtime_incarnation: "00000000-0000-4000-8000-000000000001".into(),
1646
+ + child: GuardedChildIdentity {
1647
+ + pid: 123,
1648
+ + uid: 1000,
1649
+ + gid: 1000,
1650
+ + groups: vec![1000],
1651
+ + parent_pid: 99,
1652
+ + started_at: "linux:00000000-0000-4000-8000-000000000002:12345".into(),
1653
+ + host: "source-fixture".into(),
1654
+ + },
1655
+ + exited: AtomicBool::new(false),
1656
+ + handle: live.clone(),
1657
+ + };
1658
+ + let expected = origin.projection("term_aa11");
1659
+ + (live, origin, expected)
1660
+ + }
1661
+ +
1662
+ + #[test]
1663
+ + fn guarded_request_closes_shape_body_bound_and_missing_guard() {
1664
+ + let (_, _, expected) = fixture();
1665
+ + let value = serde_json::json!({"target": expected.terminal_id, "text": "direction", "expected_origin": expected});
1666
+ + let mut params: crate::api::schema::AgentPromptParams =
1667
+ + serde_json::from_value(value.clone()).unwrap();
1668
+ + assert!(params.validate_guarded().is_ok());
1669
+ + params.text = "é".repeat(8192);
1670
+ + assert!(params.validate_guarded().is_ok());
1671
+ + params.text.push('x');
1672
+ + assert!(params.validate_guarded().is_err());
1673
+ + params.text = " \n\t".into();
1674
+ + assert!(params.validate_guarded().is_err());
1675
+ + params.text = "direction".into();
1676
+ + params.expected_origin = None;
1677
+ + assert!(params.validate_guarded().is_err());
1678
+ + let mut extra = value.clone();
1679
+ + extra["pid"] = serde_json::json!(123);
1680
+ + assert!(serde_json::from_value::<crate::api::schema::AgentPromptParams>(extra).is_err());
1681
+ + let mut wait = value;
1682
+ + wait["wait"] = serde_json::json!({"until":[], "timeout_ms":1000});
1683
+ + let params: crate::api::schema::AgentPromptParams = serde_json::from_value(wait).unwrap();
1684
+ + assert!(params.validate_guarded().is_err());
1685
+ + }
1686
+ +
1687
+ + #[test]
1688
+ + fn static_linux_origin_parser_checks_birth_status_and_groups() {
1689
+ + let stat = "123 (command ) with spaces) S 99 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 12345";
1690
+ + let status = "Uid: 1000 1000 1000 1000\nGid: 1000 1000 1000 1000\nGroups: 2 1\nPPid: 99\n";
1691
+ + let boot = "00000000-0000-4000-8000-000000000002";
1692
+ + let actual = parse_linux_child_identity(123, status, stat, boot, "source-fixture").unwrap();
1693
+ + assert_eq!(actual.groups, vec![1, 2]);
1694
+ + assert_eq!(actual.started_at, format!("linux:{boot}:12345"));
1695
+ + assert!(parse_linux_child_identity(124, status, stat, boot, "source-fixture").is_err());
1696
+ + assert!(parse_linux_child_identity(
1697
+ + 123,
1698
+ + &status.replace("PPid: 99", "PPid: 98"),
1699
+ + stat,
1700
+ + boot,
1701
+ + "source-fixture"
1702
+ + )
1703
+ + .is_err());
1704
+ + assert!(parse_linux_child_identity(
1705
+ + 123,
1706
+ + &status.replace("Uid: 1000", "Uid: 999"),
1707
+ + stat,
1708
+ + boot,
1709
+ + "source-fixture"
1710
+ + )
1711
+ + .is_err());
1712
+ + assert!(parse_linux_child_identity(
1713
+ + 123,
1714
+ + status,
1715
+ + &stat.replace(") S", ") Z"),
1716
+ + boot,
1717
+ + "source-fixture"
1718
+ + )
1719
+ + .is_err());
1720
+ + }
1721
+ +
1722
+ + #[test]
1723
+ + fn closed_origin_rejects_unknown_fields_and_wrong_types() {
1724
+ + let (_, _, expected) = fixture();
1725
+ + let mut value = serde_json::to_value(&expected).unwrap();
1726
+ + assert_eq!(
1727
+ + serde_json::from_value::<GuardedPromptOrigin>(value.clone()).unwrap(),
1728
+ + expected
1729
+ + );
1730
+ + value["child"]["pid"] = serde_json::json!(true);
1731
+ + assert!(serde_json::from_value::<GuardedPromptOrigin>(value).is_err());
1732
+ + let mut value = serde_json::to_value(&expected).unwrap();
1733
+ + value["child"]["unknown"] = serde_json::json!(1);
1734
+ + assert!(serde_json::from_value::<GuardedPromptOrigin>(value).is_err());
1735
+ + let mut value = serde_json::to_value(&expected).unwrap();
1736
+ + value["unknown"] = serde_json::json!(1);
1737
+ + assert!(serde_json::from_value::<GuardedPromptOrigin>(value).is_err());
1738
+ + }
1739
+ + #[test]
1740
+ + fn rejects_aliases_unsorted_groups_and_malformed_birth() {
1741
+ + let (_, _, original) = fixture();
1742
+ + for id in ["pane:1", "name", "term_AA", "term_a", "term_", "term_a/1"] {
1743
+ + let mut expected = original.clone();
1744
+ + expected.terminal_id = id.into();
1745
+ + assert!(!expected.valid());
1746
+ + }
1747
+ + let mut expected = original.clone();
1748
+ + expected.child.groups = vec![2, 1];
1749
+ + assert!(!expected.valid());
1750
+ + expected.child.groups = vec![1, 1];
1751
+ + assert!(!expected.valid());
1752
+ + expected = original.clone();
1753
+ + expected.child.started_at = "linux:bad:1".into();
1754
+ + assert!(!expected.valid());
1755
+ + }
1756
+ + #[test]
1757
+ + fn runtime_replacement_and_same_pid_different_birth_refuse() {
1758
+ + let (_, origin, original) = fixture();
1759
+ + let mut expected = original.clone();
1760
+ + expected.runtime_incarnation = "00000000-0000-4000-8000-000000000003".into();
1761
+ + assert_eq!(
1762
+ + origin.check(&expected),
1763
+ + Err(GuardedPromptError::GuardMismatch)
1764
+ + );
1765
+ + expected = original;
1766
+ + expected.child.started_at.push('9');
1767
+ + assert_eq!(
1768
+ + origin.check(&expected),
1769
+ + Err(GuardedPromptError::GuardMismatch)
1770
+ + );
1771
+ + }
1772
+ + #[test]
1773
+ + fn dead_before_first_write_has_no_focus_or_prompt_bytes() {
1774
+ + let (live, origin, expected) = fixture();
1775
+ + live.0.store(false, Ordering::Release);
1776
+ + let mut output = vec![];
1777
+ + let mut state = GuardedWriteState::default();
1778
+ + assert_eq!(
1779
+ + state.write(origin.check(&expected), || {
1780
+ + output.extend_from_slice(b"focus+text");
1781
+ + Ok(10)
1782
+ + }),
1783
+ + Err(GuardedPromptError::OriginExited)
1784
+ + );
1785
+ + assert!(output.is_empty());
1786
+ + assert_eq!(
1787
+ + state.failure(GuardedPromptError::OriginUnavailable).phase(),
1788
+ + "not_issued"
1789
+ + );
1790
+ + }
1791
+ + #[test]
1792
+ + fn full_text_and_enter_complete_with_live_origin() {
1793
+ + let (_, origin, expected) = fixture();
1794
+ + let mut output = vec![];
1795
+ + let mut state = GuardedWriteState::default();
1796
+ + for part in [b"focus+text".as_slice(), b"\r"] {
1797
+ + assert_eq!(
1798
+ + state.write(origin.check(&expected), || {
1799
+ + output.extend_from_slice(part);
1800
+ + Ok(part.len())
1801
+ + }),
1802
+ + Ok(Some(part.len()))
1803
+ + );
1804
+ + }
1805
+ + assert_eq!(output, b"focus+text\r");
1806
+ + }
1807
+ + #[test]
1808
+ + fn partial_text_then_exit_never_writes_enter_and_is_uncertain() {
1809
+ + let (live, origin, expected) = fixture();
1810
+ + let mut output = vec![];
1811
+ + let mut state = GuardedWriteState::default();
1812
+ + assert_eq!(
1813
+ + state.write(origin.check(&expected), || {
1814
+ + output.extend_from_slice(b"te");
1815
+ + Ok(2)
1816
+ + }),
1817
+ + Ok(Some(2))
1818
+ + );
1819
+ + live.0.store(false, Ordering::Release);
1820
+ + assert_eq!(
1821
+ + state.write(origin.check(&expected), || {
1822
+ + output.extend_from_slice(b"\r");
1823
+ + Ok(1)
1824
+ + }),
1825
+ + Err(GuardedPromptError::SubmissionUncertain)
1826
+ + );
1827
+ + assert_eq!(output, b"te");
1828
+ + }
1829
+ + #[test]
1830
+ + fn would_block_without_bytes_allows_truthful_later_zero_byte_refusal() {
1831
+ + let (live, origin, expected) = fixture();
1832
+ + let mut state = GuardedWriteState::default();
1833
+ + assert_eq!(
1834
+ + state.write(origin.check(&expected), || Err(
1835
+ + std::io::ErrorKind::WouldBlock.into()
1836
+ + )),
1837
+ + Ok(None)
1838
+ + );
1839
+ + live.0.store(false, Ordering::Release);
1840
+ + assert_eq!(
1841
+ + state.write(origin.check(&expected), || panic!(
1842
+ + "dead origin must not write"
1843
+ + )),
1844
+ + Err(GuardedPromptError::OriginExited)
1845
+ + );
1846
+ + }
1847
+ + #[test]
1848
+ + fn write_error_after_admission_is_uncertain_and_never_retries() {
1849
+ + let (_, origin, expected) = fixture();
1850
+ + let mut state = GuardedWriteState::default();
1851
+ + let mut calls = 0;
1852
+ + assert_eq!(
1853
+ + state.write(origin.check(&expected), || {
1854
+ + calls += 1;
1855
+ + Err(std::io::ErrorKind::BrokenPipe.into())
1856
+ + }),
1857
+ + Err(GuardedPromptError::SubmissionUncertain)
1858
+ + );
1859
+ + assert_eq!(calls, 1);
1860
+ + }
1861
+ + #[test]
1862
+ + fn waiter_exit_invalidates_origin_without_delayed_app_event() {
1863
+ + let (_, origin, expected) = fixture();
1864
+ + origin.mark_exited();
1865
+ + assert_eq!(
1866
+ + origin.check(&expected),
1867
+ + Err(GuardedPromptError::OriginExited)
1868
+ + );
1869
+ + }
1870
+ +}
1871
+ +
1872
+ +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1873
+ +pub(crate) enum InputDrainError {
1874
+ + GuardMismatch,
1875
+ + OriginUnavailable,
1876
+ + Unavailable,
1877
+ +}
1878
+ diff --git a/src/platform/linux.rs b/src/platform/linux.rs
1879
+ index c73584e..9edf05b 100644
1880
+ --- a/src/platform/linux.rs
1881
+ +++ b/src/platform/linux.rs
1882
+ @@ -26,6 +26,9 @@ pub(crate) use super::unix_common::{
1883
+ #[cfg(test)]
1884
+ mod config_file_tests;
1885
+
1886
+ +mod guarded_origin;
1887
+ +pub(crate) use guarded_origin::capture_guarded_child;
1888
+ +
1889
+ mod shutdown;
1890
+ pub(crate) use shutdown::monitor_host_shutdown;
1891
+
1892
+ diff --git a/src/platform/linux/guarded_origin.rs b/src/platform/linux/guarded_origin.rs
1893
+ new file mode 100644
1894
+ index 0000000..2a44e8f
1895
+ --- /dev/null
1896
+ +++ b/src/platform/linux/guarded_origin.rs
1897
+ @@ -0,0 +1,87 @@
1898
+ +//! Spawn-established Linux origin. Never reconstruct a handle from restored PID data.
1899
+ +use crate::platform::guarded_prompt::{
1900
+ + parse_linux_child_identity, GuardedChildIdentity, GuardedChildOrigin, OriginLiveness,
1901
+ +};
1902
+ +use std::{
1903
+ + io::{self, Read},
1904
+ + os::fd::{AsRawFd, FromRawFd, OwnedFd},
1905
+ + sync::{atomic::AtomicBool, Arc},
1906
+ +};
1907
+ +
1908
+ +struct LinuxChildHandle(OwnedFd);
1909
+ +impl OriginLiveness for LinuxChildHandle {
1910
+ + fn is_live(&self) -> io::Result<bool> {
1911
+ + let mut item = libc::pollfd {
1912
+ + fd: self.0.as_raw_fd(),
1913
+ + events: libc::POLLIN,
1914
+ + revents: 0,
1915
+ + };
1916
+ + let result = unsafe { libc::poll(&mut item, 1, 0) };
1917
+ + if result < 0 {
1918
+ + return Err(io::Error::last_os_error());
1919
+ + }
1920
+ + Ok(result == 0 && item.revents == 0)
1921
+ + }
1922
+ +}
1923
+ +
1924
+ +fn bounded_read(path: &str, limit: usize) -> io::Result<String> {
1925
+ + let mut bytes = Vec::new();
1926
+ + std::fs::File::open(path)?
1927
+ + .take((limit + 1) as u64)
1928
+ + .read_to_end(&mut bytes)?;
1929
+ + if bytes.len() > limit {
1930
+ + return Err(io::Error::other("origin data exceeds bounds"));
1931
+ + }
1932
+ + String::from_utf8(bytes).map_err(|_| io::Error::other("origin data is not UTF-8"))
1933
+ +}
1934
+ +
1935
+ +fn identity(pid: u32) -> io::Result<GuardedChildIdentity> {
1936
+ + let status = bounded_read(&format!("/proc/{pid}/status"), 65_536)?;
1937
+ + let stat = bounded_read(&format!("/proc/{pid}/stat"), 4096)?;
1938
+ + let boot = bounded_read("/proc/sys/kernel/random/boot_id", 64)?;
1939
+ + let mut hostname = [0u8; 256];
1940
+ + if unsafe { libc::gethostname(hostname.as_mut_ptr().cast(), hostname.len()) } != 0 {
1941
+ + return Err(io::Error::last_os_error());
1942
+ + }
1943
+ + let end = hostname
1944
+ + .iter()
1945
+ + .position(|b| *b == 0)
1946
+ + .ok_or_else(|| io::Error::other("hostname exceeds bounds"))?;
1947
+ + let host = std::str::from_utf8(&hostname[..end])
1948
+ + .map_err(|_| io::Error::other("hostname is not UTF-8"))?;
1949
+ + parse_linux_child_identity(pid, &status, &stat, boot.trim(), host)
1950
+ +}
1951
+ +
1952
+ +fn runtime_incarnation() -> io::Result<String> {
1953
+ + let mut bytes = [0u8; 16];
1954
+ + std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
1955
+ + bytes[6] = (bytes[6] & 0x0f) | 0x40;
1956
+ + bytes[8] = (bytes[8] & 0x3f) | 0x80;
1957
+ + let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
1958
+ + Ok(format!(
1959
+ + "{}-{}-{}-{}-{}",
1960
+ + &hex[..8],
1961
+ + &hex[8..12],
1962
+ + &hex[12..16],
1963
+ + &hex[16..20],
1964
+ + &hex[20..]
1965
+ + ))
1966
+ +}
1967
+ +
1968
+ +pub(crate) fn capture_guarded_child(pid: u32) -> io::Result<Arc<GuardedChildOrigin>> {
1969
+ + let before = identity(pid)?;
1970
+ + let raw = unsafe { libc::syscall(libc::SYS_pidfd_open, pid as libc::pid_t, 0) };
1971
+ + if raw < 0 {
1972
+ + return Err(io::Error::last_os_error());
1973
+ + }
1974
+ + let handle = LinuxChildHandle(unsafe { OwnedFd::from_raw_fd(raw as i32) });
1975
+ + if identity(pid)? != before || !handle.is_live()? {
1976
+ + return Err(io::Error::other("child origin changed during capture"));
1977
+ + }
1978
+ + Ok(Arc::new(GuardedChildOrigin {
1979
+ + child: before,
1980
+ + runtime_incarnation: runtime_incarnation()?,
1981
+ + exited: AtomicBool::new(false),
1982
+ + handle: Arc::new(handle),
1983
+ + }))
1984
+ +}
1985
+ diff --git a/src/platform/mod.rs b/src/platform/mod.rs
1986
+ index 927cd32..0ea23d5 100644
1987
+ --- a/src/platform/mod.rs
1988
+ +++ b/src/platform/mod.rs
1989
+ @@ -3,6 +3,8 @@
1990
+ //! Centralizes OS-dependent behavior behind a clean boundary so core
1991
+ //! modules don't scatter `#[cfg]` branches through product logic.
1992
+
1993
+ +pub(crate) mod guarded_prompt;
1994
+ +
1995
+ #[cfg(unix)]
1996
+ pub(crate) mod ssh_agent;
1997
+
1998
+ @@ -727,3 +729,13 @@ pub(crate) fn shared_ssh_control_path(
1999
+ "interactive SSH recovery requires Unix OpenSSH multiplexing",
2000
+ ))
2001
+ }
2002
+ +
2003
+ +#[cfg(not(target_os = "linux"))]
2004
+ +pub(crate) fn capture_guarded_child(
2005
+ + _pid: u32,
2006
+ +) -> std::io::Result<std::sync::Arc<guarded_prompt::GuardedChildOrigin>> {
2007
+ + Err(std::io::Error::new(
2008
+ + std::io::ErrorKind::Unsupported,
2009
+ + "guarded child origin requires Linux",
2010
+ + ))
2011
+ +}
2012
+ diff --git a/src/pty/actor/unix.rs b/src/pty/actor/unix.rs
2013
+ index 4d57dd1..7740265 100644
2014
+ --- a/src/pty/actor/unix.rs
2015
+ +++ b/src/pty/actor/unix.rs
2016
+ @@ -10,6 +10,9 @@ use bytes::Bytes;
2017
+ use tokio::sync::mpsc::{self, error::TryRecvError as DataTryRecvError};
2018
+ use tracing::{debug, warn};
2019
+
2020
+ +use crate::platform::guarded_prompt::{
2021
+ + GuardedChildOrigin, GuardedPromptError, GuardedPromptOrigin, GuardedWriteState, InputDrainError,
2022
+ +};
2023
+ use crate::pty::fd;
2024
+
2025
+ // Actor handle methods must call wake_actor() after queuing work. The idle
2026
+ @@ -66,6 +69,7 @@ struct SharedPtyControls {
2027
+ pub(crate) struct PtyIoActorConfig {
2028
+ pub pane_id: u32,
2029
+ pub master_fd: OwnedFd,
2030
+ + pub origin: Option<Arc<GuardedChildOrigin>>,
2031
+ pub initially_quiesced: bool,
2032
+ pub on_read: ReadCallback,
2033
+ pub on_reader_exit: Option<ReaderExitCallback>,
2034
+ @@ -73,6 +77,14 @@ pub(crate) struct PtyIoActorConfig {
2035
+
2036
+ enum PtyIoDataCommand {
2037
+ WriteUserInput(Bytes),
2038
+ + GuardedSubmit {
2039
+ + text: Bytes,
2040
+ + enter: Bytes,
2041
+ + delay: Duration,
2042
+ + expected: GuardedPromptOrigin,
2043
+ + admission: GuardedAdmission,
2044
+ + reply: std_mpsc::Sender<Result<(), GuardedPromptError>>,
2045
+ + },
2046
+ SubmitUserInput {
2047
+ text: Bytes,
2048
+ enter: Bytes,
2049
+ @@ -87,6 +99,7 @@ enum PtyIoControlCommand {
2050
+ ForegroundProcessGroup(std_mpsc::Sender<Option<u32>>),
2051
+ RollbackHandoff(std_mpsc::Sender<std::io::Result<()>>),
2052
+ ReleaseAfterCommit(std_mpsc::Sender<std::io::Result<()>>),
2053
+ + InhibitInput(std_mpsc::Sender<Result<(), InputDrainError>>),
2054
+ Shutdown,
2055
+ }
2056
+
2057
+ @@ -98,46 +111,195 @@ pub(crate) struct PtyIoActorHandle {
2058
+ user_writes: Arc<Mutex<UserWriteGate>>,
2059
+ controls: Arc<Mutex<SharedPtyControls>>,
2060
+ response_order: Arc<Mutex<()>>,
2061
+ + origin: Option<Arc<GuardedChildOrigin>>,
2062
+ }
2063
+
2064
+ #[derive(Debug)]
2065
+ struct UserWriteGate {
2066
+ accepting: bool,
2067
+ + guarded_pending: usize,
2068
+ + scope_inhibited: bool,
2069
+ +}
2070
+ +
2071
+ +impl UserWriteGate {
2072
+ + fn inhibit(&mut self) {
2073
+ + self.scope_inhibited = true;
2074
+ + self.accepting = false;
2075
+ + }
2076
+ + fn reopen(&mut self) -> bool {
2077
+ + if self.scope_inhibited {
2078
+ + return false;
2079
+ + }
2080
+ + self.accepting = true;
2081
+ + true
2082
+ + }
2083
+ + fn admit_guarded(&mut self) -> Result<(), GuardedPromptError> {
2084
+ + if !self.accepting {
2085
+ + return Err(GuardedPromptError::OriginUnavailable);
2086
+ + }
2087
+ + self.guarded_pending += 1;
2088
+ + Ok(())
2089
+ + }
2090
+ +
2091
+ + fn close_for_handoff(&mut self) -> std::io::Result<()> {
2092
+ + if !self.accepting || self.guarded_pending > 0 {
2093
+ + return Err(std::io::Error::new(std::io::ErrorKind::WouldBlock,
2094
+ + "PTY handoff is unavailable while admission is closed or guarded submission is pending"));
2095
+ + }
2096
+ + self.accepting = false;
2097
+ + Ok(())
2098
+ + }
2099
+ +}
2100
+ +
2101
+ +struct GuardedAdmission(Arc<Mutex<UserWriteGate>>);
2102
+ +impl Drop for GuardedAdmission {
2103
+ + fn drop(&mut self) {
2104
+ + let mut gate = self
2105
+ + .0
2106
+ + .lock()
2107
+ + .unwrap_or_else(|poisoned| poisoned.into_inner());
2108
+ + gate.guarded_pending = gate.guarded_pending.saturating_sub(1);
2109
+ + }
2110
+ +}
2111
+ +
2112
+ +fn queue_guarded_command(
2113
+ + data_tx: &mpsc::Sender<PtyIoDataCommand>,
2114
+ + user_writes: &Arc<Mutex<UserWriteGate>>,
2115
+ + text: Bytes,
2116
+ + enter: Bytes,
2117
+ + delay: Duration,
2118
+ + expected: GuardedPromptOrigin,
2119
+ +) -> Result<std_mpsc::Receiver<Result<(), GuardedPromptError>>, GuardedPromptError> {
2120
+ + let mut gate = user_writes
2121
+ + .lock()
2122
+ + .unwrap_or_else(|poisoned| poisoned.into_inner());
2123
+ + // Admission and handoff share this gate. Failed sends drop their permit
2124
+ + // only after releasing this lock.
2125
+ + gate.admit_guarded()?;
2126
+ + let (reply, result) = std_mpsc::channel();
2127
+ + let command = PtyIoDataCommand::GuardedSubmit {
2128
+ + text,
2129
+ + enter,
2130
+ + delay,
2131
+ + expected,
2132
+ + admission: GuardedAdmission(Arc::clone(user_writes)),
2133
+ + reply,
2134
+ + };
2135
+ + let queued = data_tx.try_send(command);
2136
+ + drop(gate);
2137
+ + queued.map_err(|error| match error {
2138
+ + mpsc::error::TrySendError::Full(_) => GuardedPromptError::SubmissionBusy,
2139
+ + mpsc::error::TrySendError::Closed(_) => GuardedPromptError::OriginUnavailable,
2140
+ + })?;
2141
+ + Ok(result)
2142
+ +}
2143
+ +
2144
+ +fn queue_plain_input(
2145
+ + data_tx: &mpsc::Sender<PtyIoDataCommand>,
2146
+ + gate: &Arc<Mutex<UserWriteGate>>,
2147
+ + bytes: Bytes,
2148
+ +) -> Result<(), mpsc::error::TrySendError<Bytes>> {
2149
+ + let gate = gate.lock().unwrap_or_else(|p| p.into_inner());
2150
+ + if !gate.accepting {
2151
+ + return Err(mpsc::error::TrySendError::Closed(bytes));
2152
+ + }
2153
+ + data_tx
2154
+ + .try_send(PtyIoDataCommand::WriteUserInput(bytes))
2155
+ + .map_err(|error| match error {
2156
+ + mpsc::error::TrySendError::Full(PtyIoDataCommand::WriteUserInput(bytes)) => {
2157
+ + mpsc::error::TrySendError::Full(bytes)
2158
+ + }
2159
+ + mpsc::error::TrySendError::Closed(PtyIoDataCommand::WriteUserInput(bytes)) => {
2160
+ + mpsc::error::TrySendError::Closed(bytes)
2161
+ + }
2162
+ + _ => unreachable!("plain input queue returned another command"),
2163
+ + })
2164
+ +}
2165
+ +
2166
+ +fn queue_input_inhibit(
2167
+ + origin: Option<&GuardedChildOrigin>,
2168
+ + expected: &GuardedPromptOrigin,
2169
+ + gate: &Arc<Mutex<UserWriteGate>>,
2170
+ + controls: &std_mpsc::Sender<PtyIoControlCommand>,
2171
+ +) -> Result<std_mpsc::Receiver<Result<(), InputDrainError>>, InputDrainError> {
2172
+ + let origin = origin.ok_or(InputDrainError::OriginUnavailable)?;
2173
+ + if !origin.matches_captured(expected) {
2174
+ + return Err(InputDrainError::GuardMismatch);
2175
+ + }
2176
+ + let (reply, result) = std_mpsc::channel();
2177
+ + {
2178
+ + let mut gate = gate.lock().unwrap_or_else(|p| p.into_inner());
2179
+ + if !gate.accepting && !gate.scope_inhibited {
2180
+ + return Err(InputDrainError::OriginUnavailable);
2181
+ + }
2182
+ + gate.inhibit();
2183
+ + controls
2184
+ + .send(PtyIoControlCommand::InhibitInput(reply))
2185
+ + .map_err(|_| InputDrainError::Unavailable)?;
2186
+ + }
2187
+ + Ok(result)
2188
+ }
2189
+
2190
+ impl PtyIoActorHandle {
2191
+ + pub(crate) fn captured_input_origin(&self, terminal_id: &str) -> Option<GuardedPromptOrigin> {
2192
+ + self.origin
2193
+ + .as_ref()
2194
+ + .map(|origin| origin.projection(terminal_id))
2195
+ + }
2196
+ + pub(crate) fn inhibit_input(
2197
+ + &self,
2198
+ + expected: &GuardedPromptOrigin,
2199
+ + ) -> Result<std_mpsc::Receiver<Result<(), InputDrainError>>, InputDrainError> {
2200
+ + let result = queue_input_inhibit(
2201
+ + self.origin.as_deref(),
2202
+ + expected,
2203
+ + &self.user_writes,
2204
+ + &self.control_tx,
2205
+ + )?;
2206
+ + self.wake_actor();
2207
+ + Ok(result)
2208
+ + }
2209
+ +
2210
+ + pub(crate) fn guarded_origin(&self, terminal_id: &str) -> Option<GuardedPromptOrigin> {
2211
+ + let origin = self.origin.as_ref()?;
2212
+ + let expected = origin.projection(terminal_id);
2213
+ + origin.check(&expected).ok()?;
2214
+ + Some(expected)
2215
+ + }
2216
+ +
2217
+ + pub(crate) fn queue_guarded_submission(
2218
+ + &self,
2219
+ + text: Bytes,
2220
+ + enter: Bytes,
2221
+ + delay: Duration,
2222
+ + expected: GuardedPromptOrigin,
2223
+ + ) -> Result<std_mpsc::Receiver<Result<(), GuardedPromptError>>, GuardedPromptError> {
2224
+ + let origin = self
2225
+ + .origin
2226
+ + .as_ref()
2227
+ + .ok_or(GuardedPromptError::OriginUnavailable)?;
2228
+ + origin.check(&expected)?;
2229
+ + let result = queue_guarded_command(
2230
+ + &self.data_tx,
2231
+ + &self.user_writes,
2232
+ + text,
2233
+ + enter,
2234
+ + delay,
2235
+ + expected,
2236
+ + )?;
2237
+ + self.wake_actor();
2238
+ + Ok(result)
2239
+ + }
2240
+ +
2241
+ pub(crate) fn try_write_user_input(
2242
+ &self,
2243
+ bytes: Bytes,
2244
+ ) -> Result<(), mpsc::error::TrySendError<Bytes>> {
2245
+ - let user_writes = self
2246
+ - .user_writes
2247
+ - .lock()
2248
+ - .unwrap_or_else(|poisoned| poisoned.into_inner());
2249
+ - if !user_writes.accepting {
2250
+ - return Err(mpsc::error::TrySendError::Closed(bytes));
2251
+ - }
2252
+ - match self
2253
+ - .data_tx
2254
+ - .try_send(PtyIoDataCommand::WriteUserInput(bytes))
2255
+ - {
2256
+ - Ok(()) => {
2257
+ - self.wake_actor();
2258
+ - Ok(())
2259
+ - }
2260
+ - Err(mpsc::error::TrySendError::Full(command)) => {
2261
+ - let PtyIoDataCommand::WriteUserInput(bytes) = command else {
2262
+ - unreachable!("queued write returned another command")
2263
+ - };
2264
+ - Err(mpsc::error::TrySendError::Full(bytes))
2265
+ - }
2266
+ - Err(mpsc::error::TrySendError::Closed(command)) => {
2267
+ - let PtyIoDataCommand::WriteUserInput(bytes) = command else {
2268
+ - unreachable!("queued write returned another command")
2269
+ - };
2270
+ - Err(mpsc::error::TrySendError::Closed(bytes))
2271
+ - }
2272
+ + let result = queue_plain_input(&self.data_tx, &self.user_writes, bytes);
2273
+ + if result.is_ok() {
2274
+ + self.wake_actor();
2275
+ }
2276
+ + result
2277
+ }
2278
+
2279
+ pub(crate) fn queue_user_input_submission(
2280
+ @@ -181,6 +343,10 @@ impl PtyIoActorHandle {
2281
+ .response_order
2282
+ .lock()
2283
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
2284
+ + let gate = self.user_writes.lock().unwrap_or_else(|p| p.into_inner());
2285
+ + if gate.scope_inhibited {
2286
+ + return;
2287
+ + }
2288
+ let Some(bytes) = response() else {
2289
+ return;
2290
+ };
2291
+ @@ -202,6 +368,10 @@ impl PtyIoActorHandle {
2292
+ cell_height_px: u32,
2293
+ terminal_responses: Vec<Bytes>,
2294
+ ) {
2295
+ + let gate = self.user_writes.lock().unwrap_or_else(|p| p.into_inner());
2296
+ + if gate.scope_inhibited {
2297
+ + return;
2298
+ + }
2299
+ {
2300
+ let mut controls = self
2301
+ .controls
2302
+ @@ -227,6 +397,10 @@ impl PtyIoActorHandle {
2303
+ cell_width_px: u32,
2304
+ cell_height_px: u32,
2305
+ ) {
2306
+ + let gate = self.user_writes.lock().unwrap_or_else(|p| p.into_inner());
2307
+ + if gate.scope_inhibited {
2308
+ + return;
2309
+ + }
2310
+ {
2311
+ let mut controls = self
2312
+ .controls
2313
+ @@ -249,19 +423,13 @@ impl PtyIoActorHandle {
2314
+ .user_writes
2315
+ .lock()
2316
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
2317
+ - if !user_writes.accepting {
2318
+ - return Err(std::io::Error::new(
2319
+ - std::io::ErrorKind::WouldBlock,
2320
+ - "PTY handoff is already in progress",
2321
+ - ));
2322
+ - }
2323
+ - user_writes.accepting = false;
2324
+ + user_writes.close_for_handoff()?;
2325
+ if self
2326
+ .control_tx
2327
+ .send(PtyIoControlCommand::BeginHandoff(reply_tx))
2328
+ .is_err()
2329
+ {
2330
+ - user_writes.accepting = true;
2331
+ + user_writes.reopen();
2332
+ return Err(std::io::Error::new(
2333
+ std::io::ErrorKind::BrokenPipe,
2334
+ "pty actor closed",
2335
+ @@ -325,7 +493,7 @@ impl PtyIoActorHandle {
2336
+ .user_writes
2337
+ .lock()
2338
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
2339
+ - user_writes.accepting = true;
2340
+ + user_writes.reopen();
2341
+ }
2342
+ result
2343
+ }
2344
+ @@ -390,6 +558,8 @@ impl PtyIoActor {
2345
+ let wake_pipe = fd::create_wake_pipe()?;
2346
+ let user_writes = Arc::new(Mutex::new(UserWriteGate {
2347
+ accepting: !config.initially_quiesced,
2348
+ + guarded_pending: 0,
2349
+ + scope_inhibited: false,
2350
+ }));
2351
+ let controls = Arc::new(Mutex::new(SharedPtyControls::default()));
2352
+ let response_order = Arc::new(Mutex::new(()));
2353
+ @@ -397,13 +567,16 @@ impl PtyIoActor {
2354
+ data_tx,
2355
+ control_tx,
2356
+ wake: wake_pipe.writer,
2357
+ - user_writes,
2358
+ + user_writes: Arc::clone(&user_writes),
2359
+ + origin: config.origin.clone(),
2360
+ controls: Arc::clone(&controls),
2361
+ response_order: Arc::clone(&response_order),
2362
+ };
2363
+
2364
+ let mut runner = PtyIoActorRunner {
2365
+ pane_id: config.pane_id,
2366
+ + origin: config.origin,
2367
+ + user_writes,
2368
+ file: std::fs::File::from(config.master_fd),
2369
+ data_rx,
2370
+ control_rx,
2371
+ @@ -440,9 +613,11 @@ impl PtyIoActor {
2372
+ }
2373
+ }
2374
+
2375
+ -struct PtyIoActorRunner {
2376
+ +struct PtyIoActorRunner<F = std::fs::File, W = OwnedFd> {
2377
+ pane_id: u32,
2378
+ - file: std::fs::File,
2379
+ + origin: Option<Arc<GuardedChildOrigin>>,
2380
+ + user_writes: Arc<Mutex<UserWriteGate>>,
2381
+ + file: F,
2382
+ data_rx: mpsc::Receiver<PtyIoDataCommand>,
2383
+ control_rx: std_mpsc::Receiver<PtyIoControlCommand>,
2384
+ state: ActorState,
2385
+ @@ -450,7 +625,7 @@ struct PtyIoActorRunner {
2386
+ current_write_offset: usize,
2387
+ active_submission: Option<ActiveSubmission>,
2388
+ pending_handoff: Option<std_mpsc::Sender<std::io::Result<()>>>,
2389
+ - wake_read_fd: OwnedFd,
2390
+ + wake_read_fd: W,
2391
+ controls: Arc<Mutex<SharedPtyControls>>,
2392
+ response_order: Arc<Mutex<()>>,
2393
+ on_read: ReadCallback,
2394
+ @@ -458,11 +633,44 @@ struct PtyIoActorRunner {
2395
+ poll_observer: Option<std_mpsc::Sender<()>>,
2396
+ }
2397
+
2398
+ +enum SubmissionReply {
2399
+ + Plain(std_mpsc::Sender<std::io::Result<()>>),
2400
+ + Guarded(std_mpsc::Sender<Result<(), GuardedPromptError>>),
2401
+ +}
2402
+ +impl SubmissionReply {
2403
+ + fn success(self) {
2404
+ + match self {
2405
+ + Self::Plain(reply) => {
2406
+ + let _ = reply.send(Ok(()));
2407
+ + }
2408
+ + Self::Guarded(reply) => {
2409
+ + let _ = reply.send(Ok(()));
2410
+ + }
2411
+ + }
2412
+ + }
2413
+ + fn failure(self, error: std::io::Error, guarded: GuardedPromptError) {
2414
+ + match self {
2415
+ + Self::Plain(reply) => {
2416
+ + let _ = reply.send(Err(error));
2417
+ + }
2418
+ + Self::Guarded(reply) => {
2419
+ + let _ = reply.send(Err(guarded));
2420
+ + }
2421
+ + }
2422
+ + }
2423
+ +}
2424
+ +struct GuardedSubmission {
2425
+ + expected: GuardedPromptOrigin,
2426
+ + writes: GuardedWriteState,
2427
+ + _admission: GuardedAdmission,
2428
+ +}
2429
+ +
2430
+ struct ActiveSubmission {
2431
+ + guard: Option<GuardedSubmission>,
2432
+ enter: Bytes,
2433
+ delay: Duration,
2434
+ phase: SubmissionPhase,
2435
+ - reply: std_mpsc::Sender<std::io::Result<()>>,
2436
+ + reply: SubmissionReply,
2437
+ }
2438
+
2439
+ #[derive(Debug, PartialEq, Eq)]
2440
+ @@ -483,7 +691,7 @@ enum SubmissionPhase {
2441
+ WritingEnter,
2442
+ }
2443
+
2444
+ -impl PtyIoActorRunner {
2445
+ +impl<F: Read + Write + AsRawFd, W: AsRawFd> PtyIoActorRunner<F, W> {
2446
+ fn enqueue_write(&mut self, bytes: Bytes) {
2447
+ if !bytes.is_empty() {
2448
+ self.pending_writes.push_back(PendingWrite {
2449
+ @@ -635,12 +843,49 @@ impl PtyIoActorRunner {
2450
+ }
2451
+
2452
+ fn handle_data_command(&mut self, command: PtyIoDataCommand) -> bool {
2453
+ + if self.input_inhibited() {
2454
+ + Self::refuse_input(command);
2455
+ + return false;
2456
+ + }
2457
+ match command {
2458
+ PtyIoDataCommand::WriteUserInput(bytes) => {
2459
+ if self.state == ActorState::Running {
2460
+ self.enqueue_write(bytes);
2461
+ }
2462
+ }
2463
+ + PtyIoDataCommand::GuardedSubmit {
2464
+ + text,
2465
+ + enter,
2466
+ + delay,
2467
+ + expected,
2468
+ + admission,
2469
+ + reply,
2470
+ + } => {
2471
+ + let result = if self.state != ActorState::Running {
2472
+ + Err(GuardedPromptError::OriginUnavailable)
2473
+ + } else {
2474
+ + self.origin
2475
+ + .as_ref()
2476
+ + .ok_or(GuardedPromptError::OriginUnavailable)
2477
+ + .and_then(|origin| origin.check(&expected))
2478
+ + };
2479
+ + if let Err(error) = result {
2480
+ + let _ = reply.send(Err(error));
2481
+ + } else {
2482
+ + self.enqueue_submission_write(text, SubmissionBoundary::Text);
2483
+ + self.active_submission = Some(ActiveSubmission {
2484
+ + enter,
2485
+ + delay,
2486
+ + phase: SubmissionPhase::WritingText,
2487
+ + reply: SubmissionReply::Guarded(reply),
2488
+ + guard: Some(GuardedSubmission {
2489
+ + expected,
2490
+ + writes: GuardedWriteState::default(),
2491
+ + _admission: admission,
2492
+ + }),
2493
+ + });
2494
+ + }
2495
+ + }
2496
+ PtyIoDataCommand::SubmitUserInput {
2497
+ text,
2498
+ enter,
2499
+ @@ -658,7 +903,8 @@ impl PtyIoActorRunner {
2500
+ enter,
2501
+ delay,
2502
+ phase,
2503
+ - reply,
2504
+ + reply: SubmissionReply::Plain(reply),
2505
+ + guard: None,
2506
+ });
2507
+ } else {
2508
+ let _ = reply.send(Err(std::io::Error::new(
2509
+ @@ -671,13 +917,61 @@ impl PtyIoActorRunner {
2510
+ false
2511
+ }
2512
+
2513
+ + fn input_inhibited(&self) -> bool {
2514
+ + self.user_writes
2515
+ + .lock()
2516
+ + .unwrap_or_else(|p| p.into_inner())
2517
+ + .scope_inhibited
2518
+ + }
2519
+ + fn refuse_input(command: PtyIoDataCommand) {
2520
+ + match command {
2521
+ + PtyIoDataCommand::SubmitUserInput { reply, .. } => {
2522
+ + let _ = reply.send(Err(input_submission_closed_error()));
2523
+ + }
2524
+ + PtyIoDataCommand::GuardedSubmit { reply, .. } => {
2525
+ + let _ = reply.send(Err(GuardedPromptError::OriginUnavailable));
2526
+ + }
2527
+ + PtyIoDataCommand::WriteUserInput(_) => {}
2528
+ + }
2529
+ + }
2530
+ + fn drain_inhibited_input(&mut self) -> Result<(), InputDrainError> {
2531
+ + if self.state == ActorState::Released || !self.input_inhibited() {
2532
+ + return Err(InputDrainError::Unavailable);
2533
+ + }
2534
+ + {
2535
+ + let mut controls = self.controls.lock().unwrap_or_else(|p| p.into_inner());
2536
+ + controls.terminal_responses.clear();
2537
+ + controls.resize = None;
2538
+ + controls.nudge = None;
2539
+ + }
2540
+ + self.pending_writes.clear();
2541
+ + self.current_write_offset = 0;
2542
+ + self.fail_active_submission(input_submission_closed_error());
2543
+ + while let Ok(command) = self.data_rx.try_recv() {
2544
+ + Self::refuse_input(command);
2545
+ + }
2546
+ + if let Some(reply) = self.pending_handoff.take() {
2547
+ + let _ = reply.send(Err(input_submission_closed_error()));
2548
+ + }
2549
+ + let gate = self.user_writes.lock().unwrap_or_else(|p| p.into_inner());
2550
+ + if gate.scope_inhibited && !gate.accepting && gate.guarded_pending == 0 {
2551
+ + Ok(())
2552
+ + } else {
2553
+ + Err(InputDrainError::Unavailable)
2554
+ + }
2555
+ + }
2556
+ fn handle_control_command(&mut self, command: PtyIoControlCommand) -> bool {
2557
+ match command {
2558
+ + PtyIoControlCommand::InhibitInput(reply) => {
2559
+ + let result = self.drain_inhibited_input();
2560
+ + let _ = reply.send(result);
2561
+ + }
2562
+ PtyIoControlCommand::BeginHandoff(reply) => {
2563
+ self.defer_or_begin_handoff(reply);
2564
+ }
2565
+ PtyIoControlCommand::DuplicateForHandoff(reply) => {
2566
+ - let result = if self.state == ActorState::Quiesced {
2567
+ + let gate = self.user_writes.lock().unwrap_or_else(|p| p.into_inner());
2568
+ + let result = if self.state == ActorState::Quiesced && !gate.scope_inhibited {
2569
+ fd::duplicate_cloexec_fd(self.file.as_raw_fd())
2570
+ } else {
2571
+ Err(std::io::Error::other(
2572
+ @@ -693,7 +987,7 @@ impl PtyIoActorRunner {
2573
+ }
2574
+ PtyIoControlCommand::RollbackHandoff(reply) => {
2575
+ self.pending_handoff.take();
2576
+ - let result = if self.state == ActorState::Released {
2577
+ + let result = if self.state == ActorState::Released || self.input_inhibited() {
2578
+ Err(std::io::Error::new(
2579
+ std::io::ErrorKind::BrokenPipe,
2580
+ "PTY actor was released before handoff rollback",
2581
+ @@ -716,6 +1010,10 @@ impl PtyIoActorRunner {
2582
+ }
2583
+
2584
+ fn defer_or_begin_handoff(&mut self, reply: std_mpsc::Sender<std::io::Result<()>>) {
2585
+ + if self.input_inhibited() {
2586
+ + let _ = reply.send(Err(input_submission_closed_error()));
2587
+ + return;
2588
+ + }
2589
+ if self.active_submission.is_none() {
2590
+ self.drain_pre_quiesce_commands();
2591
+ }
2592
+ @@ -728,6 +1026,9 @@ impl PtyIoActorRunner {
2593
+ }
2594
+
2595
+ fn begin_handoff(&mut self) -> std::io::Result<()> {
2596
+ + if self.input_inhibited() {
2597
+ + return Err(input_submission_closed_error());
2598
+ + }
2599
+ self.drain_pre_quiesce_commands();
2600
+ if self.active_submission.is_some() {
2601
+ return Err(std::io::Error::new(
2602
+ @@ -800,7 +1101,7 @@ impl PtyIoActorRunner {
2603
+ std::mem::take(&mut controls.terminal_responses),
2604
+ )
2605
+ };
2606
+ - if self.state == ActorState::Released {
2607
+ + if self.state == ActorState::Released || self.input_inhibited() {
2608
+ return;
2609
+ }
2610
+ if let Some(request) = resize {
2611
+ @@ -829,11 +1130,16 @@ impl PtyIoActorRunner {
2612
+ .lock()
2613
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
2614
+ let result = (self.on_read)(&buf[..n]);
2615
+ - self.controls
2616
+ - .lock()
2617
+ - .unwrap_or_else(|poisoned| poisoned.into_inner())
2618
+ - .terminal_responses
2619
+ - .extend(result.terminal_responses);
2620
+ + {
2621
+ + let gate = self.user_writes.lock().unwrap_or_else(|p| p.into_inner());
2622
+ + if !gate.scope_inhibited {
2623
+ + self.controls
2624
+ + .lock()
2625
+ + .unwrap_or_else(|p| p.into_inner())
2626
+ + .terminal_responses
2627
+ + .extend(result.terminal_responses);
2628
+ + }
2629
+ + }
2630
+ drop(_order);
2631
+ let terminal_responses = std::mem::take(
2632
+ &mut self
2633
+ @@ -849,7 +1155,7 @@ impl PtyIoActorRunner {
2634
+ }
2635
+
2636
+ fn enqueue_terminal_responses(&mut self, terminal_responses: Vec<Bytes>) {
2637
+ - if self.state == ActorState::Released {
2638
+ + if self.state == ActorState::Released || self.input_inhibited() {
2639
+ return;
2640
+ }
2641
+ for bytes in terminal_responses {
2642
+ @@ -871,7 +1177,7 @@ impl PtyIoActorRunner {
2643
+ return;
2644
+ };
2645
+ debug_assert!(matches!(submission.phase, SubmissionPhase::WritingEnter));
2646
+ - let _ = submission.reply.send(Ok(()));
2647
+ + submission.reply.success();
2648
+ }
2649
+ }
2650
+ }
2651
+ @@ -889,7 +1195,7 @@ impl PtyIoActorRunner {
2652
+ let enter = enter.clone();
2653
+ if enter.is_empty() {
2654
+ let submission = self.active_submission.take().unwrap();
2655
+ - let _ = submission.reply.send(Ok(()));
2656
+ + submission.reply.success();
2657
+ } else {
2658
+ self.active_submission.as_mut().unwrap().phase = SubmissionPhase::WritingEnter;
2659
+ self.enqueue_submission_write(enter, SubmissionBoundary::Enter);
2660
+ @@ -912,9 +1218,25 @@ impl PtyIoActorRunner {
2661
+ .min(ACTOR_IDLE_POLL_MS as u128) as i32
2662
+ }
2663
+
2664
+ + fn fail_guarded_submission(&mut self, error: GuardedPromptError) {
2665
+ + self.pending_writes.retain(|write| write.boundary.is_none());
2666
+ + self.current_write_offset = 0;
2667
+ + if let Some(submission) = self.active_submission.take() {
2668
+ + submission
2669
+ + .reply
2670
+ + .failure(std::io::Error::other("guarded submission failed"), error);
2671
+ + }
2672
+ + }
2673
+ +
2674
+ fn fail_active_submission(&mut self, err: std::io::Error) {
2675
+ if let Some(submission) = self.active_submission.take() {
2676
+ - let _ = submission.reply.send(Err(err));
2677
+ + let guarded = submission
2678
+ + .guard
2679
+ + .as_ref()
2680
+ + .map_or(GuardedPromptError::SubmissionUncertain, |guard| {
2681
+ + guard.writes.failure(GuardedPromptError::OriginUnavailable)
2682
+ + });
2683
+ + submission.reply.failure(err, guarded);
2684
+ }
2685
+ }
2686
+
2687
+ @@ -922,16 +1244,60 @@ impl PtyIoActorRunner {
2688
+ self.data_rx.close();
2689
+ self.fail_active_submission(input_submission_closed_error());
2690
+ while let Some(command) = self.data_rx.blocking_recv() {
2691
+ - if let PtyIoDataCommand::SubmitUserInput { reply, .. } = command {
2692
+ - let _ = reply.send(Err(input_submission_closed_error()));
2693
+ + match command {
2694
+ + PtyIoDataCommand::SubmitUserInput { reply, .. } => {
2695
+ + let _ = reply.send(Err(input_submission_closed_error()));
2696
+ + }
2697
+ + PtyIoDataCommand::GuardedSubmit { reply, .. } => {
2698
+ + let _ = reply.send(Err(GuardedPromptError::OriginUnavailable));
2699
+ + }
2700
+ + _ => {}
2701
+ }
2702
+ }
2703
+ }
2704
+
2705
+ fn flush_pending_writes_once(&mut self) -> std::io::Result<Option<SubmissionBoundary>> {
2706
+ while let Some(write) = self.pending_writes.front() {
2707
+ + let gate = Arc::clone(&self.user_writes);
2708
+ + let gate = gate.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
2709
+ + if gate.scope_inhibited {
2710
+ + drop(gate);
2711
+ + self.drain_inhibited_input()
2712
+ + .map_err(|_| input_submission_closed_error())?;
2713
+ + return Ok(None);
2714
+ + }
2715
+ let chunk = &write.bytes[self.current_write_offset..];
2716
+ - match self.file.write(chunk) {
2717
+ + let result = if let Some(guard) = self
2718
+ + .active_submission
2719
+ + .as_mut()
2720
+ + .filter(|_| write.boundary.is_some())
2721
+ + .and_then(|submission| submission.guard.as_mut())
2722
+ + {
2723
+ + let admission = if !gate.accepting || self.state != ActorState::Running {
2724
+ + Err(GuardedPromptError::OriginUnavailable)
2725
+ + } else {
2726
+ + self.origin
2727
+ + .as_ref()
2728
+ + .ok_or(GuardedPromptError::OriginUnavailable)
2729
+ + .and_then(|origin| origin.check(&guard.expected))
2730
+ + };
2731
+ + match guard.writes.write(admission, || self.file.write(chunk)) {
2732
+ + Ok(Some(bytes)) => Ok(bytes),
2733
+ + Ok(None) => {
2734
+ + drop(gate);
2735
+ + return Ok(None);
2736
+ + }
2737
+ + Err(error) => {
2738
+ + drop(gate);
2739
+ + self.fail_guarded_submission(error);
2740
+ + return Ok(None);
2741
+ + }
2742
+ + }
2743
+ + } else {
2744
+ + self.file.write(chunk)
2745
+ + };
2746
+ + drop(gate);
2747
+ + match result {
2748
+ Ok(0) => {
2749
+ return Err(std::io::Error::new(
2750
+ std::io::ErrorKind::WriteZero,
2751
+ @@ -949,8 +1315,14 @@ impl PtyIoActorRunner {
2752
+ }
2753
+ }
2754
+ }
2755
+ - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => return Ok(None),
2756
+ - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => return Ok(None),
2757
+ + Err(err)
2758
+ + if matches!(
2759
+ + err.kind(),
2760
+ + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
2761
+ + ) =>
2762
+ + {
2763
+ + return Ok(None);
2764
+ + }
2765
+ Err(err) => {
2766
+ warn!(pane = self.pane_id, err = %err, "PTY actor write failed");
2767
+ self.pending_writes.clear();
2768
+ @@ -1067,6 +1439,7 @@ mod tests {
2769
+ let owned = unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) };
2770
+ let (read_tx, read_rx) = std_mpsc::channel();
2771
+ let config = PtyIoActorConfig {
2772
+ + origin: None,
2773
+ pane_id: 1,
2774
+ master_fd: owned,
2775
+ initially_quiesced,
2776
+ @@ -1097,6 +1470,12 @@ mod tests {
2777
+ let (_control_tx, control_rx) = std_mpsc::channel();
2778
+ let wake_pipe = fd::create_wake_pipe().expect("wake pipe");
2779
+ let runner = PtyIoActorRunner {
2780
+ + origin: None,
2781
+ + user_writes: Arc::new(Mutex::new(UserWriteGate {
2782
+ + accepting: true,
2783
+ + guarded_pending: 0,
2784
+ + scope_inhibited: false,
2785
+ + })),
2786
+ pane_id: 1,
2787
+ file: std::fs::File::from(owned),
2788
+ data_rx,
2789
+ @@ -1320,6 +1699,7 @@ mod tests {
2790
+ let handle_slot = Arc::new(Mutex::new(None::<PtyIoActorHandle>));
2791
+ let (attempt_tx, attempt_rx) = std_mpsc::channel();
2792
+ let config = PtyIoActorConfig {
2793
+ + origin: None,
2794
+ pane_id: 1,
2795
+ master_fd: owned,
2796
+ initially_quiesced: false,
2797
+ @@ -1412,6 +1792,7 @@ mod tests {
2798
+ let owned = unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) };
2799
+ let (read_tx, read_rx) = std_mpsc::channel();
2800
+ let handle = PtyIoActor::spawn(PtyIoActorConfig {
2801
+ + origin: None,
2802
+ pane_id: 1,
2803
+ master_fd: owned,
2804
+ initially_quiesced: false,
2805
+ @@ -1612,10 +1993,15 @@ mod tests {
2806
+ let controls = Arc::new(Mutex::new(SharedPtyControls::default()));
2807
+ let (wake, _wake_read_fd) = test_wake_pair();
2808
+ let handle = PtyIoActorHandle {
2809
+ + origin: None,
2810
+ data_tx,
2811
+ control_tx,
2812
+ wake,
2813
+ - user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
2814
+ + user_writes: Arc::new(Mutex::new(UserWriteGate {
2815
+ + accepting: true,
2816
+ + guarded_pending: 0,
2817
+ + scope_inhibited: false,
2818
+ + })),
2819
+ controls: Arc::clone(&controls),
2820
+ response_order: Arc::new(Mutex::new(())),
2821
+ };
2822
+ @@ -1668,6 +2054,12 @@ mod tests {
2823
+ let light = Arc::new(AtomicBool::new(false));
2824
+ let query_light = Arc::clone(&light);
2825
+ let runner = PtyIoActorRunner {
2826
+ + origin: None,
2827
+ + user_writes: Arc::new(Mutex::new(UserWriteGate {
2828
+ + accepting: true,
2829
+ + guarded_pending: 0,
2830
+ + scope_inhibited: false,
2831
+ + })),
2832
+ pane_id: 1,
2833
+ file: std::fs::File::from(owned),
2834
+ data_rx,
2835
+ @@ -1691,10 +2083,15 @@ mod tests {
2836
+ poll_observer: None,
2837
+ };
2838
+ let handle = PtyIoActorHandle {
2839
+ + origin: None,
2840
+ data_tx,
2841
+ control_tx,
2842
+ wake: wake_pipe.writer,
2843
+ - user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
2844
+ + user_writes: Arc::new(Mutex::new(UserWriteGate {
2845
+ + accepting: true,
2846
+ + guarded_pending: 0,
2847
+ + scope_inhibited: false,
2848
+ + })),
2849
+ controls,
2850
+ response_order,
2851
+ };
2852
+ @@ -1760,10 +2157,15 @@ mod tests {
2853
+ .expect("fill data queue");
2854
+ let (wake, _wake_read_fd) = test_wake_pair();
2855
+ let handle = PtyIoActorHandle {
2856
+ + origin: None,
2857
+ data_tx,
2858
+ control_tx,
2859
+ wake,
2860
+ - user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
2861
+ + user_writes: Arc::new(Mutex::new(UserWriteGate {
2862
+ + accepting: true,
2863
+ + guarded_pending: 0,
2864
+ + scope_inhibited: false,
2865
+ + })),
2866
+ controls: Arc::new(Mutex::new(SharedPtyControls::default())),
2867
+ response_order: Arc::new(Mutex::new(())),
2868
+ };
2869
+ @@ -1801,6 +2203,12 @@ mod tests {
2870
+ )))
2871
+ .expect("queued write");
2872
+ let mut runner = PtyIoActorRunner {
2873
+ + origin: None,
2874
+ + user_writes: Arc::new(Mutex::new(UserWriteGate {
2875
+ + accepting: true,
2876
+ + guarded_pending: 0,
2877
+ + scope_inhibited: false,
2878
+ + })),
2879
+ pane_id: 1,
2880
+ file: std::fs::File::from(unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) }),
2881
+ data_rx,
2882
+ @@ -1840,3 +2248,521 @@ mod tests {
2883
+ assert!(read_rx.recv_timeout(Duration::from_millis(150)).is_err());
2884
+ }
2885
+ }
2886
+ +
2887
+ +#[cfg(test)]
2888
+ +mod guarded_prompt_pure_tests {
2889
+ + use super::*;
2890
+ + use crate::platform::guarded_prompt::{GuardedChildIdentity, OriginLiveness};
2891
+ + use std::sync::atomic::{AtomicBool, Ordering};
2892
+ + use std::sync::{Arc, Mutex};
2893
+ +
2894
+ + #[test]
2895
+ + fn queued_guard_blocks_handoff_until_completion_or_failed_send_drops_permit() {
2896
+ + let gate = Arc::new(Mutex::new(UserWriteGate {
2897
+ + accepting: true,
2898
+ + guarded_pending: 0,
2899
+ + scope_inhibited: false,
2900
+ + }));
2901
+ + gate.lock()
2902
+ + .expect("fixture gate")
2903
+ + .admit_guarded()
2904
+ + .expect("admission");
2905
+ + let permit = GuardedAdmission(Arc::clone(&gate));
2906
+ + assert_eq!(
2907
+ + gate.lock()
2908
+ + .expect("fixture gate")
2909
+ + .close_for_handoff()
2910
+ + .expect_err("pending guard")
2911
+ + .kind(),
2912
+ + std::io::ErrorKind::WouldBlock
2913
+ + );
2914
+ + assert!(gate.lock().expect("fixture gate").accepting);
2915
+ + drop(permit);
2916
+ + gate.lock()
2917
+ + .expect("fixture gate")
2918
+ + .close_for_handoff()
2919
+ + .expect("released guard");
2920
+ + assert_eq!(
2921
+ + gate.lock().expect("fixture gate").admit_guarded(),
2922
+ + Err(GuardedPromptError::OriginUnavailable)
2923
+ + );
2924
+ + }
2925
+ +
2926
+ + #[test]
2927
+ + fn every_queued_guard_retains_its_own_handoff_permit() {
2928
+ + let gate = Arc::new(Mutex::new(UserWriteGate {
2929
+ + accepting: true,
2930
+ + guarded_pending: 0,
2931
+ + scope_inhibited: false,
2932
+ + }));
2933
+ + let mut permits = Vec::new();
2934
+ + for _ in 0..2 {
2935
+ + gate.lock()
2936
+ + .expect("fixture gate")
2937
+ + .admit_guarded()
2938
+ + .expect("admission");
2939
+ + permits.push(GuardedAdmission(Arc::clone(&gate)));
2940
+ + }
2941
+ + permits.pop();
2942
+ + assert_eq!(gate.lock().expect("fixture gate").guarded_pending, 1);
2943
+ + assert!(gate
2944
+ + .lock()
2945
+ + .expect("fixture gate")
2946
+ + .close_for_handoff()
2947
+ + .is_err());
2948
+ + drop(permits);
2949
+ + assert!(gate
2950
+ + .lock()
2951
+ + .expect("fixture gate")
2952
+ + .close_for_handoff()
2953
+ + .is_ok());
2954
+ + }
2955
+ + struct ControlledLive(AtomicBool);
2956
+ + impl OriginLiveness for ControlledLive {
2957
+ + fn is_live(&self) -> std::io::Result<bool> {
2958
+ + Ok(self.0.load(Ordering::Acquire))
2959
+ + }
2960
+ + }
2961
+ + struct NoNativeDescriptor;
2962
+ + impl AsRawFd for NoNativeDescriptor {
2963
+ + fn as_raw_fd(&self) -> std::os::fd::RawFd {
2964
+ + panic!("pure actor test must not invoke native readiness")
2965
+ + }
2966
+ + }
2967
+ + #[derive(Default)]
2968
+ + struct MemoryWriter {
2969
+ + bytes: Vec<u8>,
2970
+ + stop_after: Option<usize>,
2971
+ + pause: Option<(std_mpsc::Sender<()>, std_mpsc::Receiver<()>)>,
2972
+ + }
2973
+ + impl AsRawFd for MemoryWriter {
2974
+ + fn as_raw_fd(&self) -> std::os::fd::RawFd {
2975
+ + panic!("pure actor test must not invoke native PTY operations")
2976
+ + }
2977
+ + }
2978
+ + impl Read for MemoryWriter {
2979
+ + fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
2980
+ + panic!("pure submission test must not read native output")
2981
+ + }
2982
+ + }
2983
+ + impl Write for MemoryWriter {
2984
+ + fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
2985
+ + if let Some((started, resume)) = self.pause.take() {
2986
+ + started.send(()).expect("write started");
2987
+ + resume
2988
+ + .recv_timeout(Duration::from_secs(2))
2989
+ + .expect("controlled resume");
2990
+ + }
2991
+ + let available = self.stop_after.map_or(bytes.len(), |limit| {
2992
+ + limit.saturating_sub(self.bytes.len()).min(bytes.len())
2993
+ + });
2994
+ + if available == 0 {
2995
+ + return Err(std::io::ErrorKind::WouldBlock.into());
2996
+ + }
2997
+ + self.bytes.extend_from_slice(&bytes[..available]);
2998
+ + Ok(available)
2999
+ + }
3000
+ + fn flush(&mut self) -> std::io::Result<()> {
3001
+ + Ok(())
3002
+ + }
3003
+ + }
3004
+ + fn controlled_runner() -> (
3005
+ + PtyIoActorRunner<MemoryWriter, NoNativeDescriptor>,
3006
+ + mpsc::Sender<PtyIoDataCommand>,
3007
+ + Arc<ControlledLive>,
3008
+ + GuardedPromptOrigin,
3009
+ + ) {
3010
+ + let live = Arc::new(ControlledLive(AtomicBool::new(true)));
3011
+ + let origin = Arc::new(GuardedChildOrigin {
3012
+ + runtime_incarnation: "00000000-0000-4000-8000-000000000001".into(),
3013
+ + child: GuardedChildIdentity {
3014
+ + pid: 123,
3015
+ + uid: 1000,
3016
+ + gid: 1000,
3017
+ + groups: vec![1000],
3018
+ + parent_pid: 99,
3019
+ + started_at: "linux:00000000-0000-4000-8000-000000000002:12345".into(),
3020
+ + host: "source-fixture".into(),
3021
+ + },
3022
+ + exited: AtomicBool::new(false),
3023
+ + handle: live.clone(),
3024
+ + });
3025
+ + let expected = origin.projection("term_aa11");
3026
+ + let (tx, data_rx) = mpsc::channel(1);
3027
+ + let (_, control_rx) = std_mpsc::channel();
3028
+ + (
3029
+ + PtyIoActorRunner {
3030
+ + pane_id: 1,
3031
+ + origin: Some(origin),
3032
+ + user_writes: Arc::new(Mutex::new(UserWriteGate {
3033
+ + accepting: true,
3034
+ + guarded_pending: 0,
3035
+ + scope_inhibited: false,
3036
+ + })),
3037
+ + file: MemoryWriter::default(),
3038
+ + data_rx,
3039
+ + control_rx,
3040
+ + state: ActorState::Running,
3041
+ + pending_writes: VecDeque::new(),
3042
+ + current_write_offset: 0,
3043
+ + active_submission: None,
3044
+ + pending_handoff: None,
3045
+ + wake_read_fd: NoNativeDescriptor,
3046
+ + controls: Arc::new(Mutex::new(SharedPtyControls::default())),
3047
+ + response_order: Arc::new(Mutex::new(())),
3048
+ + on_read: Box::new(|_| panic!("pure actor test must not read")),
3049
+ + on_reader_exit: None,
3050
+ + poll_observer: None,
3051
+ + },
3052
+ + tx,
3053
+ + live,
3054
+ + expected,
3055
+ + )
3056
+ + }
3057
+ + fn queue(
3058
+ + runner: &PtyIoActorRunner<MemoryWriter, NoNativeDescriptor>,
3059
+ + tx: &mpsc::Sender<PtyIoDataCommand>,
3060
+ + expected: GuardedPromptOrigin,
3061
+ + ) -> Result<std_mpsc::Receiver<Result<(), GuardedPromptError>>, GuardedPromptError> {
3062
+ + queue_guarded_command(
3063
+ + tx,
3064
+ + &runner.user_writes,
3065
+ + Bytes::from_static(b"focus+text"),
3066
+ + Bytes::from_static(b"\r"),
3067
+ + Duration::ZERO,
3068
+ + expected,
3069
+ + )
3070
+ + }
3071
+ + #[test]
3072
+ + fn actual_actor_queued_origin_exit_writes_no_focus_or_text() {
3073
+ + let (mut runner, tx, live, expected) = controlled_runner();
3074
+ + let reply = queue(&runner, &tx, expected).expect("queued");
3075
+ + live.0.store(false, Ordering::Release);
3076
+ + let command = runner.data_rx.try_recv().expect("command");
3077
+ + assert!(!runner.handle_data_command(command));
3078
+ + assert_eq!(
3079
+ + reply.try_recv().expect("refused"),
3080
+ + Err(GuardedPromptError::OriginExited)
3081
+ + );
3082
+ + assert!(runner.file.bytes.is_empty());
3083
+ + assert!(runner.pending_writes.is_empty());
3084
+ + }
3085
+ + #[test]
3086
+ + fn actual_actor_partial_text_exit_suppresses_enter_and_reports_uncertain() {
3087
+ + let (mut runner, tx, live, expected) = controlled_runner();
3088
+ + let reply = queue(&runner, &tx, expected).expect("queued");
3089
+ + let command = runner.data_rx.try_recv().expect("command");
3090
+ + runner.handle_data_command(command);
3091
+ + runner.file.stop_after = Some(2);
3092
+ + assert_eq!(
3093
+ + runner
3094
+ + .flush_pending_writes_once()
3095
+ + .expect("controlled write"),
3096
+ + None
3097
+ + );
3098
+ + live.0.store(false, Ordering::Release);
3099
+ + runner.file.stop_after = None;
3100
+ + assert_eq!(
3101
+ + runner
3102
+ + .flush_pending_writes_once()
3103
+ + .expect("controlled refusal"),
3104
+ + None
3105
+ + );
3106
+ + runner.schedule_submission_enter();
3107
+ + assert_eq!(runner.file.bytes, b"fo");
3108
+ + assert_eq!(
3109
+ + reply.try_recv().expect("uncertain"),
3110
+ + Err(GuardedPromptError::SubmissionUncertain)
3111
+ + );
3112
+ + assert!(runner.active_submission.is_none());
3113
+ + }
3114
+ + #[test]
3115
+ + fn actual_actor_full_text_then_enter_acknowledges_once() {
3116
+ + let (mut runner, tx, _, expected) = controlled_runner();
3117
+ + let reply = queue(&runner, &tx, expected).expect("queued");
3118
+ + let command = runner.data_rx.try_recv().expect("command");
3119
+ + runner.handle_data_command(command);
3120
+ + assert!(reply.try_recv().is_err());
3121
+ + let boundary = runner
3122
+ + .flush_pending_writes_once()
3123
+ + .expect("text write")
3124
+ + .expect("text boundary");
3125
+ + runner.complete_submission_boundary(boundary);
3126
+ + assert!(reply.try_recv().is_err());
3127
+ + runner.schedule_submission_enter();
3128
+ + let boundary = runner
3129
+ + .flush_pending_writes_once()
3130
+ + .expect("enter write")
3131
+ + .expect("enter boundary");
3132
+ + runner.complete_submission_boundary(boundary);
3133
+ + assert_eq!(reply.try_recv().expect("ack"), Ok(()));
3134
+ + assert!(reply.try_recv().is_err());
3135
+ + assert_eq!(runner.file.bytes, b"focus+text\r");
3136
+ + assert_eq!(runner.user_writes.lock().expect("gate").guarded_pending, 0);
3137
+ + }
3138
+ + #[test]
3139
+ + fn actual_queue_full_and_closed_are_distinct_zero_effect_refusals() {
3140
+ + let (mut runner, tx, _, expected) = controlled_runner();
3141
+ + let _reply = queue(&runner, &tx, expected.clone()).expect("queued");
3142
+ + assert!(matches!(
3143
+ + queue(&runner, &tx, expected.clone()),
3144
+ + Err(GuardedPromptError::SubmissionBusy)
3145
+ + ));
3146
+ + assert_eq!(runner.user_writes.lock().expect("gate").guarded_pending, 1);
3147
+ + runner.close_input_queue();
3148
+ + assert!(matches!(
3149
+ + queue(&runner, &tx, expected),
3150
+ + Err(GuardedPromptError::OriginUnavailable)
3151
+ + ));
3152
+ + assert_eq!(runner.user_writes.lock().expect("gate").guarded_pending, 0);
3153
+ + assert!(runner.file.bytes.is_empty());
3154
+ + }
3155
+ + #[test]
3156
+ + fn actual_actor_queued_runtime_replacement_refuses_without_focus() {
3157
+ + let (mut runner, tx, live, expected) = controlled_runner();
3158
+ + let reply = queue(&runner, &tx, expected.clone()).expect("queued");
3159
+ + runner.origin = Some(Arc::new(GuardedChildOrigin {
3160
+ + runtime_incarnation: "00000000-0000-4000-8000-000000000003".into(),
3161
+ + child: expected.child,
3162
+ + exited: AtomicBool::new(false),
3163
+ + handle: live,
3164
+ + }));
3165
+ + let command = runner.data_rx.try_recv().expect("command");
3166
+ + runner.handle_data_command(command);
3167
+ + assert_eq!(
3168
+ + reply.try_recv().expect("replacement refused"),
3169
+ + Err(GuardedPromptError::GuardMismatch)
3170
+ + );
3171
+ + assert!(runner.file.bytes.is_empty());
3172
+ + assert!(runner.pending_writes.is_empty());
3173
+ + }
3174
+ + fn inhibit(
3175
+ + runner: &mut PtyIoActorRunner<MemoryWriter, NoNativeDescriptor>,
3176
+ + expected: &GuardedPromptOrigin,
3177
+ + ) -> Result<(), InputDrainError> {
3178
+ + let (control, received) = std_mpsc::channel();
3179
+ + let reply = queue_input_inhibit(
3180
+ + runner.origin.as_deref(),
3181
+ + expected,
3182
+ + &runner.user_writes,
3183
+ + &control,
3184
+ + )?;
3185
+ + runner.handle_control_command(received.recv().expect("barrier"));
3186
+ + reply.recv().expect("drain completion")
3187
+ + }
3188
+ + #[test]
3189
+ + fn input_drain_pure_tests_queued_generic_and_guarded_are_canceled_before_ack() {
3190
+ + let (mut runner, tx, _, expected) = controlled_runner();
3191
+ + queue_plain_input(&tx, &runner.user_writes, Bytes::from_static(b"plain"))
3192
+ + .expect("plain queued");
3193
+ + assert_eq!(inhibit(&mut runner, &expected), Ok(()));
3194
+ + assert!(runner.data_rx.try_recv().is_err());
3195
+ + assert!(runner.file.bytes.is_empty());
3196
+ + assert!(queue_plain_input(&tx, &runner.user_writes, Bytes::from_static(b"late")).is_err());
3197
+ + let (mut runner, tx, _, expected) = controlled_runner();
3198
+ + let prior = queue(&runner, &tx, expected.clone()).expect("guarded queued");
3199
+ + assert_eq!(inhibit(&mut runner, &expected), Ok(()));
3200
+ + assert_eq!(
3201
+ + prior.recv().expect("canceled"),
3202
+ + Err(GuardedPromptError::OriginUnavailable)
3203
+ + );
3204
+ + assert_eq!(runner.user_writes.lock().expect("gate").guarded_pending, 0);
3205
+ + assert!(queue(&runner, &tx, expected).is_err());
3206
+ + assert!(runner.file.bytes.is_empty());
3207
+ + }
3208
+ + #[test]
3209
+ + fn input_drain_pure_tests_terminal_response_queues_are_empty_before_and_after_ack() {
3210
+ + let (mut runner, _, _, expected) = controlled_runner();
3211
+ + runner
3212
+ + .controls
3213
+ + .lock()
3214
+ + .expect("controls")
3215
+ + .terminal_responses
3216
+ + .push(Bytes::from_static(b"queued-response"));
3217
+ + runner.enqueue_terminal_responses(vec![Bytes::from_static(b"pending-response")]);
3218
+ + assert_eq!(inhibit(&mut runner, &expected), Ok(()));
3219
+ + assert!(runner
3220
+ + .controls
3221
+ + .lock()
3222
+ + .expect("controls")
3223
+ + .terminal_responses
3224
+ + .is_empty());
3225
+ + assert!(runner.pending_writes.is_empty());
3226
+ + runner.enqueue_terminal_responses(vec![Bytes::from_static(b"late-response")]);
3227
+ + runner.apply_pending_controls();
3228
+ + runner.flush_pending_writes_once().expect("inhibited flush");
3229
+ + assert!(runner.pending_writes.is_empty());
3230
+ + assert!(runner.file.bytes.is_empty());
3231
+ + }
3232
+ + #[test]
3233
+ + fn input_drain_pure_tests_partial_guarded_write_stays_uncertain_and_suppresses_enter() {
3234
+ + let (mut runner, tx, _, expected) = controlled_runner();
3235
+ + let prior = queue(&runner, &tx, expected.clone()).expect("queued");
3236
+ + let command = runner.data_rx.try_recv().expect("command");
3237
+ + runner.handle_data_command(command);
3238
+ + runner.file.stop_after = Some(2);
3239
+ + runner.flush_pending_writes_once().expect("partial");
3240
+ + assert_eq!(inhibit(&mut runner, &expected), Ok(()));
3241
+ + assert_eq!(
3242
+ + prior.recv().expect("canceled"),
3243
+ + Err(GuardedPromptError::SubmissionUncertain)
3244
+ + );
3245
+ + runner.file.stop_after = None;
3246
+ + runner.schedule_submission_enter();
3247
+ + runner.flush_pending_writes_once().expect("inhibited");
3248
+ + assert_eq!(runner.file.bytes, b"fo");
3249
+ + }
3250
+ + #[test]
3251
+ + fn input_drain_pure_tests_lost_ack_repeat_and_exited_same_actor_are_positive() {
3252
+ + let (mut runner, _, live, expected) = controlled_runner();
3253
+ + live.0.store(false, Ordering::Release);
3254
+ + runner.origin.as_ref().expect("origin").mark_exited();
3255
+ + let (control, received) = std_mpsc::channel();
3256
+ + let lost = queue_input_inhibit(
3257
+ + runner.origin.as_deref(),
3258
+ + &expected,
3259
+ + &runner.user_writes,
3260
+ + &control,
3261
+ + )
3262
+ + .expect("first");
3263
+ + drop(lost);
3264
+ + runner.handle_control_command(received.recv().expect("barrier"));
3265
+ + assert_eq!(inhibit(&mut runner, &expected), Ok(()));
3266
+ + assert!(!runner.user_writes.lock().expect("gate").reopen());
3267
+ + }
3268
+ + #[test]
3269
+ + fn input_drain_pure_tests_wrong_imported_replaced_or_released_actor_never_grants() {
3270
+ + let (mut runner, _, _, expected) = controlled_runner();
3271
+ + let mut other = expected.clone();
3272
+ + other.runtime_incarnation = "00000000-0000-4000-8000-000000000009".into();
3273
+ + assert_eq!(
3274
+ + inhibit(&mut runner, &other),
3275
+ + Err(InputDrainError::GuardMismatch)
3276
+ + );
3277
+ + assert!(runner.user_writes.lock().expect("gate").accepting);
3278
+ + runner.origin = None;
3279
+ + assert_eq!(
3280
+ + inhibit(&mut runner, &expected),
3281
+ + Err(InputDrainError::OriginUnavailable)
3282
+ + );
3283
+ + let (mut runner, _, _, expected) = controlled_runner();
3284
+ + runner.state = ActorState::Released;
3285
+ + assert_eq!(
3286
+ + inhibit(&mut runner, &expected),
3287
+ + Err(InputDrainError::Unavailable)
3288
+ + );
3289
+ + }
3290
+ + #[test]
3291
+ + fn input_drain_pure_tests_control_loss_is_inhibited_unconfirmed() {
3292
+ + let (runner, _, _, expected) = controlled_runner();
3293
+ + let (control, received) = std_mpsc::channel();
3294
+ + drop(received);
3295
+ + assert!(matches!(
3296
+ + queue_input_inhibit(
3297
+ + runner.origin.as_deref(),
3298
+ + &expected,
3299
+ + &runner.user_writes,
3300
+ + &control
3301
+ + ),
3302
+ + Err(InputDrainError::Unavailable)
3303
+ + ));
3304
+ + let mut gate = runner.user_writes.lock().expect("gate");
3305
+ + assert!(gate.scope_inhibited);
3306
+ + assert!(!gate.accepting);
3307
+ + assert!(!gate.reopen());
3308
+ + }
3309
+ + #[test]
3310
+ + fn input_drain_pure_tests_prequeued_handoff_rollback_cannot_reopen_or_transfer() {
3311
+ + let (mut runner, _, _, expected) = controlled_runner();
3312
+ + let (control, received) = std_mpsc::channel();
3313
+ + runner.control_rx = received;
3314
+ + let (rollback, rollback_result) = std_mpsc::channel();
3315
+ + let (handoff, handoff_result) = std_mpsc::channel();
3316
+ + let (duplicate, duplicate_result) = std_mpsc::channel();
3317
+ + control
3318
+ + .send(PtyIoControlCommand::RollbackHandoff(rollback))
3319
+ + .expect("queued rollback");
3320
+ + control
3321
+ + .send(PtyIoControlCommand::BeginHandoff(handoff))
3322
+ + .expect("queued handoff");
3323
+ + control
3324
+ + .send(PtyIoControlCommand::DuplicateForHandoff(duplicate))
3325
+ + .expect("queued duplicate");
3326
+ + let drained = queue_input_inhibit(
3327
+ + runner.origin.as_deref(),
3328
+ + &expected,
3329
+ + &runner.user_writes,
3330
+ + &control,
3331
+ + )
3332
+ + .expect("inhibit admission");
3333
+ + runner.state = ActorState::Quiesced;
3334
+ + assert!(!runner.drain_control_commands());
3335
+ + assert!(rollback_result.recv().expect("rollback refusal").is_err());
3336
+ + assert!(handoff_result.recv().expect("handoff refusal").is_err());
3337
+ + assert!(duplicate_result.recv().expect("dup refusal").is_err()); // descriptor sentinel would panic on a syscall
3338
+ + assert_eq!(drained.recv().expect("drain completion"), Ok(()));
3339
+ + assert!(!runner.user_writes.lock().expect("gate").accepting);
3340
+ + assert_eq!(inhibit(&mut runner, &expected), Ok(()));
3341
+ + }
3342
+ + #[test]
3343
+ + fn input_drain_pure_tests_paused_enqueue_cannot_append_after_drain_ack() {
3344
+ + let (mut runner, tx, _, expected) = controlled_runner();
3345
+ + let gate = Arc::clone(&runner.user_writes);
3346
+ + let (ready, start) = std_mpsc::channel();
3347
+ + let sender = std::thread::spawn(move || {
3348
+ + start
3349
+ + .recv_timeout(Duration::from_secs(2))
3350
+ + .expect("resume enqueue");
3351
+ + queue_plain_input(&tx, &gate, Bytes::from_static(b"delayed"))
3352
+ + });
3353
+ + assert_eq!(inhibit(&mut runner, &expected), Ok(()));
3354
+ + ready.send(()).expect("resume sender");
3355
+ + assert!(matches!(
3356
+ + sender.join().expect("sender"),
3357
+ + Err(mpsc::error::TrySendError::Closed(_))
3358
+ + ));
3359
+ + assert!(runner.data_rx.try_recv().is_err());
3360
+ + assert!(runner.file.bytes.is_empty());
3361
+ + }
3362
+ + #[test]
3363
+ + fn input_drain_pure_tests_paused_inflight_write_finishes_before_inhibit_and_no_later_write() {
3364
+ + let (mut runner, _, _, expected) = controlled_runner();
3365
+ + runner.enqueue_write(Bytes::from_static(b"first"));
3366
+ + let (started, observed) = std_mpsc::channel();
3367
+ + let (resume, proceed) = std_mpsc::channel();
3368
+ + runner.file.pause = Some((started, proceed));
3369
+ + let gate = Arc::clone(&runner.user_writes);
3370
+ + let origin = Arc::clone(runner.origin.as_ref().expect("origin"));
3371
+ + let (control, received) = std_mpsc::channel();
3372
+ + let worker = std::thread::spawn(move || {
3373
+ + runner.flush_pending_writes_once().expect("inflight write");
3374
+ + runner.handle_control_command(
3375
+ + received
3376
+ + .recv_timeout(Duration::from_secs(2))
3377
+ + .expect("barrier"),
3378
+ + );
3379
+ + runner.enqueue_write(Bytes::from_static(b"late"));
3380
+ + runner.flush_pending_writes_once().expect("late suppressed");
3381
+ + runner
3382
+ + });
3383
+ + observed
3384
+ + .recv_timeout(Duration::from_secs(2))
3385
+ + .expect("write paused");
3386
+ + let (attempted, attempting) = std_mpsc::channel();
3387
+ + let inhibitor = std::thread::spawn(move || {
3388
+ + attempted.send(()).expect("attempt inhibit");
3389
+ + queue_input_inhibit(Some(&origin), &expected, &gate, &control)
3390
+ + .expect("inhibit")
3391
+ + .recv_timeout(Duration::from_secs(2))
3392
+ + .expect("ack")
3393
+ + });
3394
+ + attempting
3395
+ + .recv_timeout(Duration::from_secs(2))
3396
+ + .expect("inhibit pending");
3397
+ + resume.send(()).expect("finish prior write");
3398
+ + assert_eq!(inhibitor.join().expect("inhibitor"), Ok(()));
3399
+ + let runner = worker.join().expect("worker");
3400
+ + assert_eq!(runner.file.bytes, b"first");
3401
+ + assert!(runner.pending_writes.is_empty());
3402
+ + }
3403
+ +}
3404
+ diff --git a/src/pty/backend/unix.rs b/src/pty/backend/unix.rs
3405
+ index 5871004..d0c2916 100644
3406
+ --- a/src/pty/backend/unix.rs
3407
+ +++ b/src/pty/backend/unix.rs
3408
+ @@ -7,6 +7,7 @@ use crate::pty::fd;
3409
+ pub(crate) struct SpawnedPty {
3410
+ pub master_fd: OwnedFd,
3411
+ pub child: Box<dyn Child + Send + Sync>,
3412
+ + pub origin: Option<std::sync::Arc<crate::platform::guarded_prompt::GuardedChildOrigin>>,
3413
+ }
3414
+
3415
+ pub(crate) fn spawn_with_portable_pty(
3416
+ @@ -33,11 +34,15 @@ pub(crate) fn spawn_with_portable_pty(
3417
+ .slave
3418
+ .spawn_command(cmd)
3419
+ .map_err(|err| std::io::Error::other(err.to_string()))?;
3420
+ + let origin = child
3421
+ + .process_id()
3422
+ + .and_then(|pid| crate::platform::capture_guarded_child(pid).ok());
3423
+ drop(pair);
3424
+
3425
+ Ok(SpawnedPty {
3426
+ master_fd: actor_fd,
3427
+ child,
3428
+ + origin,
3429
+ })
3430
+ }
3431
+
3432
+ diff --git a/src/server/headless.rs b/src/server/headless.rs
3433
+ index 18869e0..9bb0328 100644
3434
+ --- a/src/server/headless.rs
3435
+ +++ b/src/server/headless.rs
3436
+ @@ -2957,7 +2957,10 @@ impl HeadlessServer {
3437
+ return changed;
3438
+ }
3439
+ let alt_screen_read_spec = self.alt_screen_read_spec(&msg.request);
3440
+ - if matches!(&msg.request.method, api::schema::Method::AgentPrompt(_)) {
3441
+ + if matches!(
3442
+ + &msg.request.method,
3443
+ + api::schema::Method::AgentPrompt(_) | api::schema::Method::TerminalInhibitInput(_)
3444
+ + ) {
3445
+ let deferred_changed = self
3446
+ .app
3447
+ .handle_deferred_agent_api_request(msg.request, msg.respond_to);
3448
+ diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
3449
+ index 9171340..a971ef6 100644
3450
+ --- a/src/terminal/runtime.rs
3451
+ +++ b/src/terminal/runtime.rs
3452
+ @@ -478,6 +478,41 @@ impl TerminalRuntime {
3453
+ self.0.try_send_bytes(bytes)
3454
+ }
3455
+
3456
+ + pub(crate) fn guarded_prompt_origin(
3457
+ + &self,
3458
+ + terminal_id: &str,
3459
+ + ) -> Option<crate::platform::guarded_prompt::GuardedPromptOrigin> {
3460
+ + self.0.guarded_prompt_origin(terminal_id)
3461
+ + }
3462
+ + pub(crate) fn captured_input_origin(
3463
+ + &self,
3464
+ + terminal_id: &str,
3465
+ + ) -> Option<crate::platform::guarded_prompt::GuardedPromptOrigin> {
3466
+ + self.0.captured_input_origin(terminal_id)
3467
+ + }
3468
+ + pub(crate) fn inhibit_input(
3469
+ + &self,
3470
+ + expected: &crate::platform::guarded_prompt::GuardedPromptOrigin,
3471
+ + ) -> Result<
3472
+ + std::sync::mpsc::Receiver<Result<(), crate::platform::guarded_prompt::InputDrainError>>,
3473
+ + crate::platform::guarded_prompt::InputDrainError,
3474
+ + > {
3475
+ + self.0.inhibit_input(expected)
3476
+ + }
3477
+ + pub(crate) fn queue_guarded_submission(
3478
+ + &self,
3479
+ + text: Bytes,
3480
+ + enter: Bytes,
3481
+ + delay: std::time::Duration,
3482
+ + expected: crate::platform::guarded_prompt::GuardedPromptOrigin,
3483
+ + ) -> Result<
3484
+ + std::sync::mpsc::Receiver<Result<(), crate::platform::guarded_prompt::GuardedPromptError>>,
3485
+ + crate::platform::guarded_prompt::GuardedPromptError,
3486
+ + > {
3487
+ + self.0
3488
+ + .queue_guarded_submission(text, enter, delay, expected)
3489
+ + }
3490
+ +
3491
+ pub fn queue_user_input_submission(
3492
+ &self,
3493
+ text: Bytes,