Skip to content
Open
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 charts/openab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ 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.serviceAccountName` | Existing ServiceAccount attached to the gateway pod. Empty falls back to the per-agent `serviceAccountName`, then the chart-global value. Required for GKE Workload Identity when `gateway.googleChat.useAdc=true`; the chart does not create or annotate the ServiceAccount. | `""` |
| `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
13 changes: 12 additions & 1 deletion charts/openab/templates/gateway.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
{{- $gwCfg := omit $cfg "nameOverride" }}
{{- $d := dict "ctx" $ "agent" (printf "%s-gateway" $name) "cfg" $gwCfg }}
{{- $agentD := dict "ctx" $ "agent" $name "cfg" $cfg }}
{{- $agentSvcAcct := default $.Values.serviceAccountName $cfg.serviceAccountName }}
{{- $gatewaySvcAcct := default $agentSvcAcct (($cfg.gateway).serviceAccountName) }}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F2 - Preserve gateway identity across Helm upgrades

Before this change the gateway omitted serviceAccountName and used Kubernetes' default ServiceAccount. An empty gateway value now inherits the agent/global account, so existing releases with either value set silently change the gateway's RBAC and cloud identity on upgrade. There is also no value that explicitly preserves the old default-account behavior.

Requested change: make inheritance opt-in or add an explicit preserve-default mode, then cover and document the upgrade path. Explicit gateway.serviceAccountName should continue to support Workload Identity.

{{- $hasTeams := and (($cfg.gateway).teams).appId (($cfg.gateway).teams).appSecret }}
{{- $hasTelegram := (($cfg.gateway).telegram).botToken }}
{{- $hasLine := (($cfg.gateway).line).channelSecret }}
Expand Down Expand Up @@ -32,6 +34,9 @@ spec:
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if $gatewaySvcAcct }}
serviceAccountName: {{ $gatewaySvcAcct | quote }}
{{- end }}
containers:
- name: gateway
image: {{ printf "%s:%s" (($cfg.gateway).image | default "ghcr.io/openabdev/openab-gateway") (($cfg.gateway).tag | default $.Chart.AppVersion) }}
Expand Down Expand Up @@ -157,10 +162,16 @@ spec:
value: "false"
{{- end }}
{{- end }}
{{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken (($cfg.gateway).googleChat).audience }}
{{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken (($cfg.gateway).googleChat).audience (($cfg.gateway).googleChat).useAdc }}
{{- if $hasGoogleChat }}
- name: GOOGLE_CHAT_ENABLED
value: "true"
{{- if (($cfg.gateway).googleChat).useAdc }}
- name: GOOGLE_CHAT_USE_ADC
value: "true"
- name: GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT
value: {{ required "gateway.googleChat.adcTargetServiceAccount is required when useAdc=true" (($cfg.gateway).googleChat).adcTargetServiceAccount | quote }}
{{- end }}
{{- if (($cfg.gateway).googleChat).audience }}
- name: GOOGLE_CHAT_AUDIENCE
value: {{ ($cfg.gateway).googleChat.audience | quote }}
Expand Down
69 changes: 69 additions & 0 deletions charts/openab/tests/gateway_serviceaccount_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
suite: gateway serviceAccountName support

templates:
- templates/gateway.yaml

set:
agents.kiro.gateway.enabled: true

tests:
- it: does not render a gateway serviceAccountName when no value is set
documentIndex: 0
asserts:
- notExists:
path: spec.template.spec.serviceAccountName

- it: falls back to the chart-global serviceAccountName
set:
serviceAccountName: global-runtime
documentIndex: 0
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: global-runtime

- it: falls back to the per-agent serviceAccountName before the global value
set:
serviceAccountName: global-runtime
agents.kiro.serviceAccountName: agent-runtime
documentIndex: 0
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: agent-runtime

- it: gateway serviceAccountName overrides per-agent and global values
set:
serviceAccountName: global-runtime
agents.kiro.serviceAccountName: agent-runtime
agents.kiro.gateway.serviceAccountName: gateway-runtime
documentIndex: 0
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: gateway-runtime

- it: renders ADC target and the dedicated gateway runtime service account together
set:
agents.kiro.gateway.serviceAccountName: googlechat-runtime
agents.kiro.gateway.googleChat.useAdc: true
agents.kiro.gateway.googleChat.adcTargetServiceAccount: openab-chat@project.iam.gserviceaccount.com
documentIndex: 0
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: googlechat-runtime
- contains:
path: spec.template.spec.containers[0].env
content:
name: GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT
value: openab-chat@project.iam.gserviceaccount.com

- it: quotes numeric-looking gateway serviceAccountName as a string
set:
agents.kiro.gateway.serviceAccountName: "123"
documentIndex: 0
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: "123"
6 changes: 6 additions & 0 deletions charts/openab/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,10 @@ 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)
# Existing Kubernetes ServiceAccount for the gateway pod. Empty falls back
# to agents.<name>.serviceAccountName, then chart-global serviceAccountName.
# Required on GKE when googleChat.useAdc uses Workload Identity.
serviceAccountName: ""
url: "" # e.g. ws://openab-gateway:8080/ws
platform: "telegram" # default platform when gateway is enabled
token: "" # optional shared secret (injected via GATEWAY_WS_TOKEN env var)
Expand Down Expand Up @@ -459,6 +463,8 @@ agents:
audience: "" # JWT audience → GOOGLE_CHAT_AUDIENCE (set to your webhook URL to enable JWT verification)
saKeyJson: "" # Service account key JSON string → GOOGLE_CHAT_SA_KEY_JSON (recommended, auto-refresh)
accessToken: "" # Static OAuth2 access token → GOOGLE_CHAT_ACCESS_TOKEN (fallback, 1-hour TTL)
useAdc: false # Keyless ADC → GOOGLE_CHAT_USE_ADC. Runtime SA impersonates the distinct adcTargetServiceAccount; no SA key file. Needs roles/iam.serviceAccountTokenCreator on the target + iamcredentials.googleapis.com. Self-impersonation is prohibited.
adcTargetServiceAccount: "" # Dedicated Chat-app SA email → GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT. Required when useAdc=true; MUST differ from the pod/runtime SA.
webhookPath: "" # Gateway default: /webhook/googlechat → GOOGLE_CHAT_WEBHOOK_PATH
# WeCom (企业微信) adapter config (gateway-side env vars)
# See docs/wecom.md for full setup guide
Expand Down
2 changes: 2 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto-
# sa_key_json = "${GOOGLE_CHAT_SA_KEY_JSON}" # inline SA key; wins over sa_key_file
# sa_key_file = "/etc/openab/sa.json" # env fallback: GOOGLE_CHAT_SA_KEY_FILE
# access_token = "${GOOGLE_CHAT_ACCESS_TOKEN}" # static token alternative
# use_adc = true # keyless ADC via GCE metadata + IAM Credentials; runtime SA impersonates a DISTINCT target (self-impersonation is prohibited). env: GOOGLE_CHAT_USE_ADC
# adc_target_service_account = "chat-bot@project.iam.gserviceaccount.com" # required with use_adc; env: GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT
# audience = "projects/<n>/..." # enables webhook JWT verification (L1)
# webhook_path = "/webhook/googlechat" # env fallback: GOOGLE_CHAT_WEBHOOK_PATH
# allow_all_users = false # env fallback: GOOGLE_CHAT_ALLOW_ALL_USERS
Expand Down
50 changes: 41 additions & 9 deletions crates/openab-core/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ fn reply_message_limit(platform: &str, adapter_limit: usize) -> usize {
}
}

/// Whether to use cosmetic streaming (placeholder + in-place edits) for
/// `platform`, given the adapter's own preference. Forces send-once for:
/// - `acp`: streams append-only `agent_message_chunk` deltas, not edits.
/// - platforms in `NON_STREAMING_PLATFORMS` — no message-edit API, or an edit
/// API that cannot be driven per-token (e.g. `googlechat`) — see
/// `NON_STREAMING_PLATFORMS` for the per-platform rationale and
/// `platform_supports_streaming`.
///
/// This is the embedded/unified dispatch gate; the WebSocket
/// `run_gateway_adapter` path applies the same `platform_supports_streaming`
/// check but has no `acp` case (ACP is embedded-only). The two gates are
/// siblings: adding a platform to `NON_STREAMING_PLATFORMS` covers both
/// paths, while an embedded-only carve-out belongs here.
fn resolve_streaming(platform: &str, adapter_prefers_streaming: bool) -> bool {
platform != "acp"
&& crate::gateway::platform_supports_streaming(platform)
&& adapter_prefers_streaming
}

/// Parse `[[key:value]]` directives from the beginning of agent output.
/// Returns parsed directives and the remaining content (directives stripped).
pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) {
Expand Down Expand Up @@ -701,15 +720,14 @@ impl AdapterRouter {
let adapter = adapter.clone();
let thread_channel = thread_channel.clone();
let message_limit = reply_message_limit(&thread_channel.platform, adapter.message_limit());
// ACP must not inherit the unified adapter's Telegram streaming flag (wrong
// coupling): it streams append-only `agent_message_chunk` deltas built from the
// post+edit (`edit_message` snapshot) path, i.e. streaming=false. Decide it
// explicitly by platform rather than by whatever Telegram happens to be set to.
let streaming = if thread_channel.platform == "acp" {
false
} else {
adapter.use_streaming(other_bot_present)
};
// Decide streaming explicitly by platform, not by whatever the unified
// adapter's Telegram flag happens to be. ACP streams append-only deltas
// (not cosmetic edits); Google Chat / LINE can't sustain in-place edits.
// See `resolve_streaming`.
let streaming = resolve_streaming(
&thread_channel.platform,
adapter.use_streaming(other_bot_present),
);
// Keep the full turn text (incl. inter-tool narration) when streaming
// (it was already shown live) OR when `[reactions] narration_display` is
// set. Otherwise a send-once turn delivers only the final answer block.
Expand Down Expand Up @@ -1746,6 +1764,20 @@ mod tests {
assert_eq!(crate::format::split_message(&long, reply_message_limit("acp", 4096)).len(), 1);
}

#[test]
fn resolve_streaming_forces_send_once_for_acp_and_googlechat() {
// Editable platforms honor the adapter's own streaming preference.
assert!(resolve_streaming("discord", true));
assert!(!resolve_streaming("discord", false));
assert!(resolve_streaming("telegram", true));
// ACP streams append-only deltas, not cosmetic edits → always send-once.
assert!(!resolve_streaming("acp", true));
// Google Chat: synthetic id can't be patched (400 INVALID_ARGUMENT) → send-once regardless of pref.
assert!(!resolve_streaming("googlechat", true));
// LINE: no edit API → send-once.
assert!(!resolve_streaming("line", true));
}

#[test]
fn select_delivery_text_send_once_keeps_only_final_block() {
// Simulates: narration "n1" → tool (answer_start→2) → narration "n2"
Expand Down
63 changes: 58 additions & 5 deletions crates/openab-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,16 @@ pub struct GoogleChatConfig {
/// checked when `allow_all_users` resolves to `false`. Env fallback:
/// `GOOGLE_CHAT_ALLOWED_USERS` (comma-separated).
pub allowed_users: Option<Vec<String>>,
/// Use keyless ADC (GCE metadata server + IAM Credentials
/// `generateAccessToken`) to mint the `chat.bot` token for a distinct target
/// service account. Env fallback: `GOOGLE_CHAT_USE_ADC` (`true`/`1`; default
/// false). Ignored when a configured SA key loads successfully.
pub use_adc: Option<bool>,
/// Dedicated Google Chat service account impersonated by the attached
/// runtime service account. Required when `use_adc=true`; MUST differ from
/// the runtime identity because Google prohibits access-token
/// self-impersonation. Env: `GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT`.
pub adc_target_service_account: Option<String>,
}

/// Fully resolved Google Chat settings (config → env → default applied).
Expand All @@ -1234,6 +1244,8 @@ pub struct ResolvedGoogleChat {
pub sa_key_json: Option<String>,
pub sa_key_file: Option<String>,
pub access_token: Option<String>,
pub use_adc: bool,
pub adc_target_service_account: Option<String>,
pub audience: Option<String>,
pub webhook_path: String,
pub allow_all_users: bool,
Expand All @@ -1242,13 +1254,13 @@ pub struct ResolvedGoogleChat {

impl GoogleChatConfig {
/// Resolve every field: config value (if set) → `GOOGLE_CHAT_*` env →
/// default. String fields filter empty strings from `${}` expansion.
/// default. String fields filter empty or whitespace-only `${}` expansion.
pub fn resolve(&self) -> ResolvedGoogleChat {
let opt_str = |cfg: &Option<String>, env: &str| -> Option<String> {
cfg.as_ref()
.filter(|s| !s.is_empty())
.filter(|s| !s.trim().is_empty())
.cloned()
.or_else(|| std::env::var(env).ok())
.or_else(|| std::env::var(env).ok().filter(|s| !s.trim().is_empty()))
};
ResolvedGoogleChat {
enabled: self.enabled.unwrap_or_else(|| {
Expand All @@ -1259,6 +1271,15 @@ impl GoogleChatConfig {
sa_key_json: opt_str(&self.sa_key_json, "GOOGLE_CHAT_SA_KEY_JSON"),
sa_key_file: opt_str(&self.sa_key_file, "GOOGLE_CHAT_SA_KEY_FILE"),
access_token: opt_str(&self.access_token, "GOOGLE_CHAT_ACCESS_TOKEN"),
use_adc: self.use_adc.unwrap_or_else(|| {
std::env::var("GOOGLE_CHAT_USE_ADC")
.map(|v| v.trim() == "1" || v.trim().eq_ignore_ascii_case("true"))
.unwrap_or(false)
}),
adc_target_service_account: opt_str(
&self.adc_target_service_account,
"GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT",
),
audience: opt_str(&self.audience, "GOOGLE_CHAT_AUDIENCE"),
webhook_path: opt_str(&self.webhook_path, "GOOGLE_CHAT_WEBHOOK_PATH")
.unwrap_or_else(|| "/webhook/googlechat".into()),
Expand Down Expand Up @@ -2966,6 +2987,8 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"]
"GOOGLE_CHAT_SA_KEY_JSON",
"GOOGLE_CHAT_SA_KEY_FILE",
"GOOGLE_CHAT_ACCESS_TOKEN",
"GOOGLE_CHAT_USE_ADC",
"GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT",
"GOOGLE_CHAT_AUDIENCE",
"GOOGLE_CHAT_WEBHOOK_PATH",
] {
Expand All @@ -2974,9 +2997,39 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"]
// --- defaults ---
let r = GoogleChatConfig::default().resolve();
assert!(!r.enabled);
assert!(!r.use_adc);
assert!(r.audience.is_none());
assert_eq!(r.webhook_path, "/webhook/googlechat");

// --- whitespace-only secrets/targets are absent at the config boundary ---
std::env::set_var("GOOGLE_CHAT_ACCESS_TOKEN", " \t ");
std::env::set_var("GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT", " \t ");
let r = GoogleChatConfig::default().resolve();
assert!(r.access_token.is_none());
assert!(r.adc_target_service_account.is_none());
std::env::remove_var("GOOGLE_CHAT_ACCESS_TOKEN");
std::env::remove_var("GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT");

// --- identity-selecting bool env accepts common case/whitespace forms ---
std::env::set_var("GOOGLE_CHAT_USE_ADC", " True ");
assert!(GoogleChatConfig::default().resolve().use_adc);
std::env::remove_var("GOOGLE_CHAT_USE_ADC");

// --- use_adc: config value resolves without touching env ---
let r = GoogleChatConfig {
use_adc: Some(true),
adc_target_service_account: Some(
"chat-bot@project.iam.gserviceaccount.com".into(),
),
..Default::default()
}
.resolve();
assert!(r.use_adc);
assert_eq!(
r.adc_target_service_account.as_deref(),
Some("chat-bot@project.iam.gserviceaccount.com")
);

// --- config wins over env ---
std::env::set_var("GOOGLE_CHAT_ENABLED", "true");
std::env::set_var("GOOGLE_CHAT_AUDIENCE", "env-aud");
Expand All @@ -2989,9 +3042,9 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"]
assert!(!r.enabled); // config false wins over env true
assert_eq!(r.audience.as_deref(), Some("cfg-aud"));

// --- empty-string ${} expansion falls through to env ---
// --- whitespace-only ${} expansion falls through to env ---
let cfg = GoogleChatConfig {
audience: Some("".into()),
audience: Some(" \t ".into()),
..Default::default()
};
let r = cfg.resolve();
Expand Down
Loading
Loading