Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b4d526d
fix(webui): stop SSE reload retry storms (#7268)
henrypark133 Aug 6, 2026
2a76f21
Install signed IronHub prompt assets (#7217)
serrrfirat Aug 6, 2026
b79b113
fix(libsql): avoid repeated FTS backfills (#7286)
serrrfirat Aug 6, 2026
b33eb8f
fix(release): use v1.1 extension asset contract for IronHub prompts
serrrfirat Aug 10, 2026
92818ce
fix(webui): scope chat run bookkeeping to the thread that owns it (#7…
ilblackdragon Aug 6, 2026
768e671
fix(loop): preserve pageable result_read continuation references (#7135)
serrrfirat Aug 6, 2026
6dd53ac
fix(filesystem): make libSQL FTS safe for natural-language recall (#7…
serrrfirat Aug 7, 2026
b438ed8
fix(host-runtime): wire WASM secret-exists to staged credentials (#73…
serrrfirat Aug 7, 2026
704df9f
fix(extensions): chat "connect account" dead-end — already-connected …
BenKurrek Aug 7, 2026
390b681
test(release): give result-read regression sufficient stack
serrrfirat Aug 10, 2026
54fb9a3
fix(telegram): accept /pair as a pairing-code alias (#7363)
BenKurrek Aug 7, 2026
016ba41
fix(slack): retain provisioned personal DM targets (#7300)
serrrfirat Aug 10, 2026
9784705
fix(release): skip rc1 channel state migration by default
serrrfirat Aug 10, 2026
e0c1eab
docs(deploy): document durable container storage
serrrfirat Aug 10, 2026
913e5eb
release: prepare 1.1.1-rc.1
serrrfirat Aug 10, 2026
f021f41
fix(ci): classify example environment documentation
serrrfirat Aug 10, 2026
6fb60b1
fix(ci): route golden payload snapshots
serrrfirat Aug 10, 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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,8 @@ SAFETY_INJECTION_CHECK_ENABLED=true
# IRONCLAW_REBORN_SERVE_HOST=127.0.0.1
# IRONCLAW_REBORN_SERVE_PORT=3000
# IRONCLAW_REBORN_SLACK_ENABLED=true # accepts 1/true to enable Slack, 0/false as a kill switch
# 1.1.1 skips legacy rc1 Slack/Telegram state by default. Set false to import it.
# IRONCLAW_REBORN_SKIP_RC1_CHANNEL_STATE_MIGRATION=true
# IRONCLAW_REBORN_CONFIRM_HOST_ACCESS=false
#
# WebChat v2 SSO login (Google / GitHub). Setting either CLIENT_ID
Expand Down
17 changes: 9 additions & 8 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,15 @@ hand-maintained CI list. The musl entries also use `readelf` to reject a program
interpreter or dynamic-library dependency, which prevents an installed musl
loader on the build runner from hiding a non-portable artifact.

For the `1.0.0-rc.1` to `1.1.0-rc.1` compatibility window, the tag publisher
also runs a blocking `Release upgrade canary` after all cargo-dist artifacts
exist and before `host` receives permission to publish them. It downloads and
checksum-verifies the exact previous Linux x86_64 release archive, compares it
with the exact candidate archive, creates state through the shipping WebChat
API, and verifies upgrade, restart, rollback, and re-upgrade plus the explicit
workspace-snapshot handoff. The local model endpoint is deterministic; this is
a release-artifact/runtime gate rather than live-provider evidence.
For the 1.1.1 compatibility window, the tag publisher runs blocking `Release
upgrade canary` matrix legs from both stable supported predecessors (`1.0.0`
and `1.1.0`) after all cargo-dist artifacts exist and before `host` receives
permission to publish them. Each leg downloads and checksum-verifies the exact
previous Linux x86_64 release archive, compares it with the exact candidate
archive, creates state through the shipping WebChat API, and verifies upgrade,
restart, rollback, and re-upgrade plus the explicit workspace-snapshot handoff.
The local model endpoint is deterministic; this is a release-artifact/runtime
gate rather than live-provider evidence.

The scheduled Postgres capacity lane complements that portable gate by building
the same canonical binary with `--profile dist`, starting `serve`, applying the
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/ironclaw-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -253,19 +253,27 @@ jobs:
${{ env.BUILD_MANIFEST_NAME }}

release-upgrade-canary:
name: Release upgrade canary
name: Release upgrade canary (${{ matrix.label }})
permissions:
contents: read
needs:
- plan
- build-local-artifacts
- build-global-artifacts
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && needs.build-local-artifacts.result == 'success' && needs.build-global-artifacts.result == 'success' }}
strategy:
fail-fast: false
matrix:
include:
- label: from-1.0.0
previous_tag: ironclaw-v1.0.0
- label: from-1.1.0
previous_tag: ironclaw-v1.1.0
runs-on: ubuntu-22.04
timeout-minutes: 20
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PREVIOUS_RELEASE_TAG: ironclaw-v1.0.0-rc.1
PREVIOUS_RELEASE_TAG: ${{ matrix.previous_tag }}
CANARY_TARGET: x86_64-unknown-linux-gnu
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
Expand Down Expand Up @@ -323,7 +331,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: release-upgrade-canary-evidence
name: release-upgrade-canary-evidence-${{ matrix.label }}
path: ${{ steps.upgrade-evidence.outputs.path }}
if-no-files-found: error
retention-days: 30
Expand Down
61 changes: 57 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.1.1-rc.1] - 2026-08-10

Urgent patch candidate for the 1.1 line. This release concentrates on channel
delivery and pairing, IronHub/custom MCP compatibility, WebUI streaming
stability, durable retrieval, and safe upgrades from both supported stable
predecessors.

**Upgrading from 1.0.0.** Stop all writers and take a database-native snapshot
before starting 1.1.1. Container deployments must also copy the old shared
`/workspace` to durable storage and set
`IRONCLAW_REBORN_LEGACY_WORKSPACE_SNAPSHOT` to that retained snapshot. Startup
applies the additive database migrations and the bounded record migration,
imports workspace files create-only, verifies the result, and retains the old
authorities for rollback. Slack and Telegram state is skipped by default and
must be reconfigured; set
`IRONCLAW_REBORN_SKIP_RC1_CHANNEL_STATE_MIGRATION=false` to opt into its
verified import.

**Upgrading from 1.1.0.** No offline data transform is required. Stop all
writers, take the normal database/volume snapshot, and start 1.1.1 against the
same durable state. Startup re-verifies any retained release-pair migration
record. The Slack/Telegram skip only affects installations that still have
legacy channel state awaiting that migration.

For containers, keep both `IRONCLAW_REBORN_HOME` and
`IRONCLAW_REBORN_WORKSPACE_ROOT` on durable storage. A database alone does not
retain project files, generated artifacts, materialized extension packages, or
filesystem-backed skills when a container is replaced.

### Fixed

- **Channels:** retain provisioned Slack personal-DM delivery targets and
accept Telegram `/pair` as a pairing-code alias.
- **Channel upgrade safety:** skip malformed legacy Slack/Telegram state by
default without deleting its source rows, while keeping an explicit opt-in
path for verified import.
- **IronHub and custom MCP:** install signed IronHub prompt assets using the
1.1 extension asset contract, and fix the chat “connect account” dead end for
already-connected extensions.
- **WebUI:** stop SSE reload retry storms and scope active-run bookkeeping to
the thread that owns the run.
- **Retrieval:** avoid repeated libSQL FTS backfills, make natural-language FTS
queries safe, and preserve pageable `result_read` continuation references.
- **Runtime credentials:** make WASM `secret_exists` see credentials staged
during extension setup.

### Documentation

- Document how to place the Reborn home and workspace root on durable storage
so container replacement does not discard filesystem artifacts.

## [1.1.0] - 2026-08-06

First stable release since 1.0.0, promoting `1.1.0-rc.1` plus the fixes listed
Expand All @@ -17,10 +68,12 @@ commands — plus a broad pass on making failures legible: to the model, which
now gets told what to do next instead of an opaque stop, and to the user, who
gets localized, actionable errors instead of silent dead ends.

**Upgrading from 1.0.0.** No migration steps. Extension lifecycle state moved
to a normalized on-disk shape; rows written by 1.0.0 keep deserializing. The
one behavioral removal is the `/webhooks/slack/events` compatibility alias
(see Removed).
**Upgrading from 1.0.0.** Extension lifecycle state moved to a normalized
on-disk shape; rows written by 1.0.0 keep deserializing. Container operators
must preserve the shared workspace separately from the database; the 1.1.1
upgrade instructions above describe the durable snapshot handoff. The one
behavioral removal is the `/webhooks/slack/events` compatibility alias (see
Removed).

### Added

Expand Down
3 changes: 2 additions & 1 deletion Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ libsql = { version = "0.9", default-features = false, features = ["core", "repli
# alone.
ironclaw_host_api = { path = "crates/ironclaw_host_api", version = "0.1.0", features = ["test-support"] }
ironclaw_memory = { path = "crates/ironclaw_memory", version = "0.1.0" }
# The production-backend memory integration scenario binds the native provider
# over the same libSQL composite used by memory tool dispatch.
ironclaw_memory_native = { path = "crates/extensions/packages/memory-native", version = "0.1.0" }
ironclaw_host_ingress = { path = "crates/ironclaw_host_ingress", version = "0.1.0" }
ironclaw_runtime_policy = { path = "crates/ironclaw_runtime_policy", version = "0.1.0" }
ironclaw_common = { path = "crates/ironclaw_common" }
Expand Down
5 changes: 4 additions & 1 deletion crates/extensions/packages/telegram/manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ submit_label = "Open pairing"
error_message = "Telegram pairing failed. Get a fresh code and try again."
connection_success_message = "Telegram is installed as an inbound entrypoint. If WebChat shows a Telegram pairing panel, tell the user to pair via the link, the QR code, or by sending the shown code to the bot in Telegram — nothing is pasted into normal chat. Once paired the user can DM the bot directly. Telegram exposes no tools and cannot read messages or send on the user's behalf."
deep_link_template = "https://t.me/{bot_username}?start={code}"
inbound_code_prefixes = ["/start"]
# `/start` is the vendor deep-link convention and stays the only prefix any
# instruction wording suggests; `/pair` is an accepted alias for users who
# type it from habit and must never appear in suggested wording.
inbound_code_prefixes = ["/start", "/pair"]

[channel.connection.notices]
connect_required = "👋 Pair your Telegram account in the IronClaw web app, then message me here again."
Expand Down
88 changes: 88 additions & 0 deletions crates/ironclaw_extension_host/src/channel_host/e2e_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4608,6 +4608,94 @@ async fn generic_outbound_targets_list_from_channel_config_and_generic_dm_store(
}
}

/// REGRESSION (OAuth post-bind provisioning): Slack's `conversations.open`
/// response supplies the DM conversation id but not the workspace id. The
/// generic target provider must complete that record with the active,
/// connection-scoped workspace claim or the creator's personal destination
/// disappears and trigger creation cannot bind delivery to their own DM.
#[tokio::test]
async fn generic_dm_target_inherits_active_workspace_when_record_omits_space() {
let harness = build_harness(TurnMode::Running).await;
save_outbound_target_config(&harness).await;
let dm_targets = generic_dm_target_store();
dm_targets
.upsert(
ADAPTER,
&UserId::new(USER).expect("user"), // safety: static test user id is valid.
SLACK_USER.to_string(),
dm_target_payload(None, CHANNEL),
)
.await
.expect("provision DM target without workspace");
let provider = generic_outbound_target_provider(&harness, dm_targets);

let listed = provider
.list_outbound_delivery_targets(&operator_caller())
.await
.expect("target list");
let dm = listed
.iter()
.find(|entry| entry.summary.target_id.as_str().contains("personal-dm"))
.expect("workspace-less provisioned DM should remain available");
assert_eq!(
dm.summary.target_id.as_str(),
format!("slack:personal-dm:{TEAM}:{USER}")
);
let conversation = SlackPreferenceTargetCodec
.conversation_for_target(external_reply_target(dm))
.expect("personal-DM binding ref decodes");
assert_eq!(conversation.space_id(), Some(TEAM));
assert_eq!(conversation.conversation_id(), CHANNEL);

let resolved = provider
.resolve_outbound_delivery_target(&operator_caller(), &dm.summary.target_id)
.await
.expect("resolve succeeds")
.expect("listed personal-DM target resolves");
assert_eq!(resolved.summary.target_id, dm.summary.target_id);
}

/// A DM record from a different workspace must never be rebound to the
/// currently active Slack connection. This prevents stale or tampered state
/// from turning the compatibility fallback into cross-workspace delivery.
#[tokio::test]
async fn generic_dm_target_rejects_record_from_a_different_workspace() {
let harness = build_harness(TurnMode::Running).await;
save_outbound_target_config(&harness).await;
let dm_targets = generic_dm_target_store();
dm_targets
.upsert(
ADAPTER,
&UserId::new(USER).expect("user"), // safety: static test user id is valid.
SLACK_USER.to_string(),
dm_target_payload(Some("T_OTHER_WORKSPACE"), CHANNEL),
)
.await
.expect("provision DM target for a different workspace");
let provider = generic_outbound_target_provider(&harness, dm_targets);

let listed = provider
.list_outbound_delivery_targets(&operator_caller())
.await
.expect("target list");
assert!(
listed
.iter()
.all(|entry| !entry.summary.target_id.as_str().contains("personal-dm")),
"a DM record from another workspace must fail closed: {listed:?}"
);

let active_workspace_binding = dm_reply_target_binding_ref();
assert!(
provider
.resolve_reply_target_binding(&operator_caller(), &active_workspace_binding)
.await
.expect("reply-target resolution succeeds")
.is_none(),
"an active-workspace binding must not resolve through a stored record from another workspace"
);
}

/// REGRESSION (migration tolerance): stored beta preferences embed the
/// RETIRED setup installation id in their binding refs. Resolution must
/// tolerate both ids — ownership is proven against caller-scoped generic
Expand Down
31 changes: 24 additions & 7 deletions crates/ironclaw_extension_host/src/channel_outbound_targets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,8 @@ impl GenericChannelOutboundTargetProvider {
caller: &OutboundDeliveryTargetScope,
record: &ChannelDmTargetRecord,
) -> Option<OutboundDeliveryTargetEntry> {
let (space_id, conversation_id) = dm_record_conversation(record)?;
let (space_id, conversation_id) =
dm_record_conversation(record, context.space_id.as_deref())?;
let conversation =
ExternalConversationRef::new(space_id.as_deref(), &conversation_id, None, None).ok()?;
let reply_target_binding_ref = context.codec.encode_personal_direct_message_target(
Expand Down Expand Up @@ -447,10 +448,14 @@ impl OutboundDeliveryTargetProvider for GenericChannelOutboundTargetProvider {
let Some(record) = self.dm_record(&context, caller).await? else {
return Ok(None);
};
let Some((_, record_conversation_id)) = dm_record_conversation(&record) else {
let Some((record_space_id, record_conversation_id)) =
dm_record_conversation(&record, context.space_id.as_deref())
else {
return Ok(None);
};
if record_conversation_id != decoded.conversation_id() {
if record_space_id.as_deref() != decoded.space_id()
|| record_conversation_id != decoded.conversation_id()
{
return Ok(None);
}
// The presented ref's actor must be the provisioned actor —
Expand All @@ -474,18 +479,30 @@ impl OutboundDeliveryTargetProvider for GenericChannelOutboundTargetProvider {
}
}

/// The canonical DM-target payload's conversation ref.
fn dm_record_conversation(record: &ChannelDmTargetRecord) -> Option<(Option<String>, String)> {
/// The canonical DM-target payload's conversation ref, completed from the
/// active connection scope when post-bind provisioning could only persist the
/// conversation id. An explicitly stored space must match the active scope;
/// stale cross-workspace state fails closed instead of being rebound.
fn dm_record_conversation(
record: &ChannelDmTargetRecord,
active_space_id: Option<&str>,
) -> Option<(Option<String>, String)> {
let conversation_id = record
.target
.get(DM_TARGET_CONVERSATION_ID_KEY)?
.as_str()?
.to_string();
let space_id = record
let stored_space_id = record
.target
.get(DM_TARGET_SPACE_ID_KEY)
.and_then(|value| value.as_str())
.map(str::to_string);
.filter(|value| !value.trim().is_empty());
let space_id = match (stored_space_id, active_space_id) {
(Some(stored), Some(active)) if stored != active => return None,
(Some(stored), _) => Some(stored.to_string()),
(None, Some(active)) => Some(active.to_string()),
(None, None) => None,
};
Some((space_id, conversation_id))
}

Expand Down
Loading
Loading