ace-herdr 0.8.1 → 0.10.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.
@@ -1,7 +1,7 @@
1
1
  From 7a2e78b4e92d2b694bd8a06c1ef4943915c399b2 Mon Sep 17 00:00:00 2001
2
2
  From: Miguel <mc@cs3b.com>
3
3
  Date: Wed, 7 Oct 2026 00:43:20 +0100
4
- Subject: [PATCH] feat: guard and permanently drain original-terminal input
4
+ Subject: [PATCH 1/6] feat: guard and permanently drain original-terminal input
5
5
 
6
6
  ---
7
7
  docs/next/api/herdr-api.schema.json | 338 ++++++
@@ -3491,3 +3491,1779 @@ index 9171340..a971ef6 100644
3491
3491
  pub fn queue_user_input_submission(
3492
3492
  &self,
3493
3493
  text: Bytes,
3494
+ --
3495
+ 2.54.0 (Apple Git-157)
3496
+
3497
+
3498
+ From 78b571c45ebe80425d8e69bc6f19e16357043131 Mon Sep 17 00:00:00 2001
3499
+ From: Miguel <mc@cs3b.com>
3500
+ Date: Wed, 4 Nov 2026 02:57:12 +0000
3501
+ Subject: [PATCH 2/6] fix: validate Pi report acknowledgements before accepting
3502
+ delivery
3503
+
3504
+ ---
3505
+ .../assets/herdr-agent-state.test.ts | 28 +++++++++++++------
3506
+ .../assets/pi/herdr-agent-state.ts | 17 +++++++++--
3507
+ src/integration/mod.rs | 2 +-
3508
+ 3 files changed, 35 insertions(+), 12 deletions(-)
3509
+
3510
+ diff --git a/src/integration/assets/herdr-agent-state.test.ts b/src/integration/assets/herdr-agent-state.test.ts
3511
+ index abfeb0e..7657c2c 100644
3512
+ --- a/src/integration/assets/herdr-agent-state.test.ts
3513
+ +++ b/src/integration/assets/herdr-agent-state.test.ts
3514
+ @@ -119,8 +119,9 @@ async function startRecordingServer(name: string): Promise<unknown[]> {
3515
+ if (newline === -1) {
3516
+ return;
3517
+ }
3518
+ - requests.push(JSON.parse(input.slice(0, newline)));
3519
+ - socket.end("{}\n");
3520
+ + const request = JSON.parse(input.slice(0, newline));
3521
+ + requests.push(request);
3522
+ + socket.end(JSON.stringify({ id: request.id, result: { type: "ok" } }) + "\n");
3523
+ });
3524
+ });
3525
+ server = recordingServer;
3526
+ @@ -424,10 +425,10 @@ test("Pi waits for a replacement session report before publishing state", async
3527
+ const request = JSON.parse(input.slice(0, newline));
3528
+ requests.push(request);
3529
+ if (isRecord(request) && request.method === "pane.report_agent_session") {
3530
+ - acknowledgeSessionReport = () => socket.end("{}\n");
3531
+ + acknowledgeSessionReport = () => socket.end(JSON.stringify({ id: request.id, result: { type: "ok" } }) + "\n");
3532
+ return;
3533
+ }
3534
+ - socket.end("{}\n");
3535
+ + socket.end(JSON.stringify({ id: request.id, result: { type: "ok" } }) + "\n");
3536
+ });
3537
+ });
3538
+ server = recordingServer;
3539
+ @@ -481,7 +482,7 @@ test("Pi waits for a replacement session report before publishing state", async
3540
+ ]);
3541
+ });
3542
+
3543
+ -async function startDroppedFirstResponseServer(name: string) {
3544
+ +async function startDroppedFirstResponseServer(name: string, firstResponse = "unanswered") {
3545
+ const recordingSocketPath = join(tmpdir(), `herdr-${name}-${process.pid}.sock`);
3546
+ socketPath = recordingSocketPath;
3547
+ await rm(recordingSocketPath, { force: true });
3548
+ @@ -503,10 +504,16 @@ async function startDroppedFirstResponseServer(name: string) {
3549
+ const request = JSON.parse(input.slice(0, newline));
3550
+ attemptedRequests.push(request);
3551
+ if (connectionNumber === 1) {
3552
+ + if (firstResponse === "error") socket.end(JSON.stringify({id: request.id, error: {code: "pane_not_found"}}) + "\n");
3553
+ + if (firstResponse === "wrong-id") socket.end(JSON.stringify({id: "unrelated", result: {type: "ok"}}) + "\n");
3554
+ + if (firstResponse === "malformed") socket.end("not-json\n");
3555
+ + if (firstResponse === "oversized") socket.end("x".repeat(65_537) + "\n");
3556
+ return;
3557
+ }
3558
+ deliveredRequests.push(request);
3559
+ - socket.end("{}\n");
3560
+ + const response = JSON.stringify({ id: request.id, result: { type: "ok" } }) + "\n";
3561
+ + socket.write(response.slice(0, 5));
3562
+ + setTimeout(() => socket.end(response.slice(5)), 5);
3563
+ });
3564
+ });
3565
+ server = recordingServer;
3566
+ @@ -592,9 +599,10 @@ test("Oh My Pi keeps working when a turn ends with a scheduled continuation", as
3567
+ expect(requestStates(requests)).toEqual(["idle", "working", "idle"]);
3568
+ });
3569
+
3570
+ -test("Pi retries working state after an unanswered socket attempt", async () => {
3571
+ +for (const firstResponse of ["unanswered", "error", "wrong-id", "malformed", "oversized"]) {
3572
+ +test(`Pi retries working state after ${firstResponse} acknowledgement`, async () => {
3573
+ const { attemptedRequests, deliveredRequests, connectionCount } =
3574
+ - await startDroppedFirstResponseServer("pi-retry");
3575
+ + await startDroppedFirstResponseServer("pi-retry", firstResponse);
3576
+ const { handlers, pi } = createExtensionHarness();
3577
+
3578
+ const { default: install } = await importFresh("./pi/herdr-agent-state.ts");
3579
+ @@ -629,12 +637,14 @@ test("Pi retries working state after an unanswered socket attempt", async () =>
3580
+ await Bun.sleep(5);
3581
+ }
3582
+
3583
+ - expect(connectionCount()).toBeGreaterThanOrEqual(2);
3584
+ + expect(connectionCount()).toBe(2);
3585
+ expect(attemptedRequests.length).toBeGreaterThanOrEqual(2);
3586
+ expect(attemptedRequests[1]).toEqual(attemptedRequests[0]);
3587
+ expect(reportedWorking()).toBe(true);
3588
+ });
3589
+
3590
+ +}
3591
+ +
3592
+ function completionHandlers(handlers: Map<string, Handler>): string[] {
3593
+ return ["agent_end", "agent_settled"].filter((event) => handlers.has(event));
3594
+ }
3595
+ diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts
3596
+ index 276c964..0c0970a 100644
3597
+ --- a/src/integration/assets/pi/herdr-agent-state.ts
3598
+ +++ b/src/integration/assets/pi/herdr-agent-state.ts
3599
+ @@ -2,7 +2,7 @@
3600
+ // managed by herdr; reinstalling or updating the integration overwrites this file.
3601
+ // add custom hooks/plugins beside this file instead of editing it.
3602
+ // HERDR_INTEGRATION_ID=pi
3603
+ -// HERDR_INTEGRATION_VERSION=9
3604
+ +// HERDR_INTEGRATION_VERSION=10
3605
+ // @ts-nocheck
3606
+
3607
+ import net from "node:net";
3608
+ @@ -40,7 +40,20 @@ function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolea
3609
+ const socket = net.createConnection(socketEndpoint!);
3610
+ socket.on("error", () => finish(false));
3611
+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
3612
+ - socket.on("data", () => finish(true));
3613
+ + let reply = Buffer.alloc(0);
3614
+ + socket.on("data", (chunk) => {
3615
+ + reply = Buffer.concat([reply, chunk]);
3616
+ + if (reply.length > 65_536) return finish(false);
3617
+ + const newline = reply.indexOf(10);
3618
+ + if (newline === -1) return;
3619
+ + try {
3620
+ + const response = JSON.parse(reply.subarray(0, newline).toString("utf8"));
3621
+ + finish(newline === reply.length - 1 && response?.id === (request as any)?.id &&
3622
+ + response?.result != null && !Object.hasOwn(response, "error"));
3623
+ + } catch {
3624
+ + finish(false);
3625
+ + }
3626
+ + });
3627
+ socket.on("end", () => finish(false));
3628
+ timeout = setTimeout(() => finish(false), timeoutMs);
3629
+ timeout.unref?.();
3630
+ diff --git a/src/integration/mod.rs b/src/integration/mod.rs
3631
+ index 673225a..546289d 100644
3632
+ --- a/src/integration/mod.rs
3633
+ +++ b/src/integration/mod.rs
3634
+ @@ -35,7 +35,7 @@ pub(crate) const EXPERIMENTAL_INTEGRATION_TARGET_LABELS: &[&str] = &["letta"];
3635
+
3636
+ const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts";
3637
+ const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts");
3638
+ -const PI_INTEGRATION_VERSION: u32 = 9;
3639
+ +const PI_INTEGRATION_VERSION: u32 = 10;
3640
+ const OMP_EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts";
3641
+ const OMP_EXTENSION_ASSET: &str = include_str!("assets/omp/herdr-agent-state.ts");
3642
+ const OMP_INTEGRATION_VERSION: u32 = 10;
3643
+ --
3644
+ 2.54.0 (Apple Git-157)
3645
+
3646
+
3647
+ From 27ff0cfe4255c3b31c81e8e42964b318f1d8fbd4 Mon Sep 17 00:00:00 2001
3648
+ From: Miguel <mc@cs3b.com>
3649
+ Date: Wed, 4 Nov 2026 04:17:44 +0000
3650
+ Subject: [PATCH 3/6] fix: resolve agent queries by immutable terminal identity
3651
+
3652
+ ---
3653
+ src/app/terminal_targets.rs | 35 +++++++++++++++++++++++++++++++++++
3654
+ 1 file changed, 35 insertions(+)
3655
+
3656
+ diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs
3657
+ index 658eeaa..003c30c 100644
3658
+ --- a/src/app/terminal_targets.rs
3659
+ +++ b/src/app/terminal_targets.rs
3660
+ @@ -93,6 +93,16 @@ impl App {
3661
+ &self,
3662
+ target: &str,
3663
+ ) -> Result<TerminalTarget, TerminalTargetError> {
3664
+ + if target.starts_with("term_") {
3665
+ + let resolved = self.resolve_guarded_agent_target(target)?;
3666
+ + return if self.target_is_agent(&resolved) {
3667
+ + Ok(resolved)
3668
+ + } else {
3669
+ + Err(TerminalTargetError::NotFound {
3670
+ + target: target.into(),
3671
+ + })
3672
+ + };
3673
+ + }
3674
+ if let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(target) {
3675
+ if let Some(resolved) = self
3676
+ .terminal_target_for_pane(ws_idx, pane_id)
3677
+ @@ -225,6 +235,31 @@ mod tests {
3678
+ )
3679
+ }
3680
+
3681
+ + #[test]
3682
+ + fn isolated_agent_target_is_exact_terminal_without_name_fallback() {
3683
+ + let mut app = test_app();
3684
+ + app.state = AppState::test_with_adversarial_identity_state();
3685
+ + let targets = app.terminal_targets();
3686
+ + let first = &targets[0];
3687
+ + app.state
3688
+ + .terminals
3689
+ + .get_mut(first.terminal_id.as_str())
3690
+ + .unwrap()
3691
+ + .set_agent_name("term_missing".into());
3692
+ + assert_eq!(
3693
+ + app.resolve_agent_target(&first.terminal_id).unwrap(),
3694
+ + *first
3695
+ + );
3696
+ + assert!(matches!(
3697
+ + app.resolve_agent_target("term_missing"),
3698
+ + Err(TerminalTargetError::NotFound { .. })
3699
+ + ));
3700
+ + assert!(matches!(
3701
+ + app.resolve_agent_target(&targets[1].terminal_id),
3702
+ + Err(TerminalTargetError::NotFound { .. })
3703
+ + ));
3704
+ + }
3705
+ +
3706
+ #[test]
3707
+ fn named_targets_follow_terminal_identity_after_pane_and_tab_reordering() {
3708
+ let mut app = test_app();
3709
+ --
3710
+ 2.54.0 (Apple Git-157)
3711
+
3712
+
3713
+ From 18242eabb417277aa2ab987c000e63c6ffc208e2 Mon Sep 17 00:00:00 2001
3714
+ From: Miguel <mc@cs3b.com>
3715
+ Date: Tue, 10 Nov 2026 22:59:15 +0000
3716
+ Subject: [PATCH 4/6] feat: launch ordinary agents with private explicit
3717
+ environments
3718
+
3719
+ ---
3720
+ docs/next/api/herdr-api.schema.json | 55 +++++++++
3721
+ src/api/mod.rs | 1 +
3722
+ src/api/schema.rs | 2 +
3723
+ src/api/schema/agents.rs | 9 ++
3724
+ src/api/schema/tests.rs | 10 ++
3725
+ src/api/server.rs | 1 +
3726
+ src/app/agents.rs | 20 ++-
3727
+ src/app/api.rs | 3 +
3728
+ src/app/api/agents.rs | 102 +++++++++-------
3729
+ src/app/api/panes.rs | 79 +++++++++++-
3730
+ src/cli/agent.rs | 41 +++++--
3731
+ src/pane.rs | 66 +++++++---
3732
+ src/platform/guarded_prompt.rs | 7 +-
3733
+ src/platform/mod.rs | 1 +
3734
+ src/platform/process_environment.rs | 182 ++++++++++++++++++++++++++++
3735
+ tests/cli/agents.rs | 79 ++++++++++++
3736
+ tests/process_environment.rs | 74 +++++++++++
3737
+ 17 files changed, 652 insertions(+), 80 deletions(-)
3738
+ create mode 100644 src/platform/process_environment.rs
3739
+ create mode 100644 tests/process_environment.rs
3740
+
3741
+ diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json
3742
+ index 022c899..2ee5fb4 100644
3743
+ --- a/docs/next/api/herdr-api.schema.json
3744
+ +++ b/docs/next/api/herdr-api.schema.json
3745
+ @@ -1475,6 +1475,45 @@
3746
+ ],
3747
+ "type": "object"
3748
+ },
3749
+ + "AgentStartWithEnvParams": {
3750
+ + "description": "Explicit environment support has its own method so older servers refuse it\ninstead of ignoring an unknown field and inheriting ambient credentials.",
3751
+ + "properties": {
3752
+ + "args": {
3753
+ + "items": {
3754
+ + "type": "string"
3755
+ + },
3756
+ + "type": "array"
3757
+ + },
3758
+ + "env_file": {
3759
+ + "type": "string"
3760
+ + },
3761
+ + "kind": {
3762
+ + "type": "string"
3763
+ + },
3764
+ + "name": {
3765
+ + "type": "string"
3766
+ + },
3767
+ + "pane_id": {
3768
+ + "type": "string"
3769
+ + },
3770
+ + "timeout_ms": {
3771
+ + "description": "Startup timeout in milliseconds. Values must be greater than 3000 and at most 300000.",
3772
+ + "format": "uint64",
3773
+ + "minimum": 0,
3774
+ + "type": [
3775
+ + "integer",
3776
+ + "null"
3777
+ + ]
3778
+ + }
3779
+ + },
3780
+ + "required": [
3781
+ + "name",
3782
+ + "kind",
3783
+ + "pane_id",
3784
+ + "env_file"
3785
+ + ],
3786
+ + "type": "object"
3787
+ + },
3788
+ "AgentStatus": {
3789
+ "enum": [
3790
+ "idle",
3791
+ @@ -5589,6 +5628,22 @@
3792
+ ],
3793
+ "type": "object"
3794
+ },
3795
+ + {
3796
+ + "properties": {
3797
+ + "method": {
3798
+ + "const": "agent.start_with_env",
3799
+ + "type": "string"
3800
+ + },
3801
+ + "params": {
3802
+ + "$ref": "#/schemas/request/$defs/AgentStartWithEnvParams"
3803
+ + }
3804
+ + },
3805
+ + "required": [
3806
+ + "method",
3807
+ + "params"
3808
+ + ],
3809
+ + "type": "object"
3810
+ + },
3811
+ {
3812
+ "properties": {
3813
+ "method": {
3814
+ diff --git a/src/api/mod.rs b/src/api/mod.rs
3815
+ index 39638a0..08ea114 100644
3816
+ --- a/src/api/mod.rs
3817
+ +++ b/src/api/mod.rs
3818
+ @@ -50,6 +50,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
3819
+ | Method::AgentViewClear(_)
3820
+ | Method::AgentFocus(_)
3821
+ | Method::AgentStart(_)
3822
+ + | Method::AgentStartWithEnv(_)
3823
+ | Method::AgentPrompt(_)
3824
+ | Method::TerminalInhibitInput(_)
3825
+ | Method::AgentSendKeys(_)
3826
+ diff --git a/src/api/schema.rs b/src/api/schema.rs
3827
+ index d7ea332..c770da2 100644
3828
+ --- a/src/api/schema.rs
3829
+ +++ b/src/api/schema.rs
3830
+ @@ -135,6 +135,8 @@ pub enum Method {
3831
+ AgentFocus(AgentTarget),
3832
+ #[serde(rename = "agent.start")]
3833
+ AgentStart(AgentStartParams),
3834
+ + #[serde(rename = "agent.start_with_env")]
3835
+ + AgentStartWithEnv(AgentStartWithEnvParams),
3836
+ #[serde(rename = "agent.prompt")]
3837
+ AgentPrompt(AgentPromptParams),
3838
+ #[serde(rename = "terminal.inhibit_input")]
3839
+ diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs
3840
+ index a5c3007..0b09221 100644
3841
+ --- a/src/api/schema/agents.rs
3842
+ +++ b/src/api/schema/agents.rs
3843
+ @@ -175,6 +175,15 @@ pub struct AgentStartParams {
3844
+ pub timeout_ms: Option<u64>,
3845
+ }
3846
+
3847
+ +/// Explicit environment support has its own method so older servers refuse it
3848
+ +/// instead of ignoring an unknown field and inheriting ambient credentials.
3849
+ +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
3850
+ +pub struct AgentStartWithEnvParams {
3851
+ + #[serde(flatten)]
3852
+ + pub start: AgentStartParams,
3853
+ + pub env_file: String,
3854
+ +}
3855
+ +
3856
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
3857
+ #[serde(deny_unknown_fields)]
3858
+ pub struct AgentPromptParams {
3859
+ diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
3860
+ index 2199451..968b2af 100644
3861
+ --- a/src/api/schema/tests.rs
3862
+ +++ b/src/api/schema/tests.rs
3863
+ @@ -92,6 +92,16 @@ fn workspace_close_group_intent_defaults_false_and_round_trips() {
3864
+ assert_eq!(serde_json::from_value::<Request>(json).unwrap(), explicit);
3865
+ }
3866
+
3867
+ +#[test]
3868
+ +fn agent_environment_start_is_an_explicit_method() {
3869
+ + let json = serde_json::json!({"id":"start", "method":"agent.start_with_env", "params":{
3870
+ + "name":"worker", "kind":"pi", "pane_id":"w1:p1", "env_file":"/tmp/private.json"
3871
+ + }});
3872
+ + let request: Request = serde_json::from_value(json.clone()).unwrap();
3873
+ + assert!(matches!(&request.method, Method::AgentStartWithEnv(_)));
3874
+ + assert_eq!(serde_json::to_value(request).unwrap(), json);
3875
+ +}
3876
+ +
3877
+ #[test]
3878
+ fn agent_start_and_prompt_requests_round_trip() {
3879
+ let start = Request {
3880
+ diff --git a/src/api/server.rs b/src/api/server.rs
3881
+ index 5f698c0..35deffc 100644
3882
+ --- a/src/api/server.rs
3883
+ +++ b/src/api/server.rs
3884
+ @@ -626,6 +626,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str {
3885
+ Method::AgentViewClear(_) => "agent.view.clear",
3886
+ Method::AgentFocus(_) => "agent.focus",
3887
+ Method::AgentStart(_) => "agent.start",
3888
+ + Method::AgentStartWithEnv(_) => "agent.start_with_env",
3889
+ Method::AgentPrompt(_) => "agent.prompt",
3890
+ Method::TerminalInhibitInput(_) => "terminal.inhibit_input",
3891
+ Method::AgentWait(_) => "agent.wait",
3892
+ diff --git a/src/app/agents.rs b/src/app/agents.rs
3893
+ index fd2a58e..9bbfb31 100644
3894
+ --- a/src/app/agents.rs
3895
+ +++ b/src/app/agents.rs
3896
+ @@ -145,6 +145,14 @@ impl App {
3897
+ pub(super) fn start_agent(
3898
+ &mut self,
3899
+ params: AgentStartParams,
3900
+ + ) -> Result<(crate::api::schema::AgentInfo, Vec<String>), AgentStartError> {
3901
+ + self.start_agent_with_environment(params, None)
3902
+ + }
3903
+ +
3904
+ + pub(super) fn start_agent_with_environment(
3905
+ + &mut self,
3906
+ + params: AgentStartParams,
3907
+ + env_file: Option<&str>,
3908
+ ) -> Result<(crate::api::schema::AgentInfo, Vec<String>), AgentStartError> {
3909
+ let name = params.name;
3910
+ if !valid_agent_name(&name) {
3911
+ @@ -196,7 +204,12 @@ impl App {
3912
+
3913
+ let mut argv = vec![crate::detect::interactive_agent_executable(kind).to_string()];
3914
+ argv.extend(params.args);
3915
+ - let command = crate::platform::interactive_shell_command(&argv, &shell_name)
3916
+ + let launch_argv = match env_file {
3917
+ + Some(path) => crate::platform::process_environment::launch_argv(path, &argv)
3918
+ + .map_err(|_| AgentStartError::InvalidEnvironment)?,
3919
+ + None => argv.clone(),
3920
+ + };
3921
+ + let command = crate::platform::interactive_shell_command(&launch_argv, &shell_name)
3922
+ .ok_or(AgentStartError::InvalidArgument)?;
3923
+ let bytes = crate::app::api_helpers::encode_api_submission(runtime, &command);
3924
+ let timeout = Duration::from_millis(
3925
+ @@ -248,6 +261,10 @@ impl App {
3926
+ code: "invalid_agent_argument".into(),
3927
+ message: "agent arguments cannot be encoded safely for the target shell".into(),
3928
+ },
3929
+ + AgentStartError::InvalidEnvironment => crate::api::schema::ErrorBody {
3930
+ + code: "invalid_agent_environment".into(),
3931
+ + message: "agent environment requires a private regular JSON file on this host".into(),
3932
+ + },
3933
+ AgentStartError::InvalidTimeout => crate::api::schema::ErrorBody {
3934
+ code: "invalid_agent_timeout".into(),
3935
+ message: INVALID_AGENT_TIMEOUT_MESSAGE.into(),
3936
+ @@ -447,6 +464,7 @@ fn live_runtime_agent(runtime: &crate::terminal::TerminalRuntime) -> Option<crat
3937
+ }
3938
+
3939
+ pub(super) enum AgentStartError {
3940
+ + InvalidEnvironment,
3941
+ InvalidName,
3942
+ UnsupportedKind(String),
3943
+ InvalidArgument,
3944
+ diff --git a/src/app/api.rs b/src/app/api.rs
3945
+ index 54132f2..fdcd9a2 100644
3946
+ --- a/src/app/api.rs
3947
+ +++ b/src/app/api.rs
3948
+ @@ -1087,6 +1087,9 @@ impl App {
3949
+ return self.handle_agent_view_clear(request.id, params);
3950
+ }
3951
+ Method::AgentStart(params) => return self.handle_agent_start(request.id, params),
3952
+ + Method::AgentStartWithEnv(params) => {
3953
+ + return self.handle_agent_start_with_env(request.id, params)
3954
+ + }
3955
+ Method::TerminalInhibitInput(_) => {
3956
+ return agents::input_drain_error(
3957
+ request.id,
3958
+ diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs
3959
+ index 17ce8c1..e119df0 100644
3960
+ --- a/src/app/api/agents.rs
3961
+ +++ b/src/app/api/agents.rs
3962
+ @@ -11,6 +11,13 @@ use crate::platform::guarded_prompt::{GuardedPromptError, GuardedPromptOrigin};
3963
+
3964
+ use super::responses::{encode_error, encode_error_body, encode_success};
3965
+
3966
+ +type QueuedGuardedPrompt = (
3967
+ + String,
3968
+ + crate::api::schema::AgentInfo,
3969
+ + GuardedPromptOrigin,
3970
+ + std::sync::mpsc::Receiver<Result<(), GuardedPromptError>>,
3971
+ +);
3972
+ +
3973
+ const AGENT_PROMPT_SUBMIT_DELAY: Duration = Duration::from_millis(300);
3974
+ // A missing completion never authorizes drain; inhibition remains installed.
3975
+ const INPUT_DRAIN_ACK_TIMEOUT: Duration = Duration::from_secs(5);
3976
+ @@ -82,6 +89,19 @@ impl App {
3977
+ encode_success(id, ResponseResult::AgentStarted { agent, argv })
3978
+ }
3979
+
3980
+ + pub(super) fn handle_agent_start_with_env(
3981
+ + &mut self,
3982
+ + id: String,
3983
+ + params: crate::api::schema::AgentStartWithEnvParams,
3984
+ + ) -> String {
3985
+ + let (agent, argv) =
3986
+ + match self.start_agent_with_environment(params.start, Some(&params.env_file)) {
3987
+ + Ok(started) => started,
3988
+ + Err(err) => return encode_error_body(id, self.agent_start_error_body(err)),
3989
+ + };
3990
+ + encode_success(id, ResponseResult::AgentStarted { agent, argv })
3991
+ + }
3992
+ +
3993
+ pub(crate) fn handle_deferred_agent_api_request(
3994
+ &mut self,
3995
+ request: crate::api::schema::Request,
3996
+ @@ -199,15 +219,7 @@ impl App {
3997
+ &mut self,
3998
+ id: String,
3999
+ params: AgentPromptParams,
4000
+ - ) -> Result<
4001
+ - (
4002
+ - String,
4003
+ - crate::api::schema::AgentInfo,
4004
+ - GuardedPromptOrigin,
4005
+ - std::sync::mpsc::Receiver<Result<(), GuardedPromptError>>,
4006
+ - ),
4007
+ - String,
4008
+ - > {
4009
+ + ) -> Result<QueuedGuardedPrompt, String> {
4010
+ let expected = params
4011
+ .validate_guarded()
4012
+ .map_err(|error| guarded_error(id.clone(), error))?;
4013
+ @@ -547,6 +559,42 @@ fn agent_not_found(id: String, target: &str) -> String {
4014
+ )
4015
+ }
4016
+
4017
+ +fn guarded_error(id: String, error: GuardedPromptError) -> String {
4018
+ + serde_json::json!(crate::api::schema::GuardedPromptErrorResponse {
4019
+ + id,
4020
+ + error: crate::api::schema::GuardedPromptErrorBody {
4021
+ + code: error.code().to_owned(),
4022
+ + message: "guarded prompt did not complete".to_owned(),
4023
+ + phase: error.phase().to_owned(),
4024
+ + },
4025
+ + })
4026
+ + .to_string()
4027
+ +}
4028
+ +
4029
+ +pub(super) fn input_drain_error(
4030
+ + id: String,
4031
+ + error: crate::platform::guarded_prompt::InputDrainError,
4032
+ +) -> String {
4033
+ + serde_json::json!(crate::api::schema::InputDrainErrorResponse {
4034
+ + id,
4035
+ + error: crate::api::schema::InputDrainErrorBody {
4036
+ + code: match error {
4037
+ + crate::platform::guarded_prompt::InputDrainError::GuardMismatch => {
4038
+ + crate::api::schema::InputDrainCode::GuardMismatch
4039
+ + }
4040
+ + crate::platform::guarded_prompt::InputDrainError::OriginUnavailable => {
4041
+ + crate::api::schema::InputDrainCode::OriginUnavailable
4042
+ + }
4043
+ + crate::platform::guarded_prompt::InputDrainError::Unavailable => {
4044
+ + crate::api::schema::InputDrainCode::Unavailable
4045
+ + }
4046
+ + },
4047
+ + phase: crate::api::schema::InputDrainPhase::Unconfirmed,
4048
+ + },
4049
+ + })
4050
+ + .to_string()
4051
+ +}
4052
+ +
4053
+ #[cfg(test)]
4054
+ mod tests {
4055
+ use super::*;
4056
+ @@ -986,39 +1034,3 @@ mod tests {
4057
+ }
4058
+ }
4059
+ }
4060
+ -
4061
+ -fn guarded_error(id: String, error: GuardedPromptError) -> String {
4062
+ - serde_json::json!(crate::api::schema::GuardedPromptErrorResponse {
4063
+ - id,
4064
+ - error: crate::api::schema::GuardedPromptErrorBody {
4065
+ - code: error.code().to_owned(),
4066
+ - message: "guarded prompt did not complete".to_owned(),
4067
+ - phase: error.phase().to_owned(),
4068
+ - },
4069
+ - })
4070
+ - .to_string()
4071
+ -}
4072
+ -
4073
+ -pub(super) fn input_drain_error(
4074
+ - id: String,
4075
+ - error: crate::platform::guarded_prompt::InputDrainError,
4076
+ -) -> String {
4077
+ - serde_json::json!(crate::api::schema::InputDrainErrorResponse {
4078
+ - id,
4079
+ - error: crate::api::schema::InputDrainErrorBody {
4080
+ - code: match error {
4081
+ - crate::platform::guarded_prompt::InputDrainError::GuardMismatch => {
4082
+ - crate::api::schema::InputDrainCode::GuardMismatch
4083
+ - }
4084
+ - crate::platform::guarded_prompt::InputDrainError::OriginUnavailable => {
4085
+ - crate::api::schema::InputDrainCode::OriginUnavailable
4086
+ - }
4087
+ - crate::platform::guarded_prompt::InputDrainError::Unavailable => {
4088
+ - crate::api::schema::InputDrainCode::Unavailable
4089
+ - }
4090
+ - },
4091
+ - phase: crate::api::schema::InputDrainPhase::Unconfirmed,
4092
+ - },
4093
+ - })
4094
+ - .to_string()
4095
+ -}
4096
+ diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
4097
+ index 5ca1830..c5e4601 100644
4098
+ --- a/src/app/api/panes.rs
4099
+ +++ b/src/app/api/panes.rs
4100
+ @@ -521,7 +521,11 @@ impl App {
4101
+ id: String,
4102
+ params: PaneProcessInfoParams,
4103
+ ) -> String {
4104
+ - let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
4105
+ + let target = match params.pane_id.as_deref() {
4106
+ + Some(target) => self.resolve_report_pane(target),
4107
+ + None => self.resolve_optional_pane(None),
4108
+ + };
4109
+ + let Some((ws_idx, pane_id)) = target else {
4110
+ return encode_error(id, "pane_not_found", "pane not found");
4111
+ };
4112
+ let Some((runtime, _workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else {
4113
+ @@ -1554,12 +1558,24 @@ impl App {
4114
+ )
4115
+ }
4116
+
4117
+ + // Root-bound isolated sessions report the immutable terminal identity.
4118
+ + // Keep this exact: a missing terminal must never resolve as an agent name
4119
+ + // or a recycled pane alias. Ordinary pane reports retain their API.
4120
+ + fn resolve_report_pane(&self, target: &str) -> Option<(usize, PaneId)> {
4121
+ + if target.starts_with("term_") {
4122
+ + self.state.terminals.get(target)?;
4123
+ + let resolved = self.resolve_guarded_agent_target(target).ok()?;
4124
+ + return Some((resolved.ws_idx, resolved.pane_id));
4125
+ + }
4126
+ + self.parse_pane_id(target)
4127
+ + }
4128
+ +
4129
+ pub(super) fn handle_pane_report_agent(
4130
+ &mut self,
4131
+ id: String,
4132
+ params: PaneReportAgentParams,
4133
+ ) -> String {
4134
+ - let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
4135
+ + let Some((ws_idx, pane_id)) = self.resolve_report_pane(&params.pane_id) else {
4136
+ return pane_not_found(id, &params.pane_id);
4137
+ };
4138
+ let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
4139
+ @@ -1604,7 +1620,7 @@ impl App {
4140
+ id: String,
4141
+ params: PaneReportAgentSessionParams,
4142
+ ) -> String {
4143
+ - let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
4144
+ + let Some((ws_idx, pane_id)) = self.resolve_report_pane(&params.pane_id) else {
4145
+ return pane_not_found(id, &params.pane_id);
4146
+ };
4147
+ let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
4148
+ @@ -2342,6 +2358,63 @@ mod tests {
4149
+ (app, public_pane_id)
4150
+ }
4151
+
4152
+ + #[tokio::test]
4153
+ + async fn isolated_process_info_resolves_exact_terminal_without_alias_fallback() {
4154
+ + let (mut app, _) = app_with_test_workspace();
4155
+ + let pane = app.state.workspaces[0].tabs[0].root_pane;
4156
+ + let terminal = app.state.workspaces[0].terminal_id(pane).unwrap().clone();
4157
+ + let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
4158
+ + app.state.insert_test_runtime(pane, runtime);
4159
+ + let response = app.handle_pane_process_info(
4160
+ + "process".into(),
4161
+ + PaneProcessInfoParams {
4162
+ + pane_id: Some(terminal.to_string()),
4163
+ + },
4164
+ + );
4165
+ + serde_json::from_str::<SuccessResponse>(&response).unwrap();
4166
+ + app.state
4167
+ + .public_pane_id_aliases
4168
+ + .insert("term_missing".into(), pane);
4169
+ + let response = app.handle_pane_process_info(
4170
+ + "missing".into(),
4171
+ + PaneProcessInfoParams {
4172
+ + pane_id: Some("term_missing".into()),
4173
+ + },
4174
+ + );
4175
+ + assert!(serde_json::from_str::<serde_json::Value>(&response)
4176
+ + .unwrap()
4177
+ + .get("error")
4178
+ + .is_some());
4179
+ + }
4180
+ +
4181
+ + #[test]
4182
+ + fn isolated_report_target_is_exact_and_never_falls_back_to_an_alias() {
4183
+ + let (mut app, public_pane_id) = app_with_test_workspace();
4184
+ + let pane = app.state.workspaces[0].tabs[0].root_pane;
4185
+ + let terminal = app.state.workspaces[0].terminal_id(pane).unwrap().clone();
4186
+ + assert_eq!(app.resolve_report_pane(terminal.as_str()), Some((0, pane)));
4187
+ + assert_eq!(app.resolve_report_pane(&public_pane_id), Some((0, pane)));
4188
+ + let state_response = app.handle_pane_report_agent("state".into(), serde_json::from_value(
4189
+ + serde_json::json!({"pane_id": terminal.as_str(), "agent": "pi", "source": "herdr:pi", "state": "working", "seq": 1})
4190
+ + ).unwrap());
4191
+ + serde_json::from_str::<SuccessResponse>(&state_response).unwrap();
4192
+ + let session_response = app.handle_pane_report_agent_session("session".into(), serde_json::from_value(
4193
+ + serde_json::json!({"pane_id": terminal.as_str(), "agent": "pi", "source": "herdr:pi", "agent_session_id": "isolated-session", "seq": 2})
4194
+ + ).unwrap());
4195
+ + serde_json::from_str::<SuccessResponse>(&session_response).unwrap();
4196
+ + app.state
4197
+ + .terminals
4198
+ + .get_mut(&terminal)
4199
+ + .unwrap()
4200
+ + .set_agent_name("term_missing".into());
4201
+ + app.state
4202
+ + .public_pane_id_aliases
4203
+ + .insert("term_missing".into(), pane);
4204
+ + assert_eq!(app.resolve_report_pane("term_missing"), None);
4205
+ + app.state.terminals.remove(&terminal);
4206
+ + assert_eq!(app.resolve_report_pane(terminal.as_str()), None);
4207
+ + }
4208
+ +
4209
+ #[test]
4210
+ fn pane_input_set_changes_only_the_target_pane() {
4211
+ let (mut app, public_pane_id) = app_with_test_workspace();
4212
+ diff --git a/src/cli/agent.rs b/src/cli/agent.rs
4213
+ index 5a941b7..6c7d6f2 100644
4214
+ --- a/src/cli/agent.rs
4215
+ +++ b/src/cli/agent.rs
4216
+ @@ -2,8 +2,9 @@ use std::time::{Duration, Instant};
4217
+
4218
+ use crate::api::schema::{
4219
+ AgentPromptParams, AgentPromptWaitOptions, AgentReadParams, AgentRenameParams,
4220
+ - AgentSendKeysParams, AgentStartParams, AgentTarget, AgentWaitParams, EmptyParams, ErrorBody,
4221
+ - ErrorResponse, Method, PaneProcessInfoParams, PaneTarget, ReadFormat, ReadSource, Request,
4222
+ + AgentSendKeysParams, AgentStartParams, AgentStartWithEnvParams, AgentTarget, AgentWaitParams,
4223
+ + EmptyParams, ErrorBody, ErrorResponse, Method, PaneProcessInfoParams, PaneTarget, ReadFormat,
4224
+ + ReadSource, Request,
4225
+ };
4226
+
4227
+ const AGENT_START_POLL_INTERVAL: Duration = Duration::from_millis(100);
4228
+ @@ -26,6 +27,7 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
4229
+ "wait" => agent_wait(&args[1..]),
4230
+ "attach" => agent_attach(&args[1..]),
4231
+ "start" => agent_start(&args[1..]),
4232
+ + "exec-env" => crate::platform::process_environment::exec(&args[1..]),
4233
+ "explain" => agent_explain(&args[1..]),
4234
+ "help" | "--help" | "-h" => {
4235
+ print_agent_help();
4236
+ @@ -288,7 +290,7 @@ fn matched_rule_region_preview<'a>(
4237
+
4238
+ fn agent_start(args: &[String]) -> std::io::Result<i32> {
4239
+ let Some(name) = args.first() else {
4240
+ - eprintln!("usage: herdr agent start <name> --kind KIND --pane ID [--timeout MS] [-- <agent-args...>]");
4241
+ + eprintln!("usage: herdr agent start <name> --kind KIND --pane ID [--timeout MS] [--env-file PATH] [-- <agent-args...>]");
4242
+ return Ok(2);
4243
+ };
4244
+ let separator = args
4245
+ @@ -298,9 +300,18 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> {
4246
+ let mut kind = None;
4247
+ let mut pane_id = None;
4248
+ let mut timeout_ms = None;
4249
+ + let mut env_file = None;
4250
+ let mut index = 1;
4251
+ while index < separator {
4252
+ match args[index].as_str() {
4253
+ + "--env-file" => {
4254
+ + let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
4255
+ + eprintln!("missing value for --env-file");
4256
+ + return Ok(2);
4257
+ + };
4258
+ + env_file = Some(value.clone());
4259
+ + index += 2;
4260
+ + }
4261
+ "--kind" => {
4262
+ let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
4263
+ eprintln!("missing value for --kind");
4264
+ @@ -369,15 +380,23 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> {
4265
+ }
4266
+ }
4267
+
4268
+ + let start = AgentStartParams {
4269
+ + name: name.clone(),
4270
+ + kind: kind.clone(),
4271
+ + pane_id: pane_id.clone(),
4272
+ + args: agent_args.clone(),
4273
+ + timeout_ms,
4274
+ + };
4275
+ + let method = match &env_file {
4276
+ + Some(path) => Method::AgentStartWithEnv(AgentStartWithEnvParams {
4277
+ + start,
4278
+ + env_file: path.clone(),
4279
+ + }),
4280
+ + None => Method::AgentStart(start),
4281
+ + };
4282
+ let response = super::send_request(&Request {
4283
+ id: "cli:agent:start".into(),
4284
+ - method: Method::AgentStart(AgentStartParams {
4285
+ - name: name.clone(),
4286
+ - kind: kind.clone(),
4287
+ - pane_id: pane_id.clone(),
4288
+ - args: agent_args.clone(),
4289
+ - timeout_ms,
4290
+ - }),
4291
+ + method,
4292
+ })?;
4293
+ if response.get("error").is_none() {
4294
+ break response;
4295
+ @@ -942,7 +961,7 @@ fn print_agent_help() {
4296
+ eprintln!(" herdr agent wait <target> [--until STATUS]... [--timeout MS]");
4297
+ eprintln!(" herdr agent attach <target> [--takeover]");
4298
+ eprintln!(
4299
+ - " herdr agent start <name> --kind KIND --pane ID [--timeout MS] [-- <agent-args...>]"
4300
+ + " herdr agent start <name> --kind KIND --pane ID [--timeout MS] [--env-file PATH] [-- <agent-args...>]"
4301
+ );
4302
+ eprintln!(" herdr agent explain <target> [--json|--format text|json] [--verbose]");
4303
+ eprintln!(
4304
+ diff --git a/src/pane.rs b/src/pane.rs
4305
+ index 7b296da..eebd3ef 100644
4306
+ --- a/src/pane.rs
4307
+ +++ b/src/pane.rs
4308
+ @@ -3536,11 +3536,18 @@ impl PaneRuntime {
4309
+ terminal_id: &str,
4310
+ ) -> Option<crate::platform::guarded_prompt::GuardedPromptOrigin> {
4311
+ #[cfg(unix)]
4312
+ - if let PaneRuntimeIo::Actor(actor) = &self.io {
4313
+ - return actor.guarded_origin(terminal_id);
4314
+ + {
4315
+ + match &self.io {
4316
+ + PaneRuntimeIo::Actor(actor) => actor.guarded_origin(terminal_id),
4317
+ + #[cfg(test)]
4318
+ + PaneRuntimeIo::TestChannel { .. } => None,
4319
+ + }
4320
+ + }
4321
+ + #[cfg(not(unix))]
4322
+ + {
4323
+ + let _ = terminal_id;
4324
+ + None
4325
+ }
4326
+ - let _ = terminal_id;
4327
+ - None
4328
+ }
4329
+
4330
+ pub(crate) fn captured_input_origin(
4331
+ @@ -3548,11 +3555,18 @@ impl PaneRuntime {
4332
+ terminal_id: &str,
4333
+ ) -> Option<crate::platform::guarded_prompt::GuardedPromptOrigin> {
4334
+ #[cfg(unix)]
4335
+ - if let PaneRuntimeIo::Actor(actor) = &self.io {
4336
+ - return actor.captured_input_origin(terminal_id);
4337
+ + {
4338
+ + match &self.io {
4339
+ + PaneRuntimeIo::Actor(actor) => actor.captured_input_origin(terminal_id),
4340
+ + #[cfg(test)]
4341
+ + PaneRuntimeIo::TestChannel { .. } => None,
4342
+ + }
4343
+ + }
4344
+ + #[cfg(not(unix))]
4345
+ + {
4346
+ + let _ = terminal_id;
4347
+ + None
4348
+ }
4349
+ - let _ = terminal_id;
4350
+ - None
4351
+ }
4352
+ pub(crate) fn inhibit_input(
4353
+ &self,
4354
+ @@ -3562,11 +3576,20 @@ impl PaneRuntime {
4355
+ crate::platform::guarded_prompt::InputDrainError,
4356
+ > {
4357
+ #[cfg(unix)]
4358
+ - if let PaneRuntimeIo::Actor(actor) = &self.io {
4359
+ - return actor.inhibit_input(expected);
4360
+ + {
4361
+ + match &self.io {
4362
+ + PaneRuntimeIo::Actor(actor) => actor.inhibit_input(expected),
4363
+ + #[cfg(test)]
4364
+ + PaneRuntimeIo::TestChannel { .. } => {
4365
+ + Err(crate::platform::guarded_prompt::InputDrainError::OriginUnavailable)
4366
+ + }
4367
+ + }
4368
+ + }
4369
+ + #[cfg(not(unix))]
4370
+ + {
4371
+ + let _ = expected;
4372
+ + Err(crate::platform::guarded_prompt::InputDrainError::OriginUnavailable)
4373
+ }
4374
+ - let _ = expected;
4375
+ - Err(crate::platform::guarded_prompt::InputDrainError::OriginUnavailable)
4376
+ }
4377
+ pub(crate) fn queue_guarded_submission(
4378
+ &self,
4379
+ @@ -3579,11 +3602,22 @@ impl PaneRuntime {
4380
+ crate::platform::guarded_prompt::GuardedPromptError,
4381
+ > {
4382
+ #[cfg(unix)]
4383
+ - if let PaneRuntimeIo::Actor(actor) = &self.io {
4384
+ - return actor.queue_guarded_submission(text, enter, delay, expected);
4385
+ + {
4386
+ + match &self.io {
4387
+ + PaneRuntimeIo::Actor(actor) => {
4388
+ + actor.queue_guarded_submission(text, enter, delay, expected)
4389
+ + }
4390
+ + #[cfg(test)]
4391
+ + PaneRuntimeIo::TestChannel { .. } => {
4392
+ + Err(crate::platform::guarded_prompt::GuardedPromptError::OriginUnavailable)
4393
+ + }
4394
+ + }
4395
+ + }
4396
+ + #[cfg(not(unix))]
4397
+ + {
4398
+ + let _ = (text, enter, delay, expected);
4399
+ + Err(crate::platform::guarded_prompt::GuardedPromptError::OriginUnavailable)
4400
+ }
4401
+ - let _ = (text, enter, delay, expected);
4402
+ - Err(crate::platform::guarded_prompt::GuardedPromptError::OriginUnavailable)
4403
+ }
4404
+
4405
+ pub fn queue_user_input_submission(
4406
+ diff --git a/src/platform/guarded_prompt.rs b/src/platform/guarded_prompt.rs
4407
+ index d8c2848..e35245d 100644
4408
+ --- a/src/platform/guarded_prompt.rs
4409
+ +++ b/src/platform/guarded_prompt.rs
4410
+ @@ -201,6 +201,7 @@ impl GuardedWriteState {
4411
+ }
4412
+ }
4413
+
4414
+ +#[cfg(any(target_os = "linux", test))]
4415
+ pub(crate) fn parse_linux_child_identity(
4416
+ pid: u32,
4417
+ status: &str,
4418
+ @@ -259,10 +260,8 @@ pub(crate) fn parse_linux_child_identity(
4419
+ values.dedup();
4420
+ groups = Some(values);
4421
+ }
4422
+ - "PPid" => {
4423
+ - if values.len() != 1 || ppid.replace(values[0]).is_some() {
4424
+ - return Err(invalid());
4425
+ - }
4426
+ + "PPid" if values.len() != 1 || ppid.replace(values[0]).is_some() => {
4427
+ + return Err(invalid());
4428
+ }
4429
+ _ => {}
4430
+ }
4431
+ diff --git a/src/platform/mod.rs b/src/platform/mod.rs
4432
+ index 0ea23d5..2470ff5 100644
4433
+ --- a/src/platform/mod.rs
4434
+ +++ b/src/platform/mod.rs
4435
+ @@ -4,6 +4,7 @@
4436
+ //! modules don't scatter `#[cfg]` branches through product logic.
4437
+
4438
+ pub(crate) mod guarded_prompt;
4439
+ +pub(crate) mod process_environment;
4440
+
4441
+ #[cfg(unix)]
4442
+ pub(crate) mod ssh_agent;
4443
+ diff --git a/src/platform/process_environment.rs b/src/platform/process_environment.rs
4444
+ new file mode 100644
4445
+ index 0000000..b6d6187
4446
+ --- /dev/null
4447
+ +++ b/src/platform/process_environment.rs
4448
+ @@ -0,0 +1,182 @@
4449
+ +//! Ordinary agent startup with an explicit environment. The terminal sees only
4450
+ +//! a private file path, never credential values. No daemon or OS identity layer.
4451
+ +
4452
+ +use std::collections::BTreeMap;
4453
+ +use std::io;
4454
+ +
4455
+ +fn invalid() -> io::Error {
4456
+ + io::Error::new(
4457
+ + io::ErrorKind::InvalidInput,
4458
+ + "invalid private agent environment",
4459
+ + )
4460
+ +}
4461
+ +
4462
+ +#[cfg(unix)]
4463
+ +fn read_environment(path: &str) -> io::Result<BTreeMap<String, String>> {
4464
+ + use std::io::Read;
4465
+ + use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
4466
+ + if !std::path::Path::new(path).is_absolute() || path.chars().any(char::is_control) {
4467
+ + return Err(invalid());
4468
+ + }
4469
+ + let file = std::fs::OpenOptions::new()
4470
+ + .read(true)
4471
+ + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
4472
+ + .open(path)
4473
+ + .map_err(|_| invalid())?;
4474
+ + let metadata = file.metadata().map_err(|_| invalid())?;
4475
+ + // SAFETY: geteuid has no arguments and does not dereference memory.
4476
+ + let uid = unsafe { libc::geteuid() };
4477
+ + if !metadata.is_file() || metadata.uid() != uid || metadata.mode() & 0o777 != 0o600 {
4478
+ + return Err(invalid());
4479
+ + }
4480
+ + let mut bytes = Vec::new();
4481
+ + file.take(262_145)
4482
+ + .read_to_end(&mut bytes)
4483
+ + .map_err(|_| invalid())?;
4484
+ + if bytes.len() > 262_144 {
4485
+ + return Err(invalid());
4486
+ + }
4487
+ + let environment: BTreeMap<String, String> =
4488
+ + serde_json::from_slice(&bytes).map_err(|_| invalid())?;
4489
+ + for (name, value) in &environment {
4490
+ + let mut chars = name.chars();
4491
+ + if !chars
4492
+ + .next()
4493
+ + .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
4494
+ + || !chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
4495
+ + || value.contains('\0')
4496
+ + {
4497
+ + return Err(invalid());
4498
+ + }
4499
+ + }
4500
+ + Ok(environment)
4501
+ +}
4502
+ +
4503
+ +#[cfg(not(unix))]
4504
+ +fn read_environment(_path: &str) -> io::Result<BTreeMap<String, String>> {
4505
+ + Err(io::Error::new(
4506
+ + io::ErrorKind::Unsupported,
4507
+ + "private agent environments require Unix",
4508
+ + ))
4509
+ +}
4510
+ +
4511
+ +pub(crate) fn launch_argv(path: &str, argv: &[String]) -> io::Result<Vec<String>> {
4512
+ + // Validate before accepting the start. The child independently validates
4513
+ + // before exec; neither error path includes file contents.
4514
+ + read_environment(path)?;
4515
+ + let executable = std::env::current_exe()?
4516
+ + .into_os_string()
4517
+ + .into_string()
4518
+ + .map_err(|_| invalid())?;
4519
+ + let mut result = vec![executable, "agent".into(), "exec-env".into(), path.into()];
4520
+ + result.extend_from_slice(argv);
4521
+ + Ok(result)
4522
+ +}
4523
+ +
4524
+ +#[cfg(unix)]
4525
+ +pub(crate) fn exec(args: &[String]) -> io::Result<i32> {
4526
+ + use std::os::unix::process::CommandExt;
4527
+ + let [path, executable, rest @ ..] = args else {
4528
+ + return Err(invalid());
4529
+ + };
4530
+ + let mut environment = read_environment(path)?;
4531
+ + // These values are supplied by the existing terminal runtime. Session
4532
+ + // credentials and arbitrary caller variables are never inherited.
4533
+ + for name in [
4534
+ + "HERDR_ENV",
4535
+ + "HERDR_SOCKET_PATH",
4536
+ + "HERDR_PANE_ID",
4537
+ + "HERDR_WORKSPACE_ID",
4538
+ + "HERDR_TAB_ID",
4539
+ + ] {
4540
+ + environment.remove(name);
4541
+ + if let Ok(value) = std::env::var(name) {
4542
+ + environment.insert(name.into(), value);
4543
+ + }
4544
+ + }
4545
+ + for (alias, native) in [
4546
+ + ("HERDR_PANE", "HERDR_PANE_ID"),
4547
+ + ("HERDR_SESSION", "HERDR_WORKSPACE_ID"),
4548
+ + ] {
4549
+ + environment.remove(alias);
4550
+ + if let Some(value) = environment.get(native).cloned() {
4551
+ + environment.insert(alias.into(), value);
4552
+ + }
4553
+ + }
4554
+ + let error = std::process::Command::new(executable)
4555
+ + .args(rest)
4556
+ + .env_clear()
4557
+ + .envs(environment)
4558
+ + .exec();
4559
+ + Err(io::Error::new(
4560
+ + error.kind(),
4561
+ + "agent environment command could not start",
4562
+ + ))
4563
+ +}
4564
+ +
4565
+ +#[cfg(not(unix))]
4566
+ +pub(crate) fn exec(_args: &[String]) -> io::Result<i32> {
4567
+ + Err(io::Error::new(
4568
+ + io::ErrorKind::Unsupported,
4569
+ + "private agent environments require Unix",
4570
+ + ))
4571
+ +}
4572
+ +
4573
+ +#[cfg(all(test, unix))]
4574
+ +mod tests {
4575
+ + use super::*;
4576
+ + use std::os::unix::fs::PermissionsExt;
4577
+ +
4578
+ + fn fixture(bytes: &str) -> std::path::PathBuf {
4579
+ + let nonce = std::time::SystemTime::now()
4580
+ + .duration_since(std::time::UNIX_EPOCH)
4581
+ + .unwrap()
4582
+ + .as_nanos();
4583
+ + let dir =
4584
+ + std::env::temp_dir().join(format!("herdr-process-env-{}-{nonce}", std::process::id()));
4585
+ + std::fs::create_dir(&dir).unwrap();
4586
+ + let path = dir.join("environment.json");
4587
+ + std::fs::write(&path, bytes).unwrap();
4588
+ + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
4589
+ + path
4590
+ + }
4591
+ +
4592
+ + #[test]
4593
+ + fn process_environment_values_never_enter_launch_argv() {
4594
+ + let path = fixture(r#"{"TOKEN":"private-token","PATH":"/usr/bin:/bin"}"#);
4595
+ + let argv = launch_argv(
4596
+ + path.to_str().unwrap(),
4597
+ + &["pi".into(), "--model".into(), "test".into()],
4598
+ + )
4599
+ + .unwrap();
4600
+ + assert!(!argv.join(" ").contains("private-token"));
4601
+ + assert_eq!(&argv[4..], ["pi", "--model", "test"]);
4602
+ + assert_eq!(
4603
+ + read_environment(path.to_str().unwrap()).unwrap()["TOKEN"],
4604
+ + "private-token"
4605
+ + );
4606
+ + std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
4607
+ + }
4608
+ +
4609
+ + #[test]
4610
+ + fn process_environment_rejects_permissions_symlinks_and_malformed_values() {
4611
+ + let path = fixture(r#"{"TOKEN":"private-token"}"#);
4612
+ + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
4613
+ + assert!(read_environment(path.to_str().unwrap()).is_err());
4614
+ + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
4615
+ + let link = path.with_extension("link");
4616
+ + std::os::unix::fs::symlink(&path, &link).unwrap();
4617
+ + assert!(read_environment(link.to_str().unwrap()).is_err());
4618
+ + for bytes in [
4619
+ + r#"{"TOKEN":12}"#,
4620
+ + r#"{"bad=name":"value"}"#,
4621
+ + r#"{"TOKEN":"\u0000"}"#,
4622
+ + "[]",
4623
+ + ] {
4624
+ + std::fs::write(&path, bytes).unwrap();
4625
+ + let error = read_environment(path.to_str().unwrap()).unwrap_err();
4626
+ + assert_eq!(error.to_string(), "invalid private agent environment");
4627
+ + }
4628
+ + std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
4629
+ + }
4630
+ +}
4631
+ diff --git a/tests/cli/agents.rs b/tests/cli/agents.rs
4632
+ index cc5af06..7a0df1a 100644
4633
+ --- a/tests/cli/agents.rs
4634
+ +++ b/tests/cli/agents.rs
4635
+ @@ -1,5 +1,84 @@
4636
+ use super::harness::*;
4637
+
4638
+ +#[test]
4639
+ +fn agent_start_with_environment_keeps_credentials_out_of_terminal_and_child_ambient() {
4640
+ + use std::os::unix::fs::PermissionsExt;
4641
+ + let base = unique_test_dir();
4642
+ + let config_home = base.join("config");
4643
+ + let runtime_dir = base.join("runtime");
4644
+ + let socket_path = runtime_dir.join("herdr.sock");
4645
+ + let (bin, shell, _) = write_delayed_shell_and_fake_pi(&base, "0");
4646
+ + let evidence = base.join("child-environment");
4647
+ + fs::write(bin.join("pi"), format!(
4648
+ + "#!/bin/sh\nprintf '%s|%s|%s|%s' \"$TOKEN\" \"${{AMBIENT_SECRET-unset}}\" \"$ACE_TOOLCHAIN\" \"$HERDR_PANE_ID\" > '{}'\nexport HERDR_AGENT=pi\n'{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:env-test --agent pi --state idle >/dev/null\nwhile IFS= read -r _prompt; do :; done\n",
4649
+ + evidence.display(), env!("CARGO_BIN_EXE_herdr"))).unwrap();
4650
+ + let config = format!(
4651
+ + "onboarding = false\n[terminal]\ndefault_shell = {:?}\nshell_mode = \"non_login\"\n",
4652
+ + shell.to_str().unwrap()
4653
+ + );
4654
+ + let herdr = spawn_herdr_with_config(
4655
+ + &config_home,
4656
+ + &runtime_dir,
4657
+ + &socket_path,
4658
+ + Some(&bin),
4659
+ + &config,
4660
+ + );
4661
+ + wait_for_socket(&socket_path, Duration::from_secs(5));
4662
+ + let created = run_cli_json(
4663
+ + &socket_path,
4664
+ + &["workspace", "create", "--cwd", base.to_str().unwrap()],
4665
+ + );
4666
+ + let pane = created["result"]["root_pane"]["pane_id"].as_str().unwrap();
4667
+ + assert!(run_cli(
4668
+ + &socket_path,
4669
+ + &[
4670
+ + "pane",
4671
+ + "run",
4672
+ + pane,
4673
+ + "export AMBIENT_SECRET=ambient-sentinel"
4674
+ + ]
4675
+ + )
4676
+ + .status
4677
+ + .success());
4678
+ + let environment = base.join("private.json");
4679
+ + fs::write(&environment, serde_json::to_vec(&serde_json::json!({
4680
+ + "PATH":format!("{}:/usr/bin:/bin", bin.display()), "TOKEN":"secret-sentinel", "ACE_TOOLCHAIN":"installed"
4681
+ + })).unwrap()).unwrap();
4682
+ + fs::set_permissions(&environment, fs::Permissions::from_mode(0o644)).unwrap();
4683
+ + let args = [
4684
+ + "agent",
4685
+ + "start",
4686
+ + "env-worker",
4687
+ + "--kind",
4688
+ + "pi",
4689
+ + "--pane",
4690
+ + pane,
4691
+ + "--timeout",
4692
+ + "6000",
4693
+ + "--env-file",
4694
+ + environment.to_str().unwrap(),
4695
+ + ];
4696
+ + let rejected = run_cli(&socket_path, &args);
4697
+ + assert!(!rejected.status.success());
4698
+ + assert!(!evidence.exists());
4699
+ + fs::set_permissions(&environment, fs::Permissions::from_mode(0o600)).unwrap();
4700
+ + let started = run_cli_json(&socket_path, &args);
4701
+ + assert_eq!(started["result"]["agent"]["name"], "env-worker");
4702
+ + let recorded = fs::read_to_string(&evidence).unwrap();
4703
+ + assert!(
4704
+ + recorded.starts_with("secret-sentinel|unset|installed|"),
4705
+ + "{recorded}"
4706
+ + );
4707
+ + assert!(!recorded.ends_with('|'));
4708
+ + fs::remove_file(&environment).unwrap();
4709
+ + let transcript = run_cli(&socket_path, &["pane", "read", pane, "--source", "recent"]);
4710
+ + assert!(!String::from_utf8_lossy(&transcript.stdout).contains("secret-sentinel"));
4711
+ + assert!(!serde_json::to_string(&started)
4712
+ + .unwrap()
4713
+ + .contains("secret-sentinel"));
4714
+ + cleanup_spawned_herdr(herdr, base);
4715
+ +}
4716
+ +
4717
+ #[test]
4718
+ fn agent_explain_missing_file_reports_json_error() {
4719
+ let base = unique_test_dir();
4720
+ diff --git a/tests/process_environment.rs b/tests/process_environment.rs
4721
+ new file mode 100644
4722
+ index 0000000..afe8c3a
4723
+ --- /dev/null
4724
+ +++ b/tests/process_environment.rs
4725
+ @@ -0,0 +1,74 @@
4726
+ +#![cfg(unix)]
4727
+ +
4728
+ +use std::os::unix::fs::PermissionsExt;
4729
+ +use std::process::Command;
4730
+ +
4731
+ +fn environment_file(bytes: &str) -> std::path::PathBuf {
4732
+ + let nonce = std::time::SystemTime::now()
4733
+ + .duration_since(std::time::UNIX_EPOCH)
4734
+ + .unwrap()
4735
+ + .as_nanos();
4736
+ + let directory =
4737
+ + std::env::temp_dir().join(format!("herdr-env-exec-{}-{nonce}", std::process::id()));
4738
+ + std::fs::create_dir(&directory).unwrap();
4739
+ + let file = directory.join("env.json");
4740
+ + std::fs::write(&file, bytes).unwrap();
4741
+ + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
4742
+ + file
4743
+ +}
4744
+ +
4745
+ +#[test]
4746
+ +fn process_environment_exec_clears_ambient_credentials_and_preserves_terminal_identity() {
4747
+ + let path = environment_file(
4748
+ + r#"{"ACE_TOOLCHAIN":"installed","TOKEN":"scoped","HERDR_PANE_ID":"wrong","PATH":"/usr/bin:/bin"}"#,
4749
+ + );
4750
+ + let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
4751
+ + .args(["agent", "exec-env", path.to_str().unwrap(), "/bin/sh", "-c",
4752
+ + "test -z \"${UNRELATED_SECRET+x}\" && test \"$TOKEN\" = scoped && test \"$ACE_TOOLCHAIN\" = installed && test \"$HERDR_PANE_ID\" = actual && test \"$HERDR_PANE\" = actual && test \"$HERDR_SESSION\" = w1 && test \"$HERDR_SOCKET_PATH\" = /tmp/native.sock && test \"$HERDR_ENV\" = 1"])
4753
+ + .env("UNRELATED_SECRET", "must-not-inherit")
4754
+ + .env("HERDR_ENV", "1")
4755
+ + .env("HERDR_PANE_ID", "actual")
4756
+ + .env("HERDR_WORKSPACE_ID", "w1")
4757
+ + .env("HERDR_SESSION", "wrong-coordinator")
4758
+ + .env("HERDR_PANE", "wrong-coordinator")
4759
+ + .env("HERDR_SOCKET_PATH", "/tmp/native.sock")
4760
+ + .output().unwrap();
4761
+ + assert!(
4762
+ + output.status.success(),
4763
+ + "{}",
4764
+ + String::from_utf8_lossy(&output.stderr)
4765
+ + );
4766
+ + assert!(output.stdout.is_empty());
4767
+ + std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
4768
+ +}
4769
+ +
4770
+ +#[test]
4771
+ +fn process_environment_failure_never_reports_secret_contents() {
4772
+ + let path = environment_file(r#"{"TOKEN":"secret-sentinel", malformed}"#);
4773
+ + let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
4774
+ + .args(["agent", "exec-env", path.to_str().unwrap(), "/bin/true"])
4775
+ + .output()
4776
+ + .unwrap();
4777
+ + assert!(!output.status.success());
4778
+ + assert!(!String::from_utf8_lossy(&output.stderr).contains("secret-sentinel"));
4779
+ + assert!(!String::from_utf8_lossy(&output.stdout).contains("secret-sentinel"));
4780
+ + std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
4781
+ +}
4782
+ +
4783
+ +#[test]
4784
+ +fn process_environment_exec_preserves_real_exit_status() {
4785
+ + let path = environment_file("{}");
4786
+ + let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
4787
+ + .args([
4788
+ + "agent",
4789
+ + "exec-env",
4790
+ + path.to_str().unwrap(),
4791
+ + "/bin/sh",
4792
+ + "-c",
4793
+ + "exit 37",
4794
+ + ])
4795
+ + .output()
4796
+ + .unwrap();
4797
+ + assert_eq!(output.status.code(), Some(37));
4798
+ + std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
4799
+ +}
4800
+ --
4801
+ 2.54.0 (Apple Git-157)
4802
+
4803
+
4804
+ From 4462e31354e9fe88c929f5961023fd75694768c1 Mon Sep 17 00:00:00 2001
4805
+ From: Miguel <mc@cs3b.com>
4806
+ Date: Tue, 10 Nov 2026 23:06:09 +0000
4807
+ Subject: [PATCH 5/6] fix: require fresh environments on cold agent restoration
4808
+
4809
+ ---
4810
+ src/app/agents.rs | 3 ++
4811
+ src/persist/restore.rs | 51 +++++++++++++++++++++++++++++
4812
+ src/platform/process_environment.rs | 6 ++++
4813
+ 3 files changed, 60 insertions(+)
4814
+
4815
+ diff --git a/src/app/agents.rs b/src/app/agents.rs
4816
+ index 9bbfb31..b2be1b8 100644
4817
+ --- a/src/app/agents.rs
4818
+ +++ b/src/app/agents.rs
4819
+ @@ -228,6 +228,9 @@ impl App {
4820
+ .get_mut(&terminal_id)
4821
+ .ok_or_else(|| AgentStartError::TargetUnavailable(params.pane_id.clone()))?;
4822
+ terminal.begin_managed_agent(name.clone(), kind, now, AGENT_START_SETTLE_DELAY, timeout);
4823
+ + if env_file.is_some() {
4824
+ + terminal.launch_argv = Some(launch_argv);
4825
+ + }
4826
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) {
4827
+ terminal.clear_agent_name();
4828
+ return Err(AgentStartError::InputFailed(err.to_string()));
4829
+ diff --git a/src/persist/restore.rs b/src/persist/restore.rs
4830
+ index e79a0f3..253ba88 100644
4831
+ --- a/src/persist/restore.rs
4832
+ +++ b/src/persist/restore.rs
4833
+ @@ -515,6 +515,17 @@ fn restore_tab(
4834
+
4835
+ let cwd = saved_cwd;
4836
+ let has_import = old_id.is_some_and(|old_id| imported_panes.contains_key(old_id));
4837
+ + if !has_import
4838
+ + && saved_pane
4839
+ + .and_then(|pane| pane.launch_argv.as_deref())
4840
+ + .is_some_and(crate::platform::process_environment::requires_environment)
4841
+ + {
4842
+ + let terminal = unavailable_restored_terminal(saved_pane, cwd,
4843
+ + "This agent requires a fresh explicit environment. Relaunch it through its coordinator.".into());
4844
+ + panes.insert(*id, PaneState::new(terminal.id.clone()));
4845
+ + terminals.push(terminal);
4846
+ + continue;
4847
+ + }
4848
+ if !has_import && !cwd.is_dir() {
4849
+ let terminal = unavailable_restored_terminal(
4850
+ saved_pane,
4851
+ @@ -1356,6 +1367,46 @@ mod tests {
4852
+ assert!(restored_terminal_agent_session(Some(&session), true).is_none());
4853
+ }
4854
+
4855
+ + #[tokio::test]
4856
+ + async fn explicit_environment_cold_restore_never_resumes_with_ambient_credentials() {
4857
+ + let cwd = std::env::current_dir().unwrap();
4858
+ + let snapshot: SessionSnapshot = serde_json::from_value(serde_json::json!({
4859
+ + "version":super::super::snapshot::SNAPSHOT_VERSION,
4860
+ + "workspaces":[{"id":"explicit", "identity_cwd":cwd, "tabs":[{
4861
+ + "layout":{"Pane":0}, "panes":{"0":{"cwd":cwd,
4862
+ + "agent_name":"worker", "managed_agent_kind":"pi",
4863
+ + "launch_argv":["/usr/bin/herdr","agent","exec-env","/gone/env.json","pi"],
4864
+ + "agent_session":{"source":"herdr:pi","agent":"pi","kind":"path","value":"/tmp/pi-session.jsonl"}
4865
+ + }}, "zoomed":false}], "active_tab":0}], "active":0, "selected":0
4866
+ + })).unwrap();
4867
+ + let (events, _rx) = mpsc::channel(32);
4868
+ + let (workspaces, terminals, runtimes) = restore(
4869
+ + &snapshot,
4870
+ + None,
4871
+ + 24,
4872
+ + 80,
4873
+ + 0,
4874
+ + test_restore_shell(),
4875
+ + crate::config::ShellModeConfig::NonLogin,
4876
+ + true,
4877
+ + events,
4878
+ + Arc::new(Notify::new()),
4879
+ + Arc::new(RenderSignal::new()),
4880
+ + );
4881
+ + assert_eq!(workspaces.len(), 1);
4882
+ + assert!(runtimes.is_empty());
4883
+ + let terminal = terminals.values().next().unwrap();
4884
+ + assert!(terminal
4885
+ + .restore_error
4886
+ + .as_ref()
4887
+ + .unwrap()
4888
+ + .contains("fresh explicit environment"));
4889
+ + assert!(terminal.pending_agent_resume_plan.is_none());
4890
+ + assert!(crate::platform::process_environment::requires_environment(
4891
+ + terminal.launch_argv.as_deref().unwrap()
4892
+ + ));
4893
+ + }
4894
+ +
4895
+ #[tokio::test]
4896
+ async fn failed_cold_restore_preserves_panes_and_saved_directories() {
4897
+ for missing_shell in [false, true] {
4898
+ diff --git a/src/platform/process_environment.rs b/src/platform/process_environment.rs
4899
+ index b6d6187..66ae240 100644
4900
+ --- a/src/platform/process_environment.rs
4901
+ +++ b/src/platform/process_environment.rs
4902
+ @@ -73,6 +73,12 @@ pub(crate) fn launch_argv(path: &str, argv: &[String]) -> io::Result<Vec<String>
4903
+ Ok(result)
4904
+ }
4905
+
4906
+ +pub(crate) fn requires_environment(argv: &[String]) -> bool {
4907
+ + argv.get(1).is_some_and(|value| value == "agent")
4908
+ + && argv.get(2).is_some_and(|value| value == "exec-env")
4909
+ + && argv.len() >= 5
4910
+ +}
4911
+ +
4912
+ #[cfg(unix)]
4913
+ pub(crate) fn exec(args: &[String]) -> io::Result<i32> {
4914
+ use std::os::unix::process::CommandExt;
4915
+ --
4916
+ 2.54.0 (Apple Git-157)
4917
+
4918
+
4919
+ From b6eef16bbbeb5210f5414b67104d2656576e0e5c Mon Sep 17 00:00:00 2001
4920
+ From: Miguel <mc@cs3b.com>
4921
+ Date: Tue, 10 Nov 2026 23:16:46 +0000
4922
+ Subject: [PATCH 6/6] fix: clean ordinary session credentials when agents exit
4923
+
4924
+ ---
4925
+ src/persist/restore.rs | 13 +--
4926
+ src/platform/process_environment.rs | 132 ++++++++++++++++++++++++++--
4927
+ tests/cli/agents.rs | 7 +-
4928
+ tests/process_environment.rs | 121 +++++++++++++++++++++++++
4929
+ 4 files changed, 257 insertions(+), 16 deletions(-)
4930
+
4931
+ diff --git a/src/persist/restore.rs b/src/persist/restore.rs
4932
+ index 253ba88..3b0ca57 100644
4933
+ --- a/src/persist/restore.rs
4934
+ +++ b/src/persist/restore.rs
4935
+ @@ -459,15 +459,9 @@ fn unavailable_restored_terminal(
4936
+ if let Some(resume) = saved_reported_resume(pane) {
4937
+ terminal.restore_reported_resume(reported_resume_from_snapshot(resume));
4938
+ }
4939
+ - match (
4940
+ - pane.agent_name.as_ref(),
4941
+ - pane.managed_agent_kind
4942
+ - .as_deref()
4943
+ - .and_then(crate::detect::parse_canonical_agent_label),
4944
+ - ) {
4945
+ - (Some(name), Some(agent)) => terminal.restore_managed_agent(name.clone(), agent),
4946
+ - (Some(name), None) => terminal.set_agent_name(name.clone()),
4947
+ - _ => {}
4948
+ + // Preserve identity for reconciliation, never readiness without a runtime.
4949
+ + if let Some(name) = &pane.agent_name {
4950
+ + terminal.set_agent_name(name.clone());
4951
+ }
4952
+ }
4953
+ terminal
4954
+ @@ -1402,6 +1396,7 @@ mod tests {
4955
+ .unwrap()
4956
+ .contains("fresh explicit environment"));
4957
+ assert!(terminal.pending_agent_resume_plan.is_none());
4958
+ + assert!(!terminal.managed_agent_interactive_ready());
4959
+ assert!(crate::platform::process_environment::requires_environment(
4960
+ terminal.launch_argv.as_deref().unwrap()
4961
+ ));
4962
+ diff --git a/src/platform/process_environment.rs b/src/platform/process_environment.rs
4963
+ index 66ae240..df7469d 100644
4964
+ --- a/src/platform/process_environment.rs
4965
+ +++ b/src/platform/process_environment.rs
4966
+ @@ -63,7 +63,11 @@ fn read_environment(_path: &str) -> io::Result<BTreeMap<String, String>> {
4967
+ pub(crate) fn launch_argv(path: &str, argv: &[String]) -> io::Result<Vec<String>> {
4968
+ // Validate before accepting the start. The child independently validates
4969
+ // before exec; neither error path includes file contents.
4970
+ - read_environment(path)?;
4971
+ + let environment = read_environment(path)?;
4972
+ + #[cfg(unix)]
4973
+ + CredentialCleanup::from_environment(&environment)?;
4974
+ + #[cfg(not(unix))]
4975
+ + let _ = environment;
4976
+ let executable = std::env::current_exe()?
4977
+ .into_os_string()
4978
+ .into_string()
4979
+ @@ -109,17 +113,133 @@ pub(crate) fn exec(args: &[String]) -> io::Result<i32> {
4980
+ environment.insert(alias.into(), value);
4981
+ }
4982
+ }
4983
+ - let error = std::process::Command::new(executable)
4984
+ - .args(rest)
4985
+ - .env_clear()
4986
+ - .envs(environment)
4987
+ - .exec();
4988
+ + let credentials = CredentialCleanup::from_environment(&environment)?;
4989
+ + let mut command = std::process::Command::new(executable);
4990
+ + command.args(rest).env_clear().envs(environment);
4991
+ + if let Some(credentials) = credentials {
4992
+ + let outcome = wait_for_child(&mut command);
4993
+ + credentials.clean()?;
4994
+ + return outcome;
4995
+ + }
4996
+ + let error = command.exec();
4997
+ Err(io::Error::new(
4998
+ error.kind(),
4999
+ "agent environment command could not start",
5000
+ ))
5001
+ }
5002
+
5003
+ +// Keep an open directory descriptor so cleanup cannot follow a replaced path.
5004
+ +#[cfg(unix)]
5005
+ +struct CredentialCleanup(std::fs::File);
5006
+ +
5007
+ +#[cfg(unix)]
5008
+ +impl CredentialCleanup {
5009
+ + fn from_environment(environment: &BTreeMap<String, String>) -> io::Result<Option<Self>> {
5010
+ + use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
5011
+ + let Some(directory) = environment.get("ACE_SESSION_CREDENTIALS_DIRECTORY") else {
5012
+ + return Ok(None);
5013
+ + };
5014
+ + let path = std::path::Path::new(directory);
5015
+ + if environment.get("PI_CODING_AGENT_DIR") != Some(directory)
5016
+ + || !path.is_absolute()
5017
+ + || !path
5018
+ + .file_name()
5019
+ + .and_then(|name| name.to_str())
5020
+ + .is_some_and(|name| name.starts_with("ace-session-") && name.len() > 12)
5021
+ + {
5022
+ + return Err(invalid());
5023
+ + }
5024
+ + let directory = std::fs::OpenOptions::new()
5025
+ + .read(true)
5026
+ + .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY)
5027
+ + .open(path)
5028
+ + .map_err(|_| invalid())?;
5029
+ + let metadata = directory.metadata().map_err(|_| invalid())?;
5030
+ + // SAFETY: geteuid has no arguments or pointers.
5031
+ + if !metadata.is_dir()
5032
+ + || metadata.uid() != unsafe { libc::geteuid() }
5033
+ + || metadata.mode() & 0o777 != 0o700
5034
+ + {
5035
+ + return Err(invalid());
5036
+ + }
5037
+ + Ok(Some(Self(directory)))
5038
+ + }
5039
+ +
5040
+ + fn clean(self) -> io::Result<()> {
5041
+ + use std::os::fd::AsRawFd;
5042
+ + // SAFETY: directory remains open and the fixed filename is NUL terminated.
5043
+ + if unsafe { libc::unlinkat(self.0.as_raw_fd(), c"auth.json".as_ptr(), 0) } == 0 {
5044
+ + return Ok(());
5045
+ + }
5046
+ + let error = io::Error::last_os_error();
5047
+ + if error.kind() == io::ErrorKind::NotFound {
5048
+ + return Ok(());
5049
+ + }
5050
+ + Err(io::Error::new(
5051
+ + error.kind(),
5052
+ + "agent credential cleanup failed",
5053
+ + ))
5054
+ + }
5055
+ +}
5056
+ +
5057
+ +#[cfg(unix)]
5058
+ +static CHILD_PID: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
5059
+ +#[cfg(unix)]
5060
+ +static STOP_SIGNAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
5061
+ +
5062
+ +#[cfg(unix)]
5063
+ +extern "C" fn forward_stop(signal: libc::c_int) {
5064
+ + use std::sync::atomic::Ordering;
5065
+ + // Terminal Ctrl-C already reaches the child in the same foreground group.
5066
+ + // Keep the waiter alive to clean credentials when the child actually exits.
5067
+ + if signal == libc::SIGINT {
5068
+ + return;
5069
+ + }
5070
+ + STOP_SIGNAL.store(signal, Ordering::SeqCst);
5071
+ + let pid = CHILD_PID.load(Ordering::SeqCst);
5072
+ + if pid > 0 {
5073
+ + // SAFETY: kill is async-signal-safe; pid identifies our still-owned child.
5074
+ + unsafe {
5075
+ + libc::kill(pid, signal);
5076
+ + }
5077
+ + }
5078
+ +}
5079
+ +
5080
+ +#[cfg(unix)]
5081
+ +fn wait_for_child(command: &mut std::process::Command) -> io::Result<i32> {
5082
+ + use std::os::unix::process::ExitStatusExt;
5083
+ + use std::sync::atomic::Ordering;
5084
+ + for signal in [libc::SIGTERM, libc::SIGHUP, libc::SIGINT] {
5085
+ + // SAFETY: a static handler with the required C ABI; used only by this
5086
+ + // standalone helper process, before spawning its one child.
5087
+ + if unsafe { libc::signal(signal, forward_stop as *const () as libc::sighandler_t) }
5088
+ + == libc::SIG_ERR
5089
+ + {
5090
+ + return Err(io::Error::other("agent signal forwarding failed"));
5091
+ + }
5092
+ + }
5093
+ + let mut child = command.spawn().map_err(|error| {
5094
+ + io::Error::new(error.kind(), "agent environment command could not start")
5095
+ + })?;
5096
+ + CHILD_PID.store(child.id() as i32, Ordering::SeqCst);
5097
+ + let pending = STOP_SIGNAL.load(Ordering::SeqCst);
5098
+ + if pending != 0 {
5099
+ + // SAFETY: child is owned and not yet reaped.
5100
+ + unsafe {
5101
+ + libc::kill(child.id() as i32, pending);
5102
+ + }
5103
+ + }
5104
+ + let result = child.wait();
5105
+ + CHILD_PID.store(0, Ordering::SeqCst);
5106
+ + result
5107
+ + .map(|status| {
5108
+ + status
5109
+ + .code()
5110
+ + .unwrap_or_else(|| 128 + status.signal().unwrap_or(1))
5111
+ + })
5112
+ + .map_err(|error| io::Error::new(error.kind(), "agent command wait failed"))
5113
+ +}
5114
+ +
5115
+ #[cfg(not(unix))]
5116
+ pub(crate) fn exec(_args: &[String]) -> io::Result<i32> {
5117
+ Err(io::Error::new(
5118
+ diff --git a/tests/cli/agents.rs b/tests/cli/agents.rs
5119
+ index 7a0df1a..43bafba 100644
5120
+ --- a/tests/cli/agents.rs
5121
+ +++ b/tests/cli/agents.rs
5122
+ @@ -40,9 +40,14 @@ fn agent_start_with_environment_keeps_credentials_out_of_terminal_and_child_ambi
5123
+ )
5124
+ .status
5125
+ .success());
5126
+ + let credentials = base.join("ace-session-native-test");
5127
+ + fs::create_dir(&credentials).unwrap();
5128
+ + fs::set_permissions(&credentials, fs::Permissions::from_mode(0o700)).unwrap();
5129
+ + fs::write(credentials.join("auth.json"), "private-model-auth").unwrap();
5130
+ let environment = base.join("private.json");
5131
+ fs::write(&environment, serde_json::to_vec(&serde_json::json!({
5132
+ - "PATH":format!("{}:/usr/bin:/bin", bin.display()), "TOKEN":"secret-sentinel", "ACE_TOOLCHAIN":"installed"
5133
+ + "PATH":format!("{}:/usr/bin:/bin", bin.display()), "TOKEN":"secret-sentinel", "ACE_TOOLCHAIN":"installed",
5134
+ + "PI_CODING_AGENT_DIR": credentials, "ACE_SESSION_CREDENTIALS_DIRECTORY": credentials
5135
+ })).unwrap()).unwrap();
5136
+ fs::set_permissions(&environment, fs::Permissions::from_mode(0o644)).unwrap();
5137
+ let args = [
5138
+ diff --git a/tests/process_environment.rs b/tests/process_environment.rs
5139
+ index afe8c3a..9dedb83 100644
5140
+ --- a/tests/process_environment.rs
5141
+ +++ b/tests/process_environment.rs
5142
+ @@ -72,3 +72,124 @@ fn process_environment_exec_preserves_real_exit_status() {
5143
+ assert_eq!(output.status.code(), Some(37));
5144
+ std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
5145
+ }
5146
+ +
5147
+ +fn credentials_fixture() -> (std::path::PathBuf, std::path::PathBuf) {
5148
+ + let environment = environment_file("{}");
5149
+ + let directory = environment.parent().unwrap().join("ace-session-owned");
5150
+ + std::fs::create_dir(&directory).unwrap();
5151
+ + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).unwrap();
5152
+ + std::fs::write(directory.join("auth.json"), "private-sentinel").unwrap();
5153
+ + std::fs::write(directory.join("session.jsonl"), "evidence").unwrap();
5154
+ + std::fs::write(
5155
+ + &environment,
5156
+ + serde_json::json!({
5157
+ + "PATH":"/usr/bin:/bin", "PI_CODING_AGENT_DIR":directory,
5158
+ + "ACE_SESSION_CREDENTIALS_DIRECTORY":directory
5159
+ + })
5160
+ + .to_string(),
5161
+ + )
5162
+ + .unwrap();
5163
+ + (environment, directory)
5164
+ +}
5165
+ +
5166
+ +#[test]
5167
+ +fn process_environment_cleanup_preserves_evidence_and_child_exit_code() {
5168
+ + for executable in ["/bin/sh", "/nonexistent-herdr-test-command"] {
5169
+ + let (environment, directory) = credentials_fixture();
5170
+ + let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
5171
+ + .args([
5172
+ + "agent",
5173
+ + "exec-env",
5174
+ + environment.to_str().unwrap(),
5175
+ + executable,
5176
+ + "-c",
5177
+ + "exit 37",
5178
+ + ])
5179
+ + .output()
5180
+ + .unwrap();
5181
+ + assert!(!output.status.success());
5182
+ + if executable == "/bin/sh" {
5183
+ + assert_eq!(output.status.code(), Some(37));
5184
+ + }
5185
+ + assert!(!directory.join("auth.json").exists());
5186
+ + assert_eq!(
5187
+ + std::fs::read_to_string(directory.join("session.jsonl")).unwrap(),
5188
+ + "evidence"
5189
+ + );
5190
+ + assert!(!String::from_utf8_lossy(&output.stderr).contains("private-sentinel"));
5191
+ + std::fs::remove_dir_all(environment.parent().unwrap()).unwrap();
5192
+ + }
5193
+ +}
5194
+ +
5195
+ +#[test]
5196
+ +fn process_environment_cleanup_refuses_public_or_mismatched_directory() {
5197
+ + for mismatch in [false, true] {
5198
+ + let (environment, directory) = credentials_fixture();
5199
+ + if mismatch {
5200
+ + std::fs::write(
5201
+ + &environment,
5202
+ + serde_json::json!({
5203
+ + "PI_CODING_AGENT_DIR":"/tmp/some-other-directory",
5204
+ + "ACE_SESSION_CREDENTIALS_DIRECTORY":directory
5205
+ + })
5206
+ + .to_string(),
5207
+ + )
5208
+ + .unwrap();
5209
+ + } else {
5210
+ + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o755)).unwrap();
5211
+ + }
5212
+ + let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
5213
+ + .args([
5214
+ + "agent",
5215
+ + "exec-env",
5216
+ + environment.to_str().unwrap(),
5217
+ + "/bin/true",
5218
+ + ])
5219
+ + .output()
5220
+ + .unwrap();
5221
+ + assert!(!output.status.success());
5222
+ + assert!(directory.join("auth.json").exists());
5223
+ + std::fs::remove_dir_all(environment.parent().unwrap()).unwrap();
5224
+ + }
5225
+ +}
5226
+ +
5227
+ +#[test]
5228
+ +fn process_environment_cleanup_forwards_termination_and_keeps_evidence() {
5229
+ + let (environment, directory) = credentials_fixture();
5230
+ + let ready = directory.join("ready");
5231
+ + let mut child = Command::new(env!("CARGO_BIN_EXE_herdr"))
5232
+ + .args([
5233
+ + "agent",
5234
+ + "exec-env",
5235
+ + environment.to_str().unwrap(),
5236
+ + "/bin/sh",
5237
+ + "-c",
5238
+ + "touch \"$PI_CODING_AGENT_DIR/ready\"; exec sleep 30",
5239
+ + ])
5240
+ + .spawn()
5241
+ + .unwrap();
5242
+ + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
5243
+ + while !ready.exists() {
5244
+ + assert!(
5245
+ + std::time::Instant::now() < deadline,
5246
+ + "child readiness timed out"
5247
+ + );
5248
+ + std::thread::sleep(std::time::Duration::from_millis(10));
5249
+ + }
5250
+ + // SAFETY: the test owns the running child PID.
5251
+ + assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
5252
+ + let status = loop {
5253
+ + if let Some(status) = child.try_wait().unwrap() {
5254
+ + break status;
5255
+ + }
5256
+ + if std::time::Instant::now() >= deadline {
5257
+ + child.kill().unwrap();
5258
+ + panic!("helper did not forward termination");
5259
+ + }
5260
+ + std::thread::sleep(std::time::Duration::from_millis(10));
5261
+ + };
5262
+ + assert_eq!(status.code(), Some(128 + libc::SIGTERM));
5263
+ + assert!(!directory.join("auth.json").exists());
5264
+ + assert!(directory.join("session.jsonl").exists());
5265
+ + std::fs::remove_dir_all(environment.parent().unwrap()).unwrap();
5266
+ +}
5267
+ --
5268
+ 2.54.0 (Apple Git-157)
5269
+