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
39 changes: 34 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,51 @@ on:
- "src/**"
- "crates/**"
- "operator/**"
- "scripts/teams-ack-drop-proxy.py"
- "scripts/test-teams-ack-drop-proxy.py"
- ".github/workflows/ci.yml"
- "Cargo.toml"
- "Cargo.lock"
- "Dockerfile*"

env:
CARGO_TERM_COLOR: always

permissions:
contents: read

jobs:
changes:
runs-on: ubuntu-latest
outputs:
core: ${{ steps.filter.outputs.core }}
operator: ${{ steps.filter.outputs.operator }}
teams_ack_proxy: ${{ steps.filter.outputs.teams_ack_proxy }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
fetch-depth: 0
persist-credentials: false
- id: filter
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: |
CHANGED=$(git diff --name-only "$BASE" "$HEAD")
echo "core=$(echo "$CHANGED" | grep -qE '^(src/|crates/|Cargo\.(toml|lock))' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "operator=$(echo "$CHANGED" | grep -q '^operator/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
{
echo "core=$(echo "$CHANGED" | grep -qE '^(src/|crates/|Cargo\.(toml|lock)|\.github/workflows/ci\.yml$)' && echo true || echo false)"
echo "operator=$(echo "$CHANGED" | grep -q '^operator/' && echo true || echo false)"
echo "teams_ack_proxy=$(echo "$CHANGED" | grep -qE '^(scripts/(teams-ack-drop-proxy|test-teams-ack-drop-proxy)\.py|\.github/workflows/ci\.yml)$' && echo true || echo false)"
} >> "$GITHUB_OUTPUT"

check:
needs: changes
if: needs.changes.outputs.core == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-07-13)
with:
components: clippy
Expand Down Expand Up @@ -94,6 +107,20 @@ jobs:
- name: cargo build (unified)
run: cargo build --features unified

teams-ack-proxy:
needs: changes
if: needs.changes.outputs.teams_ack_proxy == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Test bounded Teams ACK-drop proxy
run: |
set -euo pipefail
python3 -m py_compile scripts/teams-ack-drop-proxy.py scripts/test-teams-ack-drop-proxy.py
python3 -W error::ResourceWarning scripts/test-teams-ack-drop-proxy.py

operator:
needs: changes
if: needs.changes.outputs.operator == 'true'
Expand All @@ -102,7 +129,9 @@ jobs:
run:
working-directory: operator
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-07-13)
with:
components: clippy
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ gateway/target/
config.toml
*.swp
.DS_Store
__pycache__/
*.py[cod]
.env
.kiro/

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

16 changes: 16 additions & 0 deletions charts/openab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ Each agent lives under `agents.<name>`.
| `stt.baseUrl` | STT API base URL. | `"https://api.groq.com/openai/v1"` |
| `gateway.enabled` | Enable the gateway config block for webhook-based platforms. | `false` |
| `gateway.deploy` | Deploy the gateway Deployment and Service. | `true` |
| `gateway.teams.reactionsEnabled` | Opt in to Microsoft public-preview Bot Connector reactions. | `false` |
| `gateway.teams.inboundAttachments` | Enable metadata-first Teams image/text ingress on both Core and Gateway. | `false` |
| `cron.usercronEnabled` | Enable user-provided cron configuration. | `false` |
| `cronjobs` | Config-driven scheduled messages for an agent. | `[]` |
| `persistence.enabled` | Enable persistent storage for auth and settings. | `true` |
Expand Down Expand Up @@ -112,6 +114,20 @@ See [`docs/migrate-to-configtoml.md`](../../docs/migrate-to-configtoml.md) for a
[`docs/adr/configurl-over-helm-rendering.md`](../../docs/adr/configurl-over-helm-rendering.md) for when to prefer `configUrl` instead
(platform-agnostic — works identically on Kubernetes, ECS, Zeabur, and AgentCore).

For Teams typed scope, put the policy in that raw TOML rather than under the Gateway transport values:

```toml
[teams]
allowed_teams = []
allowed_channels = [] # both empty = all Team channels; otherwise Team OR channel match
allow_personal = true
allow_group_chats = true
```

Presence of any of these four fields opts into typed L2 policy. In Standalone Gateway mode, the policy still belongs to the OpenAB Core `configToml`; `gateway.teams.*` configures transport credentials and reaction preview on the Gateway container.

`gateway.teams.inboundAttachments=true` is the exception that must stay aligned across processes: the chart emits `TEAMS_INBOUND_ATTACHMENTS=true` into both Core and Gateway. It enables bounded metadata-first image/text materialization only after Core trust admission. When `gateway.deploy=false`, configure the same environment variable on the external Gateway yourself.

### Discord ID precision warning

Discord IDs must be set with `--set-string`, not `--set`. Otherwise Helm may coerce them into numbers and lose precision.
4 changes: 4 additions & 0 deletions charts/openab/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ spec:
name: {{ include "openab.agentFullname" $d }}
key: gateway-ws-token
{{- end }}
{{- if and ($cfg.gateway).enabled (hasKey (($cfg.gateway).teams) "inboundAttachments") }}
- name: TEAMS_INBOUND_ATTACHMENTS
value: {{ ($cfg.gateway).teams.inboundAttachments | quote }}
{{- end }}
- name: HOME
value: {{ $cfg.workingDir | default "/home/agent" }}
{{- range $k, $v := $cfg.env }}
Expand Down
8 changes: 8 additions & 0 deletions charts/openab/templates/gateway.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ spec:
- name: TEAMS_WEBHOOK_PATH
value: {{ ($cfg.gateway).teams.webhookPath | quote }}
{{- end }}
{{- if hasKey (($cfg.gateway).teams) "reactionsEnabled" }}
- name: TEAMS_REACTIONS_ENABLED
value: {{ ($cfg.gateway).teams.reactionsEnabled | quote }}
{{- end }}
{{- if hasKey (($cfg.gateway).teams) "inboundAttachments" }}
- name: TEAMS_INBOUND_ATTACHMENTS
value: {{ ($cfg.gateway).teams.inboundAttachments | quote }}
{{- end }}
{{- end }}
{{- $hasFeishu := and (($cfg.gateway).feishu).appId (($cfg.gateway).feishu).appSecret }}
{{- if $hasFeishu }}
Expand Down
12 changes: 11 additions & 1 deletion charts/openab/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ agents:
# # allowed_channels = ["C01234567"]
# # allow_user_messages = "mentions"
# #
# # [teams]
# # allowed_teams = [] # Team IDs; both lists empty = all Team channels
# # allowed_channels = [] # a Team OR channel match admits
# # allow_personal = true
# # allow_group_chats = true
# #
# # [agent]
# # command = "claude-agent-acp"
# # inherit_env = ["ANTHROPIC_API_KEY"]
Expand Down Expand Up @@ -416,7 +422,7 @@ agents:
gateway:
enabled: false # set to true + provide url to enable the [gateway] config block
deploy: true # set to false to skip Gateway Deployment/Service (config-only mode)
url: "" # e.g. ws://openab-gateway:8080/ws
url: "" # WebSocket URL, e.g. the in-cluster openab-gateway Service
platform: "telegram" # default platform when gateway is enabled
token: "" # optional shared secret (injected via GATEWAY_WS_TOKEN env var)
botUsername: "" # optional, for @mention gating
Expand Down Expand Up @@ -461,6 +467,10 @@ agents:
openidMetadata: "" # Override for sovereign clouds → TEAMS_OPENID_METADATA
allowedTenants: [] # List of tenant IDs → TEAMS_ALLOWED_TENANTS
webhookPath: "" # Gateway default: /webhook/teams → TEAMS_WEBHOOK_PATH
reactionsEnabled: false # Public-preview Bot Connector reactions → TEAMS_REACTIONS_ENABLED
# Default-off metadata-first image/text ingress. Sets the same env on
# Core and Gateway; no Microsoft URL or token crosses into Core.
inboundAttachments: false # → TEAMS_INBOUND_ATTACHMENTS
# Feishu/Lark adapter config (gateway-side env vars)
# See docs/feishu.md for full setup guide
feishu:
Expand Down
16 changes: 15 additions & 1 deletion config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto-
# # send-once (streaming is forced off to avoid
# # posting duplicate, growing messages)
# streaming_placeholder = false # set false for draft-based platforms (e.g. Telegram Rich Messages)
# gateway_ack_timeout_secs = 12 # only enforced for ACKs advertised by a negotiated gateway

# --- Telegram (first-class section; alternative to TELEGRAM_* env vars) ---
# Config-authoritative with ${} expansion; each field falls back to its
Expand Down Expand Up @@ -133,7 +134,20 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto-
# app_secret = "${TEAMS_APP_SECRET}" # env fallback: TEAMS_APP_SECRET
# allowed_tenants = ["<tenant-guid>"] # env fallback: TEAMS_ALLOWED_TENANTS (empty = all)
# webhook_path = "/webhook/teams" # env fallback: TEAMS_WEBHOOK_PATH
# allow_all_users = false # env fallback: TEAMS_ALLOW_ALL_USERS
# dedupe_ttl_secs = 600 # env fallback: TEAMS_DEDUPE_TTL_SECS
# route_ttl_secs = 3600 # env fallback: TEAMS_ROUTE_TTL_SECS
# max_route_entries = 10000 # independent route/dedupe/ownership caps; env: TEAMS_MAX_ROUTE_ENTRIES
# reactions_enabled = false # public-preview reactions; env: TEAMS_REACTIONS_ENABLED
# processing_indicator = "off" # off | message; env: TEAMS_PROCESSING_INDICATOR
# streaming = false # progressive bot-owned edits; env: TEAMS_STREAMING
# inbound_attachments = false # post-trust image/text materialization; env: TEAMS_INBOUND_ATTACHMENTS
# allowed_teams = [] # Team IDs; env: TEAMS_ALLOWED_TEAMS (comma-separated)
# allowed_channels = [] # channel IDs; env: TEAMS_ALLOWED_CHANNELS
# # both empty = all Team channels; Team OR channel match
# allow_personal = true # env: TEAMS_ALLOW_PERSONAL
# allow_group_chats = true # env: TEAMS_ALLOW_GROUP_CHATS
# # setting any field above opts into typed scope policy
# allow_all_users = false # independent L3 gate; env: TEAMS_ALLOW_ALL_USERS
# allowed_users = ["29:1abc..."] # Bot Framework activity.from.id values (29:…)
# # env fallback: TEAMS_ALLOWED_USERS (comma-separated)

Expand Down
93 changes: 50 additions & 43 deletions crates/openab-core/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,20 +602,55 @@ impl AcpConnection {
Ok(session_id)
}

/// Set a config option (e.g. model, mode) via ACP session/set_config_option.
/// Returns the updated list of all config options.
/// Set a config option while retaining the legacy prompt fallback used by
/// operator-supplied default configuration. Broker-owned commands use the
/// strict variant below so they never consume an agent turn.
pub async fn set_config_option(
&mut self,
config_id: &str,
value: &str,
) -> Result<Vec<ConfigOption>> {
if let Ok(options) = self.set_config_option_strict(config_id, value).await {
return Ok(options);
}

let session_id = self
.acp_session_id
.as_ref()
.ok_or_else(|| anyhow!("no session"))?
.clone();
let command = format!("/{config_id} {value}");
info!("set_config_option unsupported; using legacy prompt fallback");
self.send_request(
"session/prompt",
Some(json!({
"sessionId": session_id,
"prompt": [{"type": "text", "text": command}],
})),
)
.await?;
for option in &mut self.config_options {
if option.id == config_id {
option.current_value = value.to_string();
}
}
Ok(self.config_options.clone())
}

let resp = self
/// Set a config option only through the ACP configuration method. No
/// `session/prompt` fallback is allowed because command interception must
/// not turn a broker control into an agent turn.
pub async fn set_config_option_strict(
&mut self,
config_id: &str,
value: &str,
) -> Result<Vec<ConfigOption>> {
let session_id = self
.acp_session_id
.as_ref()
.ok_or_else(|| anyhow!("no session"))?
.clone();
let response = self
.send_request(
"session/set_config_option",
Some(json!({
Expand All @@ -624,39 +659,11 @@ impl AcpConnection {
"value": value,
})),
)
.await;

match resp {
Ok(r) => {
if let Some(result) = r.result.as_ref() {
self.config_options = parse_config_options(result);
}
info!(config_id, value, "config option set");
}
Err(_) => {
// Fall back: send as a slash command (e.g. "/model claude-sonnet-4")
let cmd = format!("/{config_id} {value}");
info!(
cmd,
"set_config_option not supported, falling back to prompt"
);
let _resp = self
.send_request(
"session/prompt",
Some(json!({
"sessionId": session_id,
"prompt": [{"type": "text", "text": cmd}],
})),
)
.await?;
for opt in &mut self.config_options {
if opt.id == config_id {
opt.current_value = value.to_string();
}
}
}
.await?;
if let Some(result) = response.result.as_ref() {
self.config_options = parse_config_options(result);
}

info!("config option set");
Ok(self.config_options.clone())
}

Expand Down Expand Up @@ -956,7 +963,7 @@ mod tests {

let (result, inherited) = build_agent_env(&explicit, &inherit);

assert_eq!(result.get(key).unwrap(), "from_config");
assert_eq!(result.get(key).map(String::as_str), Some("from_config"));
assert!(!inherited.contains(&key.to_string()));
std::env::remove_var(key);
}
Expand All @@ -970,7 +977,7 @@ mod tests {

let (result, inherited) = build_agent_env(&explicit, &inherit);

assert_eq!(result.get(key).unwrap(), "process_value");
assert_eq!(result.get(key).map(String::as_str), Some("process_value"));
assert!(inherited.contains(&key.to_string()));
std::env::remove_var(key);
}
Expand Down Expand Up @@ -1022,8 +1029,8 @@ mod reader_loop_tests {
));

let stale = b"{\"jsonrpc\":\"2.0\",\"id\":42,\"result\":{\"stopReason\":\"ok\"}}\n";
agent_stdout_writer.write_all(stale).await.unwrap();
agent_stdout_writer.flush().await.unwrap();
assert!(agent_stdout_writer.write_all(stale).await.is_ok());
assert!(agent_stdout_writer.flush().await.is_ok());

let forwarded = tokio::time::timeout(std::time::Duration::from_secs(2), sub_rx.recv())
.await
Expand All @@ -1033,7 +1040,7 @@ mod reader_loop_tests {
assert!(pending.lock().await.is_empty());

drop(agent_stdout_writer);
handle.await.unwrap();
assert!(handle.await.is_ok());
}

/// Matched-id path: when a response's id is in `pending`, the loop must
Expand Down Expand Up @@ -1065,8 +1072,8 @@ mod reader_loop_tests {
));

let payload = b"{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"stopReason\":\"end_turn\"}}\n";
agent_stdout_writer.write_all(payload).await.unwrap();
agent_stdout_writer.flush().await.unwrap();
assert!(agent_stdout_writer.write_all(payload).await.is_ok());
assert!(agent_stdout_writer.flush().await.is_ok());

let resolved = tokio::time::timeout(std::time::Duration::from_secs(2), resp_rx)
.await
Expand All @@ -1082,7 +1089,7 @@ mod reader_loop_tests {
assert!(pending.lock().await.is_empty());

drop(agent_stdout_writer);
handle.await.unwrap();
assert!(handle.await.is_ok());
}

#[test]
Expand Down
Loading
Loading