@@ -119,6 +119,23 @@ pub async fn mcp_proxy(
119119 "write tools are not enabled" ,
120120 ) ;
121121 }
122+ // 2b. Multi-installation mode: repo-less agents ride pooled
123+ // PATs, and writes never run on pooled PATs — even when
124+ // writes are enabled. (Also rejected at startup
125+ // validation; kept as defense-in-depth.)
126+ if crate :: policy:: classify_tool ( tool_name) == crate :: policy:: ToolKind :: Write
127+ && state. multi_app_tokens . is_some ( )
128+ && agent. repos . is_empty ( )
129+ {
130+ tracing:: warn!(
131+ "MCP tools/call {} DENIED (repo-less agent uses pooled PATs) [agent={}]{}" ,
132+ tool_name, agent. id, session_suffix( session_id. as_deref( ) )
133+ ) ;
134+ return rpc_error (
135+ StatusCode :: FORBIDDEN ,
136+ "write tools require a repository-scoped agent" ,
137+ ) ;
138+ }
122139 // 3. Repository allowlist (deny-if-unresolvable)
123140 if !agent. repos . is_empty ( ) {
124141 match & resolved_repo {
@@ -153,7 +170,8 @@ pub async fn mcp_proxy(
153170
154171 // Multi-installation mode: `initialize` fans out to one upstream session
155172 // per owner in the agent's envelope; DELETE and notifications fan out to
156- // every pinned route. Everything else is routed per-call below.
173+ // every pinned route. Repo-less agents skip fan-out entirely — they keep
174+ // the legacy PAT-backed path (reads only; writes are denied above).
157175 if state. multi_app_tokens . is_some ( ) {
158176 let frame_method = frame. as_ref ( ) . map ( |f| f. method . as_str ( ) ) . unwrap_or ( "" ) ;
159177 if method == Method :: POST && frame_method == "initialize" && session_id. is_none ( ) {
@@ -162,7 +180,9 @@ pub async fn mcp_proxy(
162180 // authenticate() already rejected keyless requests.
163181 return rpc_error ( StatusCode :: UNAUTHORIZED , "agent authentication required" ) ;
164182 } ;
165- return multi_initialize ( & state, & headers, body, agent) . await ;
183+ if !agent. repos . is_empty ( ) {
184+ return multi_initialize ( & state, & headers, body, agent) . await ;
185+ }
166186 }
167187 if let Some ( sid) = session_id. as_deref ( ) {
168188 if method == Method :: DELETE
@@ -702,33 +722,32 @@ async fn pick_credential(
702722 // New session, multi-installation mode: stateless call (initialize is
703723 // handled by the fan-out path before credential resolution). Route by
704724 // the resolved owner, or the agent's first owner for repo-less methods.
725+ // Repo-less agents fall through to the PAT pool (legacy read path).
705726 if let Some ( multi) = & state. multi_app_tokens {
706- // Validation guarantees agents exist in multi mode.
707- let Some ( agent) = agent else {
708- return Err ( StatusCode :: UNAUTHORIZED ) ;
709- } ;
710- let key = match route_owner {
711- Some ( o) => o. to_lowercase ( ) ,
712- None => match route_owners ( agent) . into_iter ( ) . next ( ) {
713- Some ( o) => o,
714- None => return Err ( StatusCode :: BAD_GATEWAY ) ,
715- } ,
716- } ;
717- let Some ( provider) = multi. get ( & key) else {
718- return Err ( StatusCode :: FORBIDDEN ) ;
719- } ;
720- let envelope = scope_envelope_for_owner ( agent, & key) ;
721- return match provider. token_scoped ( & envelope) . await {
722- Ok ( t) => Ok ( McpCredential :: Routed {
723- owner : key,
724- token : t. token ,
725- upstream_session : None ,
726- } ) ,
727- Err ( e) => {
728- tracing:: error!( "App token mint failed for owner {}: {}" , key, e) ;
729- Err ( StatusCode :: BAD_GATEWAY )
727+ if let Some ( agent) = agent {
728+ let owners = route_owners ( agent) ;
729+ if let Some ( first_owner) = owners. first ( ) {
730+ let key = match route_owner {
731+ Some ( o) => o. to_lowercase ( ) ,
732+ None => first_owner. clone ( ) ,
733+ } ;
734+ let Some ( provider) = multi. get ( & key) else {
735+ return Err ( StatusCode :: FORBIDDEN ) ;
736+ } ;
737+ let envelope = scope_envelope_for_owner ( agent, & key) ;
738+ return match provider. token_scoped ( & envelope) . await {
739+ Ok ( t) => Ok ( McpCredential :: Routed {
740+ owner : key,
741+ token : t. token ,
742+ upstream_session : None ,
743+ } ) ,
744+ Err ( e) => {
745+ tracing:: error!( "App token mint failed for owner {}: {}" , key, e) ;
746+ Err ( StatusCode :: BAD_GATEWAY )
747+ }
748+ } ;
730749 }
731- } ;
750+ }
732751 }
733752 // New session: App backend takes precedence when configured. The token
734753 // is scoped to the agent's repo envelope when possible (exact entries,
@@ -2901,15 +2920,27 @@ mod tests {
29012920 & [ "issue_read" ] ,
29022921 & [ "openabdev/openab" ] ,
29032922 ) ,
2923+ // Repo-less agent (like the legacy b2): keeps the PAT-backed
2924+ // read path in multi mode, writes always denied.
2925+ agent_with_repos(
2926+ "b2pat" ,
2927+ "key-b2pat" ,
2928+ & [ "search_code" , "issue_read" , "create_issue" ] ,
2929+ & [ ] ,
2930+ ) ,
29042931 ] ;
29052932 let cache_config = config:: CacheConfig :: default ( ) ;
29062933 let has_sink = sink. is_some ( ) ;
2934+ let identities = vec ! [ config:: IdentityConfig {
2935+ id: "alice" . into( ) ,
2936+ token: "token-alice" . into( ) ,
2937+ } ] ;
29072938 Arc :: new ( AppState {
2908- pool : pool:: PatPool :: new ( & [ ] ) ,
2939+ pool : pool:: PatPool :: new ( & identities ) ,
29092940 cache : cache:: Cache :: new ( & cache_config) ,
29102941 config : config:: Config {
29112942 port : 8080 ,
2912- identities : vec ! [ ] ,
2943+ identities : identities . clone ( ) ,
29132944 allowed_owners : vec ! [ "openabdev" . to_string( ) , "oablab" . to_string( ) ] ,
29142945 cache : cache_config,
29152946 mcp : config:: McpConfig {
@@ -3297,6 +3328,64 @@ mod tests {
32973328 assert ! ( state. mcp_sessions. get( "sess-ghs_oablab" ) . await . is_none( ) ) ;
32983329 }
32993330
3331+ #[ tokio:: test]
3332+ async fn test_multi_repoless_agent_keeps_pat_read_path ( ) {
3333+ let ( url, captured) = spawn_mock_upstream_multi ( ) . await ;
3334+ let state = test_state_multi ( & url, false , None ) . await ;
3335+
3336+ // initialize as the repo-less agent: NO fan-out — single upstream
3337+ // request, served by the pooled PAT, pinned normally
3338+ let resp = mcp_app ( state. clone ( ) )
3339+ . oneshot ( post_frame ( MULTI_INIT , & [ ( "x-ghpool-key" , "key-b2pat" ) ] ) )
3340+ . await
3341+ . unwrap ( ) ;
3342+ assert_eq ! ( resp. status( ) , StatusCode :: OK ) ;
3343+ assert_eq ! ( resp. headers( ) . get( "mcp-session-id" ) . unwrap( ) , "sess-token-alice" ) ;
3344+ {
3345+ let reqs = captured. lock ( ) . unwrap ( ) ;
3346+ assert_eq ! ( reqs. len( ) , 1 , "repo-less agent must not fan out" ) ;
3347+ assert_eq ! ( reqs[ 0 ] . auth. as_deref( ) , Some ( "Bearer token-alice" ) ) ;
3348+ }
3349+ assert_eq ! (
3350+ state. mcp_sessions. get( "sess-token-alice" ) . await ,
3351+ Some ( pin( "alice" , Some ( "b2pat" ) ) )
3352+ ) ;
3353+
3354+ // repo-less read tools (search_code) still work on the session
3355+ let resp = mcp_app ( state)
3356+ . oneshot ( post_frame (
3357+ r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_code","arguments":{"query":"foo"}}}"# ,
3358+ & [ ( "x-ghpool-key" , "key-b2pat" ) , ( "mcp-session-id" , "sess-token-alice" ) ] ,
3359+ ) )
3360+ . await
3361+ . unwrap ( ) ;
3362+ assert_eq ! ( resp. status( ) , StatusCode :: OK ) ;
3363+ assert_eq ! ( captured. lock( ) . unwrap( ) . len( ) , 2 ) ;
3364+ }
3365+
3366+ #[ tokio:: test]
3367+ async fn test_multi_repoless_agent_writes_denied_even_when_enabled ( ) {
3368+ let ( url, captured) = spawn_mock_upstream_multi ( ) . await ;
3369+ let path = audit_tmp ( "repoless-write" ) ;
3370+ let sink = crate :: audit:: AuditSink :: open ( & path) . unwrap ( ) ;
3371+ // writes globally enabled — the repo-less agent must still be denied
3372+ let state = test_state_multi ( & url, true , Some ( sink) ) . await ;
3373+ let resp = mcp_app ( state)
3374+ . oneshot ( post_frame (
3375+ r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_issue","arguments":{"owner":"openabdev","repo":"openab","title":"t"}}}"# ,
3376+ & [ ( "x-ghpool-key" , "key-b2pat" ) ] ,
3377+ ) )
3378+ . await
3379+ . unwrap ( ) ;
3380+ assert_eq ! ( resp. status( ) , StatusCode :: FORBIDDEN ) ;
3381+ let body = axum:: body:: to_bytes ( resp. into_body ( ) , usize:: MAX ) . await . unwrap ( ) ;
3382+ let v: serde_json:: Value = serde_json:: from_slice ( & body) . unwrap ( ) ;
3383+ assert ! ( v[ "error" ] [ "message" ] . as_str( ) . unwrap( ) . contains( "repository-scoped" ) ) ;
3384+ assert ! ( captured. lock( ) . unwrap( ) . is_empty( ) ) ;
3385+ assert ! ( read_audit( & path) . is_empty( ) ) ;
3386+ std:: fs:: remove_file ( & path) . ok ( ) ;
3387+ }
3388+
33003389 #[ tokio:: test]
33013390 async fn test_multi_write_audited_with_installation_label ( ) {
33023391 let ( url, captured) = spawn_mock_upstream_multi ( ) . await ;
0 commit comments