diff --git a/doc/execution-github-identity.md b/doc/execution-github-identity.md index 90ef3e30874..86b5a3c3a1a 100644 --- a/doc/execution-github-identity.md +++ b/doc/execution-github-identity.md @@ -64,13 +64,20 @@ the active run through that broker. They do not require a persistent GitHub CLI login or inherit an ambient host broker capability. Claude through native ACPX retains the controller-projected broker URL and -session/bridge capabilities alongside its managed launcher paths. Both isolated +session/bridge capabilities alongside its managed launcher paths through both +the Rust daemon-to-sidecar and TypeScript provider launch boundaries. Both isolated and preconfigured homes use this transport for shell `git` and `gh` operations. These capabilities stay out of persisted session environment data. Raw GitHub tokens remain confined to each managed command; ambient host capabilities and task-supplied broker overrides are excluded. A working connector tool alone does not prove that the terminal received its broker transport. +The Rust sidecar launch accepts this transport only for Claude with a nonempty +managed launcher directory. Existing saved sessions retain their profile and +checkpoint format. A running provider needs its normal retirement and restart +to receive the repaired launch environment; reconnecting the GitHub account +does not repair a process that already started without the transport. + Without a managed identity, local commits default to `Paperclip `. The launcher writes this token-free default to a private per-operation global Git configuration file, removed on exit. diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 631a61b3c9b..b84526d397c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -271,6 +271,15 @@ impl AcpxSidecarTransport { keys.push("PAPERCLIP_ACPX_CREDENTIAL_BINDING"); } if agent == "claude" { + if std::env::var("PAPERCLIP_GITHUB_LAUNCHER_DIR") + .is_ok_and(|directory| !directory.trim().is_empty()) + { + keys.extend_from_slice(&[ + "PAPERCLIP_GITHUB_BROKER_URL", + "PAPERCLIP_GITHUB_BROKER_TOKEN", + "PAPERCLIP_GITHUB_BRIDGE_TOKEN", + ]); + } keys.extend_from_slice(&[ // Launcher/controller-owned helper paths; credentials stay behind the broker. "BASH_ENV", @@ -1290,46 +1299,6 @@ mod tests { use super::*; - #[test] - #[cfg(unix)] - fn managed_claude_helpers_reach_the_sidecar_without_host_credentials() { - const CHILD: &str = "PAPERCLIP_TEST_MANAGED_HELPER_CHILD"; - if std::env::var_os(CHILD).is_none() { - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "acpx_sidecar_transport::tests::managed_claude_helpers_reach_the_sidecar_without_host_credentials"]) - .env_clear() - .env(CHILD, "1") - .env("PATH", "/usr/bin:/bin") - .env("BASH_ENV", "/managed/.bashrc") - .env("ZDOTDIR", "/managed") - .env("GH_CONFIG_DIR", "/managed") - .env("PAPERCLIP_GITHUB_LAUNCHER_DIR", "/managed") - .env("GH_TOKEN", "unbound-secret") - .env("PAPERCLIP_GITHUB_BROKER_TOKEN", "unbound-capability") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stdout) - ); - return; - } - let config = AcpxSidecarTransportConfig { - command: PathBuf::from("/bin/sh"), - args: vec!["-c".into(), "printf '%s|%s|%s|%s|%s|%s\\n' \"$BASH_ENV\" \"$ZDOTDIR\" \"$GH_CONFIG_DIR\" \"$PAPERCLIP_GITHUB_LAUNCHER_DIR\" \"$GH_TOKEN\" \"$PAPERCLIP_GITHUB_BROKER_TOKEN\"".into()], - verified_launch: None, - request_timeout: Duration::from_secs(1), - shutdown_grace: Duration::from_millis(100), - }; - let mut transport = AcpxSidecarTransport::start_for_agent(&config, "claude").unwrap(); - let line = transport - .receive_stdout_line(Duration::from_secs(2), "managed-helper test") - .unwrap() - .expect("sidecar did not report its environment"); - assert_eq!(line, "/managed/.bashrc|/managed|/managed|/managed||"); - } - #[test] fn parse_frame_does_not_echo_untrusted_deserialization_details() { let cases = [ diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs index f6264141dd1..1b04751a440 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs @@ -4,6 +4,8 @@ use std::time::{Duration, Instant}; use paperclip_runner_core::acpx_sidecar_transport::{ AcpxSidecarTransport, AcpxSidecarTransportConfig, }; +#[cfg(unix)] +use paperclip_runner_core::generated_acpx_sidecar_contract::GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION; use paperclip_runner_core::generated_acpx_sidecar_contract::{ GeneratedAcpxSidecarCommand, GeneratedAcpxSidecarEventType, }; @@ -521,3 +523,93 @@ fn pi_ordinary_request_timeout_still_retires_the_sidecar() { .shutdown() .expect("timeout cleanup remains idempotent"); } + +#[test] +#[cfg(unix)] +fn managed_claude_broker_transport_reaches_the_sidecar_without_host_credentials() { + const CHILD: &str = "PAPERCLIP_TEST_MANAGED_BROKER_CHILD"; + if std::env::var_os(CHILD).is_none() { + for (agent, launcher) in [ + ("claude", "/managed"), + ("claude", ""), + ("claude", " "), + ("codex", "/managed"), + ("cursor", "/managed"), + ] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "managed_claude_broker_transport_reaches_the_sidecar_without_host_credentials", + ]) + .env_clear() + .env(CHILD, agent) + .env("PATH", "/usr/bin:/bin") + .env("BASH_ENV", "/managed/.bashrc") + .env("ZDOTDIR", "/managed") + .env("GH_CONFIG_DIR", "/managed") + .env("PAPERCLIP_GITHUB_LAUNCHER_DIR", launcher) + .env("PAPERCLIP_GITHUB_BROKER_URL", "http://127.0.0.1:3210") + .env("PAPERCLIP_GITHUB_BROKER_TOKEN", "session-capability") + .env("PAPERCLIP_GITHUB_BRIDGE_TOKEN", "bridge-capability") + .env("GH_TOKEN", "unbound-secret") + .env("GITHUB_TOKEN", "unbound-secret") + .env("UNRELATED_SECRET", "unbound-secret") + .output() + .unwrap(); + assert!( + output.status.success(), + "agent={agent}, launcher={launcher:?}: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + let agent = std::env::var(CHILD).unwrap(); + let names = [ + "BASH_ENV", + "ZDOTDIR", + "GH_CONFIG_DIR", + "PAPERCLIP_GITHUB_LAUNCHER_DIR", + "PAPERCLIP_GITHUB_BROKER_URL", + "PAPERCLIP_GITHUB_BROKER_TOKEN", + "PAPERCLIP_GITHUB_BRIDGE_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + "UNRELATED_SECRET", + ]; + let arguments = names.map(|name| format!("\"${name}\"")).join(" "); + let response = json!({ + "protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, + "id": 1, "ok": true, "result": { "environment": vec!["%s"; names.len()] }, + }); + let config = AcpxSidecarTransportConfig { + command: PathBuf::from("/bin/sh"), + args: vec![ + "-c".into(), + format!("IFS= read -r request; printf '{response}\\n' {arguments}; IFS= read -r next"), + ], + verified_launch: None, + request_timeout: Duration::from_secs(1), + shutdown_grace: Duration::from_millis(100), + }; + let mut transport = AcpxSidecarTransport::start_for_agent(&config, &agent).unwrap(); + let managed = agent == "claude" + && !std::env::var("PAPERCLIP_GITHUB_LAUNCHER_DIR") + .unwrap() + .trim() + .is_empty(); + let response = transport + .request(GeneratedAcpxSidecarCommand::Initialize, json!({})) + .expect("sidecar should report its launch environment"); + for (index, name) in names.iter().enumerate() { + let value = response["environment"][index].as_str().unwrap(); + let expected = if (index < 4 && agent == "claude") || ((4..7).contains(&index) && managed) { + std::env::var(name).unwrap() + } else { + String::new() + }; + assert_eq!(value, expected, "sidecar environment: {name}"); + } + transport.shutdown().unwrap(); +}