Skip to content

chore: promote staging to main (2026-03-11 00:16 UTC)#907

Merged
henrypark133 merged 1 commit intomainfrom
staging-promote/b0214fef-22930316561
Mar 11, 2026
Merged

chore: promote staging to main (2026-03-11 00:16 UTC)#907
henrypark133 merged 1 commit intomainfrom
staging-promote/b0214fef-22930316561

Conversation

@ironclaw-ci
Copy link
Copy Markdown
Contributor

@ironclaw-ci ironclaw-ci bot commented Mar 11, 2026

Auto-promotion from staging CI

Batch range: 873322f2fb53143d0af30f4456c8a368937c6587..b0214fef41e953b6c2996522a4095354133ce995
Promotion branch: staging-promote/b0214fef-22930316561
Base: staging-promote/3a841b30-22928320566
Triggered by: Staging CI batch at 2026-03-11 00:16 UTC

Waiting for gates:

  • Tests: pending
  • E2E: pending
  • Claude Code review: pending (will post comments on this PR)

Auto-created by staging-ci workflow

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add channel-relay integration for Slack via external relay service

- Add RelayChannel and RelayClient for connecting to channel-relay SSE streams
- Add RelayConfig with env-based configuration (CHANNEL_RELAY_URL, CHANNEL_RELAY_API_KEY)
- Add channel-relay extension lifecycle: install, OAuth auth, activate with hot-add
- Add proxy message sending through channel-relay for Slack chat.postMessage
- Add extension registry entry for Slack relay with OAuth auth hint
- Add relay integration test with mock SSE server
- Wire relay channel into app startup with reconnect on stored credentials
- Add AuthRequired extension error variant for cleaner auth flow detection

[skip-regression-check]

* chore: apply cargo fmt

* fix: remove remaining Telegram test references in relay channel

* fix: address PR #790 review feedback — parser handle leak, CSRF, circuit breaker

- Fix parser handle leak on reconnect by sharing Arc<RwLock> instead of
  creating a local copy in start() (shutdown now aborts the correct task)
- Add CSRF state nonce to OAuth flow: generate in auth_channel_relay,
  validate in slack_relay_oauth_callback_handler, one-time use
- Remove dead proxy_slack method, update integration test to use
  proxy_provider
- Add reconnect circuit breaker (max_consecutive_failures, default 50)
- Fix stale docs (Telegram refs), extract event_types constants

---------

Co-authored-by: Henry Park <henrypark133@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions github-actions bot added scope: channel Channel infrastructure scope: channel/web Web gateway channel scope: extensions Extension management size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: experienced 6-19 merged PRs labels Mar 11, 2026
@claude
Copy link
Copy Markdown

claude bot commented Mar 11, 2026

Code review

Found 11 issues:

  1. [CRITICAL:100] Missing consecutive_failures reset on successful reconnection — Stream end increments counter but successful reconnect doesn't reset to 0, so circuit breaker counts total not consecutive failures.

return;
}
}
// Stream ended, attempt reconnect with backoff
consecutive_failures += 1;
if consecutive_failures >= max_consecutive_failures {
tracing::error!(
channel = %relay_name,
failures = consecutive_failures,
"Relay channel giving up after {} consecutive failures",
consecutive_failures
);
break;
}
tracing::warn!(
backoff_ms = backoff_ms,
failures = consecutive_failures,
"Relay SSE stream ended, reconnecting..."
);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
backoff_ms = (backoff_ms * 2).min(backoff_max_ms);
// Try to reconnect
let token = stream_token.read().await.clone();
match client.connect_stream(&token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!("Relay SSE stream reconnected");
current_stream = new_stream;
// Abort old parser before replacing
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(RelayError::TokenExpired) => {
// Attempt token renewal
tracing::info!("Relay stream token expired, renewing...");
match client.renew_token(&instance_id, &user_id).await {
Ok(new_token) => {
*stream_token.write().await = new_token.clone();
match client.connect_stream(&new_token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!(
"Relay SSE stream reconnected with new token"
);
current_stream = new_stream;
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(e) => {
tracing::error!(
error = %e,
"Failed to reconnect after token renewal"
);
}
}
}
Err(e) => {
tracing::error!(
error = %e,
"Failed to renew relay stream token"
);
}
}
}
Err(e) => {
tracing::error!(error = %e, "Failed to reconnect relay SSE stream");
}
}
// Check if the team is still valid (skip when team_id is unknown,
// e.g. when no DB store was available at activation time)
if !team_id.is_empty() {
match client.list_connections(&instance_id).await {

  1. [HIGH:75] Missing Module Initialization Documentation — ExtensionManager requires calling set_relay_channel_manager() before restore_relay_channels(). Order dependency not documented; calling in wrong order silently fails.

/// Gateway auth token for authenticating with the platform token exchange proxy.
/// Read once at construction from `GATEWAY_AUTH_TOKEN` env var.
gateway_token: Option<String>,
/// Relay config captured at startup. Used by `auth_channel_relay` and
/// `activate_channel_relay` instead of re-reading env vars.
relay_config: Option<crate::config::RelayConfig>,
}
/// Sanitize a URL for logging by removing query parameters and credentials.
/// Prevents accidental logging of API keys, OAuth tokens, or other sensitive data in URLs.
fn sanitize_url_for_logging(url: &str) -> String {
// If URL is very short or doesn't look like a URL, just use as-is
if url.len() < 10 || !url.contains("://") {
return url.to_string();
}
// Try to parse and remove sensitive components
if let Ok(mut parsed) = url::Url::parse(url) {
// Remove query string and fragment
parsed.set_query(None);
parsed.set_fragment(None);

  1. [MEDIUM:75] DRY Violation — Repeated Secret Key Format Strings — Pattern format!("relay:{}:oauth_state", name) repeated ~14 times. Extract to helper functions in relay module.

self.secrets
.exists(&self.user_id, &format!("relay:{}:stream_token", name))
.await
.unwrap_or(false)
}
/// Restore persisted relay channels after startup.
///
/// Loads the persisted active channel list, filters to relay types (those with
/// a stored stream token), and activates each via `activate_stored_relay()`.
/// Skips channels that are already active. Call this after `set_relay_channel_manager()`.
pub async fn restore_relay_channels(&self) {
let persisted = self.load_persisted_active_channels().await;
let already_active = self.active_channel_names.read().await.clone();
for name in &persisted {
if already_active.contains(name) {
continue;
}
if !self.is_relay_channel(name).await {
continue;

  1. [MEDIUM:75] Hardcoded Provider ("slack") — RelayProvider::Slack hardcoded at activation instead of from registry/config. Violates CLAUDE.md extensibility principle; blocks support for Teams/Discord without code changes.

)
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
let channel = crate::channels::relay::RelayChannel::new_with_provider(
client,
crate::channels::relay::channel::RelayProvider::Slack,
stream_token,
team_id,
instance_id,
self.user_id.clone(),
)

  1. [MEDIUM:75] Repeated RwLock Read + Clone on Hot Path — Stream token read and cloned on every reconnection attempt, creating unnecessary allocations and lock contention.

backoff_ms = (backoff_ms * 2).min(backoff_max_ms);
// Try to reconnect
let token = stream_token.read().await.clone();
match client.connect_stream(&token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!("Relay SSE stream reconnected");
current_stream = new_stream;
// Abort old parser before replacing
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(RelayError::TokenExpired) => {
// Attempt token renewal
tracing::info!("Relay stream token expired, renewing...");
match client.renew_token(&instance_id, &user_id).await {
Ok(new_token) => {
*stream_token.write().await = new_token.clone();
match client.connect_stream(&new_token, stream_timeout_secs).await {

  1. [MEDIUM:75] Missing Timeout on list_connections API — Call relies on global 30+ second timeout. If relay service is slow, entire reconnect loop blocks, freezing the channel.

}
// Check if the team is still valid (skip when team_id is unknown,
// e.g. when no DB store was available at activation time)
if !team_id.is_empty() {
match client.list_connections(&instance_id).await {
Ok(conns) => {
let has_team =
conns.iter().any(|c| c.team_id == team_id && c.connected);
if !has_team {
tracing::warn!(
team_id = %team_id,
"Team no longer connected, stopping relay channel"
);
return;
}
}
Err(e) => {
tracing::warn!(
error = %e,
"Could not verify team connection, will retry next iteration"
);
}
}
}
}

  1. [MEDIUM:75] Inefficient String Building in SSE Parser — Creates Vec with multiple allocations per line, then calls join(). Use single String with push_str() instead.

) {
use futures::StreamExt;
let mut buffer = Vec::<u8>::new();
let mut event_type = String::new();
let mut data_lines = Vec::new();
let mut byte_stream = std::pin::pin!(byte_stream);
while let Some(chunk_result) = byte_stream.next().await {
let chunk = match chunk_result {
Ok(c) => c,
Err(e) => {
tracing::debug!(error = %e, "SSE stream chunk error");
break;
}
};
buffer.extend_from_slice(&chunk);
// Process complete lines (decode UTF-8 only on full lines to avoid
// corruption when multi-byte characters span chunk boundaries)
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
let line = String::from_utf8_lossy(&buffer[..newline_pos])
.trim_end_matches('\r')
.to_string();
buffer.drain(..=newline_pos);
if line.is_empty() {
// Blank line = end of event
if !data_lines.is_empty() {
let data = data_lines.join("\n");
if let Ok(mut event) = serde_json::from_str::<ChannelEvent>(&data) {
if event.event_type.is_empty() && !event_type.is_empty() {
event.event_type = event_type.clone();
}
if tx.send(event).await.is_err() {
return; // receiver dropped
}
} else {
tracing::debug!(
event_type = %event_type,
data_len = data.len(),
"Failed to parse SSE event data as ChannelEvent"
);
}
}
event_type.clear();
data_lines.clear();
} else if let Some(value) = line.strip_prefix("event:") {
event_type = value.trim().to_string();
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim().to_string());
}
// Ignore other fields (id:, retry:, comments)
}
}

  1. [MEDIUM:50] Stringly-Typed Secret Keys — Keys passed as plain strings. CLAUDE.md says prefer strong types. Use newtype wrappers or enums for relay secret keys.

self.secrets
.exists(&self.user_id, &format!("relay:{}:stream_token", name))
.await
.unwrap_or(false)
}
/// Restore persisted relay channels after startup.
///
/// Loads the persisted active channel list, filters to relay types (those with
/// a stored stream token), and activates each via `activate_stored_relay()`.
/// Skips channels that are already active. Call this after `set_relay_channel_manager()`.
pub async fn restore_relay_channels(&self) {
let persisted = self.load_persisted_active_channels().await;
let already_active = self.active_channel_names.read().await.clone();
for name in &persisted {
if already_active.contains(name) {
continue;
}
if !self.is_relay_channel(name).await {
continue;

  1. [LOW:50] Config Duplication — RelayConfig captured at construction but also re-read elsewhere. Document that runtime env changes won't be reflected.

/// Gateway auth token for authenticating with the platform token exchange proxy.
/// Read once at construction from `GATEWAY_AUTH_TOKEN` env var.
gateway_token: Option<String>,
/// Relay config captured at startup. Used by `auth_channel_relay` and
/// `activate_channel_relay` instead of re-reading env vars.
relay_config: Option<crate::config::RelayConfig>,
}
/// Sanitize a URL for logging by removing query parameters and credentials.
/// Prevents accidental logging of API keys, OAuth tokens, or other sensitive data in URLs.
fn sanitize_url_for_logging(url: &str) -> String {

  1. [LOW:50] RwLock Double-Acquire on Parser Handle — Acquire, take, release, then acquire again to store. Combine into single lock acquisition, but impact minimal.

Ok((new_stream, new_parser)) => {
tracing::info!("Relay SSE stream reconnected");
current_stream = new_stream;
// Abort old parser before replacing
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(RelayError::TokenExpired) => {
// Attempt token renewal
tracing::info!("Relay stream token expired, renewing...");
match client.renew_token(&instance_id, &user_id).await {
Ok(new_token) => {
*stream_token.write().await = new_token.clone();
match client.connect_stream(&new_token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!(
"Relay SSE stream reconnected with new token"
);
current_stream = new_stream;
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}

  1. [LOW:50] Metadata JSON per Message — 7-field JSON object allocated per message. Could use dedicated struct, but impact negligible.

"Relay: received message from {}", provider_str
);
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
.with_user_name(event.display_name())
.with_metadata(serde_json::json!({
"team_id": event.team_id(),
"channel_id": event.channel_id,
"sender_id": event.sender_id,
"sender_name": event.display_name(),
"event_type": event.event_type,
"thread_id": event.thread_id,
"provider": event.provider,
}));
let msg = if let Some(ref thread_id) = event.thread_id {

Summary: 1 critical logic bug (consecutive_failures), 1 high-priority documentation issue, 6 medium refactorings/perf optimizations, 3 low-priority style improvements. Security passed.

@henrypark133 henrypark133 merged commit 6aae1f8 into main Mar 11, 2026
40 of 41 checks passed
@henrypark133 henrypark133 deleted the staging-promote/b0214fef-22930316561 branch March 11, 2026 17:09
@github-actions github-actions bot mentioned this pull request Mar 11, 2026
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
…930316561

chore: promote staging to main (2026-03-11 00:16 UTC)
drchirag1991 pushed a commit to drchirag1991/ironclaw that referenced this pull request Apr 8, 2026
…930316561

chore: promote staging to main (2026-03-11 00:16 UTC)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: experienced 6-19 merged PRs risk: medium Business logic, config, or moderate-risk modules scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: extensions Extension management size: XL 500+ changed lines staging-promotion

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants