Files
ironclaw/tools-src
Benjamin Kurrek f4b22cf2f5 feat(reborn): land Slack personal OAuth and WebUI v2 Slack remodel (#5643)
* ci(reborn): run all webui_v2 JS tests in CI; drop deleted e2e ref

- Broaden the reborn-tests.yml JS-test glob from static/js/pages/settings
  to all of crates/ironclaw_webui_v2/static/js (minus node_modules/dist),
  so the chat/extensions/lib suites run in CI.
- Stub DOMPurify.addHook in markdown.test.mjs: renderMarkdown registers a
  one-time afterSanitizeAttributes hook before sanitizing, so the mock
  must accept the registration for the sanitize assertion to be exercised.
- Drop tests/e2e/scenarios/test_reborn_webui_v2_legacy_channel_connect.py
  from the reborn-playwright legacy-auth-inputs group (the scenario is
  removed later in this stack).

Mechanical re-slice (1/4) of the fully-reviewed #5604 head 178829a4c.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reborn): Slack personal OAuth foundations (dormant, additive)

Additive layer for Slack personal OAuth; no user-visible change. The
catalog still offers pairing, all pairing endpoints remain intact, and
the new OAuth surface stays dormant until the serve/webui wiring lands
in the next PR of this stack.

- ironclaw_auth: OAuth primitives generalized beyond Google
  (OAuthCallbackState newtype, OAuthRedirectUri), provider_identity on
  credential accounts, SLACK_PERSONAL constants, owner-granularity
  cleanup + provider selector, contract tests.
- oauth_provider_client: SlackAuthedUser parsing, expires_in=0
  (non-expiring token) fix, exchange logging.
- host_api http + host_runtime egress: execute_credential_exchange with
  fail-closed enforcement + runtime egress contract tests.
- oauth_gate: unified OAuth gate driver (Google refactored onto it,
  OAuthGateProviderRegistry) incl. fallthrough fix; ripple into
  google_oauth/notion_oauth/oauth_dcr/nearai_mcp.
- slack_personal_oauth (new) + product_auth_serve: Slack OAuth
  start/callback routes, identity hook, failure-HTML signal.
- slack_setup: oauth client-id/secret slot + personal_oauth_ready;
  slack_channel_connection; SlackPersonalUserBinder trait;
  product_auth_durable cleanup/flows.
- slack_user tool: full WASM user-token tool (tools-src +
  first_party_extensions assets incl. committed wasm).
- gsuite account_policy ctor ripple; provider_identity field ripple
  across struct literals.

Transitional #[allow(dead_code)] markers sit on four pub(crate) hooks
that the serve/slack_host_beta wiring consumes in the next stack PR;
they disappear when those files reach their final reviewed state.

Mechanical re-slice (2/4) of the fully-reviewed #5604 head 178829a4c.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reborn): swap Slack pairing codes for personal OAuth

The accepted swap: Slack relay pairing is retired; users connect via
the personal OAuth flow added by the previous PR of this stack.

- Remove slack_personal_binding_pairing{,_serve}, slack_pairing_notifier,
  channel_connection_resume (composition + product_workflow), the /pair
  slash-command endpoint, and the v1 pairing_approve builtin tool +
  registry wiring.
- host_api: remove the ChannelPairing runtime credential setup variant,
  adding a tolerant #[serde(other)] Retired variant + wire test so
  persisted legacy rows still decode.
- reborn_cli serve wiring: fill the Slack personal OAuth slot; drop the
  commands mount and pairing route config. serve_slack.rs loses the
  legacy env-based setup import path; legacy [slack] fields are ignored
  here, and the explicit startup rejection lands in the final stack PR.
- WebUI v2: OAuth Configure flow, in-chat OAuth card, watchers, shared
  product-auth-oauth-events lib + tests; pairing JS deleted; i18n x11
  locales; asset manifest updated.
- e2e scenarios updated; legacy channel-connect scenario removed.
- CHANGELOG: dm_policy default pairing->allowlist + Removed entries;
  registry/channels/slack.json bump; docs/plans updates.

Behavior (owner-accepted, CHANGELOG'd): previously-paired v1 Slack
users are force-unpaired and reconnect via OAuth; Telegram/WASM
self-service pairing via the pairing endpoints is unaffected.

Mechanical re-slice (3/4) of the fully-reviewed #5604 head 178829a4c.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reborn-cli)!: reject legacy [slack] config fields at serve startup

BREAKING: ironclaw-reborn serve now rejects the legacy [slack] config
fields (installation_id, team_id, api_app_id, slack_user_id, user_id,
shared_subject_user_id, signing_secret_env, bot_token_env,
channel_routes) with an actionable error instead of silently ignoring
them. Slack bot credentials and routing are configured from the WebUI
channel setup page; per-user identity comes only from Slack OAuth.
[slack].enabled / IRONCLAW_REBORN_SLACK_ENABLED still gate whether the
channel mounts.

Shipped last in the stack and independently revertable: reverting this
commit alone restores the silent-ignore behavior without touching the
OAuth swap. Includes the reject_legacy_slack_setup_fields regression
test and the CHANGELOG Breaking entry.

Mechanical re-slice (4/4) of the fully-reviewed #5604 head 178829a4c.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(reborn): rename JS-test step to match broadened webui scope

Review follow-up on #5643 (CodeRabbit): the step name and empty-result
message still said "settings" after the find glob broadened to all of
crates/ironclaw_webui_v2/static/js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reborn): close review findings on Slack OAuth foundations (#5644)

Review follow-ups from the multi-agent + Gemini review of the
foundations slice:

- slack_setup: return the client secret SecretString directly instead
  of expose->to_string->rewrap (SecretMaterial is a SecretString alias;
  drops a needless plaintext heap copy).
- slack_setup: rollback_failed_activation_save now also deletes the
  failed save's fresh oauth_client_secret_handle (guarded by the same
  previous/protected-current reference check as bot/signing handles);
  extended the rollback test and added an inherited-handle regression.
- ironclaw_auth: OAuthProviderIdentitySubject deserializes through
  validation (#[serde(try_from = "String")] + TryFrom delegating to
  new) per the types.md newtype rule; the type is new in this stack so
  no persisted rows predate the tightening.
- oauth_provider_client + oauth_dcr: consume the staged network policy
  after every credential exchange (success or failure) via the
  obligation handler's abort seam; the egress pipeline only discards on
  pre-transport errors, so successful exchanges leaked one unbounded
  policy-store entry per flow. Regression asserts the staged policy is
  discarded after a successful exchange.
- product_auth_serve: the Slack callback identity hook now returns a
  compensating rollback; if complete_oauth_callback fails after the
  hook durably bound the Slack identity, the completion-failure arm
  deletes exactly that binding (scoped by full provider_user_id) so a
  failed completion cannot leave Slack "connected" with no usable
  credential. Threaded a RebornUserIdentityBindingDeleteStore through
  SlackPersonalOAuthBindingConfig; regression drives the route-level
  harness with a failing completion and asserts bind-then-rollback.
- slack_user tool: log only the static Slack API resource name (strip
  the query string carrying search terms / channel ids / timestamps) in
  both source copies; committed wasm rebuilt with cargo-component
  (wasm32-wasip2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reborn): close review findings on the Slack OAuth swap (#5645)

Review follow-ups from the multi-agent + Gemini review of the swap
slice:

- serve_slack: restore the fail-loud guard for featureless builds. The
  swap had left the non-slack-v2-host-beta branch returning Ok(None)
  unconditionally, silently starting without Slack when
  [slack].enabled=true / IRONCLAW_REBORN_SLACK_ENABLED=true. The
  enablement helpers are no longer feature-gated and the cfg(not)
  variant bails with the build-feature message; cfg(not) regression
  tests reinstated plus a unit test for the shared bool parser.
- configure-modal: a blocked about:blank pre-open now surfaces
  "Authorization popup was blocked." and skips the OAuth mutation
  instead of burning the server-side flow with no completion watcher
  (mirrors the in-chat startOnboardingOAuth guard); tests drive the
  captured authorize handler for both the blocked and unblocked paths.
- webui_v2_product_auth: caller-level serve tests for the Slack
  personal OAuth wiring — bearer-required 401, start through the
  composed router (asserting the Slack authorize URL + server-side
  user_scope), fail-closed 503 backend_unavailable when product auth is
  mounted without the slot (the exact state a dropped webui_serve
  wiring block would produce), and callback mounted + fail-closed
  sanitized. Backed by a tests-only filled-slot seam on
  SlackPersonalSetupServiceSlot mirroring the production fill path.
- slack_host_beta: thread the mounts' user_identity_delete_store into
  SlackPersonalOAuthBindingConfig (completes the callback binding
  rollback landed in the foundations slice).
- runtime_setup: document that the setup-save hook deliberately
  activates the channel only — the slack_user companion requires a
  caller-scoped slack_personal account and is owned by the post-OAuth
  activation path.
- .env.example: document IRONCLAW_REBORN_SLACK_PERSONAL_OAUTH_REDIRECT_URI
  alongside the Reborn Slack serve settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(reborn): pin slack token-parse failures to TokenExchangeFailed

Review follow-up from #5604 (Gemini): the two slack parse-failure tests
asserted only is_err(), so an unrelated parse failure could keep them
green. Both now assert the specific AuthErrorCode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reborn): close manual-E2E findings on the Slack OAuth flows

Four findings from live end-to-end testing of the stack:

- Restore the pair-once-resume-all behavior OAuth lost in the swap: a
  completed auth flow's continuation references at most one run
  (TurnGateResume) or none (SetupOnly from the Settings surface), so
  authorizing Slack in one chat left the caller's other chats parked on
  BlockedAuth — the deleted channel_connection_resume machinery was the
  only cross-thread fan-out and nothing replaced it. A new
  BlockedAuthResumeFanout decorator wraps the continuation dispatcher:
  after the primary dispatch it scans the durable turn-state snapshot
  for the caller's other BlockedAuth runs whose credential requirements
  name the completed flow's provider and resumes each (best-effort,
  idempotent per flow+run, strict tenant+owner scoping, primary run
  skipped). Provider-keyed, so multiple chats blocked on Google resume
  together too — pairing-era parity, generalized.
  AuthContinuationEvent now carries the completed flow's provider so
  dispatchers can fan out without re-reading the flow record.
  Production-shaped builders keep the single-run dispatcher (explicit
  None) until their snapshot source is wired.
- auth-oauth-card: open authorization in a sized popup instead of a new
  tab — pre-open about:blank with window features, sever the opener,
  navigate via the shared openAuthPopup reuse path (gate completion
  travels over localStorage/BroadcastChannel, never window.opener), and
  surface a blocked popup instead of silently doing nothing. Matches
  the onboarding and configure flows.
- auth-oauth-card: render provider display names ("Slack") instead of
  naively capitalized raw ids ("Slack_personal") in the authorize CTA
  and gate shell, with an underscores-to-title-case fallback.
- Spinners: the OAuth surfaces used Tailwind's animate-spin, which
  app.css never defines and whose static-motion policy would suppress
  anyway; switch to the sheet's sanctioned v2-spin class (the same one
  the automations refresh spinner uses).

Tests: three fan-out regressions (turn-gate completion resumes only the
caller's other provider-blocked runs; SetupOnly completion resumes all;
resume failures stay best-effort), five auth-oauth-card component tests
(display name, prettified unknown ids, sized popup + severed opener +
in-place navigation, blocked-popup surface, non-HTTPS refusal), spinner
class pins updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reborn): let Slack disconnect succeed when no workspace setup exists

Live-testing follow-up: POST /extensions/slack/remove returned 500 on a
fresh instance because disconnect_channel_for_caller fails closed when
the personal connection scope is unresolvable — but a never-configured
(or setup-deleted) instance has no installation scope at all, so
extension uninstall was impossible before Slack was ever set up.

The no-scope arm now still revokes the caller's provider-scoped
slack_personal credentials, deletes the caller's own Slack identity
bindings without an installation prefix (the delete stays tenant +
caller-user bound), skips DM targets (installation-keyed and
unreachable without a setup), and succeeds. The scoped path is
unchanged; the shared cleanup request moved into a helper so the two
arms cannot drift. Updated the pinned test to the new contract:
disconnect-without-scope succeeds and cleans the caller's bindings
unscoped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(reborn): design spec for Slack bot/tools remodel

Approved design-of-record for the model-B remodel (bot = operator
entrypoint, tools = user-installable extension) and the three stacked
follow-up PRs: remodel / least-privilege scopes / OAuth durability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reborn): rename Slack extensions (bot slack->slack_bot, tools slack_user->slack)

Mechanical rename only, no behavior change - commit 1 of the model-B
remodel. Bot channel extension id -> slack_bot; user-tools extension id
-> slack (the visible Slack extension). Renames id constants, manifest
ids, asset dirs, include paths, and capability ids (slack_user.*->slack.*);
cross-crate test refs updated.

Unchanged: slack_user_token/slack_bot_token handles, slack_personal
provider, Slack adapter/channel and actor kinds, ironclaw_slack_v2_adapter
crate, slack-v2-host-beta feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reborn): model-B Slack backend — bot is hidden channel, tools are the installable extension

Commit 2 of the model-B remodel (backend behavior). The Slack tools
extension (slack) becomes the visible, user-installable extension; the bot
channel (slack_bot) becomes hidden operator infrastructure.

- Visibility flip: is_internal_extension_package_ref now hides the bot
  (slack_bot), not the tools; the catalog/list surface the tools (slack).
- Delete the ~150-line companion coupling (activate_slack_with_companion +
  5 helpers + call-site branches). The tools extension is a normal visible
  extension activated directly via the standard lifecycle, so the
  connect-but-inactive activation-gap bug dissolves — there is no hidden
  companion that can silently fail to activate.
- The tools extension owns the slack_personal OAuth: the credential
  requirement and the OAuth-start requester check target slack, not the bot.
- Unbound-user greeting: a first-contact DM from a Slack user with no
  identity binding is greeted with a connect nudge (previously silently
  dropped) — no binding lookup, no agent turn, canned text only.
- Operator flow: the bot is fully hidden from the extension catalog/list;
  operators configure it via the Slack setup panel.

Test-first: rewrote the companion/connection-state/search tests for model B
and flipped the unbound-user silence test to assert the nudge. All Slack
behavior tests pass; the remaining composition failures are pre-existing
sandbox scheduler/model-call timeouts plus one flaky trigger test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reborn-webui): Slack extension is the tools package, not a channel

Commit 3 of the model-B remodel (frontend). No behavioral change — the
frontend is already model-B-compatible (verified: extensions + chat JS
suites pass, 324 tests). The Slack tools extension connects via the standard
credential / auth-OAuth-card path (like Gmail); the channel-connection path
was the bot's and is superseded by the commit-2 unbound-user greeting.

Renames the now-misleading isSlackChannel flag in configure-modal.js to
isSlackToolsExtension: under model B the visible slack extension is the
user-tools package, not the bot channel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): rebuild slack tools WASM component after capability rename

The slack->slack_bot / slack_user->slack rename (e4776999f) updated the
wasm-src dispatch keys (slack.search_messages, slack.send_message, ...) and
the host-runtime contract test's capability id, but the compiled
slack_user_tool.wasm was git-renamed at 100% similarity — never recompiled.
The shipped binary still dispatched on the old slack_user.* ids, so every
Slack tool call failed at runtime (OperationFailed) and
host_runtime_services_injects_personal_xoxp_token_for_slack_user_search_capability
failed at CI.

Recompiled the component from the renamed source
(cargo component build --release --target wasm32-wasip2) and redeployed the
artifact. Both github_wasm_runtime_contract slack tests now pass.

No source change accompanies this commit — the regression test already lives
in github_wasm_runtime_contract.rs and this binary rebuild is precisely what
makes it green, so [skip-regression-check].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reborn): least-privilege per-capability Slack scopes

The Slack tools manifest previously declared the full personal-OAuth scope
union (including chat:write) on every capability, so a read-only tool like
search_messages advertised write access it never uses.

Declare each capability's real scopes instead:
- The four read tools (search_messages, list_conversations,
  get_conversation_history, get_user_info) request only the ten read scopes.
- Only send_message keeps chat:write (eleven-scope union).

The OAuth *setup* request (SLACK_PERSONAL_OAUTH_SETUP_SCOPES) still asks for
the union up front — a single consent that covers every tool the user may
call — because per-scope, opt-in-on-first-write consent is a larger UX effort
tracked in #5669. This change makes the per-capability manifest truthful now
and documents the follow-up at the constant.

Tests:
- available_extensions: slack_read_only_tools_do_not_request_chat_write
  asserts only send_message carries chat:write in the manifest.
- github_wasm_runtime_contract: slack_user_scopes() drops chat:write so the
  read-capability injection fixtures match the read-only manifest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): compile composition tests without slack-v2-host-beta

compose_provider_client_with_runtime takes a `#[cfg(feature =
"slack-v2-host-beta")]`-gated 5th parameter (slack_personal_oauth_slot). The
production wrapper gates the argument it forwards with the same cfg, but the
four `#[tokio::test]` call sites in product_auth_providers.rs passed `None`
unconditionally. Without the feature the function takes 4 args, so the tests
supply one too many and fail to compile (E0061) — breaking `cargo test` and
`cargo clippy --tests` in any default-feature build of this crate.

Gate each test call site's 5th argument exactly like the production wrapper.
The composition lib-tests now compile under both the default and the
slack-v2-host-beta feature sets.

Why CI stayed green until now: code_style.yml runs the SLIM clippy matrix
(--all-features only) for pull_request and merge_group, where the feature is
on and the tests compile. Only the FULL matrix on push-to-main adds the
no-features `default` leg that trips E0061 — so this latent break would have
turned main red after the Slack stack merged. That default leg is the
regression guard for this fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(reborn): drop stray blank line left by /pair command removal

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reborn): drop dead slack_manifest_toml() after model-B ingress repoint

The Slack events host-ingress route now projects from the bot manifest
(slack_bot_manifest_toml), so slack_manifest_toml() has no callers and trips
clippy's dead_code lint under -D warnings. The SLACK_MANIFEST const it wrapped
is still used directly for the tools package. Removed the dead wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reborn): durable Slack conversation binding (survives process restart)

The Slack host-beta record builder constructed an in-memory
`InMemoryConversationServices`, so conversation->thread bindings were lost on
process restart (the removed `tracing::warn!` admitted exactly that). Thread
the conversation services in as a parameter instead: the async production
path (`runtime_setup::build_resolver`) now constructs a durable,
filesystem-backed `RebornFilesystemConversationServices` over the shared
host-state filesystem (backend = libSQL / Postgres / local disk, a property
of the root filesystem, shared with the idempotency ledger), while the
sync/test entrypoint keeps in-memory (no async context there to rehydrate).

A `SlackConversationServices` bundle enforces that the
`ConversationBindingService` and `ConversationActorPairingService` handles
share one backing store.

Coverage: durability is contract-tested by
`ironclaw_conversations::filesystem_conversation_services_round_trip_persisted_state_on_reopen`
(write a binding, reopen a fresh store over the same filesystem, read it back
— the filesystem equivalent of the libSQL/Postgres restart-replay tests); the
async Slack path building and routing DMs with the durable store in place is
covered by the `slack_host_beta` runtime-mount regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reborn): display the Slack tools extension as "Slack"

Drop the stale "(personal)" qualifier from the tools package display name. It
was inherited from the pre-model-B "slack_user" package, when a visible bot
"Slack" and visible tools needed to be told apart. Model-B hides the bot, so
the qualifier now contrasts with something the user cannot see. Rename the
display name (manifest + the mirrored Rust literal), the 5 schema titles, and
trim the description reference to the hidden bot channel. Identity is unchanged
(id stays "slack"); this is display-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reborn): match Slack tool asset dirs to the extension id

Rename the tools schema/prompt asset directories from the legacy slack_user/
(the pre-model-B id) to slack/, matching the extension id and the house
convention (github uses schemas/github/). Updates the git paths, the manifest
input/output/prompt refs, and the include_bytes! mounts in slack_assets(). The
WASM binary filename (slack_user_tool.wasm) and the slack_user_token credential
handle keep their legacy names (identity/build artifacts, out of scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(reborn): rustfmt-collapse slack_package call after shortening the label

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reborn): report all rejected legacy Slack fields at once

Address CodeRabbit review on #5646: the legacy-config rejection in
resolve_slack_config_for_serve short-circuited on the first deprecated
[slack] field, so an operator fixing one field would rediscover the next
only on the next boot. Collect every violated field (including
channel_routes) into one bail message, and add a regression test that
drives the channel_routes rejection branch through
resolve_slack_config_for_serve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reborn): tidy Slack tools ext per CodeRabbit review on #5668

Three review nits on the model-B Slack tools extension:

- get_user_info claimed it returns the user's email, but the granted
  scope is `users:read` (not `users:read.email`), so email is never
  available. Drop "email" from the capability description, the
  model-facing prompt doc, and the params doc comment so the model
  does not promise a field it cannot fetch.
- action_from_context dropped the serde parse error via
  `map_err(|_| ...)`. Carry the cause (`invalid_invocation_context:
  {e}`) so a malformed invocation context is diagnosable. Recompiled
  slack_user_tool.wasm (wasm32-wasip2) with the change.
- Extract the bare `"slack"` magic string in configure-modal.js into a
  named `SLACK_TOOLS_EXTENSION_ID` constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): classify Slack conversation-store outage as 503, not 401

Address CodeRabbit review on #5693: when the durable filesystem
conversation-binding store fails to initialize, build_resolver mapped
the error to SlackIngressError::InstallationNotFound, which
ingress_error_response renders as 401 Unauthorized. A storage/infra
outage is not an authentication failure — a 401 tells Slack the
installation is unauthorized (misleading, and Slack will not
meaningfully retry), and the underlying cause was dropped.

Add a dedicated SlackIngressError::ConversationStoreUnavailable
variant that carries the cause, map the resolver error to it, and
render it as 503 Service Unavailable (TemporarilyUnavailable) so Slack
retries delivery. Add a regression test pinning the 503 mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): revoke exclusive extension credential on removal

Regression from the Slack pairing->OAuth swap (2ce8807ab). Before it,
Slack used pairing-based, extension-owned credentials that removal
cleaned up automatically, so a removed extension could not be silently
re-added. OAuth personal credentials are stored `UserReusable` and are
preserved across extension removal by default (the crate guardrail:
reusable credentials are untouched unless a provider-scoped cleanup opts
in). Extension removal did no credential cleanup at all, so after removal
the agent re-installed the bundled extension and re-activated on the
surviving token — no OAuth re-consent, a security boundary violation.

The revocation lives on the single convergence point both removal
entrypoints already call — `RebornLocalExtensionManagementPort::remove`
(now `remove(package_ref, scope)`) — so neither door can bypass it: the
WebUI facade (`LifecycleProductAction::ExtensionRemove`, the door users
actually use) and the `builtin.extension_remove` agent capability both
route through it. On success `remove` revokes the removed extension's
credential providers that are exclusive to it, via the sanctioned
`RebornProductAuthServices::cleanup_credentials_for_lifecycle` path
(provider-scoped `Uninstall`). Shared vendor credentials are preserved:
removing `gmail` does not revoke the `google` token `google-calendar`/
`drive` still use, determined by re-checking every still-installed
extension's declared providers. Cleanup is best-effort (never fails or
rolls back the removal) and fails safe (revokes nothing) when it cannot
prove a provider is unused, so a shared credential is never deleted out
from under another extension.

Regression tests (both doors converge on the port):
- ui_facade_extension_remove_revokes_exclusive_credential_at_convergence_point:
  drives the WebUI facade `ExtensionRemove` and asserts the port issues
  exactly one provider-scoped cleanup for the exclusive github provider.
- local_dev_extension_remove_revokes_exclusive_credential_so_reactivation_requires_auth:
  drives the `builtin.extension_remove` agent capability, then asserts
  re-activate returns auth_required (previously re-activated silently).
- local_dev_extension_remove_preserves_shared_credential_used_by_another_extension:
  gmail+calendar share google; removing gmail keeps calendar activatable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): origin-independent OAuth flow-status poll for reconnect

The extension-setup "reconnect" modal could hang forever after OAuth
completed. On reconnect the frontend watcher has no configured-state
poll fallback (the extension is already configured), so it depends
solely on the callback page's same-origin localStorage/BroadcastChannel
signal. When the callback runs on a different origin (local ngrok
callback vs 127.0.0.1 opener, or split app/callback domains in prod),
that signal never reaches the opener tab and the modal never closes.

Add a read-only, authenticated, caller-scoped flow-status endpoint
(GET /api/reborn/product-auth/oauth/flow/{flow_id}/status) returning the
durable AuthFlowStatus by id, and wire the reconnect watcher to poll it
as an origin-independent backstop (fire-and-forget with a pending guard;
the same-origin browser signal stays the fast path). The response
carries the status enum only -- never tokens, PKCE verifiers,
authorization codes, or opaque state. Ownership is enforced by get_flow
full-scope equality; a flow that is unknown OR owned by another scope
both return 404 so the read cannot be a cross-user existence oracle. The
browser echoes back the invocation_id the start response minted
(callback_scope.invocation_id) so the caller-scoped handler re-derives
the exact scope get_flow matched on, while the trusted tenant/user still
come from the authenticated caller.

Regression coverage: frontend reconnect-with-no-browser-signal completes
via the poll (and a failed poll surfaces a retryable error); backend
caller-level tests lock completed->"completed" without secrets, malformed
id->400, unknown id->404, and cross-scope->404 (not 403).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reborn): post the renamed 'slack' extension id in personal OAuth start tests

The model-B remodel renamed the installable Slack tools extension
slack_user -> slack (and the bot channel slack -> slack_bot), but
slack_personal_oauth_start_serves_through_composed_router /
_fails_closed_without_slot still posted "slack_bot" — the hidden bot
channel, which is not OAuth-installable — so the handler correctly
rejected it (400) and the composed-router test asserted 200 and failed.
Post "slack" so the tests exercise the real installable extension.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reborn): unify activation button loading into the shared Button

Both the in-extension (configure-modal) and in-chat (onboarding-pairing-card)
activation flows rendered their own copy-pasted `spinnerGlyph()` — a small,
cramped, filled quarter-arc glyph placed ad-hoc before the label. Replace
both with a single clean `loading` state on the design-system Button:

- Button gains a `loading` prop: a stroke-based ring + rounded-cap arc
  spinner (v2-spin, reduced-motion-safe), auto-disables the button, sets
  aria-busy, and keeps the label so width doesn't jump.
- configure-modal (OAuth connect, pairing connect, save) and
  onboarding-pairing-card (configure, pairing submit) buttons now pass
  `loading=...` instead of hand-rolling the glyph; both `spinnerGlyph()`
  copies are deleted.
- Tests updated to assert the button's `loading` prop (the spinner now
  lives inside Button) instead of the old `disabled`/`v2-spin` mechanism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reborn): loading state + popup-close feedback for the in-chat OAuth gate

The in-chat auth-gate OAuth card only rendered "Open/Re-open Slack
authorization" — no loading state, and no feedback if the user closed the
popup before finishing (the extension Configure path already put the
spinner on its button). Bring it to parity:

- While the authorization popup is open, the primary button itself shows
  the shared clean loading spinner (Button `loading`) with a "waiting to
  authorize" label — same button, same color, no separate status row.
- Watch the popup for closing; after a short grace window (a successful
  callback closes the popup itself, and the gate then clears via the
  completion signal / resumed-run projection), surface a "closed before
  you finished — re-open to try again" notice.
- Extract the spinner into design-system/spinner.js so Button's loading
  state (used here and by the extension flow) shares one clean animation.

Completion detection is unchanged (signal + projection backstop); this is
UI feedback only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): register the /conversations mount alias for the Slack durable conversation store

The Slack host-state ScopedFilesystem (built via slack_host_state_mount_view)
registered aliases for slack-personal-binding, slack-channel-routes, slack-setup,
and product_workflow/idempotency — but not /conversations. The durable
conversation-binding store (RebornFilesystemConversationServices) persists
/conversations/state.json, so every inbound Slack event — including a DM to the
bot — failed to open the store and was dropped with a 503
("no mount alias matches scoped path"). The split/7 durability wiring surfaced
the failure correctly, but the store itself could never initialize.

Add the /conversations alias (mapped to /tenants/{tenant}/shared/slack-conversations)
and lock it with a regression case in the mount-view test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reborn): coverage for OAuth disconnect/reconnect/durable-store + Button loading

Lands the test-integration agent's coverage on split/7:
- Rust (composition, through-the-caller):
  - ui_facade_extension_remove_preserves_credential_still_shared_with_another_extension
    (extension_lifecycle.rs) — the providers_still_in_use fail-safe: removing gmail
    must NOT revoke the google credential still used by google-calendar.
  - slack_durable_conversation_store_initializes_through_composed_host_state_mount
    (factory.rs) — drives local_dev_slack_host_state_filesystem ->
    RebornFilesystemConversationServices::new, asserting /conversations/state.json
    opens (caller-level guard for the mount-alias fix).
  - flow_status_route_descriptor_locks_read_only_bearer_policy
    (product_auth_serve/mod.rs) — locks the reconnect flow-status route as GET /
    NoBody / bearer-required / AuthenticatedCaller / per-caller / SameOriginOnly.
- JS unit:
  - button.test.mjs — the shared Button `loading` prop (spinner + disabled +
    aria-busy; idle has none; both variant paths; disabled alone).
  - auth-oauth-card.test.mjs — button loading + "authorizing" label while the
    popup is open; closed-before-finish notice after the popup closes.
- Fmt-only: webui_v2_product_auth.rs line unwrapped after the slack_bot->slack rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(reborn): run reborn-tests on stacked PRs + add webui-v2 no-undef lint

Two CI-scope gaps that let bugs through on the stacked Slack PRs:

1. reborn-tests.yml had `pull_request: branches: [main]`, so the entire
   Reborn test workflow (composition crate-tests, group-tests, webui-v2 JS
   tests, integration tier) skipped every PR whose base is a feature branch
   rather than main. A rename in one slice broke a composition test in
   another and no per-PR run caught it. Drop the branches filter (matching
   code_style.yml); the changes/classify job still scopes suites by path.
   merge_group/push stay main-only.

2. Add a `webui-v2-js-lint` (eslint no-undef) gate over static/js. The
   node --test component suites stub every collaborator through a vm
   context, so a module referencing an un-imported symbol still passes its
   unit test and only crashes at runtime. This gate is the regression guard
   for exactly that class; it immediately caught a pre-existing latent bug —
   useChatEvents.js calling isTerminalToolStatus without importing it
   (ReferenceError in the tool-activity upsert path, on main since 47772e4594)
   — fixed here by adding the import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reborn): post the renamed 'slack' extension id in personal OAuth start tests

Cascades fee5549bc down to split/5 (the branch that owns the rename), so
#5668's own CI goes green rather than the fix being stranded on split/7.

The model-B remodel renamed the installable Slack tools extension
slack_user -> slack (and the bot channel slack -> slack_bot), but
slack_personal_oauth_start_serves_through_composed_router /
_fails_closed_without_slot still posted "slack_bot" — the hidden bot
channel, which is not OAuth-installable — so the handler correctly
rejected it (400) and the composed-router test asserted 200 and failed.
Post "slack" so the tests exercise the real installable extension.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): map extension-lifecycle call failures to OperationFailed, not InputEncode

Asked "is my slack connected?" after removing Slack, the agent escalated
extension_search -> extension_activate("slack"). Activating a not-installed
extension returns ProductWorkflowError::InvalidBindingRequest ("available
extension was not found"), which lifecycle_error() mapped to
RuntimeDispatchErrorKind::InputEncode -> the model saw the nonsensical
"the tool input could not be encoded" / invalid_input, with a
"requires_changed_input" retry hint, and relayed it to the user.

A failure from a lifecycle CALL (activation requirements, activate/install/
remove) is an operation-level failure, not a tool-input encoding problem.
Map InvalidBindingRequest / UnsupportedActionKind (and keep Transient/other)
to OperationFailed ("the tool operation failed"). The old code also dropped
the reason via `.map_err(|_| ...)`; log it at debug! (server-side) since the
fixed model-visible summary cannot carry the raw reason (safe-summary
invariant), so the failure stays diagnosable instead of vanishing.

Input-parsing failures (parse_input, extension_package_ref) keep InputEncode —
those are genuinely malformed tool input.

Regression: lifecycle_error_maps_call_failures_to_operation_failed_not_input_encode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): gate the Slack connect-nudge to 1:1 DMs, never shared channels

post_connect_nudge_if_unbound_user_message fired for any UserMessage that
rejected with BindingRequired — the doc comment said "first-contact DM" but
the code never verified it. An unbound user's app-mention in a SHARED channel
also rejects with BindingRequired, so the host connect-nudge ("connect your
Slack account…") got posted INTO the shared channel, dropping a message
addressed to one user where the whole channel sees it.

Gate the nudge on the conversation being a 1:1 DM: Slack DM (im) channel ids
start with 'D'; shared channels ('C') and multi-person/group DMs ('G') are
excluded, and a missing/blank conversation ref fails closed. Matches the
existing slack_reply_target_is_personal_dm 'D'-prefix convention.

Regression: rejected_unbound_user_message_in_shared_channel_posts_no_connect_nudge
(shared channel -> zero posts); the existing DM test still posts exactly one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Revert "fix(reborn): map extension-lifecycle call failures to OperationFailed, not InputEncode"

This reverts commit 61c35a6d77.

* fix(reborn): add authGate.authorizing to all locales for i18n key parity

The in-chat OAuth card added `authGate.authorizing` ("Waiting for {provider}…")
to en.js only. The i18n_consistency test (`all_locales_share_the_en_key_set`)
requires every locale to share en's key set, so it drifted — surfaced now that
the CI-scope fix runs the webui-v2 crate bucket on stacked PRs. Add a
translation to all 10 non-en locales (ar/de/es/fr/hi/ja/ko/pt-BR/uk/zh-CN).

Regression guard: crates/ironclaw_webui_v2/tests/i18n_consistency.rs
all_locales_share_the_en_key_set (already present; now green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(reborn): rustfmt webui_v2_product_auth after slack_bot->slack shortening

The #22 fix shortened `post_extension_oauth_start(&app, "slack_bot", ...)` to
`"slack"`, which makes the call fit on one line; the two-line wrap it left
behind fails `cargo fmt --check`. Normalize it (split/7 already had the
one-line form via the cascade conflict resolution, which is why only #5668/
#5670 were red on Formatting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(reborn): require webui-v2-js-lint in the code-style rollup

The webui-v2-js-lint (no-undef) job was added but never wired into the
`code-style` aggregate's `needs:` + must-succeed result loop, so a JS
no-undef regression would run but not block merge. Add it to both so the
gate this PR introduced actually gates. It sits in the has_code-guarded
must-succeed loop (the job runs whenever has_code is true, the same guard
the rollup already short-circuits on).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): drop slack_actor from triggered_delivery_outcome after OAuth-only merge

main's triggered_delivery_outcome.rs set SlackHostBetaConfig.slack_actor: None,
but this stack removed the slack_actor field (Option<ExternalActorRef>, a
preselected Slack user) as part of the pairing->OAuth swap — OAuth-only uses
durable personal bindings, no preselected user. The main-merge brought the test
but kept the stack's fieldless struct, so it failed E0560 (breaking
Clippy(all-features) + the integration-coverage job). Remove the stale field.

Verified: cargo test --no-run --test reborn_integration_triggered_delivery_outcome
--all-features now compiles.

[skip-regression-check] merge-cleanup: removes a reference to a stack-removed
field so a main-added test compiles; no behavior change to test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): log discarded OAuth egress-policy execution-context error

discard_oauth_egress_policy's doc says a discard failure "is logged", but the
oauth_execution_context arm did `Err(_) => return` silently — inconsistent with
the sibling handler.abort branch that warns. Add the warn! so the best-effort
cleanup failure is diagnosable (error-handling.md: don't drop the cause).

[skip-regression-check] log-only change; no testable behavior change (best-effort
cleanup path, outcome unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): log dropped serde errors in extension lifecycle capability output

Two silent-failure sites (error-handling.md): the dispatch output serialization
mapped its serde error to OutputDecode via map_err(|_| ...) (cause dropped), and
channel_connection_display_preview did serde_json::to_string(requirement).ok()?
which silently returns None on failure — meaning the in-chat OAuth connection
panel would silently never open. Add debug! logs before each so both are
diagnosable; behavior is unchanged (still OutputDecode / still skips the
preview, just no longer silently).

[skip-regression-check] log-only diagnostics; no testable behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): post Slack connect nudge on the workflow-error path

An unbound user's first-contact DM resolves as a `BindingRequired`
workflow error (ScopeNotFound -> status 404), which the runner routes to
`observe_workflow_error`, NOT `observe_workflow_ack`. The connect nudge
was wired only into `observe_workflow_ack`, so an unbound 1:1 DM got
total silence instead of the "connect your Slack account" prompt -- the
nudge had never actually fired in production.

Wire `post_connect_nudge_if_unbound_user_message` into
`observe_workflow_error` too, mirroring the ack-path ordering: authorized
rejection hint first, then the connect nudge for unbound 1:1 DMs.

Regression test `unbound_user_message_via_workflow_error_posts_connect_nudge`
drives the real error observer path -- the coverage that was missing. The
existing test only called `observe_workflow_ack` directly with a
synthetic `Rejected` ack, masking the gap (the repo's "test through the
caller, not just the helper" rule). Proven red -> green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix Slack OAuth live canary setup

* Tighten Slack OAuth live canary checks

* Seed Slack OAuth redirect for live QA

* Redact Slack setup API failure bodies

* Require real Slack personal auth in live QA

* Accept Slack admin setup surface in live QA

* Harden Slack live QA credential guards

* Address Slack OAuth foundation review

* Address Slack OAuth swap review

* Cover legacy Slack config rejection in serve

* Clean up retired Slack user install on restore

* Use effect metadata for Slack write-scope test

* Address Slack OAuth durability review

* Implement AsRef for OAuth identity subject

* Align channel install toast test

* Restore pairing card i18n test harness

* Address Slack live canary review feedback

* fix(hooks): bound libsql predicate connections under write lock

* Harden live canary Playwright install

* test(reborn): align Playwright expectations with Slack setup remodel

* test(reborn): update Slack canary setup label

* Fix WebUI v2 lint workflow after pnpm migration

* Enable pnpm before WebUI lint cache setup

* Fix QA 7C canary sheet prompt

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: firat.sertgoz <firat.sertgoz@near.ai>
Co-authored-by: serrrfirat <f@nuff.tech>
2026-07-08 08:51:14 -04:00
..