Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
109 commits
Select commit Hold shift + click to select a range
ecf826c
docs(acp): add MCP-over-ACP browser-control implementation blueprint …
brettchien Jul 19, 2026
21cfbb5
docs(adr): resolve MCP-over-ACP browser-control design (D1-D4) + flow…
brettchien Jul 19, 2026
b14552a
feat(gateway/acp): server-initiated request direction (T1.2/1.3)
brettchien Jul 19, 2026
4d6055d
refactor(gateway/acp): construct trivial responses from generated typ…
brettchien Jul 19, 2026
54ac761
feat(gateway/acp): MCP-over-ACP tunnel frame API (T4.1)
brettchien Jul 19, 2026
f6c9294
feat(core): scaffold MCP proxy server deps + static browser toolset (…
brettchien Jul 19, 2026
b8a01cb
feat(core): MCP proxy ServerHandler + loopback server (T5.1b)
brettchien Jul 19, 2026
cc9dbf7
feat(core): bearer gate on the MCP proxy server (T5.2 part 1)
brettchien Jul 19, 2026
63d6566
docs(adr): correct runtime diagram — mcp/message flattens, no inner id
brettchien Jul 19, 2026
d91814e
feat(gateway/acp): parse + record client-declared type:acp mcpServers…
brettchien Jul 19, 2026
9661191
docs: MCP-over-ACP tunnel contract for the extension (T4.3)
brettchien Jul 19, 2026
767c9ee
feat(gateway/acp): TunnelHandle — per-connection MCP tunnel handle (T…
brettchien Jul 19, 2026
daa23da
feat(gateway/acp): tunnel registry + establish_and_register_tunnel (T…
brettchien Jul 19, 2026
eedf6ce
feat(gateway): thread AcpTunnelRegistry through AppState (T5.3)
brettchien Jul 19, 2026
b88a176
feat(gateway/acp): open MCP tunnels on session/new + cleanup (T5.3)
brettchien Jul 19, 2026
4af5ad9
feat(core): BrowserTunnel trait + ProxyHandler forwards tool calls (T…
brettchien Jul 19, 2026
c908496
feat(core): start_session_server — per-session server + .cursor/mcp.j…
brettchien Jul 19, 2026
6cbd03f
feat(core): start per-session MCP proxy on agent spawn (T5.2/D5-a)
brettchien Jul 19, 2026
e582383
feat(root): wire the browser tunnel bridge end-to-end (T5.3, D6-a')
brettchien Jul 19, 2026
c9896ce
docs(adr): record the as-built OpenAB side (D5-a + D6-a', end-to-end)
brettchien Jul 19, 2026
059da24
test(e2e): MCP-over-ACP tunnel producer section in acp-ws-smoke (T7)
brettchien Jul 19, 2026
6d5d1ce
test(e2e): complete the MCP-over-ACP tunnel suite (fan-out + filtering)
brettchien Jul 19, 2026
ddc9620
fix(acp-mcp): address Mira review nits — constant-time bearer + strin…
brettchien Jul 19, 2026
6476dae
fix(acp-mcp): establish the browser tunnel on session/resume, not jus…
brettchien Jul 19, 2026
dde1b34
chore(acp-mcp): log browser tunnel open/register for live-session obs…
brettchien Jul 19, 2026
06165f0
fix(acp-mcp): raise ACP frame cap 1→8 MiB for browser-tool results
brettchien Jul 19, 2026
1f5b0c9
fix(acp-mcp): one browser tunnel per session — fix fan-out overwrite/…
brettchien Jul 19, 2026
bbc9e13
fix(acp-mcp): 0600 mcp.json + strip stale bearer on evict (M-B2)
brettchien Jul 19, 2026
b9bdd08
chore(acp-mcp): lock subtle dependency
brettchien Jul 19, 2026
a6d9654
feat(mcp-proxy): write openab-browser into kiro-cli config too, not j…
brettchien Jul 20, 2026
dc0009a
docs: browser MCP agent setup — per-variant mcp.json how-to (Phase 2 #8)
brettchien Jul 20, 2026
c2bd77d
feat(mcp-proxy): per-pod browser-bridge socket server (Option C, P1)
brettchien Jul 20, 2026
73a777e
feat(cli): openab browser-bridge subcommand — stdio MCP relay to the …
brettchien Jul 20, 2026
8b18c48
feat(acp): inject OPENAB_BROWSER_CHANNEL into the agent env (Option C…
brettchien Jul 20, 2026
2509bde
feat(mcp-proxy): static write-once browser-bridge config (Option C, P4)
brettchien Jul 20, 2026
822fdf9
feat: OPENAB_BROWSER_MODE proxy|bridge toggle wiring (Option C, P5)
brettchien Jul 20, 2026
1d51376
fix(browser-bridge): resolve channel via process-ancestry, not env (O…
brettchien Jul 20, 2026
c83e399
feat(mcp-proxy): revert bridge config to pure {command,args} (Option …
brettchien Jul 20, 2026
3696d13
fix(acp): add acp_mcp_servers to test-only AcpSession initializers
brettchien Jul 24, 2026
0ed644d
docs(acp): split into reverse-MCP mechanism ADR + browser-control ADR…
brettchien Jul 24, 2026
144c232
docs(adr): correct D4 list_changed overclaim in browser ADR
brettchien Jul 24, 2026
1c35aaa
refactor(acp): compound-key (channel_id,server_id) tunnel registry + …
brettchien Jul 24, 2026
c000534
Merge main (post #1448/#1449/#1453): keep browser tunnel wiring along…
chaodu-agent Jul 25, 2026
487a05a
chore: regenerate Cargo.lock for merged deps
chaodu-agent Jul 25, 2026
c2a7c6a
fix(mcp-proxy): register browser server in kiro per-agent configs (--…
chaodu-agent Jul 25, 2026
a492eb5
Merge remote-tracking branch 'origin/main' into pr1447rw
chaodu-agent Jul 25, 2026
bf37d25
feat(mcp): browser capabilities through the session-aware facade (Fac…
chaodu-agent Jul 25, 2026
74e23f0
fix: facade_serving is acp-only — derive it, don't flag it (default-f…
chaodu-agent Jul 25, 2026
32cc50f
docs(adr): §6 builds on the OAB MCP Facade — one AcpTunnelSource, sta…
brettchien Jul 26, 2026
9e53486
Merge main: OAB MCP adapter ADR (#1446) + gmail-native doc fix (#1455)
brettchien Jul 26, 2026
ee0998c
docs(adr): align §6 with the merged facade series + record the mcpSer…
brettchien Jul 26, 2026
3cb8bde
docs(adr): tighten §6.4 trust gate — deny-all tool_filter + pinned br…
brettchien Jul 26, 2026
0cc6dd5
docs(adr): §6.1/§6.2 — declared id vs name, and last-attach-wins on s…
brettchien Jul 26, 2026
5dc45de
feat(acp): record declared server name, establish all declared tunnel…
brettchien Jul 26, 2026
ba94efe
feat(acp): route client-declared servers by name and gate their tool …
brettchien Jul 26, 2026
2d334ae
docs(adr): §6.3 — policy entries seed the catalog; cache narrows, nev…
brettchien Jul 26, 2026
eb732f1
feat(mcp): operator config surface for the client-declared server all…
brettchien Jul 26, 2026
1e7ea11
feat(acp): discovery cache — fetch each declared server's real tools/…
brettchien Jul 26, 2026
9ab286a
test(acp): two client-declared servers in one session
brettchien Jul 26, 2026
270a2ff
refactor(mcp): rename the client-declared browser surface to katashiro.*
brettchien Jul 26, 2026
81d775c
docs(adr): follow the katashiro.* rename through both ADRs
brettchien Jul 26, 2026
17ecc47
docs: lead the browser-MCP setup guide with facade mode, demote proxy…
brettchien Jul 26, 2026
969684a
docs(adr): fold the browser ADR into the reverse-MCP ADR as its worke…
brettchien Jul 26, 2026
1c1919c
docs(mcp): note that mcp.audit must be named in RUST_LOG or auditing …
brettchien Jul 26, 2026
19f9831
docs: warn that a leftover mode entry silently bypasses the facade
brettchien Jul 26, 2026
b6fdeb4
ci: run the acp-mcp core and acp root tests
brettchien Jul 28, 2026
08a6053
fix(acp): do not open tunnels after a rejected session/resume
brettchien Jul 28, 2026
a3068b3
fix(acp): do not mint a facade session token when its config write fails
brettchien Jul 28, 2026
16df6de
fix(mcp): retire the direct browser transport when facade mode sets up
brettchien Jul 28, 2026
28cad68
fix(acp): keep the 8 MiB frame allowance to tunnel results only
brettchien Jul 28, 2026
63fb63a
fix(mcp): authenticate the bridge connection, not the frame
brettchien Jul 28, 2026
1970bae
fix(mcp): revoke facade session tokens by token, not by channel
brettchien Jul 28, 2026
3aa8f6a
fix(acp): disconnect tunnels replaced by last-attach-wins
brettchien Jul 28, 2026
d9a2679
fix(mcp): bound bridge frame reads
brettchien Jul 28, 2026
34b5f2a
ci: quote the acp-mcp test filter so the workflow file parses
brettchien Jul 28, 2026
e308e75
fix(mcp): only delete a direct browser entry we can prove we wrote
brettchien Jul 28, 2026
399d892
fix(acp): bound client-declared MCP server fan-out per session
brettchien Jul 28, 2026
580988c
ci: select the pool facade tests, not just compile them
brettchien Jul 28, 2026
55eab82
fix(mcp): stop a second mint from invalidating a live session token
brettchien Jul 28, 2026
bb27e96
docs(mcp): correct SessionTokens docs to match coexisting-token seman…
brettchien Jul 28, 2026
6ea4b44
feat(acp)!: remove the stdio bridge transport
brettchien Jul 28, 2026
feded44
fix(acp): warn when OPENAB_BROWSER_MODE names the removed bridge
brettchien Jul 28, 2026
01dff01
feat(acp)!: remove the per-session proxy and require [mcp]
brettchien Jul 28, 2026
8e7009d
fix(acp): report browser control at startup, and drop the proxy's lef…
brettchien Jul 28, 2026
e0c4a92
docs(acp): retire the descriptions the removed transports left behind
brettchien Jul 28, 2026
93c8e15
docs(adr): correct the descriptions the removed transports left in th…
brettchien Jul 28, 2026
87206fd
docs(adr): remove the last four present-tense claims about the old tr…
brettchien Jul 28, 2026
f2b4dbd
docs(adr): correct nine claims found by two independent semantic reads
brettchien Jul 28, 2026
a91437c
docs(adr): resolve the two claims that were ambiguous between past an…
brettchien Jul 28, 2026
bdd70c3
docs(adr): fix the discovery order, the status line and the last `bro…
brettchien Jul 28, 2026
0f58183
docs(adr): correct nine more claims, and record two gaps instead of h…
brettchien Jul 28, 2026
deb2577
test(acp): drive the real /acp WebSocket end to end
brettchien Jul 29, 2026
197bb4d
fix(acp): scope registry teardown to the connection that owns the entry
brettchien Jul 29, 2026
8045a1b
refactor(acp): give reset_session and hung eviction one implementation
brettchien Jul 29, 2026
468c141
fix(acp): resume stores what it accepted, and retires what it withdrew
brettchien Jul 29, 2026
147c094
fix(acp): give tunnel establishes their own task budget
brettchien Jul 29, 2026
bfeebb8
fix(acp): make the facade config writer safe on a user's mcp.json
brettchien Jul 29, 2026
9249a5a
fix(acp): stop logging a resume credential at info
brettchien Jul 29, 2026
91563f2
fix(acp): complete the inner MCP lifecycle before using a tunnel
brettchien Jul 29, 2026
8b8f550
fix(acp): restore the deadlock warning to the function it belongs to
brettchien Jul 29, 2026
1e70a93
fix(acp): test that a refused handshake blocks registration, and tell…
brettchien Jul 29, 2026
3182b82
fix(acp): disconnect the connection a failed handshake leaves open
brettchien Jul 29, 2026
d98e554
fix(acp): warn when handshake cleanup fails, and stop duplicating the…
brettchien Jul 29, 2026
5306541
chore(acp): drop six dead_code allows that had stopped being true
brettchien Jul 29, 2026
87fac9c
chore(acp): drop the last six dead_code allows, and the doc claims th…
brettchien Jul 29, 2026
7915db2
chore(acp): drop the seventh dead_code allow, which was stale like th…
brettchien Jul 29, 2026
c1f4dac
fix(acp): reject an inner MCP server that answers a protocol version …
brettchien Jul 29, 2026
bbb5394
fix(acp): order last-attach-wins by a generation stamp, not by who re…
brettchien Jul 29, 2026
130a1f7
fix(acp): derive withdrawn declarations from the registry, and delete…
brettchien Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
target/
.git/
data/
*.tgz
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@ jobs:
# `-p openab-gateway` to avoid the workspace hooks::tests parallel flake.
- name: cargo test (acp gateway)
run: cargo test -p openab-gateway --features acp
# Same default-features gap on the core side: `mcp_proxy` is the only `acp-mcp`-gated module,
# so its tunnel-trait, tools-cache and bridge-dispatch tests never compile above either.
# Filtered to `mcp_proxy::` for the same reason the gateway step is scoped to one package —
# it keeps the flaky hooks::tests out of this job.
- name: cargo test (acp-mcp core)
# Quoted: the trailing `::` of the filter would otherwise read as a YAML mapping
# indicator and make the whole workflow file unparseable.
run: "cargo test -p openab-core --features acp-mcp mcp_proxy::"
# The pool's facade-session tests are `acp-mcp`-gated too but live outside `mcp_proxy::`, so
# the filter above compiled them and then selected them away. Naming the module runs them
# while still leaving the flaky hooks::tests out.
- name: cargo test (acp-mcp pool)
# Quoted for the same reason as above — a trailing `::` reads as a YAML mapping indicator.
run: "cargo test -p openab-core --features acp-mcp acp::pool::"
# And the root package's own tests — the ACP-tunnel capability source (browser_source.rs) and
# are `acp`-gated, so `--workspace` above skips
# them too. This is where the source's routing and trust-gate coverage lives.
- name: cargo test (acp root)
run: cargo test --features acp
- name: cargo build (unified)
run: cargo build --features unified

Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ feishu = ["dep:openab-gateway", "dep:axum", "openab-gateway/feishu"]
googlechat = ["dep:openab-gateway", "dep:axum", "openab-gateway/googlechat"]
wecom = ["dep:openab-gateway", "dep:axum", "openab-gateway/wecom"]
teams = ["dep:openab-gateway", "dep:axum", "openab-gateway/teams"]
acp = ["dep:openab-gateway", "dep:axum", "openab-gateway/acp"]
acp = ["dep:openab-gateway", "dep:axum", "openab-gateway/acp", "openab-core/acp-mcp"]

[dev-dependencies]
tempfile = "3.27.0"
8 changes: 8 additions & 0 deletions crates/openab-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ aws-credential-types = { version = "1", optional = true }
urlencoding = { version = "2", optional = true }
hex = { version = "0.4", optional = true }
http = { version = "1", optional = true }
# Browser tool definitions for the facade's capability source. Only `rmcp::model` is used now —
# the loopback MCP server that needed the server + streamable-http transport features (and its own
# axum listener) was removed with the per-session proxy.
rmcp = { version = "1.7", default-features = false, optional = true }
tokio-util = { version = "0.7", optional = true }

[target.'cfg(unix)'.dependencies]
libc = "0.2"
Expand All @@ -65,3 +70,6 @@ config-s3 = ["dep:aws-sdk-s3", "dep:aws-config"]
pre-seed = ["dep:aws-sdk-s3", "dep:aws-config", "dep:zip", "dep:hex", "dep:flate2", "dep:tar"]
filestore = ["dep:aws-sdk-s3", "dep:aws-config"]
agentcore = ["dep:aws-config", "dep:aws-sigv4", "dep:aws-credential-types", "dep:urlencoding", "dep:hex", "dep:http", "dep:rustls", "dep:tokio-rustls", "dep:webpki-roots"]
# Core-side wiring for MCP-over-ACP browser control (enabled by the root `acp`): the browser tool
# definitions and the agent's facade MCP config. Core hosts no MCP server of its own any more.
acp-mcp = ["dep:rmcp", "dep:tokio-util"]
16 changes: 16 additions & 0 deletions crates/openab-core/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ pub struct AcpConnection {
pub session_reset: bool,
_reader_handle: JoinHandle<()>,
_stderr_handle: Option<JoinHandle<()>>,
/// Revokes this session's facade token when the connection is dropped, on any evict path.
/// Held only for its `Drop` side effect (never read).
///
/// It used to cancel a per-session MCP proxy server; that server is gone and the guard now
/// carries the minted token instead.
#[cfg(feature = "acp-mcp")]
#[allow(dead_code)]
facade_token_guard: Option<tokio_util::sync::DropGuard>,
}

/// Build the final set of env vars for the agent subprocess.
Expand Down Expand Up @@ -485,9 +493,17 @@ impl AcpConnection {
session_reset: false,
_reader_handle: reader_handle,
_stderr_handle: stderr_handle,
#[cfg(feature = "acp-mcp")]
facade_token_guard: None,
})
}

/// Attach the guard that revokes this session's facade token when the connection drops.
#[cfg(feature = "acp-mcp")]
pub fn set_facade_token_guard(&mut self, guard: Option<tokio_util::sync::DropGuard>) {
self.facade_token_guard = guard;
}

fn next_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::Relaxed)
}
Expand Down
204 changes: 190 additions & 14 deletions crates/openab-core/src/acp/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ pub struct SessionPool {
mapping_path: PathBuf,
meta_path: PathBuf,
default_config_options: HashMap<String, String>,
#[cfg(feature = "acp-mcp")]
session_registrar: Option<Arc<dyn crate::mcp_proxy::SessionTokenRegistrar>>,
#[cfg(feature = "acp-mcp")]
facade_url: Option<String>,
}

type CancelHandle = (Arc<tokio::sync::Mutex<tokio::process::ChildStdin>>, String);
Expand Down Expand Up @@ -102,13 +106,46 @@ fn better_candidate(current_oldest: Option<Instant>, candidate_last_active: Inst
}
}

/// Remove every non-`active` pool entry for `key`, reset-style.
/// Prepare facade browser capabilities for one session: write the agent's facade MCP entry, and
/// mint its session token **only if that write succeeded**.
///
/// Hung eviction must NOT leave the session resumable: the old streaming task
/// still holds an Arc clone of the connection, so the agent process may be
/// alive and mid-turn. If the session id stayed in `suspended`/`persisted`,
/// the next message would `session/load` the same session while the old
/// process still owns an in-flight turn. Mirror `reset_session` instead.
/// The token is useless without the config. The entry is what points the agent at the facade and
/// carries `Authorization: Bearer ${OPENAB_SESSION_TOKEN}`; with no entry the agent never reaches
/// the facade and never presents the token. Minting regardless would register a live credential
/// for a session that cannot use it and leave it valid until eviction, while the failure showed up
/// only as a warning. Returning `None` keeps the session running without browser capabilities,
/// which is the honest description of what actually happened.
#[cfg(feature = "acp-mcp")]
async fn setup_facade_session(
workdir: &str,
facade_url: &str,
channel_id: &str,
registrar: &Arc<dyn crate::mcp_proxy::SessionTokenRegistrar>,
) -> Option<String> {
match crate::mcp_proxy::write_facade_mcp_config(workdir, facade_url).await {
Ok(()) => Some(registrar.mint(channel_id)),
Err(e) => {
tracing::error!(
workdir, error = %e,
"facade mcp config write failed — starting this session WITHOUT browser \
capabilities and not minting a session token that could never be presented"
);
None
}
}
}

/// Remove every non-`active` pool entry for `key`.
///
/// The single implementation for both hung eviction and [`SessionPool::reset_session`]; the latter
/// removes `active` itself and then calls this. It used to be a second copy of the same list, which
/// is how the two could drift — and the line most likely to be lost from a copy is the one below
/// about the creating gate, because it says *not* to remove something.
///
/// Hung eviction must NOT leave the session resumable: the old streaming task still holds an Arc
/// clone of the connection, so the agent process may be alive and mid-turn. If the session id
/// stayed in `suspended`/`persisted`, the next message would `session/load` the same session while
/// the old process still owns an in-flight turn.
fn purge_session_entries(state: &mut PoolState, key: &str) {
state.cancel_handles.remove(key);
state.activity.remove(key);
Expand Down Expand Up @@ -197,9 +234,29 @@ impl SessionPool {
mapping_path,
meta_path,
default_config_options,
#[cfg(feature = "acp-mcp")]
session_registrar: None,
#[cfg(feature = "acp-mcp")]
facade_url: None,
}
}

/// Wire the facade session-token registrar + facade URL (Facade mode,
/// set by the root when `[mcp]` is running). With both present, browser
/// capabilities route through the facade: the pool mints one token per
/// session, injects it as `OPENAB_SESSION_TOKEN` in the agent process
/// env, and writes the static facade MCP entry once per workdir.
#[cfg(feature = "acp-mcp")]
pub fn with_facade_sessions(
mut self,
registrar: Option<Arc<dyn crate::mcp_proxy::SessionTokenRegistrar>>,
facade_url: Option<String>,
) -> Self {
self.session_registrar = registrar;
self.facade_url = facade_url;
self
}

fn load_mapping(path: &Path) -> HashMap<String, String> {
match std::fs::read_to_string(path) {
Ok(data) => serde_json::from_str(&data).unwrap_or_else(|e| {
Expand Down Expand Up @@ -347,13 +404,70 @@ impl SessionPool {
self.config.working_dir.clone()
};

// Browser capabilities for an `acp:` session come from the OAB MCP Facade and nowhere
// else: mint a per-session token (it rides the agent spawn below as OPENAB_SESSION_TOKEN)
// and write the static facade entry before the agent boots. The returned guard revokes
// that token when this connection is dropped, on any evict path.
//
// There is no transport fallback. Without `[mcp]` the root wires no registrar, and the
// session simply starts without browser capabilities — which is the honest outcome and is
// reported once at startup rather than being silently substituted per session.
#[cfg(feature = "acp-mcp")]
let mut session_token: Option<String> = None;
#[cfg(feature = "acp-mcp")]
let facade_token_guard: Option<tokio_util::sync::DropGuard> = match (
thread_id.strip_prefix("acp:"),
self.session_registrar.as_ref(),
self.facade_url.as_ref(),
) {
(Some(channel_id), Some(registrar), Some(facade_url)) => {
match setup_facade_session(&effective_workdir, facade_url, channel_id, registrar)
.await
{
Some(token) => {
session_token = Some(token.clone());
info!(thread_id, "session token minted for facade browser capabilities");
// The guard carries the TOKEN it minted, not the channel. A replaced
// session's teardown runs after its successor has already re-minted for
// the same channel, so revoking by channel would strip the live token and
// silently cut the new agent off from the facade; revoking this exact
// token is a no-op by then (R1).
let ct = tokio_util::sync::CancellationToken::new();
let child = ct.child_token();
let registrar = registrar.clone();
tokio::spawn(async move {
child.cancelled().await;
registrar.revoke(&token);
});
Some(ct.drop_guard())
}
// No config, so no token and no revoke guard to arm. The session still
// starts — it simply has no browser capabilities.
None => None,
}
}
_ => None,
};

// Build the replacement connection outside the state lock so one stuck
// initialization does not block all unrelated sessions.
#[cfg(feature = "acp-mcp")]
let spawn_env: std::collections::HashMap<String, String> = {
let mut env = self.config.env.clone();
if let Some(tok) = &session_token {
// The static facade MCP entry references ${OPENAB_SESSION_TOKEN};
// the value lives only in this agent process's environment.
env.insert("OPENAB_SESSION_TOKEN".to_string(), tok.clone());
}
env
};
#[cfg(not(feature = "acp-mcp"))]
let spawn_env = self.config.env.clone();
let mut new_conn = AcpConnection::spawn(
&self.config.command,
&self.config.args,
&effective_workdir,
&self.config.env,
&spawn_env,
&self.config.inherit_env,
)
.await?;
Expand Down Expand Up @@ -423,6 +537,8 @@ impl SessionPool {
let activity_handle = new_conn.activity_handle();
let child_pgid = new_conn.child_pgid();
let cancel_session_id = new_conn.acp_session_id.clone().unwrap_or_default();
#[cfg(feature = "acp-mcp")]
new_conn.set_facade_token_guard(facade_token_guard);
let new_conn = Arc::new(Mutex::new(new_conn));

let mut state = self.state.write().await;
Expand Down Expand Up @@ -636,13 +752,11 @@ impl SessionPool {

let mut state = self.state.write().await;
let had_active = state.active.remove(thread_id).is_some();
state.cancel_handles.remove(thread_id);
state.activity.remove(thread_id);
state.pgids.remove(thread_id);
state.suspended.remove(thread_id);
state.persisted.remove(thread_id);
state.creating.remove(thread_id);
state.session_workdirs.remove(thread_id);
// Everything else a reset clears is exactly what hung eviction clears, including the rule
// that the creating gate survives. Call the one implementation rather than keeping a second
// copy of the list: the copies are what let the two drift, and the gate rule is precisely
// the kind of line that gets dropped from a duplicate without anyone noticing.
purge_session_entries(&mut state, thread_id);
self.save_mapping(&state.persisted);
self.save_meta(&state.session_workdirs);
if had_active {
Expand Down Expand Up @@ -818,6 +932,68 @@ mod tests {
use tokio::sync::Mutex;
use tokio::time::Instant;

/// Registrar double that records every mint, so a test can assert one never happened.
#[cfg(feature = "acp-mcp")]
#[derive(Default)]
struct CountingRegistrar {
minted: std::sync::Mutex<Vec<String>>,
}

#[cfg(feature = "acp-mcp")]
impl crate::mcp_proxy::SessionTokenRegistrar for CountingRegistrar {
fn mint(&self, channel_id: &str) -> String {
self.minted.lock().unwrap().push(channel_id.to_string());
"token-xyz".to_string()
}
fn revoke(&self, _token: &str) {}
}

/// A failed facade config write must not mint a token. The agent has no `openab` entry, so it
/// can never present one; minting anyway would leave a live credential registered for a
/// session that cannot use it until eviction.
#[cfg(feature = "acp-mcp")]
#[tokio::test]
async fn no_token_is_minted_when_the_facade_config_write_fails() {
let dir = tempfile::tempdir().unwrap();
// Make `<workdir>/.cursor` a FILE, so create_dir_all inside the writer fails.
std::fs::write(dir.path().join(".cursor"), b"not a directory").unwrap();

let counting = Arc::new(CountingRegistrar::default());
let registrar: Arc<dyn crate::mcp_proxy::SessionTokenRegistrar> = counting.clone();
let token = super::setup_facade_session(
dir.path().to_str().unwrap(),
"http://127.0.0.1:8848/mcp",
"acp_x",
&registrar,
)
.await;

assert!(token.is_none(), "a failed config write must yield no token");
assert!(
counting.minted.lock().unwrap().is_empty(),
"the registrar must never be asked to mint when the config could not be written"
);
}

/// The happy path still mints exactly once, for the right channel.
#[cfg(feature = "acp-mcp")]
#[tokio::test]
async fn a_successful_facade_config_write_mints_one_token() {
let dir = tempfile::tempdir().unwrap();
let counting = Arc::new(CountingRegistrar::default());
let registrar: Arc<dyn crate::mcp_proxy::SessionTokenRegistrar> = counting.clone();
let token = super::setup_facade_session(
dir.path().to_str().unwrap(),
"http://127.0.0.1:8848/mcp",
"acp_x",
&registrar,
)
.await;

assert_eq!(token.as_deref(), Some("token-xyz"));
assert_eq!(counting.minted.lock().unwrap().as_slice(), ["acp_x"]);
}

#[test]
fn remove_if_same_handle_removes_matching_entry() {
let expected = Arc::new(Mutex::new(1_u8));
Expand Down
Loading
Loading