Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ jobs:
run: |
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_permissions.py
cargo test --locked --manifest-path tests/architecture/ipc-probe/Cargo.toml
PYTHONDONTWRITEBYTECODE=1 python3 scripts/generate_protocol.py --check
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_protocol_schemas.py
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_identity.py
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_forbidden_surface.py
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ jobs:
bun run workspace:test
bun run workspace:build
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=packages/sdk-python python3 -m unittest discover -s packages/sdk-python/tests -v
PYTHONDONTWRITEBYTECODE=1 python3 scripts/generate_protocol.py --check
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_protocol_schemas.py
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_identity.py
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_forbidden_surface.py
Expand Down
5 changes: 5 additions & 0 deletions docs/adr/0001-agenttab-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ TCP and bearer-token access are not Standard transport. They exist only behind t

AgentTab Core RPC and the host-to-extension native protocol are separately versioned. They MUST NOT silently downgrade across an incompatible version.

The canonical protocol catalog, generated artifact workflow, and additive capability negotiation are
documented in [`docs/protocol-development.md`](../protocol-development.md). An incompatible hello or
connection receives an explicit compatibility frame before disconnect; v1 peers that omit capability
fields retain the legacy handshake shape.

MCP, OMP, CLI, TypeScript, and Python are adapters over Core RPC. They are not alternate hosts. The public Standard surface has exactly seven tools:

1. `browser_open`
Expand Down
48 changes: 48 additions & 0 deletions docs/protocol-development.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Protocol development

AgentTab keeps wire behavior reviewable without asking contributors to synchronize lists by hand.
The canonical catalog is [`protocol/agenttab-v1.json`](../protocol/agenttab-v1.json). It owns protocol
names and versions, feature names, frame limits, outcomes, RPC method metadata, native methods and
events, and the mapping from each RPC method to its JSON Schema input.

The JSON Schemas under [`schemas/`](../schemas/) remain the canonical structural definitions for
requests, responses, connection negotiation, native messages, and tool inputs. The generator checks
that their method branches, mutation requirements, outcomes, schema IDs, and registered files agree
with the catalog. MCP consumes the input schemas directly. Rust, TypeScript, Python, and the extension
consume small committed generated catalogs so release artifacts do not need Python at runtime.

## Changing the protocol

1. Edit the catalog and the affected JSON Schemas together.
2. Run `python3 scripts/generate_protocol.py` (or `bun run protocol:generate`).
3. Review the generated diff; generated files are committed.
4. Run `python3 scripts/generate_protocol.py --check` and the normal workspace tests.

CI and release jobs run the check offline and fail when generated files drift. Do not edit files marked
`@generated` directly.

## Compatibility contract

Core `connect` and native `hello` messages may include `supported_versions` and
`supported_features`. Version 1 messages that omit both fields retain their original behavior and
receive the original acknowledgement shape. When a peer advertises capabilities, the host selects an
overlapping version and returns only the feature intersection. Feature names are additive; code must
not infer support for an unadvertised feature.

An incompatible Core connection receives a bounded `kind: "incompatible"` frame before the host
closes the stream. An incompatible native hello receives the corresponding native frame. Both include
the requested protocol/version, host-supported versions, and a recovery instruction. This replaces an
ambiguous EOF while still refusing silent major-version downgrade.

Capability-advertising clients first send the enriched v1 handshake. If a peer closes or rejects that
transport before sending any acknowledgement, the TypeScript and Python SDKs retry once on a new
connection with the exact legacy-v1 handshake. The extension does the same and keeps using legacy v1
for that service-worker lifetime. A timeout, any Core response bytes, any received native message, or
an explicit `incompatible` frame never triggers the fallback. This bounded asymmetric-upgrade path lets
an auto-updated client reach an older strict-v1 host without turning real incompatibility into a retry
loop.

Version and feature fields are optional specifically so the current v1 clients and extension continue
to connect to a newer host. A future major may advertise several supported versions, but it must use
the version selected by the host for every subsequent frame. Removing or changing an existing feature,
method, outcome, or schema constraint requires a new protocol major and a migration note.
1 change: 1 addition & 0 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=packages/sdk-python python3 -m unittest dis
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_permissions.py
cargo test --locked --manifest-path tests/architecture/ipc-probe/Cargo.toml
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_protocol_schemas.py
PYTHONDONTWRITEBYTECODE=1 python3 scripts/generate_protocol.py --check
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_identity.py
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_forbidden_surface.py
cargo fmt --all --manifest-path host-rs/Cargo.toml -- --check
Expand Down
85 changes: 68 additions & 17 deletions host-rs/crates/agenttab-host/src/native.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
use crate::handoff::HandoffState;
use crate::lifecycle::Lifecycle;
use agenttab_protocol::{
native_close_task, native_command, native_event_ack, native_event_ack_result, native_ready,
read_frame, write_frame, NativeDisconnectEvent, NativeDisconnectRecovery, NativeEvent,
NativeEventName, NativeEventPayload, NativeHandoff, NativeHello, NativeOriginPolicy,
NativeResponse, NativeStagedCommit, NativeTab, Outcome, ProtocolError, RpcError, RuntimeState,
EXTENSION_TO_HOST_MAX_BYTES, HOST_TO_EXTENSION_MAX_BYTES, NATIVE_PROTOCOL, PROTOCOL_VERSION,
native_close_task, native_command, native_event_ack, native_event_ack_result,
native_incompatible, native_ready, read_frame, write_frame, NativeDisconnectEvent,
NativeDisconnectRecovery, NativeEvent, NativeEventName, NativeEventPayload, NativeHandoff,
NativeHello, NativeOriginPolicy, NativeResponse, NativeStagedCommit, NativeTab, Outcome,
ProtocolError, RpcError, RuntimeState, EXTENSION_TO_HOST_MAX_BYTES,
HOST_TO_EXTENSION_MAX_BYTES, NATIVE_PROTOCOL, NATIVE_VERSION,
};
use parking_lot::{Mutex, RwLock};
use serde_json::Value;
Expand Down Expand Up @@ -166,19 +167,35 @@ impl StdioNative {
.get("protocol")
.and_then(Value::as_str)
.unwrap_or_default();
let version = value
let requested_version = value
.get("version")
.and_then(Value::as_u64)
.unwrap_or_default() as u16;
if protocol != NATIVE_PROTOCOL || version != PROTOCOL_VERSION {
.unwrap_or_default();
let kind = value.get("kind").and_then(Value::as_str).map(str::to_owned);
let supports_host_version = value
.get("supported_versions")
.and_then(Value::as_array)
.is_some_and(|versions| {
versions
.iter()
.any(|version| version.as_u64() == Some(u64::from(NATIVE_VERSION)))
});
if protocol != NATIVE_PROTOCOL
|| (requested_version != u64::from(NATIVE_VERSION)
&& !(kind.as_deref() == Some("hello") && supports_host_version))
{
if kind.as_deref() == Some("hello") {
self.write_value(&native_incompatible(protocol, requested_version))?;
}
return Err(ProtocolError::UnsupportedProtocol {
protocol: protocol.into(),
version,
version: u16::try_from(requested_version).unwrap_or_default(),
});
}
match value.get("kind").and_then(Value::as_str) {
match kind.as_deref() {
Some("hello") => {
let hello = NativeHello::parse(value)?;
let features = hello.negotiated_features();
self.lifecycle.begin_reconciliation();
if let Some(sink) = self.event_sink.read().clone() {
sink.reconcile(&hello.inventory, &hello.staged_commits, &hello.handoff)
Expand All @@ -192,7 +209,7 @@ impl StdioNative {
} else {
RuntimeState::Ready
};
self.write_value(&native_ready(state))?;
self.write_value(&native_ready(state, features))?;
}
Some("response") => {
let response = NativeResponse::parse(value)?;
Expand Down Expand Up @@ -543,13 +560,15 @@ mod tests {
let native = StdioNative::new(output.clone(), lifecycle.clone(), handoff.clone());
let hello = json!({
"protocol": NATIVE_PROTOCOL,
"version": PROTOCOL_VERSION,
"version": NATIVE_VERSION,
"kind": "hello",
"extension_version": "0.2.0",
"inventory": [],
"paused": false,
"handoff": {"active": false},
"staged_commits": []
"staged_commits": [],
"supported_versions": [1],
"supported_features": ["event_ack_v1", "future_feature"]
});
let mut input = Vec::new();
write_frame(&mut input, &hello, EXTENSION_TO_HOST_MAX_BYTES).unwrap();
Expand All @@ -560,6 +579,38 @@ mod tests {
.unwrap()
.unwrap();
assert_eq!(ready["kind"], "ready");
assert_eq!(ready["features"], json!(["event_ack_v1"]));
}

#[test]
fn incompatible_native_hello_receives_recovery_before_failure() {
let lifecycle = Arc::new(Lifecycle::default());
let handoff = Arc::new(HandoffState::default());
let output = SharedWriter::default();
let native = StdioNative::new(output.clone(), lifecycle, handoff);
let result = native.handle_inbound(json!({
"protocol": NATIVE_PROTOCOL,
"version": 2,
"kind": "hello",
"extension_version": "3.0.0",
"inventory": [],
"paused": false,
"handoff": {"active": false},
"staged_commits": [],
"supported_versions": [2],
"supported_features": []
}));
assert!(matches!(
result,
Err(ProtocolError::UnsupportedProtocol { .. })
));
let bytes = output.bytes.lock().clone();
let incompatible = read_frame(&mut bytes.as_slice(), HOST_TO_EXTENSION_MAX_BYTES)
.unwrap()
.unwrap();
assert_eq!(incompatible["kind"], "incompatible");
assert_eq!(incompatible["requested_version"], 2);
assert_eq!(incompatible["supported_versions"], json!([1]));
}
#[test]
fn handoff_clear_is_acknowledged_only_after_sink_applies_it() {
Expand All @@ -576,7 +627,7 @@ mod tests {
native
.handle_inbound(json!({
"protocol": NATIVE_PROTOCOL,
"version": PROTOCOL_VERSION,
"version": NATIVE_VERSION,
"kind": "event",
"event": "handoff_changed",
"event_id": "handoff-clear-0001",
Expand All @@ -596,7 +647,7 @@ mod tests {
.unwrap(),
json!({
"protocol": NATIVE_PROTOCOL,
"version": PROTOCOL_VERSION,
"version": NATIVE_VERSION,
"kind": "event_ack",
"event": "handoff_changed",
"event_id": "handoff-clear-0001",
Expand All @@ -618,7 +669,7 @@ mod tests {
native
.handle_inbound(json!({
"protocol": NATIVE_PROTOCOL,
"version": PROTOCOL_VERSION,
"version": NATIVE_VERSION,
"kind": "event",
"event": "popup_commit_approved",
"event_id": event_id,
Expand Down Expand Up @@ -650,7 +701,7 @@ mod tests {
native
.handle_inbound(json!({
"protocol": NATIVE_PROTOCOL,
"version": PROTOCOL_VERSION,
"version": NATIVE_VERSION,
"kind": "response",
"request_id": request_id,
"outcome": "completed",
Expand Down
10 changes: 10 additions & 0 deletions host-rs/crates/agenttab-host/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1884,6 +1884,8 @@ mod tests {
kind: ConnectKind::Connect,
conversation_id: None,
resume_capability: None,
supported_versions: None,
supported_features: None,
})
.unwrap();
(temp, runtime, connection)
Expand All @@ -1909,6 +1911,8 @@ mod tests {
kind: ConnectKind::Connect,
conversation_id: None,
resume_capability: None,
supported_versions: None,
supported_features: None,
})
.unwrap();
(temp, runtime, connection)
Expand Down Expand Up @@ -1947,6 +1951,8 @@ mod tests {
kind: ConnectKind::Connect,
conversation_id: None,
resume_capability: None,
supported_versions: None,
supported_features: None,
})
.unwrap();
(temp, runtime, connection, upload_root)
Expand Down Expand Up @@ -2238,6 +2244,8 @@ mod tests {
kind: ConnectKind::Connect,
conversation_id: None,
resume_capability: None,
supported_versions: None,
supported_features: None,
})
.unwrap();
let response = runtime.handle(
Expand Down Expand Up @@ -2296,6 +2304,8 @@ mod tests {
kind: ConnectKind::Connect,
conversation_id: None,
resume_capability: None,
supported_versions: None,
supported_features: None,
})
.unwrap();
own_tab(&runtime, &connection, 7);
Expand Down
62 changes: 58 additions & 4 deletions host-rs/crates/agenttab-host/src/server.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::runtime::{request_lock_scope, RequestLockScope, Runtime};
use agenttab_protocol::{
ConnectionInit, Outcome, ResumeCapabilityConfirm, RpcError, RpcMethod, RpcResponse,
CLIENT_TO_HOST_MAX_BYTES, HOST_TO_CLIENT_MAX_BYTES,
connection_incompatible, ConnectionInit, Outcome, ProtocolError, ResumeCapabilityConfirm,
RpcError, RpcMethod, RpcResponse, CLIENT_TO_HOST_MAX_BYTES, HOST_TO_CLIENT_MAX_BYTES,
};
#[cfg(all(test, unix))]
use agenttab_protocol::{PROTOCOL_VERSION, RPC_PROTOCOL};
Expand Down Expand Up @@ -540,8 +540,27 @@ where
let Some(init_value) = read_frame_async(&mut stream, CLIENT_TO_HOST_MAX_BYTES).await? else {
return Ok(());
};
let init = ConnectionInit::parse(init_value)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let init = match ConnectionInit::parse(init_value.clone()) {
Ok(init) => init,
Err(ProtocolError::UnsupportedProtocol { .. }) => {
let requested_protocol = init_value
.get("protocol")
.and_then(Value::as_str)
.unwrap_or("<missing>");
let requested_version = init_value
.get("version")
.and_then(Value::as_u64)
.unwrap_or_default();
write_frame_async(
&mut stream,
&connection_incompatible(requested_protocol, requested_version),
HOST_TO_CLIENT_MAX_BYTES,
)
.await?;
return Ok(());
}
Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)),
};
let resume_capability_supplied = init.resume_capability.is_some();
let (connection, ack) = runtime
.connect(init)
Expand Down Expand Up @@ -1002,6 +1021,8 @@ mod tests {
kind: agenttab_protocol::ConnectKind::Connect,
conversation_id: None,
resume_capability,
supported_versions: None,
supported_features: None,
}
}

Expand Down Expand Up @@ -1040,6 +1061,37 @@ mod tests {
.unwrap()
}

#[tokio::test]
async fn incompatible_connection_receives_a_recovery_frame_before_eof() {
let temp = tempfile::tempdir().unwrap();
let (runtime, paths) = test_runtime(&temp);
let (mut client, server) = start_test_connection(runtime).await;
write_frame_async(
&mut client,
&serde_json::json!({
"protocol": RPC_PROTOCOL,
"version": 2,
"kind": "connect",
"supported_versions": [2],
"supported_features": []
}),
CLIENT_TO_HOST_MAX_BYTES,
)
.await
.unwrap();

let response = read_frame_async(&mut client, HOST_TO_CLIENT_MAX_BYTES)
.await
.unwrap()
.unwrap();
assert_eq!(response["kind"], "incompatible");
assert_eq!(response["requested_version"], 2);
assert_eq!(response["supported_versions"], serde_json::json!([1]));
assert!(response["recovery"].as_str().unwrap().contains("Update"));
assert_eq!(task_count(&paths), 0);
assert!(server.await.unwrap().is_ok());
}

#[tokio::test]
async fn explicit_invalid_resume_capability_closes_before_a_pipelined_rpc_can_create_a_task() {
let temp = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -1376,6 +1428,8 @@ mod tests {
kind: agenttab_protocol::ConnectKind::Connect,
conversation_id: None,
resume_capability: None,
supported_versions: None,
supported_features: None,
})
.unwrap(),
CLIENT_TO_HOST_MAX_BYTES,
Expand Down
Loading
Loading