Compare commits

...
16 Commits
Author SHA1 Message Date
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
308758c27c chore: release v0.14.0 (#480)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-04 05:02:33 +00:00
f60c91e9a7 ci: enforce regression tests for fix commits (#517)
* ci: enforce regression tests for fix commits

Add a commit-msg hook and CI workflow that require test changes
alongside bug fix commits, ensuring every fix includes a regression
test that would have caught the bug.

- scripts/commit-msg-regression.sh: local git hook (blocks fix commits
  without test changes; exempts static/docs-only; bypass via
  [skip-regression-check] marker)
- .github/workflows/regression-test-check.yml: CI mirror on PRs
  (checks title + commit messages; skip via label)
- scripts/dev-setup.sh: install hook in step 6
- .github/scripts/create-labels.sh: add skip-regression-check label
- CLAUDE.md: document regression test policy

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback on regression test enforcement

- Use here-strings instead of echo|grep to avoid misinterpreting
  special characters in variables
- Use git diff -W (whole-function context) to detect edits inside
  existing test functions, not just new #[test] attributes
- Honor [skip-regression-check] in commit messages in CI (not just
  the PR label)
- Use git rev-parse --git-path hooks for worktree-safe hook install

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Update .github/workflows/regression-test-check.yml

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Copilot <[email protected]>
2026-03-04 04:35:54 +00:00
a22d44f2b2 ci: add code coverage with cargo-llvm-cov and Codecov (#511)
* ci: add code coverage with cargo-llvm-cov and Codecov

Add a Coverage workflow that runs on PRs and pushes to main using
cargo-llvm-cov with --all-features, uploading LCOV results to Codecov.
Include codecov.yml config with project/patch targets and ignore rules
for stub files.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* ci: switch Codecov upload to OIDC (tokenless)

Use GitHub OIDC tokens instead of CODECOV_TOKEN secret so coverage
uploads work for fork PRs where secrets are not available.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* ci: fail coverage upload strictly on push, leniently on PRs

Use a conditional so pushes to main fail if Codecov upload breaks
(preventing silent reporting gaps) while PRs stay lenient to avoid
blocking fork PRs where OIDC may not be available.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* ci: disable Codecov auto-detection to suppress warnings

We provide lcov.info explicitly, so disable auto-search for gcov,
coverage.py, and Xcode formats that produce noisy warnings.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* ci: include channels-src and tools-src in coverage reporting

These WASM source directories should be tracked for test coverage
rather than ignored.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* ci: remove stale ignore entries from codecov.yml

The marketplace, ecommerce, taskrabbit, and restaurant stub files
no longer exist in the codebase.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* ci: run coverage on push to main only

Avoids running tests twice on PRs (once in test.yml, once for coverage).
Coverage runs on merge to main instead. Simplify fail_ci_if_error to
always true since it only runs on push now.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-04 02:14:15 +00:00
a181c8b384 fix(web): mobile browser bar obscures chat input (#508)
* fix(web): use dvh units to prevent mobile browser bar from obscuring chat input

On mobile browsers (Brave/Android, Safari/iOS), the bottom navigation bar
covers the chat input because 100vh includes space behind browser chrome.
Switch to 100dvh (dynamic viewport height) with vh fallback for older
browsers, and add safe-area-inset padding for notched devices.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Fix padding declaration in chat input style

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
2026-03-04 01:55:52 +00:00
35a79caf87 fix(web): assign unique thread_id to manual routine triggers (#500)
* fix(web): assign unique thread_id to manual routine triggers

Manual routine triggers via the web API created an IncomingMessage
without a thread_id, causing session_manager.resolve_thread() to
route the output to whatever thread was last associated with the
(user, "gateway", None) key. This sets a unique thread_id of the
form "routine-{id}-{timestamp}" so each manual trigger gets its own
dedicated thread.

Closes #484

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add ownership check to routine trigger handler (IDOR)

Address review feedback: verify routine.user_id matches the
authenticated user before allowing the trigger, preventing
unauthorized cross-user routine execution.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-04 00:41:41 +00:00
85999b25a8 fix(web): refresh routine UI after Run Now trigger (#501)
* fix(web): refresh routine UI after "Run Now" trigger

triggerRoutine() only showed a toast but did not refresh the routine
data after triggering. This adds openRoutineDetail() / loadRoutines()
calls after the toast, matching the pattern used by toggleRoutine().

Closes #483

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: only refresh detail view if triggered routine matches current view

Check currentRoutineId === id before refreshing the detail panel to
avoid refreshing the wrong routine's view.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-04 00:38:48 +00:00
b60e5e907a fix(skills): use slug for skill download URL from ClawHub (#502)
* fix(web): use slug for skill download URL from ClawHub

The skill install handler was using req.name (display name like
"Markdown Converter") instead of the slug (like "owner/markdown-converter")
when constructing the download URL. The registry endpoint expects a slug,
so display names caused 502 errors.

- Add optional `slug` field to SkillInstallRequest
- Prefer slug over name when building the download URL
- JS installSkill() now sends slug from search results

Closes #482

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: guard against empty slug string in skill download URL

Filter out empty slug strings so we fall back to name instead of
constructing an invalid download URL.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-03 23:53:16 +00:00
d562dc8d90 fix(workspace): thread document path through search results (#503)
* fix(workspace): thread document path through search results

Memory search results were showing chunk UUIDs instead of source file
paths. Thread document_path through RankedResult, SearchResult, and the
RRF fusion pipeline so handlers can display the actual file path.

Fixes #481

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: use into_iter to move values instead of cloning

Address review feedback: consume results with into_iter() to move
String fields directly instead of cloning them.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-03 23:31:30 +00:00
Nick PismenkovandGitHub c239a4fc2a feat: remove the okta tool (#506) 2026-03-03 21:30:54 +00:00
944968bf76 fix(workspace): import custom templates before seeding defaults (#505)
Swap the order of import_from_directory() and seed_if_empty() so that
custom workspace templates from WORKSPACE_IMPORT_DIR take priority
over generic seeds. Previously, seed_if_empty() ran first and created
all default files, causing import_from_directory() to skip everything
since the files already existed in the DB.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-03 11:14:02 -08:00
18b59ae9a7 feat: add OAuth support for WASM tools in web gateway (#489)
* feat: add OAuth support for WASM tools in web gateway

Extract reusable OAuth functions (build_oauth_url, exchange_oauth_code,
store_oauth_tokens, validate_oauth_token) from CLI into shared
oauth_defaults module, then wire them into the web gateway's
ExtensionManager.

Key changes:
- Install auto-activates WASM tools (no separate Activate button)
- Configure button triggers OAuth flow via save_setup_secrets
- Scope merging: installing a second Google tool triggers re-auth with
  merged scopes from all tools sharing the same secret_name
- Cancel-and-retry: aborting stale OAuth listeners prevents port conflicts
- Post-auth validation: wrong account detected via validation_endpoint
- Reconfigure always re-auths (deletes old token before starting fresh)
- UI shows error toast on OAuth failure, refreshes extension list

Flow: Install → Active → Configure (enter client_id/secret) → Save →
OAuth popup → authorize → done. Second Google tool install auto-triggers
scope expansion OAuth.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review comments

- Add custom headers support to ValidationEndpointSchema (fixes
  missing Notion-Version header regression)
- Guard activate handler auth check with status == "awaiting_authorization"
  to prevent unexpected OAuth popups
- Add window dimensions to OAuth popup in activateExtension()
- Simplify UTF-8 truncation boundary check

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address Copilot PR review comments (security, UX, bugs)

- Add CSRF state parameter to OAuth flow (random state in auth URL, validated in callback)
- Restore MCP server Activate button in web UI (was hidden for all non-channel extensions)
- Abort JoinHandle in cleanup_expired_auths to prevent port 9876 conflicts
- Fix Google-specific error message for non-Google OAuth providers
- Add has_auth field to ExtensionInfo API response (fixes Configure button visibility)
- Use oauth_defaults::callback_url() instead of hardcoded redirect_uri (both CLI and manager)
- Update auth check comment to match actual behavior (scope expansion + first-time auth)
- Add unit tests for build_oauth_url (basic, PKCE, extra params, state uniqueness)
- Check all required setup secrets (client_id + client_secret) before starting OAuth

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-04 01:08:40 +08:00
f4855962fc fix: use std::sync::RwLock in MessageTool to avoid runtime panic (#411)
* fix: use std::sync::RwLock in MessageTool to avoid runtime panic

The `requires_approval` method is synchronous but was using
`tokio::sync::RwLock` with `.await` which requires blocking the
runtime. This caused a panic:
"Cannot block the current thread from within a runtime"

Changes:
- Replace `tokio::sync::RwLock` with `std::sync::RwLock` for
  `default_channel` and `default_target` fields
- Use `unwrap_or_else(|e| e.into_inner())` to gracefully handle
  poisoned locks (recovers instead of panicking)
- Update all usages from `.read().await` to `.read().unwrap_or_else()`

The locks are short-held (just cloning strings), making std::sync::RwLock
appropriate for sync methods called from async contexts.

Fixes: "Cannot block the current thread from within a runtime" panic
when the LLM tries to send a message via the message tool.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address code review feedback for MessageTool RwLock fix

- Fix formatting (long lines broken up per rustfmt)
- Add regression test that demonstrates the panic with tokio::sync::RwLock
  and passes with std::sync::RwLock when calling requires_approval()
  (sync method) from async context

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-03 16:16:02 +00:00
f18fb5173b feat(web): fix jobs UI parity for non-sandbox mode (#491)
* feat(web): fix jobs UI parity for non-sandbox mode

The web gateway Jobs UI was built primarily for sandbox (Docker) jobs.
When running without sandbox (common for NEAR AI hosted envs), multiple
features were broken. This change fixes all of them:

- Agent jobs now broadcast live SSE events to the web UI (Activity tab)
- Agent job restart via scheduler.dispatch_job (not chat message)
- Follow-up prompts for agent jobs via WorkerMessage injection
- Capability flags (can_restart, can_prompt, job_kind) in job detail API
- Rate-limit retry with cap (10 consecutive) and Retry-After header parsing
- Plan interruption on user message (breaks out of plan, re-evaluates)
- Correct SSE status field in mark_completed/mark_failed/mark_stuck
- SseManager preserved across rebuild_state calls

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: fix formatting in db/mod.rs and nearai_chat.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-03 22:23:30 +08:00
78878ad7ef Remove restart infrastructure, generalize WASM channel setup (#493)
* refactor: remove restart infrastructure and generalize Telegram-specific code

Remove the gateway restart mechanism (hot-activation works, restart won't
fix activation failures) and generalize Telegram-specific hardcoded checks
so all WASM channels get equal treatment.

Part 1 - Remove restart infrastructure:
- Remove needs_restart from ActionResponse, restart_requested from GatewayState
- Remove gateway_restart_handler, /api/gateway/restart route, exit code 75
- Remove restart overlay JS/CSS (dead code - restartGateway() never called)
- Surface actual activation errors instead of suggesting restart

Part 2 - Generalize Telegram-specific code:
- Replace telegram_owner_id: Option<i64> with generic
  wasm_channel_owner_ids: HashMap<String, i64> (backwards-compatible
  via TELEGRAM_OWNER_ID env var)
- Pairing status check now applies to all active WASM channels
- All channels get 3-step stepper in web UI, remove "coming soon" note
- Remove dead setup_telegram() code (~700 lines) - Telegram's
  capabilities.json declares required_secrets, so the generic
  setup_wasm_channel() path handles it

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test: add Settings::set() test for wasm_channel_owner_ids

Addresses review feedback: verify that setting per-channel owner IDs
via the dotted-path Settings::set() API works correctly with the new
HashMap<String, i64> type.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(web): refresh extension stepper after pairing approval

loadPairingRequests only refreshed the pairing section, not the
stepper status. Call loadExtensions() instead so the stepper updates
from "Awaiting Pairing" to "Active" immediately after approval.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-03 22:10:32 +08:00
5f841554d5 feat(workspace): add TOOLS.md, BOOTSTRAP.md, and disk-to-DB import (#477)
* feat(workspace): add TOOLS.md, BOOTSTRAP.md, and disk-to-DB import

Add two new OpenClaw-compatible workspace markdown files:

- TOOLS.md: Environment-specific tool notes (SSH hosts, device names,
  etc.) injected into the system prompt under "## Tool Notes". Seeded
  as comment-only (like HEARTBEAT.md) so it's effectively empty until
  the user adds real content. Not write-protected — the agent can
  update it as it learns the environment.

- BOOTSTRAP.md: First-run onboarding ritual. Injected FIRST in the
  system prompt when present. Guides the agent through introducing
  itself, learning about the user, and updating workspace files.
  Only seeded on truly fresh workspaces (no existing identity files)
  to avoid triggering the ritual on existing deployments. Agent clears
  it via `memory_write(target="bootstrap")` when done.

Add `Workspace::import_from_directory()` for disk-to-DB import:

- Scans a directory for *.md files and imports any that don't already
  exist in the database (never overwrites user edits)
- Controlled by WORKSPACE_IMPORT_DIR env var, runs after seed_if_empty()
- Enables Docker images / deployment scripts to ship customized
  workspace templates that override generic seeds
- Backwards compatible: no-op when env var is unset

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review comments

- Use stable `path.extension() != Some(OsStr::new("md"))` instead of
  unstable `is_none_or` (nightly-only)
- Use `tokio::join!` for concurrent DB reads in fresh-workspace check
- Skip unreadable directory entries instead of failing the entire import
- Skip unreadable files instead of failing the entire import

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-02 19:00:21 -08:00
6adf95b6d1 fix: wire secrets store into all WASM runtime activation paths (#479)
WASM tools and channels activated at runtime (via web UI or CLI) were
missing secrets store wiring, causing credential injection to silently
fail. Tools like web-search would get 401s from APIs even though the
user had configured their API key.

Four bugs fixed:
- activate_wasm_tool(): WasmToolLoader created without .with_secrets_store()
- register_wasm_from_storage(): hardcoded secrets_store: None
- WasmChannelLoader: no secrets_store field at all (added field + builder)
- activate_wasm_channel() and startup path: both missed wiring secrets

The startup path in app.rs was correct; all runtime paths now match it.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-02 16:56:24 -08:00
66 changed files with 2453 additions and 1656 deletions
+3
View File
@@ -62,6 +62,9 @@ create "scope: ci" "546E7A" "CI/CD workflows"
create "scope: docs" "78909C" "Documentation"
create "scope: dependencies" "90A4AE" "Dependency updates"
echo "==> Creating workflow labels..."
create "skip-regression-check" "9E9E9E" "Acknowledged: fix without regression test"
echo "==> Creating contributor labels..."
create "contributor: new" "FFF9C4" "First-time contributor"
create "contributor: regular" "FFE082" "2-5 merged PRs"
+32
View File
@@ -0,0 +1,32 @@
name: Code Coverage
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
with:
key: coverage
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate coverage
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
- name: Upload to Codecov
uses: codecov/codecov-action@v5
with:
files: lcov.info
disable_search: true
use_oidc: true
fail_ci_if_error: true
+107
View File
@@ -0,0 +1,107 @@
name: Regression Test Check
on:
pull_request:
jobs:
regression-test:
name: Regression test enforcement
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for regression tests
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
run: |
set -euo pipefail
BASE_REF="origin/${{ github.event.pull_request.base.ref }}"
# --- 1. Is this a fix PR? Check title first, then commit messages ---
IS_FIX=false
if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$PR_TITLE"; then
IS_FIX=true
fi
if [ "$IS_FIX" = false ]; then
COMMITS=$(git log --format='%s' "${BASE_REF}..HEAD")
if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$COMMITS"; then
IS_FIX=true
fi
fi
if [ "$IS_FIX" = false ]; then
echo "Not a fix PR — skipping regression test check."
exit 0
fi
echo "Fix PR detected."
# --- 2. Skip label or commit message marker ---
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
echo "skip-regression-check label present — skipping."
exit 0
fi
COMMIT_BODIES=$(git log --format='%B' "${BASE_REF}..HEAD")
if grep -qF '[skip-regression-check]' <<< "$COMMIT_BODIES"; then
echo "[skip-regression-check] found in commit message — skipping."
exit 0
fi
# --- 3. Exempt static-only / docs-only changes ---
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...HEAD")
if [ -z "$CHANGED_FILES" ]; then
echo "No changed files — skipping."
exit 0
fi
ALL_EXEMPT=true
while IFS= read -r file; do
case "$file" in
src/channels/web/static/*) ;;
*.md) ;;
*) ALL_EXEMPT=false; break ;;
esac
done <<< "$CHANGED_FILES"
if [ "$ALL_EXEMPT" = true ]; then
echo "All changes are static assets or docs — skipping."
exit 0
fi
# --- 4. Look for test changes ---
# Fast path: new test attributes or test modules in added lines.
if git diff "${BASE_REF}...HEAD" -U0 -- '*.rs' | grep -qE '^\+.*(#\[test\]|#\[tokio::test\]|#\[cfg\(test\)\]|mod tests)'; then
echo "Test changes found in .rs files."
exit 0
fi
# Whole-function context: detect edits inside existing test functions.
if git diff "${BASE_REF}...HEAD" -W -- '*.rs' | awk '
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
/^\+.*#\[test\]/ || /^\+.*#\[tokio::test\]/ || /^\+.*#\[cfg\(test\)\]/ || /^\+.*mod tests/ { has_test=1 }
/^\+[^+]/ { has_add=1 }
END { if (has_test && has_add) found=1; exit !found }
'; then
echo "Test changes found in existing test functions."
exit 0
fi
if grep -qE '^tests/' <<< "$CHANGED_FILES"; then
echo "Test file changes found under tests/."
exit 0
fi
# --- 5. No tests found ---
echo "::warning::This PR looks like a bug fix but contains no test changes. Every fix should include a regression test. Add a #[test] or #[tokio::test], or apply the 'skip-regression-check' label if not feasible."
exit 1
+26
View File
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.14.0](https://github.com/nearai/ironclaw/compare/v0.13.1...v0.14.0) - 2026-03-04
### Added
- remove the okta tool ([#506](https://github.com/nearai/ironclaw/pull/506))
- add OAuth support for WASM tools in web gateway ([#489](https://github.com/nearai/ironclaw/pull/489))
- *(web)* fix jobs UI parity for non-sandbox mode ([#491](https://github.com/nearai/ironclaw/pull/491))
- *(workspace)* add TOOLS.md, BOOTSTRAP.md, and disk-to-DB import ([#477](https://github.com/nearai/ironclaw/pull/477))
### Fixed
- *(web)* mobile browser bar obscures chat input ([#508](https://github.com/nearai/ironclaw/pull/508))
- *(web)* assign unique thread_id to manual routine triggers ([#500](https://github.com/nearai/ironclaw/pull/500))
- *(web)* refresh routine UI after Run Now trigger ([#501](https://github.com/nearai/ironclaw/pull/501))
- *(skills)* use slug for skill download URL from ClawHub ([#502](https://github.com/nearai/ironclaw/pull/502))
- *(workspace)* thread document path through search results ([#503](https://github.com/nearai/ironclaw/pull/503))
- *(workspace)* import custom templates before seeding defaults ([#505](https://github.com/nearai/ironclaw/pull/505))
- use std::sync::RwLock in MessageTool to avoid runtime panic ([#411](https://github.com/nearai/ironclaw/pull/411))
- wire secrets store into all WASM runtime activation paths ([#479](https://github.com/nearai/ironclaw/pull/479))
### Other
- enforce regression tests for fix commits ([#517](https://github.com/nearai/ironclaw/pull/517))
- add code coverage with cargo-llvm-cov and Codecov ([#511](https://github.com/nearai/ironclaw/pull/511))
- Remove restart infrastructure, generalize WASM channel setup ([#493](https://github.com/nearai/ironclaw/pull/493))
## [0.13.1](https://github.com/nearai/ironclaw/compare/v0.13.0...v0.13.1) - 2026-03-02
### Added
+3
View File
@@ -321,6 +321,8 @@ cargo check --all-features # all features
```
Dead code behind the wrong `#[cfg]` gate will only show up when building with a single feature.
**Regression test with every fix:** Every bug fix must include a test that would have caught the bug. Add a `#[test]` or `#[tokio::test]` that reproduces the original failure. Exempt: changes limited to `src/channels/web/static/` or `.md` files. Use `[skip-regression-check]` in commit message or PR label if genuinely not feasible. The `commit-msg` hook and CI workflow enforce this automatically.
**Zero clippy warnings policy:** Fix ALL clippy warnings before committing, including pre-existing ones in files you didn't change. Never leave warnings behind — treat `cargo clippy` output as a zero-tolerance gate.
**Mechanical verification before committing:** Run these checks on changed files before committing:
@@ -328,6 +330,7 @@ Dead code behind the wrong `#[cfg]` gate will only show up when building with a
- `grep -rnE '\.unwrap\(|\.expect\(' <files>` -- no panics in production
- `grep -rn 'super::' <files>` -- use `crate::` imports
- If you fixed a pattern bug, `grep` for other instances of that pattern across `src/`
- Fix commits must include regression tests (enforced by `commit-msg` hook; bypass with `[skip-regression-check]`)
## Configuration
Generated
+1 -1
View File
@@ -2828,7 +2828,7 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"aes-gcm",
"aho-corasick",
+1 -2
View File
@@ -12,14 +12,13 @@ exclude = [
"tools-src/google-drive",
"tools-src/google-sheets",
"tools-src/google-slides",
"tools-src/okta",
"tools-src/slack",
"tools-src/telegram",
]
[package]
name = "ironclaw"
version = "0.13.1"
version = "0.14.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
+10
View File
@@ -0,0 +1,10 @@
coverage:
status:
project:
default:
target: auto
threshold: 1%
patch:
default:
target: 80%
threshold: 5%
-31
View File
@@ -1,31 +0,0 @@
{
"name": "okta",
"display_name": "Okta",
"kind": "tool",
"version": "0.1.0",
"description": "Okta SSO for user profile, app catalog, and SSO launch links",
"keywords": ["sso", "identity", "authentication", "okta"],
"source": {
"dir": "tools-src/okta",
"capabilities": "okta-tool.capabilities.json",
"crate_name": "okta-tool"
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/okta-wasm32-wasip2.tar.gz",
"sha256": null
}
},
"auth_summary": {
"method": "oauth",
"provider": "Okta",
"secrets": ["okta_oauth_token"],
"shared_auth": null,
"setup_url": "https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/"
},
"tags": ["identity"]
}
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# commit-msg hook: require regression tests for fix commits.
#
# Installed by scripts/dev-setup.sh as .git/hooks/commit-msg.
# Bypass with [skip-regression-check] in the commit message.
set -euo pipefail
MSG_FILE="$1"
FIRST_LINE=$(head -1 "$MSG_FILE")
# --- 1. Is this a fix commit? ---
if ! grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$FIRST_LINE"; then
exit 0
fi
# --- 2. Skip marker ---
if grep -qF '[skip-regression-check]' "$MSG_FILE"; then
exit 0
fi
# --- 3. Exempt static-only / docs-only changes ---
# Get staged files (commit-msg runs after staging is finalized).
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
if [ -z "$STAGED_FILES" ]; then
exit 0
fi
ALL_EXEMPT=true
while IFS= read -r file; do
case "$file" in
src/channels/web/static/*) ;;
*.md) ;;
*) ALL_EXEMPT=false; break ;;
esac
done <<< "$STAGED_FILES"
if [ "$ALL_EXEMPT" = true ]; then
exit 0
fi
# --- 4. Look for test changes in staged .rs files ---
# Fast path: new test attributes or test modules in added lines.
if git diff --cached -U0 -- '*.rs' | grep -qE '^\+.*(#\[test\]|#\[tokio::test\]|#\[cfg\(test\)\]|mod tests)'; then
exit 0
fi
# Whole-function context: detect edits inside existing test functions.
# -W shows the full enclosing function, so #[test] appears in context
# lines when changes are inside a test function.
if git diff --cached -W -- '*.rs' | awk '
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
/^\+.*#\[test\]/ || /^\+.*#\[tokio::test\]/ || /^\+.*#\[cfg\(test\)\]/ || /^\+.*mod tests/ { has_test=1 }
/^\+[^+]/ { has_add=1 }
END { if (has_test && has_add) found=1; exit !found }
'; then
exit 0
fi
# Also check for new/modified files under tests/
if grep -qE '^tests/' <<< "$STAGED_FILES"; then
exit 0
fi
# --- 5. No test found — block the commit ---
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ REGRESSION TEST REQUIRED ║"
echo "║ ║"
echo "║ This commit looks like a bug fix but has no test changes. ║"
echo "║ Every fix should include a test that reproduces the bug. ║"
echo "║ ║"
echo "║ Options: ║"
echo "║ • Add a #[test] or #[tokio::test] that catches the bug ║"
echo "║ • Add [skip-regression-check] to your commit message ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
exit 1
+17 -5
View File
@@ -24,14 +24,14 @@ if ! command -v rustup &>/dev/null; then
echo "ERROR: rustup not found. Install from https://rustup.rs"
exit 1
fi
echo "[1/5] rustup found: $(rustup --version 2>/dev/null | head -1)"
echo "[1/6] rustup found: $(rustup --version 2>/dev/null | head -1)"
# 2. Add WASM target (required by build.rs for channel compilation)
echo "[2/5] Adding wasm32-wasip2 target..."
echo "[2/6] Adding wasm32-wasip2 target..."
rustup target add wasm32-wasip2
# 3. Install wasm-tools (required by build.rs for WASM component model)
echo "[3/5] Installing wasm-tools..."
echo "[3/6] Installing wasm-tools..."
if command -v wasm-tools &>/dev/null; then
echo " wasm-tools already installed: $(wasm-tools --version)"
else
@@ -39,13 +39,25 @@ else
fi
# 4. Verify the project compiles
echo "[4/5] Running cargo check..."
echo "[4/6] Running cargo check..."
cargo check
# 5. Run tests using libsql temp DB (no Docker/external DB needed)
echo "[5/5] Running tests (no external DB required)..."
echo "[5/6] Running tests (no external DB required)..."
cargo test
# 6. Install git hooks
echo "[6/6] Installing git hooks..."
HOOKS_DIR=$(git rev-parse --git-path hooks 2>/dev/null) || true
if [ -n "$HOOKS_DIR" ]; then
mkdir -p "$HOOKS_DIR"
SCRIPT_ABS="$(cd "$(dirname "$0")" && pwd)/commit-msg-regression.sh"
ln -sf "$SCRIPT_ABS" "$HOOKS_DIR/commit-msg"
echo " commit-msg hook installed (regression test enforcement)"
else
echo " Skipped: not a git repository"
fi
echo ""
echo "=== Setup complete ==="
echo ""
+8 -2
View File
@@ -73,6 +73,8 @@ pub struct AgentDeps {
pub hooks: Arc<HookRegistry>,
/// Cost enforcement guardrails (daily budget, hourly rate limits).
pub cost_guard: Arc<crate::agent::cost_guard::CostGuard>,
/// SSE broadcast sender for live job event streaming to the web gateway.
pub sse_tx: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
}
/// The main agent that coordinates all components.
@@ -111,7 +113,7 @@ impl Agent {
let session_manager = session_manager.unwrap_or_else(|| Arc::new(SessionManager::new()));
let scheduler = Arc::new(Scheduler::new(
let mut scheduler = Scheduler::new(
config.clone(),
context_manager.clone(),
deps.llm.clone(),
@@ -119,7 +121,11 @@ impl Agent {
deps.tools.clone(),
deps.store.clone(),
deps.hooks.clone(),
));
);
if let Some(ref tx) = deps.sse_tx {
scheduler.set_sse_sender(tx.clone());
}
let scheduler = Arc::new(scheduler);
Self {
config,
+3
View File
@@ -982,6 +982,7 @@ mod tests {
skills_config: SkillsConfig::default(),
hooks: Arc::new(HookRegistry::new()),
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
sse_tx: None,
};
Agent::new(
@@ -1719,6 +1720,7 @@ mod tests {
skills_config: SkillsConfig::default(),
hooks: Arc::new(HookRegistry::new()),
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
sse_tx: None,
};
Agent::new(
@@ -1830,6 +1832,7 @@ mod tests {
skills_config: SkillsConfig::default(),
hooks: Arc::new(HookRegistry::new()),
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
sse_tx: None,
};
Agent::new(
+32
View File
@@ -10,6 +10,7 @@ use uuid::Uuid;
use crate::agent::task::{Task, TaskContext, TaskOutput};
use crate::agent::worker::{Worker, WorkerDeps};
use crate::channels::web::types::SseEvent;
use crate::config::AgentConfig;
use crate::context::{ContextManager, JobContext, JobState};
use crate::db::Database;
@@ -28,6 +29,8 @@ pub enum WorkerMessage {
Stop,
/// Check health.
Ping,
/// Inject a follow-up user message into the worker's reasoning context.
UserMessage(String),
}
/// Status of a scheduled job.
@@ -51,6 +54,8 @@ pub struct Scheduler {
tools: Arc<ToolRegistry>,
store: Option<Arc<dyn Database>>,
hooks: Arc<HookRegistry>,
/// SSE broadcast sender for live job event streaming.
sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>,
/// Running jobs (main LLM-driven jobs).
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
/// Running sub-tasks (tool executions, background tasks).
@@ -76,11 +81,17 @@ impl Scheduler {
tools,
store,
hooks,
sse_tx: None,
jobs: Arc::new(RwLock::new(HashMap::new())),
subtasks: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Set the SSE broadcast sender for live job event streaming.
pub fn set_sse_sender(&mut self, tx: tokio::sync::broadcast::Sender<SseEvent>) {
self.sse_tx = Some(tx);
}
/// Create, persist, and schedule a job in one shot.
///
/// This is the preferred entry point for dispatching new jobs. It:
@@ -169,6 +180,7 @@ impl Scheduler {
hooks: self.hooks.clone(),
timeout: self.config.job_timeout,
use_planning: self.config.use_planning,
sse_tx: self.sse_tx.clone(),
};
let worker = Worker::new(job_id, deps);
@@ -500,6 +512,26 @@ impl Scheduler {
Ok(())
}
/// Send a follow-up user message to a running job.
///
/// Returns `Ok(())` if the message was queued, `Err` if the job is not running.
pub async fn send_message(&self, job_id: Uuid, content: String) -> Result<(), JobError> {
// Clone the sender while holding the lock, then release before the
// async send to avoid blocking scheduler writes during backpressure.
let tx = {
let jobs = self.jobs.read().await;
let scheduled = jobs.get(&job_id).ok_or(JobError::NotFound { id: job_id })?;
scheduled.tx.clone()
};
tx.send(WorkerMessage::UserMessage(content))
.await
.map_err(|_| JobError::Failed {
id: job_id,
reason: "Worker channel closed".to_string(),
})?;
Ok(())
}
/// Check if a job is running.
pub async fn is_running(&self, job_id: Uuid) -> bool {
self.jobs.read().await.contains_key(&job_id)
+210 -19
View File
@@ -9,6 +9,7 @@ use uuid::Uuid;
use crate::agent::scheduler::WorkerMessage;
use crate::agent::task::TaskOutput;
use crate::channels::web::types::SseEvent;
use crate::context::{ContextManager, JobState};
use crate::db::Database;
use crate::error::Error;
@@ -34,6 +35,8 @@ pub struct WorkerDeps {
pub hooks: Arc<HookRegistry>,
pub timeout: Duration,
pub use_planning: bool,
/// SSE broadcast sender for live job event streaming to the web gateway.
pub sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>,
}
/// Worker that executes a single job.
@@ -98,18 +101,90 @@ impl Worker {
}
}
/// Fire-and-forget persistence of a job event.
/// Fire-and-forget persistence of a job event and SSE broadcast.
fn log_event(&self, event_type: &str, data: serde_json::Value) {
let job_id = self.job_id;
// Persist to DB
if let Some(store) = self.store() {
let store = store.clone();
let job_id = self.job_id;
let event_type = event_type.to_string();
let et = event_type.to_string();
let d = data.clone();
tokio::spawn(async move {
if let Err(e) = store.save_job_event(job_id, &event_type, &data).await {
if let Err(e) = store.save_job_event(job_id, &et, &d).await {
tracing::warn!("Failed to persist event for job {}: {}", job_id, e);
}
});
}
// Broadcast SSE for live web UI updates
if let Some(ref tx) = self.deps.sse_tx {
let job_id_str = job_id.to_string();
let event = match event_type {
"message" => Some(SseEvent::JobMessage {
job_id: job_id_str,
role: data
.get("role")
.and_then(|v| v.as_str())
.unwrap_or("assistant")
.to_string(),
content: data
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
}),
"tool_use" => Some(SseEvent::JobToolUse {
job_id: job_id_str,
tool_name: data
.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
input: data
.get("input")
.cloned()
.unwrap_or(serde_json::Value::Null),
}),
"tool_result" => Some(SseEvent::JobToolResult {
job_id: job_id_str,
tool_name: data
.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
output: data
.get("output")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
}),
"status" => Some(SseEvent::JobStatus {
job_id: job_id_str,
message: data
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
}),
"result" => Some(SseEvent::JobResult {
job_id: job_id_str,
status: data
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("completed")
.to_string(),
session_id: data
.get("session_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
}),
_ => None,
};
if let Some(event) = event {
let _ = tx.send(event);
}
}
}
/// Run the worker until the job is complete or stopped.
@@ -123,7 +198,7 @@ impl Worker {
tracing::debug!("Worker for job {} stopped before starting", self.job_id);
return Ok(());
}
Some(WorkerMessage::Ping) => {}
Some(WorkerMessage::Ping) | Some(WorkerMessage::UserMessage(_)) => {}
}
// Get job context
@@ -219,6 +294,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.unwrap_or(50) as usize;
let max_iterations = max_iterations.min(MAX_WORKER_ITERATIONS);
let mut iteration = 0;
const MAX_CONSECUTIVE_RATE_LIMITS: usize = 10;
let mut consecutive_rate_limits = 0usize;
// Initial tool definitions for planning (will be refreshed in loop)
reason_ctx.available_tools = self.tools().tool_definitions().await;
@@ -269,15 +346,27 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
None
};
// If we have a plan, execute it
// If we have a plan, execute it. Two exit paths:
// 1. Plan ran to completion → job is Completed or needs continuation
// (check state and only fall through if not terminal)
// 2. Plan was interrupted by UserMessage → fall through to direct loop
if let Some(ref plan) = plan {
return self.execute_plan(rx, reasoning, reason_ctx, plan).await;
self.execute_plan(rx, reasoning, reason_ctx, plan).await?;
// If the plan marked the job terminal, we're done. Only fall
// through to the direct selection loop if the plan was
// interrupted or explicitly left the job in-progress.
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await
&& (ctx.state.is_terminal() || ctx.state == JobState::Stuck)
{
return Ok(());
}
}
// Otherwise, use direct tool selection loop
// Direct tool selection loop (also used as fallback after plan interruption)
loop {
// Check for stop signal
if let Ok(msg) = rx.try_recv() {
// Check for stop signal and injected user messages
while let Ok(msg) = rx.try_recv() {
match msg {
WorkerMessage::Stop => {
tracing::debug!("Worker for job {} received stop signal", self.job_id);
@@ -287,6 +376,20 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
tracing::trace!("Worker for job {} received ping", self.job_id);
}
WorkerMessage::Start => {}
WorkerMessage::UserMessage(content) => {
tracing::info!(
job_id = %self.job_id,
"Worker received follow-up user message"
);
reason_ctx.messages.push(ChatMessage::user(&content));
self.log_event(
"message",
serde_json::json!({
"role": "user",
"content": content,
}),
);
}
}
}
@@ -307,12 +410,64 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
// Refresh tool definitions so newly built tools become visible
reason_ctx.available_tools = self.tools().tool_definitions().await;
// Select next tool(s) to use
let selections = reasoning.select_tools(reason_ctx).await?;
// Select next tool(s) to use, with rate-limit retry.
let selections = match reasoning.select_tools(reason_ctx).await {
Ok(s) => s,
Err(crate::error::LlmError::RateLimited { retry_after, .. }) => {
consecutive_rate_limits += 1;
let wait = retry_after.unwrap_or(Duration::from_secs(5));
tracing::warn!(
job_id = %self.job_id,
wait_secs = wait.as_secs(),
attempt = consecutive_rate_limits,
"LLM rate limited during tool selection, backing off"
);
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
self.mark_stuck("Persistent rate limiting").await?;
return Ok(());
}
self.log_event(
"status",
serde_json::json!({
"message": format!("Rate limited, retrying in {}s ({}/{})...",
wait.as_secs(), consecutive_rate_limits, MAX_CONSECUTIVE_RATE_LIMITS),
}),
);
tokio::time::sleep(wait).await;
continue;
}
Err(e) => return Err(e.into()),
};
if selections.is_empty() {
// No tools from select_tools, ask LLM directly (may still return tool calls)
let respond_output = reasoning.respond_with_tools(reason_ctx).await?;
let respond_output = match reasoning.respond_with_tools(reason_ctx).await {
Ok(o) => o,
Err(crate::error::LlmError::RateLimited { retry_after, .. }) => {
consecutive_rate_limits += 1;
let wait = retry_after.unwrap_or(Duration::from_secs(5));
tracing::warn!(
job_id = %self.job_id,
wait_secs = wait.as_secs(),
attempt = consecutive_rate_limits,
"LLM rate limited during respond_with_tools, backing off"
);
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
self.mark_stuck("Persistent rate limiting").await?;
return Ok(());
}
self.log_event(
"status",
serde_json::json!({
"message": format!("Rate limited, retrying in {}s ({}/{})...",
wait.as_secs(), consecutive_rate_limits, MAX_CONSECUTIVE_RATE_LIMITS),
}),
);
tokio::time::sleep(wait).await;
continue;
}
Err(e) => return Err(e.into()),
};
match respond_output.result {
RespondResult::Text(response) => {
@@ -424,6 +579,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}
}
// Reset rate-limit counter after a successful iteration (all LLM
// calls succeeded). Placed here so alternating success/fail between
// select_tools and respond_with_tools cannot bypass the cap.
consecutive_rate_limits = 0;
// Small delay between iterations
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -836,8 +996,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
plan: &ActionPlan,
) -> Result<(), Error> {
for (i, action) in plan.actions.iter().enumerate() {
// Check for stop signal
if let Ok(msg) = rx.try_recv() {
// Check for stop signal and injected user messages
while let Ok(msg) = rx.try_recv() {
match msg {
WorkerMessage::Stop => {
tracing::debug!(
@@ -850,6 +1010,29 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
tracing::trace!("Worker for job {} received ping", self.job_id);
}
WorkerMessage::Start => {}
WorkerMessage::UserMessage(content) => {
tracing::info!(
job_id = %self.job_id,
"User message received during plan execution, abandoning plan"
);
reason_ctx.messages.push(ChatMessage::user(&content));
self.log_event(
"message",
serde_json::json!({
"role": "user",
"content": content,
}),
);
self.log_event(
"status",
serde_json::json!({
"message": "Plan interrupted by user message, re-evaluating...",
}),
);
// Return Ok to break out of plan; caller falls through to
// the direct selection loop for LLM re-evaluation.
return Ok(());
}
}
}
@@ -902,14 +1085,18 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
if crate::util::llm_signals_completion(&response) {
self.mark_completed().await?;
} else {
// Job not complete, could re-plan or fall back to direct selection
// Job not complete — return Ok without marking terminal so the
// caller falls through to the direct selection loop for continuation.
tracing::info!(
"Job {} plan completed but work remains, falling back to direct selection",
self.job_id
);
// Continue with standard execution loop by returning (will be picked up by main loop)
self.mark_stuck("Plan completed but job incomplete - needs re-planning")
.await?;
self.log_event(
"status",
serde_json::json!({
"message": "Plan completed but job needs more work, continuing...",
}),
);
}
Ok(())
@@ -940,6 +1127,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
self.log_event(
"result",
serde_json::json!({
"status": "completed",
"success": true,
"message": "Job completed successfully",
}),
@@ -965,6 +1153,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
self.log_event(
"result",
serde_json::json!({
"status": "failed",
"success": false,
"message": format!("Execution failed: {}", reason),
}),
@@ -985,6 +1174,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
self.log_event(
"result",
serde_json::json!({
"status": "stuck",
"success": false,
"message": format!("Job stuck: {}", reason),
}),
@@ -1103,6 +1293,7 @@ mod tests {
hooks: Arc::new(crate::hooks::HookRegistry::new()),
timeout: Duration::from_secs(30),
use_planning: false,
sse_tx: None,
};
Worker::new(job_id, deps)
+25
View File
@@ -665,6 +665,31 @@ impl AppBuilder {
// Seed workspace and backfill embeddings
if let Some(ref ws) = workspace {
// Import workspace files from disk FIRST if WORKSPACE_IMPORT_DIR is set.
// This lets Docker images / deployment scripts ship customized
// workspace templates (e.g., AGENTS.md, TOOLS.md) that override
// the generic seeds. Only imports files that don't already exist
// in the database — never overwrites user edits.
//
// Runs before seed_if_empty() so that custom templates take priority
// over generic seeds. seed_if_empty() then fills any remaining gaps.
if let Ok(import_dir) = std::env::var("WORKSPACE_IMPORT_DIR") {
let import_path = std::path::Path::new(&import_dir);
match ws.import_from_directory(import_path).await {
Ok(count) if count > 0 => {
tracing::info!("Imported {} workspace file(s) from {}", count, import_dir);
}
Ok(_) => {}
Err(e) => {
tracing::warn!(
"Failed to import workspace files from {}: {}",
import_dir,
e
);
}
}
}
match ws.seed_if_empty().await {
Ok(_) => {}
Err(e) => {
+13 -1
View File
@@ -19,12 +19,14 @@ use crate::channels::wasm::schema::ChannelCapabilitiesFile;
use crate::channels::wasm::wrapper::WasmChannel;
use crate::db::SettingsStore;
use crate::pairing::PairingStore;
use crate::secrets::SecretsStore;
/// Loads WASM channels from the filesystem.
pub struct WasmChannelLoader {
runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
settings_store: Option<Arc<dyn SettingsStore>>,
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
}
impl WasmChannelLoader {
@@ -38,9 +40,16 @@ impl WasmChannelLoader {
runtime,
pairing_store,
settings_store,
secrets_store: None,
}
}
/// Set the secrets store for host-based credential injection in WASM channels.
pub fn with_secrets_store(mut self, store: Arc<dyn SecretsStore + Send + Sync>) -> Self {
self.secrets_store = Some(store);
self
}
/// Load a single WASM channel from a file pair.
///
/// Expects:
@@ -127,7 +136,7 @@ impl WasmChannelLoader {
.await?;
// Create the channel
let channel = WasmChannel::new(
let mut channel = WasmChannel::new(
self.runtime.clone(),
prepared,
capabilities,
@@ -135,6 +144,9 @@ impl WasmChannelLoader {
self.pairing_store.clone(),
self.settings_store.clone(),
);
if let Some(ref secrets) = self.secrets_store {
channel = channel.with_secrets_store(Arc::clone(secrets));
}
tracing::info!(
name = name,
+7 -2
View File
@@ -33,7 +33,7 @@ pub async fn extensions_list_handler(
"failed".to_string()
} else if !ext.authenticated {
"installed".to_string()
} else if ext.active && ext.name == "telegram" {
} else if ext.active {
let has_paired = pairing_store
.read_allow_from(&ext.name)
.map(|list| !list.is_empty())
@@ -59,6 +59,7 @@ pub async fn extensions_list_handler(
active: ext.active,
tools: ext.tools,
needs_setup: ext.needs_setup,
has_auth: ext.has_auth,
activation_status,
activation_error: ext.activation_error,
}
@@ -123,7 +124,11 @@ pub async fn extensions_activate_handler(
))?;
match ext_mgr.activate(&name).await {
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
Ok(result) => {
// Activation just loads the WASM module. Auth (OAuth/manual) is
// triggered separately via save_setup_secrets or the auth endpoint.
Ok(Json(ActionResponse::ok(result.message)))
}
Err(activate_err) => {
let err_str = activate_err.to_string();
let needs_auth = err_str.contains("authentication")
+202 -94
View File
@@ -181,6 +181,9 @@ pub async fn jobs_detail_handler(
});
}
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
let is_claude_code = mode.as_deref() == Some("claude_code");
return Ok(Json(JobDetailResponse {
id: job.id,
title: job.task.clone(),
@@ -193,11 +196,11 @@ pub async fn jobs_detail_handler(
elapsed_secs,
project_dir: Some(job.project_dir.clone()),
browse_url: Some(format!("/projects/{}/", browse_id)),
job_mode: {
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
mode.filter(|m| m != "worker")
},
job_mode: mode.filter(|m| m != "worker"),
transitions,
can_restart: state.job_manager.is_some(),
can_prompt: is_claude_code && state.prompt_queue.is_some(),
job_kind: Some("sandbox".to_string()),
}));
}
@@ -208,6 +211,12 @@ pub async fn jobs_detail_handler(
(end - start).num_seconds().max(0) as u64
});
// Only show prompt bar for jobs that have a running worker (Pending/InProgress).
// Stuck jobs have no active worker loop, so messages would be silently dropped.
let is_promptable = matches!(
ctx.state,
crate::context::JobState::Pending | crate::context::JobState::InProgress
);
return Ok(Json(JobDetailResponse {
id: ctx.job_id,
title: ctx.title.clone(),
@@ -222,6 +231,9 @@ pub async fn jobs_detail_handler(
browse_url: None,
job_mode: None,
transitions: Vec::new(),
can_restart: state.scheduler.is_some(),
can_prompt: is_promptable && state.scheduler.is_some(),
job_kind: Some("agent".to_string()),
}));
}
@@ -295,108 +307,164 @@ pub async fn jobs_restart_handler(
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let jm = state.job_manager.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Sandbox not enabled".to_string(),
))?;
let old_job_id = Uuid::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
let old_job = store
.get_sandbox_job(old_job_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
// Try sandbox job restart first.
if let Ok(Some(old_job)) = store.get_sandbox_job(old_job_id).await {
if old_job.status != "interrupted" && old_job.status != "failed" {
return Err((
StatusCode::CONFLICT,
format!("Cannot restart job in state '{}'", old_job.status),
));
}
if old_job.status != "interrupted" && old_job.status != "failed" {
return Err((
StatusCode::CONFLICT,
format!("Cannot restart job in state '{}'", old_job.status),
));
let jm = state.job_manager.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Sandbox not enabled".to_string(),
))?;
// Enrich the task with failure context.
let task = if let Some(ref reason) = old_job.failure_reason {
format!(
"Previous attempt failed: {}. Retry: {}",
reason, old_job.task
)
} else {
old_job.task.clone()
};
let new_job_id = Uuid::new_v4();
let now = chrono::Utc::now();
let record = crate::history::SandboxJobRecord {
id: new_job_id,
task: task.clone(),
status: "creating".to_string(),
user_id: old_job.user_id.clone(),
project_dir: old_job.project_dir.clone(),
success: None,
failure_reason: None,
created_at: now,
started_at: None,
completed_at: None,
credential_grants_json: old_job.credential_grants_json.clone(),
};
store
.save_sandbox_job(&record)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mode = match store.get_sandbox_job_mode(old_job_id).await {
Ok(Some(m)) if m == "claude_code" => {
crate::orchestrator::job_manager::JobMode::ClaudeCode
}
_ => crate::orchestrator::job_manager::JobMode::Worker,
};
let credential_grants: Vec<crate::orchestrator::auth::CredentialGrant> =
serde_json::from_str(&old_job.credential_grants_json).unwrap_or_else(|e| {
tracing::warn!(
job_id = %old_job.id,
"Failed to deserialize credential grants from stored job: {}. \
Restarted job will have no credentials.",
e
);
vec![]
});
let project_dir = std::path::PathBuf::from(&old_job.project_dir);
let _token = jm
.create_job(
new_job_id,
&task,
Some(project_dir),
mode,
credential_grants,
)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create container: {}", e),
)
})?;
store
.update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
return Ok(Json(serde_json::json!({
"status": "restarted",
"old_job_id": old_job_id,
"new_job_id": new_job_id,
})));
}
// Create a new job with the same task and project_dir.
let new_job_id = Uuid::new_v4();
let now = chrono::Utc::now();
// Try agent job restart: dispatch a new job via the scheduler.
if let Ok(Some(old_job)) = store.get_job(old_job_id).await {
if old_job.state.is_active() {
return Err((
StatusCode::CONFLICT,
format!("Cannot restart job in state '{}'", old_job.state),
));
}
let record = crate::history::SandboxJobRecord {
id: new_job_id,
task: old_job.task.clone(),
status: "creating".to_string(),
user_id: old_job.user_id.clone(),
project_dir: old_job.project_dir.clone(),
success: None,
failure_reason: None,
created_at: now,
started_at: None,
completed_at: None,
credential_grants_json: old_job.credential_grants_json.clone(),
};
store
.save_sandbox_job(&record)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let slot = state.scheduler.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Scheduler not available".to_string(),
))?;
let scheduler_guard = slot.read().await;
let scheduler = scheduler_guard.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Agent not started yet".to_string(),
))?;
// Look up the original job's mode so the restart uses the same mode.
let mode = match store.get_sandbox_job_mode(old_job_id).await {
Ok(Some(m)) if m == "claude_code" => crate::orchestrator::job_manager::JobMode::ClaudeCode,
_ => crate::orchestrator::job_manager::JobMode::Worker,
};
// Look up failure reason (O(1) point lookup).
let failure_reason = store
.get_agent_job_failure_reason(old_job_id)
.await
.ok()
.flatten()
.unwrap_or_default();
// Restore credential grants from the original job so the restarted container
// has access to the same secrets.
let credential_grants: Vec<crate::orchestrator::auth::CredentialGrant> =
serde_json::from_str(&old_job.credential_grants_json).unwrap_or_else(|e| {
tracing::warn!(
job_id = %old_job.id,
"Failed to deserialize credential grants from stored job: {}. \
Restarted job will have no credentials.",
e
);
vec![]
});
let project_dir = std::path::PathBuf::from(&old_job.project_dir);
let _token = jm
.create_job(
new_job_id,
&old_job.task,
Some(project_dir),
mode,
credential_grants,
)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create container: {}", e),
let title = if !failure_reason.is_empty() {
format!(
"Previous attempt failed: {}. Retry: {}",
failure_reason, old_job.title
)
})?;
} else {
old_job.title.clone()
};
store
.update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let new_job_id = scheduler
.dispatch_job(&old_job.user_id, &title, &old_job.description, None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"status": "restarted",
"old_job_id": old_job_id,
"new_job_id": new_job_id,
})))
return Ok(Json(serde_json::json!({
"status": "restarted",
"old_job_id": old_job_id,
"new_job_id": new_job_id,
})));
}
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
}
/// Submit a follow-up prompt to a running Claude Code sandbox job.
/// Submit a follow-up prompt to a running job.
///
/// Routes to the appropriate backend:
/// - Claude Code sandbox jobs → prompt queue (polled by the bridge)
/// - Agent (non-sandbox) jobs → WorkerMessage injection via scheduler
/// - Worker-mode sandbox jobs → not supported (no mechanism to inject)
pub async fn jobs_prompt_handler(
State(state): State<Arc<GatewayState>>,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let prompt_queue = state.prompt_queue.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Claude Code not configured".to_string(),
))?;
let job_id: uuid::Uuid = id
.parse()
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
@@ -412,17 +480,57 @@ pub async fn jobs_prompt_handler(
let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false);
let prompt = crate::orchestrator::api::PendingPrompt { content, done };
// Try sandbox job path: check if we have a sandbox record for this ID.
if let Some(ref s) = state.store
&& let Ok(Some(_)) = s.get_sandbox_job(job_id).await
{
let mut queue = prompt_queue.lock().await;
queue.entry(job_id).or_default().push_back(prompt);
// It's a sandbox job. Check if Claude Code mode.
let mode = s.get_sandbox_job_mode(job_id).await.ok().flatten();
if mode.as_deref() == Some("claude_code") {
let prompt_queue = state.prompt_queue.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Claude Code not configured".to_string(),
))?;
let prompt = crate::orchestrator::api::PendingPrompt { content, done };
{
let mut queue = prompt_queue.lock().await;
queue.entry(job_id).or_default().push_back(prompt);
}
return Ok(Json(serde_json::json!({
"status": "queued",
"job_id": job_id.to_string(),
})));
} else {
return Err((
StatusCode::NOT_IMPLEMENTED,
"Follow-up prompts are not supported for worker-mode sandbox jobs".to_string(),
));
}
}
Ok(Json(serde_json::json!({
"status": "queued",
"job_id": job_id.to_string(),
})))
// Try agent job path: send via scheduler.
let slot = state.scheduler.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Agent job prompts require the scheduler to be configured".to_string(),
))?;
let scheduler_guard = slot.read().await;
if let Some(ref scheduler) = *scheduler_guard
&& scheduler.is_running(job_id).await
{
scheduler
.send_message(job_id, content)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
return Ok(Json(serde_json::json!({
"status": "sent",
"job_id": job_id.to_string(),
})));
}
Err((
StatusCode::NOT_FOUND,
"Job not found or not running".to_string(),
))
}
/// Load persisted job events for a job (for history replay on page open).
+3 -3
View File
@@ -159,10 +159,10 @@ pub async fn memory_search_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let hits: Vec<SearchHit> = results
.iter()
.into_iter()
.map(|r| SearchHit {
path: r.document_id.to_string(),
content: r.content.clone(),
path: r.document_path,
content: r.content,
score: r.score as f64,
})
.collect();
+10 -1
View File
@@ -147,6 +147,10 @@ pub async fn routines_trigger_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
if routine.user_id != state.user_id {
return Err((StatusCode::FORBIDDEN, "Access denied".to_string()));
}
// Send the routine prompt through the message pipeline as a manual trigger.
let prompt = match &routine.action {
crate::agent::routine::RoutineAction::Lightweight { prompt, .. } => prompt.clone(),
@@ -156,7 +160,12 @@ pub async fn routines_trigger_handler(
};
let content = format!("[routine:{}] {}", routine.name, prompt);
let msg = IncomingMessage::new("gateway", &state.user_id, content);
let thread_id = format!(
"routine-{}-{}",
routine_id,
chrono::Utc::now().timestamp_millis()
);
let msg = IncomingMessage::new("gateway", &state.user_id, content).with_thread(thread_id);
let tx_guard = state.msg_tx.read().await;
let tx = tx_guard.as_ref().ok_or((
+8 -1
View File
@@ -148,7 +148,14 @@ pub async fn skills_install_handler(
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
} else if let Some(ref catalog) = state.skill_catalog {
let url = crate::skills::catalog::skill_download_url(catalog.registry_url(), &req.name);
// Prefer slug (e.g. "owner/skill-name") over display name for the
// download URL, since the registry endpoint expects a slug.
let download_key = req
.slug
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&req.name);
let url = crate::skills::catalog::skill_download_url(catalog.registry_url(), download_key);
crate::tools::builtin::skill_tools::fetch_skill_content(&url)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
+10 -3
View File
@@ -84,6 +84,7 @@ impl GatewayChannel {
store: None,
job_manager: None,
prompt_queue: None,
scheduler: None,
user_id: config.user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
@@ -94,7 +95,6 @@ impl GatewayChannel {
registry_entries: Vec::new(),
cost_guard: None,
startup_time: std::time::Instant::now(),
restart_requested: std::sync::atomic::AtomicBool::new(false),
});
Self {
@@ -108,7 +108,8 @@ impl GatewayChannel {
fn rebuild_state(&mut self, mutate: impl FnOnce(&mut GatewayState)) {
let mut new_state = GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
sse: SseManager::new(),
// Preserve the existing broadcast channel so sender handles remain valid.
sse: SseManager::from_sender(self.state.sse.sender()),
workspace: self.state.workspace.clone(),
session_manager: self.state.session_manager.clone(),
log_broadcaster: self.state.log_broadcaster.clone(),
@@ -118,6 +119,7 @@ impl GatewayChannel {
store: self.state.store.clone(),
job_manager: self.state.job_manager.clone(),
prompt_queue: self.state.prompt_queue.clone(),
scheduler: self.state.scheduler.clone(),
user_id: self.state.user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: self.state.ws_tracker.clone(),
@@ -128,7 +130,6 @@ impl GatewayChannel {
registry_entries: self.state.registry_entries.clone(),
cost_guard: self.state.cost_guard.clone(),
startup_time: self.state.startup_time,
restart_requested: std::sync::atomic::AtomicBool::new(false),
};
mutate(&mut new_state);
self.state = Arc::new(new_state);
@@ -198,6 +199,12 @@ impl GatewayChannel {
self
}
/// Inject the scheduler for sending follow-up messages to agent jobs.
pub fn with_scheduler(mut self, slot: crate::tools::builtin::SchedulerSlot) -> Self {
self.rebuild_state(|s| s.scheduler = Some(slot));
self
}
/// Inject the skill registry for skill management API.
pub fn with_skill_registry(mut self, sr: Arc<std::sync::RwLock<SkillRegistry>>) -> Self {
self.rebuild_state(|s| s.skill_registry = Some(sr));
+52 -38
View File
@@ -156,6 +156,8 @@ pub struct GatewayState {
pub skill_registry: Option<Arc<std::sync::RwLock<crate::skills::SkillRegistry>>>,
/// Skill catalog for searching the ClawHub registry.
pub skill_catalog: Option<Arc<crate::skills::catalog::SkillCatalog>>,
/// Scheduler for sending follow-up messages to running agent jobs.
pub scheduler: Option<crate::tools::builtin::SchedulerSlot>,
/// Rate limiter for chat endpoints (30 messages per 60 seconds).
pub chat_rate_limiter: RateLimiter,
/// Registry catalog entries for the available extensions API.
@@ -165,8 +167,6 @@ pub struct GatewayState {
pub cost_guard: Option<Arc<crate::agent::cost_guard::CostGuard>>,
/// Server startup time for uptime calculation.
pub startup_time: std::time::Instant,
/// Flag set when a restart has been requested via the API.
pub restart_requested: std::sync::atomic::AtomicBool,
}
/// Start the gateway HTTP server.
@@ -247,8 +247,6 @@ pub async fn start_server(
"/api/extensions/{name}/setup",
get(extensions_setup_handler).post(extensions_setup_submit_handler),
)
// Gateway management
.route("/api/gateway/restart", post(gateway_restart_handler))
// Pairing
.route("/api/pairing/{channel}", get(pairing_list_handler))
.route(
@@ -1218,8 +1216,8 @@ async fn extensions_list_handler(
} else if !ext.authenticated {
// No credentials configured yet.
"installed".to_string()
} else if ext.active && ext.name == "telegram" {
// Telegram: check pairing status (end-to-end setup via web UI).
} else if ext.active {
// Check pairing status for active channels.
let has_paired = pairing_store
.read_allow_from(&ext.name)
.map(|list| !list.is_empty())
@@ -1230,7 +1228,7 @@ async fn extensions_list_handler(
"pairing".to_string()
}
} else {
// Authenticated but not fully active (or non-Telegram).
// Authenticated but not yet active.
"configured".to_string()
})
} else {
@@ -1246,6 +1244,7 @@ async fn extensions_list_handler(
active: ext.active,
tools: ext.tools,
needs_setup: ext.needs_setup,
has_auth: ext.has_auth,
activation_status,
activation_error: ext.activation_error,
}
@@ -1315,7 +1314,37 @@ async fn extensions_install_handler(
.install(&req.name, req.url.as_deref(), kind_hint)
.await
{
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
Ok(result) => {
let mut resp = ActionResponse::ok(result.message);
// Auto-activate WASM tools after install (install = active).
if result.kind == crate::extensions::ExtensionKind::WasmTool {
if let Err(e) = ext_mgr.activate(&req.name).await {
tracing::debug!(
extension = %req.name,
error = %e,
"Auto-activation after install failed"
);
}
// Check auth after activation. This may initiate OAuth both for scope
// expansion and for first-time auth when credentials are already
// configured (e.g., built-in providers). We only surface an auth_url
// when the extension reports it is awaiting authorization.
match ext_mgr.auth(&req.name, None).await {
Ok(auth_result)
if auth_result.auth_url.is_some()
&& auth_result.status == "awaiting_authorization" =>
{
// Scope expansion or initial OAuth: user needs to authorize
resp.auth_url = auth_result.auth_url;
}
_ => {}
}
}
Ok(Json(resp))
}
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
}
}
@@ -1330,7 +1359,20 @@ async fn extensions_activate_handler(
))?;
match ext_mgr.activate(&name).await {
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
Ok(result) => {
// Activation loaded the WASM module. Check if the tool needs
// OAuth scope expansion (e.g., adding google-docs when gmail
// already has a token but missing the documents scope).
// Initial OAuth setup is triggered via save_setup_secrets.
let mut resp = ActionResponse::ok(result.message);
if let Ok(auth_result) = ext_mgr.auth(&name, None).await
&& auth_result.auth_url.is_some()
&& auth_result.status == "awaiting_authorization"
{
resp.auth_url = auth_result.auth_url;
}
Ok(Json(resp))
}
Err(activate_err) => {
let err_str = activate_err.to_string();
let needs_auth = err_str.contains("authentication")
@@ -1552,41 +1594,13 @@ async fn extensions_setup_submit_handler(
Ok(result) => {
let mut resp = ActionResponse::ok(result.message);
resp.activated = Some(result.activated);
if !result.activated {
resp.needs_restart = Some(true);
}
resp.auth_url = result.auth_url;
Ok(Json(resp))
}
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
}
}
// --- Gateway management handlers ---
async fn gateway_restart_handler(State(state): State<Arc<GatewayState>>) -> Json<ActionResponse> {
// Idempotency guard: only allow one restart at a time.
if state
.restart_requested
.compare_exchange(
false,
true,
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
)
.is_err()
{
return Json(ActionResponse::ok("Restart already in progress"));
}
// Take the shutdown sender and trigger graceful shutdown.
if let Some(tx) = state.shutdown_tx.write().await.take() {
let _ = tx.send(());
tracing::info!("Gateway restart requested via API");
}
Json(ActionResponse::ok("Restarting..."))
}
// --- Pairing handlers ---
async fn pairing_list_handler(
+17
View File
@@ -36,6 +36,23 @@ impl SseManager {
}
}
/// Create an SSE manager that reuses an existing broadcast sender.
///
/// This preserves the broadcast channel across `rebuild_state` calls so
/// that sender handles captured by other components remain valid.
///
/// **Important:** The connection counter is reset to zero. This method must
/// only be called before the server starts accepting connections (i.e.,
/// during startup wiring). Calling it after connections are established
/// will break connection tracking and allow exceeding `MAX_CONNECTIONS`.
pub fn from_sender(tx: broadcast::Sender<SseEvent>) -> Self {
Self {
tx,
connection_count: Arc::new(AtomicU64::new(0)),
max_connections: MAX_CONNECTIONS,
}
}
/// Broadcast an event to all connected clients.
pub fn broadcast(&self, event: SseEvent) {
// Ignore send errors (no receivers is fine)
+61 -95
View File
@@ -228,7 +228,13 @@ function connectSSE() {
eventSource.addEventListener('auth_completed', (e) => {
const data = JSON.parse(e.data);
removeAuthCard(data.extension_name);
showToast(data.message, 'success');
if (data.success) {
showToast(data.message, 'success');
} else {
showToast(data.message, 'error');
}
// Refresh extensions list so status indicators update
if (currentTab === 'extensions') loadExtensions();
enableChatInput();
});
@@ -1760,6 +1766,11 @@ function renderAvailableExtensionCard(entry) {
}).then(function(res) {
if (res.success) {
showToast('Installed ' + entry.display_name, 'success');
// OAuth popup if auth started during install (builtin creds)
if (res.auth_url) {
showToast('Opening authentication for ' + entry.display_name, 'info');
window.open(res.auth_url, '_blank', 'width=600,height=700');
}
loadExtensions();
// Auto-open configure for WASM channels
if (entry.kind === 'wasm_channel') {
@@ -1931,14 +1942,6 @@ function renderExtensionCard(ext) {
card.appendChild(errorDiv);
}
// Show "coming soon" note for non-Telegram channels that are configured but not fully supported yet
if (ext.kind === 'wasm_channel' && ext.name !== 'telegram'
&& (ext.activation_status === 'configured' || ext.active)) {
const noteDiv = document.createElement('div');
noteDiv.className = 'ext-note';
noteDiv.textContent = 'Full integration coming soon. Use the CLI to complete setup.';
card.appendChild(noteDiv);
}
const actions = document.createElement('div');
actions.className = 'ext-actions';
@@ -1969,24 +1972,25 @@ function renderExtensionCard(ext) {
actions.appendChild(setupBtn);
}
} else {
// Non-WASM-channel extensions: original behavior
if (!ext.active) {
// WASM tools / MCP servers
const activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = ext.active ? 'Active' : 'Installed';
actions.appendChild(activeLabel);
// MCP servers may be installed but inactive — show Activate button
if (ext.kind === 'mcp_server' && !ext.active) {
const activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = 'Activate';
activateBtn.addEventListener('click', () => activateExtension(ext.name));
actions.appendChild(activateBtn);
} else {
const activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = 'Active';
actions.appendChild(activeLabel);
}
if (ext.needs_setup) {
if (ext.needs_setup || ext.has_auth) {
const configBtn = document.createElement('button');
configBtn.className = 'btn-ext configure';
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Setup';
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
actions.appendChild(configBtn);
}
@@ -2016,6 +2020,11 @@ function activateExtension(name) {
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' })
.then((res) => {
if (res.success) {
// Even on success, the tool may need OAuth (e.g., WASM loaded but no token yet)
if (res.auth_url) {
showToast('Opening authentication for ' + name, 'info');
window.open(res.auth_url, '_blank', 'width=600,height=700');
}
loadExtensions();
return;
}
@@ -2166,12 +2175,14 @@ function submitConfigureModal(name, fields) {
.then((res) => {
closeConfigureModal();
if (res.success) {
if (res.activated) {
if (res.auth_url) {
// OAuth flow started — open consent popup
showToast('Opening OAuth authorization for ' + name, 'info');
window.open(res.auth_url, '_blank', 'width=600,height=700');
} else if (res.activated) {
showToast('Configured and activated ' + name, 'success');
} else if (res.needs_restart) {
showToast('Configured ' + name + '. Use Reconfigure to re-enter credentials and activate.', 'info');
} else {
showToast(res.message, 'success');
showToast(res.message || 'Configuration saved but activation failed', 'warning');
}
} else {
showToast(res.message || 'Configuration failed', 'error');
@@ -2235,7 +2246,7 @@ function approvePairing(channel, code, container) {
}).then(res => {
if (res.success) {
showToast('Pairing approved', 'success');
loadPairingRequests(channel, container);
loadExtensions();
} else {
showToast(res.message || 'Approve failed', 'error');
}
@@ -2258,53 +2269,6 @@ function stopPairingPoll() {
}
}
// --- Gateway restart ---
function restartGateway() {
if (!confirm('Restart IronClaw gateway? Active connections will be dropped.')) return;
apiFetch('/api/gateway/restart', { method: 'POST' })
.then(function() {
showRestartOverlay();
})
.catch(function() {
showRestartOverlay();
});
}
function showRestartOverlay() {
var overlay = document.createElement('div');
overlay.className = 'restart-overlay';
overlay.innerHTML = '<div class="restart-message">'
+ '<div class="restart-spinner"></div>'
+ '<h2>Restarting IronClaw...</h2>'
+ '<p>Waiting for server to come back online</p>'
+ '</div>';
document.body.appendChild(overlay);
var pollCount = 0;
var pollTimer = setInterval(function() {
pollCount++;
if (pollCount > 30) { // 60 seconds
clearInterval(pollTimer);
overlay.querySelector('h2').textContent = 'Restart timed out';
overlay.querySelector('p').textContent = 'Server did not come back within 60 seconds. Check logs.';
overlay.querySelector('.restart-spinner').style.display = 'none';
return;
}
fetch('/api/gateway/status', {
headers: { 'Authorization': 'Bearer ' + token },
})
.then(function(r) {
if (r.ok) {
clearInterval(pollTimer);
window.location.reload();
}
})
.catch(function() { /* still restarting */ });
}, 2000);
}
// --- WASM channel stepper ---
function renderWasmChannelStepper(ext) {
@@ -2312,23 +2276,17 @@ function renderWasmChannelStepper(ext) {
stepper.className = 'ext-stepper';
var status = ext.activation_status || 'installed';
var isTelegram = ext.name === 'telegram';
// Telegram gets a 3-step stepper (Installed → Configured → Active/Pairing).
// Other channels only get 2 steps (Installed → Configured) since full
// integration isn't available in the web UI yet.
var steps = [
{ label: 'Installed', key: 'installed' },
{ label: 'Configured', key: 'configured' },
{ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' },
];
if (isTelegram) {
steps.push({ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' });
}
var reachedIdx;
if (status === 'active') reachedIdx = isTelegram ? 2 : 1;
if (status === 'active') reachedIdx = 2;
else if (status === 'pairing') reachedIdx = 2;
else if (status === 'failed') reachedIdx = isTelegram ? 2 : 1;
else if (status === 'failed') reachedIdx = 2;
else if (status === 'configured') reachedIdx = 1;
else reachedIdx = 0;
@@ -2439,9 +2397,8 @@ function renderJobsList(jobs) {
let actionBtns = '';
if (job.state === 'pending' || job.state === 'in_progress') {
actionBtns = '<button class="btn-cancel" onclick="event.stopPropagation(); cancelJob(\'' + job.id + '\')">Cancel</button>';
} else if (job.state === 'failed' || job.state === 'interrupted') {
actionBtns = '<button class="btn-restart" onclick="event.stopPropagation(); restartJob(\'' + job.id + '\')">Restart</button>';
}
// Retry is only shown in the detail view where can_restart is available.
return '<tr class="job-row" onclick="openJobDetail(\'' + job.id + '\')">'
+ '<td title="' + escapeHtml(job.id) + '">' + shortId + '</td>'
@@ -2508,8 +2465,8 @@ function renderJobDetail(job) {
+ '<h2>' + escapeHtml(job.title) + '</h2>'
+ '<span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span>';
if (job.state === 'failed' || job.state === 'interrupted') {
headerHtml += '<button class="btn-restart" onclick="restartJob(\'' + job.id + '\')">Restart</button>';
if ((job.state === 'failed' || job.state === 'interrupted') && job.can_restart === true) {
headerHtml += '<button class="btn-restart" onclick="restartJob(\'' + job.id + '\')">Retry</button>';
}
if (job.browse_url) {
headerHtml += '<a class="btn-browse" href="' + escapeHtml(job.browse_url) + '" target="_blank">Browse Files</a>';
@@ -2756,7 +2713,7 @@ function renderJobActivity(container, job) {
activityCurrentJobId = job ? job.id : null;
activityRenderedLiveIndex = 0;
container.innerHTML = '<div class="activity-toolbar">'
let html = '<div class="activity-toolbar">'
+ '<select id="activity-type-filter">'
+ '<option value="all">All Events</option>'
+ '<option value="message">Messages</option>'
@@ -2765,12 +2722,17 @@ function renderJobActivity(container, job) {
+ '</select>'
+ '<label class="logs-checkbox"><input type="checkbox" id="activity-autoscroll" checked> Auto-scroll</label>'
+ '</div>'
+ '<div class="activity-terminal" id="activity-terminal"></div>'
+ '<div class="activity-input-bar" id="activity-input-bar">'
+ '<input type="text" id="activity-prompt-input" placeholder="Send follow-up prompt..." />'
+ '<button id="activity-send-btn">Send</button>'
+ '<button id="activity-done-btn" title="Signal done">Done</button>'
+ '</div>';
+ '<div class="activity-terminal" id="activity-terminal"></div>';
if (job && job.can_prompt === true) {
html += '<div class="activity-input-bar" id="activity-input-bar">'
+ '<input type="text" id="activity-prompt-input" placeholder="Send follow-up prompt..." />'
+ '<button id="activity-send-btn">Send</button>'
+ '<button id="activity-done-btn" title="Signal done">Done</button>'
+ '</div>';
}
container.innerHTML = html;
document.getElementById('activity-type-filter').addEventListener('change', applyActivityFilter);
@@ -2779,9 +2741,9 @@ function renderJobActivity(container, job) {
const sendBtn = document.getElementById('activity-send-btn');
const doneBtn = document.getElementById('activity-done-btn');
sendBtn.addEventListener('click', () => sendJobPrompt(job.id, false));
doneBtn.addEventListener('click', () => sendJobPrompt(job.id, true));
input.addEventListener('keydown', (e) => {
if (sendBtn) sendBtn.addEventListener('click', () => sendJobPrompt(job.id, false));
if (doneBtn) doneBtn.addEventListener('click', () => sendJobPrompt(job.id, true));
if (input) input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendJobPrompt(job.id, false);
});
@@ -3068,7 +3030,11 @@ function renderRoutineDetail(routine) {
function triggerRoutine(id) {
apiFetch('/api/routines/' + id + '/trigger', { method: 'POST' })
.then(() => showToast('Routine triggered', 'success'))
.then(() => {
showToast('Routine triggered', 'success');
if (currentRoutineId === id) openRoutineDetail(id);
else loadRoutines();
})
.catch((err) => showToast('Trigger failed: ' + err.message, 'error'));
}
@@ -3618,7 +3584,7 @@ function formatTimeAgo(epochMs) {
}
function installSkill(nameOrSlug, url, btn) {
var body = { name: nameOrSlug };
var body = { name: nameOrSlug, slug: nameOrSlug };
if (url) body.url = url;
apiFetch('/api/skills/install', {
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>IronClaw</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<link rel="preconnect" href="https://fonts.googleapis.com">
+5 -38
View File
@@ -30,6 +30,7 @@ body {
background: var(--bg);
color: var(--text);
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -41,6 +42,7 @@ body {
align-items: center;
justify-content: center;
height: 100vh;
height: 100dvh;
}
.auth-card-login {
@@ -141,6 +143,7 @@ body {
display: none;
flex-direction: column;
height: 100vh;
height: 100dvh;
}
/* Tab Bar */
@@ -987,7 +990,7 @@ body {
/* Chat input */
.chat-input {
display: flex;
padding: 12px 16px;
padding: 12px 16px max(12px, env(safe-area-inset-bottom)) 16px;
gap: 8px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
@@ -1808,6 +1811,7 @@ body {
.job-files {
display: flex;
height: calc(100vh - 280px);
height: calc(100dvh - 280px);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
@@ -2312,43 +2316,6 @@ body {
margin-top: 6px;
}
/* Restart overlay */
.restart-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.restart-message {
text-align: center;
color: var(--text);
}
.restart-message h2 {
margin: 16px 0 8px;
}
.restart-message p {
color: var(--text-secondary);
}
.restart-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
+15 -5
View File
@@ -332,6 +332,15 @@ pub struct JobDetailResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub job_mode: Option<String>,
pub transitions: Vec<TransitionInfo>,
/// Whether this job can be restarted from the UI.
#[serde(default)]
pub can_restart: bool,
/// Whether follow-up prompts can be sent to this job.
#[serde(default)]
pub can_prompt: bool,
/// The kind of job: "sandbox" or "agent".
#[serde(skip_serializing_if = "Option::is_none")]
pub job_kind: Option<String>,
}
// --- Project Files ---
@@ -379,6 +388,9 @@ pub struct ExtensionInfo {
/// Whether this extension has configurable secrets (setup schema).
#[serde(default)]
pub needs_setup: bool,
/// Whether this extension has an auth configuration (OAuth or manual token).
#[serde(default)]
pub has_auth: bool,
/// WASM channel activation status: "installed", "configured", "active", "failed".
#[serde(skip_serializing_if = "Option::is_none")]
pub activation_status: Option<String>,
@@ -451,9 +463,6 @@ pub struct ActionResponse {
/// Whether the channel was successfully activated after setup.
#[serde(skip_serializing_if = "Option::is_none")]
pub activated: Option<bool>,
/// Whether a gateway restart is needed (activation failed).
#[serde(skip_serializing_if = "Option::is_none")]
pub needs_restart: Option<bool>,
}
impl ActionResponse {
@@ -465,7 +474,6 @@ impl ActionResponse {
awaiting_token: None,
instructions: None,
activated: None,
needs_restart: None,
}
}
@@ -477,7 +485,6 @@ impl ActionResponse {
awaiting_token: None,
instructions: None,
activated: None,
needs_restart: None,
}
}
}
@@ -562,6 +569,9 @@ pub struct SkillSearchResponse {
#[derive(Debug, Deserialize)]
pub struct SkillInstallRequest {
pub name: String,
/// Registry slug (e.g. "owner/skill-name"). Preferred over `name` for
/// constructing the download URL when fetching from ClawHub.
pub slug: Option<String>,
pub url: Option<String>,
pub content: Option<String>,
}
+1 -1
View File
@@ -483,6 +483,7 @@ mod tests {
store: None,
job_manager: None,
prompt_queue: None,
scheduler: None,
user_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
@@ -493,7 +494,6 @@ mod tests {
registry_entries: Vec::new(),
cost_guard: None,
startup_time: std::time::Instant::now(),
restart_requested: std::sync::atomic::AtomicBool::new(false),
}
}
}
+440 -13
View File
@@ -17,11 +17,17 @@
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
//! env vars, which take priority over built-in defaults.
use std::collections::HashMap;
use std::time::Duration;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngCore;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use crate::secrets::{CreateSecretParams, SecretsStore};
// ── Built-in credentials ────────────────────────────────────────────────
pub struct OAuthCredentials {
@@ -121,6 +127,9 @@ pub enum OAuthCallbackError {
#[error("Timed out waiting for authorization")]
Timeout,
#[error("CSRF state mismatch: expected {expected}, got {actual}")]
StateMismatch { expected: String, actual: String },
#[error("IO error: {0}")]
Io(String),
}
@@ -177,16 +186,22 @@ pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError>
/// extracts the value of `param_name` (e.g., "code" or "token"), and shows a branded
/// landing page using `display_name` (e.g., "Google", "Notion", "NEAR AI").
///
/// When `expected_state` is `Some`, the callback's `state` query parameter is validated
/// against it to prevent CSRF attacks. If the state doesn't match, the callback is
/// rejected with an error page.
///
/// Times out after 5 minutes.
pub async fn wait_for_callback(
listener: TcpListener,
path_prefix: &str,
param_name: &str,
display_name: &str,
expected_state: Option<&str>,
) -> Result<String, OAuthCallbackError> {
let path_prefix = path_prefix.to_string();
let param_name = param_name.to_string();
let display_name = display_name.to_string();
let expected_state = expected_state.map(String::from);
tokio::time::timeout(Duration::from_secs(300), async move {
loop {
@@ -221,17 +236,29 @@ pub async fn wait_for_callback(
return Err(OAuthCallbackError::Denied);
}
// Look for the target parameter
for param in query.split('&') {
let parts: Vec<&str> = param.splitn(2, '=').collect();
if parts.len() == 2 && parts[0] == param_name {
let value = urlencoding::decode(parts[1])
.unwrap_or_else(|_| parts[1].into())
.into_owned();
// Parse all query params into a map for validation
let params: HashMap<&str, String> = query
.split('&')
.filter_map(|p| {
let mut parts = p.splitn(2, '=');
let key = parts.next()?;
let val = parts.next().unwrap_or("");
Some((
key,
urlencoding::decode(val)
.unwrap_or_else(|_| val.into())
.into_owned(),
))
})
.collect();
let html = landing_html(&display_name, true);
// Validate CSRF state parameter
if let Some(ref expected) = expected_state {
let actual = params.get("state").cloned().unwrap_or_default();
if actual != *expected {
let html = landing_html(&display_name, false);
let response = format!(
"HTTP/1.1 200 OK\r\n\
"HTTP/1.1 403 Forbidden\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Connection: close\r\n\
\r\n\
@@ -239,11 +266,29 @@ pub async fn wait_for_callback(
html
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.shutdown().await;
return Ok(value);
return Err(OAuthCallbackError::StateMismatch {
expected: expected.clone(),
actual,
});
}
}
// Look for the target parameter
if let Some(value) = params.get(param_name.as_str()) {
let html = landing_html(&display_name, true);
let response = format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Connection: close\r\n\
\r\n\
{}",
html
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.shutdown().await;
return Ok(value.clone());
}
}
// Not the callback we're looking for
@@ -271,7 +316,288 @@ fn html_escape(s: &str) -> String {
out
}
/// HTML landing page shown in the browser after an OAuth redirect.
// ── Shared OAuth flow steps ─────────────────────────────────────────
/// Response from the OAuth token exchange.
pub struct OAuthTokenResponse {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: Option<u64>,
}
/// Result of building an OAuth 2.0 authorization URL.
pub struct OAuthUrlResult {
/// The full authorization URL to redirect the user to.
pub url: String,
/// PKCE code verifier (must be sent with the token exchange request).
pub code_verifier: Option<String>,
/// Random state parameter for CSRF protection (must be validated in callback).
pub state: String,
}
/// Build an OAuth 2.0 authorization URL with optional PKCE and CSRF state.
///
/// Returns an `OAuthUrlResult` containing the authorization URL, optional PKCE
/// code verifier, and a random `state` parameter for CSRF protection. The caller
/// must validate the `state` value in the callback before exchanging the code.
pub fn build_oauth_url(
authorization_url: &str,
client_id: &str,
redirect_uri: &str,
scopes: &[String],
use_pkce: bool,
extra_params: &HashMap<String, String>,
) -> OAuthUrlResult {
// Generate PKCE verifier and challenge
let (code_verifier, code_challenge) = if use_pkce {
let mut verifier_bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut verifier_bytes);
let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
(Some(verifier), Some(challenge))
} else {
(None, None)
};
// Generate random state for CSRF protection
let mut state_bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut state_bytes);
let state = URL_SAFE_NO_PAD.encode(state_bytes);
// Build authorization URL
let mut auth_url = format!(
"{}?client_id={}&response_type=code&redirect_uri={}&state={}",
authorization_url,
urlencoding::encode(client_id),
urlencoding::encode(redirect_uri),
urlencoding::encode(&state),
);
if !scopes.is_empty() {
auth_url.push_str(&format!(
"&scope={}",
urlencoding::encode(&scopes.join(" "))
));
}
if let Some(ref challenge) = code_challenge {
auth_url.push_str(&format!(
"&code_challenge={}&code_challenge_method=S256",
challenge
));
}
for (key, value) in extra_params {
auth_url.push_str(&format!(
"&{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
));
}
OAuthUrlResult {
url: auth_url,
code_verifier,
state,
}
}
/// Exchange an OAuth authorization code for tokens.
///
/// POSTs to `token_url` with the authorization code and optional PKCE verifier.
/// If `client_secret` is provided, uses HTTP Basic auth; otherwise includes
/// `client_id` in the form body (for public clients).
pub async fn exchange_oauth_code(
token_url: &str,
client_id: &str,
client_secret: Option<&str>,
code: &str,
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
let client = reqwest::Client::new();
let mut token_params = vec![
("grant_type", "authorization_code".to_string()),
("code", code.to_string()),
("redirect_uri", redirect_uri.to_string()),
];
if let Some(verifier) = code_verifier {
token_params.push(("code_verifier", verifier.to_string()));
}
let mut request = client.post(token_url);
if let Some(secret) = client_secret {
request = request.basic_auth(client_id, Some(secret));
} else {
token_params.push(("client_id", client_id.to_string()));
}
let token_response = request
.form(&token_params)
.send()
.await
.map_err(|e| OAuthCallbackError::Io(format!("Token exchange request failed: {}", e)))?;
if !token_response.status().is_success() {
let status = token_response.status();
let body = token_response.text().await.unwrap_or_default();
return Err(OAuthCallbackError::Io(format!(
"Token exchange failed: {} - {}",
status, body
)));
}
let token_data: serde_json::Value = token_response
.json()
.await
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse token response: {}", e)))?;
let access_token = token_data
.get(access_token_field)
.and_then(|v| v.as_str())
.ok_or_else(|| {
// Log only the field names present, not values (which may contain tokens)
let fields: Vec<&str> = token_data
.as_object()
.map(|o| o.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
OAuthCallbackError::Io(format!(
"No '{}' field in token response (fields present: {:?})",
access_token_field, fields
))
})?
.to_string();
let refresh_token = token_data
.get("refresh_token")
.and_then(|v| v.as_str())
.map(String::from);
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64());
Ok(OAuthTokenResponse {
access_token,
refresh_token,
expires_in,
})
}
/// Store OAuth tokens (access + refresh) in the secrets store.
///
/// Also stores the granted scopes as `{secret_name}_scopes` so that scope
/// expansion can be detected on subsequent activations.
#[allow(clippy::too_many_arguments)]
pub async fn store_oauth_tokens(
store: &(dyn SecretsStore + Send + Sync),
user_id: &str,
secret_name: &str,
provider: Option<&str>,
access_token: &str,
refresh_token: Option<&str>,
expires_in: Option<u64>,
scopes: &[String],
) -> Result<(), OAuthCallbackError> {
let mut params = CreateSecretParams::new(secret_name, access_token);
if let Some(prov) = provider {
params = params.with_provider(prov);
}
if let Some(secs) = expires_in {
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(secs as i64);
params = params.with_expiry(expires_at);
}
store
.create(user_id, params)
.await
.map_err(|e| OAuthCallbackError::Io(format!("Failed to save token: {}", e)))?;
// Store refresh token separately (no expiry, it's long-lived)
if let Some(rt) = refresh_token {
let refresh_name = format!("{}_refresh_token", secret_name);
let mut refresh_params = CreateSecretParams::new(&refresh_name, rt);
if let Some(prov) = provider {
refresh_params = refresh_params.with_provider(prov);
}
store
.create(user_id, refresh_params)
.await
.map_err(|e| OAuthCallbackError::Io(format!("Failed to save refresh token: {}", e)))?;
}
// Store granted scopes for scope expansion detection
if !scopes.is_empty() {
let scopes_name = format!("{}_scopes", secret_name);
let scopes_value = scopes.join(" ");
let scopes_params = CreateSecretParams::new(&scopes_name, &scopes_value);
// Best-effort: scope tracking failure shouldn't block auth
let _ = store.create(user_id, scopes_params).await;
}
Ok(())
}
/// Validate an OAuth token against a tool's validation endpoint.
///
/// Sends a request to the configured endpoint with the token as a Bearer header.
/// Returns `Ok(())` if the response status matches the expected success status,
/// or an error with details if validation fails (wrong account, expired token, etc.).
pub async fn validate_oauth_token(
token: &str,
validation: &crate::tools::wasm::ValidationEndpointSchema,
) -> Result<(), OAuthCallbackError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
let request = match validation.method.to_uppercase().as_str() {
"POST" => client.post(&validation.url),
_ => client.get(&validation.url),
};
let mut request = request.header("Authorization", format!("Bearer {}", token));
// Add custom headers from the validation schema (e.g., Notion-Version)
for (key, value) in &validation.headers {
request = request.header(key, value);
}
let response = request
.send()
.await
.map_err(|e| OAuthCallbackError::Io(format!("Validation request failed: {}", e)))?;
if response.status().as_u16() == validation.success_status {
Ok(())
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let truncated: String = if body.len() > 200 {
let mut end = 200;
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &body[..end])
} else {
body
};
Err(OAuthCallbackError::Io(format!(
"Token validation failed: HTTP {} (expected {}): {}",
status, validation.success_status, truncated
)))
}
}
// ── Landing pages ───────────────────────────────────────────────────
pub fn landing_html(provider_name: &str, success: bool) -> String {
let safe_name = html_escape(provider_name);
let (icon, heading, subtitle, accent) = if success {
@@ -512,4 +838,105 @@ mod tests {
assert!(html.contains("#ef4444")); // red accent
assert!(!html.contains("Connected"));
}
#[test]
fn test_build_oauth_url_basic() {
use std::collections::HashMap;
use crate::cli::oauth_defaults::build_oauth_url;
let result = build_oauth_url(
"https://accounts.google.com/o/oauth2/auth",
"my-client-id",
"http://localhost:9876/callback",
&["openid".to_string(), "email".to_string()],
false,
&HashMap::new(),
);
assert!(
result
.url
.starts_with("https://accounts.google.com/o/oauth2/auth?")
);
assert!(result.url.contains("client_id=my-client-id"));
assert!(result.url.contains("response_type=code"));
assert!(result.url.contains("redirect_uri="));
assert!(result.url.contains("scope=openid%20email"));
assert!(result.url.contains("state="));
assert!(result.code_verifier.is_none());
assert!(!result.state.is_empty());
}
#[test]
fn test_build_oauth_url_with_pkce() {
use std::collections::HashMap;
use crate::cli::oauth_defaults::build_oauth_url;
let result = build_oauth_url(
"https://auth.example.com/authorize",
"client-123",
"http://localhost:9876/callback",
&[],
true,
&HashMap::new(),
);
assert!(result.url.contains("code_challenge="));
assert!(result.url.contains("code_challenge_method=S256"));
assert!(result.code_verifier.is_some());
let verifier = result.code_verifier.unwrap();
assert!(!verifier.is_empty());
}
#[test]
fn test_build_oauth_url_with_extra_params() {
use std::collections::HashMap;
use crate::cli::oauth_defaults::build_oauth_url;
let mut extra = HashMap::new();
extra.insert("access_type".to_string(), "offline".to_string());
extra.insert("prompt".to_string(), "consent".to_string());
let result = build_oauth_url(
"https://auth.example.com/authorize",
"client-123",
"http://localhost:9876/callback",
&["read".to_string()],
false,
&extra,
);
assert!(result.url.contains("access_type=offline"));
assert!(result.url.contains("prompt=consent"));
}
#[test]
fn test_build_oauth_url_state_is_unique() {
use std::collections::HashMap;
use crate::cli::oauth_defaults::build_oauth_url;
let result1 = build_oauth_url(
"https://auth.example.com/authorize",
"client",
"http://localhost:9876/callback",
&[],
false,
&HashMap::new(),
);
let result2 = build_oauth_url(
"https://auth.example.com/authorize",
"client",
"http://localhost:9876/callback",
&[],
false,
&HashMap::new(),
);
// State should be different each time (random)
assert_ne!(result1.state, result2.state);
}
}
+58 -184
View File
@@ -782,11 +782,7 @@ async fn auth_tool_oauth(
auth: &crate::tools::wasm::AuthCapabilitySchema,
oauth: &crate::tools::wasm::OAuthConfigSchema,
) -> anyhow::Result<()> {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngCore;
use sha2::{Digest, Sha256};
use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT};
use crate::cli::oauth_defaults;
let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name);
@@ -827,142 +823,69 @@ async fn auth_tool_oauth(
println!();
let listener = oauth_defaults::bind_callback_listener().await?;
let redirect_uri = format!("http://localhost:{}/callback", OAUTH_CALLBACK_PORT);
let redirect_uri = format!("{}/callback", oauth_defaults::callback_url());
// Generate PKCE verifier and challenge
let (code_verifier, code_challenge) = if oauth.use_pkce {
let mut verifier_bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut verifier_bytes);
let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
(Some(verifier), Some(challenge))
} else {
(None, None)
};
// Build authorization URL
let mut auth_url = format!(
"{}?client_id={}&response_type=code&redirect_uri={}",
oauth.authorization_url,
urlencoding::encode(&client_id),
urlencoding::encode(&redirect_uri)
// Build authorization URL with PKCE and CSRF state
let oauth_result = oauth_defaults::build_oauth_url(
&oauth.authorization_url,
&client_id,
&redirect_uri,
&oauth.scopes,
oauth.use_pkce,
&oauth.extra_params,
);
if !oauth.scopes.is_empty() {
auth_url.push_str(&format!(
"&scope={}",
urlencoding::encode(&oauth.scopes.join(" "))
));
}
if let Some(ref challenge) = code_challenge {
auth_url.push_str(&format!(
"&code_challenge={}&code_challenge_method=S256",
challenge
));
}
// Add extra params
for (key, value) in &oauth.extra_params {
auth_url.push_str(&format!(
"&{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
));
}
let code_verifier = oauth_result.code_verifier;
println!(" Opening browser for {} login...", display_name);
println!();
if let Err(e) = open::that(&auth_url) {
if let Err(e) = open::that(&oauth_result.url) {
println!(" Could not open browser: {}", e);
println!(" Please open this URL manually:");
println!(" {}", auth_url);
println!(" {}", oauth_result.url);
}
println!(" Waiting for authorization...");
let code =
oauth_defaults::wait_for_callback(listener, "/callback", "code", display_name).await?;
let code = oauth_defaults::wait_for_callback(
listener,
"/callback",
"code",
display_name,
Some(&oauth_result.state),
)
.await?;
println!();
println!(" Exchanging code for token...");
// Exchange code for token
let client = reqwest::Client::new();
let mut token_params = vec![
("grant_type", "authorization_code".to_string()),
("code", code),
("redirect_uri", redirect_uri),
];
if let Some(ref verifier) = code_verifier {
token_params.push(("code_verifier", verifier.to_string()));
}
// Build token request
let mut request = client.post(&oauth.token_url);
// Use Basic auth if client_secret is provided, otherwise include client_id in body
if let Some(ref secret) = client_secret {
request = request.basic_auth(&client_id, Some(secret));
} else {
token_params.push(("client_id", client_id));
}
let token_response = request.form(&token_params).send().await?;
if !token_response.status().is_success() {
let status = token_response.status();
let body = token_response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"Token exchange failed: {} - {}",
status,
body
));
}
let token_data: serde_json::Value = token_response.json().await?;
let access_token = token_data
.get(&oauth.access_token_field)
.and_then(|v| v.as_str())
.ok_or_else(|| {
anyhow::anyhow!(
"No {} in token response: {:?}",
oauth.access_token_field,
token_data
)
})?;
let refresh_token = token_data.get("refresh_token").and_then(|v| v.as_str());
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64());
// Save the token (with refresh token and expiry if provided)
save_token(
store,
user_id,
auth,
access_token,
refresh_token,
expires_in,
let token_response = oauth_defaults::exchange_oauth_code(
&oauth.token_url,
&client_id,
client_secret.as_deref(),
&code,
&redirect_uri,
code_verifier.as_deref(),
&oauth.access_token_field,
)
.await?;
// Extract any additional info for display
let workspace_name = token_data
.get("workspace_name")
.and_then(|v| v.as_str())
.or_else(|| token_data.get("team_name").and_then(|v| v.as_str()));
// Save tokens (access + refresh + scopes)
oauth_defaults::store_oauth_tokens(
store,
user_id,
&auth.secret_name,
auth.provider.as_deref(),
&token_response.access_token,
token_response.refresh_token.as_deref(),
token_response.expires_in,
&oauth.scopes,
)
.await?;
println!();
println!("{} connected!", display_name);
if let Some(workspace) = workspace_name {
println!(" Workspace: {}", workspace);
}
println!();
println!(" The tool can now access the API.");
println!();
@@ -1107,46 +1030,15 @@ async fn validate_token(
validation: &crate::tools::wasm::ValidationEndpointSchema,
_secret_name: &str,
) -> anyhow::Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
// Build request based on method
let request = match validation.method.to_uppercase().as_str() {
"GET" => client.get(&validation.url),
"POST" => client.post(&validation.url),
_ => client.get(&validation.url),
};
// Add authorization header (assume Bearer for now, could be extended)
let response = request
.header("Authorization", format!("Bearer {}", token))
.header("Notion-Version", "2022-06-28") // Notion-specific, but harmless for others
.send()
.await?;
if response.status().as_u16() == validation.success_status {
Ok(())
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
Err(anyhow::anyhow!(
"HTTP {} (expected {}): {}",
status,
validation.success_status,
if body.len() > 100 {
format!("{}...", &body[..100])
} else {
body
}
))
}
crate::cli::oauth_defaults::validate_oauth_token(token, validation)
.await
.map_err(|e| anyhow::anyhow!("{}", e))
}
/// Save token to secrets store.
///
/// Optionally stores a refresh token (as `{secret_name}_refresh_token`) and
/// sets `expires_at` on the access token so the runtime can auto-refresh.
/// Delegates to the shared `store_oauth_tokens` for OAuth tokens, or stores
/// directly for manual/env-var tokens (no scopes or refresh token).
async fn save_token(
store: &(dyn SecretsStore + Send + Sync),
user_id: &str,
@@ -1155,36 +1047,18 @@ async fn save_token(
refresh_token: Option<&str>,
expires_in: Option<u64>,
) -> anyhow::Result<()> {
let mut params = CreateSecretParams::new(&auth.secret_name, token);
if let Some(ref provider) = auth.provider {
params = params.with_provider(provider);
}
if let Some(secs) = expires_in {
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(secs as i64);
params = params.with_expiry(expires_at);
}
store
.create(user_id, params)
.await
.map_err(|e| anyhow::anyhow!("Failed to save token: {}", e))?;
// Store refresh token separately (no expiry, it's long-lived)
if let Some(rt) = refresh_token {
let refresh_name = format!("{}_refresh_token", auth.secret_name);
let mut refresh_params = CreateSecretParams::new(&refresh_name, rt);
if let Some(ref provider) = auth.provider {
refresh_params = refresh_params.with_provider(provider);
}
store
.create(user_id, refresh_params)
.await
.map_err(|e| anyhow::anyhow!("Failed to save refresh token: {}", e))?;
}
Ok(())
crate::cli::oauth_defaults::store_oauth_tokens(
store,
user_id,
&auth.secret_name,
auth.provider.as_deref(),
token,
refresh_token,
expires_in,
&[], // No scopes for manual/env-var tokens
)
.await
.map_err(|e| anyhow::anyhow!("{}", e))
}
/// Print success message.
+18 -10
View File
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::path::PathBuf;
use secrecy::SecretString;
@@ -18,8 +19,9 @@ pub struct ChannelsConfig {
pub wasm_channels_dir: std::path::PathBuf,
/// Whether WASM channels are enabled.
pub wasm_channels_enabled: bool,
/// Telegram owner user ID. When set, the bot only responds to this user.
pub telegram_owner_id: Option<i64>,
/// Per-channel owner user IDs. When set, the channel only responds to this user.
/// Key: channel name (e.g., "telegram"), Value: owner user ID.
pub wasm_channel_owner_ids: HashMap<String, i64>,
}
#[derive(Debug, Clone)]
@@ -180,14 +182,20 @@ impl ChannelsConfig {
.map(PathBuf::from)
.unwrap_or_else(default_channels_dir),
wasm_channels_enabled: parse_bool_env("WASM_CHANNELS_ENABLED", true)?,
telegram_owner_id: optional_env("TELEGRAM_OWNER_ID")?
.map(|s| s.parse())
.transpose()
.map_err(|e: std::num::ParseIntError| ConfigError::InvalidValue {
key: "TELEGRAM_OWNER_ID".to_string(),
message: format!("must be an integer: {e}"),
})?
.or(settings.channels.telegram_owner_id),
wasm_channel_owner_ids: {
let mut ids = settings.channels.wasm_channel_owner_ids.clone();
// Backwards compat: TELEGRAM_OWNER_ID env var
if let Some(id_str) = optional_env("TELEGRAM_OWNER_ID")? {
let id: i64 = id_str.parse().map_err(|e: std::num::ParseIntError| {
ConfigError::InvalidValue {
key: "TELEGRAM_OWNER_ID".to_string(),
message: format!("must be an integer: {e}"),
}
})?;
ids.insert("telegram".to_string(), id);
}
ids
},
})
}
}
+24
View File
@@ -213,6 +213,30 @@ impl JobStore for LibSqlBackend {
Ok(jobs)
}
async fn get_agent_job_failure_reason(
&self,
id: Uuid,
) -> Result<Option<String>, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
.query(
"SELECT failure_reason FROM agent_jobs WHERE id = ?1",
[id.to_string()],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
if let Some(row) = rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
Ok(get_opt_text(&row, 0))
} else {
Ok(None)
}
}
async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
+6 -4
View File
@@ -515,7 +515,7 @@ impl WorkspaceStore for LibSqlBackend {
let mut rows = conn
.query(
r#"
SELECT c.id, c.document_id, c.content
SELECT c.id, c.document_id, d.path, c.content
FROM memory_chunks_fts fts
JOIN memory_chunks c ON c._rowid = fts.rowid
JOIN memory_documents d ON d.id = c.document_id
@@ -542,7 +542,8 @@ impl WorkspaceStore for LibSqlBackend {
results.push(RankedResult {
chunk_id: get_text(&row, 0).parse().unwrap_or_default(),
document_id: get_text(&row, 1).parse().unwrap_or_default(),
content: get_text(&row, 2),
document_path: get_text(&row, 2),
content: get_text(&row, 3),
rank: results.len() as u32 + 1,
});
}
@@ -563,7 +564,7 @@ impl WorkspaceStore for LibSqlBackend {
let mut rows = conn
.query(
r#"
SELECT c.id, c.document_id, c.content
SELECT c.id, c.document_id, d.path, c.content
FROM vector_top_k('idx_memory_chunks_embedding', vector(?1), ?2) AS top_k
JOIN memory_chunks c ON c._rowid = top_k.id
JOIN memory_documents d ON d.id = c.document_id
@@ -587,7 +588,8 @@ impl WorkspaceStore for LibSqlBackend {
results.push(RankedResult {
chunk_id: get_text(&row, 0).parse().unwrap_or_default(),
document_id: get_text(&row, 1).parse().unwrap_or_default(),
content: get_text(&row, 2),
document_path: get_text(&row, 2),
content: get_text(&row, 3),
rank: results.len() as u32 + 1,
});
}
+3
View File
@@ -177,6 +177,9 @@ pub trait JobStore: Send + Sync {
async fn get_stuck_jobs(&self) -> Result<Vec<Uuid>, DatabaseError>;
async fn list_agent_jobs(&self) -> Result<Vec<AgentJobRecord>, DatabaseError>;
async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError>;
/// Get the failure reason for a single agent job (O(1) lookup).
async fn get_agent_job_failure_reason(&self, id: Uuid)
-> Result<Option<String>, DatabaseError>;
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError>;
async fn get_job_actions(&self, job_id: Uuid) -> Result<Vec<ActionRecord>, DatabaseError>;
async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result<Uuid, DatabaseError>;
+7
View File
@@ -223,6 +223,13 @@ impl JobStore for PgBackend {
self.store.agent_job_summary().await
}
async fn get_agent_job_failure_reason(
&self,
id: Uuid,
) -> Result<Option<String>, DatabaseError> {
self.store.get_agent_job_failure_reason(id).await
}
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError> {
self.store.save_action(job_id, action).await
}
+3
View File
@@ -331,6 +331,9 @@ pub enum WorkspaceError {
#[error("Heartbeat error: {reason}")]
HeartbeatError { reason: String },
#[error("I/O error: {reason}")]
IoError { reason: String },
}
/// Orchestrator errors (internal API, container management).
+523 -36
View File
@@ -38,6 +38,9 @@ struct PendingAuth {
_name: String,
_kind: ExtensionKind,
created_at: std::time::Instant,
/// Background task listening for the OAuth callback.
/// Aborted when a new auth flow starts for the same extension.
task_handle: Option<tokio::task::JoinHandle<()>>,
}
/// Runtime infrastructure needed for hot-activating WASM channels.
@@ -49,7 +52,7 @@ struct ChannelRuntimeState {
wasm_channel_runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
wasm_channel_router: Arc<WasmChannelRouter>,
telegram_owner_id: Option<i64>,
wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
}
/// Result of saving setup secrets and attempting activation.
@@ -58,6 +61,8 @@ pub struct SetupResult {
pub message: String,
/// Whether the channel was successfully activated after saving secrets.
pub activated: bool,
/// OAuth authorization URL for the UI to open (if OAuth flow was started).
pub auth_url: Option<String>,
}
/// Central manager for extension lifecycle operations.
@@ -150,14 +155,14 @@ impl ExtensionManager {
wasm_channel_runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
wasm_channel_router: Arc<WasmChannelRouter>,
telegram_owner_id: Option<i64>,
wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
) {
*self.channel_runtime.write().await = Some(ChannelRuntimeState {
channel_manager,
wasm_channel_runtime,
pairing_store,
wasm_channel_router,
telegram_owner_id,
wasm_channel_owner_ids,
});
}
@@ -385,6 +390,7 @@ impl ExtensionManager {
active,
tools,
needs_setup: false,
has_auth: false,
installed: true,
activation_error: None,
});
@@ -411,6 +417,11 @@ impl ExtensionManager {
.await
.map(|e| e.display_name);
let (authenticated, needs_setup) = self.check_tool_auth_status(&name).await;
let has_auth = self
.load_tool_capabilities(&name)
.await
.and_then(|c| c.auth)
.is_some();
extensions.push(InstalledExtension {
name: name.clone(),
kind: ExtensionKind::WasmTool,
@@ -421,6 +432,7 @@ impl ExtensionManager {
active,
tools: if active { vec![name] } else { Vec::new() },
needs_setup,
has_auth,
installed: true,
activation_error: None,
});
@@ -460,6 +472,7 @@ impl ExtensionManager {
active,
tools: Vec::new(),
needs_setup,
has_auth: false,
installed: true,
activation_error,
});
@@ -497,6 +510,7 @@ impl ExtensionManager {
active: false,
tools: Vec::new(),
needs_setup: false,
has_auth: false,
installed: false,
activation_error: None,
});
@@ -1338,6 +1352,7 @@ impl ExtensionManager {
_name: name.to_string(),
_kind: ExtensionKind::McpServer,
created_at: std::time::Instant::now(),
task_handle: None,
},
);
@@ -1424,23 +1439,45 @@ impl ExtensionManager {
});
}
// Check if already authenticated
if self
// Check if already authenticated (with scope expansion detection)
let token_exists = self
.secrets
.exists(&self.user_id, &auth.secret_name)
.await
.unwrap_or(false)
{
return Ok(AuthResult {
name: name.to_string(),
kind: ExtensionKind::WasmTool,
auth_url: None,
callback_type: None,
instructions: None,
setup_url: None,
awaiting_token: false,
status: "authenticated".to_string(),
});
.unwrap_or(false);
if token_exists {
// If this tool has OAuth config, check whether new scopes are needed
let needs_reauth = if let Some(ref oauth) = auth.oauth {
let merged = self
.collect_shared_scopes(&auth.secret_name, &oauth.scopes)
.await;
let needs = self.needs_scope_expansion(&auth.secret_name, &merged).await;
tracing::debug!(
tool = name,
secret_name = %auth.secret_name,
merged_scopes = ?merged,
needs_reauth = needs,
"Scope expansion check"
);
needs
} else {
false
};
if !needs_reauth {
return Ok(AuthResult {
name: name.to_string(),
kind: ExtensionKind::WasmTool,
auth_url: None,
callback_type: None,
instructions: None,
setup_url: None,
awaiting_token: false,
status: "authenticated".to_string(),
});
}
// Fall through to OAuth branch for scope expansion
}
// If a token was provided, store it
@@ -1464,6 +1501,62 @@ impl ExtensionManager {
});
}
// OAuth flow: if the tool has OAuth config, start the browser-based flow.
// But only if credentials are available — if the tool has setup secrets
// for client_id/secret that aren't configured yet, return needs_setup.
if let Some(ref oauth) = auth.oauth {
let (setup_client_id_entry, setup_client_secret_entry) =
self.find_setup_credential_names(name).await;
// Check all required (non-optional) setup credentials before starting
// OAuth, to avoid starting a flow that will fail during token exchange
// due to missing credentials.
let mut needs_setup = false;
if let Some((ref id_name, optional)) = setup_client_id_entry
&& !optional
&& !self
.secrets
.exists(&self.user_id, id_name)
.await
.unwrap_or(false)
{
needs_setup = true;
}
if !needs_setup
&& let Some((ref secret_name, optional)) = setup_client_secret_entry
&& !optional
&& !self
.secrets
.exists(&self.user_id, secret_name)
.await
.unwrap_or(false)
{
needs_setup = true;
}
if needs_setup {
let display = auth.display_name.as_deref().unwrap_or(name);
return Ok(AuthResult {
name: name.to_string(),
kind: ExtensionKind::WasmTool,
auth_url: None,
callback_type: None,
instructions: Some(format!(
"Configure OAuth credentials for {} in the Setup tab.",
display
)),
setup_url: auth.setup_url.clone(),
awaiting_token: false,
status: "needs_setup".to_string(),
});
}
return self
.start_wasm_oauth(name, &auth, oauth)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()));
}
// Return instructions for manual token entry
let display = auth.display_name.unwrap_or_else(|| name.to_string());
let instructions = auth
@@ -1534,6 +1627,353 @@ impl ExtensionManager {
crate::tools::wasm::CapabilitiesFile::from_bytes(&cap_bytes).ok()
}
/// Collect merged OAuth scopes from all installed tools sharing the same secret_name.
///
/// When multiple tools share an OAuth provider (e.g., google-calendar and google-drive
/// both use `google_oauth_token`), we request all their scopes in a single OAuth flow
/// so one login covers everything.
async fn collect_shared_scopes(
&self,
secret_name: &str,
base_scopes: &[String],
) -> Vec<String> {
let mut all_scopes: std::collections::BTreeSet<String> =
base_scopes.iter().cloned().collect();
if let Ok(tools) = discover_tools(&self.wasm_tools_dir).await {
for tool_name in tools.keys() {
if let Some(cap) = self.load_tool_capabilities(tool_name).await
&& let Some(auth) = &cap.auth
&& auth.secret_name == secret_name
&& let Some(oauth) = &auth.oauth
{
all_scopes.extend(oauth.scopes.iter().cloned());
}
}
}
all_scopes.into_iter().collect()
}
/// Check whether the stored scopes are insufficient for the merged scopes.
async fn needs_scope_expansion(&self, secret_name: &str, merged_scopes: &[String]) -> bool {
if merged_scopes.is_empty() {
return false;
}
let scopes_key = format!("{}_scopes", secret_name);
let stored_scopes: std::collections::HashSet<String> =
match self.secrets.get_decrypted(&self.user_id, &scopes_key).await {
Ok(secret) => {
let scopes: std::collections::HashSet<String> = secret
.expose()
.split_whitespace()
.map(String::from)
.collect();
tracing::debug!(
secret_name,
stored_scopes = ?scopes,
"Loaded stored scopes for expansion check"
);
scopes
}
Err(_) => {
// No stored scopes record — this is a legacy token created before
// scope tracking. Force re-auth to ensure all required scopes are granted.
tracing::debug!(
secret_name,
"No stored scopes record, forcing re-auth for legacy token"
);
return true;
}
};
// Check if any merged scope is missing from stored scopes
merged_scopes
.iter()
.any(|scope| !stored_scopes.contains(scope))
}
/// Find the setup secret names for OAuth client_id and client_secret.
///
/// Scans `setup.required_secrets` for names containing "client_id" and "client_secret".
/// Returns `(Option<(name, optional)>, Option<(name, optional)>)`.
async fn find_setup_credential_names(
&self,
tool_name: &str,
) -> (Option<(String, bool)>, Option<(String, bool)>) {
let Some(cap) = self.load_tool_capabilities(tool_name).await else {
return (None, None);
};
let Some(setup) = &cap.setup else {
return (None, None);
};
let mut client_id_entry = None;
let mut client_secret_entry = None;
for secret in &setup.required_secrets {
let lower = secret.name.to_lowercase();
if lower.ends_with("client_id") || lower == "client_id" {
client_id_entry = Some((secret.name.clone(), secret.optional));
} else if lower.ends_with("client_secret") || lower == "client_secret" {
client_secret_entry = Some((secret.name.clone(), secret.optional));
}
}
(client_id_entry, client_secret_entry)
}
/// Resolve an OAuth credential value via: secrets store → inline → env var → builtin.
///
/// For web gateway users, the secrets store is checked first because client_id/secret
/// may have been entered via the Setup tab (stored as setup secrets).
async fn resolve_oauth_credential(
&self,
inline_value: &Option<String>,
env_var_name: &Option<String>,
builtin_value: Option<&str>,
setup_secret_name: Option<&str>,
) -> Option<String> {
// 1. Check secrets store (entered via Setup tab)
if let Some(secret_name) = setup_secret_name
&& let Ok(secret) = self.secrets.get_decrypted(&self.user_id, secret_name).await
{
let val = secret.expose();
if !val.is_empty() {
return Some(val.to_string());
}
}
// 2. Inline value from capabilities.json
if let Some(val) = inline_value {
return Some(val.clone());
}
// 3. Runtime environment variable
if let Some(env) = env_var_name
&& let Ok(val) = std::env::var(env)
{
return Some(val);
}
// 4. Built-in defaults
builtin_value.map(String::from)
}
/// Start the OAuth browser flow for a WASM tool.
///
/// Binds a callback listener, builds the authorization URL, spawns a background
/// task to wait for the callback and exchange the code, then returns the auth URL
/// immediately so the web UI can open it.
async fn start_wasm_oauth(
&self,
name: &str,
auth: &crate::tools::wasm::AuthCapabilitySchema,
oauth: &crate::tools::wasm::OAuthConfigSchema,
) -> Result<AuthResult, String> {
use crate::cli::oauth_defaults;
let builtin = oauth_defaults::builtin_credentials(&auth.secret_name);
// Find setup secret names for client_id and client_secret from capabilities.
// These are the actual names used in the Setup tab (e.g., "google_oauth_client_id"),
// which may differ from "{secret_name}_client_id".
let (setup_client_id_entry, setup_client_secret_entry) =
self.find_setup_credential_names(name).await;
let setup_client_id_name = setup_client_id_entry.map(|(n, _)| n);
let setup_client_secret_name = setup_client_secret_entry.map(|(n, _)| n);
// Resolve client_id: setup secrets → inline → env var → builtin
let client_id = self
.resolve_oauth_credential(
&oauth.client_id,
&oauth.client_id_env,
builtin.as_ref().map(|c| c.client_id),
setup_client_id_name.as_deref(),
)
.await
.ok_or_else(|| {
let env_name = oauth
.client_id_env
.as_deref()
.unwrap_or("the client_id env var");
let mut msg = format!(
"OAuth client_id not configured for '{}'. \
Enter it in the Setup tab or set {} env var",
name, env_name
);
// Only mention the Google-specific build flag for Google providers
if auth.secret_name.to_lowercase().contains("google") {
msg.push_str(", or build with IRONCLAW_GOOGLE_CLIENT_ID");
}
msg.push('.');
msg
})?;
// Resolve client_secret (optional for PKCE-only flows)
let client_secret = self
.resolve_oauth_credential(
&oauth.client_secret,
&oauth.client_secret_env,
builtin.as_ref().map(|c| c.client_secret),
setup_client_secret_name.as_deref(),
)
.await;
// Cancel any existing pending auth for this tool (frees port 9876)
{
let mut pending = self.pending_auth.write().await;
if let Some(old) = pending.remove(name)
&& let Some(handle) = old.task_handle
{
handle.abort();
}
}
// Bind callback listener
let listener = oauth_defaults::bind_callback_listener()
.await
.map_err(|e| format!("Failed to start OAuth callback listener: {}", e))?;
let redirect_uri = format!("{}/callback", oauth_defaults::callback_url());
// Merge scopes from all tools sharing this provider
let merged_scopes = self
.collect_shared_scopes(&auth.secret_name, &oauth.scopes)
.await;
// Build authorization URL with CSRF state
let oauth_result = oauth_defaults::build_oauth_url(
&oauth.authorization_url,
&client_id,
&redirect_uri,
&merged_scopes,
oauth.use_pkce,
&oauth.extra_params,
);
let auth_url = oauth_result.url.clone();
let code_verifier = oauth_result.code_verifier;
let expected_state = oauth_result.state;
// Spawn background task: wait for callback → exchange code → validate → store tokens
let display_name = auth
.display_name
.clone()
.unwrap_or_else(|| name.to_string());
let token_url = oauth.token_url.clone();
let access_token_field = oauth.access_token_field.clone();
let secret_name = auth.secret_name.clone();
let provider = auth.provider.clone();
let validation_endpoint = auth.validation_endpoint.clone();
let user_id = self.user_id.clone();
let secrets = Arc::clone(&self.secrets);
let sse_sender = self.sse_sender.read().await.clone();
let ext_name = name.to_string();
let task_handle = tokio::spawn(async move {
let result: Result<(), String> = async {
let code = oauth_defaults::wait_for_callback(
listener,
"/callback",
"code",
&display_name,
Some(&expected_state),
)
.await
.map_err(|e| e.to_string())?;
let token_response = oauth_defaults::exchange_oauth_code(
&token_url,
&client_id,
client_secret.as_deref(),
&code,
&redirect_uri,
code_verifier.as_deref(),
&access_token_field,
)
.await
.map_err(|e| e.to_string())?;
// Validate the token before storing (catches wrong account, etc.)
if let Some(ref validation) = validation_endpoint {
oauth_defaults::validate_oauth_token(&token_response.access_token, validation)
.await
.map_err(|e| e.to_string())?;
}
oauth_defaults::store_oauth_tokens(
secrets.as_ref(),
&user_id,
&secret_name,
provider.as_deref(),
&token_response.access_token,
token_response.refresh_token.as_deref(),
token_response.expires_in,
&merged_scopes,
)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
.await;
// Broadcast SSE event
let (success, message) = match result {
Ok(()) => (true, format!("{} authenticated successfully", display_name)),
Err(ref e) => (
false,
format!("{} authentication failed: {}", display_name, e),
),
};
match &result {
Ok(()) => {
tracing::info!(
tool = %ext_name,
"OAuth completed successfully"
);
}
Err(e) => {
tracing::warn!(
tool = %ext_name,
error = %e,
"WASM tool OAuth failed"
);
}
}
if let Some(ref sender) = sse_sender {
let _ = sender.send(crate::channels::web::types::SseEvent::AuthCompleted {
extension_name: ext_name,
success,
message,
});
}
});
// Store pending auth with task handle
self.pending_auth.write().await.insert(
name.to_string(),
PendingAuth {
_name: name.to_string(),
_kind: ExtensionKind::WasmTool,
created_at: std::time::Instant::now(),
task_handle: Some(task_handle),
},
);
Ok(AuthResult {
name: name.to_string(),
kind: ExtensionKind::WasmTool,
auth_url: Some(auth_url),
callback_type: Some("local".to_string()),
instructions: None,
setup_url: None,
awaiting_token: false,
status: "awaiting_authorization".to_string(),
})
}
/// Check whether a WASM tool's required setup secrets are provided.
///
/// Returns `(authenticated, needs_setup)` — same semantics as `check_channel_auth_status`.
@@ -1804,7 +2244,8 @@ impl ExtensionManager {
None
};
let loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&self.tool_registry));
let loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&self.tool_registry))
.with_secrets_store(Arc::clone(&self.secrets));
loader
.load_from_files(name, &wasm_path, cap_path_option)
.await
@@ -1871,21 +2312,18 @@ impl ExtensionManager {
channel_manager,
pairing_store,
wasm_channel_router,
telegram_owner_id,
wasm_channel_owner_ids,
) = {
let rt_guard = self.channel_runtime.read().await;
let rt = rt_guard.as_ref().ok_or_else(|| {
ExtensionError::ActivationFailed(
"WASM channel runtime not configured. Restart IronClaw to activate."
.to_string(),
)
ExtensionError::ActivationFailed("WASM channel runtime not configured".to_string())
})?;
(
Arc::clone(&rt.wasm_channel_runtime),
Arc::clone(&rt.channel_manager),
Arc::clone(&rt.pairing_store),
Arc::clone(&rt.wasm_channel_router),
rt.telegram_owner_id,
rt.wasm_channel_owner_ids.clone(),
)
};
@@ -1915,7 +2353,8 @@ impl ExtensionManager {
Arc::clone(&channel_runtime),
Arc::clone(&pairing_store),
settings_store,
);
)
.with_secrets_store(Arc::clone(&self.secrets));
let loaded = loader
.load_from_files(name, &wasm_path, cap_path_option)
.await
@@ -1954,9 +2393,7 @@ impl ExtensionManager {
);
}
if channel_name == "telegram"
&& let Some(owner_id) = telegram_owner_id
{
if let Some(&owner_id) = wasm_channel_owner_ids.get(channel_name.as_str()) {
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
}
@@ -2264,7 +2701,16 @@ impl ExtensionManager {
async fn cleanup_expired_auths(&self) {
let mut pending = self.pending_auth.write().await;
pending.retain(|_, auth| auth.created_at.elapsed() < std::time::Duration::from_secs(300));
pending.retain(|_, auth| {
let expired = auth.created_at.elapsed() >= std::time::Duration::from_secs(300);
if expired {
// Abort the background listener task to free port 9876
if let Some(ref handle) = auth.task_handle {
handle.abort();
}
}
!expired
});
}
/// Get the setup schema for an extension (secret fields and their status).
@@ -2481,16 +2927,55 @@ impl ExtensionManager {
}
}
// For tools, save and attempt auto-activation
// For tools, save and attempt auto-activation, then check auth.
if kind == ExtensionKind::WasmTool {
match self.activate_wasm_tool(name).await {
Ok(result) => {
return Ok(SetupResult {
message: format!(
// Delete existing OAuth token so auth() starts a fresh flow.
// Done AFTER activation succeeds to avoid losing tokens on failure.
// This covers Reconfigure: user wants to re-auth (switch account, update creds).
if let Some(cap) = self.load_tool_capabilities(name).await
&& let Some(ref auth_cfg) = cap.auth
&& auth_cfg.oauth.is_some()
{
let _ = self
.secrets
.delete(&self.user_id, &auth_cfg.secret_name)
.await;
let _ = self
.secrets
.delete(&self.user_id, &format!("{}_scopes", auth_cfg.secret_name))
.await;
let _ = self
.secrets
.delete(
&self.user_id,
&format!("{}_refresh_token", auth_cfg.secret_name),
)
.await;
}
// Check if auth is needed (OAuth or manual token).
// This is safe to call here — cancel-and-retry prevents port conflicts.
let mut auth_url = None;
if let Ok(auth_result) = self.auth(name, None).await {
auth_url = auth_result.auth_url;
}
let message = if auth_url.is_some() {
format!(
"Configuration saved and tool '{}' activated. Complete OAuth in your browser.",
name
)
} else {
format!(
"Configuration saved and tool '{}' activated. {}",
name, result.message
),
)
};
return Ok(SetupResult {
message,
activated: true,
auth_url,
});
}
Err(e) => {
@@ -2502,6 +2987,7 @@ impl ExtensionManager {
return Ok(SetupResult {
message: format!("Configuration saved for '{}'.", name),
activated: false,
auth_url: None,
});
}
}
@@ -2518,6 +3004,7 @@ impl ExtensionManager {
name, result.message
),
activated: true,
auth_url: None,
})
}
Err(e) => {
@@ -2525,7 +3012,7 @@ impl ExtensionManager {
tracing::warn!(
channel = name,
error = %e,
"Saved configuration but hot-activation failed, restart may be needed"
"Saved configuration but hot-activation failed"
);
self.activation_errors
.write()
@@ -2535,11 +3022,11 @@ impl ExtensionManager {
.await;
Ok(SetupResult {
message: format!(
"Configuration saved for '{}'. \
Automatic activation failed ({}), restart IronClaw to activate.",
"Configuration saved for '{}'. Activation failed: {}",
name, e
),
activated: false,
auth_url: None,
})
}
}
+3
View File
@@ -204,6 +204,9 @@ pub struct InstalledExtension {
/// Whether this extension has a setup schema (required_secrets) that can be configured.
#[serde(default)]
pub needs_setup: bool,
/// Whether this extension has an auth configuration (OAuth or manual token).
#[serde(default)]
pub has_auth: bool,
/// Whether this extension is installed locally (false = available in registry but not installed).
#[serde(default = "default_true")]
pub installed: bool,
+15
View File
@@ -821,6 +821,21 @@ impl Store {
.collect())
}
/// Get the failure reason for a single agent job.
pub async fn get_agent_job_failure_reason(
&self,
id: Uuid,
) -> Result<Option<String>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
"SELECT failure_reason FROM agent_jobs WHERE id = $1",
&[&id],
)
.await?;
Ok(row.and_then(|r| r.get::<_, Option<String>>("failure_reason")))
}
/// Summary counts for agent (non-sandbox) jobs.
pub async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError> {
let conn = self.conn().await?;
+24 -1
View File
@@ -199,6 +199,29 @@ impl NearAiChatProvider {
})?;
let status = response.status();
// Extract Retry-After header before consuming the response body.
// Supports both delay-seconds (RFC 7231 §7.1.3) and HTTP-date formats.
let retry_after_header = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| {
// Try delay-seconds first (most common from API providers)
if let Ok(secs) = v.trim().parse::<u64>() {
return Some(std::time::Duration::from_secs(secs));
}
// Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT")
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
let now = chrono::Utc::now();
let delta = dt.signed_duration_since(now);
// Use max(0) so past/present dates yield Duration::ZERO
// rather than None (which would cause an immediate retry).
return Some(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64
));
}
None
});
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: format!("Failed to read response body: {}", e),
@@ -230,7 +253,7 @@ impl NearAiChatProvider {
if status_code == 429 {
return Err(LlmError::RateLimited {
provider: "nearai_chat".to_string(),
retry_after: None,
retry_after: retry_after_header,
});
}
+1 -1
View File
@@ -347,7 +347,7 @@ impl SessionManager {
// The NEAR AI API redirects to: {frontend_callback}/auth/callback?token=X&...
let session_token =
oauth_defaults::wait_for_callback(listener, "/auth/callback", "token", "NEAR AI")
oauth_defaults::wait_for_callback(listener, "/auth/callback", "token", "NEAR AI", None)
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
+14 -20
View File
@@ -484,8 +484,6 @@ async fn async_main() -> anyhow::Result<()> {
let mut sse_sender: Option<
tokio::sync::broadcast::Sender<ironclaw::channels::web::types::SseEvent>,
> = None;
let mut gateway_state: Option<std::sync::Arc<ironclaw::channels::web::server::GatewayState>> =
None;
if let Some(ref gw_config) = config.channels.gateway {
let mut gw =
GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm));
@@ -508,6 +506,7 @@ async fn async_main() -> anyhow::Result<()> {
if let Some(ref jm) = container_job_manager {
gw = gw.with_job_manager(Arc::clone(jm));
}
gw = gw.with_scheduler(scheduler_slot.clone());
if let Some(ref sr) = components.skill_registry {
gw = gw.with_skill_registry(Arc::clone(sr));
}
@@ -542,7 +541,6 @@ async fn async_main() -> anyhow::Result<()> {
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
// creates a new SseManager, which would orphan this sender.
sse_sender = Some(gw.state().sse.sender());
gateway_state = Some(Arc::clone(gw.state()));
channel_names.push("gateway".to_string());
channels.add(Box::new(gw)).await;
@@ -618,7 +616,7 @@ async fn async_main() -> anyhow::Result<()> {
rt,
ps,
router,
config.channels.telegram_owner_id,
config.channels.wasm_channel_owner_ids.clone(),
)
.await;
tracing::info!("Channel runtime wired into extension manager for hot-activation");
@@ -649,9 +647,9 @@ async fn async_main() -> anyhow::Result<()> {
// Wire SSE sender into extension manager for broadcasting status events.
if let Some(ref ext_mgr) = components.extension_manager
&& let Some(sender) = sse_sender
&& let Some(ref sender) = sse_sender
{
ext_mgr.set_sse_sender(sender).await;
ext_mgr.set_sse_sender(sender.clone()).await;
}
let deps = AgentDeps {
@@ -667,6 +665,7 @@ async fn async_main() -> anyhow::Result<()> {
skills_config: config.skills.clone(),
hooks: components.hooks,
cost_guard: components.cost_guard,
sse_tx: sse_sender,
};
let agent = Agent::new(
@@ -700,16 +699,6 @@ async fn async_main() -> anyhow::Result<()> {
tracing::info!("Agent shutdown complete");
// Check if a restart was requested via the gateway API.
if let Some(ref gw_state) = gateway_state
&& gw_state
.restart_requested
.load(std::sync::atomic::Ordering::Relaxed)
{
eprintln!("Restarting IronClaw (exit code 75)...");
std::process::exit(75);
}
Ok(())
}
@@ -911,11 +900,14 @@ async fn setup_wasm_channels(
let pairing_store = Arc::new(PairingStore::new());
let settings_store: Option<Arc<dyn ironclaw::db::SettingsStore>> =
database.map(|db| Arc::clone(db) as Arc<dyn ironclaw::db::SettingsStore>);
let loader = WasmChannelLoader::new(
let mut loader = WasmChannelLoader::new(
Arc::clone(&runtime),
Arc::clone(&pairing_store),
settings_store,
);
if let Some(secrets) = secrets_store {
loader = loader.with_secrets_store(Arc::clone(secrets));
}
let results = match loader
.load_from_dir(&config.channels.wasm_channels_dir)
@@ -979,9 +971,11 @@ async fn setup_wasm_channels(
);
}
// Inject owner_id for Telegram so the bot only responds to the bound user.
if channel_name == "telegram"
&& let Some(owner_id) = config.channels.telegram_owner_id
// Inject owner_id if configured for this channel.
if let Some(&owner_id) = config
.channels
.wasm_channel_owner_ids
.get(channel_name.as_str())
{
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
}
+28 -15
View File
@@ -249,10 +249,10 @@ pub struct ChannelSettings {
#[serde(default)]
pub signal_group_allow_from: Option<String>,
/// Telegram owner user ID. When set, the bot only responds to this user.
/// Captured during setup by having the user message the bot.
/// Per-channel owner user IDs. When set, the channel only responds to this user.
/// Key: channel name (e.g., "telegram"), Value: owner user ID.
#[serde(default)]
pub telegram_owner_id: Option<i64>,
pub wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
/// Enabled WASM channels by name.
/// Channels not in this list but present in the channels directory will still load.
@@ -1049,28 +1049,37 @@ mod tests {
}
#[test]
fn test_telegram_owner_id_db_round_trip() {
fn test_wasm_channel_owner_ids_db_round_trip() {
let mut settings = Settings::default();
settings.channels.telegram_owner_id = Some(123456789);
settings
.channels
.wasm_channel_owner_ids
.insert("telegram".to_string(), 123456789);
let map = settings.to_db_map();
let restored = Settings::from_db_map(&map);
assert_eq!(restored.channels.telegram_owner_id, Some(123456789));
assert_eq!(
restored.channels.wasm_channel_owner_ids.get("telegram"),
Some(&123456789)
);
}
#[test]
fn test_telegram_owner_id_default_none() {
fn test_wasm_channel_owner_ids_default_empty() {
let settings = Settings::default();
assert_eq!(settings.channels.telegram_owner_id, None);
assert!(settings.channels.wasm_channel_owner_ids.is_empty());
}
#[test]
fn test_telegram_owner_id_via_set() {
fn test_wasm_channel_owner_ids_via_set() {
let mut settings = Settings::default();
settings
.set("channels.telegram_owner_id", "987654321")
.set("channels.wasm_channel_owner_ids.telegram", "987654321")
.unwrap();
assert_eq!(settings.channels.telegram_owner_id, Some(987654321));
assert_eq!(
settings.channels.wasm_channel_owner_ids.get("telegram"),
Some(&987654321)
);
}
#[test]
@@ -1406,7 +1415,11 @@ mod tests {
channels: ChannelSettings {
http_enabled: true,
http_port: Some(9090),
telegram_owner_id: Some(12345),
wasm_channel_owner_ids: {
let mut m = std::collections::HashMap::new();
m.insert("telegram".to_string(), 12345);
m
},
..Default::default()
},
heartbeat: HeartbeatSettings {
@@ -1473,9 +1486,9 @@ mod tests {
assert!(restored.channels.http_enabled, "http_enabled lost");
assert_eq!(restored.channels.http_port, Some(9090), "http_port lost");
assert_eq!(
restored.channels.telegram_owner_id,
Some(12345),
"telegram_owner_id lost"
restored.channels.wasm_channel_owner_ids.get("telegram"),
Some(&12345),
"wasm_channel_owner_ids lost"
);
assert!(restored.heartbeat.enabled, "heartbeat.enabled lost");
assert_eq!(
+2 -337
View File
@@ -1,6 +1,6 @@
//! Channel-specific setup flows.
//! Channel setup flows.
//!
//! Each channel (Telegram, HTTP, etc.) has its own setup function that:
//! Each channel (HTTP, Signal, WASM, etc.) has its own setup function that:
//! 1. Displays setup instructions
//! 2. Collects configuration (tokens, ports, etc.)
//! 3. Validates the configuration
@@ -9,9 +9,7 @@
use std::sync::Arc;
use base64::Engine;
use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use url::Url;
use uuid::Uuid;
@@ -105,261 +103,6 @@ impl SecretsContext {
}
}
/// Result of Telegram setup.
#[derive(Debug, Clone)]
pub struct TelegramSetupResult {
pub enabled: bool,
pub bot_username: Option<String>,
pub webhook_secret: Option<String>,
pub owner_id: Option<i64>,
}
/// Telegram Bot API response for getMe.
#[derive(Debug, Deserialize)]
struct TelegramGetMeResponse {
ok: bool,
result: Option<TelegramUser>,
}
#[derive(Debug, Deserialize)]
struct TelegramUser {
username: Option<String>,
#[allow(dead_code)]
first_name: String,
}
/// Telegram Bot API response for getUpdates.
#[derive(Debug, Deserialize)]
struct TelegramGetUpdatesResponse {
ok: bool,
result: Vec<TelegramUpdate>,
}
#[derive(Debug, Deserialize)]
struct TelegramUpdate {
update_id: i64,
message: Option<TelegramUpdateMessage>,
}
#[derive(Debug, Deserialize)]
struct TelegramUpdateMessage {
from: Option<TelegramUpdateUser>,
}
#[derive(Debug, Deserialize)]
struct TelegramUpdateUser {
id: i64,
first_name: String,
username: Option<String>,
}
/// Set up Telegram bot channel.
///
/// Guides the user through:
/// 1. Creating a bot with @BotFather
/// 2. Entering the bot token
/// 3. Validating the token
/// 4. Saving the token to the database
pub async fn setup_telegram(
secrets: &SecretsContext,
settings: &Settings,
) -> Result<TelegramSetupResult, ChannelSetupError> {
println!("Telegram Setup:");
println!();
print_info("To create a Telegram bot:");
print_info("1. Open Telegram and message @BotFather");
print_info("2. Send /newbot and follow the prompts");
print_info("3. Copy the bot token (looks like 123456:ABC-DEF...)");
println!();
// Check if token already exists
if secrets.secret_exists("telegram_bot_token").await {
print_info("Existing Telegram token found in database.");
if !confirm("Replace existing token?", false)? {
// Still offer to configure webhook secret and owner binding
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
let owner_id = bind_telegram_owner_flow(secrets, settings).await?;
return Ok(TelegramSetupResult {
enabled: true,
bot_username: None,
webhook_secret,
owner_id,
});
}
}
loop {
let token = secret_input("Bot token (from @BotFather)")?;
// Validate the token
print_info("Validating bot token...");
match validate_telegram_token(&token).await {
Ok(username) => {
print_success(&format!(
"Bot validated: @{}",
username.as_deref().unwrap_or("unknown")
));
// Save to database
secrets.save_secret("telegram_bot_token", &token).await?;
print_success("Token saved to database");
// Bind bot to owner's Telegram account
let owner_id = bind_telegram_owner(&token).await?;
// Offer webhook secret configuration
let webhook_secret =
setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
return Ok(TelegramSetupResult {
enabled: true,
bot_username: username,
webhook_secret,
owner_id,
});
}
Err(e) => {
print_error(&format!("Token validation failed: {}", e));
if !confirm("Try again?", true)? {
return Ok(TelegramSetupResult {
enabled: false,
bot_username: None,
webhook_secret: None,
owner_id: None,
});
}
}
}
}
}
/// Bind the bot to the owner's Telegram account by having them send a message.
///
/// Polls `getUpdates` until a message arrives, then captures the sender's user ID.
/// Returns `None` if the user declines or the flow times out.
async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, ChannelSetupError> {
println!();
print_info("Account Binding (recommended):");
print_info("Binding restricts the bot so only YOU can use it.");
print_info("Without this, anyone who finds your bot can send it messages.");
println!();
if !confirm("Bind bot to your Telegram account?", true)? {
print_info("Skipping account binding. Bot will accept messages from all users.");
return Ok(None);
}
print_info("Send any message (e.g. /start) to your bot in Telegram.");
print_info("Waiting for your message (up to 120 seconds)...");
let client = Client::builder()
.timeout(std::time::Duration::from_secs(35))
.build()
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
// Clear any existing webhook so getUpdates works
let delete_url = format!(
"https://api.telegram.org/bot{}/deleteWebhook",
token.expose_secret()
);
if let Err(e) = client.post(&delete_url).send().await {
tracing::warn!("Failed to delete webhook (getUpdates may not work): {e}");
}
let updates_url = format!(
"https://api.telegram.org/bot{}/getUpdates",
token.expose_secret()
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
while std::time::Instant::now() < deadline {
let response = client
.get(&updates_url)
.query(&[("timeout", "30"), ("allowed_updates", "[\"message\"]")])
.send()
.await
.map_err(|e| ChannelSetupError::Network(format!("getUpdates request failed: {}", e)))?;
if !response.status().is_success() {
return Err(ChannelSetupError::Network(format!(
"getUpdates returned status {}",
response.status()
)));
}
let body: TelegramGetUpdatesResponse = response.json().await.map_err(|e| {
ChannelSetupError::Network(format!("Failed to parse getUpdates response: {}", e))
})?;
if !body.ok {
return Err(ChannelSetupError::Network(
"Telegram API returned error for getUpdates".to_string(),
));
}
// Find the first message with a sender
for update in &body.result {
if let Some(ref msg) = update.message
&& let Some(ref from) = msg.from
{
let display_name = from
.username
.as_ref()
.map(|u| format!("@{}", u))
.unwrap_or_else(|| from.first_name.clone());
print_success(&format!(
"Received message from {} (ID: {})",
display_name, from.id
));
// Acknowledge the update so it doesn't pile up
let ack_url = format!(
"https://api.telegram.org/bot{}/getUpdates",
token.expose_secret()
);
if let Err(e) = client
.get(&ack_url)
.query(&[("offset", &(update.update_id + 1).to_string())])
.send()
.await
{
tracing::warn!("Failed to acknowledge Telegram update: {e}");
}
return Ok(Some(from.id));
}
}
}
print_error("Timed out waiting for a message. You can re-run setup to try again.");
print_info("Bot will accept messages from all users until owner is bound.");
Ok(None)
}
/// Bind flow when the token already exists (reads from secrets store).
///
/// Retrieves the saved bot token and delegates to `bind_telegram_owner`.
async fn bind_telegram_owner_flow(
secrets: &SecretsContext,
settings: &Settings,
) -> Result<Option<i64>, ChannelSetupError> {
if settings.channels.telegram_owner_id.is_some() {
print_info("Bot is already bound to a Telegram account.");
if !confirm("Re-bind to a different account?", false)? {
return Ok(settings.channels.telegram_owner_id);
}
}
// We need the token to poll getUpdates
let token = secrets.get_secret("telegram_bot_token").await?;
bind_telegram_owner(&token).await
}
/// Set up a tunnel for exposing the agent to the internet.
///
/// This is shared across all channels that need webhook endpoints.
@@ -725,84 +468,6 @@ fn setup_tunnel_static() -> Result<TunnelSettings, ChannelSetupError> {
})
}
/// Set up Telegram webhook secret for signature validation.
///
/// Returns the webhook secret if configured.
async fn setup_telegram_webhook_secret(
secrets: &SecretsContext,
tunnel: &TunnelSettings,
) -> Result<Option<String>, ChannelSetupError> {
if tunnel.public_url.is_none() {
print_info("");
print_info("No tunnel configured. Telegram will use polling mode (30s+ delay).");
print_info("Run setup again to configure a tunnel for instant delivery.");
return Ok(None);
}
println!();
print_info("Telegram Webhook Security:");
print_info("A webhook secret adds an extra layer of security by validating");
print_info("that requests actually come from Telegram's servers.");
if !confirm("Generate a webhook secret?", true)? {
return Ok(None);
}
let secret = generate_webhook_secret();
secrets
.save_secret(
"telegram_webhook_secret",
&SecretString::from(secret.clone()),
)
.await?;
print_success("Webhook secret generated and saved");
Ok(Some(secret))
}
/// Validate a Telegram bot token by calling the getMe API.
///
/// Returns the bot's username if valid.
pub async fn validate_telegram_token(
token: &SecretString,
) -> Result<Option<String>, ChannelSetupError> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
let url = format!(
"https://api.telegram.org/bot{}/getMe",
token.expose_secret()
);
let response = client
.get(&url)
.send()
.await
.map_err(|e| ChannelSetupError::Network(format!("Request failed: {}", e)))?;
if !response.status().is_success() {
return Err(ChannelSetupError::Network(format!(
"API returned status {}",
response.status()
)));
}
let body: TelegramGetMeResponse = response
.json()
.await
.map_err(|e| ChannelSetupError::Network(format!("Failed to parse response: {}", e)))?;
if body.ok {
Ok(body.result.and_then(|u| u.username))
} else {
Err(ChannelSetupError::Network(
"Telegram API returned error".to_string(),
))
}
}
/// Result of HTTP webhook setup.
#[derive(Debug, Clone)]
pub struct HttpSetupResult {
+1 -4
View File
@@ -24,10 +24,7 @@ mod prompts;
#[cfg(any(feature = "postgres", feature = "libsql"))]
mod wizard;
pub use channels::{
ChannelSetupError, SecretsContext, setup_http, setup_telegram, setup_tunnel,
validate_telegram_token,
};
pub use channels::{ChannelSetupError, SecretsContext, setup_http, setup_tunnel};
pub use prompts::{
confirm, input, optional_input, print_error, print_header, print_info, print_step,
print_success, secret_input, select_many, select_one,
+1 -10
View File
@@ -26,7 +26,7 @@ use crate::llm::{SessionConfig, SessionManager};
use crate::secrets::{SecretsCrypto, SecretsStore};
use crate::settings::{KeySource, Settings};
use crate::setup::channels::{
SecretsContext, setup_http, setup_signal, setup_telegram, setup_tunnel, setup_wasm_channel,
SecretsContext, setup_http, setup_signal, setup_tunnel, setup_wasm_channel,
};
use crate::setup::prompts::{
confirm, input, optional_input, print_error, print_header, print_info, print_step,
@@ -1670,15 +1670,6 @@ impl SetupWizard {
let result = if let Some(cap_file) = discovered_by_name.get(&channel_name) {
if !cap_file.setup.required_secrets.is_empty() {
setup_wasm_channel(ctx, &channel_name, &cap_file.setup).await?
} else if channel_name == "telegram" {
let telegram_result = setup_telegram(ctx, &self.settings).await?;
if let Some(owner_id) = telegram_result.owner_id {
self.settings.channels.telegram_owner_id = Some(owner_id);
}
crate::setup::channels::WasmChannelSetupResult {
enabled: telegram_result.enabled,
channel_name: "telegram".to_string(),
}
} else {
print_info(&format!(
"No setup configuration found for {}",
+1
View File
@@ -293,6 +293,7 @@ impl TestHarnessBuilder {
skills_config: SkillsConfig::default(),
hooks,
cost_guard,
sse_tx: None,
};
TestHarness {
+31 -9
View File
@@ -95,15 +95,17 @@ impl Tool for MemorySearchTool {
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Search failed: {}", e)))?;
let result_count = results.len();
let output = serde_json::json!({
"query": query,
"results": results.iter().map(|r| serde_json::json!({
"results": results.into_iter().map(|r| serde_json::json!({
"content": r.content,
"score": r.score,
"path": r.document_path,
"document_id": r.document_id.to_string(),
"is_hybrid_match": r.is_hybrid(),
})).collect::<Vec<_>>(),
"result_count": results.len(),
"result_count": result_count,
});
Ok(ToolOutput::success(output, start.elapsed()))
@@ -140,7 +142,8 @@ impl Tool for MemoryWriteTool {
Use for important facts, decisions, preferences, or lessons learned that should \
be remembered across sessions. Targets: 'memory' for curated long-term facts, \
'daily_log' for timestamped session notes, 'heartbeat' for the periodic \
checklist (HEARTBEAT.md), or provide a custom path for arbitrary file creation."
checklist (HEARTBEAT.md), 'bootstrap' to clear the first-run ritual file, \
or provide a custom path for arbitrary file creation."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -153,7 +156,7 @@ impl Tool for MemoryWriteTool {
},
"target": {
"type": "string",
"description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, 'heartbeat' for HEARTBEAT.md checklist, or a path like 'projects/alpha/notes.md'",
"description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, 'heartbeat' for HEARTBEAT.md checklist, 'bootstrap' to clear BOOTSTRAP.md (content is ignored; the file is always cleared), or a path like 'projects/alpha/notes.md'",
"default": "daily_log"
},
"append": {
@@ -175,17 +178,36 @@ impl Tool for MemoryWriteTool {
let content = require_str(&params, "content")?;
let target = params
.get("target")
.and_then(|v| v.as_str())
.unwrap_or("daily_log");
// Bootstrap target: clear BOOTSTRAP.md to mark first-run ritual complete.
// Handled early because it accepts empty content (unlike other targets).
if target == "bootstrap" {
// Write empty content to effectively disable the bootstrap injection.
// system_prompt_for_context() skips empty files.
self.workspace
.write(paths::BOOTSTRAP, "")
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?;
let output = serde_json::json!({
"status": "cleared",
"path": paths::BOOTSTRAP,
"message": "BOOTSTRAP.md cleared. First-run ritual will not repeat.",
});
return Ok(ToolOutput::success(output, start.elapsed()));
}
if content.trim().is_empty() {
return Err(ToolError::InvalidParameters(
"content cannot be empty".to_string(),
));
}
let target = params
.get("target")
.and_then(|v| v.as_str())
.unwrap_or("daily_log");
// Reject writes to identity files that are loaded into the system prompt.
// An attacker could use prompt injection to trick the agent into overwriting
// these, poisoning future conversations.
+72 -17
View File
@@ -3,10 +3,9 @@
//! Allows the agent to proactively message users on any connected channel.
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::{Arc, RwLock};
use async_trait::async_trait;
use tokio::sync::RwLock;
use crate::bootstrap::ironclaw_base_dir;
use crate::channels::{ChannelManager, OutgoingResponse};
@@ -19,6 +18,7 @@ use crate::tools::tool::{
pub struct MessageTool {
channel_manager: Arc<ChannelManager>,
/// Default channel for current conversation (set per-turn).
/// Uses std::sync::RwLock because requires_approval() is sync and called from async context.
default_channel: Arc<RwLock<Option<String>>>,
/// Default target (user_id or group_id) for current conversation (set per-turn).
default_target: Arc<RwLock<Option<String>>>,
@@ -48,8 +48,14 @@ impl MessageTool {
/// Set the default channel and target for the current conversation turn.
/// Call this before each agent turn with the incoming message's channel/target.
pub async fn set_context(&self, channel: Option<String>, target: Option<String>) {
*self.default_channel.write().await = channel;
*self.default_target.write().await = target;
*self
.default_channel
.write()
.unwrap_or_else(|e| e.into_inner()) = channel;
*self
.default_target
.write()
.unwrap_or_else(|e| e.into_inner()) = target;
}
}
@@ -106,24 +112,32 @@ impl Tool for MessageTool {
let channel = if let Some(c) = params.get("channel").and_then(|v| v.as_str()) {
c.to_string()
} else {
self.default_channel.read().await.clone().ok_or_else(|| {
ToolError::ExecutionFailed(
"No channel specified and no active conversation. Provide channel parameter."
.to_string(),
)
})?
self.default_channel
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
.ok_or_else(|| {
ToolError::ExecutionFailed(
"No channel specified and no active conversation. Provide channel parameter."
.to_string(),
)
})?
};
// Get target: use param or fall back to default
let target = if let Some(t) = params.get("target").and_then(|v| v.as_str()) {
t.to_string()
} else {
self.default_target.read().await.clone().ok_or_else(|| {
ToolError::ExecutionFailed(
"No target specified and no active conversation. Provide target parameter."
.to_string(),
)
})?
self.default_target
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
.ok_or_else(|| {
ToolError::ExecutionFailed(
"No target specified and no active conversation. Provide target parameter."
.to_string(),
)
})?
};
let attachments: Vec<String> = match params.get("attachments") {
@@ -199,7 +213,10 @@ impl Tool for MessageTool {
let param_channel = params.get("channel").and_then(|v| v.as_str());
if let Some(channel) = param_channel {
// Check if it differs from the default channel
let default_channel = self.default_channel.blocking_read();
let default_channel = self
.default_channel
.read()
.unwrap_or_else(|e| e.into_inner());
if let Some(default) = default_channel.as_ref()
&& channel != default
{
@@ -515,4 +532,42 @@ mod tests {
err
);
}
/// Regression test: requires_approval() is a sync method called from async context.
/// With tokio::sync::RwLock, this would panic with:
/// "Cannot block the current thread from within a runtime"
/// because blocking_read() cannot be called inside an async runtime.
/// With std::sync::RwLock, it works correctly since std locks are safe
/// for short-held locks in sync methods called from async contexts.
#[tokio::test]
async fn requires_approval_works_from_async_context() {
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
// Set context asynchronously (simulating real usage pattern)
tool.set_context(Some("signal".to_string()), Some("+1234567890".to_string()))
.await;
// Call requires_approval (sync method) from async context.
// This is the critical test: with tokio::sync::RwLock::blocking_read(),
// this would panic. With std::sync::RwLock::read(), it works.
let approval = tool.requires_approval(&serde_json::json!({
"content": "hello",
"channel": "telegram"
}));
// Different channel from default -> Always
assert!(matches!(approval, ApprovalRequirement::Always));
// No channel specified (uses default) -> UnlessAutoApproved
let approval = tool.requires_approval(&serde_json::json!({
"content": "hello"
}));
assert!(matches!(approval, ApprovalRequirement::UnlessAutoApproved));
// Explicit channel (even if same as default) -> Always
let approval = tool.requires_approval(&serde_json::json!({
"content": "hello",
"channel": "signal"
}));
assert!(matches!(approval, ApprovalRequirement::Always));
}
}
+4 -1
View File
@@ -531,7 +531,7 @@ pub async fn wait_for_authorization_callback(
listener: TcpListener,
server_name: &str,
) -> Result<String, AuthError> {
oauth_defaults::wait_for_callback(listener, "/callback", "code", server_name)
oauth_defaults::wait_for_callback(listener, "/callback", "code", server_name, None)
.await
.map_err(|e| match e {
oauth_defaults::OAuthCallbackError::Denied => AuthError::AuthorizationDenied,
@@ -539,6 +539,9 @@ pub async fn wait_for_authorization_callback(
oauth_defaults::OAuthCallbackError::PortInUse(_, msg) => {
AuthError::Http(format!("Port error: {}", msg))
}
oauth_defaults::OAuthCallbackError::StateMismatch { .. } => {
AuthError::Http("CSRF state mismatch in OAuth callback".to_string())
}
oauth_defaults::OAuthCallbackError::Io(msg) => AuthError::Http(msg),
})
}
+1 -1
View File
@@ -585,7 +585,7 @@ impl ToolRegistry {
limits: None,
description: Some(&tool_with_binary.tool.description),
schema: Some(tool_with_binary.tool.parameters_schema.clone()),
secrets_store: None,
secrets_store: self.secrets_store.clone(),
oauth_refresh: None,
})
.await
+5
View File
@@ -512,6 +512,11 @@ pub struct ValidationEndpointSchema {
/// Expected HTTP status code for success (defaults to 200).
#[serde(default = "default_success_status")]
pub success_status: u16,
/// Additional headers to send with the validation request.
/// Used for service-specific requirements (e.g., Notion-Version for Notion API).
#[serde(default)]
pub headers: HashMap<String, String>,
}
fn default_method() -> String {
+2
View File
@@ -20,6 +20,8 @@ workspace/
├── SOUL.md <- Core values
├── AGENTS.md <- Behavior instructions
├── USER.md <- User context
├── TOOLS.md <- Environment-specific tool notes
├── BOOTSTRAP.md <- First-run ritual (deleted after onboarding)
├── context/ <- Identity-related docs
│ ├── vision.md
│ └── priorities.md
+4
View File
@@ -27,6 +27,10 @@ pub mod paths {
pub const DAILY_DIR: &str = "daily/";
/// Context directory (for identity-related docs).
pub const CONTEXT_DIR: &str = "context/";
/// User-editable notes for environment-specific tool guidance.
pub const TOOLS: &str = "TOOLS.md";
/// First-run ritual file; self-deletes after onboarding completes.
pub const BOOTSTRAP: &str = "BOOTSTRAP.md";
}
/// A memory document stored in the database.
+182
View File
@@ -271,6 +271,53 @@ const HEARTBEAT_SEED: &str = "\
- Clean up context/ documents that are outdated
-->";
/// Default template seeded into TOOLS.md on first access.
///
/// TOOLS.md does not control tool availability; it is user guidance
/// for how to use external tools. The agent may update this file as it
/// learns environment-specific details (SSH hostnames, device names, etc.).
const TOOLS_SEED: &str = "\
<!-- TOOLS.md Environment-specific tool notes.
This file does not control which tools are available; it is guidance only.
The agent can update this file as it learns your setup.
Examples:
- SSH hosts: dev-box (Ubuntu 22.04, username: alice)
- Camera: Canon R6 mounted at /Volumes/EOS_R
- Default shell on remote: bash, no zsh
Add your environment notes below (outside the comment block).
-->";
/// First-run ritual seeded into BOOTSTRAP.md on initial workspace setup.
///
/// The agent reads this file at the start of every session when it exists.
/// After completing the ritual the agent must delete this file so it is
/// never repeated. It is NOT a protected file; the agent needs write access.
const BOOTSTRAP_SEED: &str = "\
# Bootstrap
You are starting up for the first time. Follow these steps before anything else.
## Steps
1. **Say hello.** Greet the user warmly and introduce yourself briefly.
2. **Get to know the user.** Ask a few questions to understand who they are, \
what they work on, and what they want from an AI assistant. Take notes.
3. **Save what you learned.**
- Write any environment-specific tool details the user mentions to `TOOLS.md` \
using `memory_write` with target set to the path.
- Write a summary of the conversation and key facts to `MEMORY.md` \
using `memory_write` with target `memory`.
- Note: `USER.md`, `IDENTITY.md`, `SOUL.md`, and `AGENTS.md` are protected \
from tool writes for security. Tell the user what you'd suggest for those files \
so they can edit them directly.
4. **Delete this file.** When onboarding is complete, use `memory_write` with \
target `bootstrap` to clear this file so setup never repeats.
Keep the conversation natural. Do not read these steps aloud.
";
/// Workspace provides database-backed memory storage for an agent.
///
/// Each workspace is scoped to a user (and optionally an agent).
@@ -547,6 +594,24 @@ impl Workspace {
) -> Result<String, WorkspaceError> {
let mut parts = Vec::new();
// Bootstrap ritual: inject FIRST when present (first-run only).
// The agent must complete the ritual and then delete this file.
//
// Note: BOOTSTRAP.md is intentionally NOT write-protected so the agent
// can delete it after onboarding. This means a prompt injection attack
// could write to it, but the file is only injected on the next session
// (not the current one), limiting the blast radius.
if let Ok(doc) = self.read(paths::BOOTSTRAP).await
&& !doc.content.is_empty()
{
parts.push(format!(
"## First-Run Bootstrap\n\n\
A BOOTSTRAP.md file exists in the workspace. Read and follow it, \
then delete it when done.\n\n{}",
doc.content
));
}
// Load identity files in order of importance
let identity_files = [
(paths::AGENTS, "## Agent Instructions"),
@@ -563,6 +628,14 @@ impl Workspace {
}
}
// Tool notes: environment-specific guidance the agent or user has written.
// TOOLS.md does not control tool availability; it is guidance only.
if let Ok(doc) = self.read(paths::TOOLS).await
&& !doc.content.is_empty()
{
parts.push(format!("## Tool Notes\n\n{}", doc.content));
}
// Load MEMORY.md only in direct/main sessions (never group chats)
if !is_group_chat
&& let Ok(doc) = self.read(paths::MEMORY).await
@@ -693,6 +766,7 @@ impl Workspace {
- `SOUL.md` - Core values and behavioral boundaries\n\
- `AGENTS.md` - Session routine and operational instructions\n\
- `USER.md` - Information about you (the user)\n\
- `TOOLS.md` - Environment-specific tool notes\n\
- `HEARTBEAT.md` - Periodic background task checklist\n\
- `daily/` - Automatic daily session logs\n\
- `context/` - Additional context documents\n\n\
@@ -763,6 +837,7 @@ impl Workspace {
You can also edit this directly to provide context upfront.",
),
(paths::HEARTBEAT, HEARTBEAT_SEED),
(paths::TOOLS, TOOLS_SEED),
];
let mut count = 0;
@@ -784,12 +859,119 @@ impl Workspace {
}
}
// BOOTSTRAP.md is only seeded on truly fresh workspaces (no identity
// files exist yet). This prevents existing users from getting a
// spurious first-run ritual after upgrading.
if self.read(paths::BOOTSTRAP).await.is_err() {
let (agents_res, soul_res, user_res) = tokio::join!(
self.read(paths::AGENTS),
self.read(paths::SOUL),
self.read(paths::USER),
);
let is_fresh_workspace =
matches!(agents_res, Err(WorkspaceError::DocumentNotFound { .. }))
&& matches!(soul_res, Err(WorkspaceError::DocumentNotFound { .. }))
&& matches!(user_res, Err(WorkspaceError::DocumentNotFound { .. }));
if is_fresh_workspace {
if let Err(e) = self.write(paths::BOOTSTRAP, BOOTSTRAP_SEED).await {
tracing::warn!("Failed to seed {}: {}", paths::BOOTSTRAP, e);
} else {
count += 1;
}
}
}
if count > 0 {
tracing::info!("Seeded {} workspace files", count);
}
Ok(count)
}
/// Import markdown files from a directory on disk into the workspace DB.
///
/// Scans `dir` for `*.md` files (non-recursive) and writes each one into
/// the workspace **only if it doesn't already exist in the database**.
/// This allows Docker images or deployment scripts to ship customized
/// workspace templates that override the generic seeds.
///
/// Returns the number of files imported (0 if all already existed).
pub async fn import_from_directory(
&self,
dir: &std::path::Path,
) -> Result<usize, WorkspaceError> {
if !dir.is_dir() {
tracing::warn!(
"Workspace import directory does not exist: {}",
dir.display()
);
return Ok(0);
}
let entries = std::fs::read_dir(dir).map_err(|e| WorkspaceError::IoError {
reason: format!("failed to read directory {}: {}", dir.display(), e),
})?;
let mut count = 0;
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
tracing::warn!("Failed to read directory entry in {}: {}", dir.display(), e);
continue;
}
};
let path = entry.path();
// Only import .md files
if path.extension() != Some(std::ffi::OsStr::new("md")) {
continue;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
// Skip if already exists in DB (never overwrite user edits)
match self.read(file_name).await {
Ok(_) => continue,
Err(WorkspaceError::DocumentNotFound { .. }) => {}
Err(e) => {
tracing::warn!("Failed to check {}: {}", file_name, e);
continue;
}
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
tracing::warn!("Failed to read import file {}: {}", path.display(), e);
continue;
}
};
if content.trim().is_empty() {
continue;
}
if let Err(e) = self.write(file_name, &content).await {
tracing::warn!("Failed to import {}: {}", file_name, e);
} else {
tracing::info!("Imported workspace file from disk: {}", file_name);
count += 1;
}
}
if count > 0 {
tracing::info!(
"Imported {} workspace file(s) from {}",
count,
dir.display()
);
}
Ok(count)
}
/// Generate embeddings for chunks that don't have them yet.
///
/// This is useful for backfilling embeddings after enabling the provider.
+4 -2
View File
@@ -431,7 +431,7 @@ impl Repository {
let rows = conn
.query(
r#"
SELECT c.id as chunk_id, c.document_id, c.content,
SELECT c.id as chunk_id, c.document_id, d.path as document_path, c.content,
ts_rank_cd(c.content_tsv, plainto_tsquery('english', $3)) as rank
FROM memory_chunks c
JOIN memory_documents d ON d.id = c.document_id
@@ -453,6 +453,7 @@ impl Repository {
.map(|(i, row)| RankedResult {
chunk_id: row.get("chunk_id"),
document_id: row.get("document_id"),
document_path: row.get("document_path"),
content: row.get("content"),
rank: (i + 1) as u32,
})
@@ -473,7 +474,7 @@ impl Repository {
let rows = conn
.query(
r#"
SELECT c.id as chunk_id, c.document_id, c.content,
SELECT c.id as chunk_id, c.document_id, d.path as document_path, c.content,
1 - (c.embedding <=> $3) as similarity
FROM memory_chunks c
JOIN memory_documents d ON d.id = c.document_id
@@ -495,6 +496,7 @@ impl Repository {
.map(|(i, row)| RankedResult {
chunk_id: row.get("chunk_id"),
document_id: row.get("document_id"),
document_path: row.get("document_path"),
content: row.get("content"),
rank: (i + 1) as u32,
})
+9
View File
@@ -81,6 +81,8 @@ impl SearchConfig {
pub struct SearchResult {
/// Document ID containing this chunk.
pub document_id: Uuid,
/// File path of the source document.
pub document_path: String,
/// Chunk ID.
pub chunk_id: Uuid,
/// Chunk content.
@@ -115,6 +117,8 @@ impl SearchResult {
pub struct RankedResult {
pub chunk_id: Uuid,
pub document_id: Uuid,
/// File path of the source document.
pub document_path: String,
pub content: String,
pub rank: u32, // 1-based rank
}
@@ -143,6 +147,7 @@ pub fn reciprocal_rank_fusion(
// Track scores and metadata for each chunk
struct ChunkInfo {
document_id: Uuid,
document_path: String,
content: String,
score: f32,
fts_rank: Option<u32>,
@@ -162,6 +167,7 @@ pub fn reciprocal_rank_fusion(
})
.or_insert(ChunkInfo {
document_id: result.document_id,
document_path: result.document_path,
content: result.content,
score: rrf_score,
fts_rank: Some(result.rank),
@@ -180,6 +186,7 @@ pub fn reciprocal_rank_fusion(
})
.or_insert(ChunkInfo {
document_id: result.document_id,
document_path: result.document_path,
content: result.content,
score: rrf_score,
fts_rank: None,
@@ -192,6 +199,7 @@ pub fn reciprocal_rank_fusion(
.into_iter()
.map(|(chunk_id, info)| SearchResult {
document_id: info.document_id,
document_path: info.document_path,
chunk_id,
content: info.content,
score: info.score,
@@ -235,6 +243,7 @@ mod tests {
RankedResult {
chunk_id,
document_id: doc_id,
document_path: format!("docs/{}.md", doc_id),
content: format!("content for chunk {}", chunk_id),
rank,
}
+2 -2
View File
@@ -191,6 +191,7 @@ async fn start_test_server_with_provider(
store: None,
job_manager: None,
prompt_queue: None,
scheduler: None,
user_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
@@ -201,7 +202,6 @@ async fn start_test_server_with_provider(
registry_entries: Vec::new(),
cost_guard: None,
startup_time: std::time::Instant::now(),
restart_requested: std::sync::atomic::AtomicBool::new(false),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
@@ -680,6 +680,7 @@ async fn test_no_llm_provider_returns_503() {
store: None,
job_manager: None,
prompt_queue: None,
scheduler: None,
user_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
@@ -690,7 +691,6 @@ async fn test_no_llm_provider_returns_503() {
registry_entries: Vec::new(),
cost_guard: None,
startup_time: std::time::Instant::now(),
restart_requested: std::sync::atomic::AtomicBool::new(false),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
+1 -1
View File
@@ -49,6 +49,7 @@ async fn start_test_server() -> (
store: None,
job_manager: None,
prompt_queue: None,
scheduler: None,
user_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
@@ -59,7 +60,6 @@ async fn start_test_server() -> (
registry_entries: Vec::new(),
cost_guard: None,
startup_time: std::time::Instant::now(),
restart_requested: std::sync::atomic::AtomicBool::new(false),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "okta-tool"
version = "0.1.0"
edition = "2021"
description = "Okta SSO tool for IronClaw (WASM component) — user profile, app catalog, and SSO launch links"
license = "MIT OR Apache-2.0"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "=0.36"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
[workspace]
-105
View File
@@ -1,105 +0,0 @@
{
"http": {
"allowlist": [
{
"host": "*.okta.com",
"path_prefix": "/api/v1/",
"methods": ["GET", "POST", "PUT"]
},
{
"host": "*.okta.com",
"path_prefix": "/idp/myaccount/",
"methods": ["GET", "PUT"]
},
{
"host": "*.okta.com",
"path_prefix": "/oauth2/v1/",
"methods": ["POST"]
},
{
"host": "*.oktapreview.com",
"path_prefix": "/api/v1/",
"methods": ["GET", "POST", "PUT"]
},
{
"host": "*.oktapreview.com",
"path_prefix": "/idp/myaccount/",
"methods": ["GET", "PUT"]
},
{
"host": "*.oktapreview.com",
"path_prefix": "/oauth2/v1/",
"methods": ["POST"]
},
{
"host": "*.okta-emea.com",
"path_prefix": "/api/v1/",
"methods": ["GET", "POST", "PUT"]
},
{
"host": "*.okta-emea.com",
"path_prefix": "/idp/myaccount/",
"methods": ["GET", "PUT"]
},
{
"host": "*.okta-emea.com",
"path_prefix": "/oauth2/v1/",
"methods": ["POST"]
}
],
"credentials": {
"okta_oauth_token": {
"secret_name": "okta_oauth_token",
"location": { "type": "bearer" },
"host_patterns": ["*.okta.com", "*.oktapreview.com", "*.okta-emea.com"]
}
},
"rate_limit": {
"requests_per_minute": 30,
"requests_per_hour": 500
},
"timeout_secs": 30
},
"workspace": {
"allowed_prefixes": ["okta/"]
},
"secrets": {
"allowed_names": ["okta_oauth_token"]
},
"auth": {
"secret_name": "okta_oauth_token",
"display_name": "Okta",
"oauth": {
"authorization_url": "https://{okta_domain}/oauth2/v1/authorize",
"token_url": "https://{okta_domain}/oauth2/v1/token",
"client_id_env": "OKTA_OAUTH_CLIENT_ID",
"client_secret_env": "OKTA_OAUTH_CLIENT_SECRET",
"scopes": [
"openid",
"profile",
"email",
"offline_access",
"okta.users.read.self",
"okta.users.manage.self",
"okta.apps.read"
],
"use_pkce": true
},
"instructions": "1. In your Okta Admin Console, go to Applications > Create App Integration\n2. Select 'OIDC - OpenID Connect', then 'Web Application'\n3. Set Sign-in redirect URI to http://localhost:9876/callback (through :9886)\n4. Under Okta API Scopes, grant: okta.users.read.self, okta.users.manage.self, okta.apps.read\n5. Copy the Client ID and Client Secret\n6. IMPORTANT: You must use the Org Authorization Server (not a custom one)\n7. Store your Okta domain in workspace at 'okta/domain' (e.g., 'mycompany.okta.com')\n8. For custom domains, add them to okta-tool.capabilities.json allowlist",
"setup_url": "https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/",
"token_hint": "OAuth2 access token (JWT)",
"env_var": "OKTA_OAUTH_TOKEN"
},
"setup": {
"required_secrets": [
{
"name": "okta_oauth_client_id",
"prompt": "Okta OAuth Client ID"
},
{
"name": "okta_oauth_client_secret",
"prompt": "Okta OAuth Client Secret"
}
]
}
}
-281
View File
@@ -1,281 +0,0 @@
use crate::near::agent::host;
use crate::types::*;
const WORKSPACE_DOMAIN_PATH: &str = "okta/domain";
/// Read the configured Okta domain from workspace, or return a helpful error.
fn get_domain() -> Result<String, String> {
host::workspace_read(WORKSPACE_DOMAIN_PATH).ok_or_else(|| {
"Okta domain not configured. Write your Okta domain to workspace path 'okta/domain' \
using the memory_write tool (e.g., memory_write with path='okta/domain' and \
content='mycompany.okta.com')."
.to_string()
})
}
/// Build the base URL for the Okta Management API.
fn management_base(domain: &str) -> String {
format!("https://{}/api/v1", domain)
}
/// Make an Okta API call.
fn okta_api_call(method: &str, url: &str, body: Option<&str>) -> Result<String, String> {
let headers = if body.is_some() {
r#"{"Content-Type": "application/json", "Accept": "application/json"}"#
} else {
r#"{"Accept": "application/json"}"#
};
let body_bytes = body.map(|b| b.as_bytes().to_vec());
host::log(
host::LogLevel::Debug,
&format!("Okta API: {} {}", method, url),
);
let response = host::http_request(method, url, headers, body_bytes.as_deref(), None)?;
if response.status < 200 || response.status >= 300 {
let body_text = String::from_utf8_lossy(&response.body);
// Try to extract Okta's error summary for a better message.
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&body_text) {
if let Some(summary) = parsed["errorSummary"].as_str() {
return Err(format!("Okta API error ({}): {}", response.status, summary));
}
}
return Err(format!(
"Okta API returned status {}: {}",
response.status, body_text
));
}
String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e))
}
// ---------------------------------------------------------------------------
// Action implementations
// ---------------------------------------------------------------------------
/// GET /api/v1/users/me
pub fn get_profile() -> Result<String, String> {
let domain = get_domain()?;
let url = format!("{}/users/me", management_base(&domain));
let response = okta_api_call("GET", &url, None)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let profile = parse_user_profile(&parsed)?;
serde_json::to_string(&profile).map_err(|e| e.to_string())
}
/// POST /api/v1/users/me (partial update via Management API)
pub fn update_profile(fields: &serde_json::Value) -> Result<String, String> {
let domain = get_domain()?;
let url = format!("{}/users/me", management_base(&domain));
// Wrap fields under "profile" key for Okta's expected format.
let payload = serde_json::json!({ "profile": fields });
let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?;
let response = okta_api_call("POST", &url, Some(&body))?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let profile = parse_user_profile(&parsed)?;
let result = UpdateProfileResult {
success: true,
profile,
};
serde_json::to_string(&result).map_err(|e| e.to_string())
}
/// GET /api/v1/users/me/appLinks
pub fn list_apps() -> Result<String, String> {
let domain = get_domain()?;
let url = format!("{}/users/me/appLinks", management_base(&domain));
let response = okta_api_call("GET", &url, None)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let apps = parse_app_links(&parsed)?;
let count = apps.len();
let result = ListAppsResult { apps, count };
serde_json::to_string(&result).map_err(|e| e.to_string())
}
/// Search apps by label (case-insensitive substring match).
pub fn search_apps(query: &str) -> Result<String, String> {
let domain = get_domain()?;
let url = format!("{}/users/me/appLinks", management_base(&domain));
let response = okta_api_call("GET", &url, None)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let all_apps = parse_app_links(&parsed)?;
let query_lower = query.to_lowercase();
let apps: Vec<AppLink> = all_apps
.into_iter()
.filter(|app| {
app.label.to_lowercase().contains(&query_lower)
|| app.app_name.to_lowercase().contains(&query_lower)
})
.collect();
let count = apps.len();
let result = ListAppsResult { apps, count };
serde_json::to_string(&result).map_err(|e| e.to_string())
}
/// Find an app by ID or label and return its SSO launch link.
pub fn get_app_sso_link(app: &str) -> Result<String, String> {
let domain = get_domain()?;
let url = format!("{}/users/me/appLinks", management_base(&domain));
let response = okta_api_call("GET", &url, None)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let all_apps = parse_app_links(&parsed)?;
let app_lower = app.to_lowercase();
// Try exact ID match first, then case-insensitive label match.
let found = all_apps
.iter()
.find(|a| a.app_instance_id == app)
.or_else(|| {
all_apps
.iter()
.find(|a| a.label.to_lowercase() == app_lower)
})
.or_else(|| {
all_apps
.iter()
.find(|a| a.label.to_lowercase().contains(&app_lower))
});
match found {
Some(app_link) => {
let result = AppSsoLinkResult {
label: app_link.label.clone(),
link_url: app_link.link_url.clone(),
app_instance_id: app_link.app_instance_id.clone(),
app_name: app_link.app_name.clone(),
};
serde_json::to_string(&result).map_err(|e| e.to_string())
}
None => {
let available: Vec<String> = all_apps.iter().map(|a| a.label.clone()).collect();
Err(format!(
"App '{}' not found. Available apps: {}",
app,
available.join(", ")
))
}
}
}
/// GET /idp/myaccount/organization
pub fn get_org_info() -> Result<String, String> {
let domain = get_domain()?;
let url = format!("https://{}/idp/myaccount/organization", domain);
// MyAccount API requires the okta-version header.
let response = okta_api_call_with_headers(
"GET",
&url,
None,
r#"{"Accept": "application/json; okta-version=1.0.0"}"#,
)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let result = OrgInfo {
id: parsed["id"].as_str().unwrap_or("").to_string(),
name: parsed["name"].as_str().unwrap_or("").to_string(),
subdomain: parsed["subdomain"].as_str().map(|s| s.to_string()),
website: parsed["website"].as_str().map(|s| s.to_string()),
support_phone: parsed["supportPhoneNumber"].as_str().map(|s| s.to_string()),
technical_contact: parsed["technicalContact"].as_str().map(|s| s.to_string()),
};
serde_json::to_string(&result).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Like `okta_api_call` but with custom headers (for MyAccount API versioning).
fn okta_api_call_with_headers(
method: &str,
url: &str,
body: Option<&str>,
headers: &str,
) -> Result<String, String> {
let body_bytes = body.map(|b| b.as_bytes().to_vec());
host::log(
host::LogLevel::Debug,
&format!("Okta API: {} {}", method, url),
);
let response = host::http_request(method, url, headers, body_bytes.as_deref(), None)?;
if response.status < 200 || response.status >= 300 {
let body_text = String::from_utf8_lossy(&response.body);
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&body_text) {
if let Some(summary) = parsed["errorSummary"].as_str() {
return Err(format!("Okta API error ({}): {}", response.status, summary));
}
}
return Err(format!(
"Okta API returned status {}: {}",
response.status, body_text
));
}
String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e))
}
fn parse_user_profile(v: &serde_json::Value) -> Result<UserProfile, String> {
let p = &v["profile"];
Ok(UserProfile {
id: v["id"].as_str().unwrap_or("").to_string(),
status: v["status"].as_str().unwrap_or("").to_string(),
first_name: p["firstName"].as_str().unwrap_or("").to_string(),
last_name: p["lastName"].as_str().unwrap_or("").to_string(),
email: p["email"].as_str().unwrap_or("").to_string(),
login: p["login"].as_str().unwrap_or("").to_string(),
mobile_phone: p["mobilePhone"].as_str().map(|s| s.to_string()),
display_name: p["displayName"].as_str().map(|s| s.to_string()),
nick_name: p["nickName"].as_str().map(|s| s.to_string()),
title: p["title"].as_str().map(|s| s.to_string()),
department: p["department"].as_str().map(|s| s.to_string()),
organization: p["organization"].as_str().map(|s| s.to_string()),
timezone: p["timezone"].as_str().map(|s| s.to_string()),
locale: p["locale"].as_str().map(|s| s.to_string()),
})
}
fn parse_app_links(v: &serde_json::Value) -> Result<Vec<AppLink>, String> {
let arr = v
.as_array()
.ok_or_else(|| "Expected array of app links from Okta".to_string())?;
Ok(arr
.iter()
.map(|a| AppLink {
app_instance_id: a["appInstanceId"].as_str().unwrap_or("").to_string(),
label: a["label"].as_str().unwrap_or("").to_string(),
link_url: a["linkUrl"].as_str().unwrap_or("").to_string(),
logo_url: a["logoUrl"].as_str().map(|s| s.to_string()),
app_name: a["appName"].as_str().unwrap_or("").to_string(),
hidden: a["hidden"].as_bool().unwrap_or(false),
})
.collect())
}
-117
View File
@@ -1,117 +0,0 @@
//! Okta WASM Tool for IronClaw.
//!
//! Provides user profile management, SSO app catalog browsing, and
//! launch links for all applications under Okta single sign-on.
//!
//! # Setup
//!
//! 1. Configure OAuth2 with PKCE (see capabilities.json instructions)
//! 2. Write your Okta domain to workspace: `memory_write(path="okta/domain", content="mycompany.okta.com")`
//! 3. All actions read the domain from workspace automatically
//!
//! # Capabilities Required
//!
//! - HTTP: `*.okta.com/api/v1/*`, `*.okta.com/idp/myaccount/*` (GET, POST, PUT)
//! - Secrets: `okta_oauth_token` (injected as Bearer token)
//! - Workspace: `okta/` prefix (read-only, for domain config)
//!
//! # Supported Actions
//!
//! - `get_profile`: Fetch the current user's profile
//! - `update_profile`: Update profile fields
//! - `list_apps`: List all SSO apps assigned to the user
//! - `search_apps`: Search apps by name
//! - `get_app_sso_link`: Get the SSO launch URL for a specific app
//! - `get_org_info`: Get organization details
mod api;
mod types;
use types::OktaAction;
wit_bindgen::generate!({
world: "sandboxed-tool",
path: "../../wit/tool.wit",
});
struct OktaTool;
impl exports::near::agent::tool::Guest for OktaTool {
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
match execute_inner(&req.params) {
Ok(result) => exports::near::agent::tool::Response {
output: Some(result),
error: None,
},
Err(e) => exports::near::agent::tool::Response {
output: None,
error: Some(e),
},
}
}
fn schema() -> String {
r#"{
"type": "object",
"required": ["action"],
"properties": {
"action": {
"type": "string",
"enum": ["get_profile", "update_profile", "list_apps", "search_apps", "get_app_sso_link", "get_org_info"],
"description": "The Okta operation to perform"
},
"fields": {
"type": "object",
"description": "Profile fields to update (e.g., firstName, lastName, email, mobilePhone, displayName, title, department). Required for: update_profile"
},
"query": {
"type": "string",
"description": "Case-insensitive search query to match against app labels and names. Required for: search_apps"
},
"app": {
"type": "string",
"description": "App instance ID (e.g., '0oa1xxx') or app label (e.g., 'Google Workspace'). Required for: get_app_sso_link"
}
}
}"#
.to_string()
}
fn description() -> String {
"Okta SSO tool for managing your profile and accessing all applications under \
single sign-on. Supports viewing/updating your Okta profile, listing all assigned \
SSO apps, searching apps by name, and getting direct SSO launch links. Requires \
Okta domain in workspace at 'okta/domain' and an OAuth token with \
okta.users.read.self, okta.users.manage.self, and okta.apps.read scopes."
.to_string()
}
}
fn execute_inner(params: &str) -> Result<String, String> {
if !crate::near::agent::host::secret_exists("okta_oauth_token") {
return Err(
"Okta OAuth token not configured. Please add the 'okta_oauth_token' secret \
via OAuth2 flow or set the OKTA_OAUTH_TOKEN environment variable."
.to_string(),
);
}
let action: OktaAction =
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?;
crate::near::agent::host::log(
crate::near::agent::host::LogLevel::Info,
&format!("Executing Okta action: {:?}", action),
);
match action {
OktaAction::GetProfile => api::get_profile(),
OktaAction::UpdateProfile { fields } => api::update_profile(&fields),
OktaAction::ListApps => api::list_apps(),
OktaAction::SearchApps { query } => api::search_apps(&query),
OktaAction::GetAppSsoLink { app } => api::get_app_sso_link(&app),
OktaAction::GetOrgInfo => api::get_org_info(),
}
}
export!(OktaTool);
-119
View File
@@ -1,119 +0,0 @@
use serde::{Deserialize, Serialize};
/// Input parameters for the Okta tool.
///
/// Actions map to Okta Management API (/api/v1/) and MyAccount API (/idp/myaccount/).
/// The tool reads the Okta domain from workspace at `okta/domain`.
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum OktaAction {
/// Get the current user's Okta profile.
GetProfile,
/// Update fields on the current user's profile (partial update).
UpdateProfile {
/// Key-value pairs of profile fields to update.
/// Common fields: firstName, lastName, email, mobilePhone, displayName,
/// nickName, title, department, organization.
fields: serde_json::Value,
},
/// List all SSO applications assigned to the current user.
ListApps,
/// Search assigned apps by name (case-insensitive substring match).
SearchApps {
/// Search query to match against app labels.
query: String,
},
/// Get the SSO launch link for a specific app by its instance ID or label.
GetAppSsoLink {
/// App instance ID (e.g., "0oa1xxx") or app label to search for.
app: String,
},
/// Get information about the Okta organization.
GetOrgInfo,
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
/// User profile from Okta.
#[derive(Debug, Serialize)]
pub struct UserProfile {
pub id: String,
pub status: String,
pub first_name: String,
pub last_name: String,
pub email: String,
pub login: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mobile_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nick_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub department: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub organization: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
}
/// Result of a profile update.
#[derive(Debug, Serialize)]
pub struct UpdateProfileResult {
pub success: bool,
pub profile: UserProfile,
}
/// An SSO app link (chiclet) assigned to the user.
#[derive(Debug, Serialize)]
pub struct AppLink {
pub app_instance_id: String,
pub label: String,
pub link_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_url: Option<String>,
pub app_name: String,
pub hidden: bool,
}
/// Result of listing or searching apps.
#[derive(Debug, Serialize)]
pub struct ListAppsResult {
pub apps: Vec<AppLink>,
pub count: usize,
}
/// SSO launch link for a specific app.
#[derive(Debug, Serialize)]
pub struct AppSsoLinkResult {
pub label: String,
pub link_url: String,
pub app_instance_id: String,
pub app_name: String,
}
/// Okta organization info.
#[derive(Debug, Serialize)]
pub struct OrgInfo {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub subdomain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub support_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub technical_contact: Option<String>,
}