diff --git a/.github/workflows/build-gateway.yml b/.github/workflows/build-gateway.yml index b6e7730ff..516e68c3c 100644 --- a/.github/workflows/build-gateway.yml +++ b/.github/workflows/build-gateway.yml @@ -115,6 +115,7 @@ jobs: cd /tmp/digests docker buildx imagetools create \ -t ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.version }} \ + -t ${{ env.IMAGE_NAME }}:v${{ steps.tag.outputs.version }} \ -t ${{ env.IMAGE_NAME }}:latest \ $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) diff --git a/.github/workflows/build-operator-branch.yml b/.github/workflows/build-operator-branch.yml new file mode 100644 index 000000000..efdaa535e --- /dev/null +++ b/.github/workflows/build-operator-branch.yml @@ -0,0 +1,36 @@ +name: Build Operator (Branch) + +on: + push: + branches: + - "operator/**" + workflow_dispatch: + +jobs: + build-macos: + runs-on: [self-hosted, macOS, ARM64] + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Build (aarch64-apple-darwin) + run: cargo build --release + + - name: Package + run: | + mkdir -p dist + cp target/release/openab dist/ + cd dist && tar czf ../openab-macos-arm64.tar.gz * + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: openab-macos-arm64 + path: openab-macos-arm64.tar.gz + retention-days: 7 diff --git a/.github/workflows/build-operator.yml b/.github/workflows/build-operator.yml new file mode 100644 index 000000000..195891c37 --- /dev/null +++ b/.github/workflows/build-operator.yml @@ -0,0 +1,285 @@ +name: Build Operator + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: 'Version tag (e.g. v0.7.0-beta.1 or v0.7.0)' + required: true + type: string + default: 'v' + dry_run: + description: 'Dry run (build only, no push)' + required: false + type: boolean + default: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + resolve-tag: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.resolve.outputs.tag }} + chart_version: ${{ steps.resolve.outputs.chart_version }} + is_prerelease: ${{ steps.resolve.outputs.is_prerelease }} + steps: + - name: Resolve and validate tag + id: resolve + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ inputs.tag }}" + else + TAG="${GITHUB_REF_NAME}" + fi + + # Validate tag format + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "::error::Invalid tag format '${TAG}'. Expected v{major}.{minor}.{patch}[-prerelease]" + exit 1 + fi + + CHART_VERSION="${TAG#v}" + + # Pre-release if version contains '-' (e.g. 0.7.0-beta.1) + if [[ "$CHART_VERSION" == *-* ]]; then + IS_PRERELEASE="true" + else + IS_PRERELEASE="false" + fi + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "chart_version=${CHART_VERSION}" >> "$GITHUB_OUTPUT" + echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT" + + # ── Pre-release path: full build ────────────────────────────── + + build-image: + needs: resolve-tag + if: ${{ needs.resolve-tag.outputs.is_prerelease == 'true' }} + strategy: + matrix: + variant: + - { suffix: "", dockerfile: "Dockerfile", artifact: "default" } + - { suffix: "-codex", dockerfile: "Dockerfile.codex", artifact: "codex" } + - { suffix: "-claude", dockerfile: "Dockerfile.claude", artifact: "claude" } + - { suffix: "-gemini", dockerfile: "Dockerfile.gemini", artifact: "gemini" } + - { suffix: "-copilot", dockerfile: "Dockerfile.copilot", artifact: "copilot" } + - { suffix: "-opencode", dockerfile: "Dockerfile.opencode", artifact: "opencode" } + - { suffix: "-cursor", dockerfile: "Dockerfile.cursor", artifact: "cursor" } + - { suffix: "-hermes", dockerfile: "Dockerfile.hermes", artifact: "hermes" } + - { suffix: "-grok", dockerfile: "Dockerfile.grok", artifact: "grok" } + - { suffix: "-antigravity", dockerfile: "Dockerfile.antigravity", artifact: "antigravity" } + - { suffix: "-pi", dockerfile: "Dockerfile.pi", artifact: "pi" } + - { suffix: "-native", dockerfile: "Dockerfile.native", artifact: "native" } + platform: + - { os: linux/amd64, runner: ubuntu-latest } + - { os: linux/arm64, runner: ubuntu-24.04-arm } + runs-on: ${{ matrix.platform.runner }} + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.variant.dockerfile }} + platforms: ${{ matrix.platform.os }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }},push-by-digest=true,name-canonical=true,push=${{ inputs.dry_run != true }} + cache-from: type=gha,scope=${{ matrix.variant.suffix }}-${{ matrix.platform.os }} + cache-to: type=gha,scope=${{ matrix.variant.suffix }}-${{ matrix.platform.os }},mode=max + + - name: Export digest + if: inputs.dry_run != true + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + if: inputs.dry_run != true + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.variant.artifact }}-${{ matrix.platform.runner }} + path: /tmp/digests/* + retention-days: 1 + + merge-manifests: + needs: [resolve-tag, build-image] + if: ${{ inputs.dry_run != true && needs.resolve-tag.outputs.is_prerelease == 'true' }} + strategy: + matrix: + variant: + - { suffix: "", artifact: "default" } + - { suffix: "-codex", artifact: "codex" } + - { suffix: "-claude", artifact: "claude" } + - { suffix: "-gemini", artifact: "gemini" } + - { suffix: "-copilot", artifact: "copilot" } + - { suffix: "-opencode", artifact: "opencode" } + - { suffix: "-cursor", artifact: "cursor" } + - { suffix: "-hermes", artifact: "hermes" } + - { suffix: "-grok", artifact: "grok" } + - { suffix: "-antigravity", artifact: "antigravity" } + - { suffix: "-pi", artifact: "pi" } + - { suffix: "-native", artifact: "native" } + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-${{ matrix.variant.artifact }}-* + merge-multiple: true + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }} + tags: | + type=sha,prefix= + type=semver,pattern={{version}},value=${{ needs.resolve-tag.outputs.tag }} + type=raw,value=beta + + - name: Create manifest list + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }}@sha256:%s ' *) + + # ── Stable path: promote pre-release image (no rebuild) ────── + + promote-stable: + needs: resolve-tag + if: ${{ inputs.dry_run != true && needs.resolve-tag.outputs.is_prerelease == 'false' }} + strategy: + matrix: + variant: + - { suffix: "" } + - { suffix: "-codex" } + - { suffix: "-claude" } + - { suffix: "-gemini" } + - { suffix: "-copilot" } + - { suffix: "-opencode" } + - { suffix: "-cursor" } + - { suffix: "-hermes" } + - { suffix: "-grok" } + - { suffix: "-antigravity" } + - { suffix: "-pi" } + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Find pre-release image + id: find-prerelease + run: | + CHART_VERSION="${{ needs.resolve-tag.outputs.chart_version }}" + # Find latest pre-release tag matching this version (e.g. v0.7.0-beta.1) + PRERELEASE_TAG=$(git tag -l "v${CHART_VERSION}-*" --sort=-v:refname | head -1) + if [ -z "$PRERELEASE_TAG" ]; then + echo "::error::No pre-release tag found for v${CHART_VERSION}-*. Run a pre-release build first." + exit 1 + fi + PRERELEASE_VERSION="${PRERELEASE_TAG#v}" + echo "Found pre-release: ${PRERELEASE_TAG} (${PRERELEASE_VERSION})" + echo "prerelease_version=${PRERELEASE_VERSION}" >> "$GITHUB_OUTPUT" + + - name: Verify pre-release image exists + run: | + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }}" + PRERELEASE_VERSION="${{ steps.find-prerelease.outputs.prerelease_version }}" + echo "Checking ${IMAGE}:${PRERELEASE_VERSION} ..." + docker buildx imagetools inspect "${IMAGE}:${PRERELEASE_VERSION}" || \ + { echo "::error::Image ${IMAGE}:${PRERELEASE_VERSION} not found — build the pre-release first"; exit 1; } + + - name: Promote to stable tags + run: | + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }}" + PRERELEASE_VERSION="${{ steps.find-prerelease.outputs.prerelease_version }}" + CHART_VERSION="${{ needs.resolve-tag.outputs.chart_version }}" + MAJOR_MINOR="${CHART_VERSION%.*}" + + echo "Promoting ${IMAGE}:${PRERELEASE_VERSION} → ${CHART_VERSION}, ${MAJOR_MINOR}, latest, stable" + docker buildx imagetools create \ + -t "${IMAGE}:${CHART_VERSION}" \ + -t "${IMAGE}:${MAJOR_MINOR}" \ + -t "${IMAGE}:latest" \ + -t "${IMAGE}:stable" \ + "${IMAGE}:${PRERELEASE_VERSION}" + + # ── Chart release (runs after either path) ─────────────────── + + release-chart: + needs: [resolve-tag, merge-manifests, promote-stable] + if: >- + ${{ always() && inputs.dry_run != true && + needs.resolve-tag.result == 'success' && + (needs.merge-manifests.result == 'success' || needs.promote-stable.result == 'success') }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + + - name: Install Helm + uses: azure/setup-helm@v4 + + - uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push chart to OCI + run: | + CHART_VERSION="${{ needs.resolve-tag.outputs.chart_version }}" + helm package charts/openab + helm push openab-${CHART_VERSION}.tgz oci://ghcr.io/${{ github.repository_owner }}/charts diff --git a/.github/workflows/ci-auth-proxy.yml b/.github/workflows/ci-auth-proxy.yml new file mode 100644 index 000000000..740b647c5 --- /dev/null +++ b/.github/workflows/ci-auth-proxy.yml @@ -0,0 +1,42 @@ +name: CI (openab-auth-proxy) + +on: + pull_request: + paths: + - "openab-auth-proxy/**" + push: + branches: [main] + paths: + - "openab-auth-proxy/**" + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: openab-auth-proxy + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: openab-auth-proxy + + - name: cargo check + run: cargo check + + - name: cargo clippy + run: cargo clippy -- -D warnings + + - name: cargo test + run: cargo test + + - name: cargo build (release) + run: cargo build --release diff --git a/.github/workflows/ci-openab-agent.yml b/.github/workflows/ci-openab-agent.yml new file mode 100644 index 000000000..c0d5a3727 --- /dev/null +++ b/.github/workflows/ci-openab-agent.yml @@ -0,0 +1,39 @@ +name: CI openab-agent + +on: + push: + paths: + - 'openab-agent/**' + pull_request: + paths: + - 'openab-agent/**' + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: openab-agent + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: openab-agent + - run: cargo fmt --check + - run: cargo clippy -- -D warnings + - run: cargo test + - run: cargo test -- --ignored + env: + ANTHROPIC_API_KEY: "fake-key-for-ci" + - name: ACP smoke test + run: | + cargo build --release + # Test: initialize returns valid ACP response + RESP=$(echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | timeout 5 ./target/release/openab-agent 2>/dev/null | head -1) + echo "Response: $RESP" + echo "$RESP" | grep -q '"agentInfo"' || (echo "FAIL: no agentInfo in response" && exit 1) + echo "$RESP" | grep -q '"openab-agent"' || (echo "FAIL: wrong agent name" && exit 1) + echo "✅ ACP initialize OK" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4239edd95..4a2e000e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - "src/**" + - "gateway/**" + - "operator/**" - "Cargo.toml" - "Cargo.lock" - "Dockerfile*" @@ -12,22 +14,84 @@ env: CARGO_TERM_COLOR: always jobs: - check: + changes: runs-on: ubuntu-latest + outputs: + core: ${{ steps.filter.outputs.core }} + gateway: ${{ steps.filter.outputs.gateway }} + operator: ${{ steps.filter.outputs.operator }} steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - id: filter + run: | + BASE=${{ github.event.pull_request.base.sha }} + HEAD=${{ github.event.pull_request.head.sha }} + CHANGED=$(git diff --name-only "$BASE" "$HEAD") + echo "core=$(echo "$CHANGED" | grep -qE '^(src/|Cargo\.(toml|lock))' && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "gateway=$(echo "$CHANGED" | grep -q '^gateway/' && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "operator=$(echo "$CHANGED" | grep -q '^operator/' && echo true || echo false)" >> "$GITHUB_OUTPUT" + check: + needs: changes + if: needs.changes.outputs.core == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: clippy - - uses: Swatinem/rust-cache@v2 - - name: cargo check run: cargo check + - name: cargo clippy + run: cargo clippy -- -D warnings + - name: cargo test + run: cargo test + gateway: + needs: changes + if: needs.changes.outputs.gateway == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: gateway + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: gateway + - name: cargo check + run: cargo check - name: cargo clippy run: cargo clippy -- -D warnings + - name: cargo test + run: cargo test + operator: + needs: changes + if: needs.changes.outputs.operator == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: operator + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: operator + - name: cargo check + run: cargo check + - name: cargo clippy + run: cargo clippy -- -D warnings - name: cargo test run: cargo test + - name: cargo build + run: cargo build --release diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index bf10ca989..77170fd4f 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -47,7 +47,7 @@ jobs: await github.rest.issues.createComment({ ...context.repo, issue_number: pr.number, - body: `🔒 Auto-closing: this PR has had the \`${label}\` label for more than ${staleDays} days without a Discord Discussion URL being added.\n\nFeel free to reopen after adding the discussion link to the PR body.` + body: `🔒 Auto-closing: this PR has had the \`${label}\` label for more than ${staleDays} days without activity from the author.\n\nIf you'd like to continue working on this, feel free to reopen and leave a comment.` }); await github.rest.pulls.update({ diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index 64b4653a3..da5cc2980 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -20,6 +20,10 @@ jobs: - { dockerfile: Dockerfile.copilot, suffix: "-copilot", agent: "copilot", agent_args: "--acp" } - { dockerfile: Dockerfile.opencode, suffix: "-opencode", agent: "opencode", agent_args: "acp" } - { dockerfile: Dockerfile.cursor, suffix: "-cursor", agent: "cursor-agent", agent_args: "acp" } + - { dockerfile: Dockerfile.hermes, suffix: "-hermes", agent: "hermes-acp", agent_args: "" } + - { dockerfile: Dockerfile.grok, suffix: "-grok", agent: "grok", agent_args: "agent stdio" } + - { dockerfile: Dockerfile.antigravity, suffix: "-antigravity", agent: "agy-acp", agent_args: "" } + - { dockerfile: Dockerfile.pi, suffix: "-pi", agent: "pi-acp", agent_args: "" } runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -41,17 +45,16 @@ jobs: - name: Verify agent responds run: | - INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"ci-test","version":"0.0.1"}}}' + INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientCapabilities":{},"clientInfo":{"name":"ci-test","version":"0.0.1"}}}' - RAW=$(echo "$INIT" | timeout 30 docker run --rm -i \ - --entrypoint ${{ matrix.variant.agent }} \ - openab-test${{ matrix.variant.suffix }} \ - ${{ matrix.variant.agent_args }} 2>/dev/null || true) + # Start agent in background, send init, capture output with timeout + CID=$(docker run -d -i --entrypoint sh openab-test${{ matrix.variant.suffix }} -c 'exec ${{ matrix.variant.agent }} ${{ matrix.variant.agent_args }} 2>/dev/null') + echo "$INIT" | docker attach --no-stdin=false "$CID" & + sleep 5 + RESPONSE=$(docker logs "$CID" 2>/dev/null | grep -m1 '^{' || true) + docker rm -f "$CID" >/dev/null 2>&1 - echo "Raw output:" - echo "$RAW" - - RESPONSE=$(echo "$RAW" | grep -m1 '^{' || true) + echo "Response: $RESPONSE" if [ -n "$RESPONSE" ] && echo "$RESPONSE" | jq -e '.result.agentInfo.name' > /dev/null 2>&1; then AGENT_NAME=$(echo "$RESPONSE" | jq -r '.result.agentInfo.name') diff --git a/.github/workflows/pending-maintainer.yml b/.github/workflows/pending-maintainer.yml index 55c741d86..c153e1113 100644 --- a/.github/workflows/pending-maintainer.yml +++ b/.github/workflows/pending-maintainer.yml @@ -53,12 +53,6 @@ jobs: continue; } - // Skip if closing-soon — contributor has incomplete work - if (labels.includes('closing-soon')) { - console.log(`#${prNumber} — closing-soon, skipping`); - continue; - } - // Skip if has merge conflicts or already labeled needs-rebase if (pr.mergeable === false || labels.includes('needs-rebase')) { console.log(`#${prNumber} — has conflicts or needs-rebase, skipping`); @@ -121,5 +115,12 @@ jobs: name: CONTRIBUTOR }).catch(() => {}); } + if (labels.includes('closing-soon')) { + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: prNumber, + name: 'closing-soon' + }).catch(() => {}); + } console.log(`#${prNumber} — all clear, set ${MAINTAINER}`); } diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 69ba89077..2dbf2a69c 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -13,11 +13,13 @@ on: type: choice options: - default + - antigravity - claude - codex - copilot - cursor - gemini + - hermes - opencode default: 'default' diff --git a/.gitignore b/.gitignore index 26834d0d4..fe7eedff9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ /target +gateway/target/ config.toml *.swp .DS_Store .env .kiro/ + +# Claude Code (https://claude.ai/code) CLAUDE.md -gateway/target/ +.claude/ +.claude.local.md diff --git a/AGENTS.md b/AGENTS.md index 666c44162..b61901c68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ Never leak `DISCORD_BOT_TOKEN` or other OAB credentials to the agent. ### 4. Dockerfile Discipline -There are 7 Dockerfiles: `Dockerfile`, `Dockerfile.claude`, `Dockerfile.codex`, `Dockerfile.copilot`, `Dockerfile.cursor`, `Dockerfile.gemini`, `Dockerfile.opencode`. +There are 11 Dockerfiles: `Dockerfile`, `Dockerfile.antigravity`, `Dockerfile.claude`, `Dockerfile.codex`, `Dockerfile.copilot`, `Dockerfile.cursor`, `Dockerfile.gemini`, `Dockerfile.grok`, `Dockerfile.hermes`, `Dockerfile.opencode`, `Dockerfile.pi`. A change to one MUST be evaluated against ALL. Common layers (base image, openab binary, tini) are shared — update all or explain why not. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08abe5ab9..556fa27e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,3 +80,74 @@ cargo check - Run `cargo fmt` before committing - Run `cargo clippy` and address warnings - Keep PRs focused — one feature or fix per PR + +## PR Lifecycle + +Every PR follows a label-driven lifecycle that keeps the review loop moving. + +``` +┌──────────────┐ +│ PR Created │ +└──────┬───────┘ + │ + ▼ +┌──────────────────────┐ +│ Automated Checks │ +│ (CI, rebase, etc.) │ +└──────┬───────────────┘ + │ + ├── all pass ──────────────────────►┌──────────────────────┐ + │ │ pending-maintainer │ + │ └──────────┬───────────┘ + │ │ + │ ├── LGTM → approve & merge (or request + │ │ another maintainer review) + │ │ stays pending-maintainer + │ │ + │ └── pending actions for contributor + │ │ + │ ▼ + └── any fail ──────────────────────►┌──────────────────────┐ + │ pending-contributor │◄─────────┐ + └──────────┬───────────┘ │ + │ │ + │ stale 2 days │ + │ (no author activity) │ + ▼ │ + ┌───────────────────┐ │ + │ closing-soon │ │ + │ (or immediate if │ │ + │ blocker detected)│ │ + └────────┬──────────┘ │ + │ │ + ┌────────────┴──────────┐ │ + │ │ │ + ▼ ▼ │ + author comments 3 more days │ + within 3 days no activity │ + │ │ │ + ▼ ▼ │ + ┌────────────────────┐ ┌────────────┐ │ + │ pending-maintainer │ │ PR Closed │ │ + │ (labels removed) │ └────────────┘ │ + └────────┬───────────┘ │ + │ │ + └── re-check fails ────────────────────┘ +``` + +### Label Transitions + +| Current State | Trigger | Action | +|---------------|---------|--------| +| `pending-contributor` | No author activity for 2 days | Add `closing-soon` | +| `closing-soon` | No author activity for 3 more days | Auto-close PR | +| `pending-contributor` | Author adds a comment | Remove `pending-contributor`, add `pending-maintainer` | +| `closing-soon` | Author adds a comment | Remove `closing-soon` and `pending-contributor`, add `pending-maintainer` | + +### Key Rules + +- **`pending-contributor`** — the ball is on the contributor; maintainers are waiting for updates. +- **`closing-soon`** — warning that the PR will be auto-closed if no response within 3 days. +- **Author comment always resets** — any comment by the PR author removes `pending-contributor` and `closing-soon`, flipping the PR back to `pending-maintainer`. +- **Re-check may re-apply `closing-soon`** — after the flip, automated checks still run. If blockers remain (e.g., missing Discord URL, CI failure, `needs-rebase`), `closing-soon` will be re-applied immediately, keeping the ball on the contributor. +- **Immediate `closing-soon`** — in some cases (e.g., missing Discord Discussion URL), `closing-soon` is applied immediately without waiting for the stale period. diff --git a/Cargo.lock b/Cargo.lock index 6950fc242..ad9a99c7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1070,6 +1071,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "toml", + "toml_edit", "tracing", "tracing-subscriber", "unicode-width", diff --git a/Cargo.toml b/Cargo.toml index da2a7bb4a..9b64a3735 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openab" -version = "0.8.3" +version = "0.8.4" edition = "2021" license = "MIT" @@ -9,6 +9,7 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" +toml_edit = "0.22" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "rustls_backend", "cache"] } @@ -27,7 +28,7 @@ unicode-width = "0.2" pulldown-cmark = { version = "0.13", default-features = false } tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] } cron = "0.16.0" -chrono = "0.4.44" +chrono = { version = "0.4.44", features = ["serde"] } chrono-tz = "0.10.4" [target.'cfg(unix)'.dependencies] diff --git a/Dockerfile.antigravity b/Dockerfile.antigravity new file mode 100644 index 000000000..a73fd1f13 --- /dev/null +++ b/Dockerfile.antigravity @@ -0,0 +1,55 @@ +# --- Build openab --- +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY src/ src/ +RUN touch src/main.rs && cargo build --release + +# --- Build agy-acp adapter --- +FROM rust:1-bookworm AS adapter-builder +WORKDIR /build +COPY agy-acp/Cargo.toml ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY agy-acp/src/ src/ +RUN touch src/main.rs && cargo build --release + +# --- Runtime stage --- +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps tini && rm -rf /var/lib/apt/lists/* + +# Install agy (Google Antigravity CLI) +RUN ARCH=$(dpkg --print-architecture) && \ + case "$ARCH" in \ + amd64) PLATFORM="linux_amd64" ;; \ + arm64) PLATFORM="linux_arm64" ;; \ + *) echo "unsupported arch: $ARCH" && exit 1 ;; \ + esac && \ + MANIFEST_URL="https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/${PLATFORM}.json" && \ + DOWNLOAD_URL=$(curl -fsSL "$MANIFEST_URL" | grep -o '"url": *"[^"]*"' | cut -d'"' -f4) && \ + curl -fsSL "$DOWNLOAD_URL" | tar -xz -C /usr/local/bin && \ + mv /usr/local/bin/antigravity /usr/local/bin/agy && \ + chmod +x /usr/local/bin/agy + +# Install gh CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +RUN useradd -m -s /bin/bash agent +ENV HOME=/home/agent +WORKDIR /home/agent + +COPY --from=builder --chown=agent:agent /build/target/release/openab /usr/local/bin/openab +COPY --from=adapter-builder --chown=agent:agent /build/target/release/agy-acp /usr/local/bin/agy-acp + +RUN mkdir -p /home/agent/.gemini && chown -R agent:agent /home/agent/.gemini + +USER agent +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/Dockerfile.claude b/Dockerfile.claude index e98faf577..03ed1c3e3 100644 --- a/Dockerfile.claude +++ b/Dockerfile.claude @@ -8,13 +8,13 @@ RUN touch src/main.rs && cargo build --release # --- Runtime stage --- FROM node:22-bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini bubblewrap socat && rm -rf /var/lib/apt/lists/* # Install claude-agent-acp adapter and Claude Code CLI. # Without CLAUDE_CODE_EXECUTABLE the adapter uses its own bundled SDK cli.js, # ignoring the globally installed claude-code binary (see #418). ARG CLAUDE_AGENT_ACP_VERSION=0.29.2 -ARG CLAUDE_CODE_VERSION=2.1.124 +ARG CLAUDE_CODE_VERSION=2.1.146 RUN npm install -g @agentclientprotocol/claude-agent-acp@${CLAUDE_AGENT_ACP_VERSION} @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} --retry 3 ENV CLAUDE_CODE_EXECUTABLE=/usr/local/bin/claude @@ -31,6 +31,8 @@ WORKDIR /home/node COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/node/.claude && chown -R node:node /home/node/.claude + USER node HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD pgrep -x openab || exit 1 diff --git a/Dockerfile.codex b/Dockerfile.codex index bdc984f04..32fc6a257 100644 --- a/Dockerfile.codex +++ b/Dockerfile.codex @@ -8,11 +8,11 @@ RUN touch src/main.rs && cargo build --release # --- Runtime stage --- FROM node:22-bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini bubblewrap && rm -rf /var/lib/apt/lists/* # Pre-install codex-acp and codex CLI globally -ARG CODEX_ACP_VERSION=0.10.0 -ARG CODEX_VERSION=0.128.0 +ARG CODEX_ACP_VERSION=0.14.0 +ARG CODEX_VERSION=0.133.0 RUN npm install -g @zed-industries/codex-acp@${CODEX_ACP_VERSION} @openai/codex@${CODEX_VERSION} --retry 3 # Install gh CLI @@ -28,6 +28,8 @@ WORKDIR /home/node COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/node/.codex && chown -R node:node /home/node/.codex + USER node HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD pgrep -x openab || exit 1 diff --git a/Dockerfile.copilot b/Dockerfile.copilot index 2391f3d9f..99b81696d 100644 --- a/Dockerfile.copilot +++ b/Dockerfile.copilot @@ -11,7 +11,7 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/* # Install GitHub Copilot CLI via npm (pinned version) -ARG COPILOT_VERSION=1.0.40 +ARG COPILOT_VERSION=1.0.51 RUN npm install -g @github/copilot@${COPILOT_VERSION} --retry 3 # Install gh CLI (for auth and token management) @@ -27,6 +27,8 @@ WORKDIR /home/node COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/node/.copilot && chown -R node:node /home/node/.copilot + USER node HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD pgrep -x openab || exit 1 diff --git a/Dockerfile.cursor b/Dockerfile.cursor index d703da7be..eb37d2600 100644 --- a/Dockerfile.cursor +++ b/Dockerfile.cursor @@ -15,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates # URL scheme scraped from Cursor's official downloads page — no apt/yum package exists. # If Cursor changes this pattern, the build fails with curl 404. Monitor # https://cursor.com/cli or https://docs.cursor.com/cli for version/URL updates. -ARG CURSOR_VERSION=2026.04.30-4edb302 +ARG CURSOR_VERSION=2026.05.20-2b5dd59 RUN ARCH=$(dpkg --print-architecture) && \ if [ "$ARCH" = "arm64" ]; then ARCH=arm64; else ARCH=x64; fi && \ curl -fSL "https://downloads.cursor.com/lab/${CURSOR_VERSION}/linux/${ARCH}/agent-cli-package.tar.gz" \ @@ -39,6 +39,8 @@ WORKDIR /home/agent COPY --from=builder --chown=agent:agent /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/agent/.cursor && chown -R agent:agent /home/agent/.cursor + USER agent HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD pgrep -x openab || exit 1 diff --git a/Dockerfile.gemini b/Dockerfile.gemini index 506dac690..5fdd3c378 100644 --- a/Dockerfile.gemini +++ b/Dockerfile.gemini @@ -11,7 +11,7 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/* # Install Gemini CLI (native ACP support via --acp) -ARG GEMINI_CLI_VERSION=0.40.1 +ARG GEMINI_CLI_VERSION=0.42.0 RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} --retry 3 # Install gh CLI diff --git a/Dockerfile.grok b/Dockerfile.grok new file mode 100644 index 000000000..27b997ffe --- /dev/null +++ b/Dockerfile.grok @@ -0,0 +1,58 @@ +# --- Build stage --- +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY src/ src/ +RUN touch src/main.rs && cargo build --release + +# --- Runtime stage --- +FROM debian:bookworm-slim + +# Create agent user first so WORKDIR gets correct ownership +RUN useradd -m -u 1000 agent + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl procps ripgrep tini git && \ + rm -rf /var/lib/apt/lists/* + +# Install Grok Build CLI — pinned version with SHA256 checksum verification. +# Binary sourced from xAI's public artifacts bucket (same source the official +# `https://x.ai/cli/install.sh` resolves to) so the build is reproducible. +ARG GROK_VERSION=0.1.211 +ARG GROK_SHA256_AMD64=9245f9c921b1f91bfb34ee2ee27715000b65e947723541ff1a612eaece468bd0 +ARG GROK_SHA256_ARM64=b283cb72fdc3143365e044fd7f8630e14845640d4d81404bb36905cc7209abc6 +ARG TARGETPLATFORM +RUN set -eux; \ + case "${TARGETPLATFORM:-linux/amd64}" in \ + "linux/amd64") arch=x86_64; sha="${GROK_SHA256_AMD64}" ;; \ + "linux/arm64") arch=aarch64; sha="${GROK_SHA256_ARM64}" ;; \ + *) echo "Unsupported platform: ${TARGETPLATFORM}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-${GROK_VERSION}-linux-${arch}" \ + -o /tmp/grok && \ + echo "${sha} /tmp/grok" | sha256sum -c - && \ + install -m 0755 /tmp/grok /usr/local/bin/grok && \ + rm /tmp/grok + +# Install gh CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +ENV HOME=/home/agent +WORKDIR /home/agent + +COPY --from=builder --chown=1000:1000 /build/target/release/openab /usr/local/bin/openab + +# Pre-create credential dir so a PVC mounted at ~/.grok inherits correct ownership +RUN mkdir -p /home/agent/.grok && chown -R agent:agent /home/agent + +USER agent +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/Dockerfile.hermes b/Dockerfile.hermes new file mode 100644 index 000000000..7de02b411 --- /dev/null +++ b/Dockerfile.hermes @@ -0,0 +1,52 @@ +# --- Build stage --- +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY src/ src/ +RUN touch src/main.rs && cargo build --release + +# --- Runtime stage --- +FROM python:3.12-slim-bookworm + +# Create agent user first so WORKDIR gets correct ownership +RUN useradd -m -u 1000 agent + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl procps ripgrep tini git ffmpeg xz-utils && \ + rm -rf /var/lib/apt/lists/* + +# Install Hermes Agent — pinned to known commit with checksum verification +# Root install uses FHS layout: binary at /usr/local/bin/hermes, code at /usr/local/lib/hermes-agent +# HERMES_HOME points to agent user's data dir for OAuth tokens and config +ARG HERMES_INSTALL_COMMIT=cc07e30f45267c00fac97ea5569c606aca5a1ffb +ARG HERMES_INSTALL_SHA256=cb94b83b96cc924716bd1651411955da7495912ef68affe6788840e6cf147d41 +RUN curl -fsSL "https://raw.githubusercontent.com/NousResearch/hermes-agent/${HERMES_INSTALL_COMMIT}/scripts/install.sh" \ + -o /tmp/install-hermes.sh && \ + echo "${HERMES_INSTALL_SHA256} /tmp/install-hermes.sh" | sha256sum -c - && \ + HERMES_HOME=/home/agent/.hermes bash /tmp/install-hermes.sh && \ + rm /tmp/install-hermes.sh && \ + chmod -R a+rX /root/.local/share/uv && \ + chmod a+rx /root /root/.local /root/.local/share && \ + ln -sf /usr/local/lib/hermes-agent/venv/bin/hermes-acp /usr/local/bin/hermes-acp + +# Install gh CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +ENV HOME=/home/agent +WORKDIR /home/agent + +COPY --from=builder --chown=1000:1000 /build/target/release/openab /usr/local/bin/openab + +RUN chown -R agent:agent /home/agent + +USER agent +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/Dockerfile.native b/Dockerfile.native new file mode 100644 index 000000000..3879f70e4 --- /dev/null +++ b/Dockerfile.native @@ -0,0 +1,36 @@ +# --- Build stage --- +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY openab-agent/ openab-agent/ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY src/ src/ +RUN touch src/main.rs && cargo build --release +RUN cd openab-agent && cargo build --release + +# --- Runtime stage --- +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git procps ripgrep tini && rm -rf /var/lib/apt/lists/* + +# Install gh CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +RUN useradd -m -s /bin/bash -u 1000 agent +ENV HOME=/home/agent +WORKDIR /home/agent + +COPY --from=builder --chown=agent:agent /build/target/release/openab /usr/local/bin/openab +COPY --from=builder --chown=agent:agent /build/openab-agent/target/release/openab-agent /usr/local/bin/openab-agent + +RUN mkdir -p /home/agent/.openab && chown -R agent:agent /home/agent + +USER agent +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/Dockerfile.opencode b/Dockerfile.opencode index efbd777f8..9c12a4970 100644 --- a/Dockerfile.opencode +++ b/Dockerfile.opencode @@ -26,7 +26,7 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/* # Install opencode -ARG OPENCODE_VERSION=1.14.31 +ARG OPENCODE_VERSION=1.15.7 RUN npm install -g opencode-ai@${OPENCODE_VERSION} --retry 3 # Install gh CLI (matches Dockerfile.claude / Dockerfile.gemini / Dockerfile.codex) @@ -42,6 +42,8 @@ WORKDIR /home/node COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/node/.opencode && chown -R node:node /home/node/.opencode + USER node HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD pgrep -x openab || exit 1 diff --git a/Dockerfile.pi b/Dockerfile.pi new file mode 100644 index 000000000..5e9f3bba9 --- /dev/null +++ b/Dockerfile.pi @@ -0,0 +1,38 @@ +# --- Build stage --- +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY src/ src/ +RUN touch src/main.rs && cargo build --release + +# --- Runtime stage --- +FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git procps ripgrep tini bubblewrap socat && rm -rf /var/lib/apt/lists/* + +# Install pi-acp adapter and Pi coding agent CLI. +# The Pi coding agent npm package was renamed from @mariozechner/pi-coding-agent to @earendil-works/pi-coding-agent. +ARG PI_ACP_VERSION=0.0.27 +ARG PI_CODING_AGENT_VERSION=0.75.5 +RUN npm install -g pi-acp@${PI_ACP_VERSION} @earendil-works/pi-coding-agent@${PI_CODING_AGENT_VERSION} --retry 3 + +# Install gh CLI (matches other Dockerfiles) +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +ENV HOME=/home/node +WORKDIR /home/node + +COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab + +RUN mkdir -p /home/node/.pi && chown -R node:node /home/node/.pi + +USER node +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/README.md b/README.md index c8a9d3d8e..406e7fc91 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,26 @@ ![OpenAB banner](images/banner.jpg) -A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/). +A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, Antigravity, Pi, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/). 🪼 **Join our community!** Come say hi on Discord — we'd love to have you: **[🪼 OpenAB — Official](https://discord.gg/DmbhfDZjQS)** 🎉 ``` -┌──────────────┐ Gateway WS ┌──────────────┐ ACP stdio ┌──────────────┐ -│ Discord │◄─────────────►│ │──────────────►│ coding CLI │ -│ User │ │ openab │◄── JSON-RPC ──│ (acp mode) │ -├──────────────┤ Socket Mode │ (Rust) │ └──────────────┘ -│ Slack │◄─────────────►│ │ -│ User │ └──────┬───────┘ -├──────────────┤ │ WebSocket (outbound) -│ Telegram │◄──webhook──┐ │ -│ User │ │ │ -├──────────────┤ ▼ ▼ -│ LINE │◄──webhook──┌──────────────────┐ -│ User │ │ Custom Gateway │ -├──────────────┤ │ (standalone) │ -│ Feishu/Lark │◄───WS──────│ │ -│ User │ │ │ +┌──────────────┐ Gateway WS ┌──────────────┐ ACP stdio ┌──────────────────┐ +│ Discord │◄─────────────►│ │──────────────►│ coding CLI │ +│ User │ │ openab │◄── JSON-RPC ──│ (acp mode) │ +├──────────────┤ Socket Mode │ (Rust) │ ├──────────────────┤ +│ Slack │◄─────────────►│ │ │ kiro-cli acp │ +│ User │ └──────┬───────┘ │ claude-agent-acp │ +├──────────────┤ │ WebSocket │ codex-acp │ +│ Telegram │◄──webhook──┐ │ (outbound) │ gemini --acp │ +│ User │ │ │ │ copilot --acp │ +├──────────────┤ ▼ ▼ │ hermes-acp │ +│ LINE │◄──webhook──┌──────────────────┐ │ opencode acp │ +│ User │ │ Custom Gateway │ │ grok agent stdio │ +├──────────────┤ │ (standalone) │ │ agy-acp │ +│ Feishu/Lark │◄───WS──────│ │ │ pi-acp │ +│ User │ │ │ └──────────────────┘ ├──────────────┤ │ │ │ Google Chat │◄──webhook──│ │ │ User │ └──────────────────┘ @@ -38,10 +38,11 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, - **Multi-platform** — supports Discord and Slack, run one or both simultaneously - **Custom Gateway** — extend to Telegram, LINE, Feishu/Lark, Google Chat, MS Teams via standalone [gateway](gateway/) -- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI via config +- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, Antigravity, Pi via config - **@mention trigger** — mention the bot in an allowed channel to start a conversation - **Thread-based multi-turn** — auto-creates threads; no @mention needed for follow-ups - **Multi-agent collaboration** — bot-to-bot messaging for coordinated workflows ([docs/multi-agent.md](docs/multi-agent.md)) +- **Agent-controlled reply-to** — agents choose which message to reply to via `[[reply_to:id]]` directive, enabling clear conversation threads in multi-bot channels ([docs/output-directives.md](docs/output-directives.md)) - **Edit-streaming** — live-updates the Discord message every 1.5s as tokens arrive - **Emoji status reactions** — 👀→🤔→🔥/👨‍💻/⚡→👍+random mood face - **Image & file support** — send images and files through chat ([docs/sendimages.md](docs/sendimages.md), [docs/sendfiles.md](docs/sendfiles.md)) @@ -111,6 +112,13 @@ See [docs/google-chat.md](docs/google-chat.md) for the full setup guide. Require +
+WeCom (企业微信) (via Custom Gateway) + +See [docs/wecom.md](docs/wecom.md) for the full setup guide. Requires the standalone [Custom Gateway](gateway/) service. + +
+ ### 2. Install with Helm (Kiro CLI — default) ```bash @@ -129,6 +137,8 @@ helm install openab openab/openab \ --set-string 'agents.kiro.slack.allowedChannels[0]=C0123456789' ``` +For additional Helm values such as `fullnameOverride`, `nameOverride`, `envFrom`, and `agentsMd`, see [charts/openab/README.md](charts/openab/README.md). + ### 3. Authenticate (first time only) ```bash @@ -158,6 +168,11 @@ The bot creates a thread. After that, just type in the thread — no @mention ne | OpenCode | `opencode acp` | Native | [docs/opencode.md](docs/opencode.md) | | Copilot CLI ⚠️ | `copilot --acp --stdio` | Native | [docs/copilot.md](docs/copilot.md) | | Cursor | `cursor-agent acp` | Native | [docs/cursor.md](docs/cursor.md) | +| Hermes Agent | `hermes-acp` | Native | [docs/hermes.md](docs/hermes.md) | +| Grok Build | `grok agent stdio` | Native | [docs/grok.md](docs/grok.md) | +| Antigravity | `agy-acp` | [agy-acp](agy-acp/) | [docs/antigravity.md](docs/antigravity.md) | +| Pi | `pi-acp` | [pi-acp](https://www.npmjs.com/package/pi-acp) | [docs/pi.md](docs/pi.md) | +| **Native Agent** | `openab-agent` | Built-in (Rust) | [docs/native-agent.md](docs/native-agent.md) | > 🔧 Running multiple agents? See [docs/multi-agent.md](docs/multi-agent.md) diff --git a/agy-acp/.gitignore b/agy-acp/.gitignore new file mode 100644 index 000000000..eb5a316cb --- /dev/null +++ b/agy-acp/.gitignore @@ -0,0 +1 @@ +target diff --git a/agy-acp/Cargo.lock b/agy-acp/Cargo.lock new file mode 100644 index 000000000..a40584813 --- /dev/null +++ b/agy-acp/Cargo.lock @@ -0,0 +1,654 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agy-acp" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/agy-acp/Cargo.toml b/agy-acp/Cargo.toml new file mode 100644 index 000000000..681898924 --- /dev/null +++ b/agy-acp/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "agy-acp" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "ACP stdio adapter for Google Antigravity CLI (agy)" + +[dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4"] } diff --git a/agy-acp/src/main.rs b/agy-acp/src/main.rs new file mode 100644 index 000000000..51ede1e43 --- /dev/null +++ b/agy-acp/src/main.rs @@ -0,0 +1,254 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; +use tokio::process::Command; +use tokio::sync::mpsc; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + id: Option, + method: Option, + params: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcResponse { + jsonrpc: &'static str, + id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcNotification { + jsonrpc: &'static str, + method: String, + params: Value, +} + +struct Session { + /// agy conversation ID (from conversations directory) + conversation_id: Option, + /// cumulative stdout length from previous turns + prev_output_len: usize, +} + +struct Adapter { + sessions: HashMap, + working_dir: String, + conversations_dir: PathBuf, +} + +impl Adapter { + fn new() -> Self { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + Self { + sessions: HashMap::new(), + working_dir: std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "/tmp".to_string()), + conversations_dir: PathBuf::from(&home) + .join(".gemini/antigravity-cli/conversations"), + } + } + + /// Find the most recently modified conversation ID from agy's data dir. + fn latest_conversation_id(&self) -> Option { + let entries = std::fs::read_dir(&self.conversations_dir).ok()?; + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map(|x| x == "pb").unwrap_or(false)) + .max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok())) + .and_then(|e| e.path().file_stem().map(|s| s.to_string_lossy().to_string())) + } + + fn handle_initialize(&self, id: u64) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(json!({ + "protocolVersion": 1, + "agentInfo": { "name": "agy", "version": env!("CARGO_PKG_VERSION") }, + "agentCapabilities": { "streaming": true, "loadSession": false }, + })), + error: None, + } + } + + fn handle_session_new(&mut self, id: u64) -> JsonRpcResponse { + let session_id = Uuid::new_v4().to_string(); + self.sessions.insert(session_id.clone(), Session { + conversation_id: None, + prev_output_len: 0, + }); + JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(json!({ "sessionId": session_id })), + error: None, + } + } + + async fn handle_session_prompt(&mut self, id: u64, params: &Value) -> Vec { + let session_id = params.get("sessionId").and_then(|v| v.as_str()).unwrap_or(""); + let prompt_text = params + .get("prompt") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + let clean_prompt = prompt_text.trim(); + + // Build args: use --conversation for subsequent turns + let mut args: Vec = Vec::new(); + // Always add working dir as workspace so agy reads AGENTS.md/GEMINI.md + args.push("--add-dir".to_string()); + args.push(self.working_dir.clone()); + // Add extra args from AGY_EXTRA_ARGS env var if set + if let Ok(extra) = std::env::var("AGY_EXTRA_ARGS") { + args.extend(extra.split_whitespace().map(String::from)); + } + if let Some(session) = self.sessions.get(session_id) { + if let Some(conv_id) = &session.conversation_id { + args.push("--conversation".to_string()); + args.push(conv_id.clone()); + } + } + args.push("-p".to_string()); + args.push(clean_prompt.to_string()); + + let result = Command::new("agy") + .args(&args) + .current_dir(&self.working_dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .await; + + let mut output_lines = Vec::new(); + + match result { + Ok(output) => { + let full_text = String::from_utf8_lossy(&output.stdout).to_string(); + + // Extract only the new content (delta) + let prev_len = self.sessions.get(session_id) + .map(|s| s.prev_output_len) + .unwrap_or(0); + let new_text = if prev_len < full_text.len() { + full_text[prev_len..].trim_start().to_string() + } else { + full_text.clone() + }; + + // Update session state + let conv_id = if self.sessions.get(session_id) + .map(|s| s.conversation_id.is_none()) + .unwrap_or(false) + { + self.latest_conversation_id() + } else { + None + }; + + if let Some(session) = self.sessions.get_mut(session_id) { + session.prev_output_len = full_text.len(); + if session.conversation_id.is_none() { + session.conversation_id = conv_id; + } + } + + let notification = serde_json::to_string(&JsonRpcNotification { + jsonrpc: "2.0", + method: "session/update".to_string(), + params: json!({ + "sessionId": session_id, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": new_text }, + }, + }), + }).unwrap(); + output_lines.push(notification); + let resp = JsonRpcResponse { jsonrpc: "2.0", id, result: Some(json!({ "stopReason": "end_turn" })), error: None }; + output_lines.push(serde_json::to_string(&resp).unwrap()); + } + Err(e) => { + let resp = JsonRpcResponse { jsonrpc: "2.0", id, result: None, error: Some(json!({"code":-32000,"message":format!("failed to run agy: {e}")})) }; + output_lines.push(serde_json::to_string(&resp).unwrap()); + } + } + output_lines + } +} + +#[tokio::main] +async fn main() { + let mut adapter = Adapter::new(); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + match line { + Ok(l) if !l.trim().is_empty() => { + if tx.send(l).is_err() { + break; + } + } + Err(_) => break, + _ => {} + } + } + }); + + let mut stdout = io::stdout(); + + while let Some(line) = rx.recv().await { + let req: JsonRpcRequest = match serde_json::from_str(&line) { + Ok(r) => r, + Err(_) => continue, + }; + let id = match req.id { + Some(id) => id, + None => continue, + }; + + let output = match req.method.as_deref() { + Some("initialize") => { + vec![serde_json::to_string(&adapter.handle_initialize(id)).unwrap()] + } + Some("session/new") => { + vec![serde_json::to_string(&adapter.handle_session_new(id)).unwrap()] + } + Some("session/prompt") => { + let params = req.params.unwrap_or(json!({})); + adapter.handle_session_prompt(id, ¶ms).await + } + Some("session/cancel") => { + let r = JsonRpcResponse { jsonrpc: "2.0", id, result: Some(json!({})), error: None }; + vec![serde_json::to_string(&r).unwrap()] + } + Some(method) => { + let r = JsonRpcResponse { jsonrpc: "2.0", id, result: None, error: Some(json!({"code":-32601,"message":format!("method not found: {method}")})) }; + vec![serde_json::to_string(&r).unwrap()] + } + None => continue, + }; + + for line in output { + let _ = writeln!(stdout, "{}", line); + } + let _ = stdout.flush(); + } +} diff --git a/charts/openab-feishu/Chart.yaml b/charts/openab-feishu/Chart.yaml new file mode 100644 index 000000000..7904d83aa --- /dev/null +++ b/charts/openab-feishu/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: openab-feishu +description: OpenAB + Feishu/Lark — single-pod deployment with gateway (WebSocket by default, optional Cloudflare Tunnel for webhook mode). +type: application +version: 0.1.0 +appVersion: "0.8.3" diff --git a/charts/openab-feishu/README.md b/charts/openab-feishu/README.md new file mode 100644 index 000000000..69711e5ef --- /dev/null +++ b/charts/openab-feishu/README.md @@ -0,0 +1,220 @@ +# openab-feishu + +OpenAB + Feishu/Lark — single-pod Helm chart for deploying an AI agent on Feishu (飛書) or Lark. + +## Architecture + +``` +┌──────────── Pod: openab-feishu ────────────┐ +│ │ +│ ┌──────────┐ localhost ┌─────────────┐ │ +│ │ openab │ ◄──────────► │ gateway │ │ +│ │ (agent) │ │ (feishu) │ │ +│ └──────────┘ └──────┬──────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ PVC /home/agent Outbound WebSocket │ +│ to open.feishu.cn │ +└─────────────────────────────────────────────┘ +``` + +**Default mode: WebSocket** — gateway connects outbound to Feishu, no public endpoint needed. + +Optional webhook mode adds a cloudflared sidecar (same pattern as `openab-telegram`). + +## Quick Start + +```bash +helm install my-bot ./charts/openab-feishu \ + --set feishu.appId="cli_xxx" \ + --set feishu.appSecret="xxx" \ + --namespace openab --create-namespace +``` + +Only **2 required parameters**. Everything else has sane defaults. + +## Prerequisites: Feishu Open Platform Setup + +Before deploying, create a Feishu app. This is a one-time setup. + +> **Hermes Agent** and **OpenClaw** both support Feishu via their own gateway implementations (env-var based config, `pip install` / `npm install`). OAB's approach is K8s-native: a single `helm install` deploys everything, with credentials managed as K8s Secrets. + +### 1. Create App + +1. Go to [Feishu Open Platform](https://open.feishu.cn) (or [Lark Developer](https://open.larksuite.com) for overseas) +2. Click **Create Custom App** +3. Note down the **App ID** (`cli_xxx`) and **App Secret** + +### 2. Enable Bot Capability + +1. In the left sidebar, go to **App Features** → **Bot** +2. Toggle **Enable Bot** to ON + +> ⚠️ This step is easy to miss. Without it, the app cannot receive messages. + +### 3. Configure Event Subscription + +1. Navigate to **Event Subscriptions** in the left sidebar +2. **Connection mode**: Select **WebSocket** (recommended) + - WebSocket requires no public URL — the gateway connects outbound + - If you must use webhook mode, see [Webhook Mode](#webhook-mode) below +3. **Add event**: `im.message.receive_v1` (Receive messages) + +### 4. Add Permissions + +Under **Permissions & Scopes**, add these scopes: + +| Scope | Purpose | +|-------|---------| +| `im:message` | Send and receive messages | +| `im:message.group_at_msg` | Receive @mention messages in groups | +| `im:message.group_at_msg:readonly` | Read group @mention messages | +| `im:message.p2p_msg:readonly` | Read DM messages | +| `im:resource` | Download images/files from messages | +| `contact:user.base:readonly` | Resolve user display names | + +### 5. Publish + +Click **Create Version** → **Apply for publish**. For development, you can use the app in test mode without full approval. + +### 6. Get IDs for Access Control (Optional) + +To restrict which users/groups can interact with the bot: + +- **Group ID** (`oc_xxx`): Open the group → click top-right menu → Settings → Group ID +- **User Open ID** (`ou_xxx`): Check gateway logs after the user sends a message, or use the Feishu Contact API + +## Credential Management + +Three options from simplest to most secure: + +| # | Method | Security | Notes | +|---|--------|----------|-------| +| 1 | `--set feishu.appId=X --set feishu.appSecret=Y` | ⚠️ Stored in Helm release | Good for dev/testing | +| 2 | `kubectl create secret` + `--set existingSecret=name` | ✅ Out of Helm values | Good for production | +| 3 | `kubectl create secret --from-env-file=<(vault/aws sm)` + `--set existingSecret=name` | ✅✅ Never touches disk | Best for security | + +### Option 2 example: + +```bash +kubectl create secret generic feishu-creds -n openab \ + --from-literal=feishu-app-id="cli_xxx" \ + --from-literal=feishu-app-secret="xxx" + +helm install my-bot ./charts/openab-feishu \ + --set existingSecret=feishu-creds \ + --namespace openab --create-namespace +``` + +### Option 3 example (AWS Secrets Manager): + +```bash +kubectl create secret generic feishu-creds -n openab \ + --from-env-file=<(aws secretsmanager get-secret-value \ + --secret-id oab-feishu --query SecretString --output text | \ + jq -r '{"feishu-app-id": .appId, "feishu-app-secret": .appSecret} | to_entries[] | "\(.key)=\(.value)"') + +helm install my-bot ./charts/openab-feishu \ + --set existingSecret=feishu-creds \ + --namespace openab --create-namespace +``` + +## Release Channel + +| `channel` | Core image tag | Gateway image tag | +|-----------|---------------|-------------------| +| `stable` (default) | `ghcr.io/openabdev/openab:stable` | `v0.5.1` (pinned) | +| `beta` | `ghcr.io/openabdev/openab:beta` | `v0.5.1` (pinned) | + +## Webhook Mode + +If WebSocket is not available (e.g., network policy blocks outbound WebSocket), switch to webhook mode: + +```bash +helm install my-bot ./charts/openab-feishu \ + --set feishu.appId="cli_xxx" \ + --set feishu.appSecret="xxx" \ + --set feishu.connectionMode="webhook" \ + --set feishu.verificationToken="xxx" \ + --set feishu.encryptKey="xxx" \ + --set tunnel.token="eyJ..." \ + --set webhookDomain="bot.example.com" \ + --namespace openab --create-namespace +``` + +This adds a cloudflared sidecar (3-container pod, same as `openab-telegram`). + +After deployment: +1. Configure Cloudflare Tunnel ingress to point your domain at `localhost:8080` +2. In Feishu Open Platform → Event Subscriptions → set Request URL to `https://bot.example.com/webhook/feishu` + - ⚠️ The gateway must be running when you set the URL — Feishu sends a challenge request immediately + +## Lark (Overseas) + +For Lark (larksuite.com) instead of Feishu (feishu.cn): + +```bash +helm install my-bot ./charts/openab-feishu \ + --set feishu.appId="cli_xxx" \ + --set feishu.appSecret="xxx" \ + --set feishu.domain="lark" \ + --namespace openab --create-namespace +``` + +## Comparison with Other Platforms + +| Feature | openab-feishu | openab-telegram | OpenClaw | Hermes Agent | +|---------|--------------|-----------------|----------|--------------| +| Default containers | 2 (agent + gateway) | 3 (+ cloudflared) | N/A (no Helm) | N/A (no Helm) | +| Public endpoint needed | ❌ (WebSocket) | ✅ (webhook) | Varies | Varies | +| Feishu/Lark support | ✅ Native | ❌ | ❌ | ❌ | +| K8s-native deployment | ✅ Helm chart | ✅ Helm chart | ❌ docker-compose | ❌ pip install | +| Credential params | 2 (appId + appSecret) | 2 (botToken + tunnelToken) | N/A | N/A | + +## Values Reference + +| Key | Default | Description | +|-----|---------|-------------| +| `feishu.appId` | `""` | **(required)** Feishu App ID | +| `feishu.appSecret` | `""` | **(required)** Feishu App Secret | +| `feishu.domain` | `"feishu"` | `"feishu"` or `"lark"` | +| `feishu.connectionMode` | `"websocket"` | `"websocket"` or `"webhook"` | +| `feishu.verificationToken` | `""` | Webhook verification token | +| `feishu.encryptKey` | `""` | Webhook encrypt key | +| `existingSecret` | `""` | Use pre-existing K8s Secret | +| `tunnel.enabled` | `false` | Enable cloudflared sidecar | +| `tunnel.token` | `""` | Cloudflare Tunnel token | +| `webhookDomain` | `""` | Domain for webhook URL | +| `channel` | `"stable"` | `"stable"` or `"beta"` | +| `platform.requireMention` | `true` | Require @mention in groups | +| `platform.allowedGroups` | `[]` | Allowed group chat IDs | +| `platform.allowedUsers` | `[]` | Allowed user open_ids | +| `persistence.enabled` | `true` | Enable PVC for agent state | +| `persistence.size` | `"1Gi"` | PVC size | + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| Bot doesn't respond to DMs | Ensure Bot capability is enabled in App Features → Bot | +| Bot doesn't respond in groups | Ensure you @mention the bot (default: `requireMention: true`) | +| Bot doesn't receive any messages | Check event subscription: must have `im.message.receive_v1` and WebSocket mode selected | +| Gateway logs show "token refresh error" | Verify `appId` and `appSecret` are correct | +| Gateway logs show "feishu ws endpoint error" | WebSocket mode requires the app to be published (at least test version) | +| Permission denied on image/file download | Grant `im:resource` scope and re-publish the app | +| User names show as `ou_xxx` | Grant `contact:user.base:readonly` scope | +| Pod CrashLoopBackOff | Check `kubectl logs -c gateway` — usually a credential issue | + +## Comparison: OAB vs OpenClaw vs Hermes Agent (Feishu) + +| Aspect | OAB (this chart) | OpenClaw | Hermes Agent | +|--------|-----------------|----------|--------------| +| Deployment | `helm install` (K8s-native) | `npx @larksuite/openclaw-lark install` | `hermes gateway setup` | +| Runtime | Rust binary (gateway) + any agent | Node.js | Python | +| Connection mode | WebSocket (default) / Webhook | WebSocket (default) / Webhook | WebSocket (default) / Webhook | +| Config style | Helm values + K8s Secrets | JSON config file | `.env` + `config.yaml` | +| Credential management | 3-tier (--set → K8s Secret → external SM) | Plain config file | `.env` file | +| Security hardening | Non-root, read-only rootfs, drop all caps | N/A (runs as user process) | N/A (runs as user process) | +| Public endpoint needed | ❌ (WebSocket mode) | ❌ (WebSocket mode) | ❌ (WebSocket mode) | +| Feishu-specific features | @mention gating, user allowlist, group allowlist, bot-to-bot, media proxy | Streaming cards, multi-account, ACP sessions, pairing | Interactive cards, document comments, per-group ACL, burst batching | + diff --git a/charts/openab-feishu/templates/NOTES.txt b/charts/openab-feishu/templates/NOTES.txt new file mode 100644 index 000000000..f25b8373f --- /dev/null +++ b/charts/openab-feishu/templates/NOTES.txt @@ -0,0 +1,73 @@ +🎉 OpenAB Feishu bot deployed! + +{{- if include "openab-feishu.tunnelEnabled" . }} +Pod: {{ include "openab-feishu.fullname" . }} (3 containers: openab, gateway, cloudflared) +Mode: webhook (with Cloudflare Tunnel) +{{- else }} +Pod: {{ include "openab-feishu.fullname" . }} (2 containers: openab, gateway) +Mode: websocket (outbound-only, no public endpoint needed) +{{- end }} + +## Post-Install Steps + +{{- if eq .Values.feishu.connectionMode "websocket" }} + +### Step 1: Configure Feishu Open Platform (one-time) + +1. Go to https://open.feishu.cn → Create or select your app +2. Under "Event Subscriptions" → Connection mode: select "WebSocket" +3. Subscribe to event: im.message.receive_v1 +4. Under "Permissions", add: + - im:message (Send and receive messages) + - im:message.group_at_msg (Receive group @messages) + - im:resource (Download message resources) + - contact:user.base:readonly (Read user basic info — for display names) +5. Publish the app version + +No public URL or webhook configuration needed — the gateway connects outbound. + +{{- else }} + +### Step 1: Configure Cloudflare Tunnel ingress + +Option A — Via Cloudflare Dashboard: + https://one.dash.cloudflare.com/ → Networks → Tunnels → your tunnel → Public Hostname → Add: + Hostname: {{ .Values.webhookDomain | default "bot.example.com" }} + Type: HTTP + URL: localhost:8080 + +### Step 2: Configure Feishu Open Platform + +1. Go to https://open.feishu.cn → Create or select your app +2. Under "Event Subscriptions" → Connection mode: select "HTTP" +3. Set Request URL: https://{{ .Values.webhookDomain | default "YOUR_DOMAIN" }}/webhook/feishu + (The gateway must be running — Feishu sends a challenge request immediately) +4. Subscribe to event: im.message.receive_v1 +5. Under "Permissions", add: + - im:message + - im:message.group_at_msg + - im:resource + - contact:user.base:readonly +6. Publish the app version + +{{- end }} + +### {{ if eq .Values.feishu.connectionMode "websocket" }}Step 2{{ else }}Step 3{{ end }}: Authenticate the agent + +The agent needs a one-time OAuth login: + + kubectl exec -it deployment/{{ include "openab-feishu.fullname" . }} -n {{ .Release.Namespace }} -c openab -- {{ .Values.agent.command }} login --use-device-flow + +Then restart to pick up credentials: + + kubectl rollout restart deployment/{{ include "openab-feishu.fullname" . }} -n {{ .Release.Namespace }} + +## Verify + +Send a message to your bot on Feishu (DM or @mention in a group). Check logs if no response: + + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-feishu.fullname" . }} -c openab --tail=20 + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-feishu.fullname" . }} -c gateway --tail=20 +{{- if include "openab-feishu.tunnelEnabled" . }} + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-feishu.fullname" . }} -c cloudflared --tail=20 +{{- end }} diff --git a/charts/openab-feishu/templates/_helpers.tpl b/charts/openab-feishu/templates/_helpers.tpl new file mode 100644 index 000000000..f0c4a85d6 --- /dev/null +++ b/charts/openab-feishu/templates/_helpers.tpl @@ -0,0 +1,41 @@ +{{- define "openab-feishu.fullname" -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "openab-feishu.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 }} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "openab-feishu.selectorLabels" -}} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "openab-feishu.agentImage" -}} +{{- $tag := .Values.image.tag -}} +{{- if not $tag -}} + {{- $tag = .Values.channel | default "stable" -}} +{{- end -}} +{{- printf "%s:%s" .Values.image.repository $tag -}} +{{- end }} + +{{- define "openab-feishu.gatewayImage" -}} +{{- printf "%s:%s" .Values.gateway.image .Values.gateway.tag -}} +{{- end }} + +{{- define "openab-feishu.secretName" -}} +{{- .Values.existingSecret | default (include "openab-feishu.fullname" .) -}} +{{- end }} + +{{- define "openab-feishu.tunnelEnabled" -}} +{{- if .Values.tunnel.enabled -}} +true +{{- else if and (eq .Values.feishu.connectionMode "webhook") .Values.tunnel.token -}} +true +{{- else -}} +{{- end -}} +{{- end }} diff --git a/charts/openab-feishu/templates/configmap.yaml b/charts/openab-feishu/templates/configmap.yaml new file mode 100644 index 000000000..baf3fe999 --- /dev/null +++ b/charts/openab-feishu/templates/configmap.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "openab-feishu.fullname" . }} + labels: + {{- include "openab-feishu.labels" . | nindent 4 }} +data: + config.toml: | + [agent] + command = {{ .Values.agent.command | toJson }} + args = {{ .Values.agent.args | default list | toJson }} + working_dir = {{ .Values.agent.workingDir | default "/home/agent" | toJson }} + {{- if .Values.agent.env }} + env = { {{ range $i, $k := (.Values.agent.env | keys | sortAlpha) }}{{ if gt $i 0 }}, {{ end }}{{ $k }} = {{ index $.Values.agent.env $k | toJson }}{{ end }} } + {{- end }} + {{- $secretEnvKeys := list }} + {{- range .Values.agent.secretEnv }}{{ $secretEnvKeys = append $secretEnvKeys .name }}{{ end }} + {{- if $secretEnvKeys }} + inherit_env = {{ $secretEnvKeys | toJson }} + {{- end }} + + [pool] + max_sessions = {{ .Values.agent.pool.maxSessions | default 10 }} + session_ttl_hours = {{ .Values.agent.pool.sessionTtlHours | default 24 }} + + [reactions] + enabled = {{ .Values.agent.reactions.enabled }} + remove_after_reply = {{ .Values.agent.reactions.removeAfterReply }} + + [gateway] + url = "ws://localhost:8080/ws" + platform = "feishu" + allow_all_channels = {{ if .Values.platform.allowedGroups }}false{{ else }}true{{ end }} + allowed_channels = {{ .Values.platform.allowedGroups | default list | toJson }} + allow_all_users = {{ if .Values.platform.allowedUsers }}false{{ else }}true{{ end }} + allowed_users = {{ .Values.platform.allowedUsers | default list | toJson }} diff --git a/charts/openab-feishu/templates/deployment.yaml b/charts/openab-feishu/templates/deployment.yaml new file mode 100644 index 000000000..dbadc7962 --- /dev/null +++ b/charts/openab-feishu/templates/deployment.yaml @@ -0,0 +1,187 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "openab-feishu.fullname" . }} + labels: + {{- include "openab-feishu.labels" . | nindent 4 }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "openab-feishu.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- if not .Values.existingSecret }} + checksum/secret: {{ include (print .Template.BasePath "/secret.yaml") . | sha256sum }} + {{- end }} + labels: + {{- include "openab-feishu.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + # --- OAB agent (main) --- + - name: openab + image: {{ include "openab-feishu.agentImage" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: HOME + value: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- range $k, $v := .Values.agent.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- range .Values.agent.secretEnv }} + - name: {{ .name }} + valueFrom: + secretKeyRef: + name: {{ .secretName }} + key: {{ .secretKey }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: config + mountPath: /etc/openab + readOnly: true + {{- if .Values.persistence.enabled }} + - name: data + mountPath: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- end }} + - name: tmp + mountPath: /tmp + + # --- Gateway (sidecar) --- + - name: gateway + image: {{ include "openab-feishu.gatewayImage" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: HOME + value: {{ .Values.agent.workingDir | default "/home/agent" }} + - name: FEISHU_APP_ID + valueFrom: + secretKeyRef: + name: {{ include "openab-feishu.secretName" . }} + key: feishu-app-id + - name: FEISHU_APP_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openab-feishu.secretName" . }} + key: feishu-app-secret + - name: FEISHU_DOMAIN + value: {{ .Values.feishu.domain | quote }} + - name: FEISHU_CONNECTION_MODE + value: {{ .Values.feishu.connectionMode | quote }} + {{- if or .Values.feishu.verificationToken (eq .Values.feishu.connectionMode "webhook") }} + - name: FEISHU_VERIFICATION_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab-feishu.secretName" . }} + key: feishu-verification-token + optional: true + {{- end }} + {{- if or .Values.feishu.encryptKey (eq .Values.feishu.connectionMode "webhook") }} + - name: FEISHU_ENCRYPT_KEY + valueFrom: + secretKeyRef: + name: {{ include "openab-feishu.secretName" . }} + key: feishu-encrypt-key + optional: true + {{- end }} + {{- if .Values.platform.requireMention }} + - name: FEISHU_REQUIRE_MENTION + value: "true" + {{- else }} + - name: FEISHU_REQUIRE_MENTION + value: "false" + {{- end }} + {{- if .Values.platform.allowedGroups }} + - name: FEISHU_ALLOWED_GROUPS + value: {{ join "," .Values.platform.allowedGroups | quote }} + {{- end }} + {{- if .Values.platform.allowedUsers }} + - name: FEISHU_ALLOWED_USERS + value: {{ join "," .Values.platform.allowedUsers | quote }} + {{- end }} + volumeMounts: + {{- if .Values.persistence.enabled }} + - name: data + mountPath: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- end }} + - name: tmp + mountPath: /tmp + + {{- if include "openab-feishu.tunnelEnabled" . }} + {{- if and (not .Values.tunnel.token) (not .Values.existingSecret) }} + {{- fail "tunnel.token is required when the cloudflared tunnel is enabled (set tunnel.token, or supply credentials via existingSecret with a cloudflare-tunnel-token key)" }} + {{- end }} + # --- Cloudflared tunnel (sidecar, webhook mode only) --- + - name: cloudflared + image: {{ printf "%s:%s" .Values.tunnel.image .Values.tunnel.tag }} + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + args: + - tunnel + - --no-autoupdate + - run + - --token + - $(TUNNEL_TOKEN) + env: + - name: TUNNEL_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab-feishu.secretName" . }} + key: cloudflare-tunnel-token + volumeMounts: + - name: tmp + mountPath: /tmp + {{- end }} + + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "openab-feishu.fullname" . }} + {{- if .Values.persistence.enabled }} + - name: data + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "openab-feishu.fullname" .) }} + {{- end }} + - name: tmp + emptyDir: {} diff --git a/charts/openab-feishu/templates/pvc.yaml b/charts/openab-feishu/templates/pvc.yaml new file mode 100644 index 000000000..0cca83059 --- /dev/null +++ b/charts/openab-feishu/templates/pvc.yaml @@ -0,0 +1,23 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "openab-feishu.fullname" . }} + annotations: + "helm.sh/resource-policy": keep + labels: + {{- include "openab-feishu.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + {{- if eq "-" .Values.persistence.storageClass }} + storageClassName: "" + {{- else }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size | default "1Gi" }} +{{- end }} diff --git a/charts/openab-feishu/templates/secret.yaml b/charts/openab-feishu/templates/secret.yaml new file mode 100644 index 000000000..39f4ae930 --- /dev/null +++ b/charts/openab-feishu/templates/secret.yaml @@ -0,0 +1,27 @@ +{{- if not .Values.existingSecret }} +{{- if not .Values.feishu.appId }} +{{- fail "feishu.appId is required when existingSecret is not set (--set feishu.appId=YOUR_APP_ID)" }} +{{- end }} +{{- if not .Values.feishu.appSecret }} +{{- fail "feishu.appSecret is required when existingSecret is not set (--set feishu.appSecret=YOUR_APP_SECRET)" }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "openab-feishu.fullname" . }} + labels: + {{- include "openab-feishu.labels" . | nindent 4 }} +type: Opaque +stringData: + feishu-app-id: {{ .Values.feishu.appId | quote }} + feishu-app-secret: {{ .Values.feishu.appSecret | quote }} + {{- if .Values.feishu.verificationToken }} + feishu-verification-token: {{ .Values.feishu.verificationToken | quote }} + {{- end }} + {{- if .Values.feishu.encryptKey }} + feishu-encrypt-key: {{ .Values.feishu.encryptKey | quote }} + {{- end }} + {{- if .Values.tunnel.token }} + cloudflare-tunnel-token: {{ .Values.tunnel.token | quote }} + {{- end }} +{{- end }} diff --git a/charts/openab-feishu/values.yaml b/charts/openab-feishu/values.yaml new file mode 100644 index 000000000..dd9f11c04 --- /dev/null +++ b/charts/openab-feishu/values.yaml @@ -0,0 +1,122 @@ +# openab-feishu values +# +# Install (WebSocket mode — default, no tunnel needed): +# helm install my-bot ./charts/openab-feishu \ +# --set feishu.appId="cli_xxx" \ +# --set feishu.appSecret="xxx" \ +# --namespace openab --create-namespace +# +# Required: +# feishu.appId -- Feishu/Lark App ID (from open.feishu.cn) +# feishu.appSecret -- Feishu/Lark App Secret +# +# Optional: +# feishu.domain -- "feishu" (default, China) or "lark" (overseas) +# feishu.connectionMode -- "websocket" (default) or "webhook" +# tunnel.enabled -- Enable cloudflared sidecar (auto-enabled in webhook mode) +# tunnel.token -- Cloudflare Tunnel token (required if tunnel.enabled) + +# -- Feishu/Lark application credentials +feishu: + # -- (required unless existingSecret is set) App ID from Feishu Open Platform + appId: "" + # -- (required unless existingSecret is set) App Secret + appSecret: "" + # -- "feishu" for feishu.cn (China) or "lark" for larksuite.com (overseas) + domain: "feishu" + # -- "websocket" (default, outbound-only, no public endpoint needed) or "webhook" + connectionMode: "websocket" + # -- (webhook mode only) Verification token from Feishu Open Platform + verificationToken: "" + # -- (webhook mode only) Encrypt key for event signature verification + encryptKey: "" + +# -- Use a pre-existing K8s Secret instead of creating one from --set values. +# The Secret must contain keys: feishu-app-id, feishu-app-secret +# Optional keys: feishu-verification-token, feishu-encrypt-key, cloudflare-tunnel-token +# +# NOTE: When existingSecret is set, the chart cannot track changes to the external +# Secret's contents — rotating credentials will NOT automatically trigger a Pod +# rollout. To enable automatic rollout on secret rotation, use a tool such as +# Reloader (https://github.com/stakater/Reloader) and annotate the Deployment: +# kubectl annotate deployment reloader.stakater.com/auto="true" +existingSecret: "" + +# -- Cloudflare Tunnel sidecar (only needed for webhook mode) +tunnel: + # -- Enable tunnel sidecar. Auto-enabled when connectionMode=webhook and token is set. + enabled: false + # -- Cloudflare Tunnel token + token: "" + image: cloudflare/cloudflared + tag: "2026.5.0" + +# -- Webhook domain (shown in post-install notes) +webhookDomain: "" + +# -- Release channel: "stable" or "beta" +channel: stable + +# -- OAB agent image +image: + repository: ghcr.io/openabdev/openab + tag: "" # defaults to channel + pullPolicy: IfNotPresent + +# -- Gateway image +gateway: + image: ghcr.io/openabdev/openab-gateway + tag: "v0.5.1" + +# -- Agent configuration +agent: + command: kiro-cli + args: + - acp + - --trust-all-tools + workingDir: /home/agent + env: {} + secretEnv: [] + pool: + maxSessions: 10 + sessionTtlHours: 24 + reactions: + enabled: true + removeAfterReply: false + +# -- Gateway platform settings +platform: + # -- Require @mention in groups (recommended for shared groups) + requireMention: true + # -- Feishu group chat IDs allowed (empty = all groups) + allowedGroups: [] + # -- Feishu user open_ids allowed (empty = all users) + allowedUsers: [] + +# -- Persistence for agent working directory +persistence: + enabled: true + existingClaim: "" + storageClass: "" + size: 1Gi + +# -- Pod-level settings +resources: {} +nodeSelector: {} +tolerations: [] +affinity: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL diff --git a/charts/openab-telegram/Chart.yaml b/charts/openab-telegram/Chart.yaml new file mode 100644 index 000000000..f147ef0b9 --- /dev/null +++ b/charts/openab-telegram/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: openab-telegram +description: OpenAB + Telegram — single-pod deployment with gateway and Cloudflare Tunnel sidecar. +type: application +version: 0.1.0 +appVersion: "0.8.3" diff --git a/charts/openab-telegram/README.md b/charts/openab-telegram/README.md new file mode 100644 index 000000000..acd29bd73 --- /dev/null +++ b/charts/openab-telegram/README.md @@ -0,0 +1,241 @@ +# openab-telegram + +OpenAB + Telegram in a single pod — OAB agent, gateway, and Cloudflare Tunnel colocated. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Pod: openab-telegram │ +│ │ +│ ┌───────────┐ ws://localhost:8080/ws ┌───────────┐ │ +│ │ openab │◄────────────────────────────►│ gateway │ │ +│ │ (agent) │ │ :8080 │ │ +│ └─────┬─────┘ └─────┬─────┘ │ +│ │ │ │ +│ │ /etc/openab/config.toml │ │ +│ │ /home/agent (PVC) │ │ +│ │ │ │ +│ ┌─────┴──────────────────────────────────────────┴─────┐ │ +│ │ localhost │ │ +│ └──────────────────────────┬───────────────────────────┘ │ +│ │ │ +│ ┌────────┴────────┐ │ +│ │ cloudflared │ │ +│ │ (tunnel) │ │ +│ └────────┬────────┘ │ +│ │ │ +└─────────────────────────────┼───────────────────────────────┘ + │ Cloudflare Tunnel + ▼ + ┌────────────────────────┐ + │ Cloudflare Edge │ + │ (bot.example.com) │ + └────────────┬───────────┘ + │ HTTPS + ▼ + ┌────────────────────────┐ + │ Telegram API │ + │ (webhook delivery) │ + └────────────────────────┘ +``` + +## Prerequisites + +Run these on your **local machine** (or CI) — one-time setup, no browser required. + +### 1. Create a Telegram bot + +```bash +# Use the Telegram Bot API directly (no app needed): +curl "https://api.telegram.org/bot/sendMessage" \ + -d "chat_id=@BotFather" -d "text=/newbot" + +# Or message @BotFather in Telegram and save the token it returns. +# The token looks like: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz +``` + +### 2. Create a Cloudflare Tunnel (fully headless) + +```bash +# Install cloudflared +# macOS: brew install cloudflared +# Linux: curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared + +# Authenticate with API token (no browser — create token at https://dash.cloudflare.com/profile/api-tokens or via Terraform) +# Required permissions: Account:Cloudflare Tunnel:Edit, Zone:DNS:Edit +export CLOUDFLARE_API_TOKEN="your-api-token" + +# Or use service token auth: +cloudflared tunnel login # only option if no API token; opens browser once + +# Create the tunnel +cloudflared tunnel create my-telegram-bot + +# Route DNS (creates CNAME: bot.example.com → .cfargotunnel.com) +cloudflared tunnel route dns my-telegram-bot bot.example.com + +# Configure ingress (what the tunnel serves) +mkdir -p ~/.cloudflared +cat > ~/.cloudflared/config.yml < **Expected Secret keys:** `telegram-bot-token`, `cloudflare-tunnel-token` + +## Post-Install + +### Configure tunnel ingress (required for remote mode) + +The chart runs cloudflared in **remote mode** (token-based). Ingress rules must be configured via the Cloudflare API or dashboard — local config files are ignored. + +**Option A — API (recommended for AI-assisted installs):** + +Add `cloudflare-api-token` to your K8s Secret, then the helm NOTES provide a ready-to-run command. The AI can extract all credentials from the secret and configure ingress automatically. + +```bash +# Add API token to secret (required permissions: Account:Cloudflare Tunnel:Edit) +kubectl create secret generic my-bot-creds -n openab \ + --from-literal=telegram-bot-token="123:ABC" \ + --from-literal=cloudflare-tunnel-token="eyJ..." \ + --from-literal=cloudflare-api-token="cfut_..." + +# Extract IDs and configure +ACCOUNT_ID=$(kubectl get secret my-bot-creds -n openab -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .a) +TUNNEL_ID=$(kubectl get secret my-bot-creds -n openab -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .t) +CF_API_TOKEN=$(kubectl get secret my-bot-creds -n openab -o jsonpath='{.data.cloudflare-api-token}' | base64 -d) + +curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"config":{"ingress":[{"hostname":"bot.example.com","service":"http://localhost:8080"},{"service":"http_status:404"}]}}' + +# Restart to pick up ingress +kubectl rollout restart deployment/my-bot -n openab +``` + +**Option B — Dashboard:** + +Go to https://one.dash.cloudflare.com/ → Networks → Tunnels → your tunnel → Public Hostname → Add: +- Hostname: `bot.example.com` +- Type: HTTP +- URL: `localhost:8080` + +### Authenticate the agent + +Kiro CLI requires a one-time OAuth login. The PVC persists tokens across restarts. + +```bash +kubectl exec -it deployment/my-bot -n openab -c openab -- kiro-cli login --use-device-flow +kubectl rollout restart deployment/my-bot -n openab +``` + +## AI-Assisted Install + +To have an AI agent handle the full install, prompt it with: + +> Follow the openab-telegram chart README at https://github.com/openabdev/openab/blob/main/charts/openab-telegram/README.md to deploy a Telegram bot on my Kubernetes cluster. +> +> I already have: +> - A Telegram bot token: `` +> - A Cloudflare account with `cloudflared` authenticated +> - A domain: `bot.example.com` +> - kubectl access to my cluster +> +> Create the tunnel, install the chart, and complete all post-install steps from the helm NOTES output (including configuring tunnel ingress via the API and setting the webhook). Store the cloudflare-api-token in the K8s secret so ingress can be configured programmatically. + +## Values + +| Key | Required | Default | Description | +|-----|----------|---------|-------------| +| `telegramBotToken` | Yes* | `""` | Telegram bot token | +| `cloudflareTunnelToken` | Yes* | `""` | Cloudflare Tunnel token | +| `existingSecret` | No | `""` | Pre-existing Secret name (skips token fields) | +| `webhookDomain` | No | `""` | Shown in post-install notes | +| `image.repository` | No | `ghcr.io/openabdev/openab` | Agent image | +| `image.tag` | No | `appVersion` | Agent image tag | +| `gateway.tag` | No | `v0.5.0` | Gateway image tag | +| `agent.command` | No | `kiro-cli` | Agent command | +| `platform.allowAllUsers` | No | `false` | Allow any Telegram user (opt-in) | +| `platform.allowedUsers` | No | `[]` | Allowed Telegram user IDs (get yours from [@userinfobot](https://t.me/userinfobot)) | +| `persistence.enabled` | No | `true` | Enable PVC for agent state | +| `persistence.size` | No | `1Gi` | PVC size | + +*Required unless `existingSecret` is set. diff --git a/charts/openab-telegram/templates/NOTES.txt b/charts/openab-telegram/templates/NOTES.txt new file mode 100644 index 000000000..73afd02aa --- /dev/null +++ b/charts/openab-telegram/templates/NOTES.txt @@ -0,0 +1,71 @@ +🎉 OpenAB Telegram bot deployed! + +Pod: {{ include "openab-telegram.fullname" . }} (3 containers: openab, gateway, cloudflared) + +## Post-Install Steps (complete in order) + +### Step 1: Configure Cloudflare Tunnel ingress + +The tunnel is running but has no ingress rules yet — webhook will return 503 until configured. + +Option A — Via Cloudflare API (AI-friendly): + + Extract credentials from the K8s secret: + ACCOUNT_ID=$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .a) + TUNNEL_ID=$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .t) + CF_API_TOKEN=$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.cloudflare-api-token}' | base64 -d) + + Then configure ingress: + curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"config":{"ingress":[{"hostname":"{{ .Values.webhookDomain | default "bot.example.com" }}","service":"http://localhost:8080"},{"service":"http_status:404"}]}}' + + After configuring, restart the pod to pick up the new ingress: + kubectl rollout restart deployment/{{ include "openab-telegram.fullname" . }} -n {{ .Release.Namespace }} + + Note: cloudflare-api-token is optional in the Secret. If not present, use Option B. + +Option B — Via Cloudflare Dashboard: + https://one.dash.cloudflare.com/ → Networks → Tunnels → your tunnel → Public Hostname → Add: + Hostname: {{ .Values.webhookDomain | default "bot.example.com" }} + Type: HTTP + URL: localhost:8080 + +{{- if .Values.webhookDomain }} + +### Step 2: Set the Telegram webhook + +Run this command to point Telegram at your tunnel: + + curl "https://api.telegram.org/bot$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.telegram-bot-token}' | base64 -d)/setWebhook" \ + -d "url=https://{{ .Values.webhookDomain }}/webhook/telegram" + +{{- else }} + +### Step 2: Set the Telegram webhook + + curl "https://api.telegram.org/bot/setWebhook" \ + -d "url=https://YOUR_DOMAIN/webhook/telegram" + + Tip: pass --set webhookDomain=bot.example.com to get a ready-to-run command here. + +{{- end }} + +### Step 3: Authenticate the agent + +The agent needs a one-time OAuth login. Run the device flow: + + kubectl exec -it deployment/{{ include "openab-telegram.fullname" . }} -n {{ .Release.Namespace }} -c openab -- {{ .Values.agent.command }} login --use-device-flow + +Then restart to pick up credentials: + + kubectl rollout restart deployment/{{ include "openab-telegram.fullname" . }} -n {{ .Release.Namespace }} + +## Verify + +Send a message to your bot on Telegram. Check logs if no response: + + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-telegram.fullname" . }} -c openab --tail=20 + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-telegram.fullname" . }} -c gateway --tail=20 + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-telegram.fullname" . }} -c cloudflared --tail=20 diff --git a/charts/openab-telegram/templates/_helpers.tpl b/charts/openab-telegram/templates/_helpers.tpl new file mode 100644 index 000000000..2bde55e1d --- /dev/null +++ b/charts/openab-telegram/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{- define "openab-telegram.fullname" -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "openab-telegram.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 }} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "openab-telegram.selectorLabels" -}} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "openab-telegram.agentImage" -}} +{{- $tag := .Values.image.tag -}} +{{- if not $tag -}} + {{- $tag = .Values.channel | default "stable" -}} +{{- end -}} +{{- printf "%s:%s" .Values.image.repository $tag -}} +{{- end }} + +{{- define "openab-telegram.gatewayImage" -}} +{{- printf "%s:%s" .Values.gateway.image .Values.gateway.tag -}} +{{- end }} + +{{- define "openab-telegram.secretName" -}} +{{- .Values.existingSecret | default (include "openab-telegram.fullname" .) -}} +{{- end }} \ No newline at end of file diff --git a/charts/openab-telegram/templates/configmap.yaml b/charts/openab-telegram/templates/configmap.yaml new file mode 100644 index 000000000..57527fe53 --- /dev/null +++ b/charts/openab-telegram/templates/configmap.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "openab-telegram.fullname" . }} + labels: + {{- include "openab-telegram.labels" . | nindent 4 }} +data: + config.toml: | + [agent] + command = {{ .Values.agent.command | toJson }} + args = {{ .Values.agent.args | default list | toJson }} + working_dir = {{ .Values.agent.workingDir | default "/home/agent" | toJson }} + {{- if .Values.agent.env }} + env = { {{ $first := true }}{{ range $k, $v := .Values.agent.env }}{{ if not $first }}, {{ end }}{{ $k }} = {{ $v | toJson }}{{ $first = false }}{{ end }} } + {{- end }} + {{- $secretEnvKeys := list }} + {{- range .Values.agent.secretEnv }}{{ $secretEnvKeys = append $secretEnvKeys .name }}{{ end }} + {{- if $secretEnvKeys }} + inherit_env = {{ $secretEnvKeys | toJson }} + {{- end }} + + [pool] + max_sessions = {{ .Values.agent.pool.maxSessions | default 10 }} + session_ttl_hours = {{ .Values.agent.pool.sessionTtlHours | default 24 }} + + [reactions] + enabled = {{ .Values.agent.reactions.enabled | default true }} + remove_after_reply = {{ .Values.agent.reactions.removeAfterReply | default false }} + + [gateway] + url = "ws://localhost:8080/ws" + platform = "telegram" + allow_all_channels = {{ if hasKey .Values.platform "allowAllChannels" }}{{ .Values.platform.allowAllChannels }}{{ else }}true{{ end }} + allowed_channels = {{ .Values.platform.allowedChannels | default list | toJson }} + allow_all_users = {{ if hasKey .Values.platform "allowAllUsers" }}{{ .Values.platform.allowAllUsers }}{{ else }}true{{ end }} + allowed_users = {{ .Values.platform.allowedUsers | default list | toJson }} diff --git a/charts/openab-telegram/templates/deployment.yaml b/charts/openab-telegram/templates/deployment.yaml new file mode 100644 index 000000000..852ee61a0 --- /dev/null +++ b/charts/openab-telegram/templates/deployment.yaml @@ -0,0 +1,140 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "openab-telegram.fullname" . }} + labels: + {{- include "openab-telegram.labels" . | nindent 4 }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "openab-telegram.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print .Template.BasePath "/secret.yaml") . | sha256sum }} + labels: + {{- include "openab-telegram.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + # --- OAB agent (main) --- + - name: openab + image: {{ include "openab-telegram.agentImage" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: HOME + value: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- range $k, $v := .Values.agent.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- range .Values.agent.secretEnv }} + - name: {{ .name }} + valueFrom: + secretKeyRef: + name: {{ .secretName }} + key: {{ .secretKey }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: config + mountPath: /etc/openab + readOnly: true + {{- if .Values.persistence.enabled }} + - name: data + mountPath: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- end }} + - name: tmp + mountPath: /tmp + + # --- Gateway (sidecar) --- + - name: gateway + image: {{ include "openab-telegram.gatewayImage" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: HOME + value: {{ .Values.agent.workingDir | default "/home/agent" }} + - name: TELEGRAM_BOT_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab-telegram.secretName" . }} + key: telegram-bot-token + volumeMounts: + {{- if .Values.persistence.enabled }} + - name: data + mountPath: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- end }} + - name: tmp + mountPath: /tmp + + # --- Cloudflared tunnel (sidecar) --- + - name: cloudflared + image: {{ printf "%s:%s" .Values.cloudflared.image .Values.cloudflared.tag }} + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + args: + - tunnel + - --no-autoupdate + - run + - --token + - $(TUNNEL_TOKEN) + env: + - name: TUNNEL_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab-telegram.secretName" . }} + key: cloudflare-tunnel-token + volumeMounts: + - name: tmp + mountPath: /tmp + + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "openab-telegram.fullname" . }} + {{- if .Values.persistence.enabled }} + - name: data + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "openab-telegram.fullname" .) }} + {{- end }} + - name: tmp + emptyDir: {} diff --git a/charts/openab-telegram/templates/pvc.yaml b/charts/openab-telegram/templates/pvc.yaml new file mode 100644 index 000000000..d332e9596 --- /dev/null +++ b/charts/openab-telegram/templates/pvc.yaml @@ -0,0 +1,19 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "openab-telegram.fullname" . }} + annotations: + "helm.sh/resource-policy": keep + labels: + {{- include "openab-telegram.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size | default "1Gi" }} +{{- end }} diff --git a/charts/openab-telegram/templates/secret.yaml b/charts/openab-telegram/templates/secret.yaml new file mode 100644 index 000000000..b50f50445 --- /dev/null +++ b/charts/openab-telegram/templates/secret.yaml @@ -0,0 +1,21 @@ +{{- if not .Values.existingSecret }} +{{- if not .Values.telegramBotToken }} +{{- fail "telegramBotToken is required when existingSecret is not set (--set telegramBotToken=YOUR_TOKEN)" }} +{{- end }} +{{- if not .Values.cloudflareTunnelToken }} +{{- fail "cloudflareTunnelToken is required when existingSecret is not set (--set cloudflareTunnelToken=YOUR_TOKEN)" }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "openab-telegram.fullname" . }} + labels: + {{- include "openab-telegram.labels" . | nindent 4 }} +type: Opaque +stringData: + telegram-bot-token: {{ .Values.telegramBotToken | quote }} + cloudflare-tunnel-token: {{ .Values.cloudflareTunnelToken | quote }} + {{- if .Values.cloudflareApiToken }} + cloudflare-api-token: {{ .Values.cloudflareApiToken | quote }} + {{- end }} +{{- end }} diff --git a/charts/openab-telegram/values.yaml b/charts/openab-telegram/values.yaml new file mode 100644 index 000000000..2e4399de1 --- /dev/null +++ b/charts/openab-telegram/values.yaml @@ -0,0 +1,116 @@ +# openab-telegram values +# +# Install: +# helm install my-bot oci://ghcr.io/openabdev/charts/openab-telegram \ +# --set telegramBotToken="123:ABC" \ +# --set cloudflareTunnelToken="eyJ..." \ +# --set platform.allowedUsers="{YOUR_TG_USER_ID}" \ +# --namespace openab --create-namespace +# +# Required: +# telegramBotToken -- Telegram bot token from @BotFather +# cloudflareTunnelToken -- Cloudflare Tunnel token +# +# Recommended: +# platform.allowedUsers -- Your Telegram user ID (message @userinfobot to find it) +# +# Optional: +# webhookDomain -- your tunnel domain (for setWebhook reminder in NOTES.txt) + +# -- (required unless existingSecret is set) Telegram bot token +telegramBotToken: "" + +# -- (required unless existingSecret is set) Cloudflare Tunnel token +cloudflareTunnelToken: "" + +# -- (optional) Cloudflare API token for automated ingress configuration. +# Required permissions: Account:Cloudflare Tunnel:Edit +# If set, post-install NOTES provide a ready-to-run curl command for AI agents. +cloudflareApiToken: "" + +# -- Use a pre-existing K8s Secret instead of creating one from --set values. +# The Secret must contain keys: telegram-bot-token, cloudflare-tunnel-token +# See README.md for credential management options. +existingSecret: "" + +# -- Tunnel domain (shown in post-install notes for setWebhook) +webhookDomain: "" + +# -- Release channel: "stable" or "beta" +# Resolves to the floating image tag of the same name. +# stable = ghcr.io/openabdev/openab:stable (latest stable release) +# beta = ghcr.io/openabdev/openab:beta (latest pre-release) +channel: stable + +# -- OAB agent image +image: + repository: ghcr.io/openabdev/openab + tag: "" # defaults to appVersion + pullPolicy: IfNotPresent + +# -- Gateway image +gateway: + image: ghcr.io/openabdev/openab-gateway + tag: "v0.5.1" + +# -- Cloudflared image +cloudflared: + image: cloudflare/cloudflared + tag: "2026.5.0" + +# -- Agent configuration +agent: + command: kiro-cli + args: + - acp + - --trust-all-tools + workingDir: /home/agent + env: {} + secretEnv: [] + # -- Pool settings + pool: + maxSessions: 10 + sessionTtlHours: 24 + # -- Reaction settings + reactions: + enabled: true + removeAfterReply: false + +# -- Gateway platform settings +platform: + # allowAllUsers: false = only users listed in allowedUsers can talk to the bot + # Set to true to allow any Telegram user (not recommended for production) + allowAllUsers: false + # Telegram user IDs allowed to interact with the bot. + # Find your ID by messaging @userinfobot on Telegram. + allowedUsers: [] + allowAllChannels: true + allowedChannels: [] + +# -- Persistence for agent working directory +persistence: + enabled: true + existingClaim: "" + storageClass: "" + size: 1Gi + +# -- Pod-level settings +resources: {} +nodeSelector: {} +tolerations: [] +affinity: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index d21edc249..df8a512a6 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.3 -appVersion: "0.8.3-beta.3" +version: 0.8.4-beta.5 +appVersion: "0.8.4-beta.5" diff --git a/charts/openab/README.md b/charts/openab/README.md new file mode 100644 index 000000000..a7ddae8b1 --- /dev/null +++ b/charts/openab/README.md @@ -0,0 +1,101 @@ +# openab Helm Chart + +This chart deploys one or more OpenAB agents on Kubernetes. + +## Common Values + +This page highlights commonly used values and deployment patterns. For the complete list of supported options and defaults, run `helm show values openab/openab` or inspect [`values.yaml`](values.yaml). + +### Release naming + +| Value | Description | Default | +|-------|-------------|---------| +| `nameOverride` | Override the chart name portion used in generated resource names. For per-agent resource names, use `agents..nameOverride`. | `""` | +| `fullnameOverride` | Override the full generated release name for chart resources. Useful when deploying multiple instances with predictable names. | `""` | +| `serviceAccountName` | Chart-global ServiceAccount name attached to every agent pod that doesn't define its own. Empty = cluster `default` SA. Per-agent `agents..serviceAccountName` fully overrides this. Chart references an existing SA only — does not create one. Required for workload identity and pod-level RBAC. | `""` | +| `imagePullSecrets` | Chart-global image pull secrets attached to every agent pod that doesn't define its own. Per-agent `agents..imagePullSecrets` fully overrides this. | `[]` | + +### Agent values + +Each agent lives under `agents.`. + +| Value | Description | Default | +|-------|-------------|---------| +| `discord.botToken` | Discord bot token for the agent. | `""` | +| `discord.allowedChannels` | Channel allowlist. Use `--set-string` for Discord IDs. | `["YOUR_CHANNEL_ID"]` | +| `discord.allowedUsers` | User allowlist. Empty = allow all users by default. Use `--set-string` for Discord IDs. | `[]` | +| `discord.allowDm` | Whether the Discord bot responds to direct messages. | `false` | +| `discord.allowBotMessages` | Controls whether bot messages can trigger replies. | `"off"` | +| `discord.trustedBotIds` | Optional bot ID allowlist when bot-message replies are enabled. | `[]` | +| `slack.enabled` | Enable the Slack adapter for the agent. | `false` | +| `slack.botToken` | Slack Bot User OAuth token. | `""` | +| `slack.appToken` | Slack App-Level token for Socket Mode. | `""` | +| `slack.existingSecret` | Name of a pre-existing K8s Secret containing `slack-bot-token` and `slack-app-token`. When set, `botToken`/`appToken` above are ignored and the chart skips creating those keys. Enables External Secrets Operator / Vault / SealedSecrets workflows. | `""` | +| `slack.allowedChannels` | Slack channel allowlist. Empty means allow all channels by default. | `[]` | +| `slack.allowedUsers` | Slack user allowlist. Empty means allow all users by default. | `[]` | +| `nameOverride` | Override this agent's generated resource name. | `""` | +| `workingDir` | Working directory and HOME inside the container. | `"/home/agent"` | +| `env` | Inline environment variables passed to the agent process. | `{}` | +| `envFrom` | Additional environment sources from existing Secrets or ConfigMaps. | `[]` | +| `pool.maxSessions` | Maximum concurrent ACP sessions for the agent. | `10` | +| `pool.sessionTtlHours` | Idle session TTL in hours. | `24` | +| `reactions.enabled` | Enable status reactions. | `true` | +| `reactions.removeAfterReply` | Remove status reactions after the agent replies. | `false` | +| `reactions.toolDisplay` | Tool display verbosity: `full`, `compact`, or `none`. | `"full"` | +| `stt.enabled` | Enable voice-message speech-to-text. | `false` | +| `stt.apiKey` | API key for the speech-to-text provider. | `""` | +| `stt.model` | STT model name. | `"whisper-large-v3-turbo"` | +| `stt.baseUrl` | STT API base URL. | `"https://api.groq.com/openai/v1"` | +| `gateway.enabled` | Enable the gateway config block for webhook-based platforms. | `false` | +| `gateway.deploy` | Deploy the gateway Deployment and Service. | `true` | +| `cron.usercronEnabled` | Enable user-provided cron configuration. | `false` | +| `cronjobs` | Config-driven scheduled messages for an agent. | `[]` | +| `persistence.enabled` | Enable persistent storage for auth and settings. | `true` | +| `persistence.existingClaim` | Reuse an existing PVC instead of creating one. | `""` | +| `agentsMd` | Contents of `AGENTS.md` mounted into the working directory. | `""` | +| `serviceAccountName` | Per-agent ServiceAccount name. When set (non-empty), fully overrides chart-global `serviceAccountName`. Useful when only some agents need a dedicated SA. | `""` | +| `imagePullSecrets` | Per-agent image pull secrets. When set, fully overrides chart-global `imagePullSecrets`. Useful when only some agents pull from a private registry. | `[]` | +| `extraInitContainers` | Additional init containers for the agent pod. | `[]` | +| `extraContainers` | Additional sidecar containers for the agent pod. | `[]` | +| `extraVolumeMounts` | Additional volume mounts for the main agent container. | `[]` | +| `extraVolumes` | Additional volumes for the agent pod. | `[]` | + +## Examples + +### Override generated names + +```bash +helm install prod openab/openab \ + --set fullnameOverride=my-openab \ + --set-literal agents.kiro.discord.botToken="$DISCORD_BOT_TOKEN" \ + --set-string 'agents.kiro.discord.allowedChannels[0]=YOUR_CHANNEL_ID' +``` + +This makes generated resource names use `my-openab` (for example `my-openab-kiro`) instead of the default `prod-openab`. + +### Load credentials with `envFrom` + +```yaml +agents: + kiro: + envFrom: + - secretRef: + name: openab-agent-secrets + - configMapRef: + name: openab-agent-config +``` + +This is useful for credentials such as `GH_TOKEN` without storing them directly in Helm values. + +### Provide `AGENTS.md` with `--set-file` + +```bash +helm install openab openab/openab \ + --set-literal agents.kiro.discord.botToken="$DISCORD_BOT_TOKEN" \ + --set-string 'agents.kiro.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \ + --set-file agents.kiro.agentsMd=./AGENTS.md +``` + +### Discord ID precision warning + +Discord IDs must be set with `--set-string`, not `--set`. Otherwise Helm may coerce them into numbers and lose precision. diff --git a/charts/openab/templates/NOTES.txt b/charts/openab/templates/NOTES.txt index 2030ed6fe..c18989a07 100644 --- a/charts/openab/templates/NOTES.txt +++ b/charts/openab/templates/NOTES.txt @@ -39,9 +39,17 @@ Agents deployed: {{- else if eq (toString $cfg.command) "opencode" }} Authenticate: kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- opencode auth login +{{- else if eq (toString $cfg.command) "pi-acp" }} + Authenticate: + kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- pi + (Once inside the interactive interface, type `/login` to authenticate) {{- else if eq (toString $cfg.command) "cursor-agent" }} Authenticate: kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- cursor-agent login +{{- else if eq (toString $cfg.command) "grok" }} + Authenticate: + kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- grok login --device-auth + For bot/CI deployments, prefer setting `GROK_CODE_XAI_API_KEY` via `secretEnv` instead of interactive device-auth login. See docs/grok.md for all authentication options. {{- end }} Restart after auth: diff --git a/charts/openab/templates/_helpers.tpl b/charts/openab/templates/_helpers.tpl index 6aa197764..dea2e17bf 100644 --- a/charts/openab/templates/_helpers.tpl +++ b/charts/openab/templates/_helpers.tpl @@ -45,6 +45,17 @@ app.kubernetes.io/component: {{ .agent }} {{- end }} {{- end }} +{{/* Secret name to use for Slack credentials. + If existingSecret is set, reference it; otherwise fall back to the chart-managed agent secret. + Call with: dict "ctx" $ "agent" $name "cfg" $cfg */}} +{{- define "openab.slackSecretName" -}} +{{- if and .cfg.slack (.cfg.slack.existingSecret | default "" | trim) -}} +{{- .cfg.slack.existingSecret | trim -}} +{{- else -}} +{{- include "openab.agentFullname" . -}} +{{- end -}} +{{- end }} + {{/* Resolve image: agent-level string override → global default (repository:tag, tag defaults to appVersion). Caveat: "contains :" treats registry ports (e.g. my-registry:5000/img) as tagged. Not an issue for ghcr.io / Docker Hub; revisit if custom registries with ports are needed. */}} diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 12576a8e0..ebbdb7586 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -44,6 +44,17 @@ data: {{- if $cfg.discord.trustedBotIds }} trusted_bot_ids = {{ $cfg.discord.trustedBotIds | toJson }} {{- end }} + {{- range $cfg.discord.allowedRoleIds }} + {{- if regexMatch "e\\+|E\\+" (toString .) }} + {{- fail (printf "discord.allowedRoleIds contains a mangled ID: %s — use --set-string instead of --set for role IDs" (toString .)) }} + {{- end }} + {{- if not (regexMatch "^[0-9]{17,20}$" (toString .)) }} + {{- fail (printf "discord.allowedRoleIds contains an invalid role ID: %s — must be a 17-20 digit snowflake ID" (toString .)) }} + {{- end }} + {{- end }} + {{- if $cfg.discord.allowedRoleIds }} + allowed_role_ids = {{ $cfg.discord.allowedRoleIds | toJson }} + {{- end }} {{- /* allowUserMessages: controls whether the bot requires @mention in threads (Discord) */ -}} {{- if $cfg.discord.allowUserMessages }} {{- if not (has $cfg.discord.allowUserMessages (list "involved" "mentions" "multibot-mentions")) }} @@ -167,6 +178,9 @@ data: api_key = "${STT_API_KEY}" model = {{ ($cfg.stt).model | default "whisper-large-v3-turbo" | toJson }} base_url = {{ ($cfg.stt).baseUrl | default "https://api.groq.com/openai/v1" | toJson }} + {{- if hasKey ($cfg.stt | default dict) "echoTranscript" }} + echo_transcript = {{ ($cfg.stt).echoTranscript }} + {{- end }} {{- end }} {{- if ($cfg.gateway).enabled }} {{- if not ($cfg.gateway).url }} diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml index a47a3e8be..5760bb42d 100644 --- a/charts/openab/templates/deployment.yaml +++ b/charts/openab/templates/deployment.yaml @@ -29,6 +29,14 @@ spec: securityContext: {{- toYaml . | nindent 8 }} {{- end }} + {{- $svcAcct := default $.Values.serviceAccountName $cfg.serviceAccountName }} + {{- if $svcAcct }} + serviceAccountName: {{ $svcAcct }} + {{- end }} + {{- with (default $.Values.imagePullSecrets $cfg.imagePullSecrets) }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with $cfg.extraInitContainers }} initContainers: {{- toYaml . | nindent 8 }} @@ -49,18 +57,18 @@ spec: name: {{ include "openab.agentFullname" $d }} key: discord-bot-token {{- end }} - {{- if and ($cfg.slack).enabled ($cfg.slack).botToken }} + {{- if and ($cfg.slack).enabled (or ($cfg.slack).botToken ($cfg.slack).existingSecret) }} - name: SLACK_BOT_TOKEN valueFrom: secretKeyRef: - name: {{ include "openab.agentFullname" $d }} + name: {{ include "openab.slackSecretName" $d }} key: slack-bot-token {{- end }} - {{- if and ($cfg.slack).enabled ($cfg.slack).appToken }} + {{- if and ($cfg.slack).enabled (or ($cfg.slack).appToken ($cfg.slack).existingSecret) }} - name: SLACK_APP_TOKEN valueFrom: secretKeyRef: - name: {{ include "openab.agentFullname" $d }} + name: {{ include "openab.slackSecretName" $d }} key: slack-app-token {{- end }} {{- if and ($cfg.stt).enabled ($cfg.stt).apiKey }} diff --git a/charts/openab/templates/gateway-secret.yaml b/charts/openab/templates/gateway-secret.yaml index 7d4869bdc..3c2c05d00 100644 --- a/charts/openab/templates/gateway-secret.yaml +++ b/charts/openab/templates/gateway-secret.yaml @@ -8,7 +8,8 @@ {{- $hasTelegram := (($cfg.gateway).telegram).botToken }} {{- $hasLine := (($cfg.gateway).line).channelSecret }} {{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken }} -{{- if or $hasTeams $hasFeishu $hasTelegram $hasLine $hasGoogleChat }} +{{- $hasWecom := and (($cfg.gateway).wecom).corpId (($cfg.gateway).wecom).agentId (($cfg.gateway).wecom).secret (($cfg.gateway).wecom).token (($cfg.gateway).wecom).encodingAesKey }} +{{- if or $hasTeams $hasFeishu $hasTelegram $hasLine $hasGoogleChat $hasWecom }} --- apiVersion: v1 kind: Secret @@ -52,6 +53,11 @@ data: google-chat-access-token: {{ ($cfg.gateway).googleChat.accessToken | b64enc | quote }} {{- end }} {{- end }} + {{- if $hasWecom }} + wecom-secret: {{ ($cfg.gateway).wecom.secret | b64enc | quote }} + wecom-token: {{ ($cfg.gateway).wecom.token | b64enc | quote }} + wecom-encoding-aes-key: {{ ($cfg.gateway).wecom.encodingAesKey | b64enc | quote }} + {{- end }} {{- end }} {{- end }} {{- end }} diff --git a/charts/openab/templates/gateway.yaml b/charts/openab/templates/gateway.yaml index 057937dcf..2a89dc79a 100644 --- a/charts/openab/templates/gateway.yaml +++ b/charts/openab/templates/gateway.yaml @@ -184,6 +184,40 @@ spec: value: {{ ($cfg.gateway).googleChat.webhookPath | quote }} {{- end }} {{- end }} + {{- $hasWecom := and (($cfg.gateway).wecom).corpId (($cfg.gateway).wecom).agentId (($cfg.gateway).wecom).secret (($cfg.gateway).wecom).token (($cfg.gateway).wecom).encodingAesKey }} + {{- if $hasWecom }} + - name: WECOM_CORP_ID + value: {{ ($cfg.gateway).wecom.corpId | quote }} + - name: WECOM_AGENT_ID + value: {{ ($cfg.gateway).wecom.agentId | quote }} + - name: WECOM_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openab.agentFullname" $d }} + key: wecom-secret + - name: WECOM_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab.agentFullname" $d }} + key: wecom-token + - name: WECOM_ENCODING_AES_KEY + valueFrom: + secretKeyRef: + name: {{ include "openab.agentFullname" $d }} + key: wecom-encoding-aes-key + {{- if (($cfg.gateway).wecom).webhookPath }} + - name: WECOM_WEBHOOK_PATH + value: {{ ($cfg.gateway).wecom.webhookPath | quote }} + {{- end }} + {{- if (($cfg.gateway).wecom).streamingEnabled }} + - name: WECOM_STREAMING_ENABLED + value: {{ ($cfg.gateway).wecom.streamingEnabled | quote }} + {{- end }} + {{- if (($cfg.gateway).wecom).debounceSecs }} + - name: WECOM_DEBOUNCE_SECS + value: {{ ($cfg.gateway).wecom.debounceSecs | quote }} + {{- end }} + {{- end }} - name: RUST_LOG value: {{ ($cfg.gateway).rustLog | default "info" | quote }} livenessProbe: diff --git a/charts/openab/templates/secret.yaml b/charts/openab/templates/secret.yaml index 4dc5ba871..b30fac2b9 100644 --- a/charts/openab/templates/secret.yaml +++ b/charts/openab/templates/secret.yaml @@ -1,7 +1,7 @@ {{- range $name, $cfg := .Values.agents }} {{- if ne (include "openab.agentEnabled" $cfg) "false" }} {{- $hasDiscord := and (ne (toString ($cfg.discord).enabled) "false") ($cfg.discord).botToken }} -{{- $hasSlack := and ($cfg.slack).enabled (or ($cfg.slack).botToken ($cfg.slack).appToken) }} +{{- $hasSlack := and ($cfg.slack).enabled (or ($cfg.slack).botToken ($cfg.slack).appToken) (not ($cfg.slack).existingSecret) }} {{- $hasStt := and ($cfg.stt).enabled ($cfg.stt).apiKey }} {{- $hasGateway := and ($cfg.gateway).enabled ($cfg.gateway).token }} {{- if or $hasDiscord $hasSlack $hasStt $hasGateway }} @@ -20,10 +20,10 @@ data: {{- if $hasDiscord }} discord-bot-token: {{ $cfg.discord.botToken | b64enc | quote }} {{- end }} - {{- if and ($cfg.slack).enabled ($cfg.slack).botToken }} + {{- if and ($cfg.slack).enabled ($cfg.slack).botToken (not ($cfg.slack).existingSecret) }} slack-bot-token: {{ $cfg.slack.botToken | b64enc | quote }} {{- end }} - {{- if and ($cfg.slack).enabled ($cfg.slack).appToken }} + {{- if and ($cfg.slack).enabled ($cfg.slack).appToken (not ($cfg.slack).existingSecret) }} slack-app-token: {{ $cfg.slack.appToken | b64enc | quote }} {{- end }} {{- if $hasStt }} diff --git a/charts/openab/tests/imagepullsecrets_test.yaml b/charts/openab/tests/imagepullsecrets_test.yaml new file mode 100644 index 000000000..fc5abedf0 --- /dev/null +++ b/charts/openab/tests/imagepullsecrets_test.yaml @@ -0,0 +1,64 @@ +suite: imagePullSecrets support (chart-global + per-agent override) +templates: + - templates/deployment.yaml + +tests: + - it: does not render imagePullSecrets when neither global nor per-agent is set + asserts: + - notExists: + path: spec.template.spec.imagePullSecrets + + - it: renders chart-global imagePullSecrets when only the global value is set + set: + imagePullSecrets: + - name: regcred + asserts: + - equal: + path: spec.template.spec.imagePullSecrets + value: + - name: regcred + + - it: renders per-agent imagePullSecrets when only the per-agent value is set + set: + agents.kiro.imagePullSecrets: + - name: kiro-regcred + asserts: + - equal: + path: spec.template.spec.imagePullSecrets + value: + - name: kiro-regcred + + - it: per-agent imagePullSecrets fully overrides chart-global (no merge) + set: + imagePullSecrets: + - name: global-regcred + agents.kiro.imagePullSecrets: + - name: kiro-regcred + asserts: + - equal: + path: spec.template.spec.imagePullSecrets + value: + - name: kiro-regcred + + - it: falls back to chart-global when per-agent imagePullSecrets is an empty list + set: + imagePullSecrets: + - name: global-regcred + agents.kiro.imagePullSecrets: [] + asserts: + - equal: + path: spec.template.spec.imagePullSecrets + value: + - name: global-regcred + + - it: supports multiple secrets in the list + set: + imagePullSecrets: + - name: regcred-a + - name: regcred-b + asserts: + - equal: + path: spec.template.spec.imagePullSecrets + value: + - name: regcred-a + - name: regcred-b diff --git a/charts/openab/tests/serviceaccount_test.yaml b/charts/openab/tests/serviceaccount_test.yaml new file mode 100644 index 000000000..d5b92de43 --- /dev/null +++ b/charts/openab/tests/serviceaccount_test.yaml @@ -0,0 +1,51 @@ +suite: serviceAccountName support (chart-global + per-agent override) +templates: + - templates/deployment.yaml + +tests: + - it: does not render serviceAccountName when neither global nor per-agent is set + asserts: + - notExists: + path: spec.template.spec.serviceAccountName + + - it: renders chart-global serviceAccountName when only the global value is set + set: + serviceAccountName: "openab" + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: openab + + - it: renders per-agent serviceAccountName when only the per-agent value is set + set: + agents.kiro.serviceAccountName: "kiro-sa" + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: kiro-sa + + - it: per-agent serviceAccountName fully overrides chart-global + set: + serviceAccountName: "openab" + agents.kiro.serviceAccountName: "kiro-sa" + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: kiro-sa + + - it: empty per-agent serviceAccountName falls back to chart-global + set: + serviceAccountName: "openab" + agents.kiro.serviceAccountName: "" + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: openab + + - it: explicit empty global + empty per-agent renders no serviceAccountName field + set: + serviceAccountName: "" + agents.kiro.serviceAccountName: "" + asserts: + - notExists: + path: spec.template.spec.serviceAccountName diff --git a/charts/openab/tests/slack-existing-secret_test.yaml b/charts/openab/tests/slack-existing-secret_test.yaml new file mode 100644 index 000000000..b48a02095 --- /dev/null +++ b/charts/openab/tests/slack-existing-secret_test.yaml @@ -0,0 +1,196 @@ +suite: Slack existingSecret support +templates: + - templates/secret.yaml + - templates/deployment.yaml + +tests: + # ── Secret rendering ────────────────────────────────────────────────────── + + - it: omits slack-bot-token and slack-app-token from chart Secret when existingSecret is set (slack-only agent renders no Secret at all) + template: templates/secret.yaml + set: + agents.kiro.discord.enabled: true + agents.kiro.discord.botToken: "discord-token" + agents.kiro.slack.enabled: true + agents.kiro.slack.botToken: "xoxb-ignored" + agents.kiro.slack.appToken: "xapp-ignored" + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - notExists: + path: data["slack-bot-token"] + - notExists: + path: data["slack-app-token"] + + - it: skips chart Secret entirely for slack-only agent with existingSecret + template: templates/secret.yaml + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - hasDocuments: + count: 0 + + - it: still creates chart Secret for non-slack tokens when existingSecret is set + template: templates/secret.yaml + set: + agents.kiro.discord.enabled: true + agents.kiro.discord.botToken: "discord-token" + agents.kiro.slack.enabled: true + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - matchRegex: + path: data["discord-bot-token"] + pattern: '.+' + - notExists: + path: data["slack-bot-token"] + - notExists: + path: data["slack-app-token"] + + - it: renders chart-managed Slack secret keys when existingSecret is unset + template: templates/secret.yaml + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.botToken: "xoxb-test" + agents.kiro.slack.appToken: "xapp-test" + asserts: + - matchRegex: + path: data["slack-bot-token"] + pattern: '.+' + - matchRegex: + path: data["slack-app-token"] + pattern: '.+' + + # ── Deployment env-var rendering ────────────────────────────────────────── + + - it: references existingSecret name in SLACK_BOT_TOKEN secretKeyRef + template: templates/deployment.yaml + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: my-slack-creds + key: slack-bot-token + + - it: references existingSecret name in SLACK_APP_TOKEN secretKeyRef + template: templates/deployment.yaml + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_APP_TOKEN + valueFrom: + secretKeyRef: + name: my-slack-creds + key: slack-app-token + + - it: falls back to chart-managed secret name when existingSecret is unset + template: templates/deployment.yaml + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.botToken: "xoxb-test" + agents.kiro.slack.appToken: "xapp-test" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: RELEASE-NAME-openab-kiro + key: slack-bot-token + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_APP_TOKEN + valueFrom: + secretKeyRef: + name: RELEASE-NAME-openab-kiro + key: slack-app-token + + - it: omits SLACK env vars when slack is disabled even if existingSecret is set + template: templates/deployment.yaml + set: + agents.kiro.slack.enabled: false + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: my-slack-creds + key: slack-bot-token + + # ── Mixed-adapter deployment ────────────────────────────────────────────── + + - it: renders Discord from chart-managed Secret and Slack from existingSecret in same Deployment + template: templates/deployment.yaml + set: + agents.kiro.discord.enabled: true + agents.kiro.discord.botToken: "disc-token" + agents.kiro.slack.enabled: true + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DISCORD_BOT_TOKEN + valueFrom: + secretKeyRef: + name: RELEASE-NAME-openab-kiro + key: discord-bot-token + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: my-slack-creds + key: slack-bot-token + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_APP_TOKEN + valueFrom: + secretKeyRef: + name: my-slack-creds + key: slack-app-token + + # ── Multi-agent scoping ─────────────────────────────────────────────────── + + - it: agent with existingSecret does not affect another agent using inline tokens + template: templates/deployment.yaml + documentIndex: 1 + set: + agents.alpha.slack.enabled: true + agents.alpha.slack.existingSecret: "alpha-ext-creds" + agents.beta.slack.enabled: true + agents.beta.slack.botToken: "xoxb-beta" + agents.beta.slack.appToken: "xapp-beta" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: RELEASE-NAME-openab-beta + key: slack-bot-token + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_APP_TOKEN + valueFrom: + secretKeyRef: + name: RELEASE-NAME-openab-beta + key: slack-app-token diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 4314d2df2..049e7df35 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -4,6 +4,30 @@ image: tag: "" pullPolicy: IfNotPresent +# Chart-level name override used in generated resource names. +# For per-agent overrides, use agents..nameOverride. +nameOverride: "" + +# Override the full release name used in generated resource names. +fullnameOverride: "" + +# Chart-global ServiceAccount name for agent pods, used when an agent doesn't +# set its own `serviceAccountName`. Empty string = use cluster default SA. +# Per-agent values (agents..serviceAccountName) take precedence — when +# set, they fully override this. The chart only references an existing SA; it +# does NOT create one or manage annotations (provision out-of-band). +# Example: +# serviceAccountName: "openab" +serviceAccountName: "" + +# Chart-global image pull secrets, used when an agent doesn't set its own +# `imagePullSecrets`. Per-agent values (agents..imagePullSecrets) take +# precedence — when set, they fully override (do not merge with) this list. +# Example: +# imagePullSecrets: +# - name: regcred +imagePullSecrets: [] + podSecurityContext: runAsNonRoot: true runAsUser: 1000 @@ -37,6 +61,8 @@ agents: # allowBotMessages: "off" # # trustedBotIds: [] # empty = any bot (mode permitting) # trustedBotIds: [] + # # allowedRoleIds: [] # role IDs that trigger the bot + # allowedRoleIds: [] # workingDir: /home/agent # # nameOverride: custom deployment name (default: -) # nameOverride: "" @@ -100,6 +126,28 @@ agents: # agentsMd: "" # resources: {} # image: "ghcr.io/openabdev/openab-opencode:latest" + # pi: + # command: pi-acp + # discord: + # enabled: true + # allowedChannels: + # - "YOUR_CHANNEL_ID" + # allowedUsers: [] + # workingDir: /home/node + # env: {} + # envFrom: [] + # secretEnv: [] + # pool: + # maxSessions: 10 + # sessionTtlHours: 24 + # reactions: + # enabled: true + # removeAfterReply: false + # persistence: + # enabled: true + # storageClass: "" + # size: 1Gi + # image: "ghcr.io/openabdev/openab-pi:latest" # cursor: # command: cursor-agent # args: @@ -115,6 +163,7 @@ agents: # allowedUsers: [] # workingDir: /home/agent # env: {} + # # Load env vars from existing Secrets or ConfigMaps, e.g. GH_TOKEN. # envFrom: [] # secretEnv: [] # pool: @@ -130,6 +179,55 @@ agents: # storageClass: "" # size: 1Gi # image: "ghcr.io/openabdev/openab-cursor:latest" + # hermes: + # command: hermes-acp + # discord: + # enabled: true + # allowedChannels: + # - "YOUR_CHANNEL_ID" + # allowedUsers: [] + # allowBotMessages: "off" + # trustedBotIds: [] + # workingDir: /home/agent + # env: {} + # envFrom: [] + # secretEnv: [] + # pool: + # maxSessions: 10 + # sessionTtlHours: 24 + # persistence: + # enabled: true + # storageClass: "" + # size: 1Gi + # image: "ghcr.io/openabdev/openab-hermes" + # grok: + # command: grok + # args: + # - agent + # - stdio + # # See docs/grok.md for the three authentication methods: + # # 1. API key (recommended for bots): set GROK_CODE_XAI_API_KEY via env or secretEnv + # # 2. Device-code OAuth: kubectl exec + `grok login --device-auth` (requires persistence) + # # 3. Enterprise deployment key: GROK_DEPLOYMENT_KEY + # discord: + # enabled: true + # allowedChannels: + # - "YOUR_CHANNEL_ID" + # allowedUsers: [] + # allowBotMessages: "off" + # trustedBotIds: [] + # workingDir: /home/agent + # env: {} + # envFrom: [] + # secretEnv: [] + # pool: + # maxSessions: 10 + # sessionTtlHours: 24 + # persistence: + # enabled: true + # storageClass: "" + # size: 1Gi + # image: "ghcr.io/openabdev/openab-grok" image: "" command: kiro-cli args: @@ -155,6 +253,11 @@ agents: allowBotMessages: "off" # trustedBotIds: [] # empty = any bot (mode permitting); set to restrict trustedBotIds: [] + # allowedRoleIds: Role IDs that trigger the bot (same as direct @mention). + # Create a Discord role, assign it to the bot, then users can @role to trigger. + # Empty (default) = role mentions do not trigger the bot. + # allowedRoleIds: ["1234567890123456789"] + allowedRoleIds: [] # maxBotTurns: soft cap on consecutive bot turns per thread before # the bot stops auto-replying. A human message resets the counter. # Default 100 (Rust-side `default_max_bot_turns()`). Raise for long @@ -174,6 +277,14 @@ agents: enabled: false botToken: "" # Bot User OAuth Token (xoxb-...) appToken: "" # App-Level Token (xapp-...) for Socket Mode + # Use a pre-existing K8s Secret for slack-bot-token and slack-app-token + # instead of having the chart create one from botToken/appToken. + # Set to the Secret name; the Secret must contain keys: + # slack-bot-token → Bot User OAuth Token (xoxb-...) + # slack-app-token → App-Level Token (xapp-...) + # When set, botToken and appToken above are ignored. Recommended path for + # External Secrets Operator / Vault / SealedSecrets deployments. + existingSecret: "" # allowAllChannels/allowAllUsers: same auto-infer logic as discord allowedChannels: [] # empty + no allowAllChannels → allow all (auto-inferred) allowedUsers: [] # empty + no allowAllUsers → allow all (auto-inferred) @@ -205,6 +316,7 @@ agents: # maxBatchTokens: 24000 workingDir: /home/agent env: {} + # Load env vars from existing Secrets or ConfigMaps, e.g. GH_TOKEN. envFrom: [] secretEnv: [] # list of {name, secretName, secretKey} — rendered as valueFrom.secretKeyRef; keys auto-added to inherit_env pool: @@ -222,6 +334,9 @@ agents: apiKey: "" model: "whisper-large-v3-turbo" baseUrl: "https://api.groq.com/openai/v1" + # Echo the transcribed text back to the thread before the agent reply + # so users can verify STT accuracy. Default: false (opt-in). + echoTranscript: false gateway: enabled: false # set to true + provide url to enable the [gateway] config block deploy: true # set to false to skip Gateway Deployment/Service (config-only mode) @@ -290,6 +405,17 @@ agents: saKeyJson: "" # Service account key JSON string → GOOGLE_CHAT_SA_KEY_JSON (recommended, auto-refresh) accessToken: "" # Static OAuth2 access token → GOOGLE_CHAT_ACCESS_TOKEN (fallback, 1-hour TTL) webhookPath: "" # Gateway default: /webhook/googlechat → GOOGLE_CHAT_WEBHOOK_PATH + # WeCom (企业微信) adapter config (gateway-side env vars) + # See docs/wecom.md for full setup guide + wecom: + corpId: "" # Enterprise Corp ID → WECOM_CORP_ID + secret: "" # App Secret → WECOM_SECRET (use --set-literal or external secret mgmt) + token: "" # Callback verification token → WECOM_TOKEN + encodingAesKey: "" # 43-char AES key → WECOM_ENCODING_AES_KEY + agentId: "" # Agent ID → WECOM_AGENT_ID (required) + webhookPath: "" # Gateway default: /webhook/wecom → WECOM_WEBHOOK_PATH + streamingEnabled: "" # Enable thinking-placeholder + recall streaming (causes brief client flicker) → WECOM_STREAMING_ENABLED. Default off. + debounceSecs: "" # Debounce quiet-period seconds before flushing streamed text → WECOM_DEBOUNCE_SECS. Default 3, minimum 1 (0 is treated as unset by Helm). # Scheduled messages — config-driven cron (ADR: basic-cronjob) # Each entry sends a message to the agent at the specified schedule. # Example: @@ -311,6 +437,7 @@ agents: existingClaim: "" # set to reuse an existing PVC (skips PVC creation) storageClass: "" size: 1Gi # defaults to 1Gi if not set + # Mount a custom AGENTS.md file. Useful with --set-file. # ⚠️ When set, this ConfigMap mount shadows any file at the same path on the PVC. # The PVC file is NOT deleted but becomes invisible to the agent. Remove agentsMd to restore. agentsMd: "" @@ -318,6 +445,17 @@ agents: nodeSelector: {} tolerations: [] affinity: {} + # Per-agent ServiceAccount name. When set (non-empty), overrides the + # chart-global `serviceAccountName` for this agent only. Useful in + # multi-agent deployments where only some agents need a dedicated SA. + # serviceAccountName: "openab" + serviceAccountName: "" + # Per-agent image pull secrets. When set, overrides the chart-global + # `imagePullSecrets` for this agent only. Useful in multi-agent deployments + # where only some agents pull from a private registry. + # imagePullSecrets: + # - name: regcred + imagePullSecrets: [] # extraInitContainers adds init containers to the pod (runs before the main container) extraInitContainers: [] # extraContainers adds sidecar containers to the pod diff --git a/config.toml.example b/config.toml.example index cf99f6bdb..17f305a67 100644 --- a/config.toml.example +++ b/config.toml.example @@ -11,6 +11,8 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # allow_bot_messages = "off" # "off" (default) | "mentions" | "all" # "mentions" is recommended for multi-agent collaboration # trusted_bot_ids = [] # empty = any bot (mode permitting); set to restrict +# allowed_role_ids = [] # role IDs that trigger the bot (same as direct @mention) + # note: if multiple bots share the same role, all will respond simultaneously # allow_user_messages = "involved" # "involved" (default) | "mentions" # "involved" = reply in threads bot owns or has participated in # "mentions" = always require @mention @@ -51,14 +53,16 @@ args = ["acp", "--trust-all-tools"] working_dir = "/home/agent" # [agent] -# command = "claude" -# args = ["--acp"] +# command = "claude-agent-acp" +# args = [] # working_dir = "/home/node" +# # Auth: kubectl exec -it deploy/openab-claude -- claude auth login +# # (credentials persist in HOME PVC across restarts; see docs/claude-code.md) # ⚠️ SECURITY WARNING: Any env var listed here is accessible to the agent. # A user could trick the agent into leaking these values via prompt injection. # All supported backends support OAuth login — prefer that over env var API keys. # Note: env vars here can override baseline vars (HOME, PATH, USER) if needed. -# env = { ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" } +# env = {} # # By default, the agent subprocess only inherits these baseline vars: # Linux/macOS: HOME, PATH, USER @@ -101,9 +105,30 @@ working_dir = "/home/agent" # working_dir = "/home/agent" # env = {} # Auth via: kubectl exec -it -- cursor-agent login +# [agent] +# command = "hermes-acp" +# working_dir = "/home/agent" +# # Auth via: kubectl exec -it -- hermes auth add xai-oauth +# # Supports 30+ providers (xAI Grok OAuth, Anthropic, OpenAI Codex, Gemini, etc.) +# # Provider switching: kubectl exec -it -- hermes model + +# [agent] +# command = "grok" +# args = ["agent", "stdio"] +# working_dir = "/home/agent" +# # Auth options: +# # 1. API key: env = { GROK_CODE_XAI_API_KEY = "${XAI_API_KEY}" } +# # 2. Device-code: kubectl exec -it -- grok login --device-auth +# # 3. Deployment key: env = { GROK_DEPLOYMENT_KEY = "${GROK_DEPLOYMENT_KEY}" } +# # See docs/grok.md for details. + [pool] max_sessions = 10 session_ttl_hours = 24 +# Hard ceiling (sec) per prompt; see #732. Default: 1800 (30 min). +# prompt_hard_timeout_secs = 1800 +# Liveness-check cadence (sec) for the recv loop; see #732. Default: 30. +# liveness_check_secs = 30 [markdown] tables = "code" # "code" (default) | "bullets" | "off" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index ba7984193..386815b94 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -18,7 +18,17 @@ fi cat > "$CONFIG" << EOF [discord] bot_token = "${DISCORD_BOT_TOKEN}" +EOF + +# Only set allowed_channels if DISCORD_CHANNEL_ID is non-empty +# (empty = allow all channels; [""] would cause a parse error) +if [ -n "$DISCORD_CHANNEL_ID" ]; then + cat >> "$CONFIG" << EOF allowed_channels = ["${DISCORD_CHANNEL_ID}"] +EOF +fi + +cat >> "$CONFIG" << EOF [agent] command = "kiro-cli" diff --git a/docs/adr/ecs-control-plane.md b/docs/adr/ecs-control-plane.md new file mode 100644 index 000000000..191487e8b --- /dev/null +++ b/docs/adr/ecs-control-plane.md @@ -0,0 +1,689 @@ +# ADR: ECS Control Plane (CRD + Operator Pattern on ECS) + +- **Status:** Proposed +- **Date:** 2026-05-18 +- **Author:** @pahud.hsieh +- **Related:** [Multi-Platform Adapters](./multi-platform-adapters.md), [Basic CronJob](./basic-cronjob.md) + +--- + +## 1. Context & Motivation + +OpenAB currently deploys on Kubernetes using Helm charts. While K8s provides a mature operator pattern (CRD + Controller), many teams prefer or require **Amazon ECS** for its operational simplicity and tighter AWS integration. + +We want to bring the same declarative, self-healing deployment model to ECS: + +- Operators declare desired state in YAML manifests (analogous to CRDs) +- A controller reconciles desired state against actual ECS resources +- OAB instances + arbitrary backends are deployed and maintained automatically + +This enables a "GitOps for ECS" workflow where pushing a YAML change triggers the controller to converge the cluster to the new desired state. + +--- + +## 2. Design Overview + +``` +┌──────────────────────────────────────────────────────┐ +│ ECS Control Plane (runs as ECS Service) │ +│ │ +│ ┌────────────┐ ┌──────────────┐ ┌─────────────┐ │ +│ │ State Store│ │ Reconciler │ │ ECS API / │ │ +│ │ (S3) │◄─│ Controller │─►│ CloudMap │ │ +│ │ │ │ │ │ │ │ +│ └────────────┘ └──────────────┘ └─────────────┘ │ +│ ▲ │ +│ │ events / poll │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ S3 Events / │ │ +│ │ EventBridge │ │ +│ └──────────────┘ │ +└──────────────────────────────────────────────────────┘ +``` + +### Core Loop (Reconciliation) + +1. Load all YAML manifests from S3 (desired state) +2. Describe current ECS services/tasks (observed state) +3. Compute diff (compare `metadata.generation` vs `status.observedGeneration`) +4. Apply changes: create, update, or delete ECS resources +5. Write status back to S3 (separate prefix), set `status.observedGeneration = metadata.generation` +6. Sleep / wait for next event + +--- + +## 3. Manifest Schema + +### API Identity (fixed) + +| Field | Value | +|-------|-------| +| `apiVersion` | `oab.dev/v1` | +| `kind` | `OABService` | + +All examples in this ADR use this identity. No other combinations (`openab.dev/v1`, `AgentDeployment`) are valid. + +### Full Example + +```yaml +apiVersion: oab.dev/v1 +kind: OABService +metadata: + name: my-agent + namespace: prod + generation: 4 # incremented by oabctl on each apply +spec: + replicas: 1 + capacityProvider: FARGATE # FARGATE (default) or FARGATE_SPOT + cpu: 256 # vCPU units (256 = 0.25 vCPU) + memory: 512 # MB + taskDefinition: + image: 123456789.dkr.ecr.us-east-1.amazonaws.com/openab:latest + bootstrapFrom: s3://oab-state/agents/my-agent/latest.tar.gz + secrets: + - name: DISCORD_BOT_TOKEN + source: ssm + path: /oab/my-agent/discord-token + - name: LLM_API_KEY + source: secretsmanager + arn: arn:aws:secretsmanager:us-east-1:123:secret:oab/my-agent/llm-key + networking: + subnets: [subnet-abc, subnet-def] + securityGroups: [sg-123] + assignPublicIp: false + config: + agent: + name: my-agent + backend: bedrock + model: us.anthropic.claude-sonnet-4-20250514 + discord: + enabled: true + botId: "123456789" + guildId: "987654321" + channelIds: ["111111111"] + steering: + source: s3 + bucket: oab-steering + prefix: agents/my-agent/ + memory: + backend: s3 + bucket: oab-memory + prefix: agents/my-agent/ + tools: + github: { enabled: true } + web: { enabled: true } +status: + phase: Running # Pending | Running | Failed | Terminating + observedGeneration: 4 # last generation the controller reconciled + taskArns: + - arn:aws:ecs:us-east-1:123456789012:task/cluster/abc123 + lastReconciled: "2026-05-18T22:50:00Z" + conditions: + - type: Available + status: "True" + lastTransitionTime: "2026-05-18T22:50:00Z" +``` + +### Key Fields + +| Field | Description | +|-------|-------------| +| `metadata.generation` | Monotonically increasing counter, bumped by `oabctl apply` | +| `spec.capacityProvider` | `FARGATE` (on-demand) or `FARGATE_SPOT` (up to 70% savings, tolerates interruption) | +| `spec.cpu` / `spec.memory` | Maps to ECS task definition (must be valid Fargate combination) | +| `spec.taskDefinition.image` | Container image | +| `spec.bootstrapFrom` | S3 path to mutable state archive (memory, knowledge base — **no secrets**) | +| `spec.secrets` | Per-agent secret references (SSM / Secrets Manager) | +| `spec.config` | Structured agent config; controller renders to `config.toml` | +| `spec.networking` | ECS awsvpc configuration | +| `status.observedGeneration` | Last generation the controller successfully reconciled | + +### Replicas Semantics + +OAB agents are **single-instance** by design — each agent holds one adapter connection (WebSocket gateway for Discord/Telegram/Slack). There is no load balancing across agent replicas. + +**Rules:** +- `replicas: 1` — the only valid value +- Controller **rejects** `replicas > 1` at validation time +- Scaling is horizontal by deploying **more agents** (each with its own bot token), not by replicating one agent + +### Fleet Provisioning (`OABFleet`) + +Enterprise scenario: provision 10-20 agents in one apply. Controller handles everything including Discord Bot registration. + +```yaml +apiVersion: oab.dev/v1 +kind: OABFleet +metadata: + name: enterprise-team + namespace: prod +spec: + defaults: + capacityProvider: FARGATE_SPOT + cpu: 512 + memory: 1024 + taskDefinition: + image: ghcr.io/openabdev/openab:latest + networking: + subnets: [subnet-abc, subnet-def] + securityGroups: [sg-oab] + discord: + enabled: true # all agents use Discord + agents: + - name: kiro-01 + config: { agent: { backend: kiro } } + - name: kiro-02 + config: { agent: { backend: kiro } } + - name: kiro-03 + config: { agent: { backend: kiro } } + - name: codex-01 + config: { agent: { backend: codex } } + cpu: 1024 + memory: 2048 # override defaults + - name: codex-02 + config: { agent: { backend: codex } } + cpu: 1024 + memory: 2048 + - name: gemini-01 + config: { agent: { backend: gemini } } + - name: gemini-02 + config: { agent: { backend: gemini } } + - name: gemini-03 + config: { agent: { backend: gemini } } + - name: gemini-04 + config: { agent: { backend: gemini } } + - name: gemini-05 + config: { agent: { backend: gemini } } +``` + +### Discord Bot Provisioning Flow + +Since Discord does not offer a public API for Bot creation, the actual flow for fleet provisioning is: + +``` +Pre-requisite (manual, one-time per agent): + → Create Bot in Discord Developer Portal + → Store token in SSM: /oab/{namespace}/{name}/discord-token + → Note the OAuth2 invite URL + +oabctl apply -f fleet.yaml + │ + │ For each agent: + ├─ 1. Validate spec + verify secret exists in SSM + ├─ 2. Render config.toml → S3 artifact (immutable, per generation) + ├─ 3. Register ECS TaskDefinition (pinned to config artifact + secrets) + ├─ 4. Create ECS Service (desiredCount=1) + └─ 5. Write status (phase=Running) +``` + +**Apply output:** + +```bash +$ oabctl apply -f fleet.yaml + +✓ kiro-01 provisioned (ECS service created, task running) +✓ kiro-02 provisioned (ECS service created, task running) +✓ kiro-03 provisioned (ECS service created, task running) +✓ codex-01 provisioned (ECS service created, task running) +✓ codex-02 provisioned (ECS service created, task running) +✓ gemini-01 provisioned (ECS service created, task running) +... + +10 agents provisioned. +``` + +**User's manual steps (one-time):** create bots in Discord Developer Portal, store tokens, add bots to server via OAuth URL. + +### Responsibility Model + +| Layer | Responsibility | +|-------|---------------| +| `oabctl` / Controller | Desired state: create ECS Services; **observe** ECS task/service status → write back to `status/` | +| ECS | Runtime health: task dies → auto-restart (desiredCount=1) | +| User | One-time: create bots in Discord Developer Portal, add to server via OAuth URL | + +The controller does **not** restart tasks — ECS handles that. But the controller **does** observe ECS service/task/deployment status on each reconcile cycle and writes it back to `status/{ns}/{name}.json`. This enables `oabctl get`, `oabctl wait --for=Available`, and `status.phase` / `status.conditions` to work. + +### Prerequisites for Auto-Registration + +> **⚠️ Note:** Discord does not provide a public API to programmatically create Bot Applications. `autoRegister` is a **future research item** pending Discord API changes or partnership access. For now, bot credentials must be pre-created manually in the Discord Developer Portal. + +**Phase 1/2 approach:** Each agent's bot token is pre-created and stored in SSM/Secrets Manager. The `OABFleet` spec references existing credentials: + +```yaml +spec: + agents: + - name: kiro-01 + config: { agent: { backend: kiro } } + secrets: + - name: DISCORD_BOT_TOKEN + source: ssm + path: /oab/kiro-01/discord-token # pre-created +``` + +**Future (if Discord API allows):** `autoRegister: true` would automate Bot creation. This requires a separate research ADR. + +--- + +## 4. Multi-Runtime Support (ECS + K8s) + +The same YAML manifest can deploy to **both ECS and Kubernetes**. The spec is platform-agnostic; platform-specific details live in an optional `platform:` overlay. + +### Design Principle + +``` +┌─────────────────────┐ +│ oab.dev/v1 YAML │ ← one spec, platform-agnostic core +└──────────┬──────────┘ + │ + ┌──────┴──────┐ + ▼ ▼ +┌────────┐ ┌────────┐ +│ ECS │ │ K8s │ +│Controller│ │Operator│ +└────┬───┘ └────┬───┘ + ▼ ▼ + ECS Service Deployment + ConfigMap + ExternalSecret +``` + +Each controller reads the core spec and its own `platform:` overlay, ignoring the other. + +### Spec with Platform Overlay + +```yaml +apiVersion: oab.dev/v1 +kind: OABService +metadata: + name: chaodu + namespace: prod +spec: + # Core (cross-platform) + cpu: 512 + memory: 1024 + config: + agent: { backend: kiro } + discord: { enabled: true, botId: "123" } + secrets: + - name: DISCORD_BOT_TOKEN + source: ssm + path: /oab/chaodu/discord-token + + # Platform-specific (each controller reads only its own key) + platform: + ecs: + capacityProvider: FARGATE_SPOT + executionRole: arn:aws:iam::123456789012:role/oab-task-execution + taskRole: arn:aws:iam::123456789012:role/oab-chaodu-task + networking: + subnets: [subnet-abc, subnet-def] + securityGroups: [sg-oab] + assignPublicIp: false + k8s: + nodeSelector: { node.kubernetes.io/capacity-type: spot } + serviceAccount: oab-agent + storageClass: gp3 +``` + +### Translation Table + +| Core Spec | ECS Controller | K8s Operator | +|-----------|---------------|--------------| +| `cpu: 512` | TaskDef `cpu=512` | `resources.requests.cpu: 500m` | +| `memory: 1024` | TaskDef `memory=1024` | `resources.requests.memory: 1Gi` | +| `spec.config` | Render → S3 artifact → startup wrapper | Render → ConfigMap → volume mount | +| `spec.secrets[].source: ssm` | ECS native `secrets` field | ExternalSecret → K8s Secret | +| `platform.ecs.capacityProvider` | Fargate capacity provider | _(ignored)_ | +| `platform.ecs.executionRole` | ECS task execution role | _(ignored)_ | +| `platform.ecs.taskRole` | ECS task role | _(ignored)_ | +| `platform.k8s.nodeSelector` | _(ignored)_ | Pod nodeSelector | + +### Rules + +- `platform:` is optional. If omitted, controller uses its own defaults. +- Controller **strict-validates** its own platform key (e.g., ECS controller rejects invalid `platform.ecs.*` fields with an error). +- Controller **ignores** other platform keys entirely (ECS controller skips `platform.k8s`, and vice versa). +- Core spec fields (`cpu`, `memory`, `config`, `secrets`) are mandatory and cross-platform. +- `OABFleet` also supports `platform:` at both `defaults` and per-agent level. + +### Phase Plan + +- **Phase 1**: ECS controller only. `platform.ecs` supported, `platform.k8s` ignored. +- **Phase 3**: K8s operator reads same manifests (from S3 or as native CRD). Shared schema, different runtime. + +--- + +## 5. Config Delivery Model + +The controller does **not** mount config into containers (ECS/Fargate has no shared volume equivalent to K8s ConfigMap). Instead: + +### Flow + +``` +oabctl apply -f agent.yaml + → writes manifest to S3 (manifests/{ns}/{name}.yaml, generation incremented) + +Controller reconcile: + → reads spec.config from manifest + → renders config.toml + → writes to s3://oab-control-plane/artifacts/{ns}/{name}/{generation}/config.toml (immutable) + → registers new ECS TaskDefinition with env CONFIG_ARTIFACT_PATH pinned to this generation + → updates ECS Service (rolling deployment) + +ECS Task startup (entrypoint wrapper): + → s3:GetObject ${CONFIG_ARTIFACT_PATH} → /home/agent/config.toml + → s3:GetObject ${BOOTSTRAP_FROM} → tar xzf → /home/agent/ (mutable state only) + → exec openab +``` + +Config artifacts are **immutable per generation** — once written, never overwritten. During rolling updates, old tasks keep fetching their pinned generation while new tasks use the new one. + +### Entrypoint Wrapper + +```bash +#!/bin/bash +set -e +# 1. Restore mutable state FIRST (memory, knowledge base) +if [ -n "$BOOTSTRAP_FROM" ]; then + aws s3 cp "$BOOTSTRAP_FROM" /tmp/bootstrap.tar.gz + tar xzf /tmp/bootstrap.tar.gz -C /home/agent/ + rm /tmp/bootstrap.tar.gz +fi +# 2. Download controller-rendered config LAST (overwrites any config.toml from archive) +aws s3 cp "$CONFIG_ARTIFACT_PATH" /home/agent/config.toml +exec /usr/local/bin/openab +``` + +**Order matters:** bootstrap archive is restored first, then the controller-rendered `config.toml` overwrites any stale config from the archive. `oabctl snapshot` must exclude `config.toml` from the archive (it's controller-managed, not user state). + +### What Goes Where + +| Content | Location | Managed By | +|---------|----------|------------| +| `config.toml` | S3 artifact (controller renders) | `spec.config` in manifest | +| Secrets (bot tokens, API keys) | SSM / Secrets Manager | `spec.secrets` in manifest | +| Memory / knowledge base | `bootstrapFrom` archive | `oabctl snapshot` | +| Steering files | S3 (referenced in config.toml) | Separate steering bucket | + +**Secrets never go in the bootstrap archive.** The archive contains only mutable runtime state that the agent accumulates over time. + +### IAM Requirements (Task Execution Role) + +The ECS task execution role (`platform.ecs.executionRole`) must have: + +```json +{ + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": [ + "arn:aws:s3:::oab-control-plane/artifacts/${namespace}/${name}/*", + "arn:aws:s3:::oab-state/agents/${name}/*" + ] +} +``` + +This allows the startup wrapper to download the controller-rendered `config.toml` and the bootstrap archive. + +--- + +## 6. Per-Agent Secret Injection + +Each agent/bot has its **own** credentials — no token sharing between agents. + +### Design Principles + +- Each `OABService` owns its secrets (1:1 mapping) +- Controller never touches secret values — it only wires references into ECS Task Definitions +- ECS native `secrets` field handles injection at runtime +- IAM scoping ensures each task role can only read its own secret path + +### Spec + +```yaml +spec: + secrets: + - name: DISCORD_BOT_TOKEN + source: ssm + path: /oab/chaodu/discord-token + - name: LLM_API_KEY + source: secretsmanager + arn: arn:aws:secretsmanager:us-east-1:123:secret:oab/chaodu/llm-key +``` + +### Controller Behavior + +1. **Deploy** — maps `spec.secrets` to ECS TaskDefinition `secrets` field: + ```json + { + "secrets": [ + { "name": "DISCORD_BOT_TOKEN", "valueFrom": "/oab/chaodu/discord-token" }, + { "name": "LLM_API_KEY", "valueFrom": "arn:aws:secretsmanager:us-east-1:123:secret:oab/chaodu/llm-key" } + ] + } + ``` +2. **IAM** — task execution role scoped to the agent's secret path: + ```json + { + "Effect": "Allow", + "Action": ["ssm:GetParameters", "secretsmanager:GetSecretValue"], + "Resource": [ + "arn:aws:ssm:*:*:parameter/oab/chaodu/*", + "arn:aws:secretsmanager:*:*:secret:oab/chaodu/*" + ] + } + ``` + +### Secret Rotation Lifecycle + +``` +1. Operator rotates secret in SSM/Secrets Manager (manual or auto-rotation) +2. Controller detects rotation: + - Option A: spec.secrets[].autoRestart: true → controller forces new deployment + - Option B: operator runs `oabctl restart my-agent` +3. ECS launches new task → new task fetches fresh secret value at startup +4. Old task drains and stops (ECS rolling update) +5. Controller updates status: + - conditions[].type: SecretsRefreshed + - conditions[].lastTransitionTime: +``` + +**Failure handling:** +- If new task fails to start (bad secret value), ECS circuit breaker stops the rollout +- Controller sets `status.phase: Failed`, `conditions[].type: SecretInjectionFailed` +- Old task remains running (ECS deployment circuit breaker preserves last healthy state) + +--- + +## 7. State Store Design (S3-Only) + +``` +s3://oab-control-plane/ + ├── manifests/{namespace}/{name}.yaml ← desired state (oabctl writes) + ├── status/{namespace}/{name}.json ← observed state (controller writes) + └── artifacts/{namespace}/{name}/{generation}/ ← immutable config per generation + └── config.toml +``` + +| Concern | Mechanism | Rationale | +|---------|-----------|-----------| +| Desired state | `manifests/` prefix | Human-readable, git-syncable, versioned via S3 versioning | +| Status | `status/` prefix | Controller writes after each reconcile cycle | +| Config artifacts | `artifacts/` prefix | Controller-rendered config.toml for task startup | +| Generation tracking | `metadata.generation` in manifest YAML | Explicit counter, not tied to S3 VersionId | +| Change detection | S3 Event Notifications → EventBridge (Phase 2) | Phase 1 uses polling | +| Consistency | S3 strong read-after-write | Sufficient for single-controller | +| Optimistic locking | S3 conditional writes (If-None-Match / ETag) | Prevents concurrent `oabctl apply` conflicts | + +### Generation vs S3 VersionId + +S3 VersionId is an opaque string — not suitable for comparing "which is newer." Instead: +- `metadata.generation` is an explicit integer, incremented by `oabctl apply` +- `status.observedGeneration` records the last generation the controller reconciled +- Controller skips reconcile if `observedGeneration == generation` (no-op) +- Stale status writes are detected: if status.observedGeneration < manifest.generation, the status is outdated + +### Delete Semantics (Phase 2) + +Phase 1: `oabctl delete` removes the manifest from S3; controller detects absence and tears down ECS resources. + +Phase 2: Proper deletion with finalizers: +1. `oabctl delete` sets `metadata.deletionTimestamp` in the manifest (tombstone) +2. Controller runs finalizers (drain connections, cleanup CloudMap, remove artifacts) +3. Controller removes manifest and status objects only after all finalizers complete + +--- + +## 8. Controller Upgrade Strategy + +The controller runs as a single-replica ECS Service. + +### Phase 1 (acceptable brief gap) + +```yaml +# Controller's own ECS Service config +deploymentConfiguration: + minimumHealthyPercent: 0 # allow old to stop before new starts + maximumPercent: 100 +``` + +- ECS rolling update: stop old → start new +- Brief reconciliation gap (30-60s) during upgrade +- No in-flight reconcile is lost — next cycle picks up any drift +- Acceptable for Phase 1 because reconcile is idempotent + +### Phase 2 (zero-downtime) + +- DynamoDB-based leader election (two controller replicas) +- Active/standby: standby takes over within seconds if active fails health check +- Version skew handling: new controller must handle manifests written by old `oabctl` versions (schema backward compatibility) + +### Rollback + +- Controller image is pinned in its own ECS TaskDefinition +- Rollback = `aws ecs update-service --task-definition ` +- Controller state is in S3 (stateless process), so rollback is safe + +--- + +## 9. CLI UX (`oabctl`) + +### Core Commands + +```bash +oabctl apply -f agent.yaml # declare/update desired state +oabctl get oabservice # list all services + status +oabctl get oabservice my-agent # single service detail +oabctl delete oabservice my-agent # remove (Phase 1: immediate; Phase 2: finalizer) +oabctl diff -f agent.yaml # show local vs remote diff +oabctl logs my-agent # shortcut to ECS task logs (CloudWatch) +oabctl restart my-agent # force new deployment (pick up rotated secrets) +oabctl snapshot my-agent # capture runtime state → bootstrapFrom archive +oabctl wait my-agent --for=Available # block until condition met +``` + +### `apply` Semantics + +``` +$ oabctl apply -f prod/my-agent.yaml + +✓ Schema validated (oab.dev/v1 OABService) +✓ Replicas check passed (replicas=1) +✓ Uploaded to s3://oab-control-plane/manifests/prod/my-agent.yaml +✓ Generation: 3 → 4 +⏳ Waiting for reconciliation... +✓ Service my-agent reconciled (observedGeneration=4, 1/1 tasks running) +``` + +### `diff` Granularity + +```bash +oabctl diff -f agent.yaml # spec-only: local YAML vs remote manifest +oabctl diff -f agent.yaml --rendered # rendered: show generated config.toml diff +oabctl diff -f agent.yaml --status # include status comparison +``` + +### Implementation + +`oabctl` talks directly to S3 via AWS SDK. No API server needed. Auth is standard IAM (role, profile, env vars). Config stored in `~/.oabctl/config`: + +```toml +[default] +region = "us-east-1" +bucket = "oab-control-plane" +cluster = "oab-prod" +``` + +--- + +## 10. Phase Scope + +### Phase 1 — MVP (target) + +| In Scope | Out of Scope | +|----------|--------------| +| S3 manifest store (versioning enabled) | EventBridge triggers | +| Single-instance controller (poll every 30s) | Multi-replica controller / leader election | +| `oabctl apply` / `oabctl get` | `oabctl delete` with finalizers | +| Controller renders config.toml → S3 artifact | DynamoDB state store | +| ECS service create / update | Rollback (`oabctl rollback`) | +| Startup wrapper downloads config + bootstrap | EFS / shared volumes | +| `metadata.generation` / `status.observedGeneration` | Multi-region | +| Per-agent secrets via SSM/SM | Auto-rotation detection | +| Replicas validation (reject >1) | Auto-scaling policies | + +### Phase 2 + +- `OABFleet` kind + Discord `autoRegister` (batch Bot provisioning) +- Event-driven triggers (S3 → EventBridge → controller) +- `oabctl delete` with tombstone + finalizers +- `oabctl diff`, `oabctl logs`, `oabctl restart` +- DynamoDB for leader election (active/standby controller) +- Secret auto-rotation detection + auto-restart +- Rollback via generation history + +### Phase 3 + +- **K8s Operator** — same `oab.dev/v1` schema consumed as native CRD; `platform.k8s` overlay +- Multi-region (controller per region, S3 cross-region replication) +- Dependency graph (service A depends on service B) +- Auto-scaling policies in manifest spec +- GitOps integration (GitHub Actions → `oabctl apply` on push) +- Schema versioning + migration tooling + +--- + +## 11. Alternatives Considered + +| Alternative | Why not chosen | +|-------------|---------------| +| AWS Proton | Opinionated, limited customization for OAB-specific logic | +| AWS Copilot | Good for simple apps, no custom reconciliation loop | +| CDK Pipelines | Deployment tool, not a runtime controller with drift detection | +| Step Functions orchestrator | Stateless execution model, no continuous reconciliation | +| Run K8s anyway (EKS) | Valid but adds operational overhead for teams that chose ECS | +| DynamoDB as primary store | Adds infra; S3 sufficient for single-controller Phase 1 | + +--- + +## 12. Open Questions + +1. **Multi-region** — single controller per region, or global controller with regional reconcilers? +2. **Observability** — CloudWatch metrics from the controller, or push to a shared OAB dashboard? +3. **Networking isolation** — shared VPC with per-service SG rules, or per-namespace VPC? +4. **Schema versioning** — how to handle `oab.dev/v2` migration when spec evolves? + +--- + +## 13. Decision + +We adopt the CRD + Operator pattern on ECS with an **S3-only state store**, **explicit generation tracking**, and a **`oabctl` CLI** for the operator interface. The controller runs as a single ECS service that reconciles `OABService` manifests against actual ECS state. + +Key design choices: +- **Config delivery**: controller renders `config.toml` to S3 artifact; startup wrapper downloads it +- **Secrets**: per-agent SSM/Secrets Manager references; never in bootstrap archive +- **Bootstrap**: mutable runtime state only (memory, knowledge base) +- **Replicas**: always 1; scale by deploying more agents, not replicating one +- **Generation**: explicit `metadata.generation` / `status.observedGeneration` (not S3 VersionId) +- **Phase 1 scope**: narrow (create/update only, poll-based, single controller) + +DynamoDB, EventBridge, finalizers, and multi-region are deferred to Phase 2+. diff --git a/docs/adr/openab-agent.md b/docs/adr/openab-agent.md new file mode 100644 index 000000000..b1b7bb870 --- /dev/null +++ b/docs/adr/openab-agent.md @@ -0,0 +1,462 @@ +# ADR: openab-agent — Native Rust Coding Agent with Built-in ACP + +- **Status:** Proposed +- **Date:** 2026-05-26 +- **Author:** @chaodu-agent + +--- + +## 1. Context & Motivation + +Today, every coding agent in OpenAB follows the same pattern: + +``` +openab (Rust) ──stdio JSON-RPC──► adapter (Node/Rust) ──spawns──► CLI agent ──HTTP──► LLM API +``` + +This introduces 3–4 layers of indirection, each with its own runtime, dependencies, and failure modes. Every agent requires: + +- A separate Dockerfile (300–800MB images) +- A Node.js or Python runtime +- An ACP adapter wrapper (pi-acp, codex-acp, agy-acp, etc.) +- npm/pip supply-chain management + +Meanwhile, the actual work an agent does is simple: + +1. Receive a prompt +2. Call an LLM API (HTTP POST + SSE) +3. Execute tool calls (read/write/edit/bash) +4. Return the result + +**Proposal:** Build `openab-agent` — a single Rust binary that is both the ACP server and the coding agent, with no external runtime, no wrapper, and no adapter layer. + +--- + +## 2. Design + +### Architecture + +``` +┌─ openab-agent (single Rust binary) ──────────────┐ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ ACP Layer (stdin/stdout JSON-RPC) │ │ +│ │ - session/new, session/prompt, cancel │ │ +│ └──────────────────┬──────────────────────────┘ │ +│ │ │ +│ ┌──────────────────▼──────────────────────────┐ │ +│ │ Agent Core │ │ +│ │ - Prompt assembly (system + user + tools) │ │ +│ │ - Tool dispatch loop │ │ +│ │ - Session tree (branching history) │ │ +│ └──────────────────┬──────────────────────────┘ │ +│ │ │ +│ ┌──────────────────▼──────────────────────────┐ │ +│ │ LLM Client (reqwest + SSE) │ │ +│ │ - OpenAI-compatible (GPT, Codex, DeepSeek) │ │ +│ │ - Anthropic (Claude) │ │ +│ │ - Google (Gemini) │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ Tools (4 only) │ │ +│ │ - read: file/directory reading │ │ +│ │ - write: file creation │ │ +│ │ - edit: string replacement in files │ │ +│ │ - bash: command execution (sandboxed) │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +└───────────────────────────────────────────────────┘ +``` + +### Comparison with Existing Agents + +| Aspect | Existing (e.g. Pi, Codex) | openab-agent | +|--------|---------------------------|--------------| +| Layers | openab → adapter → CLI → LLM | openab → **agent** → LLM | +| Runtime | Node.js / Python | None (static binary) | +| Image size | 300–800MB | ~20MB (distroless) | +| Cold start | 1–3s | <50ms | +| ACP support | Requires wrapper | Native, zero overhead | +| Dependencies | npm ecosystem | Minimal crates | +| Supply-chain risk | High (node_modules) | Low (cargo audit) | + +### Required Crates + +Only four crates are needed beyond what openab core already uses: + +- `reqwest` — HTTP client (LLM API calls) +- `serde` / `serde_json` — JSON serialization +- `tokio` — async runtime + process management (`tokio::process`) (already used in openab) +- `futures` — `Stream` trait and `BoxStream` for async streaming + +> **Note:** `tokio-process` was merged into `tokio::process` in tokio 0.2 and the standalone crate is deprecated. All process spawning uses `tokio::process::Command` directly. + +Nothing else. Can share code with openab core (ACP types, session pool logic). + +### Key Advantage: Deep Integration + +Because we own the agent and it shares the same language as openab core, deep integration is possible — a future library mode can bypass stdio entirely, using in-process function calls to eliminate all IPC overhead. + +### Design Principles (inspired by Pi) + +1. **Minimal tool surface** — 4 tools only (read, write, edit, bash). Maximizes context window for actual code. +2. **Tiny system prompt** — Agent instructions fit in ~500 tokens. No bloated tool descriptions. +3. **Multi-model** — Provider-agnostic. Switch models via config or mid-session command. +4. **Session trees** — Branching conversation history. Explore multiple approaches without losing context. +5. **No SDK dependency** — LLM APIs are just HTTP. A thin `reqwest` client (~300 lines) covers all providers. + +--- + +## 3. LLM Client Design + +The LLM client is intentionally thin — no SDK, just HTTP: + +```rust +use futures::stream::BoxStream; + +// Unified trait for all providers +trait LlmProvider: Send + Sync { + fn chat<'a>( + &'a self, + messages: &'a [Message], + tools: &'a [Tool], + ) -> Pin>> + Send + 'a>>; +} + +// Implementations are ~100 lines each +struct OpenAiProvider { base_url: String, api_key: String, model: String } +struct AnthropicProvider { api_key: String, model: String } +struct GoogleProvider { api_key: String, model: String } +``` + +> **Note:** `Stream` is a trait (from `futures`), not a concrete type. Returning it directly from a trait method would not compile. We use `BoxStream<'a, Event>` (i.e., `Pin + Send + 'a>>`) to provide a type-erased, object-safe return type. The `async fn` in traits is similarly desugared to a boxed future for object safety. + +All three major APIs follow the same pattern: +- POST JSON body with messages + tool definitions +- Stream SSE events back +- Parse tool_use / function_call blocks +- Loop until stop + +Provider differences are minor (header format, JSON schema for tools, SSE event names) and well-contained in ~300 lines per provider. + +### API Change Tracking & Version Pinning + +Without an official SDK, API changes must be tracked deliberately. Strategy: + +- **Pin API versions in headers**: `anthropic-version: 2023-06-01`, `x-api-version` for OpenAI (when available) +- **Model version pinning**: use dated model snapshots (e.g., `claude-sonnet-4-20250514`) not aliases (`claude-sonnet-4`) in default config +- **CI canary job**: weekly integration test against each provider's API with a minimal prompt. Failures trigger alerts, not breakage. +- **Provider feature flags**: new API features (extended thinking, computer use, etc.) are gated behind feature flags, not auto-enabled +- **Changelog tracking**: maintain `PROVIDERS.md` documenting supported API versions, known breaking changes, and migration notes +- **OpenAI-compatible fallback**: providers implementing the OpenAI chat completions spec (DeepSeek, Groq, Together, Ollama, etc.) require zero additional code — only `base_url` changes + +--- + +## 4. Tool Implementation + +| Tool | Input | Behavior | +|------|-------|----------| +| `read` | path, optional line range | Read file contents or list directory | +| `write` | path, content | Create or overwrite file | +| `edit` | path, old_str, new_str | Replace exact string in file | +| `bash` | command, optional working_dir | Execute shell command, return stdout/stderr | + +### Path Security (read/write/edit tools) + +All file tools enforce **path confinement** to prevent path traversal attacks: + +- All paths are canonicalized (`std::fs::canonicalize`) before access +- Resolved path must be within `working_dir` or explicitly allowed directories +- Symlinks are resolved and checked against the boundary +- Attempts to escape (e.g., `../../etc/passwd`) return an error, not file contents + +```rust +fn validate_path(path: &Path, working_dir: &Path) -> Result { + let canonical = path.canonicalize()?; + if !canonical.starts_with(working_dir) { + return Err(Error::PathTraversal(path.display().to_string())); + } + Ok(canonical) +} +``` + +### Sandboxing (bash tool) + +The `bash` tool runs commands via `tokio::process::Command` with: + +- Configurable timeout (default: 120s) +- **Process group kill on timeout** — uses `setsid` + `kill(-pgid)` to ensure all child/grandchild processes are terminated, preventing orphan process leaks +- Optional `bubblewrap` (bwrap) sandboxing on Linux; falls back to basic process isolation on macOS (see Cross-Platform Sandboxing below) +- Working directory scoped to agent's `working_dir` +- No network access restriction by default (agent needs to call APIs, git, etc.) + +### Environment Variable Filtering + +The `bash` tool does **NOT** inherit the agent's full environment. Instead: + +- **Deny-list by default**: sensitive variables are stripped before spawning child processes: + - `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `OPENAB_AGENT_*` (all provider keys) + - Any variable matching `*_SECRET`, `*_TOKEN`, `*_KEY` patterns (configurable) +- **Allow-list passthrough**: only explicitly declared safe variables are inherited: + - `PATH`, `HOME`, `USER`, `LANG`, `TERM`, `SHELL` + - Variables listed in `OPENAB_AGENT_BASH_ENV_ALLOW` (comma-separated) +- This prevents prompt injection attacks from exfiltrating API keys via `curl`/`wget` + +```rust +fn build_env(config: &AgentConfig) -> HashMap { + let mut env = HashMap::new(); + // Only pass safe defaults + for key in &["PATH", "HOME", "USER", "LANG", "TERM", "SHELL"] { + if let Ok(val) = std::env::var(key) { + env.insert(key.to_string(), val); + } + } + // Add user-configured allow-list + for key in &config.bash_env_allow { + if let Ok(val) = std::env::var(key) { + env.insert(key.to_string(), val); + } + } + env +} +``` + +### Cross-Platform Sandboxing + +| Platform | Sandboxing | Notes | +|----------|-----------|-------| +| Linux | `bubblewrap` (bwrap) | Full filesystem/network namespace isolation | +| macOS | Process group isolation + env filtering | Primary mechanism for local dev. `sandbox-exec` is deprecated by Apple and may be removed in future macOS versions — not relied upon. | +| Fallback | Process group isolation + env filtering | Minimum viable security without platform-specific tools | + +For production (Linux containers), `bubblewrap` is the primary mechanism. For local macOS development, the env filtering + path confinement provides baseline security without requiring bwrap. + +--- + +## 5. Session Tree + +Sessions are stored as a tree structure, not a flat list: + +``` +root +├── turn-1 (user: "explain this code") +│ └── turn-2 (assistant: "This code does...") +│ ├── turn-3a (user: "refactor it") ← branch A +│ │ └── turn-4a (assistant: "Here's the refactored...") +│ └── turn-3b (user: "add tests instead") ← branch B +│ └── turn-4b (assistant: "Here are the tests...") +``` + +Benefits: +- Explore multiple approaches from a decision point +- Rollback without losing the exploration +- Persisted to disk as JSON for session resume + +### Garbage Collection / Pruning + +To prevent unbounded growth in long-running deployments: + +- **Max tree depth**: configurable (default: 200 turns per branch). Oldest turns are summarized when exceeded. +- **Max branches**: configurable (default: 20 per session). Least-recently-used branches are pruned on overflow. +- **Inactive branch TTL**: branches not accessed for N hours (default: 24h) are eligible for pruning. +- **Disk persistence cap**: per-session JSON file capped at 10MB. Exceeding triggers forced summarization of oldest branches. +- **GC trigger**: runs on every Nth turn (default: 10) or when memory pressure is detected. + +--- + +## 5a. Context Window Management + +The agent must operate within LLM context limits (typically 128K–200K tokens). Strategy: + +### Token Counting + +- Use `tiktoken-rs` for OpenAI models, character-based estimation (×0.3) for others +- Track cumulative token usage per session branch + +### Window Overflow Strategy (ordered by priority) + +1. **Tool output truncation** — large `bash` stdout or `read` results are truncated to configurable max (default: 30K tokens) with a "truncated, showing first/last N lines" indicator +2. **Oldest turn summarization** — when context exceeds 80% of model limit, oldest turns (excluding system prompt and last 4 turns) are replaced with a one-paragraph summary generated by the same LLM +3. **Branch instead of truncate** — if the user explicitly branches, the new branch starts with a compact summary of the parent path, preserving full context in the original branch +4. **Hard cap rejection** — if a single tool output exceeds 50% of context window, reject and ask the user to narrow the request + +### Configuration + +```toml +[agent.context] +max_context_percent = 80 # trigger summarization at 80% of model limit +max_tool_output_tokens = 30000 # truncate individual tool outputs +summarize_after_turns = 20 # summarize turns older than the last 20 +``` + +--- + +## 6. Configuration + +```toml +[agent] +command = "openab-agent" +working_dir = "/home/agent" + +[agent.env] +# Provider selection (one of): +OPENAB_AGENT_PROVIDER = "anthropic" # or "openai", "google", "openai-compatible" +OPENAB_AGENT_MODEL = "claude-sonnet-4-20250514" + +# Auth (provider-specific): +ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" +# or: OPENAI_API_KEY, GOOGLE_API_KEY, etc. + +# Optional: +OPENAB_AGENT_MAX_TOKENS = "8192" +OPENAB_AGENT_TIMEOUT_SECS = "120" +``` + +### Steering Files + +openab-agent reads steering files in the same pattern as other agents: + +- `AGENTS.md` in working directory (hot memory, always loaded) +- `.openab-agent/system.md` — custom system prompt override +- `.openab-agent/append.md` — append to default system prompt + +--- + +## 7. Future: Library Mode (Deferred — Not in v1 Scope) + +> **Note:** This section documents a potential future optimization. It is explicitly **out of scope** for v0.1–v0.3 and will require its own ADR if pursued. + +Because openab-agent is Rust (same as openab core), a future optimization is **in-process mode** — no stdio, no JSON-RPC serialization: + +```rust +// Current: IPC over stdio (v0.1–v0.3) +openab::spawn_process("openab-agent", &["--acp"]) // stdin/stdout JSON-RPC + +// Future: direct function call (requires separate ADR) +let agent = openab_agent::Agent::new(config); +let response = agent.prompt(session_id, messages).await; // zero-copy +``` + +### Known Risks (to be addressed in future ADR) + +- **Panic propagation**: an agent panic (e.g., malformed SSE parse) would crash the entire openab process. Mitigation: `catch_unwind` boundaries or `tokio::task::spawn` with panic hooks. +- **Resource isolation**: in-process mode shares memory/threads with openab core. A runaway agent could starve the session pool. +- **Blast radius**: process isolation (current design) provides natural fault containment. Library mode trades this for performance. + +**Decision**: v1 ships as a standalone binary with stdio ACP. Library mode is a v2+ exploration only if IPC overhead proves to be a measurable bottleneck in production. + +--- + +## 8. Rollout Plan + +| Phase | Scope | Deliverable | +|-------|-------|-------------| +| **v0.1** | Scaffold + ACP layer + single provider (Anthropic) | Working agent, 4 tools, flat session | +| **v0.2** | Multi-provider + session tree + steering files | Feature parity with Pi's core | +| **v0.3** | Dockerfile + Helm chart + CI | Production-ready deployment | +| **v0.4** | Library mode exploration | In-process integration with openab core | + +--- + +## 9. Testing Strategy + +### Unit Test Boundaries + +Following the project's unit test ADR, operations involving network, filesystem, or subprocess are **integration tests only**. Unit tests cover pure logic: + +| Layer | Unit-Testable | How | +|-------|--------------|-----| +| Prompt assembly | ✅ | Hand-written mock `LlmProvider` returning canned `BoxStream` | +| Tool dispatch routing | ✅ | Mock tool implementations (no real FS/process) | +| Session tree operations | ✅ | Pure data structure manipulation | +| Token counting / context management | ✅ | Pure computation | +| SSE event parsing | ✅ | Feed raw bytes, assert parsed `Event` structs | +| LLM HTTP calls | ❌ (integration) | Real HTTP against provider or local mock server | +| File tools (read/write/edit) | ❌ (integration) | Real filesystem in temp dirs | +| Bash tool | ❌ (integration) | Real subprocess execution | + +### Hand-Written Mocks (no `mockall`) + +Per team convention, all mocks are hand-written: + +```rust +struct MockLlmProvider { + responses: Vec>, + call_count: AtomicUsize, +} + +impl LlmProvider for MockLlmProvider { + fn chat<'a>( + &'a self, + _messages: &'a [Message], + _tools: &'a [Tool], + ) -> Pin>> + Send + 'a>> { + let idx = self.call_count.fetch_add(1, Ordering::SeqCst); + let events = self.responses[idx].clone(); + Box::pin(async move { + Ok(Box::pin(futures::stream::iter(events.into_iter())) as BoxStream<'a, Event>) + }) + } +} +``` + +### Integration Tests + +- Tagged with `#[cfg(test)]` + `#[ignore]` for CI gating +- LLM integration tests require `OPENAB_TEST_PROVIDER` env var +- File/bash tool tests use `tempdir` for isolation +- CI runs integration tests in a separate job with real credentials (not on every PR) + +### CI Pipeline + +``` +PR push → cargo fmt --check → cargo clippy → cargo test (unit only) + ↓ +merge to main → cargo test (unit + integration) → canary deploy +``` + +--- + +## 10. Open Questions + +| Question | Options | Notes | +|----------|---------|-------| +| **Crate name** | `openab-agent` as a workspace member vs separate repo | Workspace member keeps it close to openab core | +| **Subscription auth** | Support OAuth flows (Claude Pro, ChatGPT Plus) or API-key only for v1? | API-key only for v1; subscription auth adds complexity | +| **Permission model** | Auto-approve all tool calls vs interactive approval? | Auto-approve for v1 (matches OpenAB's `--trust-all-tools` pattern) | +| **Context window management** | Truncate old turns vs summarize vs session tree branching? | Session tree branching for v1; summarization for v2 | + +--- + +## Consequences + +### Positive + +- **Zero external runtime** — no Node.js, Python, or npm. Single static binary. +- **Minimal attack surface** — no node_modules supply chain, no adapter layer vulnerabilities. +- **Fastest cold start** — <50ms vs 1–3s for Node-based agents. +- **Smallest image** — ~20MB distroless vs 300–800MB for existing agents. +- **Native ACP** — no wrapper overhead, no adapter bugs, no version mismatches. +- **Same language as openab** — shared types, potential library mode, unified toolchain. +- **Full control** — no upstream CLI breaking changes; we own the entire stack. + +### Negative + +- **LLM API maintenance** — must track API changes manually without official SDKs. +- **No subscription auth (v1)** — API key only initially; users with Claude Pro/ChatGPT Plus subscriptions still need Pi or Codex. +- **Feature gap** — v1 will lack features mature agents have (image support, MCP, web search tools). +- **Development effort** — building from scratch vs leveraging existing open-source agents. + +### Risks + +- **API instability** — if providers make breaking changes frequently, maintenance burden grows. Mitigated by pinning API versions and weekly CI canary. +- **Scope creep** — temptation to add more tools/features. Mitigated by the "4 tools only" design principle as a hard constraint for v1. + +--- + +## References + +- [Pi coding agent](https://github.com/earendil-works/pi) — design inspiration (minimal tools, session trees, multi-model) +- [Agent Client Protocol](https://github.com/anthropics/agent-protocol) — ACP spec +- [OpenAB](https://github.com/openabdev/openab) — host runtime diff --git a/docs/adr/turn-boundary-batching.md b/docs/adr/turn-boundary-batching.md index 6d9cc909b..de147e59a 100644 --- a/docs/adr/turn-boundary-batching.md +++ b/docs/adr/turn-boundary-batching.md @@ -4,7 +4,7 @@ - **Date:** 2026-04-29 - **Author:** @brettchien - **Tracking issue:** [#580](https://github.com/openabdev/openab/issues/580) — kept as historical discussion record -- **Implementation PR:** [#686](https://github.com/openabdev/openab/pull/686) (Phase 1 wiring; this ADR documents the design it lands) +- **Implementation PR:** [#686](https://github.com/openabdev/openab/pull/686) (Phase 1 wiring; the ADR documents the design that PR implements) - **Related:** [#78](https://github.com/openabdev/openab/issues/78) (Session Management — precondition), [#58](https://github.com/openabdev/openab/issues/58) (per-connection locking — precondition), [#307](https://github.com/openabdev/openab/issues/307) (cross-session blocking — adjacent symptom of §2.7) - **Anchor pinning:** - **Released-code anchors (file:line) — pinned to v0.8.2-beta.1** ([`52052b8`](https://github.com/openabdev/openab/commit/52052b8b104a85a7073dd6ae99eeb9f2fd331abe)). All `acp/connection.rs:NNN`, `acp/pool.rs:NNN`, `adapter.rs:NNN`, `discord.rs:NNN`, `slack.rs:NNN` references resolve at this SHA. They will drift against later commits — that's expected; the ADR documents the *design* relative to a stable base, not a moving target. @@ -360,7 +360,7 @@ For a single-message dispatch (`batch.len() == 1`) the minimum is two blocks: de | Source | Value | |---|---| | Discord adapter | `msg.timestamp` (serenity 0.12 `Timestamp`, RFC 3339 by default) | -| Slack adapter | `slack_ts_to_iso8601(event.ts)` — converts epoch-seconds-with-fractional to ISO 8601 with millisecond precision | +| Slack adapter | `slack_ts_to_iso8601(event.ts)` (proposed helper) — converts epoch-seconds-with-fractional to ISO 8601 with millisecond precision | | Gateway adapter | `chrono::Utc::now().to_rfc3339()` at receive time — best-effort for non-Discord/Slack channels; documented as approximate | `schema` stays `openab.sender.v1` — the field is additive and existing parsers keep working. Two purposes: @@ -788,6 +788,8 @@ The rules below operationalize I3 (broker structural fidelity). Together they fo 1. **Broker forwards `{prompt}` verbatim.** Broker must not parse, classify, transform, summarize, or annotate the user-supplied text content within `{prompt}`. Any future feature that needs to inspect `{prompt}` content must do so without mutating what the agent receives. + *Note: Adapter-level preprocessing that runs before `{prompt}` is constructed (e.g. `resolve_mentions()` in `discord.rs`) is not subject to this rule. This rule applies to the broker/dispatcher layer — i.e. from `Dispatcher::submit` onward.* + **Counter-examples (prohibited):** broker stripping markdown formatting before dispatch; broker expanding Discord `<@123>` mentions to `@username` strings; broker appending an `[image attached]` string when an image accompanies the prompt; broker collapsing repeated whitespace; broker normalizing Unicode forms. 2. **No banners or framing strings.** Broker must not inject any leading or trailing instruction text into the dispatched batch (e.g. no `[Batched: N messages…]`, no `[End of batch]`). All metadata lives in `` JSON. @@ -804,7 +806,7 @@ The rules below operationalize I3 (broker structural fidelity). Together they fo 7. **Splitting only at message boundaries.** When the token-budget cap (`max_batch_tokens`) forces a batch to split across multiple ACP turns, the split must occur between two arrival events — never inside a single arrival event. A single oversized message dispatches alone; the broker does not truncate or summarize it. -8. **No silent failure on consumer death.** When `submit` observes `SendError` (consumer task death), the failure must surface as ❌ on `msg.trigger_msg` **and** `⚠️ {format_user_error}` text in the channel **and** `Err` propagated to the caller. Already-enqueued messages whose `submit` already returned `Ok` are residual loss equivalent to a pod restart mid-turn (documented; out of Phase 1 scope to recover). Messages in the consumer's in-flight batch at the time of the panic are also residual loss — their `submit` already returned `Ok` before the consumer died, so they cannot be reacted from the `SendError` path. +8. **No silent failure on consumer death (retry-failed case).** When `submit` observes `SendError` (consumer task death), it first attempts a transparent retry — evict the dead consumer, spawn a fresh one, and re-send (§2.5). The first `SendError` is absorbed silently because the dominant cause is the benign first-message-after-idle race. Only when the **retry also fails** must the failure surface as ❌ on `msg.trigger_msg` **and** `⚠️ {format_user_error}` text in the channel **and** `Err` propagated to the caller. Already-enqueued messages whose `submit` already returned `Ok` are residual loss equivalent to a pod restart mid-turn (documented; out of Phase 1 scope to recover). Messages in the consumer's in-flight batch at the time of the panic are also residual loss — their `submit` already returned `Ok` before the consumer died, so they cannot be reacted from the `SendError` path. 9. **`bot_turns` runs at ingest, not at dispatch.** Multi-bot loop guards (`slack.rs:672-696`) execute before `submit`; batching is downstream and cannot bypass them. Bot-turn-limit counts batches as turns (one ACP invocation = one logical turn); the per-message ingest counter is unchanged. @@ -848,7 +850,7 @@ info_span!("dispatch", channel = %channel_id, adapter = "discord") Per-event metrics fold into the per-dispatch line as array fields → log line count = dispatch count, independent of batch size. -**Threshold for dedup re-evaluation:** when `p95_batch_size × avg_tokens_per_event > 500 tokens` (used as a rough proxy for per-dispatch `` envelope overhead) on any production channel for a sustained 24h window, the broker team must re-open the dedup question (e.g. emit `` only when sender or timestamp delta changes). Below that threshold the envelope cost is below noise and the readability win of always-explicit headers wins. +**Threshold for dedup re-evaluation:** when `p95_batch_size (count) × avg_tokens_per_event (tokens) > 500 tokens` of per-dispatch `` envelope overhead on any production channel for a sustained 24h window, the broker team must re-open the dedup question (e.g. emit `` only when sender or timestamp delta changes). Below that threshold the envelope cost is below noise and the readability win of always-explicit headers wins. **Phase 1 acceptance test (masami #1):** after Phase 1 lands and is deployed to a test channel, send a 3-message batch and verify the single `info!` line carries `events_per_dispatch = 3`, `packed_block_count = N`, `agent_dispatch_ms = N`, `tokens_per_event = [t1, t2, t3]`, `wait_ms = [w1, w2, w3]`. If any field is missing or events are split across multiple log lines, Phase 1 does not merge. diff --git a/docs/antigravity.md b/docs/antigravity.md new file mode 100644 index 000000000..6022edebf --- /dev/null +++ b/docs/antigravity.md @@ -0,0 +1,79 @@ +# Google Antigravity CLI (agy) + +OpenAB supports [Google Antigravity CLI](https://antigravity.google/) via the `agy-acp` adapter — a thin Rust binary that translates ACP JSON-RPC into `agy -p` invocations. + +## How It Works + +``` +openab ──ACP JSON-RPC──► agy-acp ──spawns──► agy --add-dir /home/agent -p "prompt" + agy --add-dir /home/agent --conversation -p "follow-up" +``` + +- First prompt in a session: `agy -p "text"`, then discovers the conversation ID +- Subsequent prompts: `agy --conversation -p "text"` (resumes specific conversation) +- Only the **delta** (new response) is sent back — previous turns are not repeated +- Full `` metadata is passed through to agy + +## Configuration + +```toml +[agent] +command = "agy-acp" +args = [] +working_dir = "/home/agent" +``` + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `AGY_WORKING_DIR` | Working directory for agy invocations | `/tmp` | +| `AGY_EXTRA_ARGS` | Extra arguments prepended to every `agy` invocation (optional) | (none) | + +## Steering Files + +agy reads `AGENTS.md` and `GEMINI.md` when it considers a directory a workspace: + +1. `AGENTS.md` and `GEMINI.md` are loaded first and injected into the system prompt +2. agy does not disclose how it determines HOME as a workspace, but `--add-dir` explicitly adds a directory +3. agy-acp **automatically** passes `--add-dir ` on every invocation — no configuration needed + +Place your steering instructions in `/home/agent/AGENTS.md` or `/home/agent/GEMINI.md` — they will be read on every prompt as long as `working_dir` points to that directory. + +## Docker + +```bash +docker build -f Dockerfile.antigravity -t openab-antigravity . +``` + +## Authentication + +Antigravity CLI uses Google Sign-In (OAuth). Authenticate inside the container: + +```bash +kubectl exec -it deployment/openab-antigravity -- /lib64/ld-linux-x86-64.so.2 /usr/local/bin/agy auth +``` + +Complete the device flow in your browser. Auth tokens persist in the PVC at `~/.gemini/`. + +## Helm + +```yaml +agents: + antigravity: + discord: + botToken: "${DISCORD_BOT_TOKEN}" + allowedChannels: ["123456789"] + agent: + command: "agy-acp" + args: [] + workingDir: "/home/agent" + image: + repository: ghcr.io/openabdev/openab-antigravity + tag: "latest" +``` + +## Limitations + +- **No streaming**: `agy -p` returns the full response at once; the adapter sends it as a single `agent_message_chunk` notification. +- **Cancel is a no-op**: `agy -p` runs to completion; `session/cancel` acknowledges but cannot interrupt. diff --git a/docs/codex.md b/docs/codex.md index 50d1b6934..ca6e23eec 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -250,6 +250,46 @@ codex exec --dangerously-bypass-approvals-and-sandbox ... Do not use this flag on an untrusted host. +### `bubblewrap is unavailable: no system bwrap was found on PATH` + +Codex's Linux sandbox modes (read-only / workspace-write) rely on `bwrap` +(bubblewrap) to create an inner sandbox. If the runtime image does not include +bubblewrap, even basic commands like `pwd` or `ls` will fail before execution +with this error. + +This commonly happens in OpenAB deployments where Codex already runs inside an +isolated container or VM — the outer runtime provides the desired isolation, so +the inner sandbox is redundant. + +**Solution — Disable Codex's inner sandbox** (recommended when the outer OpenAB +runtime already provides isolation): + +```toml +# /home/node/.codex/config.toml +[sandbox] +sandbox_mode = "danger-full-access" +approval_policy = "on-request" +``` + +Or launch with: + +```bash +codex --sandbox danger-full-access +``` + +Or via Helm: + +```bash +helm install openab openab/openab \ + --set-json 'agents.codex.extraConfig={"sandbox":{"sandbox_mode":"danger-full-access","approval_policy":"on-request"}}' +``` + +> **Important:** `danger-full-access` disables only Codex's *inner* sandbox. It +> does **not** remove the outer OpenAB container/VM isolation. The agent remains +> confined by the runtime's own security boundary. Ensure the outer runtime is a +> non-privileged container (no `--privileged` flag or excessive capabilities) for +> this security model to hold. + ### Imagegen appears to hang Check whether an image was generated even if the CLI has not returned yet: diff --git a/docs/config-reference.md b/docs/config-reference.md index 622dd7d3a..e93e48314 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -41,6 +41,9 @@ Discord adapter. Requires a Discord bot token. | `allow_user_messages` | string | `"involved"` | `"involved"` — reply in threads bot has participated in without @mention; channel messages require @mention; DMs always process. `"mentions"` — always require @mention. `"multibot-mentions"` — like `"involved"`, but require @mention once another bot has posted in the thread. | | `allow_dm` | bool | `false` | `true` = respond to Discord DMs; `false` = ignore DMs. `allowed_users` still applies in DMs. Each DM user consumes one session slot. | | `max_bot_turns` | u32 | `100` | Max consecutive bot turns per thread before throttling (soft limit). Human message resets the counter. A compiled-in hard cap of 1000 consecutive bot messages is always enforced. | +| `message_processing_mode` | string | `"per-message"` | Message dispatch mode: `"per-message"` (each message = own turn), `"per-thread"` (all messages in thread share one buffer), or `"per-lane"` (each sender gets own buffer). See [Message Dispatch Modes](message-dispatch-modes.md). | +| `max_buffered_messages` | u32 | `10` | Per-thread/lane mpsc channel capacity. Only applies to `per-thread` / `per-lane` modes. | +| `max_batch_tokens` | u32 | `24000` | Soft token cap per ACP turn. Only applies to `per-thread` / `per-lane` modes. | --- @@ -57,9 +60,12 @@ Slack adapter using Socket Mode. Requires both a Bot User OAuth Token and an App | `allow_all_users` | bool \| omit | auto-detect | Same behavior as Discord. | | `allowed_users` | string[] | `[]` | Slack user IDs (e.g. `U0123456789`). | | `allow_bot_messages` | string | `"off"` | Same as Discord. | -| `trusted_bot_ids` | string[] | `[]` | Slack Bot User IDs (`U...`). Find via: click bot profile → Copy member ID. | +| `trusted_bot_ids` | string[] | `[]` | Slack Bot User IDs (`U...`) or Bot IDs (`B...`). `U...` matching resolves event Bot IDs via Slack `bots.info`, so the bot token needs `users:read`. | | `allow_user_messages` | string | `"involved"` | Same as Discord. | | `max_bot_turns` | u32 | `100` | Same as Discord. | +| `message_processing_mode` | string | `"per-message"` | Same as Discord. See [Message Dispatch Modes](message-dispatch-modes.md). | +| `max_buffered_messages` | u32 | `10` | Same as Discord. | +| `max_batch_tokens` | u32 | `24000` | Same as Discord. | --- @@ -77,6 +83,9 @@ Custom Gateway adapter for platforms like Telegram, LINE, Feishu/Lark, and Googl | `allowed_channels` | string[] | `[]` | Chat/group IDs to allow. Only checked when `allow_all_channels` resolves to false. | | `allow_all_users` | bool \| omit | auto-detect | `true` = any user; `false` = only `allowed_users`. Omitted = inferred from list. | | `allowed_users` | string[] | `[]` | User IDs to allow. Only checked when `allow_all_users` resolves to false. | +| `message_processing_mode` | string | `"per-message"` | Same as Discord. See [Message Dispatch Modes](message-dispatch-modes.md). | +| `max_buffered_messages` | u32 | `10` | Same as Discord. | +| `max_batch_tokens` | u32 | `24000` | Same as Discord. | --- @@ -86,10 +95,10 @@ The AI agent subprocess that OpenAB spawns to handle messages via ACP. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `command` | string | *required* | Agent binary (e.g. `kiro-cli`, `claude`, `codex`, `gemini`, `copilot`, `opencode`, `cursor-agent`). | +| `command` | string | *required* | Agent binary (e.g. `kiro-cli`, `claude-agent-acp`, `codex`, `gemini`, `copilot`, `opencode`, `pi-acp`, `cursor-agent`). | | `args` | string[] | `[]` | CLI arguments passed to the agent. | | `working_dir` | string | `"/tmp"` | Working directory for the agent process. | -| `env` | map | `{}` | Extra environment variables (e.g. `{ ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" }`). | +| `env` | map | `{}` | Extra environment variables (e.g. `{ OPENAI_API_KEY = "${OPENAI_API_KEY}" }`). | | `inherit_env` | string[] | `[]` | Env var names to inherit from the OAB process (e.g. vars injected via K8s `envFrom`). Keys in `env` take precedence. | > **Default inherited vars:** After `env_clear()`, the agent always receives `HOME`, `PATH`, and `USER` (on Windows: `USERPROFILE`, `USERNAME`, `PATH`, `SystemRoot`, `SystemDrive`). Use `inherit_env` to pass additional vars beyond this baseline. @@ -105,10 +114,11 @@ working_dir = "/home/agent" # Claude Code [agent] -command = "claude" -args = ["--acp"] +command = "claude-agent-acp" +args = [] working_dir = "/home/node" -env = { ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" } +# Auth: kubectl exec -it deploy/openab-claude -- claude auth login +# Credentials persist in HOME PVC across restarts. See docs/claude-code.md. # Codex [agent] @@ -136,11 +146,21 @@ command = "opencode" args = ["acp"] working_dir = "/home/node" +# Pi Agent +[agent] +command = "pi-acp" +working_dir = "/home/node" + # Cursor Agent [agent] command = "cursor-agent" args = ["acp", "--model", "auto", "--workspace", "/home/agent"] working_dir = "/home/agent" + +# Hermes Agent +[agent] +command = "hermes-acp" +working_dir = "/home/agent" ``` --- @@ -204,6 +224,7 @@ Speech-to-text transcription for voice messages. Uses an OpenAI-compatible `/aud | `api_key` | string | `""` | API key for the STT service. When empty and `base_url` contains `groq.com`, the `GROQ_API_KEY` environment variable is used automatically. For local servers, use `api_key = "not-needed"`. | | `model` | string | `"whisper-large-v3-turbo"` | Model name to use for transcription. | | `base_url` | string | `"https://api.groq.com/openai/v1"` | Base URL of the STT API. Any OpenAI-compatible `/audio/transcriptions` endpoint works. | +| `echo_transcript` | bool | `false` | When set to `true` and STT runs, post a `> 🎤 ` message to the thread before the agent reply so users can verify what was heard. Failures show `(transcription failed)` and add a ⚠️ reaction to the original message. | --- @@ -256,6 +277,33 @@ timezone = "UTC" The external `cronjob.toml` uses `[[jobs]]` (same fields). See [Usercron docs](cronjob.md#usercron--hot-reload-with-cronjobtoml) for details. +### Usercron-only `[[jobs]]` fields + +These fields are valid only in the external usercron file, for example `$HOME/.openab/cronjob.toml`. They are rejected in baseline `[[cron.jobs]]` because OpenAB only writes state back to the user-managed cron file. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `id` | string | *required with `disable_on_success`* | Stable job ID used when the scheduler writes `enabled = false` or `thread_id` back to `cronjob.toml`. | +| `disable_on_success` | string | — | Command to run before sending the scheduled prompt. | +| `disable_on_success_match` | string | *required with `disable_on_success`* | Marker that must appear in stdout or stderr, in addition to exit code `0`, before the job is considered complete. | +| `disable_on_success_timeout_secs` | integer | `60` | Timeout for the completion check command. | +| `disable_on_success_working_dir` | string | — | Working directory for the completion check command. | + +Example: + +```toml +[[jobs]] +id = "fix-unit-tests" +enabled = true +schedule = "*/10 * * * *" +channel = "123456789" +message = "Unit tests are still failing. Continue fixing them." +disable_on_success = "npm test && echo OPENAB_GOAL_SUCCESS" +disable_on_success_match = "OPENAB_GOAL_SUCCESS" +disable_on_success_timeout_secs = 120 +disable_on_success_working_dir = "/workspace/my-project" +``` + **Cron expression format:** ``` @@ -308,6 +356,9 @@ Key mapping (`values.yaml` → `config.toml`): | `agents..discord.allowBotMessages` | `[discord] allow_bot_messages` | | `agents..discord.trustedBotIds` | `[discord] trusted_bot_ids` | | `agents..discord.allowUserMessages` | `[discord] allow_user_messages` | +| `agents..discord.messageProcessingMode` | `[discord] message_processing_mode` | +| `agents..discord.maxBufferedMessages` | `[discord] max_buffered_messages` | +| `agents..discord.maxBatchTokens` | `[discord] max_batch_tokens` | | `agents..slack.*` | `[slack] *` (same pattern) | | `agents..pool.maxSessions` | `[pool] max_sessions` | | `agents..pool.sessionTtlHours` | `[pool] session_ttl_hours` | diff --git a/docs/copilot.md b/docs/copilot.md index 0e73e58e1..6ad44316e 100644 --- a/docs/copilot.md +++ b/docs/copilot.md @@ -140,13 +140,20 @@ helm install openab-copilot openab/openab \ ## Model Selection -Copilot CLI defaults to Claude Sonnet 4.6. Other available models include: +The default model is defined in `~/.copilot/settings.json`. -- Claude Opus 4.6, Claude Haiku 4.5 (Anthropic) -- GPT-5.3-Codex (OpenAI) -- Gemini 3 Pro (Google) +To set `auto` as the default model, exec into the container and create the file: -Model selection is controlled by Copilot CLI itself (via `/model` in interactive mode). In ACP mode, the default model is used. +```bash +kubectl exec -it deployment/openab-copilot-copilot -- bash -c ' +cat << EOF > ~/.copilot/settings.json +{ + "model": "auto" +} +EOF' +``` + +The `auto` setting lets Copilot automatically select the best model for each request. This persists across pod restarts when `persistence.enabled=true` (the home directory is on a PVC). ## Known Limitations diff --git a/docs/cronjob.md b/docs/cronjob.md index 61e7dc1be..4591af29c 100644 --- a/docs/cronjob.md +++ b/docs/cronjob.md @@ -179,7 +179,7 @@ The path is relative to `$HOME/.openab/` (e.g. `"cronjob.toml"` resolves to `$HO > **New installations**: If `~/.openab/` does not exist yet, the scheduler silently skips the file and continues running. Once you create the directory and place `cronjob.toml` inside, it will be picked up automatically on the next tick — no restart required. > [!CAUTION] -> **Breaking Change** — `usercron_path` relative path base changed from `$HOME` to `$HOME/.openab/`. +> **Breaking Change (v0.8.2)** — `usercron_path` relative path base changed from `$HOME` to `$HOME/.openab/`. > If you are upgrading from a previous version, move your existing file: > ```bash > mkdir -p ~/.openab @@ -256,6 +256,44 @@ Agent: ✅ Written to cronjob.toml, takes effect within 1 minute This enables mobile-friendly schedule management — talk to your agent from your phone, and it updates the cron file for you. +### Goal-Driven Auto-Disable + +Usercron jobs can stop themselves once a goal is complete. Add `disable_on_success` to run a command before the scheduled prompt is sent. The job is considered complete only when the command exits `0` **and** stdout or stderr contains `disable_on_success_match`. + +```toml +[[jobs]] +id = "fix-unit-tests" # required for scheduler writeback +enabled = true +schedule = "*/10 * * * *" +channel = "1490282656913559673" +message = "Unit tests are still failing. Continue fixing them and report progress." + +disable_on_success = "npm test && echo OPENAB_GOAL_SUCCESS" +disable_on_success_match = "OPENAB_GOAL_SUCCESS" +disable_on_success_timeout_secs = 120 +disable_on_success_working_dir = "/workspace/my-project" +``` + +Execution flow: + +1. The schedule matches. +2. The scheduler runs `disable_on_success`. +3. If the command exits `0` and output contains `disable_on_success_match`, OpenAB posts `✅ Goal achieved`, writes `enabled = false` back to `$HOME/.openab/cronjob.toml`, and skips the regular prompt. +4. Otherwise, OpenAB sends the regular `message` and the agent continues working. + +`disable_on_success` is supported only in usercron `[[jobs]]`, not baseline `[[cron.jobs]]`. This keeps scheduler writeback limited to the user-managed cron file. + +### Re-enabling a Disabled Job + +Once a goal is achieved and the job is disabled, re-enable it by editing `$HOME/.openab/cronjob.toml`: + +```toml +# Flip back to true to restart the job +enabled = true +``` + +This can be done manually, or by asking the AI agent (e.g. "re-enable the fix-unit-tests cron job"). + ### Kubernetes Deployment Mount `cronjob.toml` on a PVC so it persists across pod restarts, and set `usercron_path` in your config.toml: @@ -273,7 +311,7 @@ usercron_path = "cronjob.toml" - **Minute-aligned**: The scheduler aligns to minute boundaries (`:00`), so `0 9 * * *` fires at exactly 09:00:00, not at whatever second the process started. - **Overlap protection**: If a previous execution of the same job is still running, the next tick is skipped. - **Isolation**: Cron failures are logged but never block interactive chat traffic. -- **Stateless**: No persistence needed. Schedules are re-evaluated from config on restart. +- **Usercron persistence**: For usercron jobs, the scheduler may write `thread_id` and `enabled = false` back to `cronjob.toml`. - **Graceful shutdown**: In-flight cron tasks are waited on (up to 30 seconds) during shutdown. ## Sender Identity @@ -300,6 +338,15 @@ Config-driven cron covers the 80% use case: "send this message at this time." Fo See [Kubernetes CronJob Reference Architecture](cronjob_k8s_refarch.md) for the external scheduler approach. +## Known Limitations + +| Limitation | Details | +|---|---| +| Mixed numeric/name day-of-week | `1,Mon` or `Mon,3` is not supported and will be rejected. Use either all numeric (`1-5`) or all name-based (`Mon-Fri`) notation. | +| Wrap-around day-of-week ranges | `5-2` (Fri through Tue) is not supported. Use explicit listing instead: `5,6,0,1,2`. | + +> **Tip:** Name-based notation (`Mon-Fri`, `Sun`, `Mon,Wed,Fri`) is always available as an alternative to numeric day-of-week values. + ## Troubleshooting | Symptom | Cause | Fix | @@ -311,3 +358,4 @@ See [Kubernetes CronJob Reference Architecture](cronjob_k8s_refarch.md) for the | Channel not found | Bot not in channel | Invite the bot to the target channel | | Usercron not reloading | File not saved / wrong path | Check logs for `usercron file changed, reloading` | | Usercron parse error | Invalid TOML syntax | Check logs for `failed to parse usercron file` | +| Goal job does not auto-disable | Command did not exit `0` or output did not include `disable_on_success_match` | Run the command manually and confirm both conditions | diff --git a/docs/discord.md b/docs/discord.md index 9a3527962..0cabd6451 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -134,18 +134,53 @@ trusted_bot_ids = ["123456789012345678"] # only this bot's messages pass throug Empty (default) = any bot can pass through (subject to the mode check). +### `allowed_role_ids` + +Role IDs that trigger the bot, same as a direct @mention. This enables users to invoke multiple bots simultaneously with a single role mention (e.g. `@AllBots review this`). + +```toml +allowed_role_ids = ["123456789012345678"] # @mention this role = trigger the bot +``` + +Empty (default) = role mentions do not trigger the bot. + +**Setup:** +1. Create a Discord role (e.g. `Bots` or `AllAgents`) +2. Assign the role to all bots you want to trigger together +3. Add the role's ID to each bot's `allowed_role_ids` +4. Users type `@RoleName ` to trigger all bots at once + +> **Note:** If multiple bots share the same role, all will respond simultaneously. Use `multibot-mentions` mode if you want bots to require explicit @mention when other bots are already in the thread. + +#### Interaction with `multibot-mentions` mode + +When `allow_user_messages = "multibot-mentions"` is set alongside `allowed_role_ids`: + +| Action | Result | +|--------|--------| +| `@Role review this` in a channel | All bots trigger (role mention = explicit mention) | +| Follow-up in the thread without @mention | Only the thread owner responds (multibot gate kicks in) | +| `@Role follow up` in the thread | All bots respond again | + +This gives the best of both worlds: one role mention to summon all bots, but subsequent messages in the thread don't cause all bots to pile on. + --- ## @Mention Behavior -**Always @mention the bot user, not the role.** Discord shows both in autocomplete — pick the one without the role icon. +The bot responds to: + +1. **Direct @mention** (`@BotUser`) — always works +2. **Role mention** (`@RoleName`) — only if the role ID is in `allowed_role_ids` +3. **Thread reply** — depends on `allow_user_messages` mode (no @mention needed in `involved` mode) ``` -✅ @AgentBroker hello ← user mention, bot responds -❌ @AgentBroker hello ← role mention (with role icon), bot ignores +✅ @AgentBroker hello ← user mention, bot responds +✅ @AllBots hello ← role mention, bot responds (if role in allowed_role_ids) +❌ @SomeOtherRole hello ← role not in allowed_role_ids, bot ignores ``` -Role mentions are ignored because they are shared across bots and cause false positives in multi-bot setups. This is intentional since v0.7.8-beta.3 (#420, #440). +The triggering role mention is stripped from the prompt sent to the agent (same as the bot's own user mention). ### User mention UIDs @@ -153,7 +188,8 @@ When a user mentions another user (e.g. `@SomeUser`) in a message to the bot, th - The LLM can copy `<@UID>` into its reply to produce a clickable Discord mention - The bot's own mention is stripped (so the bot doesn't see itself being triggered) -- Role mentions are replaced with `@(role)` placeholder +- Triggering role mentions (in `allowed_role_ids`) are stripped +- Other role mentions are replaced with `@(role)` placeholder To help the LLM know who each UID refers to, provide a UID→name mapping via system prompt or context entry (see [Multi-Bot Setup](#multi-bot-setup) below). @@ -170,6 +206,37 @@ Each thread gets its own agent session. Sessions are cleaned up after `session_t --- +## Attachment Handling + +OpenAB processes Discord file attachments and converts them into content blocks +for the agent. Supported types (checked in order): + +| Type | Detection | Agent receives | +|------|-----------|----------------| +| Audio | MIME `audio/*` | Transcribed text via STT (if enabled) | +| Text files | Extension list (`.txt`, `.md`, `.json`, etc.) | File content inlined (up to 5 files, 1 MB total) | +| Images | MIME `image/*` or image extensions | Base64-encoded image block | +| Video | MIME `video/*` or extensions (`.mp4`, `.mov`, `.webm`, `.mkv`, `.m4v`, `.avi`) | Text block with filename, content type, size, and Discord CDN URL | + +Unsupported attachment types are silently ignored. + +### Video attachments + +Video files are not downloaded or transcoded. The agent receives metadata and the +Discord CDN URL so it can fetch or inspect the file using tools like `ffprobe`. + +``` +[Video attachment] +filename: demo.mp4 +content_type: video/mp4 +size_bytes: 8421376 +url: https://cdn.discordapp.com/attachments/.../demo.mp4 +``` + +No configuration is needed — video forwarding is always enabled. + +--- + ## Streaming OpenAB uses **edit-streaming** on Discord — the bot sends a placeholder message and updates it every 1.5 seconds as tokens arrive, giving a live typing effect. @@ -274,10 +341,11 @@ helm install openab openab/openab \ --set agents.kiro.discord.botToken="$DISCORD_BOT_TOKEN" \ --set-string 'agents.kiro.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \ --set agents.kiro.discord.allowBotMessages=off \ - --set agents.kiro.discord.allowUserMessages=involved + --set agents.kiro.discord.allowUserMessages=involved \ + --set-string 'agents.kiro.discord.allowedRoleIds[0]=YOUR_ROLE_ID' ``` -⚠️ Use `--set-string` for channel/user IDs to avoid float64 precision loss. +⚠️ Use `--set-string` for channel/user/role IDs to avoid float64 precision loss. --- @@ -288,7 +356,7 @@ helm install openab openab/openab \ 1. **Check channel ID** — make sure it's in `allowed_channels` 2. **Check permissions** — bot needs Send Messages, Create Public Threads, Read Message History in the channel 3. **Check intents** — Message Content Intent must be enabled in Developer Portal -4. **Check @mention type** — use user mention, not role mention +4. **Check @mention type** — use user mention or a role in `allowed_role_ids` 5. **Check if in a thread** — with `mentions` mode, @mention is required even in threads ### Bot stops receiving messages after restart diff --git a/docs/feishu.md b/docs/feishu.md index a4a494f98..15e344f12 100644 --- a/docs/feishu.md +++ b/docs/feishu.md @@ -80,6 +80,8 @@ https://your-gateway-host/webhook/feishu | — | `FEISHU_ALLOW_BOTS` | `off` | Bot message handling: `off` / `mentions` / `all` | | — | `FEISHU_TRUSTED_BOT_IDS` | — | Comma-separated open_id list of known bots | | — | `FEISHU_MAX_BOT_TURNS` | `20` | Max consecutive bot replies per channel before suppression | +| — | `FEISHU_SESSION_TTL_HOURS` | `24` | How long the bot remembers thread participation (hours). After expiry, @mention is required again. | +| — | `FEISHU_ALLOW_USER_MESSAGES` | `involved` | Thread response mode: `involved` / `mentions` / `multibot-mentions`. See below. | | `gateway.botUsername` | — | — | Set to bot's `open_id` for @mention gating | | `gateway.streaming` | — | `false` | Enable streaming (typewriter) mode | @@ -95,6 +97,32 @@ In group chats, the bot only responds when @mentioned (default). To find your bo To disable mention gating: `feishu.requireMention: false`. +### Thread Participation (Involved Mode) + +Once the bot replies in a thread (topic), it remembers that thread and responds to subsequent messages **without requiring @mention** — similar to Discord's `allow_user_messages: "involved"` mode. + +- Only applies to threads (messages with `root_id`). Main channel messages always require @mention. +- Participation is stored in memory. Gateway restart clears the cache; users need to @mention once to re-engage. +- TTL controlled by `FEISHU_SESSION_TTL_HOURS` (default 24h). After expiry, @mention is required again. + +### Multi-Bot Threads (multibot-mentions Mode) + +When `FEISHU_ALLOW_USER_MESSAGES=multibot-mentions`, the bot detects when another bot is @mentioned in a participated thread and reverts to requiring @mention — preventing all bots from responding simultaneously. + +| Mode | Behavior | +|------|----------| +| `involved` (default) | Bot responds in participated threads without @mention. All participated bots respond. | +| `multibot-mentions` | Same as `involved`, but once another bot is @mentioned in the thread, require @mention for all bots. | +| `mentions` | Always require @mention, even in participated threads. | + +**Multi-bot detection** (how the gateway identifies "another bot"): + +1. If `FEISHU_TRUSTED_BOT_IDS` is set → exact match against configured IDs +2. If only `FEISHU_ALLOWED_USERS` is set → any @mention that is not self and not in allowed_users is inferred as another bot (recommended, zero-config) +3. If neither is set → no multibot detection + +Note: Detection only triggers in threads where the bot has already participated. This prevents premature marking of threads the bot hasn't joined. + ## Security Notes - `appSecret`, `verificationToken`, and `encryptKey` are stored in a Kubernetes Secret, not in ConfigMap. @@ -137,13 +165,14 @@ The gateway downloads and forwards image and text file attachments to the AI age | Feishu msg_type | Handling | |-----------------|----------| | `text` | Text extracted, forwarded as prompt | -| `image` | Image downloaded, resized (max 1200px), JPEG compressed, base64 encoded → `ContentBlock::Image` | +| `image` | Image downloaded, resized (max 1200px), JPEG compressed, stored to `~/.openab/media/inbound/` → `ContentBlock::Image` | | `file` | Text files only (`.txt`, `.py`, `.rs`, `.md`, `.json`, etc., max 512KB). Non-text files (`.pdf`, `.zip`, etc.) are silently ignored. | +| `audio` | Voice message downloaded (opus/ogg, max 25MB), stored to filesystem, forwarded to core. If `[stt]` is enabled, core transcribes via Whisper API and injects `[Voice message transcript]: ...` into the prompt. If STT is disabled or fails, the message is silently skipped. | | `post` | Rich text: text nodes extracted as prompt, `img` nodes downloaded as image attachments. This is the format Feishu uses when @mention + paste image in a group. | **Group chat limitation:** Feishu does not allow @mention and image upload in the same message. However, @mention + paste (Ctrl+V) an image works — Feishu sends this as a `post` message containing both the mention and the image. Direct image upload (via the attachment button) cannot include @mention, so the bot will not respond in groups. -**Processing pipeline:** Gateway downloads media using `GET /im/v1/messages/{message_id}/resources/{key}?type=image` with `tenant_access_token`, resizes to max 1200px, compresses to JPEG (quality 75), base64 encodes, and embeds in the `GatewayEvent.content.attachments` field. OAB core decodes attachments into `ContentBlock::Image` or `ContentBlock::Text` for the AI agent. +**Processing pipeline:** Gateway downloads media using `GET /im/v1/messages/{message_id}/resources/{key}?type=image` with `tenant_access_token`, resizes to max 1200px, compresses to JPEG (quality 75), and stores to `~/.openab/media/inbound/`. The file path is passed in `GatewayEvent.content.attachments[].path`. OAB core reads the file directly from disk and converts to `ContentBlock::Image` or `ContentBlock::Text` for the AI agent. ## Streaming (Typewriter) @@ -172,6 +201,22 @@ To start a threaded conversation: reply to any bot message in a group chat (long Streaming (typewriter) mode works in threads — edits target the same message regardless of thread context. +## Agent-Controlled Reply-To + +Agents can reply to a specific message using the `[[reply_to:message_id]]` output directive (see [docs/output-directives.md](output-directives.md)). The gateway sends the reply via Feishu's native Reply API, showing a quote reference in the UI. + +``` +Agent output: + [[reply_to:om_xxx]] + This is my reply to that specific message. +``` + +**How agents get message IDs:** Every incoming message includes `message_id` in the `SenderContext` injected into the agent prompt. Agents can store and reference these IDs to reply to specific messages. + +**Fallback:** If the specified message ID is invalid or the Reply API fails, the gateway automatically falls back to a plain send (no quote). + +**Use case:** In multi-bot threads, each bot can reply to a different message, creating clear visual conversation threads within a Feishu thread. + ## Bot-to-Bot Collaboration (Gateway-Side Only) The gateway adapter includes bot identification and filtering scaffolding (`AllowBots` enum, `FEISHU_TRUSTED_BOT_IDS`, `FEISHU_MAX_BOT_TURNS` with human-reset safety valve), matching Discord's `allow_bot_messages` design. diff --git a/docs/google-chat.md b/docs/google-chat.md index d51253c93..584ea450e 100644 --- a/docs/google-chat.md +++ b/docs/google-chat.md @@ -143,11 +143,17 @@ working_dir = "/home/agent" - Inline code, fenced code blocks: pass through unchanged - Tables and other unsupported syntax pass through as-is - **Streaming (edit_message)** — when OAB streaming is enabled, the bot edits its initial reply in-place as tokens arrive (typewriter effect) +- **Inbound attachments** — image, text file, and audio attachments are downloaded via Google Chat Media API and stored to `~/.openab/media/inbound/` (colocate filesystem store): + - Images: resized to ≤1200px JPEG (q75); GIFs preserved. Max 10 MB. + - Text files: only known text extensions (`.txt`, `.md`, `.json`, `.py`, `.rs`, etc.). Max 512 KB. + - Audio: forwarded as-is for STT processing by core. Max 25 MB. + - Drive-sourced attachments are skipped (require separate Drive API integration). ### Not Supported - **Reactions** — Google Chat API does not support message reactions on behalf of bots -- **File/image attachments** — not yet implemented +- **Outbound attachments** — bot cannot send image/file attachments back to the user yet +- **Drive-linked attachments** — only `UPLOADED_CONTENT` source is handled; `DRIVE_FILE` source skipped ## Environment Variables (Gateway) diff --git a/docs/grok.md b/docs/grok.md new file mode 100644 index 000000000..b6d4d09c0 --- /dev/null +++ b/docs/grok.md @@ -0,0 +1,134 @@ +# Grok Build (xAI) + +[Grok Build](https://x.ai/news/grok-build-cli) is xAI's official coding agent CLI. It speaks ACP natively via `grok agent stdio` — no wrapper required. + +## Docker Image + +```bash +docker build -f Dockerfile.grok -t openab-grok:latest . +``` + +The image pulls a pinned `grok` binary from xAI's public artifacts bucket and verifies its SHA256 checksum. Bump `GROK_VERSION`, `GROK_SHA256_AMD64`, and `GROK_SHA256_ARM64` in `Dockerfile.grok` to upgrade. + +## Helm Install + +```bash +helm install openab openab/openab \ + --set agents.kiro.enabled=false \ + --set agents.grok.discord.enabled=true \ + --set agents.grok.discord.botToken="$DISCORD_BOT_TOKEN" \ + --set-string 'agents.grok.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \ + --set agents.grok.image=ghcr.io/openabdev/openab-grok:latest \ + --set agents.grok.command=grok \ + --set-string 'agents.grok.args[0]=agent' \ + --set-string 'agents.grok.args[1]=stdio' \ + --set agents.grok.workingDir=/home/agent +``` + +> Set `agents.kiro.enabled=false` to disable the default Kiro agent. + +## Manual config.toml + +```toml +[agent] +command = "grok" +args = ["agent", "stdio"] +working_dir = "/home/agent" +``` + +## Authentication + +Grok Build supports three credential sources. Pick whichever fits your deployment. + +### Option A: API key (simplest, recommended for CI / bot deployments) + +Set the environment variable in the pod / task definition: + +```bash +export GROK_CODE_XAI_API_KEY="xai-..." +``` + +Get a key from . No interactive login needed. + +> ⚠️ **Security**: env vars listed under `[agent].env` are visible to the agent and can be leaked via prompt injection. Prefer mounting them via the platform's secret manager. + +### Option B: Device-code OAuth (for SuperGrok subscriptions) + +If you want to use a SuperGrok subscription instead of pay-per-token API billing: + +```bash +kubectl exec -it -- grok login --device-auth +``` + +The CLI prints a short code and URL — open the URL on any device, enter the code, approve. The token is stored at `~/.grok/auth.json` inside the container. + +This works in any headless environment (K8s exec, ECS exec, plain SSH) **without port-forwarding** — unlike loopback OAuth flows. + +### Option C: Enterprise deployment key + +```bash +export GROK_DEPLOYMENT_KEY="..." +``` + +A deployment key takes precedence over `auth.json`. The CLI fetches managed config from `cli-chat-proxy.grok.com/v1/deployment/config` on startup. Available to xAI enterprise customers; contact xAI sales for details. + +## Credential Persistence + +`grok login` stores OAuth credentials at `~/.grok/auth.json` and runtime config at `~/.grok/config.toml`. The OpenAB Helm chart's default persistence covers `workingDir` automatically (PVC mounted at `/home/agent`). + +If deploying manually, mount persistent storage at `/home/agent/.grok`: + +```yaml +volumes: + - name: grok-credentials + persistentVolumeClaim: + claimName: grok-credentials-pvc +volumeMounts: + - name: grok-credentials + mountPath: /home/agent/.grok +``` + +API-key-only deployments don't need persistence. + +## Model Selection + +The default model is whichever Grok Build CLI selects (currently `grok-code-fast-1` for the free tier; `grok-4.3` family for SuperGrok). To override: + +```toml +[agent] +command = "grok" +args = ["agent", "stdio", "--model", "grok-4.3"] +working_dir = "/home/agent" +``` + +List available models inside the pod: + +```bash +kubectl exec -it -- grok models +``` + +## Updating + +```bash +# Inside the container (one-shot upgrade): +kubectl exec -it -- grok update + +# Or rebuild the image with a new pinned version: +docker build -f Dockerfile.grok \ + --build-arg GROK_VERSION=0.1.220 \ + --build-arg GROK_SHA256_AMD64=... \ + --build-arg GROK_SHA256_ARM64=... \ + -t openab-grok:latest . +``` + +## Comparison with Hermes + +| Property | `Dockerfile.grok` | `Dockerfile.hermes` | +|----------|-------------------|---------------------| +| Provider | xAI Grok only | xAI + 30 others via Nous gateway | +| ACP | Native (`grok agent stdio`) | Via `hermes-acp` wrapper | +| Headless auth | API key env or device-code | Loopback OAuth (needs port-forward / ECS curl trick) | +| Supply chain | xAI only | xAI + Nous Research install script | +| Image size | Smaller (single static binary, no Python venv) | Larger (Python + uv + ffmpeg) | + +Pick `Dockerfile.grok` if Grok is the only model you need. Pick `Dockerfile.hermes` if you want multi-provider switching or fallback chains. diff --git a/docs/hermes.md b/docs/hermes.md new file mode 100644 index 000000000..d5cccc7ba --- /dev/null +++ b/docs/hermes.md @@ -0,0 +1,176 @@ +# Hermes Agent + +[Hermes Agent](https://github.com/NousResearch/hermes-agent) by Nous Research supports ACP natively via the `hermes acp` subcommand (or the `hermes-acp` binary). + +Hermes acts as a multi-provider inference gateway — it handles OAuth token lifecycle, credential storage, and provider routing so OAB agents don't need to manage auth directly. + +## Docker Image + +```bash +docker build -f Dockerfile.hermes -t openab-hermes:latest . +``` + +The image installs Hermes Agent via the official install script. + +## Helm Install + +```bash +helm install openab openab/openab \ + --set agents.kiro.enabled=false \ + --set agents.hermes.discord.enabled=true \ + --set agents.hermes.discord.botToken="$DISCORD_BOT_TOKEN" \ + --set-string 'agents.hermes.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \ + --set agents.hermes.image=ghcr.io/openabdev/openab-hermes:latest \ + --set agents.hermes.command=hermes-acp \ + --set agents.hermes.workingDir=/home/agent +``` + +> Set `agents.kiro.enabled=false` to disable the default Kiro agent. + +## Manual config.toml + +```toml +[agent] +command = "hermes-acp" +working_dir = "/home/agent" +``` + +## Authentication + +Hermes supports 30+ providers. Authenticate inside the pod: + +```bash +kubectl exec -it -- hermes auth add xai-oauth # xAI Grok (SuperGrok $30/mo) +kubectl exec -it -- hermes auth add nous # Nous Portal +kubectl exec -it -- hermes model # Interactive provider picker +``` + +### xAI Grok OAuth (Recommended) + +> ⚠️ **Requires an active [SuperGrok paid subscription](https://x.ai/grok) ($30/mo).** Auth will succeed without one, but the API silently returns empty responses — the bot appears to work but never replies. + +xAI Grok OAuth uses a loopback redirect flow — the callback listener binds `127.0.0.1:56121` inside the pod/container. + +#### Option A: Kubernetes (port-forward) + +```bash +# Terminal 1: port-forward +kubectl port-forward deployment/ 56121:56121 + +# Terminal 2: run auth +kubectl exec -it deployment/ -- hermes auth add xai-oauth --no-browser +``` + +1. Copy the printed authorize URL → open in your local browser +2. Approve access on accounts.x.ai +3. Browser redirects to `127.0.0.1:56121/callback` → port-forward delivers it to the pod +4. Terminal shows `Added xai-oauth OAuth credential #1: "xai-oauth-oauth-1"` + +#### Option B: ECS / Remote (curl-the-callback) + +ECS Fargate doesn't support port-forward. Use two exec sessions instead: + +```bash +# Terminal 1: start the auth listener +aws ecs execute-command --cluster openab --task --container openab --interactive --command bash +hermes auth add xai-oauth --no-browser +# → prints authorize URL with &state=XXXXX in it +# → "Waiting for callback on http://127.0.0.1:56121/callback" +``` + +Open the authorize URL in your browser and approve. The browser will redirect to +`http://127.0.0.1:56121/callback?code=...` and fail ("Could not establish connection"). +**Copy the `code` value** from the page or URL bar. The `state` value comes from the +authorize URL printed in Terminal 1. + +```bash +# Terminal 2: exec into the SAME container +aws ecs execute-command --cluster openab --task --container openab --interactive --command bash +curl "http://127.0.0.1:56121/callback?code=&state=" +``` + +Terminal 1 should print: +``` +Added xai-oauth OAuth credential #1: "xai-oauth-oauth-1" +``` + +> ⚠️ The code expires in seconds — be fast. If you get `invalid_grant`, re-run `hermes auth add` and try again. + +#### After auth: set the default model + +```bash +hermes config set model.provider xai-oauth +hermes config set model.default grok-4.3 +``` + +#### Fix file ownership (important for exec-based auth) + +When running auth/config commands via `kubectl exec` or ECS exec (which runs as root), +fix ownership so the `agent` user can read the files: + +```bash +chown -R agent:agent /home/agent/.hermes/ +``` + +### Providers That Don't Need Port-Forward + +| Provider | Auth Method | +|----------|-------------| +| Anthropic (Claude Pro/Max) | Paste-the-code flow | +| OpenAI Codex (ChatGPT Plus/Pro) | Device code flow | +| MiniMax, Nous Portal | Device code flow | +| xAI Grok, Spotify | Loopback OAuth (port-forward required) | + +### Supported Providers (via OAuth) + +| Provider | Auth Command | Cost Model | +|----------|-------------|------------| +| xAI Grok | `hermes auth add xai-oauth` | SuperGrok subscription ($30/mo) | +| OpenAI Codex | `hermes model` → OpenAI Codex | ChatGPT subscription | +| GitHub Copilot | `hermes model` → GitHub Copilot | Copilot subscription | +| Google Gemini | `hermes model` → Google Gemini (OAuth) | Free tier available | +| Anthropic | `hermes model` → Anthropic | Claude Max + extra credits | +| Nous Portal | `hermes auth add nous` | Nous subscription | + +### Supported Providers (via API Key) + +Any provider can also be configured with an API key via environment variables: + +```toml +[agent] +command = "hermes-acp" +working_dir = "/home/agent" +env = { XAI_API_KEY = "${XAI_API_KEY}" } +``` + +## Provider Switching + +Switch providers without restarting the pod: + +```bash +kubectl exec -it -- hermes model +``` + +## Credential Persistence + +Hermes stores OAuth tokens in `~/.hermes/`. The OpenAB Helm chart's default persistence covers this automatically (PVC mounted at `workingDir`). + +If deploying manually (without the Helm chart), mount persistent storage at `/home/agent` or `/home/agent/.hermes`: + +```yaml +volumes: + - name: hermes-credentials + persistentVolumeClaim: + claimName: hermes-credentials-pvc +volumeMounts: + - name: hermes-credentials + mountPath: /home/agent/.hermes +``` + +## Advantages + +- **Cost**: SuperGrok $30/mo flat rate vs pay-per-token API pricing +- **Multi-provider**: 30+ providers accessible through one agent +- **Zero auth complexity**: Hermes handles OAuth + token refresh +- **Multi-modal**: TTS, image gen, video gen via the same OAuth token +- **Fallback chains**: Auto-switch providers on failure diff --git a/docs/image-tags.md b/docs/image-tags.md new file mode 100644 index 000000000..ab533dc18 --- /dev/null +++ b/docs/image-tags.md @@ -0,0 +1,55 @@ +# Docker Image Tagging Convention + +## Core (`ghcr.io/openabdev/openab`) + +| Tag | Points to | Updated when | +|-----|-----------|--------------| +| `0.8.3-beta.12` | Exact pre-release build | Pre-release tag pushed | +| `beta` | Latest pre-release | Every pre-release build | +| `0.8.3` | Promoted stable build | Stable tag pushed | +| `0.8` | Latest patch in minor | Stable promotion | +| `stable` | Latest stable | Stable promotion | +| `latest` | Latest stable (= `stable`) | Stable promotion | + +Variant images (e.g. `-codex`, `-claude`, `-gemini`) follow the same convention with a suffix: `ghcr.io/openabdev/openab-codex:beta`. + +## Gateway (`ghcr.io/openabdev/openab-gateway`) + +| Tag | Points to | Updated when | +|-----|-----------|--------------| +| `0.5.1` | Exact release | `gateway-v*` tag pushed | +| `v0.5.1` | Same as above (v-prefixed alias) | Same | +| `latest` | Latest release | Every release | + +## Which tag to use + +| Use case | Recommended tag | +|----------|----------------| +| Production (pinned) | Exact version (`0.8.3-beta.12`) | +| Helm chart default | `stable` or `beta` (channel-based) | +| Local dev / quick test | `beta` | +| CI | Exact version or SHA | + +## Release flow + +``` +release PR merged → tag-on-merge → v0.8.3-beta.12 + │ + ▼ + build-operator.yml + │ + ┌──────────┴──────────┐ + │ is_prerelease=true │ + ▼ │ + tag: 0.8.3-beta.12 │ + tag: beta │ + │ + ┌──────────────────────┘ + │ is_prerelease=false (stable) + ▼ + promote latest beta image → + tag: 0.8.3 + tag: 0.8 + tag: stable + tag: latest +``` diff --git a/docs/inbound-attachments.md b/docs/inbound-attachments.md new file mode 100644 index 000000000..47f3136bb --- /dev/null +++ b/docs/inbound-attachments.md @@ -0,0 +1,100 @@ +# Inbound Attachments + +How OAB handles images, audio, and files sent by users across all platforms. + +## Architecture + +``` +User sends media (photo/voice/file) + → Platform webhook delivers to Gateway + → Gateway downloads via platform API (auth stays in Gateway) + → Image: resize ≤1200px, JPEG compress (GIF passthrough ≤5MB) + → Store to ~/.openab/media/inbound/ + → WS event includes file path in attachments[].path + → Core reads from disk (zero encoding overhead) + → Processes: image → LLM, audio → STT, text_file → code block + → File auto-evicted after 2 minutes +``` + +## Platform Support Matrix + +| Platform | Images | Audio/Voice | Text Files | Video | Binary Files | +|----------|--------|-------------|------------|-------|--------------| +| **Discord** | ✅ | ✅ (STT) | ✅ | metadata only | skipped | +| **Telegram** | ✅ | ✅ (STT) | ✅ (whitelist) | skipped | skipped | +| **Feishu** | ✅ | ✅ (STT) | ✅ (whitelist) | skipped | skipped | +| **Google Chat** | ✅ | ✅ (STT) | ✅ (whitelist) | skipped | Drive files skipped | +| **WeCom** | ✅ | — | ✅ (whitelist) | skipped | skipped | +| **LINE** | planned | planned | — | — | — | +| **Slack** | ✅ | ✅ (STT) | ✅ | — | skipped | + +## Processing Pipeline + +### Images + +1. Gateway downloads from platform API +2. `resize_and_compress()` — longest side ≤1200px, JPEG quality 75 +3. GIFs ≤5MB passed through unchanged (preserves animation) +4. Stored to `~/.openab/media/inbound/` +5. Core reads bytes → `ContentBlock::Image` → sent to LLM + +### Audio / Voice Messages + +1. Gateway downloads raw audio (ogg/m4a/mp3) +2. Stored to filesystem (no transcoding) +3. Core reads bytes → STT transcription (Whisper/Groq) → `[Voice message transcript]: ...` +4. If STT disabled: silently skipped + +### Text Files (Documents) + +1. Gateway downloads file +2. Extension whitelist check: `.txt`, `.csv`, `.md`, `.json`, `.yaml`, `.rs`, `.py`, `.js`, `.ts`, `.go`, `.java`, `.c`, `.cpp`, `.sh`, `.sql`, `.html`, `.css`, `.toml`, `.xml`, `.ini`, `.cfg`, `.conf`, etc. +3. UTF-8 validation — non-UTF-8 files rejected +4. Stored to filesystem +5. Core reads → wraps in markdown code block: `` ```filename.ext\n\n``` `` + +### Unsupported Types + +Binary files (zip, pdf, exe, docx), video, and stickers are **silently skipped**. The agent does not receive any notification that a file was sent. + +## Size Limits + +| Type | Max Size | Enforced By | +|------|----------|-------------| +| Images | 10 MB | Gateway (pre-download Content-Length + post-download bytes) | +| Audio | 20 MB | Gateway | +| Text files | 20 MB | Gateway (same as store cap) | +| GIF passthrough | 5 MB | `resize_and_compress()` | +| Store (defense-in-depth) | 20 MB | `store_media()` | + +## Storage (Colocate Mode) + +Media is stored at `~/.openab/media/inbound/`: + +- **Filenames**: Server-generated UUID v4, no extension (MIME type in event payload) +- **TTL**: 2 minutes — background task evicts expired files every 30 seconds +- **Trust boundary**: Gateway and Core share the same `$HOME` (same pod / sidecar) +- **No auth required**: Core reads directly from filesystem, no HTTP/token needed + +### Security + +- **Path traversal**: Impossible — filenames are UUID only, never user-supplied +- **Token leakage**: Platform auth tokens (Telegram bot token, LINE access token, Feishu tenant token) stay in Gateway, never reach Core or agent +- **Disk exhaustion**: TTL eviction + size limits prevent unbounded growth +- **No executable content**: Files are raw data, never executed + +### Future: HTTP Proxy Mode + +For separated deployments (Gateway ≠ Core pod), a future PR will add `GET /media/` on the Gateway, allowing Core to fetch via internal HTTP. The `attachments[].path` field will be replaced by `attachments[].url` in that mode. + +## Configuration + +No additional configuration required. The filesystem store is always active when Gateway is running. Ensure Gateway and Core share the same `$HOME` (default in Helm colocate/sidecar mode). + +## Related + +- [Telegram](telegram.md) — Telegram-specific behavior and limitations +- [Feishu](feishu.md) — Feishu image/file/audio handling +- [Google Chat](google-chat.md) — Google Chat attachment support +- [STT (Speech-to-Text)](stt.md) — Audio transcription configuration +- [Sending Files (Outbound)](sendfiles.md) — Agent → user file delivery (separate mechanism) diff --git a/docs/messaging.md b/docs/messaging.md index 9172bca0c..40b3273d0 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -187,7 +187,7 @@ BotA in thread: here's my analysis | Key | Type | Default | Description | |-----|------|---------|-------------| | `allow_bot_messages` | string | `"off"` | `"off"` — ignore bot messages. `"mentions"` — only process bot messages that @mention this bot. `"all"` — process all bot messages (capped by `max_bot_turns`). | -| `trusted_bot_ids` | string[] | `[]` | Whitelist of bot IDs. When non-empty, only these bots pass the bot gate. Empty = any bot (mode permitting). Ignored when `allow_bot_messages = "off"`. | +| `trusted_bot_ids` | string[] | `[]` | Whitelist of bot IDs. For Slack, entries may be Bot User IDs (`U...`) or Bot IDs (`B...`); `U...` matching requires `users:read` so OpenAB can call `bots.info`. Empty = any bot (mode permitting). Ignored when `allow_bot_messages = "off"`. | | `max_bot_turns` | u32 | `20` | Max consecutive bot turns per thread before throttling. A human message resets the counter. | > **Safety:** When `allow_bot_messages = "all"`, a separate hardcoded cap of 10 consecutive bot turns applies regardless of `max_bot_turns`. diff --git a/docs/native-agent.md b/docs/native-agent.md new file mode 100644 index 000000000..b3baeedfb --- /dev/null +++ b/docs/native-agent.md @@ -0,0 +1,121 @@ +# Native Agent (openab-agent) + +A lightweight, native Rust coding agent with built-in ACP support and ChatGPT subscription authentication. No Node.js, no Python, no adapter layer. + +## Quick Start + +```bash +# Build +cd openab-agent && cargo build --release + +# Authenticate (browser flow — recommended) +openab-agent auth codex-oauth + +# Headless server (paste callback URL) +openab-agent auth codex-oauth --no-browser + +# Run as ACP server (used by openab core) +openab-agent +``` + +## Configuration + +```toml +[agent] +command = "openab-agent" +working_dir = "/home/agent" +env = { OPENAB_AGENT_OPENAI_MODEL = "gpt-5.4-mini" } +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPENAB_AGENT_OPENAI_MODEL` | `gpt-4.1-nano` | Model to use | +| `OPENAB_AGENT_OPENAI_BASE_URL` | `https://chatgpt.com/backend-api` | API base URL | +| `OPENAB_AGENT_PROVIDER` | auto-detect | Force provider (`anthropic`, `openai`, `codex`) | +| `OPENAB_AGENT_MAX_TOKENS` | `8192` | Max output tokens | +| `OPENAB_AGENT_OAUTH_CLIENT_ID` | Pi's client | Custom OAuth client ID | +| `ANTHROPIC_API_KEY` | — | Anthropic API key (alternative to OAuth) | + +## Authentication + +### Browser PKCE Flow (recommended) + +```bash +openab-agent auth codex-oauth +``` + +Opens browser to authenticate with your ChatGPT Plus/Pro subscription. + +### Headless Server (paste flow) + +```bash +openab-agent auth codex-oauth --no-browser +``` + +1. Prints an authorization URL +2. Open it in any browser and approve +3. Browser redirects to `localhost:1455` (fails on remote server) +4. Copy the full URL from the browser address bar +5. Paste it back into the terminal + +### Device Code Flow + +```bash +openab-agent auth codex-device +``` + +Note: Device flow currently has limited scopes and may not work with all models. + +### API Key (Anthropic) + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +``` + +No login needed — set the env var and the agent auto-detects it. + +## Custom System Prompt + +Place an `AGENTS.md` file in the working directory (`cwd`). It will be prepended to the default system prompt at session creation. + +``` +/home/agent/ +├── AGENTS.md ← read at session start +├── .openab/ +│ └── agent/ +│ └── auth.json +└── (your project files) +``` + +> **Note:** Skills and MCP servers are NOT supported yet. Only `AGENTS.md` at `cwd` is read. Skills and MCP support are planned for v0.2. + +## Docker + +```bash +docker build -f Dockerfile.native -t openab-native:latest . +``` + +Image is ~20MB (debian-slim + static Rust binaries). No runtime dependencies. + +## Memory Usage + +~7MB per session — 28x lighter than Pi, 55x lighter than Kiro CLI. + +## Supported Models (ChatGPT Subscription) + +- `gpt-5.2` +- `gpt-5.3-codex` +- `gpt-5.3-codex-spark` +- `gpt-5.4` +- `gpt-5.4-mini` +- `gpt-5.5` + +## Tools + +4 built-in tools: +- `read` — file contents or directory listing +- `write` — create/overwrite file +- `edit` — string replacement +- `bash` — shell execution with process group isolation diff --git a/docs/opencode.md b/docs/opencode.md index 97c4117c6..2b65bd826 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -116,6 +116,59 @@ kubectl logs deployment/openab-opencode --tail=5 `@mention` the bot in your Discord channel to start chatting. +## Example: xAI Grok with SuperGrok OAuth + +### 1. Create the auth directory + +OpenCode stores credentials at `~/.local/share/opencode/auth.json`. The directory must exist before login: + +```bash +kubectl exec deployment/openab-opencode -- mkdir -p /home/node/.local/share/opencode +``` + +### 2. Authenticate xAI (device-code flow) + +```bash +kubectl exec -it deployment/openab-opencode -- opencode auth login -p xai +``` + +Select **"xAI Grok OAuth (Headless / Remote / VPS)"**. The CLI prints a URL and a short code: + +``` +Open https://x.ai/device on any device and enter code: ABCD-1234 +``` + +Open the URL on any device with a browser, enter the code, and approve. + +### 3. Verify auth file was created + +```bash +kubectl exec deployment/openab-opencode -- cat /home/node/.local/share/opencode/auth.json +``` + +You should see a JSON object with `xai` credentials. + +### 4. Set default model + +Create `opencode.json` in the working directory (`/home/node`): + +```bash +kubectl exec -it deployment/openab-opencode -- bash -c 'cat > /home/node/opencode.json << "EOF" +{ + "$schema": "https://opencode.ai/config.json", + "model": "xai/grok-4.3" +} +EOF' +``` + +### 5. Restart to pick up config + +```bash +kubectl rollout restart deployment/openab-opencode +``` + +> **Important:** Do NOT set a custom `baseURL` or provider override for xAI. The built-in xAI provider handles routing correctly. A stale `~/.config/opencode/opencode.json` with `baseURL: "http://localhost:9090/v1"` (from xai-proxy setups) will break xAI — delete it if present. + ## Notes - **Tool authorization**: OpenCode handles tool authorization internally and never emits `session/request_permission` — all tools run without user confirmation, equivalent to `--trust-all-tools` on other backends. diff --git a/docs/output-directives.md b/docs/output-directives.md new file mode 100644 index 000000000..9b5876acf --- /dev/null +++ b/docs/output-directives.md @@ -0,0 +1,77 @@ +# Output Directives + +## Overview + +Agents can control platform-specific message delivery by prefixing their output with `[[key:value]]` directives. OAB parses and strips these before sending to the platform. + +## Format + +``` +[[reply_to:1502606076451885136]] +[[ephemeral:true]] ← future +Actual message content starts here... +``` + +Rules: +- Consecutive `[[key:value]]` lines at the start of output = directive header block +- First line that doesn't match `[[key:value]]` (with colon) = content begins +- `[[X]]` without colon is NOT a directive — stops parsing, preserved as content +- Directives are stripped from the final message (never visible to users) +- Unknown keys are silently ignored (forward compatible, logged at debug level) +- If the same key appears multiple times, the last value wins + +## Available Directives + +### `reply_to` + +Reply to a specific message by ID (Discord: `message_reference`). + +``` +[[reply_to:1502606076451885136]] +Here is my reply to that specific message. +``` + +**Value**: Platform message ID. Format depends on the target adapter — Discord requires a numeric snowflake; Slack accepts `ts` (e.g. `1234567890.123456`). The directive parser validates that the value is non-empty, ≤64 chars, and contains only ASCII alphanumeric characters plus `.`, `-`, `_`; per-platform format validation happens in each adapter. + +**Behavior**: +- Discord: sends with `message_reference`, showing the native "replying to..." UI +- Feishu: sends via Reply API (`POST /im/v1/messages/{id}/reply`), showing native quote UI +- Invalid/non-existent message ID: silently falls back to plain send +- Works in both streaming and send-once modes + +**How agents get message IDs**: Every incoming message includes `message_id` in `SenderContext`: + +```json +{ + "schema": "openab.sender.v1", + "sender_id": "845835116920307722", + "sender_name": "pahud.hsieh", + "message_id": "1502606076451885136", + "channel": "discord", + ... +} +``` + +## Multi-Agent Use Case + +In a thread with multiple bots, agents can reply to each other's messages: + +``` +Human: "Review this PR" (message_id: 100) +Bot A: "Found 3 issues" (message_id: 101) +Bot B output: + [[reply_to:101]] + I agree with Bot A on F1, but F2 is actually fine because... +``` + +This creates clear visual conversation threads within a Discord thread — essential for multi-agent collaboration. + +## Comparison with Other Platforms + +| Platform | Reply Mechanism | Agent Control | +|----------|----------------|---------------| +| OpenClaw | `replyToMode` config (`off`/`first`/`all`) | ❌ Platform decides, always to trigger msg | +| Hermes Agent | `DISCORD_REPLY_TO_MODE` env var | ❌ Platform decides, always to trigger msg | +| **OAB** | `[[reply_to:message_id]]` directive | ✅ Agent chooses any message | + +> **Note:** `reply_to` is currently implemented for Discord and Feishu (gateway). Slack message IDs (ts format like `1234567890.123456`) are accepted by the parser but the Slack adapter does not yet send threaded replies via this directive — it falls back to plain send. Slack support can be added in a future PR. diff --git a/docs/pi.md b/docs/pi.md new file mode 100644 index 000000000..3ff4a19c8 --- /dev/null +++ b/docs/pi.md @@ -0,0 +1,109 @@ +# Pi Coding Agent + +OpenAB supports the [Pi coding agent](https://github.com/earendil-works/pi-coding-agent) via the `pi-acp` adapter — a Node.js bridge that translates ACP JSON-RPC into Pi CLI invocations. + +## Advantages Over Other Native Coding Agents + +Pi is a native coding agent that supports subscription-based authentication (like Codex, Cloud Code, and GitHub Copilot). Key advantages: + +### No Auth Proxy Required + +Pi natively supports Anthropic (Claude Pro/Max) and ChatGPT Plus/Pro subscriptions via OAuth. Unlike agents that require an `openab-auth-proxy` sidecar for subscription forwarding, Pi handles subscription auth directly — reducing deployment complexity and eliminating a moving part. + +| Agent | Subscription Auth | Auth Proxy Needed? | +|-------|------------------|--------------------| +| Pi | Native OAuth (`pi /login`) | ❌ No | +| Codex | Native device flow | ❌ No | +| GitHub Copilot | Native device flow | ❌ No | +| Claude Code | Native OAuth | ❌ No | +| Kiro | Native OAuth | ❌ No | + +### Minimal Tool Surface (Maximum Context Window) + +Pi exposes only 4 core tools: `read`, `write`, `edit`, `bash`. Combined with a tiny system prompt, this drastically reduces prompt overhead and maximizes the available context window for actual project source files. + +| Agent | Tool Count | System Prompt Size | +|-------|-----------|-------------------| +| Pi | 4 | Minimal | +| Claude Code | 10+ | Large | +| Codex | 8+ | Medium | +| Copilot | 10+ | Large | + +### Multi-Model Support + +Pi is model-agnostic and supports 15+ LLM providers. Developers can switch models mid-session without restarting the agent or changing configuration. + +Supported providers include: +- Anthropic (Claude) — via subscription or API key +- OpenAI (GPT/Codex) — via subscription or API key +- Google (Gemini) — via API key +- Any OpenAI-compatible endpoint + +### Branching Session Trees + +Pi saves session history as trees, enabling clean branching of code exploration. This allows developers to explore multiple approaches from a single decision point without losing context. + +## Configuration + +```toml +[agent] +command = "pi-acp" +working_dir = "/home/node" +``` + +## Docker + +```bash +docker build -f Dockerfile.pi -t openab-pi:latest . +``` + +## Helm + +```yaml +agents: + pi: + discord: + enabled: true + allowedChannels: + - "YOUR_CHANNEL_ID" + command: pi-acp + workingDir: /home/node + image: "ghcr.io/openabdev/openab-pi:latest" +``` + +## Authentication + +```bash +kubectl exec -it deployment/openab-pi -- pi +# Once inside the interactive interface, type /login to authenticate +``` + +Supported authentication methods: + +| Provider | Auth Method | Subscription | +|----------|-------------|-------------| +| Anthropic (Claude Pro/Max) | OAuth via `pi /login` | Claude subscription | +| ChatGPT Plus/Pro | OAuth via `pi /login` | ChatGPT subscription | +| Any API key provider | `env = { OPENAI_API_KEY = "..." }` | Pay-per-token | + +## Steering Files + +Pi reads steering files in this order: + +1. `.pi/SYSTEM.md` — replaces the default system prompt entirely +2. `.pi/APPEND_SYSTEM.md` — appends to the default system prompt +3. `AGENTS.md` — loaded hierarchically (project root → global) for context injection + +Place your steering instructions in `/home/node/AGENTS.md` or `/home/node/.pi/APPEND_SYSTEM.md`. + +## Persisted Paths (PVC) + +| Path | Contents | +|------|----------| +| `/home/node/.pi/` | Pi configuration and auth tokens | +| `/home/node/.pi/sessions/` | Session history trees | + +## Limitations + +- **No streaming**: `pi-acp` returns the full response at once; streamed output is sent as a single `agent_message_chunk` notification. +- **Cancel is best-effort**: Pi CLI runs to completion; `session/cancel` may not interrupt mid-generation. diff --git a/docs/refarch/aws-ecs-fargate-spot.md b/docs/refarch/aws-ecs-fargate-spot.md new file mode 100644 index 000000000..a4e2b1cf7 --- /dev/null +++ b/docs/refarch/aws-ecs-fargate-spot.md @@ -0,0 +1,189 @@ +# Reference Architecture: OpenAB on AWS ECS Fargate Spot + +> **This doc is meant to be used with Kiro or any coding CLI.** Prompt your AI agent with something like: +> +> ``` +> per https://github.com/openabdev/openab/blob/main/docs/refarch/aws-ecs-fargate-spot.md deploy an openab on ECS Fargate Spot for me in my AWS account +> ``` +> +> and it will guide you through (or handle) the full setup on AWS. + +Deploy a single OpenAB bot on ECS Fargate Spot for ~$2.7/month with persistent auth via S3. + +## Architecture + +``` ++-- AWS -------------------------------------------------------+ +| | +| +-- ECS Fargate Spot Task --------------------------------+ | +| | | | +| | +-----------+ +----------------+ +------------+ | | +| | |s3-restore | | openab | | s3-sync | | | +| | |(init) |->|(main container)| | (sidecar) | | | +| | |pull auth | | kiro-cli acp | | push auth | | | +| | |from S3 | | Discord bot | | every 5min | | | +| | +-----------+ +----------------+ +------------+ | | +| | | | | | | | +| | +--------------+- /data volume ----+ | | +| +---------------------------------------------------------+ | +| | +| | | | +| S3 Bucket Secrets Manager | +| (auth state) (bot token) | +| | ++--------------------------------------------------------------+ + | | + Discord API +-- GitHub ------+ + (bot gateway) | Gist | + | (config.toml) | + +----------------+ +``` + +## Cost + +| Resource | Spec | Spot Price/mo | +|----------|------|---------------| +| Fargate Task | 0.25 vCPU + 512MB | ~$2.7 | +| S3 | < 1MB state | ~$0 | +| Secrets Manager | 1 secret | $0.40 | +| CloudWatch Logs | minimal | ~$0 | +| **Total** | | **~$3.1/month** | + +## Prerequisites + +- AWS CLI configured with permissions for ECS, IAM, S3, Secrets Manager, CloudWatch Logs, EC2 +- A Discord bot token (from Discord Developer Portal) +- Kiro CLI subscription (for OAuth login) + +## Deployment Steps + +### Phase 1: Store the Discord bot token + +Create a Secrets Manager secret with key `DISCORD_BOT_TOKEN`: + +```bash +aws secretsmanager create-secret --name openab \ + --secret-string '{"DISCORD_BOT_TOKEN":"YOUR_BOT_TOKEN_HERE"}' \ + --region us-east-1 +``` + +Note the secret ARN for later. + +### Phase 2: Create IAM roles + +Create two roles for ECS tasks: + +1. **Execution role** (`openab-ecs-execution-role`): + - Trust: `ecs-tasks.amazonaws.com` + - Attach: `AmazonECSTaskExecutionRolePolicy` + - Inline policy: `secretsmanager:GetSecretValue` on the secret ARN + +2. **Task role** (`openab-ecs-task-role`): + - Trust: `ecs-tasks.amazonaws.com` + - Inline policies: + - S3: `s3:GetObject`, `s3:PutObject`, `s3:ListBucket`, `s3:DeleteObject` on the state bucket + - SSM (for ECS Exec): `ssmmessages:CreateControlChannel`, `CreateDataChannel`, `OpenControlChannel`, `OpenDataChannel` + +### Phase 3: Create infrastructure + +1. **S3 bucket** for auth state persistence (e.g. `openab-state-`) +2. **CloudWatch log group** `/ecs/openab` +3. **ECS cluster** named `openab` with capacity providers `FARGATE_SPOT` + `FARGATE` +4. **Security group** — egress-only (no inbound rules needed) + +### Phase 4: Create the config.toml + +Host `config.toml` as a GitHub Gist (recommended) or any HTTPS URL. OpenAB fetches it at startup via `openab run -c `. + +Create a **secret gist** (or public if you prefer) with your config: + +```bash +gh gist create --filename config.toml --desc "OpenAB ECS config" - <<'EOF' +[discord] +bot_token = "${DISCORD_BOT_TOKEN}" +allow_all_channels = true +allow_all_users = true +allow_bot_messages = "mentions" +allow_user_messages = "multibot-mentions" +max_bot_turns = 1000 +message_processing_mode = "per-thread" + +[agent] +command = "kiro-cli" +args = ["acp", "--trust-all-tools"] +working_dir = "/home/agent" + +[pool] +max_sessions = 3 +session_ttl_hours = 1 + +[reactions] +enabled = true +remove_after_reply = false +EOF +``` + +Use the raw gist URL (e.g. `https://gist.githubusercontent.com///raw//config.toml`) in Phase 5. + +### Phase 5: Register task definition and create service + +Register a task definition with three containers: + +| Container | Image | Role | Essential | +|-----------|-------|------|-----------| +| `s3-restore` | `amazon/aws-cli` | Pull auth from S3 + `chown 1000:1000` | No (init) | +| `openab` | `ghcr.io/openabdev/openab:latest` | Main bot process | Yes | +| `s3-sync` | `amazon/aws-cli` | Push auth to S3 every 5 min | No (sidecar) | + +Key settings: +- CPU: 256 (0.25 vCPU), Memory: 512 MB +- Network mode: `awsvpc`, assign public IP +- Capacity provider: `FARGATE_SPOT` +- Enable ECS Exec for interactive login +- `openab` container depends on `s3-restore` (condition: SUCCESS) +- `openab` entrypoint: restore auth from shared volume, then `exec openab run -c ` +- Inject `DISCORD_BOT_TOKEN` from Secrets Manager via container `secrets` +- Shared volume (`agent-data`) mounted at `/data` across all containers + +Create an ECS service with `desiredCount: 1`. + +### Phase 6: Authenticate Kiro CLI (one-time) + +After the task starts, exec in and login: + +```bash +TASK_ID=$(aws ecs list-tasks --cluster openab --service-name openab-kiro \ + --desired-status RUNNING --query 'taskArns[0]' --output text | awk -F/ '{print $NF}') + +aws ecs execute-command --cluster openab --task $TASK_ID \ + --container openab --interactive \ + --command "kiro-cli login --use-device-flow" +``` + +Then copy auth to the shared volume for S3 persistence: + +```bash +aws ecs execute-command --cluster openab --task $TASK_ID \ + --container openab --interactive \ + --command "cp /home/agent/.local/share/kiro-cli/data.sqlite3 /data/data.sqlite3" +``` + +The sidecar syncs to S3 within 5 minutes. Future task restarts auto-restore auth. + +### Phase 7: Verify + +Mention `@YourBot` in a Discord channel. Check logs: + +```bash +aws logs tail /ecs/openab --follow --region us-east-1 +``` + +Look for: `discord bot connected` → `spawning agent` → streaming response. + +## Important Notes + +- **Spot interruption**: Task may be reclaimed with 2-min notice. Auth persists via S3; bot reconnects automatically on new task launch. +- **Auth file ownership**: The S3 restore step must `chown 1000:1000` the auth file — ECS Exec runs as root but kiro-cli runs as uid 1000 (`agent`). +- **Config via URL**: `openab run -c ` fetches config over HTTPS. Use `${ENV_VAR}` for secrets — expanded at runtime from container environment. +- **No NAT needed**: Public subnet + `assignPublicIp: ENABLED` gives direct internet access. +- **Memory**: 512MB is tight (~370MB idle). Bump to 1024MB if sessions OOM. diff --git a/docs/refarch/ci-observability-discord.md b/docs/refarch/ci-observability-discord.md new file mode 100644 index 000000000..34159bf75 --- /dev/null +++ b/docs/refarch/ci-observability-discord.md @@ -0,0 +1,335 @@ +# Reference Architecture: CI Observability via Discord + +> **This doc is meant to be used with Kiro or any coding CLI.** Prompt your AI agent with something like: +> +> ``` +> per https://github.com/openabdev/openab/blob/main/docs/refarch/ci-observability-discord.md set up CI notifications to my Discord channel +> ``` +> +> and it will guide you through the full setup. + +Send GitHub Actions CI results (pass/fail) to a Discord channel or thread via webhook, with full CI metadata and user mentions. + +## Problem + +When CI runs in GitHub Actions, the only way to know the result is to check the GitHub UI or wait for an email. For teams collaborating in Discord, this creates friction: + +- **No visibility** — CI failures go unnoticed until someone manually checks GitHub +- **Slow feedback loop** — contributors wait without knowing their PR is broken +- **Context switching** — developers must leave Discord to check CI status +- **No accountability** — nobody gets pinged when CI breaks + +## What We Want + +- CI finishes (pass or fail) → automatically POST result to a specific Discord channel/thread +- Show who committed, how long CI took, and which step failed +- Include both PR URL and Run URL for quick navigation +- Mention a specific user so they get pinged +- Bot-readable content (not hidden in embeds) so mentioned bots can act on it +- Route notifications to the correct thread based on the PR description +- One reusable workflow that any CI job can call + +## Two Approaches + +### Approach 1: Polling Mode (Cronjob) + +OpenAB has a built-in cron scheduler. You can schedule the agent to periodically check CI status and fix failures: + +``` +@bot can you schedule a cronjob for yourself to this thread and remind yourself to +"check https://github.com/owner/repo/actions and fix them if required" every 10min? +``` + +This creates a `[[cron.jobs]]` entry: + +```toml +[[cron.jobs]] +schedule = "*/10 * * * *" +channel = "123456789012345678" +thread_id = "1505664791719710810" +message = "check https://github.com/owner/repo/actions and fix them if required" +``` + +**Pros:** Holistic view — checks everything on your plate (all workflows, all branches, all repos). Agent can auto-fix issues. No webhook configuration needed. + +**Cons:** Up to N-minute delay, unnecessary API calls when nothing changed, burns compute on polling. + +### Approach 2: Notification Mode (Webhook Push) ← This Doc + +CI pushes results to Discord the moment it finishes — zero delay, zero wasted calls. But it only tells you about **this single CI run**. + +``` +GitHub Actions ──finish──► HTTP POST ──► Discord thread + (webhook) +``` + +**Pros:** Instant notification, no polling cost, precise metadata (duration, failed step, commit info) for the specific run. + +**Cons:** Narrow scope — only reports on the workflow that triggered it. Can't see the big picture. Can't auto-fix (notification only). Requires webhook setup. + +### Comparison + +| | Polling (Cronjob) | Notification (Webhook) | +|---|---|---| +| **Scope** | Everything on your plate — all workflows, branches, repos | Single CI run only | +| **Latency** | Up to N minutes | Instant (on completion) | +| **Auto-fix** | ✅ Agent can push fixes | ❌ Notification only | +| **Setup** | Just tell the bot | Webhook + secrets + workflow changes | +| **Cost** | Burns compute even when idle | Zero cost when nothing runs | +| **Metadata** | Whatever the agent can scrape | Precise: duration, failed step, commit SHA | +| **Best for** | "Keep my CI green across all repos" | "Tell me the moment this PR breaks" | + +| Scenario | Recommended | +|----------|-------------| +| "Tell me when CI breaks" | Notification mode (this doc) | +| "Check CI and fix it automatically" | Polling mode (cronjob) | +| Both — notify immediately + auto-fix | Combine: webhook notifies, cronjob retries fixes | + +--- + +## Solution + +A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its final job. It posts CI results as plain-text content (bot-readable) with user mention — routing to the correct thread based on the PR description. + +## Architecture + +``` ++-- GitHub Actions ----------------------------------------+ +| | +| +-- ci.yml ------------------------------------------+ | +| | | | +| | [check] ──► cargo fmt / clippy / test | | +| | │ | | +| | │ outputs: status, duration, commit_msg, | | +| | │ commit_author, failed_step | | +| | ▼ | | +| | [notify] (if: always()) | | +| | │ calls ──► notify-discord.yml (reusable) | | +| | │ | | +| +-----|----------------------------------------------+ | +| │ | +| │ inputs: status, commit_msg, pr_body, ... | +| │ secrets: DISCORD_WEBHOOK_URL | +| │ vars: DISCORD_THREAD_ID, DISCORD_MENTION_UID | +| │ | ++--------|─────────────────────────────────────────────────+ + │ + │ HTTP POST (webhook + ?thread_id=xxx) + ▼ ++-- Discord -----------------------------------------------+ +| | +| #channel or thread | +| ┌─────────────────────────────────────────────────┐ | +| │ ❌ CI failure — repo@main | PR #42 │ | +| │ 👤 author — commit message │ | +| │ ⏱️ 3m42s │ | +| │ 💥 Failed at: Tests │ | +| │ https://github.com/.../pull/42 │ | +| │ https://github.com/.../actions/runs/123 │ | +| │ @user-mention │ | +| └─────────────────────────────────────────────────┘ | +| | ++----------------------------------------------------------+ +``` + +## Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Reusable workflow (`workflow_call`) | Any CI workflow can call it; single source of truth | +| `if: always()` on notify job | Fires on success, failure, and cancellation | +| Plain-text content (not embed) | Bots can read `message.content`; embeds are invisible to bots | +| `printf` + `jq --rawfile` | Only reliable way to get real newlines into JSON payload | +| Thread ID from PR body | Dynamic routing — each PR notifies its own thread | +| Fallback to repo variable | Push-to-main events still get notified somewhere | +| Both PR URL and Run URL | PR for context, Run for debugging logs | + +## Setup + +### 1. Create a Discord Webhook + +Server Settings → Integrations → Webhooks → New Webhook → Copy URL. + +### 2. Configure Repository Secrets & Variables + +| Type | Name | Value | +|------|------|-------| +| **Secret** | `DISCORD_WEBHOOK_URL` | The webhook URL (contains token — keep secret) | +| **Variable** | `DISCORD_THREAD_ID` | Default thread ID for fallback notifications | +| **Variable** | `DISCORD_MENTION_USER_ID` | Discord user ID to mention (e.g. `1234567890`) | + +Set via CLI: + +```bash +gh secret set DISCORD_WEBHOOK_URL --repo / +gh variable set DISCORD_THREAD_ID --repo / --body "" +gh variable set DISCORD_MENTION_USER_ID --repo / --body "" +``` + +### 3. Create the Reusable Workflow + +`.github/workflows/notify-discord.yml`: + +```yaml +name: Discord Notify + +on: + workflow_call: + inputs: + status: + required: true + type: string + failed_step: + required: false + type: string + duration: + required: false + type: string + commit_msg: + required: false + type: string + commit_author: + required: false + type: string + pr_body: + required: false + type: string + secrets: + DISCORD_WEBHOOK_URL: + required: true + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Send Discord notification + env: + WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + DEFAULT_THREAD_ID: ${{ vars.DISCORD_THREAD_ID }} + MENTION_USER_ID: ${{ vars.DISCORD_MENTION_USER_ID }} + STATUS: ${{ inputs.status }} + FAILED_STEP: ${{ inputs.failed_step }} + DURATION: ${{ inputs.duration }} + COMMIT_MSG: ${{ inputs.commit_msg }} + COMMIT_AUTHOR: ${{ inputs.commit_author }} + PR_BODY: ${{ inputs.pr_body }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPO: ${{ github.repository }} + REF: ${{ github.ref_name }} + PR: ${{ github.event.pull_request.number }} + SERVER_URL: ${{ github.server_url }} + run: | + # Extract Thread ID from PR body, fallback to variable + THREAD_ID="" + if [ -n "$PR_BODY" ]; then + THREAD_ID=$(echo "$PR_BODY" | grep -ioP '^Thread:\s*\K[0-9]+' | head -1) + fi + [ -z "$THREAD_ID" ] && THREAD_ID="$DEFAULT_THREAD_ID" + + if [ "$STATUS" = "success" ]; then + EMOJI="✅" + else + EMOJI="❌" + fi + + # Build message into a temp file for proper newlines + { + printf '%s **CI %s** — `%s@%s`' "$EMOJI" "$STATUS" "$REPO" "$REF" + [ -n "$PR" ] && printf ' | PR #%s' "$PR" + echo "" + [ -n "$COMMIT_AUTHOR" ] && printf '👤 %s' "$COMMIT_AUTHOR" + [ -n "$COMMIT_MSG" ] && printf ' — `%s`' "$COMMIT_MSG" + [ -n "$COMMIT_AUTHOR" ] && echo "" + [ -n "$DURATION" ] && echo "⏱️ ${DURATION}" + [ "$STATUS" != "success" ] && [ -n "$FAILED_STEP" ] && echo "💥 Failed at: **${FAILED_STEP}**" + [ -n "$PR" ] && echo "${SERVER_URL}/${REPO}/pull/${PR}" + echo "$RUN_URL" + [ -n "$MENTION_USER_ID" ] && echo "<@${MENTION_USER_ID}>" + } > /tmp/msg.txt + + # Use jq --rawfile to preserve real newlines in JSON + PAYLOAD=$(jq -n --rawfile msg /tmp/msg.txt \ + '{content: $msg, allowed_mentions: {parse: ["users"]}}') + + URL="${WEBHOOK_URL}" + [ -n "$THREAD_ID" ] && URL="${URL}?thread_id=${THREAD_ID}" + + curl -sf -X POST "$URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" +``` + +### 4. Wire Into Your CI Workflow + +Add a `notify` job at the end of any workflow: + +```yaml +jobs: + check: + runs-on: ubuntu-latest + outputs: + duration: ${{ steps.duration.outputs.value }} + commit_msg: ${{ steps.meta.outputs.commit_msg }} + commit_author: ${{ steps.meta.outputs.commit_author }} + failed_step: ${{ steps.meta.outputs.failed_step }} + steps: + - name: Record start time + id: start + run: echo "ts=$(date +%s)" >> "$GITHUB_OUTPUT" + + # ... your build/test steps (give each an id) ... + + - name: Collect metadata + id: meta + if: always() + run: | + echo "commit_msg=$(git log -1 --pretty=%s)" >> "$GITHUB_OUTPUT" + echo "commit_author=$(git log -1 --pretty=%an)" >> "$GITHUB_OUTPUT" + # Detect which step failed + FAILED="" + # if [ "${{ steps.test.outcome }}" = "failure" ]; then FAILED="Tests"; fi + echo "failed_step=${FAILED}" >> "$GITHUB_OUTPUT" + + - name: Calculate duration + id: duration + if: always() + run: | + ELAPSED=$(( $(date +%s) - ${{ steps.start.outputs.ts }} )) + echo "value=$((ELAPSED/60))m$((ELAPSED%60))s" >> "$GITHUB_OUTPUT" + + notify: + needs: [check] + if: always() + uses: ./.github/workflows/notify-discord.yml + with: + status: ${{ needs.check.result }} + failed_step: ${{ needs.check.outputs.failed_step }} + duration: ${{ needs.check.outputs.duration }} + commit_msg: ${{ needs.check.outputs.commit_msg }} + commit_author: ${{ needs.check.outputs.commit_author }} + pr_body: ${{ github.event.pull_request.body }} + secrets: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} +``` + +### 5. Dynamic Thread Routing via PR Description + +Add a `Thread:` line anywhere in your PR description: + +``` +Thread: 1505664791719710810 +``` + +The workflow extracts the first match and posts to that thread. If absent, it falls back to `DISCORD_THREAD_ID` variable. + +## Gotchas + +| Issue | Solution | +|-------|----------| +| Embed content invisible to bots | Use plain-text `content` field — bots only see `message.content` | +| `\n` in `jq --arg` becomes literal `\\n` | Write to temp file, use `jq --rawfile` to preserve real newlines | +| Duplicate YAML keys silently break workflows | Validate with `actionlint` or check Actions run errors | +| Webhook URL contains a token | Always store as a **secret**, never in workflow files or docs | +| `if: always()` required on notify job | Otherwise it's skipped when upstream jobs fail | +| Mention requires numeric Discord user ID | Use `<@USER_ID>` format in `content` | +| Webhook mentions don't trigger bots | Webhook messages don't fire bot `MESSAGE_CREATE` — mention real users instead | diff --git a/docs/refarch/sidecar-proxy.md b/docs/refarch/sidecar-proxy.md new file mode 100644 index 000000000..ca85e2771 --- /dev/null +++ b/docs/refarch/sidecar-proxy.md @@ -0,0 +1,84 @@ +# Reference Architecture: OAuth Sidecar Proxy + +> **Note:** For xAI/Grok models, OpenCode ≥1.15.0 supports native xAI OAuth. +> The sidecar proxy is no longer required for OpenCode deployments. +> See [docs/xai-proxy.md](../xai-proxy.md) for the recommended approach. + +This document describes the **sidecar proxy pattern** implemented by +`openab-auth-proxy` — a generic OAuth proxy that injects Bearer tokens into +upstream API requests. + +## When to use this pattern + +- Agents **without** built-in OAuth (Hermes, custom agents) +- Centralizing token management across multiple containers in a pod +- Proxying to any OAuth-protected API (not just xAI) + +## Architecture + +``` +┌─ Kubernetes Pod ──────────────────────────────────────────────┐ +│ │ +│ agent container (any OpenAI-compatible client) │ +│ │ POST /v1/chat/completions │ +│ │ (no auth header needed) │ +│ ▼ │ +│ openab-auth-proxy :9090 │ +│ • Reads OAuth token from disk │ +│ • Injects Authorization: Bearer header │ +│ • Auto-refreshes 120s before expiry │ +│ │ │ +│ Token: ~/.openab-auth-proxy//tokens.json │ +└───────────────┼───────────────────────────────────────────────┘ + ▼ + upstream API (configured via TOML or xAI default) +``` + +## Configuration + +Without a config file, `openab-auth-proxy` defaults to xAI/SuperGrok. + +For other providers, create `auth-proxy.toml`: + +```toml +[provider] +name = "my-provider" +discovery_url = "https://auth.example.com/.well-known/openid-configuration" +client_id = "my-client-id" +scopes = "openid offline_access api:access" +upstream_base_url = "https://api.example.com" +redirect_port = 8080 +``` + +## Helm deployment (xAI example) + +```bash +# 1. Login locally +openab-auth-proxy login-device + +# 2. Create K8s secret +kubectl create secret generic auth-proxy-tokens \ + --from-file=tokens.json=$HOME/.openab-auth-proxy/xai/tokens.json + +# 3. Deploy with sidecar +helm install openab openab/openab \ + --set agents.mybot.command=opencode \ + --set-json 'agents.mybot.args=["acp"]' \ + --set agents.mybot.image=ghcr.io/openabdev/openab-opencode \ + --set-json 'agents.mybot.extraContainers=[{"name":"auth-proxy","image":"ghcr.io/openabdev/openab-auth-proxy:latest","args":["serve","--bind","0.0.0.0"],"ports":[{"containerPort":9090}],"volumeMounts":[{"name":"data","mountPath":"/home/agent"}]}]' \ + --set-json 'agents.mybot.extraInitContainers=[{"name":"copy-tokens","image":"busybox","command":["sh","-c","mkdir -p /dest/.openab-auth-proxy/xai && cp /src/tokens.json /dest/.openab-auth-proxy/xai/tokens.json"],"volumeMounts":[{"name":"tokens-src","mountPath":"/src","readOnly":true},{"name":"data","mountPath":"/dest"}]}]' \ + --set-json 'agents.mybot.extraVolumes=[{"name":"tokens-src","secret":{"secretName":"auth-proxy-tokens"}}]' +``` + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `AUTH_PROXY_TOKEN_PATH` | `~/.openab-auth-proxy//tokens.json` | Token file location | +| `XAI_PROXY_TOKEN_PATH` | (legacy alias) | Backward-compatible | +| `RUST_LOG` | `openab_auth_proxy=info` | Log verbosity | + +## See also + +- [openab-auth-proxy source](../../openab-auth-proxy/) — Rust implementation +- [docs/xai-proxy.md](../xai-proxy.md) — xAI-specific quick-start diff --git a/docs/refarch/telegram-cloudflare-tunnel.md b/docs/refarch/telegram-cloudflare-tunnel.md new file mode 100644 index 000000000..67df7a84f --- /dev/null +++ b/docs/refarch/telegram-cloudflare-tunnel.md @@ -0,0 +1,163 @@ +# Reference Architecture: Telegram via Cloudflare Tunnel + +Deploy OpenAB on K3s with the Custom Gateway receiving Telegram webhooks through a Cloudflare Tunnel — no public IP, no ingress controller, no TLS certificates required. + +## Architecture + +``` +Telegram Cloud + │ HTTPS POST + ▼ +Cloudflare Edge (your_custom.domain.com) + │ Tunnel (QUIC) + ▼ +┌─────────────────────────────────────────────────────┐ +│ Single Pod │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ cloudflared │─▶│gateway :8080 │◀─│ OAB │ │ +│ │ (sidecar) │ │ (sidecar) │ws│ (main) │ │ +│ └─────────────┘ └──────────────┘ └──────────────┘ │ +│ localhost localhost │ +└─────────────────────────────────────────────────────┘ +``` + +All three components run as containers in the **same pod**: + +- **cloudflared** — tunnel client, forwards Cloudflare traffic to `localhost:8080` +- **gateway** — receives Telegram webhooks, normalizes events, serves WebSocket on `:8080` +- **OAB** — connects to `ws://localhost:8080/ws`, runs the agent + +This keeps all communication on `localhost` — no K8s Services or cross-pod networking required. + +## Prerequisites + +| Requirement | Notes | +|-------------|-------| +| K3s cluster | Any single-node or multi-node K3s setup | +| Helm 3 | Installed on the node or a workstation with kubeconfig access | +| Cloudflare account | Free plan is sufficient | +| Telegram Bot Token | Create via [@BotFather](https://t.me/BotFather) | +| Domain on Cloudflare | DNS managed by Cloudflare | + +## Step 1: Create a Cloudflare Tunnel + +1. Go to **Zero Trust → Networks → Tunnels → Create a tunnel** +2. Name it (e.g. `openab-telegram`) +3. Copy the **tunnel token** +4. Add a **public hostname**: + - Subdomain: your choice (e.g. `bot`) + - Domain: your Cloudflare-managed domain + - Service: `http://localhost:8080` + +## Step 2: Deploy with Helm + +```bash +cd openab + +RELEASE_NAME="my-openab" + +helm upgrade --install "$RELEASE_NAME" ./charts/openab \ + --set agents.kiro.discord.enabled=false \ + --set agents.kiro.gateway.enabled=true \ + --set agents.kiro.gateway.deploy=false \ + --set agents.kiro.gateway.url="ws://localhost:8080/ws" \ + --set agents.kiro.gateway.platform=telegram \ + --set agents.kiro.extraContainers[0].name=gateway \ + --set agents.kiro.extraContainers[0].image="ghcr.io/openabdev/openab-gateway:0.4.0" \ + --set agents.kiro.extraContainers[0].env[0].name=TELEGRAM_BOT_TOKEN \ + --set-literal agents.kiro.extraContainers[0].env[0].value="" \ + --set agents.kiro.extraContainers[1].name=cloudflared \ + --set agents.kiro.extraContainers[1].image="cloudflare/cloudflared:latest" \ + --set agents.kiro.extraContainers[1].args[0]="tunnel" \ + --set agents.kiro.extraContainers[1].args[1]="--no-autoupdate" \ + --set agents.kiro.extraContainers[1].args[2]="run" \ + --set agents.kiro.extraContainers[1].args[3]="--token" \ + --set-literal agents.kiro.extraContainers[1].args[4]="" \ + --namespace openab --create-namespace +``` + +> **Key difference:** `gateway.deploy=false` skips the separate gateway Deployment/Service. Instead, gateway and cloudflared run as `extraContainers` sidecars in the OAB pod, communicating over `localhost`. + +## Step 3: Authenticate the Agent + +```bash +kubectl exec -it deployment/${RELEASE_NAME}-kiro -n openab -- kiro-cli login --use-device-flow +``` + +After login, restart the pod to pick up credentials: + +```bash +kubectl rollout restart deployment/${RELEASE_NAME}-kiro -n openab +``` + +## Step 4: Set the Telegram Webhook + +```bash +curl "https://api.telegram.org/bot/setWebhook" \ + -d "url=https://your_custom.domain.com/webhook/telegram" +``` + +Verify: + +```bash +curl "https://api.telegram.org/bot/getWebhookInfo" +``` + +## Resulting Resources + +``` +$ kubectl get pods -n openab +NAME READY STATUS AGE +my-openab-kiro-xxxxx-yyyyy 3/3 Running ... +``` + +The single pod runs 3 containers: `kiro` (OAB agent), `gateway`, and `cloudflared`. + +## Configuration + +The rendered `config.toml` for the OAB agent: + +```toml +[agent] +command = "kiro-cli" +args = ["acp", "--trust-all-tools"] +working_dir = "/home/agent" + +[pool] +max_sessions = 10 +session_ttl_hours = 24 + +[reactions] +enabled = true +remove_after_reply = false + +[gateway] +url = "ws://localhost:8080/ws" +platform = "telegram" +url = "ws://localhost:8080/ws" +platform = "telegram" +allow_all_channels = true +allowed_channels = [] +# ⚠️ Recommended: restrict to specific Telegram user IDs +allow_all_users = false +allowed_users = [""] +``` + +## Restricting Access + +To limit which Telegram users can interact with the bot: + +```bash +helm upgrade $RELEASE_NAME ./charts/openab \ + ... \ + --set agents.kiro.gateway.allowAllUsers=false \ + --set-string agents.kiro.gateway.allowedUsers[0]="" +``` + +## Why Cloudflare Tunnel? + +- **No public IP required** — the K3s node can be behind NAT or a firewall. +- **No TLS management** — Cloudflare terminates TLS at the edge. +- **No ingress controller config** — bypasses Traefik/nginx entirely. +- **Single-pod simplicity** — all components share `localhost`, no cross-pod networking needed. diff --git a/docs/slash-commands.md b/docs/slash-commands.md index 6d24a63a5..080c357a0 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -10,6 +10,8 @@ OpenAB registers Discord slash commands for session control. These work in both | `/agents` | Select the agent mode via dropdown menu | Yes | | `/cancel` | Cancel the current in-flight operation | Yes | | `/reset` | Reset the conversation session (clear history, start fresh) | Yes | +| `/remind` | Set a one-shot delayed reminder to mention users/roles | No | +| `/export-thread` | Export thread/DM as `.txt` (default: last 100 messages) | No | All responses are **ephemeral** — only the user who invoked the command sees the reply. @@ -63,6 +65,35 @@ This is equivalent to the `sessions close` + `sessions new` pattern used by [Ope - Bot identity and system prompt (re-applied on next session creation) - Config settings in `config.toml` +### `/export-thread` + +Fetches the current Discord thread or DM history and returns a `.txt` file as an ephemeral follow-up. The transcript includes message timestamps, author names and IDs, message text, and attachment URLs. + +**Optional parameters** (mutually exclusive — use at most one): + +| Parameter | Type | Description | +|-----------|------|-------------| +| `limit` | Integer | Export only the most recent N messages (1–5000) | +| `since` | String | Export messages after this message ID (right-click → Copy Message ID) | +| `days` | Integer | Export messages from the last N days (1–365). Rolling N×24h window from now. | +| `all` | Boolean | Export all messages (up to 5000) | + +If no parameter is provided, the **last 100 messages** are exported. + +**Examples:** +``` +/export-thread → last 100 messages (default) +/export-thread limit:500 → most recent 500 messages +/export-thread since:1503744866100842698 → messages after this specific message +/export-thread days:3 → messages from the last 3 days (rolling 72h) +/export-thread all:true → export all (cap 5000) +``` + +**Constraints:** +- Only works in allowed Discord threads or enabled DMs. +- Specifying more than one filter returns an error. +- Very large exports may be truncated to fit Discord's attachment size limit. + ## Passing CLI Commands via @mention In addition to slash commands, you can pass built-in CLI commands directly after an @mention: @@ -74,3 +105,45 @@ In addition to slash commands, you can pass built-in CLI commands directly after ``` These are forwarded as-is to the ACP session as a prompt. Any command the underlying CLI supports in its interactive mode works here. This is the recommended workaround for agents that don't expose `configOptions`. + +## `/remind` + +Set a one-shot delayed reminder that mentions users or roles in the channel after a specified delay. + +**Syntax:** +``` +/remind targets:<@user @role ...> message: delay: +``` + +**Parameters:** + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `targets` | Yes | Space-separated @mentions (users and/or roles) | +| `message` | Yes | Reminder text | +| `delay` | Yes | Duration before firing: `1m` to `30d` (supports `m`, `h`, `d` and combinations like `1h30m`) | + +**Constraints:** +- Only humans can use `/remind` (bots are rejected) +- Minimum delay: 1 minute +- Maximum delay: 30 days +- Maximum message length: 1800 characters +- Maximum 5 active reminders per user +- Maximum 10 mention targets per reminder (use a @role for larger groups) +- `@everyone` and `@here` in messages are automatically neutralized (will not trigger mass mentions) +- One-shot only (fires once, then removed) +- Reminders persist across bot restarts (stored in `$HOME/.openab/reminders.json`) + +**Examples:** +``` +/remind targets:@Alice @Bob message:Review PR #42 delay:2h +/remind targets:@Reviewers message:Stand-up time delay:30m +/remind targets:@Charlie message:Check deployment delay:1d +``` + +**When fired, the bot posts:** +``` +⏰ Reminder from @sender: +"Review PR #42" +cc @Alice @Bob +``` diff --git a/docs/steering-design-guide.md b/docs/steering-design-guide.md new file mode 100644 index 000000000..aed5b13ed --- /dev/null +++ b/docs/steering-design-guide.md @@ -0,0 +1,252 @@ +# Steering Design Guide + +## Problem + +AI coding agents load persistent instructions every session, but without deliberate organization: +- **Bloated instructions** dilute attention — critical rules get buried in noise +- **Duplicated rules** across files inevitably contradict each other when one is updated +- **Missing triggers** mean mandatory behaviors live in docs the agent never reads +- **No shared standard** across agents leads to each team reinventing the wheel + +This guide establishes a universal framework for organizing agent memory into layers, so rules are reliably followed, context budgets are respected, and teams can onboard new agents without starting from scratch. + +OpenAB is designed to be agent-agnostic — it supports Kiro, Claude Code, Codex, Gemini, Copilot, OpenCode, and Pi running side by side. This guide provides a shared memory architecture standard that allows all supported coding agents to maintain consistent behavior, collaborate effectively, and operate from a single source of truth regardless of their underlying platform differences. + +--- + +How to organize AI agent memory across three tiers: hot (always loaded), warm (triggered on demand), and cold (searched when needed). + +Applies to: Kiro, Claude Code, Codex, Gemini, Copilot, OpenCode, Pi — any agent that supports persistent instruction files. + +--- + +## Terminology + +| Term | Meaning | Examples | +|------|---------|---------| +| 🔥 **Hot memory** | Loaded every session, always in context | `AGENTS.md`, `.kiro/steering/`, `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md` | +| ☕ **Warm context** | Not always loaded, but auto-triggered when conditions match | Codex Skills (body), Copilot path-specific instructions, CC/Gemini individual memory files (pointed to by hot index), subdir instruction files | +| ❄️ **Cold storage** | Searched or retrieved on demand, no automatic trigger | Knowledge bases, `docs/`, project wikis, ADRs, historical records | + +--- + +## What Goes in Hot Memory + +| Criteria | Example | +|----------|---------| +| Every interaction might trigger it | Output format spec, verdict logic | +| Identity & relationships | Agent name, team members, contact IDs | +| SOP trigger words | "review PRs" → auto-execute workflow | +| Hard rules that are easy to get wrong | "NITs are blocking", "never merge", "English only on GitHub" | +| Tool usage patterns | Login flows, API call patterns | +| Constraints that override defaults | "Don't ask for confirmation on X", "Always do Y before Z" | + +## What Stays in Cold Storage + +| Criteria | Example | +|----------|---------| +| Historical records / case studies | Past incident lessons, collaboration logs | +| One-time reference | Installation steps, migration guides | +| Large data | User profiles, conversation history | +| Design proposals / RFCs | Architecture decisions, feature specs | +| Lookup tables | Feature flags, config reference, changelogs | + +## What Goes in Warm Context + +| Criteria | Example | +|----------|---------| +| Too large for hot, but has a reliable trigger | Deployment SOP, release checklist | +| Only relevant for specific file types or paths | Gateway adapter checklist, Helm wiring rules | +| Domain-specific expert knowledge | Platform auth spec details, crypto implementation patterns | +| Complex workflows with steps and scripts | Incident triage playbook, skill bodies | + +**Rule of thumb:** If it has a clear trigger condition and is > 1KB, make it warm. Keep only the trigger (name + one-line description + path) in hot. + +--- + +## Design Principles + +1. **Small and precise** — Keep hot memory concise. Practical caps vary by agent (CC: ~200 lines for MEMORY.md, Codex: 32KB, Kiro: ~15KB recommended). Regardless of hard limits, attention dilution is the real constraint — less is more. +2. **Behavior-oriented** — Every line should change "what the agent does next." Remove anything that's just "nice to know." +3. **Single source of truth** — Define each rule in exactly one place. Duplication across files creates contradiction risk. +4. **Testable** — Each rule should be verifiable with a single prompt from a fresh session. +5. **One file per responsibility** — Separate concerns: identity, review process, workflow triggers. Avoid monolithic instruction files. +6. **Hot/cold separation** — If the agent can find it via search when needed, it doesn't need to be always-loaded. +7. **Structure over prose** — Use lists, tables, or key-value pairs in hot memory. LLMs follow structured constraints more reliably than natural language paragraphs. +8. **WHAT and HOW only** — Hot memory defines what to do and how. Put the WHY (historical context, incident backstory) in cold storage (ADRs, lessons learned). + +--- + +## Decision Flowchart + +``` +"If this rule is NOT loaded, will the next response be wrong?" +│ +├─ Yes → 🔥 Hot memory +│ +├─ Only when doing a specific task/touching specific files +│ → ☕ Warm context (put trigger in hot, body in warm) +│ +└─ No, it's reference → ❄️ Cold storage +``` + +--- + +## Architecture Pattern + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🔥 HOT — Always Loaded │ +│ │ +│ Identity, hard rules, collaboration protocol, trigger index │ +│ AGENTS.md / CLAUDE.md / GEMINI.md / .kiro/steering/* │ +│ .github/copilot-instructions.md / MEMORY.md index │ +│ < 15KB │ +├─────────────────────────────────────────────────────────────────┤ +│ ☕ WARM — Progressive Exposure │ +│ │ +│ Auto-loaded when trigger condition matches │ +│ Skills (SKILL.md body), path-specific instructions, │ +│ subdir instruction files, individual memory files, │ +│ domain SOPs, deployment playbooks │ +│ │ +│ Triggers: Rule-based (applyTo glob) │ +│ Semantic (agent reads index, decides to load) │ +│ Explicit (activate_skill / read_file) │ +├─────────────────────────────────────────────────────────────────┤ +│ ❄️ COLD — Search on Demand │ +│ │ +│ No automatic trigger; requires explicit search/retrieval │ +│ Knowledge bases, docs/*.md, wikis, ADRs, RFCs, │ +│ historical records, lessons learned, team trivia │ +│ Unlimited │ +└─────────────────────────────────────────────────────────────────┘ + + ▲ Smaller, precise, behavioral + │ + ▼ Larger, reference, historical +``` + +**Key insight:** The warm layer's *trigger metadata* lives in hot memory (skill names, index entries, applyTo globs). Only the *body* loads on demand. Pattern: **put the table of contents in hot, put the chapters in warm.** + +> **Trigger mechanisms vary by agent:** +> - **Rule-based:** Copilot `applyTo` glob match, Codex skill metadata match +> - **Semantic:** CC/Gemini memory index — agent reads description and decides to load +> - **Explicit:** Agent calls `activate_skill` or `read_file` when task matches + +> **Real-world example:** Claude Code's auto-memory system is a natural implementation of hot/warm separation — `MEMORY.md` index (hot, 200-line cap) points to individual `.md` memory files (warm, loaded when agent determines relevance from index description). + +> **Common pattern:** CC, Codex, and Gemini all use hierarchical loading (global → project → subdir). This naturally supports "one file per responsibility" by placing topic-specific rules in the relevant subdirectory's instruction file. + +--- + +## Agent-Specific File Mapping + +> **Note:** Most agents are hybrid — they combine multiple loading models. The table below shows the primary mechanisms. + +### Loading Models + +| Model | Trigger | Examples | +|-------|---------|---------| +| **Always loaded** | Every session/interaction in repo context | Kiro `.kiro/steering/*`, CC/Codex/Gemini root instruction file, Copilot `.github/copilot-instructions.md` | +| **Directory-scoped** | Processing files within that directory tree | CC/Codex/Gemini subdir instruction files, Copilot `AGENTS.md` (nearest-in-tree) | +| **File-scoped** | Matching an `applyTo` glob pattern | Copilot `.github/instructions/**/*.instructions.md` | + +**Implication for hot memory design:** +- "Always loaded" = put task-agnostic rules here (identity, verdict logic, workflow triggers) +- "Directory-scoped" = put domain-specific rules here (gateway checklist, docs standards) +- "File-scoped" = put file-type-specific review expectations here (only Copilot supports this natively) + +| Agent | Hot Memory Location | Notes | +|-------|-------------------|-------| +| Kiro | `AGENTS.md` + `.kiro/steering/*.md` | Multiple files, one per topic | +| Claude Code | `CLAUDE.md` (project) + `~/.claude/CLAUDE.md` (global) + `MEMORY.md` index | Hierarchical loading (global → project → subdir). Auto-memory index is hot (200-line cap); individual memory files are cold. `settings.json` is config, not instructions | +| Codex | `AGENTS.md` hierarchical (global → project root → subdir) | Each directory loads at most one file. 32KB cap (`project_doc_max_bytes`). Use nested `AGENTS.md` for per-directory responsibility split. No multi-file topic split within same dir | +| Gemini | `GEMINI.md` hierarchical (`~/.gemini/GEMINI.md` global → `./GEMINI.md` project → subdir) + `MEMORY.md` index | Same hierarchical pattern as CC/Codex. Private project memory index is hot; individual memory files are cold | +| Copilot | `.github/copilot-instructions.md` (repo-wide) + `.github/instructions/**/*.instructions.md` (path-specific) + `AGENTS.md` (nearest-in-tree, where supported: cloud agent / CLI) | Layered: Personal > Path-specific > Repo-wide > Agent > Organization. No documented hard size cap for Chat/Agent (code review reads first 4K chars only). Keep short (~2 pages recommended) | +| OpenCode | `AGENTS.md` or equivalent | Follows repo convention | +| Pi | `AGENTS.md` hierarchical (project root → global) + `SYSTEM.md` or `APPEND_SYSTEM.md` in `.pi/` | Project or global `SYSTEM.md` replaces the default system prompt, while `APPEND_SYSTEM.md` appends to it. `AGENTS.md` is loaded hierarchically for context injection | + +--- + +## Validation Checklist + +After adding or changing hot memory: + +1. **Start a fresh session** (no prior context) +2. **Ask a question that triggers the rule** — e.g., "what format should a review comment use?" +3. **Verify the response follows the rule exactly** +4. **Test edge cases** — e.g., "what if there's only one 🟡 finding?" +5. **Check for contradictions** — does the new rule conflict with anything else in hot memory? + +If the agent doesn't follow the rule → it's either not loaded, too buried in other content, or ambiguously worded. + +--- + +## Anti-Patterns + +| Anti-Pattern | Why It's Bad | Fix | +|--------------|-------------|-----| +| Dumping everything into one file | Critical rules get lost in noise | Split by responsibility | +| Duplicating rules across files | Inevitable contradictions when one is updated | Single source + pointer | +| Putting case studies in hot memory | Wastes context budget on history | Move to docs, reference by lesson only | +| Vague rules ("be helpful") | Untestable, no behavioral change | Make specific and testable | +| Hot memory > 20KB | Diminishing returns, attention dilution | Audit and move cold items out | +| Task-scoped rules in file/directory-scoped locations | Review SOP, response format, collaboration protocol only load when certain files are touched — missing when needed most | Put task-agnostic workflow rules in always-loaded layer, not path-specific | +| Stale links in hot memory | Index points to missing files; fresh session gets dead references | Audit links quarterly; remove or create the target | +| Mandatory behavior hidden in cold without trigger | Agent must follow it but has no path to discover it | Add trigger metadata to hot, or promote to warm with clear activation condition | + +--- + +## Maintenance + +- **Quarterly audit**: Review hot memory files. Remove rules that are no longer relevant or have become default behavior. +- **After contradictions**: When agent behavior contradicts a rule, check if it's a loading issue or a conflict with another rule. +- **After new capabilities**: When adding new workflows, decide hot vs cold before writing the doc. +- **Adding a new agent**: Document its loading model and precedence before adding file mappings. Don't assume it works like existing agents. + +--- + +## Self-Reflection Prompt + +Use this prompt from a fresh session to audit memory allocation against this guide: + +``` +Per the steering design guide in docs/steering-design-guide.md from OpenAB GitHub repo, review your current memory allocation: + +1. INVENTORY — List all loaded/discoverable instruction sources: + - File path + - Layer (Hot / Warm / Cold) + - Trigger model (always / directory / file-glob / semantic / explicit) + - Approximate size (lines or KB) + +2. CLASSIFY — For each item, what type of content is it? + - WHAT/HOW (behavior rule) vs WHY (history/rationale) + - Identity / hard rule / workflow / reference / trivia + +3. VIOLATIONS — Identify items that break the guide's principles: + - Not behavior-oriented (nice-to-know in hot) + - Duplicated or conflicting across files + - Stale links pointing to missing files + - Too large for its layer + - WHY/history in hot instead of cold + - Mandatory behavior in cold with no trigger path + +4. TRIGGER QUALITY — Review warm layer triggers: + - Are index descriptions precise enough to fire correctly? + - Where is the canonical source for each rule? + - Will the agent reliably see it when needed? + +5. OPTIMIZATION PLAN — Propose concrete moves: + - Remove (stale, duplicate, irrelevant) + - Keep in hot (behavioral, high-frequency) + - Promote cold → warm (add trigger) + - Demote hot → warm or cold (too large, low-frequency) + - Split (one file doing too many jobs) + - Add missing trigger/index entry + +6. VERIFY — Name one fresh-session test prompt that would confirm + the highest-risk rule still loads correctly. +``` + +Expected output: a before/after table with file paths, layer assignments, sizes, and rationale for each move. diff --git a/docs/stt.md b/docs/stt.md index 5ee8fa488..5e76ff545 100644 --- a/docs/stt.md +++ b/docs/stt.md @@ -1,6 +1,6 @@ # Speech-to-Text (STT) for Voice Messages -openab can automatically transcribe Discord voice message attachments and forward the transcript to your ACP agent as text. +openab can automatically transcribe voice message attachments (Discord, Feishu, and other gateway platforms) and forward the transcript to your ACP agent as text. ## Quick Start @@ -24,7 +24,7 @@ api_key = "${GROQ_API_KEY}" ## How It Works ``` -Discord voice message (.ogg) +Voice message (Discord .ogg, Feishu opus/ogg, etc.) │ ▼ openab downloads the audio file @@ -50,6 +50,7 @@ enabled = true # default: false api_key = "${GROQ_API_KEY}" # required for cloud providers model = "whisper-large-v3-turbo" # default base_url = "https://api.groq.com/openai/v1" # default +echo_transcript = true # default: false (opt-in) ``` | Field | Required | Default | Description | @@ -58,6 +59,7 @@ base_url = "https://api.groq.com/openai/v1" # default | `api_key` | no* | — | API key for the STT provider. *Auto-detected from `GROQ_API_KEY` env var if not set. For local servers, use any non-empty string (e.g. `"not-needed"`). | | `model` | no | `whisper-large-v3-turbo` | Whisper model name. Varies by provider. | | `base_url` | no | `https://api.groq.com/openai/v1` | OpenAI-compatible API base URL. | +| `echo_transcript` | no | `false` | When set to `true` and STT runs, post a `> 🎤 ` message to the thread before the agent reply so users can verify what was heard. Failures show `(transcription failed)` and add a ⚠️ reaction to the original message. | ## Deployment Options @@ -147,6 +149,13 @@ helm upgrade openab openab/openab \ --set agents.kiro.stt.baseUrl=https://api.groq.com/openai/v1 ``` +```bash +helm upgrade openab openab/openab \ + --set agents.kiro.stt.enabled=true \ + --set agents.kiro.stt.apiKey=gsk_xxx \ + --set agents.kiro.stt.echoTranscript=true # opt in to transcript echo +``` + ## Disabling STT Omit the `[stt]` section entirely, or set: @@ -161,6 +170,6 @@ When disabled, audio attachments are silently skipped with no impact on existing ## Technical Notes - openab sends `response_format=json` in the transcription request to ensure the response is always parseable JSON. Some local whisper servers default to plain text output without this parameter. -- The actual MIME type from the Discord attachment is passed through to the STT API (e.g. `audio/ogg`, `audio/mp4`, `audio/wav`). +- The actual MIME type from the platform attachment is passed through to the STT API (e.g. `audio/ogg` for Discord and Feishu voice messages, `audio/mp4`, `audio/wav`). - Environment variables in config values are expanded via `${VAR}` syntax (e.g. `api_key = "${GROQ_API_KEY}"`). - The `api_key` field is auto-detected from the `GROQ_API_KEY` environment variable when using the default Groq endpoint. If you set a custom `base_url` (e.g. local server), auto-detect is disabled to avoid leaking the Groq key to unrelated endpoints — you must set `api_key` explicitly. diff --git a/docs/telegram.md b/docs/telegram.md index d7dd9ae09..b1f576750 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -168,6 +168,19 @@ explain VPC peering ← ignored in groups DMs and replies within forum topics always trigger the agent (no @mention needed). +### File Attachments (Inbound) + +The gateway downloads media from Telegram and stores it locally (`~/.openab/media/inbound/`). Core reads directly from disk — no base64 encoding overhead. + +| Type | Handling | +|------|----------| +| **Images** | Downloaded, resized (max 1200px), JPEG compressed, stored to filesystem. Agent sees the image. | +| **Documents** | Text-based files (`.txt`, `.csv`, `.rs`, `.py`, etc.) up to 20MB read as UTF-8 and passed to agent. Binary files silently skipped. | +| **Audio/Voice** | Downloaded and stored. If STT is enabled in Core, automatically transcribed and passed as text. | + +**Not supported (inbound):** video, stickers, animations (silently skipped). +**Not supported (outbound):** bot cannot send images/files back to the user yet. + ### Emoji reactions The bot shows status reactions on your message as the agent works: diff --git a/docs/wecom.md b/docs/wecom.md new file mode 100644 index 000000000..ad8efec8f --- /dev/null +++ b/docs/wecom.md @@ -0,0 +1,187 @@ +# WeCom (企业微信) Setup + +Connect a WeCom (Enterprise WeChat) bot to OpenAB via the Custom Gateway. + +``` +WeCom ──POST──▶ Gateway (:8080) ◀──WebSocket── OAB Pod + (OAB connects out) +``` + +## Prerequisites + +- A running OAB instance (with any ACP agent authenticated) +- The Custom Gateway deployed ([gateway/README.md](../gateway/README.md)) +- A WeCom enterprise account with admin access + +## 1. Create a WeCom App + +1. Log in to [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame) +2. Go to **应用管理** (App Management) → **自建** (Self-built) → **创建应用** (Create App) +3. Fill in the app name and description, select visible scope +4. After creation, note down: + - **AgentId** — on the app detail page + - **Secret** — click to view/copy on the app detail page +5. Go to **我的企业** (My Enterprise) → copy the **企业ID** (Corp ID) + +## 2. Configure the Callback URL + +1. In the app detail page, scroll to **接收消息** (Receive Messages) +2. Click **设置API接收** (Set API Receive) +3. Fill in: + - **URL**: `https://your-gateway-host/webhook/wecom` (must be HTTPS) + - **Token**: click "随机获取" (Random Generate) or set your own + - **EncodingAESKey**: click "随机获取" (Random Generate) or set your own +4. **Do NOT click Save yet** — you need the gateway running first to verify the URL + +## 3. Configure the Gateway + +Set the following environment variables: + +| Variable | Required | Description | +|---|---|---| +| `WECOM_CORP_ID` | Yes | Enterprise Corp ID (from My Enterprise page) | +| `WECOM_AGENT_ID` | Yes | App Agent ID | +| `WECOM_SECRET` | Yes | App Secret | +| `WECOM_TOKEN` | Yes | Callback Token (from step 2) | +| `WECOM_ENCODING_AES_KEY` | Yes | Callback EncodingAESKey (43 characters) | +| `WECOM_WEBHOOK_PATH` | No | Webhook path (default: `/webhook/wecom`) | +| `WECOM_STREAMING_ENABLED` | No | Stream replies via "thinking" placeholder + recall + resend (default: `false`). WeCom has no edit-message API; enabling this causes a brief client flicker during streaming. | +| `WECOM_DEBOUNCE_SECS` | No | Quiet-period seconds before flushing buffered streamed text (default: `3`, minimum: `1` — `0` is silently ignored by Helm's truthy check and disables the buffer purpose) | + +```bash +docker run -d --name openab-gateway \ + -e WECOM_CORP_ID="ww1234567890abcdef" \ + -e WECOM_AGENT_ID="1000002" \ + -e WECOM_SECRET="your-app-secret" \ + -e WECOM_TOKEN="your-callback-token" \ + -e WECOM_ENCODING_AES_KEY="your-43-char-encoding-aes-key" \ + -p 8080:8080 \ + ghcr.io/openabdev/openab-gateway:latest +``` + +For Kubernetes with Helm, see [`charts/openab/values.yaml`](../charts/openab/values.yaml) — set values under `agents..gateway.wecom`. + +## 4. Verify the Callback URL + +Once the gateway is running with the correct env vars: + +1. Go back to the WeCom Admin Console → App → 接收消息 → 设置API接收 +2. Click **保存** (Save) +3. WeCom will send a verification request to your URL — if the gateway decrypts and responds correctly, you'll see "保存成功" (Save Successful) + +If verification fails: +- Check that the gateway is reachable over HTTPS +- Verify `WECOM_TOKEN` and `WECOM_ENCODING_AES_KEY` match exactly what's shown in the WeCom console +- Check gateway logs for errors + +## 5. Configure OAB + +```toml +[gateway] +url = "ws://openab-gateway:8080/ws" +platform = "wecom" +allow_all_channels = true +allow_all_users = true + +[agent] +command = "claude-agent-acp" +args = [] +working_dir = "/home/node" +env = { CLAUDE_CODE_OAUTH_TOKEN = "${OPENAB_AUTH_TOKEN}" } + +[pool] +max_sessions = 10 +``` + +| Key | Required | Description | +|---|---|---| +| `url` | Yes | WebSocket URL of the gateway | +| `platform` | No | Session key namespace (default: `wecom`) | +| `allow_all_channels` | No | Allow messages from all channels (default: `false`) | +| `allow_all_users` | No | Allow messages from all users (default: `false`) | + +## 6. Expose the Gateway (HTTPS) + +WeCom requires a publicly accessible HTTPS URL for callbacks. + +### Option A: Zeabur (one-click HTTPS for quick testing) + +Deploy the gateway to [Zeabur](https://zeabur.com) — HTTPS is automatically provisioned. + +### Option B: Cloudflare Tunnel + +```bash +cloudflared tunnel --url http://localhost:8080 +``` + +### Option C: Reverse proxy (production) + +Use nginx, Caddy, or a cloud load balancer with TLS termination pointing to the gateway's `:8080`. + +## 7. Set Trusted IP (Optional) + +For production, restrict the callback to WeCom's IP ranges: + +1. In the WeCom Admin Console → App → **企业可信IP** (Trusted IP) +2. Add your gateway's public IP + +## Usage + +Send a direct message to the bot in the WeCom mobile or desktop app: + +``` +你好,帮我解释一下这段代码 +``` + +The bot will reply directly in the same conversation. + +> **Note on group chats:** WeCom self-built enterprise apps only deliver **1:1 direct messages** to the callback URL. Group chat messages are not forwarded by this API path; group chat support would require the `appchat` API (not yet implemented). For group chat use cases, see the WeCom AI Bot WebSocket API as a future adapter. + +## Features + +| Feature | Status | +|---|---| +| Direct message (1:1) | ✅ | +| Text message receive/reply | ✅ | +| AES-256-CBC message decryption | ✅ | +| Message deduplication | ✅ | +| Auto-split long replies (2048 bytes) | ✅ | +| Access token auto-refresh | ✅ | +| Image receive | ✅ | +| Text file receive | ✅ | +| Streaming replies (thinking placeholder + debounce flush) | ✅ | +| Group chat | ❌ Not supported (callback API limitation) | +| Voice/video messages | Planned | +| Markdown card replies | Planned | + +## Production Hardening + +The gateway does no application-level rate limiting on `/webhook/wecom`. Each request triggers an XML envelope parse, a SHA1 signature computation, and (if signature passes) AES-256-CBC decryption. A 5-minute timestamp freshness check rejects stale callbacks before any crypto runs, so old replays are cheap to drop, but fresh-but-invalid requests still consume CPU. + +Run the gateway behind a reverse proxy or load balancer that enforces rate limits at the IP / connection level: + +| Layer | Example | +|---|---| +| Edge / CDN | Cloudflare WAF rate limiting rules on `/webhook/wecom` | +| Cloud LB | AWS ALB rate-based rules, GCP Cloud Armor | +| Reverse proxy | nginx `limit_req_zone`, Caddy `rate_limit` directive | + +In addition, restrict the callback URL to WeCom's published IP ranges via the **企业可信IP** (Trusted IP) list in the WeCom Admin Console. This is the most effective control because all legitimate callbacks originate from those ranges. + +### Redact `corpsecret` from access logs + +WeCom's `gettoken` API mandates `corpsecret` as a query parameter (the protocol does not support a header alternative). The gateway itself does not log this URL, but if the gateway sits behind a reverse proxy with default access logging enabled, the secret will appear in access logs. Configure the proxy to redact query strings on `/cgi-bin/gettoken` outbound calls (or sanitize at log-shipping time). + +### Known limitations + +- **Streaming task lifetime on shutdown** — the optional streaming mode (`WECOM_STREAMING_ENABLED=true`) spawns one debounce task per in-flight reply. On SIGTERM these tasks are dropped by the tokio runtime; any text buffered but not yet flushed is lost. The agent will typically re-emit on the next interaction. If you need flush-on-shutdown semantics, keep streaming off (default) so each reply is sent synchronously. +- **DedupeCache eviction is lazy** — entries are TTL-checked on lookup and bulk-evicted only when the cache reaches `DEDUPE_MAX_SIZE` (10K). For low-traffic deployments the HashMap can sit just below the cap with stale entries; max memory is bounded (~500 KB) and the dedup window itself is honored, so this does not affect correctness. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Callback verification fails | Token/EncodingAESKey mismatch | Double-check values match WeCom console exactly | +| Bot receives but doesn't reply | Agent auth token not configured | Set `env = { CLAUDE_CODE_OAUTH_TOKEN = "${OPENAB_AUTH_TOKEN}" }` in OAB config | +| Intermittent "no response" | WeCom disabled callback after errors | Re-save callback config in WeCom console to re-verify | +| "IP not in whitelist" on reply | Trusted IP not set | Add gateway IP to app's trusted IP list, or leave it empty for dev | diff --git a/docs/xai-proxy.md b/docs/xai-proxy.md new file mode 100644 index 000000000..3a2302c54 --- /dev/null +++ b/docs/xai-proxy.md @@ -0,0 +1,43 @@ +# xAI / SuperGrok Integration + +## Recommended: Native xAI OAuth (OpenCode ≥1.15.0) + +OpenCode now has **built-in xAI OAuth support** — no sidecar proxy needed. + +1. Run `/connect` inside OpenCode → select **xAI Grok OAuth (Headless / Remote / VPS)** +2. Approve the device-code on any browser +3. Select your model with `/models` (e.g. `grok-4.3`) + +OpenCode handles token storage and auto-refresh internally. + +--- + +## Alternative: openab-auth-proxy sidecar + +For agents **without** native xAI OAuth (Hermes, custom agents), use +`openab-auth-proxy` — a generic OAuth sidecar that defaults to xAI. + +```bash +# Login (one-time) +openab-auth-proxy login-device + +# Start proxy +openab-auth-proxy serve --port 9090 + +# Point any OpenAI-compatible client at the proxy +export OPENAI_BASE_URL=http://127.0.0.1:9090/v1 +export OPENAI_API_KEY=dummy +``` + +See [docs/refarch/sidecar-proxy.md](refarch/sidecar-proxy.md) for the full +architecture, Helm deployment, and custom provider configuration. + +## Comparison + +| | Native OAuth | openab-auth-proxy sidecar | +|---|---|---| +| **Requires** | OpenCode ≥1.15.0 | Any OpenAI-compatible agent | +| **Extra container** | No | Yes | +| **Token management** | Built into OpenCode | Proxy handles refresh | +| **Multi-agent sharing** | Each agent needs own auth | Single proxy serves all | +| **Custom providers** | xAI only | Any OIDC provider via TOML config | diff --git a/gateway/Cargo.lock b/gateway/Cargo.lock index b0fa728bb..9a93e1098 100644 --- a/gateway/Cargo.lock +++ b/gateway/Cargo.lock @@ -1112,7 +1112,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openab-gateway" -version = "0.1.0" +version = "0.5.1" dependencies = [ "aes", "anyhow", @@ -1125,15 +1125,18 @@ dependencies = [ "image", "jsonwebtoken", "prost", + "quick-xml", "reqwest", "serde", "serde_json", + "sha1", "sha2", "subtle", "tokio", "tokio-tungstenite 0.21.0", "tracing", "tracing-subscriber", + "urlencoding", "uuid", "wiremock", ] @@ -1274,6 +1277,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2167,6 +2179,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 76746e0bc..60d13a6f7 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openab-gateway" -version = "0.4.0" +version = "0.5.1" edition = "2021" [dependencies] @@ -24,7 +24,10 @@ aes = "0.8" cbc = "0.1" prost = "0.13" subtle = "2" +sha1 = "0.10" +quick-xml = "0.37" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +urlencoding = "2" [dev-dependencies] wiremock = "0.6" diff --git a/gateway/README.md b/gateway/README.md index aa36cbf6a..79c492a28 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -67,6 +67,14 @@ url = "ws://gateway:8080/ws" | `GOOGLE_CHAT_SA_KEY_FILE` | (optional) | Path to service account key JSON file (alternative to `SA_KEY_JSON`) | | `GOOGLE_CHAT_ACCESS_TOKEN` | (optional) | Static OAuth2 access token (fallback, expires in 1 hour) | | `GOOGLE_CHAT_WEBHOOK_PATH` | `/webhook/googlechat` | Webhook endpoint path | +| `WECOM_CORP_ID` | (required*) | WeCom Corp ID — enables wecom adapter | +| `WECOM_AGENT_ID` | (required*) | WeCom App Agent ID | +| `WECOM_SECRET` | (required*) | WeCom App Secret | +| `WECOM_TOKEN` | (required*) | Callback verification Token | +| `WECOM_ENCODING_AES_KEY` | (required*) | Callback EncodingAESKey (43 chars) | +| `WECOM_WEBHOOK_PATH` | `/webhook/wecom` | Webhook endpoint path | +| `WECOM_STREAMING_ENABLED` | `false` | Enable thinking-placeholder + recall streaming (causes brief client flicker) | +| `WECOM_DEBOUNCE_SECS` | `3` | Debounce quiet-period seconds before flushing buffered streamed text | ### Endpoints @@ -76,6 +84,8 @@ url = "ws://gateway:8080/ws" | `POST /webhook/line` | LINE webhook receiver | | `POST /webhook/feishu` | Feishu webhook receiver (when `FEISHU_CONNECTION_MODE=webhook`) | | `POST /webhook/googlechat` | Google Chat webhook receiver | +| `GET /webhook/wecom` | WeCom callback URL verification | +| `POST /webhook/wecom` | WeCom message callback receiver | | `GET /ws` | WebSocket server (OAB connects here) | | `GET /health` | Health check | @@ -117,6 +127,10 @@ See [docs/feishu.md](../docs/feishu.md) for the full setup guide. See [docs/google-chat.md](../docs/google-chat.md) for the full setup guide. +### WeCom (企业微信) + +See [docs/wecom.md](../docs/wecom.md) for the full setup guide. + ### Other Platforms GitHub webhooks, CI/CD events, monitoring alerts — any HTTP event source can be added as a gateway adapter. See the ADR for the adapter interface. diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs index 922fae342..75cd45dd5 100644 --- a/gateway/src/adapters/feishu.rs +++ b/gateway/src/adapters/feishu.rs @@ -68,6 +68,20 @@ pub enum AllowBots { All, } +/// Controls when the bot responds without @mention in threads. +/// Mirrors Discord's `allow_user_messages` setting. +#[derive(Debug, Clone, PartialEq, Default)] +pub enum AllowUsers { + /// Bot responds in threads it has participated in without @mention. + #[default] + Involved, + /// Always require @mention, even in participated threads. + Mentions, + /// Like Involved, but if another bot has also posted in the thread, + /// require @mention to avoid all bots responding. + MultibotMentions, +} + #[derive(Debug, Clone)] pub struct FeishuConfig { pub app_id: String, @@ -81,10 +95,20 @@ pub struct FeishuConfig { pub allowed_users: Vec, pub require_mention: bool, pub allow_bots: AllowBots, + pub allow_user_messages: AllowUsers, pub trusted_bot_ids: Vec, pub max_bot_turns: u32, pub dedupe_ttl_secs: u64, pub message_limit: usize, + /// TTL for participated-thread cache entries (seconds). Threads older than + /// this are forgotten and require a fresh @mention to re-engage. + /// Set to 0 (via FEISHU_SESSION_TTL_HOURS=0) to disable participation + /// tracking entirely — all messages will require @mention. + /// Converted from `FEISHU_SESSION_TTL_HOURS` (user-facing, in hours) to seconds internally. + pub session_ttl_secs: u64, + /// Override the API base URL. Used in tests to point at a mock server. + /// Always None in production (not read from env). + pub api_base_override: Option, } impl FeishuConfig { @@ -125,6 +149,16 @@ impl FeishuConfig { _ => AllowBots::Off, }; let trusted_bot_ids = parse_csv("FEISHU_TRUSTED_BOT_IDS"); + let allow_user_messages = match std::env::var("FEISHU_ALLOW_USER_MESSAGES") + .unwrap_or_else(|_| "involved".into()) + .to_lowercase() + .replace('-', "_") + .as_str() + { + "mentions" => AllowUsers::Mentions, + "multibot_mentions" => AllowUsers::MultibotMentions, + _ => AllowUsers::Involved, + }; let max_bot_turns = std::env::var("FEISHU_MAX_BOT_TURNS") .ok() .and_then(|v| v.parse().ok()) @@ -137,6 +171,11 @@ impl FeishuConfig { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(4000); + let session_ttl_secs = std::env::var("FEISHU_SESSION_TTL_HOURS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(24) + * 3600; Some(Self { app_id, @@ -150,15 +189,21 @@ impl FeishuConfig { allowed_users, require_mention, allow_bots, + allow_user_messages, trusted_bot_ids, max_bot_turns, dedupe_ttl_secs, message_limit, + session_ttl_secs, + api_base_override: None, }) } /// API base URL for the configured domain. pub fn api_base(&self) -> String { + if let Some(ref base) = self.api_base_override { + return base.clone(); + } if self.domain == "lark" { "https://open.larksuite.com".into() } else { @@ -188,6 +233,8 @@ mod event_types { pub header: Option, pub event: Option, pub challenge: Option, + // Parsed by serde, not consumed in current code paths. + #[allow(dead_code)] #[serde(rename = "type")] pub event_type_field: Option, } @@ -195,6 +242,8 @@ mod event_types { #[derive(Debug, Deserialize)] pub struct FeishuEventHeader { pub event_id: Option, + // Parsed by serde, not consumed in current code paths. + #[allow(dead_code)] pub event_type: Option, } @@ -231,6 +280,8 @@ mod event_types { pub struct FeishuMention { pub key: Option, pub id: Option, + // Parsed by serde, not consumed in current code paths. + #[allow(dead_code)] pub name: Option, } @@ -242,10 +293,16 @@ mod event_types { /// Parse a feishu im.message.receive_v1 event into a GatewayEvent. /// Returns None if the event should be skipped (unsupported type, bot message, etc). /// The Vec contains references to media that need async download. + /// + /// `bypass_mention_gating`: whether the bot should skip @mention requirement for this message. + /// This is the final computed result from mode-specific logic (detect_and_mark_multibot), + /// already accounting for the configured `allow_user_messages` mode. + /// Do NOT pass raw participation status here. pub fn parse_message_event( envelope: &FeishuEventEnvelope, bot_open_id: Option<&str>, config: &FeishuConfig, + bypass_mention_gating: bool, ) -> Option<(GatewayEvent, Vec)> { let _header = envelope.header.as_ref()?; let event = envelope.event.as_ref()?; @@ -253,7 +310,7 @@ mod event_types { let sender = event.sender.as_ref()?; let msg_type = msg.message_type.as_deref().unwrap_or("text"); - if !matches!(msg_type, "text" | "image" | "file" | "post") { + if !matches!(msg_type, "text" | "image" | "file" | "post" | "audio") { return None; } // Skip bot messages with explicit sender_type @@ -341,6 +398,17 @@ mod event_types { }]; (String::new(), mentions.1, refs) } + "audio" => { + let file_key = content_json.get("file_key")?.as_str()?; + let mentions = extract_mentions( + "", msg.mentions.as_deref().unwrap_or(&[]), bot_open_id, + ); + let refs = vec![MediaRef::Audio { + message_id: message_id.to_string(), + file_key: file_key.to_string(), + }]; + (String::new(), mentions.1, refs) + } "post" => { // Rich text: content is {"title":"...","content":[[{tag,text,...},{tag,image_key,...}]]} let mut texts = Vec::new(); @@ -412,7 +480,14 @@ mod event_types { // Gateway-side mention gating: in groups, skip if require_mention // is true and bot is not mentioned (for human senders). - if channel_type == "group" && !is_bot_sender && config.require_mention { + // Bypass: if bot has previously replied in this thread (participated), + // no @mention needed (like Discord's "involved" mode). + let in_thread = thread_id.is_some(); + if channel_type == "group" + && !is_bot_sender + && config.require_mention + && !(in_thread && bypass_mention_gating) + { if let Some(bot_id) = bot_open_id { let bot_mentioned = mention_ids.iter().any(|id| id == bot_id); if !bot_mentioned { @@ -627,7 +702,14 @@ pub struct FeishuAdapter { pub name_cache: Arc>>, /// Per-channel bot turn counter. Key = chat_id, Value = (count, last_reset). /// Human message resets count to 0. Prevents runaway bot-to-bot loops. - pub bot_turns: Arc>>, // TODO: add TTL eviction for long-running deploys + pub bot_turns: Arc>>, // eviction: human msg resets; follow-up can add TTL like participated_threads + /// Positive-only cache: thread_id (root_id) → last_replied_at. + /// When bot has replied in a thread, subsequent messages in that thread + /// bypass @mention gating (like Discord's "involved" mode). + pub participated_threads: Arc>>, + /// Positive-only cache: thread_id → first_seen for threads where other bots + /// have posted. Used by multibot-mentions mode to require @mention. + pub multibot_threads: Arc>>, pub client: reqwest::Client, } @@ -644,6 +726,8 @@ impl FeishuAdapter { bot_open_id: Arc::new(RwLock::new(None)), name_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), bot_turns: Arc::new(std::sync::Mutex::new(HashMap::new())), + participated_threads: Arc::new(std::sync::Mutex::new(HashMap::new())), + multibot_threads: Arc::new(std::sync::Mutex::new(HashMap::new())), client: reqwest::Client::new(), } } @@ -737,6 +821,8 @@ pub async fn start_websocket( let client = adapter.client.clone(); let name_cache = adapter.name_cache.clone(); let bot_turns = adapter.bot_turns.clone(); + let participated_threads = adapter.participated_threads.clone(); + let multibot_threads = adapter.multibot_threads.clone(); let handle = tokio::spawn(async move { let mut backoff_secs = 1u64; @@ -751,6 +837,8 @@ pub async fn start_websocket( &mut shutdown_rx, &name_cache, &bot_turns, + &participated_threads, + &multibot_threads, ) .await; @@ -781,6 +869,7 @@ pub async fn start_websocket( } /// Single WebSocket connection lifecycle. +#[allow(clippy::too_many_arguments)] async fn ws_connect_loop( token_cache: &Arc, bot_open_id_store: &Arc>>, @@ -791,6 +880,8 @@ async fn ws_connect_loop( shutdown_rx: &mut watch::Receiver, name_cache: &Arc>>, bot_turns: &Arc>>, + participated_threads: &Arc>>, + multibot_threads: &Arc>>, ) -> anyhow::Result<()> { let api_base = config.api_base(); @@ -818,7 +909,7 @@ async fn ws_connect_loop( Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text))) => { handle_ws_message( &text, bot_open_id_store, dedupe, config, event_tx, - name_cache, token_cache, client, bot_turns, + name_cache, token_cache, client, bot_turns, participated_threads, multibot_threads, ).await; } Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(data))) => { @@ -839,7 +930,7 @@ async fn ws_connect_loop( if let Ok(text) = String::from_utf8(payload.clone()) { handle_ws_message( &text, bot_open_id_store, dedupe, config, event_tx, - name_cache, token_cache, client, bot_turns, + name_cache, token_cache, client, bot_turns, participated_threads, multibot_threads, ).await; } } @@ -848,7 +939,7 @@ async fn ws_connect_loop( ack.payload = Some(b"{\"code\":200}".to_vec()); let ack_bytes = ack.encode_to_vec(); let _ = ws_tx.send( - tokio_tungstenite::tungstenite::Message::Binary(ack_bytes.into()) + tokio_tungstenite::tungstenite::Message::Binary(ack_bytes) ).await; } } @@ -869,6 +960,7 @@ async fn ws_connect_loop( } /// Process a single WebSocket text message. +#[allow(clippy::too_many_arguments)] async fn handle_ws_message( text: &str, bot_open_id_store: &Arc>>, @@ -879,6 +971,8 @@ async fn handle_ws_message( token_cache: &Arc, client: &reqwest::Client, bot_turns: &Arc>>, + participated_threads: &Arc>>, + multibot_threads: &Arc>>, ) { let envelope: FeishuEventEnvelope = match serde_json::from_str(text) { Ok(e) => e, @@ -914,7 +1008,16 @@ async fn handle_ws_message( let bot_id = bot_open_id_store.read().await; let bot_id_ref = bot_id.as_deref(); - if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, config) { + // Check if the message is in a thread where bot has previously replied, + // respecting the allow_user_messages mode: + // - Involved (default): bypass @mention if participated + // - MultibotMentions: bypass only if participated AND no other bot in thread + // - Mentions: never bypass + let bypass_mention = detect_and_mark_multibot( + &envelope, bot_id_ref, config, participated_threads, multibot_threads, + ); + + if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, config, bypass_mention) { // Also dedupe by message_id if dedupe.is_duplicate(&gateway_event.message_id) { return; @@ -936,6 +1039,8 @@ async fn handle_ws_message( ); return; } + // (Feishu doesn't push bot messages to other bots' WebSocket, + // so multibot detection is done via mentions instead — see below.) } else { // Human message resets bot turn counter turns.remove(channel_id.as_str()); @@ -961,6 +1066,9 @@ async fn handle_ws_message( MediaRef::File { message_id, file_key, file_name } => { download_feishu_file(client, &api_base, &token, message_id, file_key, file_name).await } + MediaRef::Audio { message_id, file_key } => { + download_feishu_audio(client, &api_base, &token, message_id, file_key).await + } }; if let Some(att) = attachment { gateway_event.content.attachments.push(att); @@ -1080,8 +1188,8 @@ fn markdown_to_post(md: &str) -> serde_json::Value { let line = raw_lines[li]; // Detect fenced code block let trimmed = line.trim_start(); - if trimmed.starts_with("```") { - let lang = trimmed[3..].trim().to_string(); + if let Some(after_fence) = trimmed.strip_prefix("```") { + let lang = after_fence.trim().to_string(); let mut code = String::new(); li += 1; while li < raw_lines.len() { @@ -1154,9 +1262,7 @@ fn parse_inline(line: &str) -> Vec { } if close_ticks == ticks { // Found matching close — content between is literal - for j in i..end { - buf.push(chars[j]); - } + buf.extend(chars[i..end].iter().copied()); i = end + close_ticks; break 'outer; } @@ -1167,9 +1273,7 @@ fn parse_inline(line: &str) -> Vec { } if end >= len { // No matching close — treat backticks as literal - for j in i..len { - buf.push(chars[j]); - } + buf.extend(chars[i..len].iter().copied()); i = len; } continue; @@ -1194,9 +1298,7 @@ fn parse_inline(line: &str) -> Vec { } if close_run == run { // Found matching close — strip both, keep inner text - for j in after..scan { - buf.push(chars[j]); - } + buf.extend(chars[after..scan].iter().copied()); i = scan + close_run; found_close = true; break; @@ -1266,6 +1368,7 @@ fn try_parse_link(chars: &[char], start: usize) -> Option<(String, String, usize pub enum MediaRef { Image { message_id: String, image_key: String }, File { message_id: String, file_key: String, file_name: String }, + Audio { message_id: String, file_key: String }, } const IMAGE_MAX_DIMENSION_PX: u32 = 1200; @@ -1346,15 +1449,15 @@ pub async fn download_feishu_image( return None; } }; - use base64::Engine; - let data = base64::engine::general_purpose::STANDARD.encode(&compressed); + let path = crate::store::store_media(&compressed).await?; let ext = if mime == "image/gif" { "gif" } else { "jpg" }; Some(crate::schema::Attachment { attachment_type: "image".into(), filename: format!("{}.{}", image_key, ext), mime_type: mime, - data, + data: String::new(), size: compressed.len() as u64, + path: Some(path), }) } @@ -1408,15 +1511,71 @@ pub async fn download_feishu_file( tracing::warn!(file_name, size = bytes.len(), "feishu file exceeds 512KB limit"); return None; } - let text = String::from_utf8_lossy(&bytes); - use base64::Engine; - let data = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + let path = crate::store::store_media(&bytes).await?; Some(crate::schema::Attachment { attachment_type: "text_file".into(), filename: file_name.to_string(), mime_type: "text/plain".into(), - data, + data: String::new(), size: bytes.len() as u64, + path: Some(path), + }) +} + +const AUDIO_MAX_DOWNLOAD: u64 = 25 * 1024 * 1024; // 25 MB (Whisper API limit) + +/// Download a Feishu audio message by message_id + file_key → base64 Attachment. +pub async fn download_feishu_audio( + client: &reqwest::Client, + api_base: &str, + token: &str, + message_id: &str, + file_key: &str, +) -> Option { + use urlencoding::encode; + let url = format!( + "{}/open-apis/im/v1/messages/{}/resources/{}?type=file", + api_base, encode(message_id), encode(file_key) + ); + let resp = match client.get(&url).bearer_auth(token).send().await { + Ok(r) => r, + Err(e) => { + tracing::warn!(file_key, error = %e, "feishu audio download failed"); + return None; + } + }; + if !resp.status().is_success() { + tracing::warn!(file_key, status = %resp.status(), "feishu audio download failed"); + return None; + } + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("audio/ogg") + .to_string(); + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > AUDIO_MAX_DOWNLOAD { + tracing::warn!(file_key, size, "feishu audio exceeds 25MB limit"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > AUDIO_MAX_DOWNLOAD { + tracing::warn!(file_key, size = bytes.len(), "feishu audio exceeds 25MB limit"); + return None; + } + tracing::debug!(file_key, size = bytes.len(), "feishu audio downloaded"); + let path = crate::store::store_media(&bytes).await?; + Some(crate::schema::Attachment { + attachment_type: "audio".into(), + filename: format!("{}.ogg", file_key), + mime_type: content_type, + data: String::new(), + size: bytes.len() as u64, + path: Some(path), }) } @@ -1639,6 +1798,144 @@ async fn remove_reaction(adapter: &FeishuAdapter, message_id: &str, emoji: &str) // Reply handler // --------------------------------------------------------------------------- +/// Check if the bot has participated in the thread referenced by this envelope. +/// Returns `true` if the message is in a thread and that thread has a valid +/// (non-expired) participation entry in the cache. +fn check_thread_participated( + envelope: &FeishuEventEnvelope, + cache: &Arc>>, + session_ttl_secs: u64, +) -> bool { + envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.root_id.as_deref().or(m.parent_id.as_deref())) + .map(|tid| { + // Intentionally recover from poisoned mutex — cache data loss is acceptable + // and preferable to panicking the gateway. + let c = cache.lock().unwrap_or_else(|e| e.into_inner()); + c.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < session_ttl_secs) + }) + .unwrap_or(false) +} + +/// Max entries before eviction. Shared by both `participated_threads` and +/// `multibot_threads` caches — they have the same cardinality (one entry per +/// active thread) so a single limit is appropriate for both. +const PARTICIPATION_CACHE_MAX: usize = 1000; + +/// Detect if a message @mentions another bot in a participated thread, and if +/// so, mark the thread in the multibot cache. Returns whether @mention gating +/// should be bypassed, respecting the configured `allow_user_messages` mode. +/// +/// This consolidates the duplicated multibot detection logic used by both the +/// WebSocket and webhook paths. +fn detect_and_mark_multibot( + envelope: &FeishuEventEnvelope, + bot_open_id: Option<&str>, + config: &FeishuConfig, + participated_threads: &Arc>>, + multibot_threads: &Arc>>, +) -> bool { + let self_participated = check_thread_participated( + envelope, participated_threads, config.session_ttl_secs, + ); + + let thread_id_for_check = envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.root_id.as_deref().or(m.parent_id.as_deref())); + + // Early multibot detection: if a message in a participated thread @mentions + // another bot, mark the thread as multibot immediately. + if let Some(tid) = thread_id_for_check { + if self_participated { + let mentions = envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.mentions.as_ref()); + if let Some(mention_list) = mentions { + let bot_self_id = bot_open_id.unwrap_or(""); + let mention_ids: Vec<_> = mention_list.iter().filter_map(|m| { + m.id.as_ref().and_then(|id| id.open_id.as_deref()) + }).collect(); + + let mentions_other_bot = if !config.trusted_bot_ids.is_empty() { + mention_ids.iter().any(|oid| { + config.trusted_bot_ids.iter().any(|bid| bid == oid) + }) + } else if !config.allowed_users.is_empty() { + mention_ids.iter().any(|oid| { + *oid != bot_self_id && !config.allowed_users.iter().any(|u| u == oid) + }) + } else { + false + }; + + if mentions_other_bot { + info!(thread_id = %tid, "multibot thread detected via @mention"); + let mut cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); + cache.entry(tid.to_string()).or_insert_with(Instant::now); + if cache.len() > PARTICIPATION_CACHE_MAX { + cache.retain(|_, ts| ts.elapsed().as_secs() < config.session_ttl_secs); + } + } + } + } + } + + // Compute bypass_mention_gating based on mode + match config.allow_user_messages { + AllowUsers::Mentions => false, + AllowUsers::Involved => self_participated, + AllowUsers::MultibotMentions => { + if !self_participated { + false + } else { + thread_id_for_check + .map(|tid| { + let cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); + cache + .get(tid) + .is_none_or(|ts| ts.elapsed().as_secs() >= config.session_ttl_secs) + }) + .unwrap_or(true) + } + } + } +} + +/// Record that the bot has participated in a thread. Evicts oldest entries +/// when the cache exceeds PARTICIPATION_CACHE_MAX. +fn record_participation( + cache: &Arc>>, + thread_id: &str, + session_ttl_secs: u64, +) { + if session_ttl_secs == 0 { + return; // Participation tracking disabled + } + // Intentionally recover from poisoned mutex — cache data loss is acceptable + // and preferable to panicking the gateway. + let mut map = cache.lock().unwrap_or_else(|e| e.into_inner()); + map.insert(thread_id.to_string(), Instant::now()); + // Evict if over capacity: first drop expired entries, then oldest half if still over + if map.len() > PARTICIPATION_CACHE_MAX { + map.retain(|_, ts| ts.elapsed().as_secs() < session_ttl_secs); + if map.len() > PARTICIPATION_CACHE_MAX { + let mut entries: Vec<_> = map.iter().map(|(k, v)| (k.clone(), *v)).collect(); + entries.sort_by_key(|(_, ts)| *ts); + let evict_count = entries.len() / 2; + for (k, _) in entries.into_iter().take(evict_count) { + map.remove(&k); + } + } + } +} + pub async fn handle_reply( reply: &GatewayReply, adapter: &FeishuAdapter, @@ -1691,6 +1988,9 @@ pub async fn handle_reply( let api_base = adapter.config.api_base(); let text = &reply.content.text; let limit = adapter.config.message_limit; + // quote_message_id (agent-controlled reply-to) takes priority over thread_id + let reply_target = reply.quote_message_id.as_deref() + .or(reply.channel.thread_id.as_deref()); let thread_id = reply.channel.thread_id.as_deref(); // Split long messages; store sent message_ids in dedupe to prevent @@ -1698,9 +1998,21 @@ pub async fn handle_reply( // Use post (rich text) format for markdown rendering. // When in a thread (thread_id present), use reply API to stay in the same thread. if text.len() <= limit { - match send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, text).await { + let result = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, reply_target, text).await; + // Fallback: if quote_message_id caused failure, retry without it + let result = if result.is_none() && reply.quote_message_id.is_some() { + tracing::warn!(quote_message_id = ?reply.quote_message_id, channel_id = %reply.channel.id, "reply-to failed, falling back to plain send"); + send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, text).await + } else { + result + }; + match result { Some(msg_id) => { adapter.dedupe.is_duplicate(&msg_id); + // Record thread participation for mention bypass + if let Some(tid) = thread_id { + record_participation(&adapter.participated_threads, tid, adapter.config.session_ttl_secs); + } // Send response with message_id back to OAB core (for streaming edit) if let Some(ref req_id) = reply.request_id { let resp = crate::schema::GatewayResponse { @@ -1734,9 +2046,26 @@ pub async fn handle_reply( } } } else { + let mut sent_any = false; for chunk in split_text(text, limit) { - if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, chunk).await { + if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, reply_target, chunk).await { adapter.dedupe.is_duplicate(&msg_id); + sent_any = true; + } + } + // Fallback: if quote_message_id caused all chunks to fail, retry without it + if !sent_any && reply.quote_message_id.is_some() { + tracing::warn!(quote_message_id = ?reply.quote_message_id, channel_id = %reply.channel.id, "chunked reply-to failed, falling back to plain send"); + for chunk in split_text(text, limit) { + if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, chunk).await { + adapter.dedupe.is_duplicate(&msg_id); + sent_any = true; + } + } + } + if sent_any { + if let Some(tid) = thread_id { + record_participation(&adapter.participated_threads, tid, adapter.config.session_ttl_secs); } } } @@ -2012,7 +2341,13 @@ pub async fn webhook( let bot_id = feishu.bot_open_id.read().await; let bot_id_ref = bot_id.as_deref(); - if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, &feishu.config) { + // Check participated threads and multibot detection for mention bypass + let bypass_mention = detect_and_mark_multibot( + &envelope, bot_id_ref, &feishu.config, + &feishu.participated_threads, &feishu.multibot_threads, + ); + + if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, &feishu.config, bypass_mention) { if !feishu.dedupe.is_duplicate(&gateway_event.message_id) { let name = resolve_user_name( &gateway_event.sender.id, &feishu.name_cache, &feishu.token_cache, @@ -2033,6 +2368,9 @@ pub async fn webhook( MediaRef::File { message_id, file_key, file_name } => { download_feishu_file(&feishu.client, &api_base, &token, message_id, file_key, file_name).await } + MediaRef::Audio { message_id, file_key } => { + download_feishu_audio(&feishu.client, &api_base, &token, message_id, file_key).await + } }; if let Some(att) = attachment { gateway_event.content.attachments.push(att); @@ -2082,10 +2420,13 @@ mod tests { allowed_users: vec![], require_mention: true, allow_bots: AllowBots::Off, + allow_user_messages: AllowUsers::Involved, trusted_bot_ids: vec![], max_bot_turns: 20, dedupe_ttl_secs: 300, message_limit: 4000, + session_ttl_secs: 86400, + api_base_override: None, } } @@ -2304,7 +2645,7 @@ mod tests { fn parse_dm_text() { let env = make_envelope("p2p", "hello", "ou_user1", None); let cfg = test_config(); - let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap(); + let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); assert_eq!(evt.platform, "feishu"); assert_eq!(evt.channel.channel_type, "direct"); assert_eq!(evt.channel.id, "oc_chat1"); @@ -2324,7 +2665,7 @@ mod tests { }]; let env = make_envelope("group", "@_user_1 explain VPC", "ou_user1", Some(mentions)); let cfg = test_config(); - let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap(); + let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); assert_eq!(evt.channel.channel_type, "group"); assert_eq!(evt.content.text, "explain VPC"); assert_eq!(evt.mentions, vec!["ou_bot"]); @@ -2335,7 +2676,7 @@ mod tests { let env = make_envelope("group", "just chatting", "ou_user1", None); let cfg = test_config(); // require_mention = true // Gateway-side mention gating: group message without bot mention is filtered - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] @@ -2343,7 +2684,7 @@ mod tests { let env = make_envelope("group", "just chatting", "ou_user1", None); let mut cfg = test_config(); cfg.require_mention = false; - let evt = parse_message_event(&env, Some("ou_bot"), &cfg); + let evt = parse_message_event(&env, Some("ou_bot"), &cfg, false); assert!(evt.is_some()); } @@ -2352,14 +2693,14 @@ mod tests { let mut env = make_envelope("p2p", "hello", "ou_bot", None); env.event.as_mut().unwrap().sender.as_mut().unwrap().sender_type = Some("bot".into()); let cfg = test_config(); - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] fn parse_skips_empty_text() { let env = make_envelope("p2p", " ", "ou_user1", None); let cfg = test_config(); - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] @@ -2367,14 +2708,14 @@ mod tests { let mut env = make_envelope("p2p", "hello", "ou_user1", None); env.event.as_mut().unwrap().message.as_mut().unwrap().message_type = Some("sticker".into()); let cfg = test_config(); - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] fn parse_skips_self_message() { let env = make_envelope("p2p", "hello", "ou_bot", None); let cfg = test_config(); - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } // --- Dedupe tests --- @@ -2506,7 +2847,7 @@ mod tests { }]; let env = make_envelope("group", "@_user_1 tell me about @_user_1 patterns", "ou_user1", Some(mentions)); let cfg = test_config(); - let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap(); + let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); // Only first @_user_1 removed, second preserved assert!(evt.content.text.contains("@_user_1")); } @@ -2518,7 +2859,7 @@ mod tests { let env = make_envelope("p2p", "hello", "ou_stranger", None); let mut cfg = test_config(); cfg.allowed_users = vec!["ou_vip".into()]; - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] @@ -2526,7 +2867,7 @@ mod tests { let env = make_envelope("p2p", "hello", "ou_vip", None); let mut cfg = test_config(); cfg.allowed_users = vec!["ou_vip".into()]; - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_some()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_some()); } // --- allowed_groups filtering --- @@ -2541,7 +2882,7 @@ mod tests { let env = make_envelope("group", "@_user_1 hello", "ou_user1", Some(mentions)); let mut cfg = test_config(); cfg.allowed_groups = vec!["oc_other".into()]; // oc_chat1 not in list - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] @@ -2554,7 +2895,7 @@ mod tests { let env = make_envelope("group", "@_user_1 hello", "ou_user1", Some(mentions)); let mut cfg = test_config(); cfg.allowed_groups = vec!["oc_chat1".into()]; - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_some()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_some()); } // --- Token TTL from API response --- @@ -2611,7 +2952,7 @@ mod tests { let mut env = make_envelope("p2p", "reply", "ou_user1", None); env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("om_root".into()); let cfg = test_config(); - let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap(); + let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); assert_eq!(evt.channel.thread_id, Some("om_root".into())); } @@ -2620,7 +2961,7 @@ mod tests { let mut env = make_envelope("p2p", "reply", "ou_user1", None); env.event.as_mut().unwrap().message.as_mut().unwrap().parent_id = Some("om_parent".into()); let cfg = test_config(); - let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap(); + let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); assert_eq!(evt.channel.thread_id, Some("om_parent".into())); } @@ -2637,4 +2978,221 @@ mod tests { fn emoji_mapping_unknown() { assert_eq!(emoji_to_feishu_reaction("🎉"), None); } + + // --- Participated thread tests --- + + #[test] + fn participated_thread_bypasses_mention_gating() { + let cfg = test_config(); // require_mention = true + // Build envelope with root_id (in a thread) + let mut env = make_envelope("group", "Hello", "ou_user1", None); + env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_123".into()); + // Without participation: no @mention → None + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); + // With participation: no @mention → Some (bypass) + let result = parse_message_event(&env, Some("ou_bot"), &cfg, true); + assert!(result.is_some()); + let (evt, _) = result.unwrap(); + assert_eq!(evt.channel.thread_id.as_deref(), Some("root_123")); + } + + #[test] + fn participated_no_effect_without_thread() { + let cfg = test_config(); // require_mention = true + // Message in main channel (no thread_id) — participated flag doesn't help + let env = make_envelope("group", "Hello", "ou_user1", None); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, true).is_none()); + } + + #[test] + fn record_participation_and_eviction() { + let cache = Arc::new(std::sync::Mutex::new(HashMap::new())); + // Record a thread + record_participation(&cache, "thread_1", 86400); + assert_eq!(cache.lock().unwrap().len(), 1); + // Fill beyond PARTICIPATION_CACHE_MAX + for i in 0..PARTICIPATION_CACHE_MAX + 10 { + record_participation(&cache, &format!("thread_{i}"), 86400); + } + // After eviction, should be roughly half + assert!(cache.lock().unwrap().len() <= PARTICIPATION_CACHE_MAX); + } + + // --- Multibot-mentions mode tests --- + + #[test] + fn multibot_mentions_mode_bypasses_when_single_bot() { + let mut cfg = test_config(); + cfg.allow_user_messages = AllowUsers::MultibotMentions; + let mut env = make_envelope("group", "Hello", "ou_user1", None); + env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into()); + // participated + no other bot → bypass_mention_gating=true + let result = parse_message_event(&env, Some("ou_bot"), &cfg, true); + assert!(result.is_some()); + } + + #[test] + fn multibot_mentions_mode_requires_mention_when_not_participated() { + let mut cfg = test_config(); + cfg.allow_user_messages = AllowUsers::MultibotMentions; + let mut env = make_envelope("group", "Hello", "ou_user1", None); + env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into()); + // not participated → bypass_mention_gating=false + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); + } + + #[test] + fn mentions_mode_never_bypasses() { + let mut cfg = test_config(); + cfg.allow_user_messages = AllowUsers::Mentions; + let mut env = make_envelope("group", "Hello", "ou_user1", None); + env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_789".into()); + // Even with bypass_mention_gating=true, Mentions mode never bypasses + // (caller would pass false because Mentions mode always returns false) + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); + } + + #[test] + fn quote_message_id_takes_priority_over_thread_id() { + use crate::schema::{GatewayReply, ReplyChannel, Content}; + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "evt_123".into(), + platform: "feishu".into(), + channel: ReplyChannel { + id: "chat_123".into(), + thread_id: Some("om_root".into()), + }, + content: Content { + content_type: "text".into(), + text: "hello".into(), + attachments: vec![], + }, + command: None, + request_id: None, + quote_message_id: Some("om_specific".into()), + }; + // quote_message_id should take priority + let reply_target = reply.quote_message_id.as_deref() + .or(reply.channel.thread_id.as_deref()); + assert_eq!(reply_target, Some("om_specific")); + } + + #[test] + fn reply_target_falls_back_to_thread_id_when_no_quote() { + use crate::schema::{GatewayReply, ReplyChannel, Content}; + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "evt_123".into(), + platform: "feishu".into(), + channel: ReplyChannel { + id: "chat_123".into(), + thread_id: Some("om_root".into()), + }, + content: Content { + content_type: "text".into(), + text: "hello".into(), + attachments: vec![], + }, + command: None, + request_id: None, + quote_message_id: None, + }; + let reply_target = reply.quote_message_id.as_deref() + .or(reply.channel.thread_id.as_deref()); + assert_eq!(reply_target, Some("om_root")); + } + + #[test] + fn reply_target_is_none_when_both_absent() { + use crate::schema::{GatewayReply, ReplyChannel, Content}; + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "evt_123".into(), + platform: "feishu".into(), + channel: ReplyChannel { + id: "chat_123".into(), + thread_id: None, + }, + content: Content { + content_type: "text".into(), + text: "hello".into(), + attachments: vec![], + }, + command: None, + request_id: None, + quote_message_id: None, + }; + let reply_target = reply.quote_message_id.as_deref() + .or(reply.channel.thread_id.as_deref()); + assert_eq!(reply_target, None); + } + + #[tokio::test] + async fn quote_message_id_fallback_on_reply_failure() { + // Tests the actual handle_reply fallback path: when quote_message_id + // is set and the reply API fails, handle_reply retries as plain send. + let server = MockServer::start().await; + + // Token endpoint + Mock::given(method("POST")) + .and(path("/open-apis/auth/v3/tenant_access_token/internal")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "code": 0, + "tenant_access_token": "t-test", + "expire": 7200 + }))) + .mount(&server) + .await; + + // Reply API returns 400 (invalid quote_message_id) + Mock::given(method("POST")) + .and(path("/open-apis/im/v1/messages/om_invalid/reply")) + .respond_with(ResponseTemplate::new(400).set_body_string("invalid message_id")) + .expect(1) + .named("reply_api_fail") + .mount(&server) + .await; + + // Plain send endpoint succeeds (fallback path) + Mock::given(method("POST")) + .and(path("/open-apis/im/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "code": 0, + "data": {"message_id": "om_fallback_ok"} + }))) + .expect(1) + .named("plain_send_fallback") + .mount(&server) + .await; + + let mut config = test_config(); + config.api_base_override = Some(server.uri()); + let adapter = FeishuAdapter::new(config); + + let (event_tx, _rx) = tokio::sync::broadcast::channel(16); + + let reply = crate::schema::GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "evt_123".into(), + platform: "feishu".into(), + channel: crate::schema::ReplyChannel { + id: "oc_chat1".into(), + thread_id: None, + }, + content: crate::schema::Content { + content_type: "text".into(), + text: "hello from fallback test".into(), + attachments: vec![], + }, + command: None, + request_id: None, + quote_message_id: Some("om_invalid".into()), + }; + + handle_reply(&reply, &adapter, &event_tx).await; + // wiremock expect(1) on both mocks verifies: + // 1. Reply API was called (and failed) + // 2. Plain send was called (fallback triggered by quote_message_id.is_some() guard) + } } diff --git a/gateway/src/adapters/googlechat.rs b/gateway/src/adapters/googlechat.rs index 68759e026..93c0c8f8e 100644 --- a/gateway/src/adapters/googlechat.rs +++ b/gateway/src/adapters/googlechat.rs @@ -12,11 +12,35 @@ use tracing::{error, info, warn}; pub const GOOGLE_CHAT_API_BASE: &str = "https://chat.googleapis.com/v1"; const GOOGLE_CHAT_MESSAGE_LIMIT: usize = 4096; -// --- Google Chat types (v2 envelope format) --- +const IMAGE_MAX_DIMENSION_PX: u32 = 1200; +const IMAGE_JPEG_QUALITY: u8 = 75; +const IMAGE_MAX_DOWNLOAD: u64 = 10 * 1024 * 1024; // 10 MB +const FILE_MAX_DOWNLOAD: u64 = 512 * 1024; // 512 KB +const AUDIO_MAX_DOWNLOAD: u64 = 25 * 1024 * 1024; // 25 MB +/// Per-request timeout for Google Chat Media API downloads. Prevents a hung +/// connection from blocking the spawned download task indefinitely. +const MEDIA_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +/// Cap on text file attachments per message (matches Discord/Slack). +const TEXT_FILE_COUNT_CAP: usize = 5; +/// Cap on aggregate text file bytes per message (matches Discord/Slack 1 MB). +const TEXT_TOTAL_CAP: u64 = 1024 * 1024; + +// --- Google Chat types --- +// +// Google Chat delivers webhooks in two shapes depending on the App's +// Connection settings in the Cloud Console: +// - HTTP endpoint URL mode: top-level fields (message, user, space, ...) +// - Pub/Sub mode: wrapped under `chat.messagePayload` +// Both are supported via the optional fields below; the handler prefers +// the wrapped form and falls back to top-level when `chat` is absent. #[derive(Debug, Deserialize)] pub struct GoogleChatEnvelope { pub chat: Option, + // HTTP endpoint URL top-level fields (used when `chat` is None) + pub message: Option, + pub user: Option, + pub space: Option, } #[derive(Debug, Deserialize)] @@ -42,6 +66,52 @@ pub struct GoogleChatMessage { pub sender: Option, pub thread: Option, pub space: Option, + #[serde(default)] + pub attachment: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GoogleChatAttachment { + #[allow(dead_code)] + pub name: Option, + pub content_name: Option, + pub content_type: Option, + pub source: Option, + pub attachment_data_ref: Option, + #[allow(dead_code)] + pub drive_data_ref: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentDataRef { + pub resource_name: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct DriveDataRef { + pub drive_file_id: Option, +} + +/// Reference to media that needs async download after webhook parse. +#[derive(Debug, Clone)] +pub enum GoogleChatMediaRef { + Image { + resource_name: String, + content_name: String, + }, + File { + resource_name: String, + content_name: String, + }, + Audio { + resource_name: String, + content_name: String, + content_type: String, + }, } #[derive(Debug, Deserialize)] @@ -64,6 +134,8 @@ pub struct GoogleChatSpace { pub name: String, #[serde(rename = "type")] pub space_type: Option, + // Parsed by serde, not consumed in current code paths. + #[allow(dead_code)] pub space_type_renamed: Option, } @@ -71,20 +143,20 @@ pub struct GoogleChatSpace { const GOOGLE_CHAT_ISSUER: &str = "https://accounts.google.com"; const GOOGLE_CHAT_JWKS_URL: &str = "https://www.googleapis.com/oauth2/v3/certs"; -const GOOGLE_CHAT_EMAIL_SUFFIX: &str = "@gcp-sa-gsuiteaddons.iam.gserviceaccount.com"; +const GOOGLE_CHAT_SIGNER_EMAIL: &str = "chat@system.gserviceaccount.com"; const JWKS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600); -/// Verify the JWT's `email` claim belongs to a Google Chat service account. -/// Google Chat webhooks use `service-{PROJECT_NUMBER}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com`. +/// Verify the JWT's `email` claim belongs to Google Chat. +/// HTTP endpoint URL webhooks are signed by `chat@system.gserviceaccount.com`. /// Without this check, any Google-issued ID token would be accepted. fn verify_email_claim(claims: &serde_json::Value) -> Result<(), String> { let email = claims .get("email") .and_then(|v| v.as_str()) .ok_or("missing email claim")?; - if !email.ends_with(GOOGLE_CHAT_EMAIL_SUFFIX) { + if email != GOOGLE_CHAT_SIGNER_EMAIL { return Err(format!( - "email claim mismatch: expected *{GOOGLE_CHAT_EMAIL_SUFFIX}, got {email}" + "email claim mismatch: expected {GOOGLE_CHAT_SIGNER_EMAIL}, got {email}" )); } Ok(()) @@ -423,13 +495,20 @@ pub async fn webhook( } }; - let Some(chat) = envelope.chat else { - return empty_json_response(); - }; - let Some(payload) = chat.message_payload else { - return empty_json_response(); + // Try the Pub/Sub `chat`-wrapped shape first, then fall back to the + // HTTP endpoint URL top-level shape. + let (msg_opt, top_user, top_space) = if let Some(chat) = envelope.chat { + let user = chat.user; + let (msg, space) = match chat.message_payload { + Some(p) => (p.message, p.space), + None => (None, None), + }; + (msg, user, space) + } else { + (envelope.message, envelope.user, envelope.space) }; - let Some(ref msg) = payload.message else { + + let Some(ref msg) = msg_opt else { return empty_json_response(); }; @@ -438,12 +517,16 @@ pub async fn webhook( .as_deref() .or(msg.text.as_deref()) .unwrap_or(""); - if text.trim().is_empty() { + + let media_refs = parse_attachments(&msg.attachment); + + // Drop event only if BOTH text and attachments are empty + if text.trim().is_empty() && media_refs.is_empty() { return empty_json_response(); } - let sender = msg.sender.as_ref().or(chat.user.as_ref()); - let space = msg.space.as_ref().or(payload.space.as_ref()); + let sender = msg.sender.as_ref().or(top_user.as_ref()); + let space = msg.space.as_ref().or(top_space.as_ref()); let is_bot = sender.map(|s| s.user_type == "BOT").unwrap_or(false); if is_bot { @@ -473,28 +556,178 @@ pub async fn webhook( .unwrap_or(&msg.name) .to_string(); - let gw_event = GatewayEvent::new( + // No attachments → emit event synchronously and respond 200 + if media_refs.is_empty() { + send_googlechat_event( + &state, + &space_name, + space_type, + thread_id, + &sender_id, + &sender_name, + &display_name, + text, + &message_id, + Vec::new(), + ); + return empty_json_response(); + } + + // Has attachments — spawn background task so the webhook returns 200 within + // Google Chat's 30 s deadline regardless of how long downloads take. + let text = text.to_string(); + let state = state.clone(); + let spawn_space = space_name.clone(); + tokio::spawn(async move { + use futures_util::FutureExt; + let result = std::panic::AssertUnwindSafe(async { + let mut downloaded: Vec = Vec::new(); + let mut text_file_count: usize = 0; + let mut text_file_bytes: u64 = 0; + if let Some(ref adapter) = state.google_chat { + if let Some(token) = adapter.get_token().await { + for media_ref in &media_refs { + let attachment = match media_ref { + GoogleChatMediaRef::Image { + resource_name, + content_name, + .. + } => { + download_googlechat_image( + &adapter.client, + &token, + &adapter.api_base, + resource_name, + content_name, + ) + .await + } + GoogleChatMediaRef::File { + resource_name, + content_name, + .. + } => { + if text_file_count >= TEXT_FILE_COUNT_CAP { + warn!(content_name = %content_name, cap = TEXT_FILE_COUNT_CAP, "googlechat text file count cap reached, skipping"); + continue; + } + let remaining = TEXT_TOTAL_CAP.saturating_sub(text_file_bytes); + let att = download_googlechat_file( + &adapter.client, + &token, + &adapter.api_base, + resource_name, + content_name, + remaining, + ) + .await; + let Some(att) = att else { continue }; + text_file_count += 1; + text_file_bytes += att.size; + Some(att) + } + GoogleChatMediaRef::Audio { + resource_name, + content_name, + content_type, + } => { + download_googlechat_audio( + &adapter.client, + &token, + &adapter.api_base, + resource_name, + content_name, + content_type, + ) + .await + } + }; + if let Some(att) = attachment { + downloaded.push(att); + } + } + } else { + warn!("googlechat: no token available for attachment download"); + } + } + + // If text is empty AND every attachment failed to download, drop the event. + if text.trim().is_empty() && downloaded.is_empty() { + warn!( + space = %space_name, + "googlechat: empty text + all attachments failed, dropping event" + ); + return; + } + + send_googlechat_event( + &state, + &space_name, + space_type, + thread_id, + &sender_id, + &sender_name, + &display_name, + &text, + &message_id, + downloaded, + ); + }).catch_unwind().await; + if let Err(e) = result { + error!(space = %spawn_space, "googlechat attachment download task panicked: {e:?}"); + } + }); + + empty_json_response() +} + +#[allow(clippy::too_many_arguments)] +fn send_googlechat_event( + state: &Arc, + space_name: &str, + space_type: String, + thread_id: Option, + sender_id: &str, + sender_name: &str, + display_name: &str, + text: &str, + message_id: &str, + attachments: Vec, +) { + let mut gw_event = GatewayEvent::new( "googlechat", ChannelInfo { - id: space_name.clone(), + id: space_name.to_string(), channel_type: space_type, thread_id, }, SenderInfo { - id: sender_id, - name: sender_name.clone(), - display_name, + id: sender_id.to_string(), + name: sender_name.to_string(), + display_name: display_name.to_string(), is_bot: false, }, text, - &message_id, + message_id, vec![], ); + gw_event.content.attachments = attachments; - let json = serde_json::to_string(&gw_event).unwrap(); - info!(space = %space_name, sender = %sender_name, "googlechat → gateway"); + let attachment_count = gw_event.content.attachments.len(); + let json = match serde_json::to_string(&gw_event) { + Ok(j) => j, + Err(e) => { + error!(error = %e, "googlechat: failed to serialize GatewayEvent"); + return; + } + }; + info!( + space = %space_name, + sender = %sender_name, + attachment_count, + "googlechat → gateway" + ); let _ = state.event_tx.send(json); - empty_json_response() } fn empty_json_response() -> axum::response::Response { @@ -903,6 +1136,254 @@ fn split_text(text: &str, limit: usize) -> Vec<&str> { chunks } +// --- Attachment parsing & download --- + +/// Whitelist of text-like file extensions for `download_googlechat_file`. +const TEXT_EXTS: &[&str] = &[ + "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", + "rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", + "rb", "sh", "bash", "sql", "html", "css", "ini", "cfg", "conf", +]; + +/// Parse Google Chat attachment array into media references for async download. +/// +/// Skips Drive-sourced attachments (different download API), and unknown +/// content types. Branches on `contentType` prefix to bucket into image / +/// audio / file. +fn parse_attachments(attachments: &[GoogleChatAttachment]) -> Vec { + let mut refs = Vec::new(); + for att in attachments { + // Only handle UPLOADED_CONTENT (Drive needs separate Drive API call) + if att.source.as_deref() != Some("UPLOADED_CONTENT") { + continue; + } + let resource_name = match att + .attachment_data_ref + .as_ref() + .and_then(|d| d.resource_name.clone()) + { + Some(rn) => rn, + None => continue, + }; + let content_type = att.content_type.clone().unwrap_or_default(); + let content_name = att.content_name.clone().unwrap_or_else(|| "file".into()); + + if content_type.starts_with("image/") { + refs.push(GoogleChatMediaRef::Image { + resource_name, + content_name, + }); + } else if content_type.starts_with("audio/") { + refs.push(GoogleChatMediaRef::Audio { + resource_name, + content_name, + content_type, + }); + } else if content_type.starts_with("video/") { + info!(content_name = %content_name, content_type = %content_type, "googlechat: video attachment skipped (not yet supported)"); + } else { + refs.push(GoogleChatMediaRef::File { + resource_name, + content_name, + }); + } + } + refs +} + +/// Resize image so longest side ≤ 1200px, then encode as JPEG. +/// GIFs are passed through unchanged to preserve animation. +fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { + use image::ImageReader; + use std::io::Cursor; + + let reader = ImageReader::new(Cursor::new(raw)).with_guessed_format()?; + let format = reader.format(); + if format == Some(image::ImageFormat::Gif) { + return Ok((raw.to_vec(), "image/gif".to_string())); + } + let img = reader.decode()?; + let (w, h) = (img.width(), img.height()); + let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX { + let max_side = std::cmp::max(w, h); + let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side); + let new_w = (f64::from(w) * ratio) as u32; + let new_h = (f64::from(h) * ratio) as u32; + img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3) + } else { + img + }; + let mut buf = Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY); + img.write_with_encoder(encoder)?; + Ok((buf.into_inner(), "image/jpeg".to_string())) +} + +/// Build the Media API URL for a given resource_name. +/// Google Chat Media API uses `{+resourceName}` (RFC 6570 reserved expansion), +/// so `/` must stay literal while other special chars are percent-encoded. +fn media_url(api_base: &str, resource_name: &str) -> String { + let encoded: String = resource_name + .bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { + (b as char).to_string() + } + _ => format!("%{:02X}", b), + }) + .collect(); + format!("{}/media/{}?alt=media", api_base, encoded) +} + +/// Download an image attachment via Google Chat Media API → resize/compress → base64. +pub async fn download_googlechat_image( + client: &reqwest::Client, + token: &str, + api_base: &str, + resource_name: &str, + content_name: &str, +) -> Option { + let url = media_url(api_base, resource_name); + let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await { + Ok(r) => r, + Err(e) => { + warn!(content_name, error = %e, "googlechat image download failed"); + return None; + } + }; + if !resp.status().is_success() { + warn!(content_name, status = %resp.status(), "googlechat image download failed"); + return None; + } + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > IMAGE_MAX_DOWNLOAD { + warn!(content_name, size, "googlechat image Content-Length exceeds 10MB limit"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > IMAGE_MAX_DOWNLOAD { + warn!(content_name, size = bytes.len(), "googlechat image exceeds 10MB limit"); + return None; + } + let (compressed, mime) = match resize_and_compress(&bytes) { + Ok(v) => v, + Err(e) => { + warn!(content_name, error = %e, "googlechat image resize failed"); + return None; + } + }; + let path = crate::store::store_media(&compressed).await?; + Some(crate::schema::Attachment { + attachment_type: "image".into(), + filename: content_name.to_string(), + mime_type: mime, + data: String::new(), + size: compressed.len() as u64, + path: Some(path), + }) +} + +/// Download a text-like file via Google Chat Media API → base64. +/// Non-text extensions are skipped to avoid sending binary garbage to the model. +pub async fn download_googlechat_file( + client: &reqwest::Client, + token: &str, + api_base: &str, + resource_name: &str, + content_name: &str, + remaining_budget: u64, +) -> Option { + let ext = content_name.rsplit('.').next().unwrap_or("").to_lowercase(); + if !TEXT_EXTS.contains(&ext.as_str()) { + tracing::debug!(content_name, "skipping non-text googlechat file attachment"); + return None; + } + let max_size = FILE_MAX_DOWNLOAD.min(remaining_budget); + let url = media_url(api_base, resource_name); + let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await { + Ok(r) => r, + Err(e) => { + warn!(content_name, error = %e, "googlechat file download failed"); + return None; + } + }; + if !resp.status().is_success() { + warn!(content_name, status = %resp.status(), "googlechat file download failed"); + return None; + } + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > max_size { + warn!(content_name, size, limit = max_size, "googlechat file Content-Length exceeds limit"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > max_size { + warn!(content_name, size = bytes.len(), limit = max_size, "googlechat file exceeds size limit"); + return None; + } + let path = crate::store::store_media(&bytes).await?; + Some(crate::schema::Attachment { + attachment_type: "text_file".into(), + filename: content_name.to_string(), + mime_type: "text/plain".into(), + data: String::new(), + size: bytes.len() as u64, + path: Some(path), + }) +} + +/// Download an audio attachment as-is (no resize/transcode) → filesystem store. +/// Core's STT pipeline (when available) consumes this as `audio` attachment_type. +pub async fn download_googlechat_audio( + client: &reqwest::Client, + token: &str, + api_base: &str, + resource_name: &str, + content_name: &str, + content_type: &str, +) -> Option { + let url = media_url(api_base, resource_name); + let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await { + Ok(r) => r, + Err(e) => { + warn!(content_name, error = %e, "googlechat audio download failed"); + return None; + } + }; + if !resp.status().is_success() { + warn!(content_name, status = %resp.status(), "googlechat audio download failed"); + return None; + } + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > AUDIO_MAX_DOWNLOAD { + warn!(content_name, size, "googlechat audio Content-Length exceeds 25MB limit"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > AUDIO_MAX_DOWNLOAD { + warn!(content_name, size = bytes.len(), "googlechat audio exceeds 25MB limit"); + return None; + } + let path = crate::store::store_media(&bytes).await?; + Some(crate::schema::Attachment { + attachment_type: "audio".into(), + filename: content_name.to_string(), + mime_type: content_type.to_string(), + data: String::new(), + size: bytes.len() as u64, + path: Some(path), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1178,8 +1659,8 @@ mod tests { } #[test] - fn email_claim_accepts_gsuite_addons_account() { - let claims = serde_json::json!({"email": "service-123456@gcp-sa-gsuiteaddons.iam.gserviceaccount.com"}); + fn email_claim_accepts_chat_system_account() { + let claims = serde_json::json!({"email": "chat@system.gserviceaccount.com"}); assert!(verify_email_claim(&claims).is_ok()); } @@ -1370,10 +1851,12 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: "hello".into(), }, command: None, request_id: Some("req_123".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1412,10 +1895,12 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: "hello".into(), }, command: None, request_id: Some("req_fail".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1458,10 +1943,12 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: "".into(), }, command: None, request_id: Some("req_empty".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1501,10 +1988,12 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: long_text, }, command: None, request_id: Some("req_multi_fail".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1534,10 +2023,12 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: "hello".into(), }, command: None, request_id: Some("req_notoken".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1578,10 +2069,12 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: "updated text".into(), }, command: Some("edit_message".into()), request_id: None, + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1619,10 +2112,12 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: long_text, }, command: None, request_id: Some("req_multi".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1675,10 +2170,12 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: long_text, }, command: None, request_id: Some("req_partial".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1692,4 +2189,282 @@ mod tests { let err = resp.error.expect("partial failure should set error"); assert!(err.contains("500")); } + + // --- Attachment parsing tests --- + + fn make_attachment( + source: &str, + content_type: &str, + content_name: &str, + resource_name: Option<&str>, + ) -> GoogleChatAttachment { + GoogleChatAttachment { + name: Some("spaces/SP/messages/MSG/attachments/ATT".into()), + content_name: Some(content_name.into()), + content_type: Some(content_type.into()), + source: Some(source.into()), + attachment_data_ref: resource_name.map(|rn| AttachmentDataRef { + resource_name: Some(rn.into()), + }), + drive_data_ref: None, + } + } + + #[test] + fn parse_attachments_image() { + let atts = vec![make_attachment( + "UPLOADED_CONTENT", + "image/png", + "photo.png", + Some("AATT_resource"), + )]; + let refs = parse_attachments(&atts); + assert_eq!(refs.len(), 1); + match &refs[0] { + GoogleChatMediaRef::Image { + resource_name, + content_name, + } => { + assert_eq!(resource_name, "AATT_resource"); + assert_eq!(content_name, "photo.png"); + } + other => panic!("expected Image, got {:?}", other), + } + } + + #[test] + fn parse_attachments_audio() { + let atts = vec![make_attachment( + "UPLOADED_CONTENT", + "audio/mp4", + "voice.m4a", + Some("AATT"), + )]; + let refs = parse_attachments(&atts); + assert!(matches!(refs[0], GoogleChatMediaRef::Audio { .. })); + } + + #[test] + fn parse_attachments_file() { + let atts = vec![make_attachment( + "UPLOADED_CONTENT", + "text/plain", + "notes.txt", + Some("AATT"), + )]; + let refs = parse_attachments(&atts); + assert!(matches!(refs[0], GoogleChatMediaRef::File { .. })); + } + + #[test] + fn parse_attachments_skips_drive() { + let atts = vec![GoogleChatAttachment { + name: Some("spaces/SP/messages/MSG/attachments/ATT".into()), + content_name: Some("doc".into()), + content_type: Some("application/vnd.google-apps.document".into()), + source: Some("DRIVE_FILE".into()), + attachment_data_ref: None, + drive_data_ref: Some(DriveDataRef { + drive_file_id: Some("drive_id_123".into()), + }), + }]; + assert_eq!(parse_attachments(&atts).len(), 0); + } + + #[test] + fn parse_attachments_skips_missing_resource_name() { + let atts = vec![make_attachment( + "UPLOADED_CONTENT", + "image/png", + "photo.png", + None, + )]; + assert_eq!(parse_attachments(&atts).len(), 0); + } + + #[test] + fn media_url_preserves_slashes_and_encodes_specials() { + let url = media_url("https://chat.googleapis.com/v1", "spaces/SP/messages/MSG/attachments/ATT"); + assert_eq!( + url, + "https://chat.googleapis.com/v1/media/spaces/SP/messages/MSG/attachments/ATT?alt=media" + ); + let url2 = media_url("https://chat.googleapis.com/v1", "AATT/some+resource=name"); + assert_eq!( + url2, + "https://chat.googleapis.com/v1/media/AATT/some%2Bresource%3Dname?alt=media" + ); + } + + #[tokio::test] + async fn download_googlechat_image_resizes_and_returns_attachment() { + use wiremock::{Mock, MockServer, ResponseTemplate}; + use wiremock::matchers::{method, path_regex}; + + // Generate a small valid PNG + let img = image::RgbImage::from_pixel(10, 10, image::Rgb([255, 0, 0])); + let mut buf = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(img) + .write_to(&mut buf, image::ImageFormat::Png) + .unwrap(); + let png_bytes = buf.into_inner(); + + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex("/media/.*")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(png_bytes) + .insert_header("content-type", "image/png"), + ) + .mount(&mock_server) + .await; + + let client = reqwest::Client::new(); + let result = download_googlechat_image( + &client, + "fake-token", + &mock_server.uri(), + "AATT_resource", + "photo.png", + ) + .await; + let att = result.expect("expected successful download"); + assert_eq!(att.attachment_type, "image"); + assert_eq!(att.filename, "photo.png"); + assert_eq!(att.mime_type, "image/jpeg"); // resized PNG → JPEG + assert!(att.path.is_some()); // stored to filesystem + assert!(att.size > 0); + } + + #[tokio::test] + async fn download_googlechat_file_rejects_non_text_extension() { + let client = reqwest::Client::new(); + let result = download_googlechat_file( + &client, + "fake-token", + "https://unused", // not called for non-text + "AATT", + "binary.exe", + TEXT_TOTAL_CAP, + ) + .await; + assert!(result.is_none(), "non-text extensions must be skipped"); + } + + #[tokio::test] + async fn download_googlechat_file_text_extension_succeeds() { + use wiremock::{Mock, MockServer, ResponseTemplate}; + use wiremock::matchers::{method, path_regex}; + + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex("/media/.*")) + .respond_with( + ResponseTemplate::new(200).set_body_bytes(b"hello world".to_vec()), + ) + .mount(&mock_server) + .await; + + let client = reqwest::Client::new(); + let result = download_googlechat_file( + &client, + "fake-token", + &mock_server.uri(), + "AATT", + "notes.txt", + TEXT_TOTAL_CAP, + ) + .await; + let att = result.expect("expected successful download"); + assert_eq!(att.attachment_type, "text_file"); + assert_eq!(att.filename, "notes.txt"); + assert_eq!(att.mime_type, "text/plain"); + } + + #[tokio::test] + async fn download_googlechat_audio_returns_attachment() { + use wiremock::{Mock, MockServer, ResponseTemplate}; + use wiremock::matchers::{method, path_regex}; + + let mock_server = MockServer::start().await; + let audio_bytes = vec![0u8; 1024]; + Mock::given(method("GET")) + .and(path_regex("/media/.*")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(audio_bytes.clone())) + .mount(&mock_server) + .await; + + let client = reqwest::Client::new(); + let result = download_googlechat_audio( + &client, + "fake-token", + &mock_server.uri(), + "AATT", + "voice.m4a", + "audio/mp4", + ) + .await; + let att = result.expect("expected successful download"); + assert_eq!(att.attachment_type, "audio"); + assert_eq!(att.filename, "voice.m4a"); + assert_eq!(att.mime_type, "audio/mp4"); + assert_eq!(att.size, 1024); + } + + #[tokio::test] + async fn download_googlechat_image_rejects_oversized_content_length() { + use wiremock::{Mock, MockServer, ResponseTemplate}; + use wiremock::matchers::{method, path_regex}; + + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex("/media/.*")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", "20000000") // 20 MB > 10 MB limit + .set_body_bytes(vec![0u8; 100]), + ) + .mount(&mock_server) + .await; + + let client = reqwest::Client::new(); + let result = download_googlechat_image( + &client, + "fake-token", + &mock_server.uri(), + "AATT", + "huge.png", + ) + .await; + assert!(result.is_none(), "oversized image must be rejected"); + } + + #[test] + fn parses_http_endpoint_url_top_level_envelope() { + let envelope: GoogleChatEnvelope = serde_json::from_value(serde_json::json!({ + "message": { + "name": "spaces/AAAA/messages/BBBB", + "text": "hello", + "attachment": [] + }, + "user": { + "name": "users/123", + "displayName": "Test User", + "type": "HUMAN" + }, + "space": { + "name": "spaces/AAAA", + "type": "DM" + } + })) + .unwrap(); + assert!(envelope.chat.is_none()); + assert!(envelope.message.is_some()); + assert_eq!(envelope.message.unwrap().name, "spaces/AAAA/messages/BBBB"); + assert!(envelope.user.is_some()); + assert_eq!(envelope.user.unwrap().name, "users/123"); + assert!(envelope.space.is_some()); + assert_eq!(envelope.space.unwrap().name, "spaces/AAAA"); + } } diff --git a/gateway/src/adapters/mod.rs b/gateway/src/adapters/mod.rs index f261efe68..94a2a8a79 100644 --- a/gateway/src/adapters/mod.rs +++ b/gateway/src/adapters/mod.rs @@ -3,3 +3,4 @@ pub mod googlechat; pub mod line; pub mod teams; pub mod telegram; +pub mod wecom; diff --git a/gateway/src/adapters/wecom.rs b/gateway/src/adapters/wecom.rs new file mode 100644 index 000000000..e3e97ff17 --- /dev/null +++ b/gateway/src/adapters/wecom.rs @@ -0,0 +1,1654 @@ +use anyhow::Result; +use axum::extract::State; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{info, warn}; + +pub struct WecomConfig { + pub corp_id: String, + pub agent_id: String, + pub secret: String, + pub token: String, + pub encoding_aes_key: String, + pub webhook_path: String, + pub streaming_enabled: bool, + pub debounce_secs: u64, +} + +impl WecomConfig { + pub fn from_env() -> Option { + Self::from_reader(|k| std::env::var(k).ok()) + } + + /// Build config from an arbitrary string reader. Tests use this with a + /// HashMap so they don't mutate process-wide environment variables — + /// `env::set_var` races other tests under cargo's parallel runner. + fn from_reader Option>(read: F) -> Option { + let corp_id = read("WECOM_CORP_ID")?; + let secret = read("WECOM_SECRET")?; + let token = read("WECOM_TOKEN")?; + let encoding_aes_key = read("WECOM_ENCODING_AES_KEY")?; + let agent_id = read("WECOM_AGENT_ID")?; + if agent_id.parse::().is_err() { + warn!("WECOM_AGENT_ID must be a numeric value, got '{}'", agent_id); + return None; + } + let webhook_path = read("WECOM_WEBHOOK_PATH").unwrap_or_else(|| "/webhook/wecom".into()); + // Streaming opts-in: WeCom callback mode has no edit-message API, so + // streaming is implemented via thinking-placeholder + recall + resend, + // which causes a brief client flicker. Default off; set to true only if + // the UX tradeoff is acceptable. + let streaming_enabled = read("WECOM_STREAMING_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + let debounce_secs = read("WECOM_DEBOUNCE_SECS") + .and_then(|v| v.parse::().ok()) + .unwrap_or(3); + + if encoding_aes_key.len() != 43 { + warn!("WECOM_ENCODING_AES_KEY must be 43 characters, got {}", encoding_aes_key.len()); + return None; + } + + info!( + corp_id = %corp_id, + agent_id = %agent_id, + streaming_enabled, + debounce_secs, + "wecom adapter configured" + ); + Some(Self { + corp_id, + agent_id, + secret, + token, + encoding_aes_key, + webhook_path, + streaming_enabled, + debounce_secs, + }) + } +} + +fn decode_aes_key(encoding_aes_key: &str) -> anyhow::Result> { + use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; + use base64::Engine; + // WeCom's EncodingAESKey is 43 base64 chars without trailing padding. + // Append "=" to make it a 44-char standard base64 string before decoding. + // Indifferent + allow_trailing_bits accommodate WeCom's non-standard + // encoding: the 43rd char's last 2 bits are not part of the output and + // must be ignored rather than rejected. + let padded = format!("{}=", encoding_aes_key); + let config = GeneralPurposeConfig::new() + .with_decode_padding_mode(DecodePaddingMode::Indifferent) + .with_decode_allow_trailing_bits(true); + let engine = GeneralPurpose::new(&base64::alphabet::STANDARD, config); + let key = engine + .decode(&padded) + .map_err(|e| anyhow::anyhow!("encoding_aes_key base64 decode failed: {e}"))?; + anyhow::ensure!( + key.len() == 32, + "encoding_aes_key must decode to 32 bytes, got {}", + key.len() + ); + Ok(key) +} + +fn compute_signature(token: &str, timestamp: &str, nonce: &str, encrypt: &str) -> String { + use sha1::Digest; + let mut parts = [token, timestamp, nonce, encrypt]; + parts.sort_unstable(); + let joined: String = parts.concat(); + let hash = sha1::Sha1::digest(joined.as_bytes()); + format!("{:x}", hash) +} + +fn verify_signature( + token: &str, + timestamp: &str, + nonce: &str, + encrypt: &str, + expected: &str, +) -> bool { + let computed = compute_signature(token, timestamp, nonce, encrypt); + tracing::debug!( + computed = %computed, + expected = %expected, + token_len = token.len(), + encrypt_len = encrypt.len(), + "signature comparison" + ); + subtle::ConstantTimeEq::ct_eq(computed.as_bytes(), expected.as_bytes()).into() +} + +fn decrypt_message( + encoding_aes_key: &str, + encrypted: &str, + expected_corp_id: &str, +) -> anyhow::Result { + use aes::cipher::{BlockDecryptMut, KeyIvInit}; + use base64::Engine; + + let key = decode_aes_key(encoding_aes_key)?; + let iv = &key[..16]; + + let cipher_bytes = base64::engine::general_purpose::STANDARD + .decode(encrypted) + .map_err(|e| anyhow::anyhow!("base64 decode failed: {e}"))?; + + if cipher_bytes.is_empty() || cipher_bytes.len() % 16 != 0 { + anyhow::bail!("ciphertext length {} not a multiple of 16", cipher_bytes.len()); + } + + type Aes256CbcDec = cbc::Decryptor; + let decryptor = Aes256CbcDec::new_from_slices(&key, iv) + .map_err(|e| anyhow::anyhow!("aes init failed: {e}"))?; + + let mut buf = cipher_bytes.to_vec(); + // WeCom uses PKCS7 with block_size=32, not 16. Decrypt without padding validation + // and strip padding manually. + let plaintext = decryptor + .decrypt_padded_mut::(&mut buf) + .map_err(|e| anyhow::anyhow!("aes decrypt failed: {e}"))?; + + // Strip WeCom PKCS7 padding (block_size=32): last byte indicates pad length (1-32) + let pad_byte = *plaintext.last().ok_or_else(|| anyhow::anyhow!("empty plaintext"))? as usize; + if pad_byte == 0 || pad_byte > 32 || pad_byte > plaintext.len() { + anyhow::bail!("invalid wecom padding value: {pad_byte}"); + } + let pad_start = plaintext.len() - pad_byte; + if !plaintext[pad_start..].iter().all(|&b| b as usize == pad_byte) { + anyhow::bail!("invalid PKCS#7 padding: not all padding bytes match"); + } + let plaintext = &plaintext[..pad_start]; + + // Plaintext structure: random(16) + msg_len(4, big-endian) + msg + corp_id + if plaintext.len() < 20 { + anyhow::bail!("decrypted payload too short"); + } + let msg_len = + u32::from_be_bytes([plaintext[16], plaintext[17], plaintext[18], plaintext[19]]) as usize; + if plaintext.len() < 20 + msg_len { + anyhow::bail!("msg_len exceeds payload size"); + } + let msg = &plaintext[20..20 + msg_len]; + let corp_id = &plaintext[20 + msg_len..]; + + let corp_id_str = + std::str::from_utf8(corp_id).map_err(|e| anyhow::anyhow!("corp_id not utf8: {e}"))?; + if corp_id_str != expected_corp_id { + anyhow::bail!("corp_id mismatch: expected {expected_corp_id}, got {corp_id_str}"); + } + + String::from_utf8(msg.to_vec()).map_err(|e| anyhow::anyhow!("message not utf8: {e}")) +} + +// --- Deduplication --- + +const DEDUPE_TTL_SECS: u64 = 30; +const DEDUPE_MAX_SIZE: usize = 10_000; + +struct DedupeCache { + entries: std::sync::Mutex>, +} + +impl DedupeCache { + fn new() -> Self { + Self { + entries: std::sync::Mutex::new(std::collections::HashMap::new()), + } + } + + fn check_and_insert(&self, msg_id: &str) -> bool { + let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); + let now = std::time::Instant::now(); + + if entries.len() >= DEDUPE_MAX_SIZE { + entries.retain(|_, t| now.duration_since(*t).as_secs() < DEDUPE_TTL_SECS); + } + + if let Some(t) = entries.get(msg_id) { + if now.duration_since(*t).as_secs() < DEDUPE_TTL_SECS { + return false; + } + } + + entries.insert(msg_id.to_string(), now); + true + } +} + +// --- Token cache --- + +pub const WECOM_API_BASE: &str = "https://qyapi.weixin.qq.com"; +const TOKEN_REFRESH_MARGIN_SECS: u64 = 300; + +pub struct WecomTokenCache { + inner: RwLock>, + base_url: String, +} + +impl WecomTokenCache { + fn new() -> Self { + Self { + inner: RwLock::new(None), + base_url: WECOM_API_BASE.into(), + } + } + + #[cfg(test)] + fn with_base_url(base_url: String) -> Self { + Self { + inner: RwLock::new(None), + base_url, + } + } + + pub async fn get_token( + &self, + client: &reqwest::Client, + corp_id: &str, + secret: &str, + ) -> Result { + // Fast path: read lock + { + let guard = self.inner.read().await; + if let Some((ref token, created_at, expires_in)) = *guard { + let elapsed = created_at.elapsed().as_secs(); + if elapsed + TOKEN_REFRESH_MARGIN_SECS < expires_in { + return Ok(token.clone()); + } + } + } + + // Slow path: write lock + refresh + let mut guard = self.inner.write().await; + // Double-check after acquiring write lock + if let Some((ref token, created_at, expires_in)) = *guard { + let elapsed = created_at.elapsed().as_secs(); + if elapsed + TOKEN_REFRESH_MARGIN_SECS < expires_in { + return Ok(token.clone()); + } + } + + // WeCom's gettoken API requires `corpsecret` as a query parameter — the + // protocol mandates this, we can't move it to a header. Operators must + // configure their reverse proxy / load balancer to redact query strings + // on `/cgi-bin/gettoken` paths before logging access logs. We do not log + // this URL anywhere from the gateway side. + let url = format!( + "{}/cgi-bin/gettoken?corpid={}&corpsecret={}", + self.base_url, corp_id, secret + ); + let resp: serde_json::Value = client.get(&url).send().await?.json().await?; + + let errcode = resp["errcode"].as_i64().unwrap_or(-1); + if errcode != 0 { + anyhow::bail!( + "wecom gettoken failed: errcode={}, errmsg={}", + errcode, + resp["errmsg"] + ); + } + + let token = resp["access_token"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing access_token in response"))? + .to_string(); + let expires_in = resp["expires_in"].as_u64().unwrap_or(7200); + + *guard = Some((token.clone(), std::time::Instant::now(), expires_in)); + Ok(token) + } + + pub async fn force_refresh( + &self, + client: &reqwest::Client, + corp_id: &str, + secret: &str, + ) -> Result { + let mut guard = self.inner.write().await; + *guard = None; + drop(guard); + self.get_token(client, corp_id, secret).await + } +} + +// --- Adapter --- + +struct PendingStream { + text_watch: tokio::sync::watch::Sender, +} + +type PendingMap = Arc>>; + +pub struct WecomAdapter { + pub config: WecomConfig, + pub token_cache: Arc, + client: reqwest::Client, + dedupe: DedupeCache, + pending_streams: PendingMap, +} + +impl WecomAdapter { + pub fn new(config: WecomConfig) -> Self { + Self { + token_cache: Arc::new(WecomTokenCache::new()), + client: reqwest::Client::new(), + dedupe: DedupeCache::new(), + pending_streams: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), + config, + } + } + + + pub async fn handle_reply( + &self, + reply: &crate::schema::GatewayReply, + event_tx: &tokio::sync::broadcast::Sender, + ) { + if let Some(cmd) = reply.command.as_deref() { + match cmd { + "add_reaction" | "remove_reaction" | "create_topic" => { + info!(command = cmd, "wecom: ignoring unsupported command"); + return; + } + "edit_message" => { + self.handle_edit_message(reply); + return; + } + _ => {} + } + } + + let text = &reply.content.text; + if text.is_empty() { + return; + } + + let to_user = reply + .channel + .id + .rsplit(':') + .next() + .unwrap_or(&reply.channel.id); + + let has_pending = { + let pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); + pending.contains_key(&reply.channel.id) + }; + let is_streaming_placeholder = reply.request_id.is_some() && !has_pending; + if is_streaming_placeholder { + // Optionally send a thinking placeholder. With streaming disabled + // (default), buffer chunks silently and send the consolidated text + // when the debounce settles — no recall/flicker. + let placeholder_id = if self.config.streaming_enabled { + info!(to_user = to_user, "wecom: sending thinking placeholder"); + match self.send_text(to_user, "⏳...").await { + Ok(id) => Some(id), + Err(e) => { + warn!("wecom send thinking failed: {e}"); + return; + } + } + } else { + None + }; + + let (text_tx, text_rx) = tokio::sync::watch::channel(String::new()); + { + let mut pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); + pending.insert(reply.channel.id.clone(), PendingStream { + text_watch: text_tx, + }); + } + let client = self.client.clone(); + let token_cache = self.token_cache.clone(); + let corp_id = self.config.corp_id.clone(); + let secret = self.config.secret.clone(); + let agent_id = self.config.agent_id.clone(); + let thinking_id = placeholder_id.clone(); + let flush_to_user = to_user.to_string(); + let channel_id_clone = reply.channel.id.clone(); + let pending_clone = self.pending_streams.clone(); + let debounce_secs = self.config.debounce_secs; + tokio::spawn(async move { + let mut rx = text_rx; + let debounce = std::time::Duration::from_secs(debounce_secs); + let mut last_text = String::new(); + let max_idle = std::time::Duration::from_secs(300); + let started = std::time::Instant::now(); + loop { + match tokio::time::timeout(debounce, rx.changed()).await { + Ok(Ok(())) => { + last_text = rx.borrow().clone(); + } + Ok(Err(_)) => break, + Err(_) => { + if !last_text.is_empty() { + break; + } + if started.elapsed() > max_idle { + warn!("wecom: debounce task timed out after 5 minutes"); + break; + } + } + } + } + // Acquire pending lock first, then capture any late writes + // that landed between the loop break and now. Holding the + // lock blocks handle_reply from sending more chunks for this + // channel, so this read is the last writeable moment. Then + // remove the entry, which drops text_tx and closes the channel. + { + let mut pending = pending_clone.lock().unwrap_or_else(|e| e.into_inner()); + let final_text = rx.borrow().clone(); + if !final_text.is_empty() { + last_text = final_text; + } + pending.remove(&channel_id_clone); + } + if last_text.is_empty() { + return; + } + flush_thinking( + &client, &token_cache, &corp_id, &secret, &agent_id, + thinking_id.as_deref(), &flush_to_user, &last_text, + ).await; + }); + + if let Some(ref req_id) = reply.request_id { + let resp = crate::schema::GatewayResponse { + schema: "openab.gateway.response.v1".into(), + request_id: req_id.clone(), + success: true, + thread_id: None, + message_id: placeholder_id, + error: None, + }; + if let Ok(json) = serde_json::to_string(&resp) { + let _ = event_tx.send(json); + } + } + return; + } + + if has_pending { + // Re-check under lock: the debounce task may have removed the entry + // between our earlier read of `has_pending` and now. If it did, + // fall through to the direct-send path so the chunk isn't lost. + let appended = { + let pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(stream) = pending.get(&reply.channel.id) { + let current = stream.text_watch.borrow().clone(); + let combined = if current.is_empty() { + text.to_string() + } else { + format!("{}\n{}", current, text) + }; + let _ = stream.text_watch.send(combined); + true + } else { + false + } + }; + if appended { + if let Some(ref req_id) = reply.request_id { + let resp = crate::schema::GatewayResponse { + schema: "openab.gateway.response.v1".into(), + request_id: req_id.clone(), + success: true, + thread_id: None, + message_id: None, + error: None, + }; + if let Ok(json) = serde_json::to_string(&resp) { + let _ = event_tx.send(json); + } + } + return; + } + // Pending entry was already removed (debounce flushed) — fall + // through to direct-send below so this chunk still reaches the user. + } + + info!(to_user = to_user, "wecom: sending reply"); + let chunks = split_text_lines(text, 2048); + let mut msg_id = None; + + for chunk in &chunks { + match self.send_text(to_user, chunk).await { + Ok(id) => { + if msg_id.is_none() { + msg_id = Some(id); + } + } + Err(e) => warn!("wecom send failed: {e}"), + } + } + + if let Some(ref req_id) = reply.request_id { + let resp = crate::schema::GatewayResponse { + schema: "openab.gateway.response.v1".into(), + request_id: req_id.clone(), + success: msg_id.is_some(), + thread_id: None, + message_id: msg_id, + error: None, + }; + if let Ok(json) = serde_json::to_string(&resp) { + let _ = event_tx.send(json); + } + } + } + + fn handle_edit_message(&self, reply: &crate::schema::GatewayReply) { + let text = reply.content.text.trim(); + if text.is_empty() { + return; + } + let pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(stream) = pending.get(&reply.channel.id) { + let _ = stream.text_watch.send(text.to_string()); + } + } + + + async fn send_text(&self, to_user: &str, text: &str) -> Result { + let agent_id: u64 = self.config.agent_id.parse().expect("agent_id validated at startup"); + let body = serde_json::json!({ + "touser": to_user, + "msgtype": "text", + "agentid": agent_id, + "text": { "content": text } + }); + + let resp = post_with_token_retry( + &self.client, + &self.token_cache, + &self.config.corp_id, + &self.config.secret, + "/cgi-bin/message/send", + &body, + ) + .await?; + Ok(resp["msgid"].as_str().unwrap_or("").to_string()) + } +} + +/// POST a JSON body to a WeCom API endpoint with automatic token refresh +/// on errcode 42001 (access_token expired). Used by both `send_text` and +/// the streaming flush path so a long-running stream can't lose its final +/// reply if the cached token expires mid-flight. +async fn post_with_token_retry( + client: &reqwest::Client, + token_cache: &WecomTokenCache, + corp_id: &str, + secret: &str, + api_path: &str, + body: &serde_json::Value, +) -> Result { + let token = token_cache.get_token(client, corp_id, secret).await?; + let url = format!("{}{}?access_token={}", token_cache.base_url, api_path, token); + let resp: serde_json::Value = client.post(&url).json(body).send().await?.json().await?; + let errcode = resp["errcode"].as_i64().unwrap_or(-1); + + if errcode == 42001 { + warn!(api_path, "wecom: access_token expired, refreshing and retrying"); + let new_token = token_cache.force_refresh(client, corp_id, secret).await?; + let retry_url = format!("{}{}?access_token={}", token_cache.base_url, api_path, new_token); + let retry_resp: serde_json::Value = + client.post(&retry_url).json(body).send().await?.json().await?; + let retry_code = retry_resp["errcode"].as_i64().unwrap_or(-1); + if retry_code != 0 { + anyhow::bail!( + "wecom {} retry failed: errcode={}, errmsg={}", + api_path, + retry_code, + retry_resp["errmsg"] + ); + } + Ok(retry_resp) + } else if errcode != 0 { + anyhow::bail!( + "wecom {} failed: errcode={}, errmsg={}", + api_path, + errcode, + resp["errmsg"] + ); + } else { + Ok(resp) + } +} + +// --- Handlers --- + +fn handle_verify_request( + token: &str, + encoding_aes_key: &str, + corp_id: &str, + msg_signature: &str, + timestamp: &str, + nonce: &str, + echostr: &str, +) -> anyhow::Result { + if !verify_signature(token, timestamp, nonce, echostr, msg_signature) { + anyhow::bail!("signature verification failed"); + } + decrypt_message(encoding_aes_key, echostr, corp_id) +} + +// --- XML parsing --- + +struct CallbackEnvelope { + to_user_name: String, + encrypt: String, +} + +struct WecomMessage { + from_user: String, + msg_type: String, + content: String, + msg_id: String, + pic_url: String, + media_id: String, + file_name: String, +} + +fn parse_envelope_xml(xml: &str) -> Result { + use quick_xml::events::Event; + use quick_xml::Reader; + + let mut reader = Reader::from_str(xml); + let mut to_user_name = String::new(); + let mut encrypt = String::new(); + let mut current_tag = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(e)) => { + current_tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); + } + Ok(Event::CData(e)) => { + let text = String::from_utf8_lossy(&e).to_string(); + match current_tag.as_str() { + "ToUserName" => to_user_name = text, + "Encrypt" => encrypt = text, + _ => {} + } + } + Ok(Event::Text(e)) => { + let text = e.unescape().unwrap_or_default().to_string(); + match current_tag.as_str() { + "ToUserName" if to_user_name.is_empty() => to_user_name = text, + "Encrypt" if encrypt.is_empty() => encrypt = text, + _ => {} + } + } + Ok(Event::End(_)) => { + current_tag.clear(); + } + Ok(Event::Eof) => break, + Err(e) => anyhow::bail!("xml parse error: {e}"), + _ => {} + } + } + + if encrypt.is_empty() { + anyhow::bail!("missing Encrypt field in callback XML"); + } + Ok(CallbackEnvelope { + to_user_name, + encrypt, + }) +} + +fn parse_message_xml(xml: &str) -> Result { + use quick_xml::events::Event; + use quick_xml::Reader; + + let mut reader = Reader::from_str(xml); + let mut from_user = String::new(); + let mut msg_type = String::new(); + let mut content = String::new(); + let mut msg_id = String::new(); + let mut pic_url = String::new(); + let mut media_id = String::new(); + let mut file_name = String::new(); + let mut current_tag = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(e)) => { + current_tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); + } + Ok(Event::CData(e)) => { + let text = String::from_utf8_lossy(&e).to_string(); + match current_tag.as_str() { + "FromUserName" => from_user = text, + "MsgType" => msg_type = text, + "Content" => content = text, + "MsgId" => msg_id = text, + "PicUrl" => pic_url = text, + "MediaId" => media_id = text, + "FileName" => file_name = text, + _ => {} + } + } + Ok(Event::Text(e)) => { + let text = e.unescape().unwrap_or_default().to_string(); + match current_tag.as_str() { + "FromUserName" if from_user.is_empty() => from_user = text, + "MsgType" if msg_type.is_empty() => msg_type = text, + "Content" if content.is_empty() => content = text, + "MsgId" if msg_id.is_empty() => msg_id = text, + "PicUrl" if pic_url.is_empty() => pic_url = text, + "MediaId" if media_id.is_empty() => media_id = text, + "FileName" if file_name.is_empty() => file_name = text, + _ => {} + } + } + Ok(Event::End(_)) => { + current_tag.clear(); + } + Ok(Event::Eof) => break, + Err(e) => anyhow::bail!("xml parse error: {e}"), + _ => {} + } + } + + Ok(WecomMessage { + from_user, + msg_type, + content, + msg_id, + pic_url, + media_id, + file_name, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn flush_thinking( + client: &reqwest::Client, + token_cache: &WecomTokenCache, + corp_id: &str, + secret: &str, + agent_id: &str, + thinking_msg_id: Option<&str>, + to_user: &str, + text: &str, +) { + info!(?thinking_msg_id, text_len = text.len(), "wecom: flush_thinking starting"); + + // Recall thinking placeholder (only when streaming was enabled) + if let Some(id) = thinking_msg_id { + let body = serde_json::json!({ "msgid": id }); + match post_with_token_retry( + client, + token_cache, + corp_id, + secret, + "/cgi-bin/message/recall", + &body, + ) + .await + { + Ok(resp) => info!(body = %resp, "wecom: recall response"), + Err(e) => warn!(error = %e, "wecom: recall failed"), + } + } + + // Send final text. Each chunk goes through retry-on-token-expiry so a + // long stream that outlives the cached token still delivers its reply. + let aid = agent_id.parse::().unwrap_or(0); + let chunks = split_text_lines(text, 2048); + info!(chunk_count = chunks.len(), "wecom: sending final chunks"); + for (i, chunk) in chunks.iter().enumerate() { + let body = serde_json::json!({ + "touser": to_user, + "msgtype": "text", + "agentid": aid, + "text": { "content": chunk } + }); + match post_with_token_retry( + client, + token_cache, + corp_id, + secret, + "/cgi-bin/message/send", + &body, + ) + .await + { + Ok(val) => { + let msg_id = val["msgid"].as_str().unwrap_or(""); + info!(msg_id = %msg_id, chunk_idx = i, "wecom: sent final reply chunk"); + } + Err(e) => warn!(error = %e, chunk_idx = i, "wecom flush send failed"), + } + } +} + +/// Split `text` into chunks that each fit within `limit` bytes (WeCom's +/// `message/send` truncates server-side at 2048 bytes). Splits prefer +/// newline boundaries; lines that exceed the limit themselves are split at +/// UTF-8 char boundaries via `char_indices()` so multibyte characters are +/// never severed mid-codepoint. The `limit` and all `len()` comparisons in +/// this function are in **bytes**, matching WeCom's server-side check. +fn split_text_lines(text: &str, limit: usize) -> Vec { + if text.len() <= limit { + return vec![text.to_string()]; + } + let mut chunks = Vec::new(); + let mut current = String::new(); + for line in text.split('\n') { + if line.len() > limit { + if !current.is_empty() { + chunks.push(current); + current = String::new(); + } + // Split long line at char boundaries + let mut pos = 0; + for (i, ch) in line.char_indices() { + if i - pos + ch.len_utf8() > limit { + chunks.push(line[pos..i].to_string()); + pos = i; + } + } + if pos < line.len() { + current = line[pos..].to_string(); + } + continue; + } + let candidate_len = if current.is_empty() { + line.len() + } else { + current.len() + 1 + line.len() + }; + if candidate_len > limit && !current.is_empty() { + chunks.push(current); + current = String::new(); + } + if !current.is_empty() { + current.push('\n'); + } + current.push_str(line); + } + if !current.is_empty() { + chunks.push(current); + } + chunks +} + +pub async fn verify( + State(state): State>, + query: axum::extract::Query>, +) -> axum::response::Response { + use axum::response::IntoResponse; + + let wecom = match state.wecom.as_ref() { + Some(w) => w, + None => return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + + let msg_signature = query.get("msg_signature").map(|s| s.as_str()).unwrap_or(""); + let timestamp = query.get("timestamp").map(|s| s.as_str()).unwrap_or(""); + let nonce = query.get("nonce").map(|s| s.as_str()).unwrap_or(""); + let echostr = query.get("echostr").map(|s| s.as_str()).unwrap_or(""); + + info!( + msg_signature = %msg_signature, + timestamp = %timestamp, + nonce = %nonce, + echostr_len = echostr.len(), + "wecom verify request received" + ); + + match handle_verify_request( + &wecom.config.token, + &wecom.config.encoding_aes_key, + &wecom.config.corp_id, + msg_signature, + timestamp, + nonce, + echostr, + ) { + Ok(plaintext) => plaintext.into_response(), + Err(e) => { + warn!("wecom callback verification failed: {e}"); + axum::http::StatusCode::FORBIDDEN.into_response() + } + } +} + +pub async fn webhook( + State(state): State>, + query: axum::extract::Query>, + body: axum::body::Bytes, +) -> axum::response::Response { + use axum::response::IntoResponse; + + let wecom = match state.wecom.as_ref() { + Some(w) => w, + None => return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + + let msg_signature = query.get("msg_signature").map(|s| s.as_str()).unwrap_or(""); + let timestamp = query.get("timestamp").map(|s| s.as_str()).unwrap_or(""); + let nonce = query.get("nonce").map(|s| s.as_str()).unwrap_or(""); + + // Reject stale callbacks. WeCom retries within ~5s, our dedup window is + // 30s, so a 5-minute freshness check rejects replays without false- + // positives on legitimate retries. The signature itself doesn't bind a + // freshness expectation, so without this an attacker who captured a + // signed payload could replay it indefinitely. + if let Ok(ts) = timestamp.parse::() { + let now = chrono::Utc::now().timestamp(); + if (now - ts).abs() > 300 { + warn!(timestamp_age_secs = now - ts, "wecom webhook: rejecting stale callback"); + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + } + + let body_str = match std::str::from_utf8(&body) { + Ok(s) => s, + Err(_) => return axum::http::StatusCode::BAD_REQUEST.into_response(), + }; + + let envelope = match parse_envelope_xml(body_str) { + Ok(e) => e, + Err(e) => { + warn!("wecom envelope parse error: {e}"); + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + }; + + // ToUserName in the outer envelope must match our configured Corp ID. + // The decrypt step also validates the inner Corp ID suffix; checking here + // first surfaces misrouted callbacks before we touch crypto. + if envelope.to_user_name != wecom.config.corp_id { + warn!( + envelope_to = %envelope.to_user_name, + expected = %wecom.config.corp_id, + "wecom webhook: envelope ToUserName mismatch" + ); + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + + if !verify_signature( + &wecom.config.token, + timestamp, + nonce, + &envelope.encrypt, + msg_signature, + ) { + warn!("wecom webhook signature verification failed"); + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + + info!(encrypt_len = envelope.encrypt.len(), "wecom: decrypting callback"); + let decrypted = match decrypt_message( + &wecom.config.encoding_aes_key, + &envelope.encrypt, + &wecom.config.corp_id, + ) { + Ok(d) => { + info!("wecom: decrypt ok"); + d + } + Err(e) => { + warn!(encrypt_len = envelope.encrypt.len(), "wecom decrypt failed: {e}"); + return "success".into_response(); + } + }; + + let msg = match parse_message_xml(&decrypted) { + Ok(m) => m, + Err(e) => { + warn!("wecom message parse error: {e}"); + return "success".into_response(); + } + }; + + info!( + msg_type = %msg.msg_type, + has_pic_url = !msg.pic_url.is_empty(), + msg_id = %msg.msg_id, + "wecom: parsed message" + ); + + if !matches!(msg.msg_type.as_str(), "text" | "image" | "file") { + return "success".into_response(); + } + + if !wecom.dedupe.check_and_insert(&msg.msg_id) { + return "success".into_response(); + } + + let text = match msg.msg_type.as_str() { + "text" => msg.content.clone(), + "image" => "Describe this image.".to_string(), + "file" => format!("User sent a file: {}", msg.file_name), + _ => String::new(), + }; + + let mut attachments = Vec::new(); + if msg.msg_type == "image" && !msg.pic_url.is_empty() { + match download_wecom_image(&wecom.client, &msg.pic_url).await { + Some(att) => attachments.push(att), + None => info!("wecom: image download failed, forwarding without attachment"), + } + } + if msg.msg_type == "file" && !msg.media_id.is_empty() { + match download_wecom_file( + &wecom.client, + &wecom.token_cache, + &wecom.config.corp_id, + &wecom.config.secret, + &msg.media_id, + &msg.file_name, + ) + .await + { + Some(att) => attachments.push(att), + None => info!("wecom: file download failed, forwarding without attachment"), + } + } + + if text.trim().is_empty() && attachments.is_empty() { + return "success".into_response(); + } + + let channel_id = format!("wecom:{}:{}", wecom.config.corp_id, msg.from_user); + let mut event = crate::schema::GatewayEvent::new( + "wecom", + crate::schema::ChannelInfo { + id: channel_id, + channel_type: "direct".into(), + thread_id: None, + }, + crate::schema::SenderInfo { + id: msg.from_user.clone(), + name: msg.from_user.clone(), + display_name: msg.from_user.clone(), + is_bot: false, + }, + &text, + &msg.msg_id, + vec![], + ); + event.content.attachments = attachments; + + let att_sizes: Vec = event.content.attachments.iter().map(|a| a.data.len()).collect(); + info!( + attachments = event.content.attachments.len(), + text_len = event.content.text.len(), + att_data_sizes = ?att_sizes, + att_mime = ?event.content.attachments.iter().map(|a| a.mime_type.as_str()).collect::>(), + "wecom: forwarding event to OAB" + ); + if let Ok(json) = serde_json::to_string(&event) { + info!( + json_len = json.len(), + has_attachments_in_json = json.contains("\"attachments\""), + "wecom: event JSON ready" + ); + let _ = state.event_tx.send(json); + } + + "success".into_response() +} + +const IMAGE_MAX_DOWNLOAD: u64 = 10 * 1024 * 1024; +const IMAGE_MAX_DIMENSION_PX: u32 = 1200; +const IMAGE_JPEG_QUALITY: u8 = 75; + +async fn download_wecom_image( + client: &reqwest::Client, + pic_url: &str, +) -> Option { + // Only fetch over HTTPS. WeCom's CDN serves images over HTTPS; rejecting + // non-HTTPS URLs prevents SSRF if the AES key is ever compromised and + // an attacker forges a callback with PicUrl pointing at an internal host. + if !pic_url.starts_with("https://") { + warn!(pic_url, "wecom: rejecting non-HTTPS pic_url"); + return None; + } + info!(pic_url, "wecom: downloading image"); + let resp = match client.get(pic_url).send().await { + Ok(r) => r, + Err(e) => { + warn!(error = %e, "wecom image download failed"); + return None; + } + }; + if !resp.status().is_success() { + warn!(status = %resp.status(), "wecom image download failed"); + return None; + } + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > IMAGE_MAX_DOWNLOAD { + warn!(size, "wecom image exceeds 10MB limit, skipping"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > IMAGE_MAX_DOWNLOAD { + warn!(size = bytes.len(), "wecom image exceeds 10MB limit"); + return None; + } + let (compressed, mime) = match resize_and_compress(&bytes) { + Ok(v) => v, + Err(e) => { + warn!(error = %e, "wecom: image resize/compress failed"); + return None; + } + }; + let path = crate::store::store_media(&compressed).await?; + let ext = if mime == "image/gif" { "gif" } else { "jpg" }; + Some(crate::schema::Attachment { + attachment_type: "image".into(), + filename: format!("wecom_{}.{}", chrono::Utc::now().timestamp(), ext), + mime_type: mime, + data: String::new(), + size: compressed.len() as u64, + path: Some(path), + }) +} + +const FILE_MAX_DOWNLOAD: u64 = 20 * 1024 * 1024; + +const TEXT_EXTENSIONS: &[&str] = &[ + "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", "rs", "py", "js", + "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh", "bash", "zsh", "fish", + "ps1", "bat", "sql", "html", "css", "scss", "less", "ini", "cfg", "conf", "env", + "swift", "kt", "scala", "r", "pl", "lua", "graphql", "tsv", +]; + +const TEXT_FILENAMES: &[&str] = &[ + "dockerfile", "makefile", "justfile", "rakefile", "gemfile", + "procfile", "vagrantfile", ".gitignore", ".dockerignore", ".editorconfig", +]; + +fn is_text_file(filename: &str) -> bool { + let lower = filename.to_lowercase(); + if lower.contains('.') { + if let Some(ext) = lower.rsplit('.').next() { + if TEXT_EXTENSIONS.contains(&ext) { + return true; + } + } + } + TEXT_FILENAMES.contains(&lower.as_str()) +} + +/// GET /cgi-bin/media/get with token-expiry retry. The media API returns +/// JSON `{"errcode":42001,...}` instead of binary when the token is stale, +/// so we sniff Content-Type and retry once with a force-refreshed token. +async fn fetch_media_with_retry( + client: &reqwest::Client, + token_cache: &WecomTokenCache, + corp_id: &str, + secret: &str, + media_id: &str, +) -> Result { + let token = token_cache.get_token(client, corp_id, secret).await?; + let url = format!( + "{}/cgi-bin/media/get?access_token={}&media_id={}", + token_cache.base_url, token, media_id + ); + let resp = client.get(&url).send().await?; + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + if !content_type.contains("json") { + return Ok(resp); + } + // JSON body means error path. Inspect for 42001 and retry once. + let body = resp.text().await.unwrap_or_default(); + let val: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + let errcode = val["errcode"].as_i64().unwrap_or(-1); + if errcode == 42001 { + warn!("wecom media: access_token expired, refreshing and retrying"); + let new_token = token_cache.force_refresh(client, corp_id, secret).await?; + let retry_url = format!( + "{}/cgi-bin/media/get?access_token={}&media_id={}", + token_cache.base_url, new_token, media_id + ); + return Ok(client.get(&retry_url).send().await?); + } + anyhow::bail!("wecom media error: {body}") +} + +async fn download_wecom_file( + client: &reqwest::Client, + token_cache: &WecomTokenCache, + corp_id: &str, + secret: &str, + media_id: &str, + filename: &str, +) -> Option { + info!(filename, media_id, "wecom: downloading file"); + let resp = match fetch_media_with_retry(client, token_cache, corp_id, secret, media_id).await { + Ok(r) => r, + Err(e) => { + warn!(error = %e, "wecom file download failed"); + return None; + } + }; + if !resp.status().is_success() { + warn!(status = %resp.status(), "wecom file download failed"); + return None; + } + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > FILE_MAX_DOWNLOAD { + warn!(size, "wecom file exceeds 20MB limit, skipping"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > FILE_MAX_DOWNLOAD { + warn!(size = bytes.len(), "wecom file exceeds 20MB limit"); + return None; + } + + if !is_text_file(filename) { + info!(filename, "wecom: skipping non-text file"); + return None; + } + + let text_content = match String::from_utf8(bytes.to_vec()) { + Ok(s) => s, + Err(_) => { + info!(filename, "wecom: file is not valid UTF-8, skipping"); + return None; + } + }; + + let path = crate::store::store_media(text_content.as_bytes()).await?; + let size = text_content.len() as u64; + + Some(crate::schema::Attachment { + attachment_type: "text_file".into(), + filename: filename.to_string(), + mime_type: "text/plain".into(), + data: String::new(), + size, + path: Some(path), + }) +} + +fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { + use image::ImageReader; + use std::io::Cursor; + + let reader = ImageReader::new(Cursor::new(raw)).with_guessed_format()?; + let format = reader.format(); + if format == Some(image::ImageFormat::Gif) { + return Ok((raw.to_vec(), "image/gif".to_string())); + } + let img = reader.decode()?; + let (w, h) = (img.width(), img.height()); + let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX { + let max_side = std::cmp::max(w, h); + let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side); + let new_w = (f64::from(w) * ratio) as u32; + let new_h = (f64::from(h) * ratio) as u32; + img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3) + } else { + img + }; + let mut buf = Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY); + img.write_with_encoder(encoder)?; + Ok((buf.into_inner(), "image/jpeg".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: std::collections::HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |k: &str| map.get(k).cloned() + } + + #[test] + fn config_from_env_all_present() { + let env = make_env(&[ + ("WECOM_CORP_ID", "ww_test_corp"), + ("WECOM_SECRET", "test_secret"), + ("WECOM_TOKEN", "test_token"), + ("WECOM_ENCODING_AES_KEY", "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"), + ("WECOM_AGENT_ID", "1000002"), + ]); + let config = WecomConfig::from_reader(env).unwrap(); + assert_eq!(config.corp_id, "ww_test_corp"); + assert_eq!(config.agent_id, "1000002"); + assert_eq!(config.webhook_path, "/webhook/wecom"); + assert!(!config.streaming_enabled, "streaming defaults off"); + assert_eq!(config.debounce_secs, 3); + } + + #[test] + fn config_from_env_missing_required() { + let env = make_env(&[]); + assert!(WecomConfig::from_reader(env).is_none()); + } + + fn encrypt_for_test(encoding_aes_key: &str, msg: &str, corp_id: &str) -> String { + use aes::cipher::{BlockEncryptMut, KeyIvInit}; + use base64::Engine; + + let key = decode_aes_key(encoding_aes_key).unwrap(); + let iv = &key[..16]; + + let msg_bytes = msg.as_bytes(); + let corp_id_bytes = corp_id.as_bytes(); + let msg_len = (msg_bytes.len() as u32).to_be_bytes(); + + let mut plaintext = Vec::new(); + plaintext.extend_from_slice(&[0u8; 16]); // random bytes (zeros for test) + plaintext.extend_from_slice(&msg_len); + plaintext.extend_from_slice(msg_bytes); + plaintext.extend_from_slice(corp_id_bytes); + + // WeCom uses PKCS7 padding with block_size=32 + let block_size = 32; + let pad_len = block_size - (plaintext.len() % block_size); + for _ in 0..pad_len { + plaintext.push(pad_len as u8); + } + + // Encrypt with NoPadding since we already padded manually + let total_len = plaintext.len(); + let mut buf = vec![0u8; total_len + 16]; // extra space just in case + buf[..total_len].copy_from_slice(&plaintext); + + type Aes256CbcEnc = cbc::Encryptor; + let encryptor = Aes256CbcEnc::new_from_slices(&key, iv).unwrap(); + let encrypted = encryptor + .encrypt_padded_mut::(&mut buf, total_len) + .unwrap(); + + base64::engine::general_purpose::STANDARD.encode(encrypted) + } + + #[test] + fn aes_key_decode() { + let key_str = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let key_bytes = decode_aes_key(key_str).unwrap(); + assert_eq!(key_bytes.len(), 32); + } + + #[test] + fn signature_verify() { + let token = "testtoken"; + let timestamp = "1409659813"; + let nonce = "1372623149"; + let encrypt = "msg_encrypt_content"; + + let sig = compute_signature(token, timestamp, nonce, encrypt); + assert!(verify_signature(token, timestamp, nonce, encrypt, &sig)); + assert!(!verify_signature( + token, + timestamp, + nonce, + encrypt, + "wrong_signature_value_here" + )); + } + + #[test] + fn decrypt_wecom_payload() { + let key_str = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + let msg = "hello world"; + + let encrypted = encrypt_for_test(key_str, msg, corp_id); + let decrypted = decrypt_message(key_str, &encrypted, corp_id).unwrap(); + assert_eq!(decrypted, msg); + } + + #[test] + fn verify_callback_echostr() { + let token = "testtoken"; + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + let echostr_plain = "success_echo_string"; + + let echostr_encrypted = encrypt_for_test(encoding_aes_key, echostr_plain, corp_id); + let sig = compute_signature(token, "1409659813", "nonce123", &echostr_encrypted); + + let result = handle_verify_request( + token, + encoding_aes_key, + corp_id, + &sig, + "1409659813", + "nonce123", + &echostr_encrypted, + ); + assert_eq!(result.unwrap(), echostr_plain); + } + + #[test] + fn parse_text_message_xml() { + let xml = r#"134883186012345678901234561000002"#; + + let msg = parse_message_xml(xml).unwrap(); + assert_eq!(msg.from_user, "user001"); + assert_eq!(msg.msg_type, "text"); + assert_eq!(msg.content, "hello bot"); + assert_eq!(msg.msg_id, "1234567890123456"); + } + + #[test] + fn parse_callback_envelope() { + let xml = r#""#; + + let envelope = parse_envelope_xml(xml).unwrap(); + assert_eq!(envelope.to_user_name, "ww_test_corp"); + assert_eq!(envelope.encrypt, "some_encrypted_base64"); + } + + #[test] + fn dedupe_rejects_duplicates() { + let cache = DedupeCache::new(); + assert!(cache.check_and_insert("msg_001")); + assert!(!cache.check_and_insert("msg_001")); + assert!(cache.check_and_insert("msg_002")); + } + + #[tokio::test] + async fn token_refresh_success() { + use wiremock::matchers::{method, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(query_param("corpid", "ww_test_corp")) + .and(query_param("corpsecret", "test_secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errcode": 0, + "errmsg": "ok", + "access_token": "test_token_abc", + "expires_in": 7200 + }))) + .expect(1) + .mount(&server) + .await; + + let cache = WecomTokenCache::with_base_url(server.uri()); + let client = reqwest::Client::new(); + let token = cache.get_token(&client, "ww_test_corp", "test_secret").await.unwrap(); + assert_eq!(token, "test_token_abc"); + + // Second call uses cache (mock expects exactly 1 call) + let token2 = cache.get_token(&client, "ww_test_corp", "test_secret").await.unwrap(); + assert_eq!(token2, "test_token_abc"); + } + + #[test] + fn split_text_lines_multi() { + let text = "line1\nline2\nline3"; + let chunks = split_text_lines(text, 11); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0], "line1\nline2"); + assert_eq!(chunks[1], "line3"); + } + + #[test] + fn split_text_lines_within_limit() { + let text = "short"; + let chunks = split_text_lines(text, 100); + assert_eq!(chunks, vec!["short"]); + } + + #[test] + fn split_text_lines_long_line() { + let text = "abcdefghij"; + let chunks = split_text_lines(text, 4); + assert_eq!(chunks, vec!["abcd", "efgh", "ij"]); + } + + #[test] + fn split_text_lines_long_line_utf8() { + let text = "你好世界測試"; // 18 bytes, 6 chars + let chunks = split_text_lines(text, 6); + assert_eq!(chunks, vec!["你好", "世界", "測試"]); + } + + #[test] + fn is_text_file_check() { + assert!(is_text_file("readme.md")); + assert!(is_text_file("config.json")); + assert!(is_text_file("data.csv")); + assert!(is_text_file("MAIN.PY")); + assert!(!is_text_file("photo.png")); + assert!(!is_text_file("archive.zip")); + assert!(!is_text_file("doc.pdf")); + } + + #[test] + fn parse_file_message() { + let xml = r#"134883186066661000002"#; + let msg = parse_message_xml(xml).unwrap(); + assert_eq!(msg.msg_type, "file"); + assert_eq!(msg.media_id, "media_abc123"); + assert_eq!(msg.file_name, "report.csv"); + } + + #[test] + fn full_webhook_decrypt_and_parse() { + let token = "testtoken"; + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + let timestamp = "1409659813"; + let nonce = "nonce123"; + + // Simulate the inner message + let inner_xml = "134883186099991000002"; + + // Encrypt it + let encrypted = encrypt_for_test(encoding_aes_key, inner_xml, corp_id); + + // Compute signature + let sig = compute_signature(token, timestamp, nonce, &encrypted); + + // Verify signature + assert!(verify_signature(token, timestamp, nonce, &encrypted, &sig)); + + // Decrypt + let decrypted = decrypt_message(encoding_aes_key, &encrypted, corp_id).unwrap(); + assert_eq!(decrypted, inner_xml); + + // Parse + let msg = parse_message_xml(&decrypted).unwrap(); + assert_eq!(msg.from_user, "user42"); + assert_eq!(msg.msg_type, "text"); + assert_eq!(msg.content, "ping"); + assert_eq!(msg.msg_id, "9999"); + } + + #[test] + fn parse_image_message() { + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + + let inner_xml = "134883186088881000002"; + + let encrypted = encrypt_for_test(encoding_aes_key, inner_xml, corp_id); + let decrypted = decrypt_message(encoding_aes_key, &encrypted, corp_id).unwrap(); + let msg = parse_message_xml(&decrypted).unwrap(); + assert_eq!(msg.msg_type, "image"); + assert_eq!(msg.pic_url, "http://example.com/pic.jpg"); + assert_eq!(msg.from_user, "user42"); + } + + #[test] + fn unsupported_msg_type_skipped() { + let xml = "134883186077771000002"; + let msg = parse_message_xml(xml).unwrap(); + assert_eq!(msg.msg_type, "voice"); + assert!(!matches!(msg.msg_type.as_str(), "text" | "image")); + } + + #[test] + fn verify_rejects_wrong_signature() { + let token = "testtoken"; + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + let echostr_plain = "test_echo"; + + let echostr_encrypted = encrypt_for_test(encoding_aes_key, echostr_plain, corp_id); + + let result = handle_verify_request( + token, + encoding_aes_key, + corp_id, + "completely_wrong_signature", + "1409659813", + "nonce123", + &echostr_encrypted, + ); + assert!(result.is_err()); + } + + #[test] + fn decrypt_with_large_padding_value() { + // Verifies decryption works when WeCom's 32-byte padding exceeds 16 + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + // Choose a message where (16 + 4 + msg_len + corp_id_len) % 32 < 16, + // producing a pad value > 16 which would fail with PKCS7/block_size=16. + // 16 + 4 + 1 + 12 = 33 → 33 % 32 = 1 → pad = 31 + let msg = "x"; + let encrypted = encrypt_for_test(encoding_aes_key, msg, corp_id); + let decrypted = decrypt_message(encoding_aes_key, &encrypted, corp_id).unwrap(); + assert_eq!(decrypted, msg); + } + + #[test] + fn decrypt_rejects_wrong_corp_id() { + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + let msg = "hello"; + + let encrypted = encrypt_for_test(encoding_aes_key, msg, corp_id); + let result = decrypt_message(encoding_aes_key, &encrypted, "ww_other_corp"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("corp_id mismatch")); + } +} diff --git a/gateway/src/main.rs b/gateway/src/main.rs index 3df4ab1a1..b7bad6666 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -1,5 +1,7 @@ mod adapters; +mod media; mod schema; +pub mod store; use anyhow::Result; use axum::{ @@ -50,6 +52,7 @@ pub struct AppState { pub feishu: Option, /// Google Chat adapter (None if Google Chat disabled) pub google_chat: Option, + pub wecom: Option, /// WebSocket authentication token pub ws_token: Option, /// Broadcast channel: gateway → OAB (events from all platforms) @@ -59,6 +62,8 @@ pub struct AppState { /// the first client to `remove()` a token wins the free Reply API call; /// other clients for the same event naturally fall back to Push API. pub reply_token_cache: ReplyTokenCache, + /// Shared HTTP client for media downloads and API calls + pub client: reqwest::Client, } // --- WebSocket handler (OAB connects here) --- @@ -108,7 +113,7 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: let client = reqwest::Client::new(); while let Some(Ok(msg)) = ws_rx.next().await { if let Message::Text(text) = msg { - match serde_json::from_str::(&*text) { + match serde_json::from_str::(&text) { Ok(reply) => { info!( platform = %reply.platform, @@ -171,6 +176,13 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: warn!("reply for googlechat but adapter not configured"); } } + "wecom" => { + if let Some(ref wecom) = state_for_recv.wecom { + wecom.handle_reply(&reply, &state_for_recv.event_tx).await; + } else { + warn!("reply for wecom but adapter not configured"); + } + } other => warn!(platform = other, "unknown reply platform"), } } @@ -314,15 +326,33 @@ async fn main() -> Result<()> { None }; + // WeCom adapter + let wecom = adapters::wecom::WecomConfig::from_env().map(|config| { + let path = config.webhook_path.clone(); + info!(path = %path, "wecom adapter enabled"); + adapters::wecom::WecomAdapter::new(config) + }); + if let Some(ref w) = wecom { + app = app + .route(&w.config.webhook_path, axum::routing::get(adapters::wecom::verify)) + .route(&w.config.webhook_path, post(adapters::wecom::webhook)); + } + if telegram_bot_token.is_none() && line_access_token.is_none() && teams.is_none() && feishu.is_none() && google_chat.is_none() + && wecom.is_none() { - warn!("no adapters configured — set TELEGRAM_BOT_TOKEN, LINE_CHANNEL_ACCESS_TOKEN, TEAMS_APP_ID + TEAMS_APP_SECRET, FEISHU_APP_ID + FEISHU_APP_SECRET, and/or GOOGLE_CHAT_ENABLED=true"); + warn!("no adapters configured — set TELEGRAM_BOT_TOKEN, LINE_CHANNEL_ACCESS_TOKEN, TEAMS_APP_ID + TEAMS_APP_SECRET, FEISHU_APP_ID + FEISHU_APP_SECRET, GOOGLE_CHAT_ENABLED=true, and/or WECOM_CORP_ID + WECOM_SECRET + WECOM_TOKEN + WECOM_ENCODING_AES_KEY + WECOM_AGENT_ID"); } + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("HTTP client must build"); + let state = Arc::new(AppState { telegram_bot_token, telegram_secret_token, @@ -332,9 +362,11 @@ async fn main() -> Result<()> { teams_service_urls: Mutex::new(HashMap::new()), feishu, google_chat, + wecom, ws_token, event_tx, reply_token_cache, + client, }); // Background task: sweep expired reply tokens every REPLY_TOKEN_TTL_SECS @@ -384,6 +416,9 @@ async fn main() -> Result<()> { let app = app.with_state(state.clone()); + // Background task: evict expired media files (colocate store, TTL 2 min) + tokio::spawn(store::eviction_loop()); + // Spawn feishu WebSocket long-connection if configured // feishu_shutdown_tx must remain alive for the lifetime of main() — dropping // it signals shutdown to the WS task via feishu_shutdown_rx. @@ -427,6 +462,7 @@ mod tests { }, command: None, request_id: None, + quote_message_id: None, } } diff --git a/gateway/src/media.rs b/gateway/src/media.rs new file mode 100644 index 000000000..52613e79d --- /dev/null +++ b/gateway/src/media.rs @@ -0,0 +1,124 @@ +#![allow(dead_code)] +use image::ImageReader; +use std::io::Cursor; + +/// Media type for download functions — avoids stringly-typed branching. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaKind { + Image, + Audio, +} + +pub const IMAGE_MAX_DIMENSION_PX: u32 = 1200; +pub const IMAGE_JPEG_QUALITY: u8 = 75; +pub const IMAGE_MAX_DOWNLOAD: u64 = 10 * 1024 * 1024; // 10 MB +pub const FILE_MAX_DOWNLOAD: u64 = 20 * 1024 * 1024; // 20 MB (same as store cap) +pub const AUDIO_MAX_DOWNLOAD: u64 = 20 * 1024 * 1024; // 20 MB +pub const GIF_MAX_SIZE: usize = 5 * 1024 * 1024; // 5 MB — prevents base64 bloat exceeding LLM payload limits + +/// Resize image so longest side <= 1200px, then encode as JPEG. +/// GIFs under 5MB are passed through unchanged to preserve animation. +pub fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { + let reader = ImageReader::new(Cursor::new(raw)).with_guessed_format()?; + let format = reader.format(); + if format == Some(image::ImageFormat::Gif) { + if raw.len() > GIF_MAX_SIZE { + return Err(image::ImageError::Limits( + image::error::LimitError::from_kind(image::error::LimitErrorKind::DimensionError), + )); + } + return Ok((raw.to_vec(), "image/gif".to_string())); + } + let img = reader.decode()?; + let (w, h) = (img.width(), img.height()); + let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX { + let max_side = std::cmp::max(w, h); + let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side); + let new_w = (f64::from(w) * ratio) as u32; + let new_h = (f64::from(h) * ratio) as u32; + img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3) + } else { + img + }; + let mut buf = Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY); + img.write_with_encoder(encoder)?; + Ok((buf.into_inner(), "image/jpeg".to_string())) +} + +/// Derive file extension from Content-Type for audio files. +pub fn audio_extension(content_type: &str) -> &'static str { + if content_type.contains("mpeg") || content_type.contains("mp3") { + "mp3" + } else if content_type.contains("m4a") || content_type.contains("mp4") { + "m4a" + } else { + "ogg" + } +} + +/// Check if a filename has a text-like extension suitable for reading as UTF-8. +pub fn is_text_extension(filename: &str) -> bool { + const TEXT_EXTS: &[&str] = &[ + "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", "rs", "py", + "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh", "bash", + "sql", "html", "css", "ini", "cfg", "conf", + ]; + let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); + TEXT_EXTS.contains(&ext.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gif_under_limit_passes_through() { + let gif = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9\x04\x00\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"; + let result = resize_and_compress(gif); + assert!(result.is_ok()); + let (data, mime) = result.unwrap(); + assert_eq!(mime, "image/gif"); + assert_eq!(data, gif); + } + + #[test] + fn gif_over_limit_returns_error() { + let mut data = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00".to_vec(); + data.resize(GIF_MAX_SIZE + 1, 0); + let result = resize_and_compress(&data); + assert!(result.is_err()); + } + + #[test] + fn small_jpeg_not_resized() { + let img = image::RgbImage::from_pixel(2, 2, image::Rgb([255, 0, 0])); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap(); + let result = resize_and_compress(&buf.into_inner()); + assert!(result.is_ok()); + assert_eq!(result.unwrap().1, "image/jpeg"); + } + + #[test] + fn large_image_gets_resized() { + let img = image::RgbImage::from_pixel(2000, 2000, image::Rgb([0, 128, 255])); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); + let result = resize_and_compress(&buf.into_inner()); + assert!(result.is_ok()); + let (data, mime) = result.unwrap(); + assert_eq!(mime, "image/jpeg"); + let decoded = image::load_from_memory(&data).unwrap(); + assert!(decoded.width() <= IMAGE_MAX_DIMENSION_PX); + assert!(decoded.height() <= IMAGE_MAX_DIMENSION_PX); + } + + #[test] + fn text_extension_check() { + assert!(is_text_extension("main.rs")); + assert!(is_text_extension("data.csv")); + assert!(!is_text_extension("archive.zip")); + assert!(!is_text_extension("photo.jpg")); + } +} diff --git a/gateway/src/schema.rs b/gateway/src/schema.rs index a38554df4..740d0fab8 100644 --- a/gateway/src/schema.rs +++ b/gateway/src/schema.rs @@ -32,7 +32,7 @@ pub struct SenderInfo { pub is_bot: bool, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Content { #[serde(rename = "type")] pub content_type: String, @@ -41,14 +41,22 @@ pub struct Content { pub attachments: Vec, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Attachment { #[serde(rename = "type")] - pub attachment_type: String, // "image", "text_file" + pub attachment_type: String, // "image", "text_file", "audio" pub filename: String, pub mime_type: String, - pub data: String, // base64 encoded - pub size: u64, // size in bytes (after compression for images) + /// Base64-encoded data (deprecated — use `path` for colocate mode). + /// Kept for backward compatibility; Core prefers `path` when present. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub data: String, + pub size: u64, // size in bytes (after compression for images) + /// Local file path for colocate mode (gateway + core share filesystem). + /// When set, Core reads bytes directly from this path instead of decoding `data`. + /// Path format: ~/.openab/media/inbound/ (no extension, MIME in mime_type). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, } // --- Reply schema (ADR openab.gateway.reply.v1) --- @@ -64,6 +72,12 @@ pub struct GatewayReply { pub command: Option, #[serde(default)] pub request_id: Option, + /// When set, send this message as a reply/quote to the specified platform message ID. + /// Unlike `reply_to` (which identifies the triggering event for routing/dedup), + /// this field controls the visual reply/quote UI on the platform. + /// If quoting fails, the gateway MUST fall back to sending without quoting. + #[serde(default)] + pub quote_message_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/gateway/src/store.rs b/gateway/src/store.rs new file mode 100644 index 000000000..b08e69903 --- /dev/null +++ b/gateway/src/store.rs @@ -0,0 +1,132 @@ +use std::path::{Path, PathBuf}; +use tokio::fs; +use tracing::{error, info}; +use uuid::Uuid; + +/// Inbound media directory under $HOME. +/// Pattern follows OpenClaw's `~/.openclaw/media/inbound/`. +/// +/// # Security Considerations +/// +/// - **Path traversal prevention**: Filenames are always server-generated UUIDs, +/// never user-supplied. No extension, no special characters — eliminates path +/// traversal attacks (e.g. `../../etc/passwd`). +/// +/// - **No auth token leakage**: Platform media URLs (Telegram getFile, LINE Content API) +/// contain bot tokens or require auth headers. By downloading in the gateway and +/// storing locally, tokens never reach Core or the agent. +/// +/// - **TTL auto-eviction**: Files are evicted after 2 minutes. Prevents disk exhaustion +/// from accumulated media and limits the window for any leaked file to be exploited. +/// +/// - **Colocate trust boundary**: This module assumes gateway and core share the same +/// filesystem (same pod / same $HOME). The file path is passed over the internal WS +/// connection — never exposed externally. If gateway and core are separated in the +/// future, switch to HTTP media proxy with internal-only binding. +/// +/// - **Size limits enforced before write**: Callers must validate file size against +/// IMAGE_MAX_DOWNLOAD / AUDIO_MAX_DOWNLOAD / FILE_MAX_DOWNLOAD before calling +/// `store_media()`. This module does NOT re-validate — it trusts the caller. +/// +/// - **No executable content**: Stored files are raw bytes (images, audio, text). +/// Core reads them as data only — never executed. The `mime_type` in the event +/// payload determines processing path, not the file content or name. +const MEDIA_INBOUND_DIR: &str = ".openab/media/inbound"; + +/// TTL for stored media files (2 minutes) +const TTL_SECS: u64 = 120; + +/// Get the inbound media directory path, creating it if needed. +pub async fn media_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + let dir = Path::new(&home).join(MEDIA_INBOUND_DIR); + if !dir.exists() { + let _ = fs::create_dir_all(&dir).await; + } + dir +} + +/// Maximum file size accepted by store (defense-in-depth, callers should pre-check). +const MAX_STORE_SIZE: usize = 20 * 1024 * 1024; // 20 MB (matches AUDIO_MAX_DOWNLOAD) + +/// Store media bytes to disk, return the absolute file path. +/// Filename is UUID only (no extension) — MIME type is carried in the event payload. +/// Rejects files exceeding MAX_STORE_SIZE as a defense-in-depth measure. +pub async fn store_media(bytes: &[u8]) -> Option { + if bytes.len() > MAX_STORE_SIZE { + error!(size = bytes.len(), max = MAX_STORE_SIZE, "store_media rejected: exceeds size limit"); + return None; + } + let dir = media_dir().await; + let filename = Uuid::new_v4().to_string(); + let path = dir.join(&filename); + match fs::write(&path, bytes).await { + Ok(_) => { + info!(path = %path.display(), size = bytes.len(), "media stored"); + Some(path.to_string_lossy().into_owned()) + } + Err(e) => { + error!(error = %e, "failed to store media file"); + None + } + } +} + +/// Background task: evict files older than TTL_SECS. +pub async fn eviction_loop() { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + interval.tick().await; + if let Err(e) = evict_expired().await { + error!(error = %e, "media eviction error"); + } + } +} + +async fn evict_expired() -> std::io::Result<()> { + let dir = media_dir().await; + if !dir.exists() { + return Ok(()); + } + let mut entries = fs::read_dir(&dir).await?; + let now = std::time::SystemTime::now(); + while let Some(entry) = entries.next_entry().await? { + if let Ok(meta) = entry.metadata().await { + if let Ok(modified) = meta.modified() { + if let Ok(age) = now.duration_since(modified) { + if age.as_secs() > TTL_SECS { + let path = entry.path(); + let _ = fs::remove_file(&path).await; + tracing::debug!(path = %path.display(), "evicted expired media"); + } + } + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn store_and_read_back() { + let data = b"hello media"; + let path = store_media(data).await.unwrap(); + let read_back = fs::read(&path).await.unwrap(); + assert_eq!(read_back, data); + // Cleanup + let _ = fs::remove_file(&path).await; + } + + #[tokio::test] + async fn filename_is_uuid_no_extension() { + let path = store_media(b"test").await.unwrap(); + let filename = Path::new(&path).file_name().unwrap().to_str().unwrap(); + // UUID v4 format: 8-4-4-4-12 hex chars + assert_eq!(filename.len(), 36); + assert!(!filename.contains('.')); + let _ = fs::remove_file(&path).await; + } +} diff --git a/openab-agent/.gitignore b/openab-agent/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/openab-agent/.gitignore @@ -0,0 +1 @@ +/target diff --git a/openab-agent/Cargo.lock b/openab-agent/Cargo.lock new file mode 100644 index 000000000..5f878017f --- /dev/null +++ b/openab-agent/Cargo.lock @@ -0,0 +1,1960 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openab-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "libc", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/openab-agent/Cargo.toml b/openab-agent/Cargo.toml new file mode 100644 index 000000000..f059cfc6a --- /dev/null +++ b/openab-agent/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "openab-agent" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Native Rust coding agent with built-in ACP support" + +[dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +anyhow = "1" +uuid = { version = "1", features = ["v4"] } +clap = { version = "4", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +base64 = "0.22.1" +sha2 = "0.11.0" +getrandom = "0.4.2" +urlencoding = "2.1.3" +open = "5.3.5" +url = "2.5.8" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/openab-agent/src/acp.rs b/openab-agent/src/acp.rs new file mode 100644 index 000000000..38054f25d --- /dev/null +++ b/openab-agent/src/acp.rs @@ -0,0 +1,289 @@ +use crate::agent::Agent; +use crate::llm::AnthropicProvider; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::{self, BufRead, Write}; +use tokio::sync::mpsc; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct JsonRpcRequest { + pub id: Option, + pub method: Option, + pub params: Option, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcResponse { + pub jsonrpc: &'static str, + pub id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcNotification { + pub jsonrpc: &'static str, + pub method: String, + pub params: Value, +} + +pub struct AcpServer { + // TODO(v0.2): add session TTL and periodic cleanup to prevent OOM + sessions: HashMap, + working_dir: String, +} + +impl AcpServer { + pub fn new() -> Self { + Self { + sessions: HashMap::new(), + working_dir: std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "/tmp".to_string()), + } + } + + pub async fn run(&mut self) { + let (tx, mut rx) = mpsc::unbounded_channel::(); + + std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + #[allow(clippy::collapsible_match)] + match line { + Ok(l) if !l.trim().is_empty() => { + if tx.send(l).is_err() { + break; + } + } + Err(_) => break, + _ => {} + } + } + }); + + let mut stdout = io::stdout(); + + while let Some(line) = rx.recv().await { + let req: JsonRpcRequest = match serde_json::from_str(&line) { + Ok(r) => r, + Err(_) => continue, + }; + let id = match req.id { + Some(id) => id, + None => continue, + }; + + let output = match req.method.as_deref() { + Some("initialize") => vec![self.handle_initialize(id)], + Some("session/new") => vec![self.handle_session_new(id)], + Some("session/prompt") => { + let params = req.params.unwrap_or(json!({})); + self.handle_session_prompt(id, ¶ms).await + } + Some("session/cancel") => { + // TODO(v0.2): implement cancellation token to abort in-progress agent.run() + vec![self.ok_response(id, json!({}))] + } + Some(method) => { + vec![self.error_response(id, -32601, &format!("method not found: {method}"))] + } + None => continue, + }; + + for line in output { + let _ = writeln!(stdout, "{}", line); + } + let _ = stdout.flush(); + } + } + + fn handle_initialize(&self, id: u64) -> String { + let resp = JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(json!({ + "protocolVersion": 1, + "agentInfo": { + "name": "openab-agent", + "version": env!("CARGO_PKG_VERSION") + }, + "agentCapabilities": { + "streaming": false, + "loadSession": false + } + })), + error: None, + }; + serde_json::to_string(&resp).unwrap() + } + + fn handle_session_new(&mut self, id: u64) -> String { + let session_id = Uuid::new_v4().to_string(); + + // Respect OPENAB_AGENT_PROVIDER if set, otherwise auto-detect + let provider_choice = std::env::var("OPENAB_AGENT_PROVIDER").unwrap_or_default(); + let provider: Box = match provider_choice.as_str() { + "anthropic" => match AnthropicProvider::from_env() { + Ok(p) => Box::new(p), + Err(e) => return self.error_response(id, -32000, &e), + }, + "openai" | "codex" => match crate::llm::OpenAiProvider::from_auth_store() { + Ok(p) => Box::new(p), + Err(e) => return self.error_response(id, -32000, &e), + }, + _ => { + // Auto-detect: try API key first, then OAuth token + match AnthropicProvider::from_env() { + Ok(p) => Box::new(p), + Err(_) => match crate::llm::OpenAiProvider::from_auth_store() { + Ok(p) => Box::new(p), + Err(e) => { + return self.error_response( + id, + -32000, + &format!("No credentials: set ANTHROPIC_API_KEY or run `openab-agent auth codex-oauth`. {e}"), + ) + } + }, + } + } + }; + + let agent = Agent::new_boxed(provider, self.working_dir.clone()); + self.sessions.insert(session_id.clone(), agent); + let resp = JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(json!({ "sessionId": session_id })), + error: None, + }; + serde_json::to_string(&resp).unwrap() + } + + async fn handle_session_prompt(&mut self, id: u64, params: &Value) -> Vec { + let session_id = params + .get("sessionId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let prompt_text = params + .get("prompt") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + + if prompt_text.trim().is_empty() { + return vec![self.error_response(id, -32602, "prompt is empty")]; + } + + let agent = match self.sessions.get_mut(session_id) { + Some(a) => a, + None => { + return vec![self.error_response(id, -32600, "unknown session")]; + } + }; + + let mut output_lines = Vec::new(); + let session_id_owned = session_id.to_string(); + + match agent.run(&prompt_text).await { + Ok(response_text) => { + let notification = serde_json::to_string(&JsonRpcNotification { + jsonrpc: "2.0", + method: "session/update".to_string(), + params: json!({ + "sessionId": session_id_owned, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": response_text } + } + }), + }) + .unwrap(); + output_lines.push(notification); + output_lines.push(self.ok_response(id, json!({ "stopReason": "end_turn" }))); + } + Err(e) => { + output_lines.push(self.error_response(id, -32000, &format!("agent error: {e}"))); + } + } + + output_lines + } + + fn ok_response(&self, id: u64, result: Value) -> String { + serde_json::to_string(&JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + }) + .unwrap() + } + + fn error_response(&self, id: u64, code: i64, message: &str) -> String { + serde_json::to_string(&JsonRpcResponse { + jsonrpc: "2.0", + id, + result: None, + error: Some(json!({ "code": code, "message": message })), + }) + .unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_initialize_response() { + let server = AcpServer::new(); + let resp_str = server.handle_initialize(1); + let resp: Value = serde_json::from_str(&resp_str).unwrap(); + assert_eq!(resp["jsonrpc"], "2.0"); + assert_eq!(resp["id"], 1); + assert_eq!(resp["result"]["agentInfo"]["name"], "openab-agent"); + assert_eq!(resp["result"]["agentCapabilities"]["streaming"], false); + } + + #[test] + fn test_session_new() { + // Set a fake key so from_env() succeeds in CI + unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") }; + let mut server = AcpServer::new(); + let resp_str = server.handle_session_new(2); + let resp: Value = serde_json::from_str(&resp_str).unwrap(); + assert_eq!(resp["jsonrpc"], "2.0"); + assert_eq!(resp["id"], 2); + assert!(resp["result"]["sessionId"].as_str().unwrap().len() > 0); + } + + #[test] + fn test_session_new_missing_key() { + // Ensure no OAuth token exists either + let auth_path = + std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string())) + .join(".openab/agent/auth.json"); + let _ = std::fs::remove_file(&auth_path); + unsafe { std::env::remove_var("ANTHROPIC_API_KEY") }; + let mut server = AcpServer::new(); + let resp_str = server.handle_session_new(3); + let resp: Value = serde_json::from_str(&resp_str).unwrap(); + assert!(resp["error"].is_object()); + assert!(resp["error"]["message"] + .as_str() + .unwrap() + .contains("ANTHROPIC_API_KEY")); + } +} diff --git a/openab-agent/src/agent.rs b/openab-agent/src/agent.rs new file mode 100644 index 000000000..01ec99f44 --- /dev/null +++ b/openab-agent/src/agent.rs @@ -0,0 +1,334 @@ +use anyhow::Result; +use std::path::PathBuf; +use tracing::{debug, info}; + +use crate::llm::{ContentBlock, LlmEvent, LlmProvider, Message, ToolDef}; +use crate::tools; + +const SYSTEM_PROMPT: &str = r#"You are openab-agent, a coding assistant. You help users by reading, writing, and editing files, and running shell commands. + +You have 4 tools available: +- read: Read file contents or list a directory +- write: Create or overwrite a file +- edit: Replace a string in a file (first occurrence) +- bash: Execute a shell command + +Be direct and concise. Execute tasks immediately rather than explaining what you would do. When you need to understand code, read the relevant files first."#; + +const MAX_TOOL_LOOPS: usize = 50; +/// Maximum number of messages to keep in context. When exceeded, oldest +/// messages (excluding the first user message) are dropped. +const MAX_CONTEXT_MESSAGES: usize = 100; + +pub struct Agent { + provider: Box, + messages: Vec, + working_dir: PathBuf, + system_prompt: String, + tools: Vec, +} + +impl Agent { + #[cfg(test)] + pub fn new(provider: impl LlmProvider + 'static, working_dir: String) -> Self { + let system_prompt = Self::build_system_prompt(&working_dir); + Self { + provider: Box::new(provider), + messages: Vec::new(), + working_dir: PathBuf::from(working_dir), + system_prompt, + tools: tools::tool_definitions(), + } + } + + pub fn new_boxed(provider: Box, working_dir: String) -> Self { + let system_prompt = Self::build_system_prompt(&working_dir); + Self { + provider, + messages: Vec::new(), + working_dir: PathBuf::from(working_dir), + system_prompt, + tools: tools::tool_definitions(), + } + } + + /// Run the agent with a user prompt, executing tool calls until completion. + /// Returns the final text response. + fn build_system_prompt(working_dir: &str) -> String { + let agents_md = std::path::Path::new(working_dir).join("AGENTS.md"); + let custom = std::fs::read_to_string(&agents_md).unwrap_or_default(); + if custom.is_empty() { + SYSTEM_PROMPT.to_string() + } else { + format!( + "{} + +--- + +{}", + custom.trim(), + SYSTEM_PROMPT + ) + } + } + + pub async fn run(&mut self, prompt: &str) -> Result { + // Add user message + self.messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: prompt.to_string(), + }], + }); + + let mut final_text = String::new(); + + for iteration in 0..MAX_TOOL_LOOPS { + debug!("agent loop iteration {iteration}"); + + // Truncate context to prevent unbounded growth / token limit + self.truncate_context(); + + let events = self.call_llm().await?; + + let mut tool_calls = Vec::new(); + let mut text_parts = Vec::new(); + + for event in &events { + match event { + LlmEvent::Text(t) => text_parts.push(t.clone()), + LlmEvent::ToolUse { id, name, input } => { + tool_calls.push((id.clone(), name.clone(), input.clone())); + } + LlmEvent::Stop => {} + LlmEvent::Error(e) => { + return Err(anyhow::anyhow!("LLM error: {e}")); + } + } + } + + // Build assistant message content + let mut assistant_content: Vec = Vec::new(); + if !text_parts.is_empty() { + assistant_content.push(ContentBlock::Text { + text: text_parts.join(""), + }); + } + for (id, name, input) in &tool_calls { + assistant_content.push(ContentBlock::ToolUse { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }); + } + + self.messages.push(Message { + role: "assistant".to_string(), + content: assistant_content, + }); + + if tool_calls.is_empty() || !text_parts.is_empty() { + // No tool calls — we're done + final_text = text_parts.join(""); + break; + } + + // Execute tool calls and add results + let mut tool_results: Vec = Vec::new(); + for (id, name, input) in &tool_calls { + info!("executing tool: {name}"); + let result = tools::execute_tool(name, input, &self.working_dir).await; + match result { + Ok(output) => { + tool_results.push(ContentBlock::ToolResult { + tool_use_id: id.clone(), + content: output, + is_error: None, + }); + } + Err(e) => { + tool_results.push(ContentBlock::ToolResult { + tool_use_id: id.clone(), + content: format!("Error: {e}"), + is_error: Some(true), + }); + } + } + } + + self.messages.push(Message { + role: "user".to_string(), + content: tool_results, + }); + } + + if final_text.is_empty() { + return Err(anyhow::anyhow!( + "agent exceeded maximum tool loop iterations ({MAX_TOOL_LOOPS})" + )); + } + + Ok(final_text) + } + + /// Drop oldest message pairs when context exceeds limit, preserving the + /// first user message and maintaining strict user/assistant alternation. + fn truncate_context(&mut self) { + while self.messages.len() > MAX_CONTEXT_MESSAGES { + // Drain in pairs (assistant + user) from index 1 to maintain alternation + let end = (1 + 2).min(self.messages.len()); + self.messages.drain(1..end); + } + } + + async fn call_llm(&self) -> Result> { + self.provider + .chat(&self.system_prompt, &self.messages, &self.tools) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + /// Hand-written mock LLM provider for unit testing. + struct MockLlmProvider { + responses: Vec>, + call_count: Arc, + } + + impl MockLlmProvider { + fn new(responses: Vec>) -> Self { + Self { + responses, + call_count: Arc::new(AtomicUsize::new(0)), + } + } + } + + impl LlmProvider for MockLlmProvider { + fn chat<'a>( + &'a self, + _system: &'a str, + _messages: &'a [Message], + _tools: &'a [ToolDef], + ) -> std::pin::Pin>> + Send + 'a>> + { + let idx = self.call_count.fetch_add(1, Ordering::SeqCst); + let events = self.responses[idx].clone(); + Box::pin(async move { Ok(events) }) + } + } + + #[tokio::test] + async fn test_agent_simple_text_response() { + let mock = MockLlmProvider::new(vec![vec![ + LlmEvent::Text("Hello!".to_string()), + LlmEvent::Stop, + ]]); + + let tmp = tempfile::TempDir::new().unwrap(); + let mut agent = Agent::new(mock, tmp.path().to_string_lossy().to_string()); + let result = agent.run("hi").await.unwrap(); + assert_eq!(result, "Hello!"); + } + + #[tokio::test] + #[ignore] // Integration test: executes real file tools + async fn test_agent_tool_call_then_response() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("test.txt"), "file content here").unwrap(); + + let mock = MockLlmProvider::new(vec![ + // First call: LLM requests to read a file + vec![LlmEvent::ToolUse { + id: "tu_1".to_string(), + name: "read".to_string(), + input: serde_json::json!({ "path": "test.txt" }), + }], + // Second call: LLM responds with text + vec![ + LlmEvent::Text("The file contains: file content here".to_string()), + LlmEvent::Stop, + ], + ]); + + let mut agent = Agent::new(mock, tmp.path().to_string_lossy().to_string()); + let result = agent.run("read test.txt").await.unwrap(); + assert_eq!(result, "The file contains: file content here"); + } + + #[tokio::test] + #[ignore] // Integration test: executes real file tools + async fn test_agent_tool_error_handling() { + let tmp = tempfile::TempDir::new().unwrap(); + + let mock = MockLlmProvider::new(vec![ + // First call: LLM requests to read a non-existent file + vec![LlmEvent::ToolUse { + id: "tu_1".to_string(), + name: "read".to_string(), + input: serde_json::json!({ "path": "nonexistent.txt" }), + }], + // Second call: LLM acknowledges the error + vec![ + LlmEvent::Text("File not found.".to_string()), + LlmEvent::Stop, + ], + ]); + + let mut agent = Agent::new(mock, tmp.path().to_string_lossy().to_string()); + let result = agent.run("read nonexistent.txt").await.unwrap(); + assert_eq!(result, "File not found."); + + // Verify the tool result was marked as error + assert_eq!(agent.messages.len(), 4); // user, assistant(tool_use), user(tool_result), assistant(text) + let tool_result_msg = &agent.messages[2]; + match &tool_result_msg.content[0] { + ContentBlock::ToolResult { is_error, .. } => { + assert_eq!(*is_error, Some(true)); + } + _ => panic!("expected ToolResult"), + } + } + + #[tokio::test] + #[ignore] // Integration test: executes real file tools + async fn test_agent_multiple_tool_calls() { + let tmp = tempfile::TempDir::new().unwrap(); + + let mock = MockLlmProvider::new(vec![ + // First call: write a file + vec![LlmEvent::ToolUse { + id: "tu_1".to_string(), + name: "write".to_string(), + input: serde_json::json!({ "path": "out.txt", "content": "hello" }), + }], + // Second call: read it back + vec![LlmEvent::ToolUse { + id: "tu_2".to_string(), + name: "read".to_string(), + input: serde_json::json!({ "path": "out.txt" }), + }], + // Third call: done + vec![ + LlmEvent::Text("Done. File contains: hello".to_string()), + LlmEvent::Stop, + ], + ]); + + let mut agent = Agent::new(mock, tmp.path().to_string_lossy().to_string()); + let result = agent + .run("write hello to out.txt then read it") + .await + .unwrap(); + assert_eq!(result, "Done. File contains: hello"); + + // Verify file was actually written + let content = std::fs::read_to_string(tmp.path().join("out.txt")).unwrap(); + assert_eq!(content, "hello"); + } +} diff --git a/openab-agent/src/auth.rs b/openab-agent/src/auth.rs new file mode 100644 index 000000000..385ccede9 --- /dev/null +++ b/openab-agent/src/auth.rs @@ -0,0 +1,538 @@ +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::io::{BufRead, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +const REFRESH_SKEW_SECONDS: u64 = 120; + +const CODEX_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize"; +const CODEX_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; +const CODEX_DEVICE_AUTH_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode"; +const CODEX_DEVICE_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token"; +const CODEX_DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback"; +const REDIRECT_PORT: u16 = 1455; + +fn codex_client_id() -> String { + std::env::var("OPENAB_AGENT_OAUTH_CLIENT_ID") + .unwrap_or_else(|_| "app_EMoamEEZ73f0CkXaXp7hrann".to_string()) +} + +fn redirect_uri() -> String { + format!("http://localhost:{REDIRECT_PORT}/auth/callback") +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenStore { + pub access_token: String, + pub refresh_token: String, + pub expires_at: u64, + pub token_endpoint: String, + pub provider: String, +} + +fn auth_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home) + .join(".openab") + .join("agent") + .join("auth.json") +} + +pub fn load_tokens() -> Result { + let path = auth_path(); + let data = std::fs::read_to_string(&path).map_err(|_| { + anyhow!( + "No credentials found at {}. Run `openab-agent auth codex-oauth` first.", + path.display() + ) + })?; + serde_json::from_str(&data).map_err(|e| anyhow!("Invalid auth.json: {e}")) +} + +fn save_tokens(store: &TokenStore) -> Result<()> { + let path = auth_path(); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let data = serde_json::to_string_pretty(store)?; + #[cfg(unix)] + { + use std::fs::OpenOptions; + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt; + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path)?; + file.write_all(data.as_bytes())?; + } + #[cfg(not(unix))] + { + std::fs::write(&path, &data)?; + } + Ok(()) +} + +fn is_expired(store: &TokenStore) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now + REFRESH_SKEW_SECONDS >= store.expires_at +} + +pub async fn get_valid_token() -> Result { + let mut store = load_tokens()?; + if is_expired(&store) { + store = refresh_token(&store).await?; + save_tokens(&store)?; + } + Ok(store.access_token) +} + +pub async fn force_refresh() -> Result { + let store = load_tokens()?; + let new_store = refresh_token(&store).await?; + save_tokens(&new_store)?; + Ok(new_store.access_token) +} + +async fn refresh_token(store: &TokenStore) -> Result { + let client_id = codex_client_id(); + let client = reqwest::Client::new(); + let resp = client + .post(&store.token_endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", store.refresh_token.as_str()), + ("client_id", client_id.as_str()), + ]) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token refresh failed (HTTP {status}): {body}. Run `openab-agent auth codex-oauth` again.")); + } + let payload: serde_json::Value = resp.json().await?; + let access_token = payload["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token in refresh response"))?; + let new_refresh = payload["refresh_token"] + .as_str() + .unwrap_or(&store.refresh_token); + let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + Ok(TokenStore { + access_token: access_token.to_string(), + refresh_token: new_refresh.to_string(), + expires_at: now + expires_in, + token_endpoint: store.token_endpoint.clone(), + provider: store.provider.clone(), + }) +} + +fn generate_pkce() -> (String, String) { + let mut buf = [0u8; 32]; + getrandom::fill(&mut buf).expect("getrandom failed"); + let verifier = URL_SAFE_NO_PAD.encode(buf); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + (verifier, challenge) +} + +// Browser PKCE flow +pub async fn login_browser_flow(no_browser: bool) -> Result<()> { + let client_id = codex_client_id(); + let (code_verifier, code_challenge) = generate_pkce(); + let mut state_buf = [0u8; 16]; + getrandom::fill(&mut state_buf).expect("getrandom failed"); + let state = URL_SAFE_NO_PAD.encode(state_buf); + let redir_str = redirect_uri(); + let redir = urlencoding::encode(&redir_str); + let auth_url = format!("{CODEX_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={redir}&response_type=code&scope=openid+profile+email+offline_access&code_challenge={code_challenge}&code_challenge_method=S256&state={state}&id_token_add_organizations=true&codex_cli_simplified_flow=true&originator=openab-agent"); + + let listener = TcpListener::bind(format!("127.0.0.1:{REDIRECT_PORT}")).map_err(|e| { + anyhow!("Failed to bind port {REDIRECT_PORT}: {e}. Is another instance running?") + })?; + + if no_browser { + println!("Open this URL in your browser:\n"); + println!(" {auth_url}\n"); + println!("After approving, your browser will redirect to a localhost URL."); + println!("Copy the full URL from the browser address bar and paste it here:\n"); + + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(|e| anyhow!("Failed to read input: {e}"))?; + let input = input.trim(); + if input.is_empty() { + return Err(anyhow!("No URL provided")); + } + let url = url::Url::parse(input).map_err(|_| anyhow!("Invalid URL: {input}"))?; + + // Skip TCP listener for paste flow + let code = url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| { + let error = url + .query_pairs() + .find(|(k, _)| k == "error") + .map(|(_, v)| v.to_string()); + anyhow!( + "No code in URL. Error: {}", + error.unwrap_or_else(|| "unknown".into()) + ) + })?; + let cb_state = url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()); + if cb_state.as_deref() != Some(&state) { + return Err(anyhow!("State mismatch")); + } + + // Exchange code for tokens + let client = reqwest::Client::new(); + let resp = client + .post(CODEX_TOKEN_URL) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", client_id.as_str()), + ("code", code.as_str()), + ("code_verifier", code_verifier.as_str()), + ("redirect_uri", redirect_uri().as_str()), + ]) + .send() + .await?; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token exchange failed: {body}")); + } + let payload: serde_json::Value = resp.json().await?; + let access_token = payload["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token"))?; + let refresh_token_val = payload["refresh_token"] + .as_str() + .ok_or_else(|| anyhow!("No refresh_token"))?; + let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let store = TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token_val.to_string(), + expires_at: now + expires_in, + token_endpoint: CODEX_TOKEN_URL.to_string(), + provider: "codex".to_string(), + }; + save_tokens(&store)?; + println!( + "\n\u{2705} Login successful! Token saved to {:?}", + auth_path() + ); + return Ok(()); + } else { + println!("Opening browser for authentication...\n"); + if open::that(&auth_url).is_err() { + println!("Could not open browser. Open this URL manually:\n"); + println!(" {auth_url}\n"); + } + println!("Waiting for callback..."); + } + + listener.set_nonblocking(false)?; + let (mut stream, _) = listener + .accept() + .map_err(|e| anyhow!("Failed to accept callback: {e}"))?; + let mut reader = std::io::BufReader::new(&stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line)?; + + let path = request_line.split_whitespace().nth(1).unwrap_or(""); + let url = url::Url::parse(&format!("http://localhost{path}")) + .map_err(|_| anyhow!("Invalid callback URL"))?; + let code = url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| { + let error = url + .query_pairs() + .find(|(k, _)| k == "error") + .map(|(_, v)| v.to_string()); + anyhow!( + "No code in callback. Error: {}", + error.unwrap_or_else(|| "unknown".into()) + ) + })?; + let cb_state = url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()); + if cb_state.as_deref() != Some(&state) { + return Err(anyhow!("State mismatch in callback")); + } + + let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n

Authentication successful!

You can close this tab.

"; + let _ = stream.write_all(response.as_bytes()); + + let client = reqwest::Client::new(); + let resp = client + .post(CODEX_TOKEN_URL) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", client_id.as_str()), + ("code", code.as_str()), + ("code_verifier", code_verifier.as_str()), + ("redirect_uri", redirect_uri().as_str()), + ]) + .send() + .await?; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token exchange failed: {body}")); + } + let payload: serde_json::Value = resp.json().await?; + let access_token = payload["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token"))?; + let refresh_token_val = payload["refresh_token"] + .as_str() + .ok_or_else(|| anyhow!("No refresh_token"))?; + let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let store = TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token_val.to_string(), + expires_at: now + expires_in, + token_endpoint: CODEX_TOKEN_URL.to_string(), + provider: "codex".to_string(), + }; + save_tokens(&store)?; + println!( + "\n\u{2705} Login successful! Token saved to {:?}", + auth_path() + ); + Ok(()) +} + +// Device code flow +pub async fn login_codex_device_flow() -> Result<()> { + println!("Starting OpenAI Codex device-code login...\n"); + let client = reqwest::Client::new(); + let client_id = codex_client_id(); + + let resp = client + .post(CODEX_DEVICE_AUTH_URL) + .header("Content-Type", "application/json") + .json(&serde_json::json!({"client_id": client_id})) + .send() + .await?; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Device authorization request failed: {body}")); + } + let device_resp: serde_json::Value = resp.json().await?; + let device_auth_id = device_resp["device_auth_id"] + .as_str() + .ok_or_else(|| anyhow!("No device_auth_id"))?; + let user_code = device_resp["user_code"] + .as_str() + .ok_or_else(|| anyhow!("No user_code"))?; + let interval = device_resp["interval"] + .as_str() + .and_then(|s| s.parse::().ok()) + .or_else(|| device_resp["interval"].as_u64()) + .unwrap_or(5) + .max(5); + + println!(" Go to: https://auth.openai.com/codex/device"); + println!(" Enter code: {}\n", user_code); + println!("Waiting for authorization..."); + + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(600); + let mut poll_interval = interval; + loop { + if tokio::time::Instant::now() >= deadline { + return Err(anyhow!("Device flow timed out after 10 minutes.")); + } + tokio::time::sleep(tokio::time::Duration::from_secs(poll_interval)).await; + let resp = client.post(CODEX_DEVICE_TOKEN_URL) + .json(&serde_json::json!({"client_id": client_id, "device_auth_id": device_auth_id, "user_code": user_code})) + .send().await?; + let status = resp.status(); + let payload: serde_json::Value = resp.json().await?; + if status.is_success() { + let auth_code = payload["authorization_code"] + .as_str() + .ok_or_else(|| anyhow!("No authorization_code: {payload}"))?; + let code_verifier = payload["code_verifier"] + .as_str() + .ok_or_else(|| anyhow!("No code_verifier: {payload}"))?; + let token_resp = client + .post(CODEX_TOKEN_URL) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", client_id.as_str()), + ("code", auth_code), + ("code_verifier", code_verifier), + ("redirect_uri", CODEX_DEVICE_REDIRECT_URI), + ]) + .send() + .await?; + if !token_resp.status().is_success() { + let body = token_resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token exchange failed: {body}")); + } + let token_payload: serde_json::Value = token_resp.json().await?; + let access_token = token_payload["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token: {token_payload}"))?; + let refresh_token_val = token_payload["refresh_token"] + .as_str() + .ok_or_else(|| anyhow!("No refresh_token"))?; + let expires_in = token_payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let store = TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token_val.to_string(), + expires_at: now + expires_in, + token_endpoint: CODEX_TOKEN_URL.to_string(), + provider: "codex".to_string(), + }; + save_tokens(&store)?; + println!( + "\n\u{2705} Login successful! Token saved to {:?}", + auth_path() + ); + return Ok(()); + } + let error_code = payload["error"]["code"] + .as_str() + .or_else(|| payload["error"].as_str()) + .unwrap_or_default(); + match error_code { + "authorization_pending" | "deviceauth_authorization_pending" => continue, + "slow_down" => { + poll_interval += 5; + continue; + } + "expired_token" | "deviceauth_expired" => return Err(anyhow!("Device code expired.")), + "access_denied" => return Err(anyhow!("Authorization denied by user.")), + _ => { + if status.as_u16() == 403 || status.as_u16() == 404 { + continue; + } + return Err(anyhow!( + "Device-code error: {error_code} \u{2014} {payload}" + )); + } + } + } +} + +pub fn show_status() { + match load_tokens() { + Ok(store) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let expired = now + REFRESH_SKEW_SECONDS >= store.expires_at; + let masked = if store.access_token.len() > 12 { + format!( + "{}...{}", + &store.access_token[..8], + &store.access_token[store.access_token.len() - 4..] + ) + } else { + "****".to_string() + }; + println!("Provider: {}", store.provider); + println!("Token: {}", masked); + println!( + "Expires: {} ({})", + store.expires_at, + if expired { "EXPIRED" } else { "valid" } + ); + println!("File: {:?}", auth_path()); + } + Err(e) => { + println!("Not authenticated: {e}\nRun: openab-agent auth codex-oauth"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_store(expires_at: u64) -> TokenStore { + TokenStore { + access_token: "test_access_token_value".to_string(), + refresh_token: "test_refresh".to_string(), + expires_at, + token_endpoint: "https://example.com/token".to_string(), + provider: "codex".to_string(), + } + } + + #[test] + fn test_is_expired_future_token() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(!is_expired(&make_store(now + 3600))); + } + + #[test] + fn test_is_expired_past_token() { + assert!(is_expired(&make_store(0))); + } + + #[test] + fn test_is_expired_within_skew() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(is_expired(&make_store(now + 60))); + } + + #[test] + fn test_auth_path() { + assert!(auth_path() + .to_string_lossy() + .contains(".openab/agent/auth.json")); + } + + #[test] + fn test_codex_client_id_default() { + unsafe { std::env::remove_var("OPENAB_AGENT_OAUTH_CLIENT_ID") }; + assert_eq!(codex_client_id(), "app_EMoamEEZ73f0CkXaXp7hrann"); + } + + #[test] + fn test_codex_client_id_override() { + unsafe { std::env::set_var("OPENAB_AGENT_OAUTH_CLIENT_ID", "custom_id") }; + assert_eq!(codex_client_id(), "custom_id"); + unsafe { std::env::remove_var("OPENAB_AGENT_OAUTH_CLIENT_ID") }; + } + + #[test] + fn test_generate_pkce() { + let (verifier, challenge) = generate_pkce(); + assert!(!verifier.is_empty()); + let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + assert_eq!(challenge, expected); + } +} diff --git a/openab-agent/src/llm.rs b/openab-agent/src/llm.rs new file mode 100644 index 000000000..429b7875b --- /dev/null +++ b/openab-agent/src/llm.rs @@ -0,0 +1,644 @@ +use anyhow::{anyhow, Result}; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::pin::Pin; + +/// A message in the conversation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub role: String, + pub content: Vec, +} + +/// A content block within a message. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ContentBlock { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "tool_use")] + ToolUse { + id: String, + name: String, + input: Value, + }, + #[serde(rename = "tool_result")] + ToolResult { + tool_use_id: String, + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + is_error: Option, + }, +} + +/// Tool definition sent to the LLM. +#[derive(Debug, Clone, Serialize)] +pub struct ToolDef { + pub name: String, + pub description: String, + pub input_schema: Value, +} + +/// Events streamed back from the LLM. +#[derive(Debug, Clone)] +pub enum LlmEvent { + Text(String), + ToolUse { + id: String, + name: String, + input: Value, + }, + Stop, + #[allow(dead_code)] + Error(String), +} + +/// Trait for LLM providers. +pub trait LlmProvider: Send + Sync { + fn chat<'a>( + &'a self, + system: &'a str, + messages: &'a [Message], + tools: &'a [ToolDef], + ) -> Pin>> + Send + 'a>>; +} + +/// Anthropic Claude provider. +pub struct AnthropicProvider { + api_key: String, + model: String, + #[allow(dead_code)] + max_tokens: u32, + client: reqwest::Client, +} + +impl AnthropicProvider { + pub fn from_env() -> Result { + let api_key = std::env::var("ANTHROPIC_API_KEY") + .map_err(|_| "ANTHROPIC_API_KEY not set".to_string())?; + if api_key.is_empty() { + return Err("ANTHROPIC_API_KEY is empty".to_string()); + } + Ok(Self { + api_key, + model: std::env::var("OPENAB_AGENT_MODEL") + .unwrap_or_else(|_| "claude-sonnet-4-20250514".to_string()), + max_tokens: std::env::var("OPENAB_AGENT_MAX_TOKENS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8192), + client: reqwest::Client::new(), + }) + } + + fn build_request_body(&self, system: &str, messages: &[Message], tools: &[ToolDef]) -> Value { + let msgs: Vec = messages + .iter() + .map(|m| { + let content: Vec = m + .content + .iter() + .map(|b| match b { + ContentBlock::Text { text } => json!({ "type": "text", "text": text }), + ContentBlock::ToolUse { id, name, input } => { + json!({ "type": "tool_use", "id": id, "name": name, "input": input }) + } + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } => { + let mut v = json!({ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": content + }); + if let Some(true) = is_error { + v["is_error"] = json!(true); + } + v + } + }) + .collect(); + json!({ "role": &m.role, "content": content }) + }) + .collect(); + + let mut body = json!({ + "model": &self.model, + "max_tokens": self.max_tokens, + "messages": msgs, + "system": system, + }); + + if !tools.is_empty() { + let tool_defs: Vec = tools + .iter() + .map(|t| { + json!({ + "name": &t.name, + "description": &t.description, + "input_schema": &t.input_schema + }) + }) + .collect(); + body["tools"] = json!(tool_defs); + } + + body + } +} + +impl LlmProvider for AnthropicProvider { + fn chat<'a>( + &'a self, + system: &'a str, + messages: &'a [Message], + tools: &'a [ToolDef], + ) -> Pin>> + Send + 'a>> { + Box::pin(async move { + let body = self.build_request_body(system, messages, tools); + let max_retries = 3u32; + + for attempt in 0..=max_retries { + let resp = self + .client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", &self.api_key) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| anyhow!("HTTP request failed: {e}"))?; + + let status = resp.status(); + + // Retry on 429 (rate limit) or 529 (overloaded) + if (status.as_u16() == 429 || status.as_u16() == 529) && attempt < max_retries { + let delay = std::time::Duration::from_millis(1000 * 2u64.pow(attempt)); + tokio::time::sleep(delay).await; + continue; + } + + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Anthropic API error {status}: {text}")); + } + + let response: Value = resp + .json() + .await + .map_err(|e| anyhow!("Failed to parse response: {e}"))?; + + return parse_anthropic_response(&response); + } + + Err(anyhow!("Anthropic API: max retries exceeded")) + }) + } +} + +fn parse_anthropic_response(response: &Value) -> Result> { + let mut events = Vec::new(); + + let content = response + .get("content") + .and_then(|c| c.as_array()) + .ok_or_else(|| anyhow!("missing content in response"))?; + + for block in content { + match block.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + events.push(LlmEvent::Text(text.to_string())); + } + } + Some("tool_use") => { + let id = block + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let name = block + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let input = block.get("input").cloned().unwrap_or(json!({})); + events.push(LlmEvent::ToolUse { id, name, input }); + } + _ => {} + } + } + + let stop_reason = response + .get("stop_reason") + .and_then(|s| s.as_str()) + .unwrap_or("end_turn"); + + if stop_reason != "tool_use" { + events.push(LlmEvent::Stop); + } + + Ok(events) +} + +// === OpenAI-compatible Provider (for Codex subscription via OAuth) === + +pub struct OpenAiProvider { + base_url: String, + model: String, + #[allow(dead_code)] + max_tokens: u32, + client: reqwest::Client, +} + +impl OpenAiProvider { + /// Create provider using stored OAuth token from ~/.openab/agent/auth.json + pub fn from_auth_store() -> Result { + // Just verify tokens exist; actual token is fetched at call time + crate::auth::load_tokens().map_err(|e| e.to_string())?; + Ok(Self { + base_url: std::env::var("OPENAB_AGENT_OPENAI_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + model: std::env::var("OPENAB_AGENT_OPENAI_MODEL") + .or_else(|_| std::env::var("OPENAB_AGENT_MODEL")) + .unwrap_or_else(|_| "gpt-4.1-nano".to_string()), + max_tokens: std::env::var("OPENAB_AGENT_MAX_TOKENS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8192), + client: reqwest::Client::new(), + }) + } +} + +impl LlmProvider for OpenAiProvider { + fn chat<'a>( + &'a self, + system: &'a str, + messages: &'a [Message], + tools: &'a [ToolDef], + ) -> Pin>> + Send + 'a>> { + Box::pin(async move { + // Build Responses API input format + let mut oai_messages: Vec = vec![]; + for m in messages { + if m.role == "user" { + // User text messages + let texts: Vec<&str> = m + .content + .iter() + .filter_map(|b| { + if let ContentBlock::Text { text } = b { + Some(text.as_str()) + } else { + None + } + }) + .collect(); + if !texts.is_empty() { + oai_messages.push(json!({"role": "user", "content": [{"type": "input_text", "text": texts.join("")}]})); + } + // Tool results as function_call_output + for b in &m.content { + if let ContentBlock::ToolResult { + tool_use_id, + content, + .. + } = b + { + oai_messages.push(json!({"type": "function_call_output", "call_id": tool_use_id, "output": content})); + } + } + } else if m.role == "assistant" { + for b in &m.content { + match b { + ContentBlock::Text { text } => { + oai_messages.push(json!({"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text, "annotations": []}]})); + } + ContentBlock::ToolUse { id, name, input } => { + oai_messages.push(json!({"type": "function_call", "call_id": id, "name": name, "arguments": input.to_string()})); + } + _ => {} + } + } + } + } + + // Build Responses API body + let mut body = json!({ + "model": &self.model, + "store": false, + "stream": true, + "instructions": system, + "input": oai_messages, + "tool_choice": "auto", + "parallel_tool_calls": true, + }); + + if !tools.is_empty() { + let resp_tools: Vec = tools + .iter() + .map(|t| { + json!({ + "type": "function", + "name": &t.name, + "description": &t.description, + "parameters": &t.input_schema + }) + }) + .collect(); + body["tools"] = json!(resp_tools); + } + + let max_retries = 3u32; + for attempt in 0..=max_retries { + let token = crate::auth::get_valid_token().await?; + // Extract account ID from JWT for chatgpt backend API + let account_id = extract_account_id_from_jwt(&token); + let mut req = self + .client + .post(format!("{}/codex/responses", self.base_url)) + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .header("originator", "openab-agent"); + if let Some(ref aid) = account_id { + req = req.header("chatgpt-account-id", aid); + } + let resp = req + .json(&body) + .send() + .await + .map_err(|e| anyhow!("HTTP request failed: {e}"))?; + + let status = resp.status(); + if (status.as_u16() == 429 || status.as_u16() == 529) && attempt < max_retries { + let delay = std::time::Duration::from_millis(1000 * 2u64.pow(attempt)); + tokio::time::sleep(delay).await; + continue; + } + + // 401: token may have expired mid-request, force refresh and retry + if status.as_u16() == 401 && attempt < max_retries { + let _ = crate::auth::force_refresh().await; + continue; + } + + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(anyhow!("OpenAI API error {status}: {text}")); + } + + // Parse SSE stream - collect output items from response.output_item.done events + let text = resp + .text() + .await + .map_err(|e| anyhow!("Failed to read response: {e}"))?; + let mut output_items: Vec = Vec::new(); + for line in text.lines() { + if let Some(data) = line.strip_prefix("data: ") { + if data == "[DONE]" { + break; + } + if let Ok(event) = serde_json::from_str::(data) { + let event_type = + event.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if event_type == "response.output_item.done" { + if let Some(item) = event.get("item") { + output_items.push(item.clone()); + } + } + } + } + } + if output_items.is_empty() { + return Err(anyhow!( + "No output items in SSE stream. Raw: {}", + &text[..text.len().min(500)] + )); + } + let response = json!({"output": output_items}); + return parse_openai_response(&response); + } + Err(anyhow!("OpenAI API: max retries exceeded")) + }) + } +} + +fn extract_account_id_from_jwt(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + let mut payload = parts[1].to_string(); + while !payload.len().is_multiple_of(4) { + payload.push('='); + } + let decoded = base64::engine::general_purpose::URL_SAFE + .decode(&payload) + .ok() + .or_else(|| { + base64::engine::general_purpose::STANDARD + .decode(&payload) + .ok() + })?; + let claims: Value = serde_json::from_slice(&decoded).ok()?; + claims["https://api.openai.com/auth"]["chatgpt_account_id"] + .as_str() + .map(|s| s.to_string()) +} + +fn parse_openai_response(response: &Value) -> Result> { + let mut events = Vec::new(); + + // Handle Responses API format (output array) + if let Some(output) = response.get("output").and_then(|o| o.as_array()) { + for item in output { + match item.get("type").and_then(|t| t.as_str()) { + Some("message") => { + if let Some(content) = item.get("content").and_then(|c| c.as_array()) { + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("output_text") { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + events.push(LlmEvent::Text(text.to_string())); + } + } + } + } + } + Some("function_call") => { + let id = item + .get("call_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let name = item + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let args_str = item + .get("arguments") + .and_then(|v| v.as_str()) + .unwrap_or("{}"); + let input: Value = serde_json::from_str(args_str).unwrap_or(json!({})); + events.push(LlmEvent::ToolUse { id, name, input }); + } + _ => {} + } + } + events.push(LlmEvent::Stop); + return Ok(events); + } + + // Fallback: Chat Completions format + let choice = response + .get("choices") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()) + .ok_or_else(|| anyhow!("No choices in response"))?; + + let message = choice.get("message").ok_or_else(|| anyhow!("No message"))?; + + // Text content + if let Some(content) = message.get("content").and_then(|c| c.as_str()) { + if !content.is_empty() { + events.push(LlmEvent::Text(content.to_string())); + } + } + + // Tool calls + if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) { + for tc in tool_calls { + let id = tc + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let name = tc + .get("function") + .and_then(|f| f.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + let args_str = tc + .get("function") + .and_then(|f| f.get("arguments")) + .and_then(|a| a.as_str()) + .unwrap_or("{}"); + let input: Value = serde_json::from_str(args_str).unwrap_or(json!({})); + events.push(LlmEvent::ToolUse { id, name, input }); + } + } + + let finish_reason = choice + .get("finish_reason") + .and_then(|f| f.as_str()) + .unwrap_or("stop"); + if finish_reason != "tool_calls" { + events.push(LlmEvent::Stop); + } + + Ok(events) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_text_response() { + let resp = json!({ + "content": [{"type": "text", "text": "Hello world"}], + "stop_reason": "end_turn" + }); + let events = parse_anthropic_response(&resp).unwrap(); + assert_eq!(events.len(), 2); + match &events[0] { + LlmEvent::Text(t) => assert_eq!(t, "Hello world"), + _ => panic!("expected Text event"), + } + assert!(matches!(events[1], LlmEvent::Stop)); + } + + #[test] + fn test_parse_tool_use_response() { + let resp = json!({ + "content": [ + {"type": "tool_use", "id": "tu_1", "name": "read", "input": {"path": "/tmp/x"}} + ], + "stop_reason": "tool_use" + }); + let events = parse_anthropic_response(&resp).unwrap(); + assert_eq!(events.len(), 1); + match &events[0] { + LlmEvent::ToolUse { id, name, input } => { + assert_eq!(id, "tu_1"); + assert_eq!(name, "read"); + assert_eq!(input["path"], "/tmp/x"); + } + _ => panic!("expected ToolUse event"), + } + } + + #[test] + fn test_build_request_body() { + let provider = AnthropicProvider { + api_key: "test".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + max_tokens: 4096, + client: reqwest::Client::new(), + }; + let messages = vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "hello".to_string(), + }], + }]; + let body = provider.build_request_body("system prompt", &messages, &[]); + assert_eq!(body["model"], "claude-sonnet-4-20250514"); + assert_eq!(body["max_tokens"], 4096); + assert_eq!(body["system"], "system prompt"); + assert_eq!(body["messages"][0]["role"], "user"); + } + + #[test] + fn test_parse_openai_text_response() { + let resp = json!({ + "choices": [{"message": {"content": "Hello"}, "finish_reason": "stop"}] + }); + let events = parse_openai_response(&resp).unwrap(); + assert_eq!(events.len(), 2); + assert!(matches!(&events[0], LlmEvent::Text(t) if t == "Hello")); + assert!(matches!(events[1], LlmEvent::Stop)); + } + + #[test] + fn test_parse_openai_tool_call_response() { + let resp = json!({ + "choices": [{"message": { + "content": null, + "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read", "arguments": "{\"path\":\"x.txt\"}"}}] + }, "finish_reason": "tool_calls"}] + }); + let events = parse_openai_response(&resp).unwrap(); + assert_eq!(events.len(), 1); + match &events[0] { + LlmEvent::ToolUse { id, name, input } => { + assert_eq!(id, "call_1"); + assert_eq!(name, "read"); + assert_eq!(input["path"], "x.txt"); + } + _ => panic!("expected ToolUse"), + } + } + + #[test] + fn test_parse_openai_empty_choices() { + let resp = json!({"choices": []}); + assert!(parse_openai_response(&resp).is_err()); + } +} diff --git a/openab-agent/src/main.rs b/openab-agent/src/main.rs new file mode 100644 index 000000000..f3cc2cd75 --- /dev/null +++ b/openab-agent/src/main.rs @@ -0,0 +1,73 @@ +mod acp; +mod agent; +mod auth; +mod llm; +mod tools; + +use clap::{Parser, Subcommand}; +use tracing_subscriber::EnvFilter; + +#[derive(Parser)] +#[command(name = "openab-agent", about = "Native Rust coding agent with ACP")] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Authenticate with an LLM provider + Auth { + #[command(subcommand)] + provider: AuthProvider, + }, +} + +#[derive(Subcommand)] +enum AuthProvider { + /// OpenAI Codex via browser PKCE flow (recommended, full scopes) + CodexOauth { + /// Print URL instead of opening browser + #[arg(long)] + no_browser: bool, + }, + /// OpenAI Codex via device code (headless servers) + CodexDevice, + /// Show stored credentials + Status, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + + match cli.command { + None => { + // Default: run ACP server + let mut server = acp::AcpServer::new(); + server.run().await; + } + Some(Commands::Auth { provider }) => match provider { + AuthProvider::CodexOauth { no_browser } => { + if let Err(e) = auth::login_browser_flow(no_browser).await { + eprintln!("❌ Authentication failed: {e}"); + std::process::exit(1); + } + } + AuthProvider::CodexDevice => { + if let Err(e) = auth::login_codex_device_flow().await { + eprintln!("❌ Authentication failed: {e}"); + std::process::exit(1); + } + } + AuthProvider::Status => { + auth::show_status(); + } + }, + } +} diff --git a/openab-agent/src/tools.rs b/openab-agent/src/tools.rs new file mode 100644 index 000000000..e0f898f4f --- /dev/null +++ b/openab-agent/src/tools.rs @@ -0,0 +1,435 @@ +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tokio::process::Command; +use tracing::debug; + +use crate::llm::ToolDef; + +/// Validate that a path is within the allowed working directory. +/// This function has NO side-effects — it never creates directories or files. +fn validate_path(path: &str, working_dir: &Path) -> Result { + let target = if Path::new(path).is_absolute() { + PathBuf::from(path) + } else { + working_dir.join(path) + }; + + // For existing paths, canonicalize directly + if target.exists() { + let canonical = target.canonicalize()?; + let canonical_working = working_dir.canonicalize()?; + if !canonical.starts_with(&canonical_working) { + return Err(anyhow!( + "path traversal denied: {} is outside working directory", + path + )); + } + return Ok(canonical); + } + + // For non-existent paths, walk up to find the nearest existing ancestor + let mut ancestor = target.parent(); + while let Some(p) = ancestor { + if p.exists() { + let canonical_ancestor = p.canonicalize()?; + let canonical_working = working_dir.canonicalize()?; + if !canonical_ancestor.starts_with(&canonical_working) { + return Err(anyhow!( + "path traversal denied: {} is outside working directory", + path + )); + } + // Reconstruct the full path relative to the canonicalized ancestor + let remainder = target.strip_prefix(p).unwrap_or(target.as_path()); + return Ok(canonical_ancestor.join(remainder)); + } + ancestor = p.parent(); + } + + Err(anyhow!( + "path traversal denied: no valid ancestor for {}", + path + )) +} + +/// Build a filtered environment for bash tool execution. +fn build_env(allow_list: &[String]) -> HashMap { + let mut env = HashMap::new(); + for key in &["PATH", "HOME", "USER", "LANG", "TERM", "SHELL"] { + if let Ok(val) = std::env::var(key) { + env.insert(key.to_string(), val); + } + } + for key in allow_list { + if let Ok(val) = std::env::var(key) { + env.insert(key.to_string(), val); + } + } + env +} + +/// Execute a tool call and return the result as a string. +pub async fn execute_tool(name: &str, input: &Value, working_dir: &Path) -> Result { + match name { + "read" => tool_read(input, working_dir), + "write" => tool_write(input, working_dir), + "edit" => tool_edit(input, working_dir), + "bash" => tool_bash(input, working_dir).await, + _ => Err(anyhow!("unknown tool: {name}")), + } +} + +/// Read file contents or list directory. +fn tool_read(input: &Value, working_dir: &Path) -> Result { + let path_str = input + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("read: missing 'path' parameter"))?; + + let path = validate_path(path_str, working_dir)?; + + if path.is_dir() { + let mut entries = Vec::new(); + for entry in std::fs::read_dir(&path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let ft = entry.file_type()?; + if ft.is_dir() { + entries.push(format!("{name}/")); + } else { + entries.push(name); + } + } + entries.sort(); + Ok(entries.join("\n")) + } else { + let content = + std::fs::read_to_string(&path).map_err(|e| anyhow!("read {}: {e}", path.display()))?; + + // Apply optional line range + let offset = input.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let limit = input.get("limit").and_then(|v| v.as_u64()); + + let lines: Vec<&str> = content.lines().collect(); + let start = offset.min(lines.len()); + let end = match limit { + Some(l) => (start + l as usize).min(lines.len()), + None => lines.len(), + }; + + Ok(lines[start..end].join("\n")) + } +} + +/// Create or overwrite a file. +fn tool_write(input: &Value, working_dir: &Path) -> Result { + let path_str = input + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("write: missing 'path' parameter"))?; + let content = input + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("write: missing 'content' parameter"))?; + + let path = validate_path(path_str, working_dir)?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, content)?; + + Ok(format!( + "wrote {} bytes to {}", + content.len(), + path.display() + )) +} + +/// Replace an exact string in a file. +fn tool_edit(input: &Value, working_dir: &Path) -> Result { + let path_str = input + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("edit: missing 'path' parameter"))?; + let old_str = input + .get("old_str") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("edit: missing 'old_str' parameter"))?; + let new_str = input + .get("new_str") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("edit: missing 'new_str' parameter"))?; + + let path = validate_path(path_str, working_dir)?; + let content = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("edit: cannot read {}: {e}", path.display()))?; + + let count = content.matches(old_str).count(); + if count == 0 { + return Err(anyhow!("edit: old_str not found in {}", path.display())); + } + + let new_content = content.replacen(old_str, new_str, 1); + std::fs::write(&path, &new_content)?; + + Ok(format!( + "replaced 1 occurrence in {} ({count} total matches)", + path.display() + )) +} + +/// Execute a shell command with process group isolation and env filtering. +async fn tool_bash(input: &Value, working_dir: &Path) -> Result { + let command = input + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("bash: missing 'command' parameter"))?; + + let cmd_working_dir = input + .get("working_dir") + .and_then(|v| v.as_str()) + .map(|p| { + if Path::new(p).is_absolute() { + PathBuf::from(p) + } else { + working_dir.join(p) + } + }) + .unwrap_or_else(|| working_dir.to_path_buf()); + + let timeout_secs = std::env::var("OPENAB_AGENT_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(120); + + let env_allow: Vec = std::env::var("OPENAB_AGENT_BASH_ENV_ALLOW") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + let env = build_env(&env_allow); + + debug!("bash: executing '{}' in {:?}", command, cmd_working_dir); + + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(command) + .current_dir(&cmd_working_dir) + .env_clear() + .envs(&env) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + // Create new process group on Unix for clean cleanup + #[cfg(unix)] + unsafe { + #[allow(unused_imports)] + use std::os::unix::process::CommandExt; + cmd.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + + let child = cmd + .spawn() + .map_err(|e| anyhow!("bash: spawn failed: {e}"))?; + + // Capture pid before wait_with_output takes ownership + #[cfg(unix)] + let child_pid = child.id(); + + let result = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + child.wait_with_output(), + ) + .await; + + match result { + Ok(Ok(output)) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let code = output.status.code().unwrap_or(-1); + + let mut result = String::new(); + if !stdout.is_empty() { + result.push_str(&stdout); + } + if !stderr.is_empty() { + if !result.is_empty() { + result.push('\n'); + } + result.push_str("[stderr]\n"); + result.push_str(&stderr); + } + if code != 0 { + result.push_str(&format!("\n[exit code: {code}]")); + } + Ok(result) + } + Ok(Err(e)) => Err(anyhow!("bash: execution error: {e}")), + Err(_) => { + // Timeout — kill the process group + #[cfg(unix)] + if let Some(pid) = child_pid { + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } + } + Err(anyhow!("bash: command timed out after {timeout_secs}s")) + } + } +} + +/// Return tool definitions for the LLM. +pub fn tool_definitions() -> Vec { + vec![ + ToolDef { + name: "read".to_string(), + description: "Read file contents or list a directory.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "File or directory path" }, + "offset": { "type": "integer", "description": "Line offset to start reading from (0-indexed)" }, + "limit": { "type": "integer", "description": "Number of lines to read" } + }, + "required": ["path"] + }), + }, + ToolDef { + name: "write".to_string(), + description: "Create or overwrite a file with the given content.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path to write" }, + "content": { "type": "string", "description": "Content to write" } + }, + "required": ["path", "content"] + }), + }, + ToolDef { + name: "edit".to_string(), + description: "Replace the first occurrence of old_str with new_str in a file." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path to edit" }, + "old_str": { "type": "string", "description": "Exact string to find" }, + "new_str": { "type": "string", "description": "Replacement string" } + }, + "required": ["path", "old_str", "new_str"] + }), + }, + ToolDef { + name: "bash".to_string(), + description: "Execute a shell command and return stdout/stderr.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "command": { "type": "string", "description": "Shell command to execute" }, + "working_dir": { "type": "string", "description": "Working directory (optional, defaults to agent working dir)" } + }, + "required": ["command"] + }), + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_validate_path_within_working_dir() { + let tmp = TempDir::new().unwrap(); + let result = validate_path("test.txt", tmp.path()); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_path_traversal_denied() { + let tmp = TempDir::new().unwrap(); + let result = validate_path("../../etc/passwd", tmp.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("path traversal")); + } + + #[test] + #[ignore] // Integration test: filesystem access + fn test_tool_write_and_read() { + let tmp = TempDir::new().unwrap(); + let input = json!({ "path": "hello.txt", "content": "hello world" }); + let result = tool_write(&input, tmp.path()).unwrap(); + assert!(result.contains("11 bytes")); + + let read_input = json!({ "path": "hello.txt" }); + let content = tool_read(&read_input, tmp.path()).unwrap(); + assert_eq!(content, "hello world"); + } + + #[test] + #[ignore] // Integration test: filesystem access + fn test_tool_edit() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("test.rs"); + std::fs::write(&file_path, "fn main() {\n println!(\"old\");\n}\n").unwrap(); + + let input = json!({ + "path": "test.rs", + "old_str": "println!(\"old\")", + "new_str": "println!(\"new\")" + }); + let result = tool_edit(&input, tmp.path()).unwrap(); + assert!(result.contains("replaced 1 occurrence")); + + let content = std::fs::read_to_string(&file_path).unwrap(); + assert!(content.contains("println!(\"new\")")); + } + + #[test] + #[ignore] // Integration test: filesystem access + fn test_tool_read_directory() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "").unwrap(); + std::fs::write(tmp.path().join("b.txt"), "").unwrap(); + std::fs::create_dir(tmp.path().join("subdir")).unwrap(); + + let input = json!({ "path": "." }); + let result = tool_read(&input, tmp.path()).unwrap(); + assert!(result.contains("a.txt")); + assert!(result.contains("b.txt")); + assert!(result.contains("subdir/")); + } + + #[tokio::test] + #[ignore] // Integration test: subprocess execution + async fn test_tool_bash_simple() { + let tmp = TempDir::new().unwrap(); + let input = json!({ "command": "echo hello" }); + let result = tool_bash(&input, tmp.path()).await.unwrap(); + assert_eq!(result.trim(), "hello"); + } + + #[tokio::test] + #[ignore] // Integration test: subprocess execution + async fn test_tool_bash_env_filtered() { + let tmp = TempDir::new().unwrap(); + // Verify that arbitrary env vars are NOT passed through (env is cleared) + let input = json!({ "command": "env | grep -c ANTHROPIC || true" }); + let result = tool_bash(&input, tmp.path()).await.unwrap(); + // With env_clear(), no ANTHROPIC vars should exist in child + assert!(result.trim() == "0" || result.trim().is_empty() || result.contains("[exit code:")); + } +} diff --git a/openab-auth-proxy/.gitignore b/openab-auth-proxy/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/openab-auth-proxy/.gitignore @@ -0,0 +1 @@ +/target diff --git a/openab-auth-proxy/Cargo.toml b/openab-auth-proxy/Cargo.toml new file mode 100644 index 000000000..637349df3 --- /dev/null +++ b/openab-auth-proxy/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "openab-auth-proxy" +version = "0.2.0" +edition = "2021" +description = "Generic OAuth proxy sidecar — authenticates via OIDC/PKCE and forwards requests to any upstream API with Bearer token injection" + +[dependencies] +tokio = { version = "1.44", features = ["full"] } +axum = { version = "0.8", features = ["tokio"] } +hyper = { version = "1.6", features = ["full"] } +hyper-util = { version = "0.1", features = ["tokio", "client-legacy", "http1", "http2"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +hyper-rustls = { version = "0.27", features = ["http2", "ring", "native-tokio"] } +http-body-util = "0.1" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +clap = { version = "4", features = ["derive"] } +sha2 = "0.10" +base64 = "0.22" +rand = "0.8" +uuid = { version = "1", features = ["v4"] } +url = "2" +urlencoding = "2" +dirs = "6" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +open = "5" +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.6", features = ["trace"] } + +[profile.release] +strip = true +lto = true diff --git a/openab-auth-proxy/Dockerfile b/openab-auth-proxy/Dockerfile new file mode 100644 index 000000000..ee4c8baea --- /dev/null +++ b/openab-auth-proxy/Dockerfile @@ -0,0 +1,11 @@ +FROM rust:1.86-slim AS builder +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +RUN cargo build --release + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/openab-auth-proxy /usr/local/bin/ +ENTRYPOINT ["openab-auth-proxy"] +CMD ["serve", "--bind", "0.0.0.0"] diff --git a/openab-auth-proxy/README.md b/openab-auth-proxy/README.md new file mode 100644 index 000000000..d07ec94bf --- /dev/null +++ b/openab-auth-proxy/README.md @@ -0,0 +1,81 @@ +# openab-auth-proxy + +Generic OAuth proxy sidecar for LLM APIs. Authenticates via OIDC (PKCE or device-code flow) and injects Bearer tokens into proxied requests. + +Ships with a built-in **xAI/SuperGrok** preset. Configure any OAuth-protected API via a TOML config file. + +## Quick start (xAI default) + +```bash +# Login (device-code for headless, or browser PKCE) +openab-auth-proxy login-device +openab-auth-proxy login + +# Start proxy +openab-auth-proxy serve --port 9090 + +# Use with any OpenAI-compatible client +export OPENAI_BASE_URL=http://127.0.0.1:9090/v1 +export OPENAI_API_KEY=dummy +opencode +``` + +## Custom provider + +Create `auth-proxy.toml`: + +```toml +[provider] +name = "my-provider" +discovery_url = "https://auth.example.com/.well-known/openid-configuration" +client_id = "my-client-id" +scopes = "openid offline_access api:access" +upstream_base_url = "https://api.example.com" +redirect_port = 8080 +``` + +```bash +openab-auth-proxy -c auth-proxy.toml login-device +openab-auth-proxy -c auth-proxy.toml serve +``` + +## Architecture + +``` +┌─ Pod / Host ──────────────────────────────────────────────────┐ +│ │ +│ agent (any OpenAI-compatible client) │ +│ │ POST /v1/chat/completions │ +│ ▼ │ +│ openab-auth-proxy :9090 │ +│ • Reads OAuth token from disk │ +│ • Injects Authorization: Bearer header │ +│ • Auto-refreshes 120s before expiry │ +│ │ │ +│ Token: ~/.openab-auth-proxy//tokens.json │ +└───────────────┼───────────────────────────────────────────────┘ + ▼ + upstream API (configured via TOML) +``` + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `AUTH_PROXY_TOKEN_PATH` | `~/.openab-auth-proxy//tokens.json` | Custom token file path | +| `XAI_PROXY_TOKEN_PATH` | (legacy) | Backward-compatible alias | +| `RUST_LOG` | `openab_auth_proxy=info` | Log level | + +## Docker + +```bash +docker build -t openab-auth-proxy . +docker run --rm -v ~/.openab-auth-proxy:/root/.openab-auth-proxy openab-auth-proxy serve --bind 0.0.0.0 +``` + +## Presets + +| Provider | Config needed? | Notes | +|----------|---------------|-------| +| xAI SuperGrok | No (built-in default) | Uses Grok CLI public OAuth client | +| Custom | Yes (`auth-proxy.toml`) | Any OIDC provider with device-code or PKCE | diff --git a/openab-auth-proxy/src/main.rs b/openab-auth-proxy/src/main.rs new file mode 100644 index 000000000..256f56c6f --- /dev/null +++ b/openab-auth-proxy/src/main.rs @@ -0,0 +1,574 @@ +use anyhow::{anyhow, Context, Result}; +use axum::{body::Body, extract::State, http::Request, response::Response, routing::any, Router}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use clap::{Parser, Subcommand}; +use hyper_util::{client::legacy::Client, rt::TokioExecutor}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + net::SocketAddr, + path::PathBuf, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::TcpListener, + sync::RwLock, +}; +use tracing::{error, info}; + +const REFRESH_SKEW_SECONDS: u64 = 120; + +// === Provider Config === + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ProviderConfig { + name: String, + discovery_url: String, + client_id: String, + scopes: String, + upstream_base_url: String, + #[serde(default = "default_redirect_port")] + redirect_port: u16, + #[serde(default)] + device_authorization_endpoint: Option, +} + +fn default_redirect_port() -> u16 { + 56121 +} + +impl ProviderConfig { + fn xai() -> Self { + Self { + name: "xAI".to_string(), + discovery_url: "https://auth.x.ai/.well-known/openid-configuration".to_string(), + client_id: "b1a00492-073a-47ea-816f-4c329264a828".to_string(), + scopes: "openid profile email offline_access grok-cli:access api:access".to_string(), + upstream_base_url: "https://api.x.ai".to_string(), + redirect_port: 56121, + device_authorization_endpoint: None, + } + } + + fn upstream_host(&self) -> &str { + self.upstream_base_url + .strip_prefix("https://") + .or_else(|| self.upstream_base_url.strip_prefix("http://")) + .unwrap_or(&self.upstream_base_url) + .split('/') + .next() + .unwrap_or("localhost") + } +} + +#[derive(Debug, Deserialize)] +struct ConfigFile { + provider: ProviderConfig, +} + +fn load_config(path: Option<&PathBuf>) -> Result { + if let Some(p) = path { + let content = std::fs::read_to_string(p) + .with_context(|| format!("Cannot read config file: {}", p.display()))?; + let cfg: ConfigFile = toml::from_str(&content)?; + return Ok(cfg.provider); + } + // Check default locations + let candidates = [ + PathBuf::from("auth-proxy.toml"), + dirs::config_dir() + .unwrap_or_default() + .join("openab-auth-proxy/config.toml"), + ]; + for c in &candidates { + if c.exists() { + let content = std::fs::read_to_string(c)?; + let cfg: ConfigFile = toml::from_str(&content)?; + return Ok(cfg.provider); + } + } + // Default to xAI + Ok(ProviderConfig::xai()) +} + +// === CLI === + +#[derive(Parser)] +#[command(name = "openab-auth-proxy", about = "Generic OAuth proxy sidecar for LLM APIs")] +struct Cli { + /// Path to config file (default: auth-proxy.toml or xAI preset) + #[arg(short, long, global = true)] + config: Option, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Authenticate via browser OAuth (PKCE) + Login, + /// Authenticate via device-code flow (headless/K8s/ECS) + LoginDevice, + /// Start the proxy server + Serve { + #[arg(short, long, default_value = "9090")] + port: u16, + #[arg(long, default_value = "127.0.0.1")] + bind: String, + }, +} + +// === Token Storage === + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TokenStore { + access_token: String, + refresh_token: String, + #[serde(default)] + expires_at: u64, + #[serde(default)] + token_endpoint: String, +} + +fn token_path(provider: &ProviderConfig) -> PathBuf { + if let Ok(p) = std::env::var("AUTH_PROXY_TOKEN_PATH") { + let path = PathBuf::from(p); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).ok(); + } + return path; + } + // Legacy env var for backward compat + if let Ok(p) = std::env::var("XAI_PROXY_TOKEN_PATH") { + let path = PathBuf::from(p); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).ok(); + } + return path; + } + let dir = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".openab-auth-proxy") + .join(provider.name.to_lowercase().replace(' ', "-")); + std::fs::create_dir_all(&dir).ok(); + dir.join("tokens.json") +} + +fn load_tokens(provider: &ProviderConfig) -> Result { + let path = token_path(provider); + let data = std::fs::read_to_string(&path) + .with_context(|| format!("No token file at {}. Run `openab-auth-proxy login` first.", path.display()))?; + serde_json::from_str(&data).context("Invalid token file") +} + +fn save_tokens(provider: &ProviderConfig, store: &TokenStore) -> Result<()> { + let path = token_path(provider); + let data = serde_json::to_string_pretty(store)?; + std::fs::write(&path, data)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +// === OIDC Discovery === + +#[derive(Deserialize)] +struct OidcDiscovery { + authorization_endpoint: String, + token_endpoint: String, + #[serde(default)] + device_authorization_endpoint: String, +} + +async fn discover_endpoints(provider: &ProviderConfig) -> Result { + let client = reqwest::Client::new(); + let resp = client + .get(&provider.discovery_url) + .send() + .await? + .error_for_status()?; + resp.json().await.context("Failed to parse OIDC discovery") +} + +// === PKCE === + +fn pkce_verifier() -> String { + let mut buf = [0u8; 64]; + rand::thread_rng().fill_bytes(&mut buf); + URL_SAFE_NO_PAD.encode(buf) +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +// === OAuth Login (browser PKCE) === + +async fn do_login(provider: &ProviderConfig) -> Result<()> { + info!("Starting {} OAuth PKCE login...", provider.name); + let discovery = discover_endpoints(provider).await?; + + let code_verifier = pkce_verifier(); + let code_challenge = pkce_challenge(&code_verifier); + let state = uuid::Uuid::new_v4().to_string(); + let nonce = uuid::Uuid::new_v4().to_string(); + let redirect_uri = format!("http://127.0.0.1:{}/callback", provider.redirect_port); + + let authorize_url = format!( + "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&code_challenge={}&code_challenge_method=S256&state={}&nonce={}", + discovery.authorization_endpoint, + urlencoding::encode(&provider.client_id), + urlencoding::encode(&redirect_uri), + urlencoding::encode(&provider.scopes), + urlencoding::encode(&code_challenge), + urlencoding::encode(&state), + urlencoding::encode(&nonce), + ); + + let listener = TcpListener::bind(format!("127.0.0.1:{}", provider.redirect_port)) + .await + .with_context(|| format!("Failed to bind callback port {}", provider.redirect_port))?; + + println!("\nOpen this URL to authorize:\n\n {}\n", authorize_url); + if open::that(&authorize_url).is_ok() { + println!("Browser opened. Waiting for callback..."); + } else { + println!("Could not open browser. Please open the URL above manually."); + } + + let (mut stream, _) = listener.accept().await?; + let mut reader = BufReader::new(&mut stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + + let path = request_line + .split_whitespace() + .nth(1) + .ok_or_else(|| anyhow!("Invalid HTTP request"))?; + let url = url::Url::parse(&format!("http://localhost{}", path))?; + let params: std::collections::HashMap<_, _> = url.query_pairs().collect(); + + loop { + let mut line = String::new(); + reader.read_line(&mut line).await?; + if line.trim().is_empty() { break; } + } + + let body = "

Authorization received.

You can close this tab."; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), body + ); + stream.write_all(response.as_bytes()).await?; + + let received_state = params.get("state").ok_or_else(|| anyhow!("No state in callback"))?; + if received_state.as_ref() != state { + return Err(anyhow!("State mismatch — possible CSRF")); + } + let code = params + .get("code") + .ok_or_else(|| anyhow!("No code in callback. Error: {:?}", params.get("error")))?; + + info!("Exchanging authorization code for tokens..."); + let client = reqwest::Client::new(); + let resp = client + .post(&discovery.token_endpoint) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code.as_ref()), + ("redirect_uri", redirect_uri.as_str()), + ("client_id", provider.client_id.as_str()), + ("code_verifier", code_verifier.as_str()), + ("code_challenge", code_challenge.as_str()), + ("code_challenge_method", "S256"), + ]) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token exchange failed (HTTP {}): {}", status, body)); + } + + let token_resp: serde_json::Value = resp.json().await?; + let access_token = token_resp["access_token"].as_str().ok_or_else(|| anyhow!("No access_token"))?; + let refresh_token = token_resp["refresh_token"].as_str().ok_or_else(|| anyhow!("No refresh_token"))?; + let expires_in = token_resp["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + let store = TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token.to_string(), + expires_at: now + expires_in, + token_endpoint: discovery.token_endpoint, + }; + save_tokens(provider, &store)?; + println!("\n✅ Login successful! Token saved to {:?}", token_path(provider)); + Ok(()) +} + +// === Device-Code Login === + +async fn do_login_device(provider: &ProviderConfig) -> Result<()> { + info!("Starting {} device-code login...", provider.name); + let discovery = discover_endpoints(provider).await?; + + let device_endpoint = provider + .device_authorization_endpoint + .clone() + .unwrap_or_else(|| { + if discovery.device_authorization_endpoint.is_empty() { + // Fallback: derive from discovery URL + let base = provider.discovery_url.trim_end_matches("/.well-known/openid-configuration"); + format!("{}/oauth2/device/code", base) + } else { + discovery.device_authorization_endpoint.clone() + } + }); + + let client = reqwest::Client::new(); + let resp = client + .post(&device_endpoint) + .form(&[("client_id", provider.client_id.as_str()), ("scope", provider.scopes.as_str())]) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Device authorization failed: {}", body)); + } + + let device_resp: serde_json::Value = resp.json().await?; + let device_code = device_resp["device_code"].as_str().ok_or_else(|| anyhow!("No device_code"))?; + let user_code = device_resp["user_code"].as_str().ok_or_else(|| anyhow!("No user_code"))?; + let verification_uri = device_resp["verification_uri"] + .as_str() + .or_else(|| device_resp["verification_url"].as_str()) + .unwrap_or("(see provider docs)"); + let interval = device_resp["interval"].as_u64().unwrap_or(5); + + println!("\n Go to: {}", verification_uri); + println!(" Enter code: {}\n", user_code); + println!("Waiting for authorization..."); + + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; + let resp = client + .post(&discovery.token_endpoint) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", provider.client_id.as_str()), + ("device_code", device_code), + ]) + .send() + .await?; + + let status = resp.status(); + let payload: serde_json::Value = resp.json().await?; + + if status.is_success() { + let access_token = payload["access_token"].as_str().ok_or_else(|| anyhow!("No access_token"))?; + let refresh_token = payload["refresh_token"].as_str().ok_or_else(|| anyhow!("No refresh_token"))?; + let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + let store = TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token.to_string(), + expires_at: now + expires_in, + token_endpoint: discovery.token_endpoint, + }; + save_tokens(provider, &store)?; + println!("\n✅ Login successful! Token saved to {:?}", token_path(provider)); + return Ok(()); + } + + match payload["error"].as_str().unwrap_or_default() { + "authorization_pending" | "slow_down" => continue, + "expired_token" => return Err(anyhow!("Device code expired. Try again.")), + "access_denied" => return Err(anyhow!("Authorization denied by user.")), + e => return Err(anyhow!("Device-code error: {} — {:?}", e, payload)), + } + } +} + +// === Token Refresh === + +async fn refresh_token(provider: &ProviderConfig, store: &TokenStore) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post(&store.token_endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", &store.refresh_token), + ("client_id", &provider.client_id), + ]) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token refresh failed (HTTP {}): {}", status, body)); + } + + let token_resp: serde_json::Value = resp.json().await?; + let access_token = token_resp["access_token"].as_str().ok_or_else(|| anyhow!("No access_token"))?; + let new_refresh = token_resp["refresh_token"].as_str().unwrap_or(&store.refresh_token); + let expires_in = token_resp["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + Ok(TokenStore { + access_token: access_token.to_string(), + refresh_token: new_refresh.to_string(), + expires_at: now + expires_in, + token_endpoint: store.token_endpoint.clone(), + }) +} + +// === Proxy === + +struct ProxyState { + provider: ProviderConfig, + tokens: RwLock, + http_client: Client, Body>, +} + +impl ProxyState { + async fn get_valid_token(&self) -> Result { + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + { + let tokens = self.tokens.read().await; + if tokens.expires_at > now + REFRESH_SKEW_SECONDS { + return Ok(tokens.access_token.clone()); + } + } + let mut tokens = self.tokens.write().await; + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + if tokens.expires_at > now + REFRESH_SKEW_SECONDS { + return Ok(tokens.access_token.clone()); + } + info!("Refreshing OAuth token..."); + let new_tokens = refresh_token(&self.provider, &tokens).await?; + save_tokens(&self.provider, &new_tokens)?; + *tokens = new_tokens; + Ok(tokens.access_token.clone()) + } +} + +async fn proxy_handler(State(state): State>, mut req: Request) -> Response { + let token = match state.get_valid_token().await { + Ok(t) => t, + Err(e) => { + error!("Failed to get token: {}", e); + return Response::builder() + .status(502) + .body(Body::from(format!("Token error: {}", e))) + .unwrap(); + } + }; + + let path_and_query = req.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); + let target_uri = format!("{}{}", state.provider.upstream_base_url, path_and_query); + *req.uri_mut() = target_uri.parse().unwrap(); + + req.headers_mut().insert( + hyper::header::AUTHORIZATION, + format!("Bearer {}", token).parse().unwrap(), + ); + req.headers_mut().insert( + hyper::header::HOST, + state.provider.upstream_host().parse().unwrap(), + ); + + match state.http_client.request(req).await { + Ok(resp) => { + let (parts, body) = resp.into_parts(); + Response::from_parts(parts, Body::new(body)) + } + Err(e) => { + error!("Upstream error: {}", e); + Response::builder() + .status(502) + .body(Body::from(format!("Upstream error: {}", e))) + .unwrap() + } + } +} + +async fn do_serve(provider: &ProviderConfig, bind: &str, port: u16) -> Result<()> { + let store = load_tokens(provider)?; + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let store = if store.expires_at <= now + REFRESH_SKEW_SECONDS { + info!("Token expired, refreshing..."); + let new_store = refresh_token(provider, &store).await?; + save_tokens(provider, &new_store)?; + new_store + } else { + store + }; + + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_native_roots()? + .https_or_http() + .enable_http1() + .enable_http2() + .build(); + let http_client = Client::builder(TokioExecutor::new()).build(https); + + let state = Arc::new(ProxyState { + provider: provider.clone(), + tokens: RwLock::new(store), + http_client, + }); + + let app = Router::new() + .route("/{*path}", any(proxy_handler)) + .route("/", any(proxy_handler)) + .with_state(state); + + let addr: SocketAddr = format!("{}:{}", bind, port).parse()?; + let listener = TcpListener::bind(addr).await?; + info!("openab-auth-proxy ({}) listening on http://{}", provider.name, addr); + println!("openab-auth-proxy ({}) listening on http://{}", provider.name, addr); + println!("Upstream: {}", provider.upstream_base_url); + + axum::serve(listener, app).await?; + Ok(()) +} + +// === Main === + +#[tokio::main] +async fn main() -> Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "openab_auth_proxy=info".into()), + ) + .init(); + + let cli = Cli::parse(); + let provider = load_config(cli.config.as_ref())?; + info!("Provider: {} (upstream: {})", provider.name, provider.upstream_base_url); + + match cli.command { + Commands::Login => do_login(&provider).await, + Commands::LoginDevice => do_login_device(&provider).await, + Commands::Serve { port, bind } => do_serve(&provider, &bind, port).await, + } +} diff --git a/operator/.gitignore b/operator/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/operator/.gitignore @@ -0,0 +1 @@ +/target diff --git a/operator/Cargo.lock b/operator/Cargo.lock new file mode 100644 index 000000000..435dd4af7 --- /dev/null +++ b/operator/Cargo.lock @@ -0,0 +1,2711 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-config" +version = "1.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "sha1 0.10.6", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-ecs" +version = "1.124.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e28ffb1fa2097067108e6a0a6dd92cf9075391b50c709b268ef5f42ae0642a0" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.132.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5575840a3a6b11f6011463ebe359320dfe5b67babb5e9b06fed6ddf809a9ab40" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.11.0", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-ssm" +version = "1.109.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f4bdbeea2c7d18632093cd158644902f1e91ae025a3f68afaa449f620ae658" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.100.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "p256", + "percent-encoding", + "ring", + "sha2 0.11.0", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10efbbcec1e044b81600e2fc562a391951d291152d95b482d5b7e7132299d762" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1 0.11.0", + "sha2 0.11.0", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.14", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.9.0", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest 0.10.7", + "rustversion", + "spin", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.14", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls 0.23.40", + "rustls-native-certs", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oabctl" +version = "0.1.0" +dependencies = [ + "anyhow", + "aws-config", + "aws-sdk-ecs", + "aws-sdk-s3", + "aws-sdk-ssm", + "clap", + "serde", + "serde_yaml", + "tokio", + "toml", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2 0.10.9", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac 0.12.1", + "zeroize", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.40", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/operator/Cargo.toml b/operator/Cargo.toml new file mode 100644 index 000000000..820540a28 --- /dev/null +++ b/operator/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "oabctl" +version = "0.1.0" +edition = "2021" +description = "CLI provisioner for OAB agents on ECS" + +[[bin]] +name = "oabctl" +path = "src/main.rs" + +[dependencies] +aws-config = "1.5" +aws-sdk-ecs = "1.53" +aws-sdk-s3 = "1.65" +aws-sdk-ssm = "1.52" +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +tokio = { version = "1.40", features = ["full"] } +toml = "0.8" +anyhow = "1.0" diff --git a/operator/README.md b/operator/README.md new file mode 100644 index 000000000..5c0ce7334 --- /dev/null +++ b/operator/README.md @@ -0,0 +1,83 @@ +# oabctl — OAB Agent Provisioner for ECS + +CLI tool that provisions and manages OpenAB agents on Amazon ECS Fargate. + +## Quick Start + +```bash +# Build +cd operator && cargo build --release + +# Deploy an agent +oabctl apply -f examples/kiro-01.yaml + +# List running agents +oabctl get oabservice + +# Delete an agent +oabctl delete oabservice kiro-01 --cluster default --namespace prod +``` + +## Prerequisites + +1. **AWS credentials** — IAM role/profile with permissions for ECS, SSM, S3 +2. **S3 bucket** — `oab-control-plane` (manifests + rendered config) +3. **ECS cluster** — default cluster or specify with `--cluster` +4. **VPC** — subnets + security groups for Fargate tasks +5. **ECR image** — OAB container image pushed to ECR +6. **SSM parameters** — bot tokens stored at `/oab/{namespace}/{name}/discord-token` + +## Manifest Schema + +```yaml +apiVersion: oab.dev/v1 +kind: OABService +metadata: + name: kiro-01 + namespace: prod +spec: + capacityProvider: FARGATE_SPOT # FARGATE or FARGATE_SPOT + cpu: 256 # vCPU units + memory: 512 # MB + taskDefinition: + image: + bootstrapFrom: s3://... # agent HOME archive (memory, state) + networking: + subnets: [subnet-xxx] + securityGroups: [sg-xxx] + secrets: + - name: DISCORD_TOKEN + valueFrom: /oab/prod/kiro-01/discord-token + config: + channels: + - type: discord + backend: + type: bedrock + model_id: anthropic.claude-sonnet-4-20250514 + region: us-east-1 + steering: + system_prompt: "..." + features: + stt: false + cronjob: true +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `oabctl apply -f ` | Create or update agents from manifests | +| `oabctl get oabservice [name]` | List agents and their ECS status | +| `oabctl delete oabservice ` | Teardown agent (ECS + S3 cleanup) | + +## How It Works + +1. `oabctl apply` validates the manifest, renders `config.toml` from `spec.config`, uploads to S3 at an immutable path (`config/{ns}/{name}/{generation}/`), registers an ECS task definition, and creates/updates the ECS service. + +2. ECS maintains the desired state — restarts failed tasks, handles rolling deployments. No separate controller needed. + +3. On task startup, `entrypoint.sh` downloads the bootstrap archive and rendered config from S3, then starts OpenAB. + +## Architecture + +See [ADR: ECS Control Plane](../docs/adr/ecs-control-plane.md) for the full design. diff --git a/operator/entrypoint.sh b/operator/entrypoint.sh new file mode 100755 index 000000000..f93dc039f --- /dev/null +++ b/operator/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +# OAB ECS Entrypoint Wrapper +# Downloads bootstrap archive and rendered config before starting OAB. + +# 1. Restore bootstrap (mutable state: memory, knowledge base) +if [ -n "${BOOTSTRAP_FROM:-}" ]; then + echo "[entrypoint] Restoring bootstrap from ${BOOTSTRAP_FROM}..." + aws s3 cp "${BOOTSTRAP_FROM}" /tmp/bootstrap.tar.gz + tar xzf /tmp/bootstrap.tar.gz -C "$HOME" + rm -f /tmp/bootstrap.tar.gz +fi + +# 2. Overwrite with rendered config (AFTER bootstrap, so desired config wins) +if [ -n "${CONFIG_S3_PATH:-}" ]; then + echo "[entrypoint] Downloading config from ${CONFIG_S3_PATH}..." + aws s3 cp "${CONFIG_S3_PATH}" "$HOME/config.toml" +fi + +# 3. Start OAB (DISCORD_TOKEN etc injected via ECS secrets) +echo "[entrypoint] Starting OpenAB..." +exec /usr/bin/openab "$@" diff --git a/operator/examples/kiro-01.yaml b/operator/examples/kiro-01.yaml new file mode 100644 index 000000000..6a969c7d0 --- /dev/null +++ b/operator/examples/kiro-01.yaml @@ -0,0 +1,31 @@ +apiVersion: oab.dev/v1 +kind: OABService +metadata: + name: kiro-01 + namespace: prod +spec: + capacityProvider: FARGATE_SPOT + cpu: 256 + memory: 512 + taskDefinition: + image: 123456789.dkr.ecr.us-east-1.amazonaws.com/openab:latest + bootstrapFrom: s3://oab-backups/agents/kiro-01/latest.tar.gz + networking: + subnets: [subnet-aaa, subnet-bbb] + securityGroups: [sg-oab] + assignPublicIp: false + secrets: + - name: DISCORD_TOKEN + valueFrom: /oab/prod/kiro-01/discord-token + config: + channels: + - type: discord + backend: + type: bedrock + model_id: anthropic.claude-sonnet-4-20250514 + region: us-east-1 + steering: + system_prompt: "You are Kiro, an AI agent running on OpenAB." + features: + stt: false + cronjob: true diff --git a/operator/src/apply.rs b/operator/src/apply.rs new file mode 100644 index 000000000..868a1b4f1 --- /dev/null +++ b/operator/src/apply.rs @@ -0,0 +1,277 @@ +use crate::manifest::OABServiceManifest; +use anyhow::{Context, Result}; +use aws_sdk_ecs::types::{ + AssignPublicIp, AwsVpcConfiguration, CapacityProviderStrategyItem, ContainerDefinition, + KeyValuePair, NetworkConfiguration, Secret, +}; +use aws_sdk_s3::primitives::ByteStream; +use std::path::Path; + +pub async fn run(aws_config: &aws_config::SdkConfig, file_path: &str) -> Result<()> { + let path = Path::new(file_path); + let manifests = load_manifests(path)?; + + if manifests.is_empty() { + anyhow::bail!("no manifests found at {}", file_path); + } + + let ecs = aws_sdk_ecs::Client::new(aws_config); + let s3 = aws_sdk_s3::Client::new(aws_config); + + for m in &manifests { + m.validate()?; + println!(" Applying {}...", m.metadata.name); + apply_one(&ecs, &s3, m).await?; + } + + println!("\n{} service(s) applied.", manifests.len()); + Ok(()) +} + +fn load_manifests(path: &Path) -> Result> { + let mut manifests = Vec::new(); + if path.is_dir() { + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let p = entry.path(); + if p.extension().is_some_and(|e| e == "yaml" || e == "yml") { + manifests.push(parse_manifest(&p)?); + } + } + } else { + manifests.push(parse_manifest(path)?); + } + Ok(manifests) +} + +fn parse_manifest(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + serde_yaml::from_str(&content) + .with_context(|| format!("failed to parse {}", path.display())) +} + +async fn apply_one( + ecs: &aws_sdk_ecs::Client, + s3: &aws_sdk_s3::Client, + m: &OABServiceManifest, +) -> Result<()> { + let service_name = m.ecs_service_name(); + let bucket = "oab-control-plane"; + + // Read current generation from S3 manifest (if exists), increment + let manifest_key = format!("manifests/{}/{}.yaml", m.metadata.namespace, m.metadata.name); + let current_gen = match s3.get_object().bucket(bucket).key(&manifest_key).send().await { + Ok(resp) => { + let bytes = resp.body.collect().await?.into_bytes(); + let existing: OABServiceManifest = serde_yaml::from_slice(&bytes)?; + existing.metadata.generation + } + Err(_) => 0, + }; + let generation = current_gen + 1; + + // 1. Render config.toml and upload to S3 (immutable path) + let config_toml = render_config_toml(&m.spec.config); + let config_key = format!( + "config/{}/{}/{}/config.toml", + m.metadata.namespace, m.metadata.name, generation + ); + s3.put_object() + .bucket(bucket) + .key(&config_key) + .body(ByteStream::from(config_toml.into_bytes())) + .send() + .await + .context("failed to upload config to S3")?; + + // 2. Upload manifest to S3 (record of desired state, with updated generation) + let mut manifest_to_store = serde_yaml::to_value(m)?; + manifest_to_store["metadata"]["generation"] = serde_yaml::Value::Number(generation.into()); + let manifest_yaml = serde_yaml::to_string(&manifest_to_store)?; + let manifest_key = format!("manifests/{}/{}.yaml", m.metadata.namespace, m.metadata.name); + s3.put_object() + .bucket(bucket) + .key(&manifest_key) + .body(ByteStream::from(manifest_yaml.into_bytes())) + .send() + .await + .context("failed to upload manifest to S3")?; + + // 3. Register task definition + let config_s3_path = format!("s3://{}/{}", bucket, config_key); + let task_def_family = service_name.clone(); + + let mut env_vars = vec![ + KeyValuePair::builder() + .name("NAMESPACE") + .value(&m.metadata.namespace) + .build(), + KeyValuePair::builder() + .name("NAME") + .value(&m.metadata.name) + .build(), + KeyValuePair::builder() + .name("CONFIG_S3_PATH") + .value(&config_s3_path) + .build(), + ]; + if let Some(ref bootstrap) = m.spec.bootstrap_from { + env_vars.push( + KeyValuePair::builder() + .name("BOOTSTRAP_FROM") + .value(bootstrap) + .build(), + ); + } + + let secrets: Vec = m + .spec + .secrets + .iter() + .map(|s| { + Secret::builder() + .name(&s.name) + .value_from(&s.value_from) + .build() + .unwrap() + }) + .collect(); + + let container = ContainerDefinition::builder() + .name("openab") + .image(&m.spec.task_definition.image) + .essential(true) + .set_environment(Some(env_vars)) + .set_secrets(if secrets.is_empty() { None } else { Some(secrets) }) + .build(); + + let task_def = ecs + .register_task_definition() + .family(&task_def_family) + .requires_compatibilities(aws_sdk_ecs::types::Compatibility::Fargate) + .network_mode(aws_sdk_ecs::types::NetworkMode::Awsvpc) + .cpu(m.spec.cpu.to_string()) + .memory(m.spec.memory.to_string()) + .container_definitions(container) + .send() + .await + .context("failed to register task definition")?; + + let task_def_arn = task_def + .task_definition() + .and_then(|td| td.task_definition_arn()) + .unwrap_or_default() + .to_string(); + + // 4. Create or update ECS service + let assign_ip = if m.spec.networking.assign_public_ip { + AssignPublicIp::Enabled + } else { + AssignPublicIp::Disabled + }; + + let vpc_config = AwsVpcConfiguration::builder() + .set_subnets(Some(m.spec.networking.subnets.clone())) + .set_security_groups(Some(m.spec.networking.security_groups.clone())) + .assign_public_ip(assign_ip) + .build()?; + + let network_config = NetworkConfiguration::builder() + .awsvpc_configuration(vpc_config) + .build(); + + // Check if service exists + let existing = ecs + .describe_services() + .cluster("default") + .services(&service_name) + .send() + .await; + + let service_active = existing + .as_ref() + .ok() + .and_then(|r| r.services().first()) + .is_some_and(|s| s.status() == Some("ACTIVE")); + + if service_active { + // Update existing service + ecs.update_service() + .cluster("default") + .service(&service_name) + .task_definition(&task_def_arn) + .network_configuration(network_config) + .send() + .await + .context("failed to update ECS service")?; + println!(" ✓ {} updated", m.metadata.name); + } else { + // Create new service + let cap_strategy = CapacityProviderStrategyItem::builder() + .capacity_provider(&m.spec.capacity_provider) + .weight(1) + .build()?; + + ecs.create_service() + .cluster("default") + .service_name(&service_name) + .task_definition(&task_def_arn) + .desired_count(1) + .capacity_provider_strategy(cap_strategy) + .network_configuration(network_config) + .send() + .await + .context("failed to create ECS service")?; + println!( + " ✓ {} created ({}, {}cpu/{}mem)", + m.metadata.name, m.spec.capacity_provider, m.spec.cpu, m.spec.memory + ); + } + + Ok(()) +} + +fn render_config_toml(config: &crate::manifest::AgentConfig) -> String { + let mut out = String::new(); + + if let Some(ref backend) = config.backend { + out.push_str("[backend]\n"); + out.push_str(&format!("type = \"{}\"\n", backend.backend_type)); + if let Some(ref model) = backend.model_id { + out.push_str(&format!("model_id = \"{}\"\n", model)); + } + if let Some(ref region) = backend.region { + out.push_str(&format!("region = \"{}\"\n", region)); + } + out.push('\n'); + } + + for (i, ch) in config.channels.iter().enumerate() { + out.push_str("[[channels]]\n"); + out.push_str(&format!("type = \"{}\"\n", ch.channel_type)); + for (k, v) in &ch.extra { + if let serde_yaml::Value::String(s) = v { + out.push_str(&format!("{} = \"{}\"\n", k, s)); + } + } + if i < config.channels.len() - 1 { + out.push('\n'); + } + } + + if let Some(ref steering) = config.steering { + out.push_str("\n[steering]\n"); + if let Some(ref prompt) = steering.system_prompt { + out.push_str(&format!("system_prompt = \"\"\"\n{}\n\"\"\"\n", prompt)); + } + } + + if let Some(ref features) = config.features { + out.push_str("\n[features]\n"); + out.push_str(&format!("stt = {}\n", features.stt)); + out.push_str(&format!("cronjob = {}\n", features.cronjob)); + } + + out +} diff --git a/operator/src/delete.rs b/operator/src/delete.rs new file mode 100644 index 000000000..199049fd9 --- /dev/null +++ b/operator/src/delete.rs @@ -0,0 +1,70 @@ +use anyhow::{Context, Result}; + +pub async fn run( + aws_config: &aws_config::SdkConfig, + resource: &str, + name: &str, + cluster: &str, + namespace: &str, +) -> Result<()> { + if resource != "oabservice" { + anyhow::bail!("unknown resource type: {}. Use 'oabservice'", resource); + } + + let service_name = format!("oab-{}-{}", namespace, name); + let ecs = aws_sdk_ecs::Client::new(aws_config); + let s3 = aws_sdk_s3::Client::new(aws_config); + let bucket = "oab-control-plane"; + + println!("Deleting {}...", name); + + // 1. Scale to 0 + let _ = ecs + .update_service() + .cluster(cluster) + .service(&service_name) + .desired_count(0) + .send() + .await; + println!(" ✓ Scaled to 0"); + + // 2. Delete ECS service + ecs.delete_service() + .cluster(cluster) + .service(&service_name) + .force(true) + .send() + .await + .context("failed to delete ECS service")?; + println!(" ✓ ECS service deleted"); + + // 3. Clean up S3 manifest + let manifest_key = format!("manifests/{}/{}.yaml", namespace, name); + let _ = s3 + .delete_object() + .bucket(bucket) + .key(&manifest_key) + .send() + .await; + println!(" ✓ Manifest removed from S3"); + + // 4. Clean up S3 config (list and delete all generations) + let config_prefix = format!("config/{}/{}/", namespace, name); + let list = s3 + .list_objects_v2() + .bucket(bucket) + .prefix(&config_prefix) + .send() + .await; + if let Ok(resp) = list { + for obj in resp.contents() { + if let Some(key) = obj.key() { + let _ = s3.delete_object().bucket(bucket).key(key).send().await; + } + } + } + println!(" ✓ Config artifacts removed from S3"); + + println!("\n✓ {} deleted", name); + Ok(()) +} diff --git a/operator/src/get.rs b/operator/src/get.rs new file mode 100644 index 000000000..0011e8378 --- /dev/null +++ b/operator/src/get.rs @@ -0,0 +1,114 @@ +use anyhow::{Context, Result}; + +pub async fn run( + aws_config: &aws_config::SdkConfig, + resource: &str, + name: Option<&str>, + cluster: &str, +) -> Result<()> { + if resource != "oabservice" { + anyhow::bail!("unknown resource type: {}. Use 'oabservice'", resource); + } + + let ecs = aws_sdk_ecs::Client::new(aws_config); + + let services = if let Some(name) = name { + // Describe a specific service + let svc_name = if name.starts_with("oab-") { + name.to_string() + } else { + // Try to find by listing all oab- services and matching the name suffix + format!("oab-prod-{}", name) // TODO: support --namespace flag + }; + vec![svc_name] + } else { + // List all oab- services + let mut service_arns = Vec::new(); + let mut next_token = None; + loop { + let mut req = ecs.list_services().cluster(cluster); + if let Some(token) = &next_token { + req = req.next_token(token); + } + let resp = req.send().await.context("failed to list ECS services")?; + for arn in resp.service_arns() { + if arn.contains("/oab-") { + service_arns.push(arn.to_string()); + } + } + next_token = resp.next_token().map(|s| s.to_string()); + if next_token.is_none() { + break; + } + } + service_arns + }; + + if services.is_empty() { + println!("No OAB services found."); + return Ok(()); + } + + // Describe in batches of 10 + println!( + "{:<12} {:<10} {:<5} {:<6} {:<14} {:<6} STATUS", + "NAME", "NAMESPACE", "CPU", "MEM", "CAPACITY", "TASKS" + ); + + for chunk in services.chunks(10) { + let resp = ecs + .describe_services() + .cluster(cluster) + .set_services(Some(chunk.to_vec())) + .send() + .await + .context("failed to describe ECS services")?; + + for svc in resp.services() { + let svc_name = svc.service_name().unwrap_or("-"); + // Parse oab-{namespace}-{name} + let parts: Vec<&str> = svc_name.splitn(3, '-').collect(); + let (namespace, agent_name) = if parts.len() == 3 { + (parts[1], parts[2]) + } else { + ("?", svc_name) + }; + + let status = svc.status().unwrap_or("UNKNOWN"); + let running = svc.running_count(); + let desired = svc.desired_count(); + + // Get cpu/memory from task definition + let (cpu, mem) = if let Some(td_arn) = svc.task_definition() { + let td_resp = ecs + .describe_task_definition() + .task_definition(td_arn) + .send() + .await; + if let Ok(td) = td_resp { + let td = td.task_definition(); + let cpu = td.and_then(|t| t.cpu()).unwrap_or("-"); + let mem = td.and_then(|t| t.memory()).unwrap_or("-"); + (cpu.to_string(), mem.to_string()) + } else { + ("-".to_string(), "-".to_string()) + } + } else { + ("-".to_string(), "-".to_string()) + }; + + let cap = svc + .capacity_provider_strategy() + .first() + .map(|c| c.capacity_provider()) + .unwrap_or("FARGATE"); + + println!( + "{:<12} {:<10} {:<5} {:<6} {:<14} {}/{:<3} {}", + agent_name, namespace, cpu, mem, cap, running, desired, status + ); + } + } + + Ok(()) +} diff --git a/operator/src/main.rs b/operator/src/main.rs new file mode 100644 index 000000000..878473094 --- /dev/null +++ b/operator/src/main.rs @@ -0,0 +1,60 @@ +mod manifest; +mod apply; +mod get; +mod delete; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "oabctl", about = "OAB agent provisioner for ECS")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Create or update OAB services from manifest files + Apply { + /// Path to manifest file or directory + #[arg(short, long)] + file: String, + }, + /// List OAB services and their status + Get { + /// Resource type + resource: String, + /// Optional resource name + name: Option, + /// ECS cluster name + #[arg(long, default_value = "default")] + cluster: String, + }, + /// Delete an OAB service + Delete { + /// Resource type + resource: String, + /// Resource name + name: String, + /// ECS cluster name + #[arg(long, default_value = "default")] + cluster: String, + /// Namespace + #[arg(long, default_value = "prod")] + namespace: String, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + + match cli.command { + Commands::Apply { file } => apply::run(&config, &file).await, + Commands::Get { resource, name, cluster } => get::run(&config, &resource, name.as_deref(), &cluster).await, + Commands::Delete { resource, name, cluster, namespace } => { + delete::run(&config, &resource, &name, &cluster, &namespace).await + } + } +} diff --git a/operator/src/manifest.rs b/operator/src/manifest.rs new file mode 100644 index 000000000..fd6efb1b1 --- /dev/null +++ b/operator/src/manifest.rs @@ -0,0 +1,135 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OABServiceManifest { + pub api_version: String, + pub kind: String, + pub metadata: Metadata, + pub spec: Spec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Metadata { + pub name: String, + pub namespace: String, + #[serde(default)] + pub generation: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Spec { + #[serde(default = "default_capacity_provider")] + pub capacity_provider: String, + pub cpu: i32, + pub memory: i32, + pub task_definition: TaskDefinition, + #[serde(default)] + pub bootstrap_from: Option, + pub networking: Networking, + pub config: AgentConfig, + #[serde(default)] + pub secrets: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct TaskDefinition { + pub image: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Networking { + pub subnets: Vec, + pub security_groups: Vec, + #[serde(default)] + pub assign_public_ip: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SecretRef { + pub name: String, + pub value_from: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AgentConfig { + #[serde(default)] + pub channels: Vec, + #[serde(default)] + pub backend: Option, + #[serde(default)] + pub steering: Option, + #[serde(default)] + pub features: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ChannelConfig { + #[serde(rename = "type")] + pub channel_type: String, + #[serde(flatten)] + pub extra: std::collections::HashMap, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct BackendConfig { + #[serde(rename = "type")] + pub backend_type: String, + #[serde(default)] + pub model_id: Option, + #[serde(default)] + pub region: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SteeringConfig { + #[serde(default)] + pub system_prompt: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct FeaturesConfig { + #[serde(default)] + pub stt: bool, + #[serde(default)] + pub cronjob: bool, +} + +fn default_capacity_provider() -> String { + "FARGATE".to_string() +} + +impl OABServiceManifest { + pub fn validate(&self) -> anyhow::Result<()> { + if self.api_version != "oab.dev/v1" { + anyhow::bail!("unsupported apiVersion: {}", self.api_version); + } + if self.kind != "OABService" { + anyhow::bail!("unsupported kind: {}", self.kind); + } + if self.metadata.name.is_empty() { + anyhow::bail!("metadata.name is required"); + } + if self.metadata.namespace.is_empty() { + anyhow::bail!("metadata.namespace is required"); + } + let valid_cp = ["FARGATE", "FARGATE_SPOT"]; + if !valid_cp.contains(&self.spec.capacity_provider.as_str()) { + anyhow::bail!("capacityProvider must be FARGATE or FARGATE_SPOT"); + } + if self.spec.networking.subnets.is_empty() { + anyhow::bail!("networking.subnets must not be empty"); + } + if self.spec.networking.security_groups.is_empty() { + anyhow::bail!("networking.securityGroups must not be empty"); + } + Ok(()) + } + + pub fn ecs_service_name(&self) -> String { + format!("oab-{}-{}", self.metadata.namespace, self.metadata.name) + } +} diff --git a/src/acp/connection.rs b/src/acp/connection.rs index f49c0f503..8df3451f4 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -1,15 +1,16 @@ -use crate::acp::protocol::{ConfigOption, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse, parse_config_options}; +use crate::acp::protocol::{ + parse_config_options, ConfigOption, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse, +}; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin}; use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::task::JoinHandle; -use tracing::{debug, error, info}; - +use tracing::{debug, error, info, trace}; /// Pick the most permissive selectable permission option from ACP options. fn pick_best_option(options: &[Value]) -> Option { @@ -120,6 +121,7 @@ pub struct AcpConnection { pub last_active: Instant, pub session_reset: bool, _reader_handle: JoinHandle<()>, + _stderr_handle: Option>, } /// Build the final set of env vars for the agent subprocess. @@ -148,6 +150,113 @@ fn build_agent_env( (result, inherited) } +/// Reader loop body: reads JSON-RPC messages from `reader`, auto-replies +/// `session/request_permission` via `writer`, resolves pending responses, +/// and forwards notifications + stale id-bearing messages to the active +/// subscriber. Extracted as a free generic function so unit tests can drive +/// it with `tokio::io::duplex()` halves instead of a real child process. +pub(crate) async fn run_reader_loop( + reader: R, + writer: Arc>, + pending: Arc>>>, + notify_tx: Arc>>>, +) where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + let mut reader = BufReader::new(reader); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, // EOF + Ok(_) => {} + Err(e) => { + error!("reader error: {e}"); + break; + } + } + let msg: JsonRpcMessage = match serde_json::from_str(line.trim()) { + Ok(m) => m, + Err(_) => continue, + }; + debug!(line = line.trim(), "acp_recv"); + + // Auto-reply session/request_permission + if msg.method.as_deref() == Some("session/request_permission") { + if let Some(id) = msg.id { + let title = msg + .params + .as_ref() + .and_then(|p| p.get("toolCall")) + .and_then(|t| t.get("title")) + .and_then(|t| t.as_str()) + .unwrap_or("?"); + + let outcome = build_permission_response(msg.params.as_ref()); + info!(title, %outcome, "auto-respond permission"); + let reply = JsonRpcResponse::new(id, outcome); + if let Ok(data) = serde_json::to_string(&reply) { + let mut w = writer.lock().await; + let _ = w.write_all(format!("{data}\n").as_bytes()).await; + let _ = w.flush().await; + } + } + continue; + } + + // Response (has id) → resolve pending AND forward to subscriber + if let Some(id) = msg.id { + let mut map = pending.lock().await; + if let Some(tx) = map.remove(&id) { + // Forward to subscriber so they see the completion + let sub = notify_tx.lock().await; + if let Some(ntx) = sub.as_ref() { + // Clone the essential fields for the subscriber + let _ = ntx.send(JsonRpcMessage { + id: Some(id), + method: None, + result: msg.result.clone(), + error: msg.error.clone(), + params: None, + }); + } + let _ = tx.send(msg); + continue; + } + // Stale id (#732): pending was already abandoned. Falls through + // to subscriber forwarding; the adapter recv loop filters by + // request_id so it can't leak into the next prompt. + trace!(request_id = id, "stale id-bearing message after abandon"); + } + + // Notification → forward to subscriber + let sub = notify_tx.lock().await; + if let Some(tx) = sub.as_ref() { + let _ = tx.send(msg); + } + } + + // Connection closed — resolve all pending with error + let mut map = pending.lock().await; + for (_, tx) in map.drain() { + let _ = tx.send(JsonRpcMessage { + id: None, + method: None, + result: None, + error: Some(crate::acp::protocol::JsonRpcError { + code: -1, + message: "connection closed".into(), + data: None, + }), + params: None, + }); + } + // Close the notify channel so rx.recv() returns None + let mut sub = notify_tx.lock().await; + *sub = None; +} + impl AcpConnection { pub async fn spawn( command: &str, @@ -162,7 +271,7 @@ impl AcpConnection { cmd.args(args) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) .current_dir(working_dir); // Create a new process group so we can kill the entire tree. // SAFETY: setpgid is async-signal-safe (POSIX.1-2008) and called @@ -187,20 +296,39 @@ impl AcpConnection { // Preserve the real HOME so agents can find OAuth/auth files (~/.codex, // ~/.claude, ~/.config/gh, etc.). working_dir is already set via // current_dir() above and is not necessarily the user's home directory. - cmd.env("HOME", std::env::var("HOME").unwrap_or_else(|_| working_dir.into())); - cmd.env("PATH", std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into())); + cmd.env( + "HOME", + std::env::var("HOME").unwrap_or_else(|_| working_dir.into()), + ); + cmd.env( + "PATH", + std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()), + ); #[cfg(unix)] { - cmd.env("USER", std::env::var("USER").unwrap_or_else(|_| "agent".into())); + cmd.env( + "USER", + std::env::var("USER").unwrap_or_else(|_| "agent".into()), + ); } #[cfg(windows)] { // Windows requires SystemRoot for DLL loading and basic OS functionality. // USERPROFILE is the Windows equivalent of HOME. - cmd.env("USERPROFILE", std::env::var("USERPROFILE").unwrap_or_else(|_| working_dir.into())); - cmd.env("USERNAME", std::env::var("USERNAME").unwrap_or_else(|_| "agent".into())); - if let Ok(v) = std::env::var("SystemRoot") { cmd.env("SystemRoot", v); } - if let Ok(v) = std::env::var("SystemDrive") { cmd.env("SystemDrive", v); } + cmd.env( + "USERPROFILE", + std::env::var("USERPROFILE").unwrap_or_else(|_| working_dir.into()), + ); + cmd.env( + "USERNAME", + std::env::var("USERNAME").unwrap_or_else(|_| "agent".into()), + ); + if let Ok(v) = std::env::var("SystemRoot") { + cmd.env("SystemRoot", v); + } + if let Ok(v) = std::env::var("SystemDrive") { + cmd.env("SystemDrive", v); + } } for (k, v) in env { cmd.env(k, expand_env(v)); @@ -223,112 +351,54 @@ impl AcpConnection { let mut proc = cmd .spawn() .map_err(|e| anyhow!("failed to spawn {command}: {e}"))?; - let child_pgid = proc.id() - .and_then(|pid| i32::try_from(pid).ok()); + let child_pgid = proc.id().and_then(|pid| i32::try_from(pid).ok()); let stdout = proc.stdout.take().ok_or_else(|| anyhow!("no stdout"))?; let stdin = proc.stdin.take().ok_or_else(|| anyhow!("no stdin"))?; let stdin = Arc::new(Mutex::new(stdin)); - let pending: Arc>>> = - Arc::new(Mutex::new(HashMap::new())); - let notify_tx: Arc>>> = - Arc::new(Mutex::new(None)); - - let reader_handle = { - let pending = pending.clone(); - let notify_tx = notify_tx.clone(); - let stdin_clone = stdin.clone(); - tokio::spawn(async move { - let mut reader = BufReader::new(stdout); + // Capture agent stderr and log it (ACP spec: agents MAY write to stderr + // for logging; clients MAY capture or ignore it). + let stderr_handle = if let Some(stderr) = proc.stderr.take() { + let cmd_name = command.to_string(); + Some(tokio::spawn(async move { + let mut reader = BufReader::new(stderr); let mut line = String::new(); loop { line.clear(); match reader.read_line(&mut line).await { - Ok(0) => break, // EOF - Ok(_) => {} - Err(e) => { - error!("reader error: {e}"); - break; - } - } - let msg: JsonRpcMessage = match serde_json::from_str(line.trim()) { - Ok(m) => m, - Err(_) => continue, - }; - debug!(line = line.trim(), "acp_recv"); - - // Auto-reply session/request_permission - if msg.method.as_deref() == Some("session/request_permission") { - if let Some(id) = msg.id { - let title = msg - .params - .as_ref() - .and_then(|p| p.get("toolCall")) - .and_then(|t| t.get("title")) - .and_then(|t| t.as_str()) - .unwrap_or("?"); - - let outcome = build_permission_response(msg.params.as_ref()); - info!(title, %outcome, "auto-respond permission"); - let reply = JsonRpcResponse::new(id, outcome); - if let Ok(data) = serde_json::to_string(&reply) { - let mut w = stdin_clone.lock().await; - let _ = w.write_all(format!("{data}\n").as_bytes()).await; - let _ = w.flush().await; + Ok(0) => break, + Ok(_) => { + let trimmed = line.trim(); + if !trimmed.is_empty() { + let sanitized: String = trimmed.chars() + .filter(|c| !c.is_control() || *c == '\t') + .collect(); + if !sanitized.is_empty() { + tracing::warn!(agent = %cmd_name, "{sanitized}"); + } } } - continue; - } - - // Response (has id) → resolve pending AND forward to subscriber - if let Some(id) = msg.id { - let mut map = pending.lock().await; - if let Some(tx) = map.remove(&id) { - // Forward to subscriber so they see the completion - let sub = notify_tx.lock().await; - if let Some(ntx) = sub.as_ref() { - // Clone the essential fields for the subscriber - let _ = ntx.send(JsonRpcMessage { - id: Some(id), - method: None, - result: msg.result.clone(), - error: msg.error.clone(), - params: None, - }); - } - let _ = tx.send(msg); - continue; - } - } - - // Notification → forward to subscriber - let sub = notify_tx.lock().await; - if let Some(tx) = sub.as_ref() { - let _ = tx.send(msg); + Err(_) => break, } } - - // Connection closed — resolve all pending with error - let mut map = pending.lock().await; - for (_, tx) in map.drain() { - let _ = tx.send(JsonRpcMessage { - id: None, - method: None, - result: None, - error: Some(crate::acp::protocol::JsonRpcError { - code: -1, - message: "connection closed".into(), - }), - params: None, - }); - } - // Close the notify channel so rx.recv() returns None - let mut sub = notify_tx.lock().await; - *sub = None; - }) + })) + } else { + None }; + let pending: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + let notify_tx: Arc>>> = + Arc::new(Mutex::new(None)); + + let reader_handle = tokio::spawn(run_reader_loop( + stdout, + stdin.clone(), + pending.clone(), + notify_tx.clone(), + )); + Ok(Self { _proc: proc, child_pgid, @@ -342,6 +412,7 @@ impl AcpConnection { last_active: Instant::now(), session_reset: false, _reader_handle: reader_handle, + _stderr_handle: stderr_handle, }) } @@ -403,19 +474,22 @@ impl AcpConnection { .and_then(|c| c.get("loadSession")) .and_then(|v| v.as_bool()) .unwrap_or(false); - info!(agent = agent_name, load_session = self.supports_load_session, "initialized"); + info!( + agent = agent_name, + load_session = self.supports_load_session, + "initialized" + ); Ok(()) } pub async fn session_new(&mut self, cwd: &str) -> Result { let resp = self - .send_request( - "session/new", - Some(json!({"cwd": cwd, "mcpServers": []})), - ) + .send_request("session/new", Some(json!({"cwd": cwd, "mcpServers": []}))) .await?; - let session_id = resp.result.as_ref() + let session_id = resp + .result + .as_ref() .and_then(|r| r.get("sessionId")) .and_then(|s| s.as_str()) .ok_or_else(|| anyhow!("no sessionId in session/new response"))? @@ -434,7 +508,11 @@ impl AcpConnection { /// Set a config option (e.g. model, mode) via ACP session/set_config_option. /// Returns the updated list of all config options. - pub async fn set_config_option(&mut self, config_id: &str, value: &str) -> Result> { + pub async fn set_config_option( + &mut self, + config_id: &str, + value: &str, + ) -> Result> { let session_id = self .acp_session_id .as_ref() @@ -462,7 +540,10 @@ impl AcpConnection { Err(_) => { // Fall back: send as a slash command (e.g. "/model claude-sonnet-4") let cmd = format!("/{config_id} {value}"); - info!(cmd, "set_config_option not supported, falling back to prompt"); + info!( + cmd, + "set_config_option not supported, falling back to prompt" + ); let _resp = self .send_request( "session/prompt", @@ -503,10 +584,7 @@ impl AcpConnection { let id = self.next_id(); // Convert content blocks to JSON - let prompt_json: Vec = content_blocks - .iter() - .map(|b| b.to_json()) - .collect(); + let prompt_json: Vec = content_blocks.iter().map(|b| b.to_json()).collect(); let req = JsonRpcRequest::new( id, @@ -531,6 +609,26 @@ impl AcpConnection { self.last_active = Instant::now(); } + /// Drop the pending entry for `request_id` and best-effort send + /// `session/cancel` as a JSON-RPC notification (no id; per ACP spec the + /// agent does not reply). Errors are swallowed: the agent process may + /// already be dead, in which case the stdin write fails harmlessly. + /// See #732. + pub async fn abandon_request(&self, request_id: u64) { + self.pending.lock().await.remove(&request_id); + let Some(session_id) = self.acp_session_id.as_deref() else { + return; + }; + let req = json!({ + "jsonrpc": "2.0", + "method": "session/cancel", + "params": {"sessionId": session_id}, + }); + if let Ok(data) = serde_json::to_string(&req) { + let _ = self.send_raw(&data).await; + } + } + /// Return a clone of the stdin handle for lock-free cancel. pub fn cancel_handle(&self) -> Arc> { Arc::clone(&self.stdin) @@ -572,11 +670,15 @@ impl AcpConnection { #[cfg(unix)] { // Stage 1: SIGTERM the process group - unsafe { libc::kill(-pgid, libc::SIGTERM); } + unsafe { + libc::kill(-pgid, libc::SIGTERM); + } // Stage 2: SIGKILL after brief grace (std::thread survives runtime shutdown) std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(1500)); - unsafe { libc::kill(-pgid, libc::SIGKILL); } + unsafe { + libc::kill(-pgid, libc::SIGKILL); + } }); } #[cfg(not(unix))] @@ -588,6 +690,9 @@ impl AcpConnection { impl Drop for AcpConnection { fn drop(&mut self) { + if let Some(handle) = self._stderr_handle.take() { + handle.abort(); + } self.kill_process_group(); } } @@ -728,3 +833,105 @@ mod tests { assert!(inherited.is_empty()); } } + +#[cfg(test)] +mod reader_loop_tests { + use super::*; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::io::{duplex, AsyncWriteExt}; + use tokio::sync::{mpsc, oneshot, Mutex}; + + /// #732 stale-id path: when a response arrives for an id the broker has + /// already abandoned, the reader must (a) not crash, (b) leave `pending` + /// untouched, and (c) still forward the message to whoever is currently + /// subscribed — the adapter recv loop is responsible for filtering by + /// request_id so the stray response never leaks into the next prompt. + #[tokio::test] + async fn stale_id_response_is_forwarded_without_pending_entry() { + let (mut agent_stdout_writer, agent_stdout_reader) = duplex(8 * 1024); + let (agent_stdin_writer, _agent_stdin_reader) = duplex(8 * 1024); + + let pending: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + let notify_tx: Arc>>> = + Arc::new(Mutex::new(None)); + + let (sub_tx, mut sub_rx) = mpsc::unbounded_channel(); + *notify_tx.lock().await = Some(sub_tx); + + let writer = Arc::new(Mutex::new(agent_stdin_writer)); + let handle = tokio::spawn(run_reader_loop( + agent_stdout_reader, + writer, + pending.clone(), + notify_tx.clone(), + )); + + let stale = b"{\"jsonrpc\":\"2.0\",\"id\":42,\"result\":{\"stopReason\":\"ok\"}}\n"; + agent_stdout_writer.write_all(stale).await.unwrap(); + agent_stdout_writer.flush().await.unwrap(); + + let forwarded = tokio::time::timeout( + std::time::Duration::from_secs(2), + sub_rx.recv(), + ) + .await + .expect("subscriber should receive stale message before timeout") + .expect("subscriber channel should not be closed"); + assert_eq!(forwarded.id, Some(42)); + assert!(pending.lock().await.is_empty()); + + drop(agent_stdout_writer); + handle.await.unwrap(); + } + + /// Matched-id path: when a response's id is in `pending`, the loop must + /// resolve the oneshot AND forward a copy to the subscriber so the + /// adapter's recv loop sees the completion. Guards against regressions + /// that would suppress the forward branch while keeping resolve. + #[tokio::test] + async fn matched_id_response_resolves_pending_and_forwards() { + let (mut agent_stdout_writer, agent_stdout_reader) = duplex(8 * 1024); + let (agent_stdin_writer, _agent_stdin_reader) = duplex(8 * 1024); + + let pending: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + let notify_tx: Arc>>> = + Arc::new(Mutex::new(None)); + + let (resp_tx, resp_rx) = oneshot::channel(); + pending.lock().await.insert(7, resp_tx); + + let (sub_tx, mut sub_rx) = mpsc::unbounded_channel(); + *notify_tx.lock().await = Some(sub_tx); + + let writer = Arc::new(Mutex::new(agent_stdin_writer)); + let handle = tokio::spawn(run_reader_loop( + agent_stdout_reader, + writer, + pending.clone(), + notify_tx.clone(), + )); + + let payload = b"{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"stopReason\":\"end_turn\"}}\n"; + agent_stdout_writer.write_all(payload).await.unwrap(); + agent_stdout_writer.flush().await.unwrap(); + + let resolved = tokio::time::timeout(std::time::Duration::from_secs(2), resp_rx) + .await + .expect("oneshot should resolve") + .expect("oneshot should not be cancelled"); + assert_eq!(resolved.id, Some(7)); + + let forwarded = tokio::time::timeout(std::time::Duration::from_secs(2), sub_rx.recv()) + .await + .expect("subscriber should receive forwarded copy") + .expect("subscriber channel should not be closed"); + assert_eq!(forwarded.id, Some(7)); + assert!(pending.lock().await.is_empty()); + + drop(agent_stdout_writer); + handle.await.unwrap(); + } +} diff --git a/src/acp/mod.rs b/src/acp/mod.rs index c67cad827..f7d0141e2 100644 --- a/src/acp/mod.rs +++ b/src/acp/mod.rs @@ -2,6 +2,6 @@ pub mod connection; pub mod pool; pub mod protocol; +pub use connection::ContentBlock; pub use pool::SessionPool; pub use protocol::{classify_notification, AcpEvent}; -pub use connection::ContentBlock; diff --git a/src/acp/pool.rs b/src/acp/pool.rs index a146abb0f..42fc1113b 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -32,12 +32,7 @@ pub struct SessionPool { mapping_path: PathBuf, } -type EvictionCandidate = ( - String, - Arc>, - Instant, - Option, -); +type EvictionCandidate = (String, Arc>, Instant, Option); fn remove_if_same_handle( map: &mut HashMap>>, @@ -54,10 +49,7 @@ fn remove_if_same_handle( } } -fn get_or_insert_gate( - map: &mut HashMap>>, - key: &str, -) -> Arc> { +fn get_or_insert_gate(map: &mut HashMap>>, key: &str) -> Arc> { map.entry(key.to_string()) .or_insert_with(|| Arc::new(Mutex::new(()))) .clone() @@ -104,7 +96,9 @@ impl SessionPool { } }; let tmp = self.mapping_path.with_extension("json.tmp"); - if let Err(e) = std::fs::write(&tmp, &data).and_then(|_| std::fs::rename(&tmp, &self.mapping_path)) { + if let Err(e) = + std::fs::write(&tmp, &data).and_then(|_| std::fs::rename(&tmp, &self.mapping_path)) + { warn!(path = %self.mapping_path.display(), error = %e, "failed to persist thread mapping"); } } @@ -157,7 +151,12 @@ impl SessionPool { skipped_locked_candidates += 1; continue; }; - let candidate = (key, conn_handle, conn.last_active, conn.acp_session_id.clone()); + let candidate = ( + key, + conn_handle, + conn.last_active, + conn.acp_session_id.clone(), + ); match &eviction_candidate { Some((_, _, oldest_last_active, _)) if candidate.2 >= *oldest_last_active => {} _ => eviction_candidate = Some(candidate), @@ -250,17 +249,25 @@ impl SessionPool { state.active.insert(thread_id.to_string(), new_conn); self.save_mapping(&state.suspended); if !cancel_session_id.is_empty() { - state.cancel_handles.insert(thread_id.to_string(), (cancel_handle, cancel_session_id)); + state + .cancel_handles + .insert(thread_id.to_string(), (cancel_handle, cancel_session_id)); } Ok(()) } /// Get mutable access to a connection. Caller must have called get_or_create first. + /// + /// Only the per-connection `Mutex` is held during `f`; the pool-level + /// `RwLock` is acquired briefly (read-only) to look up the `Arc` and then + /// released, so other connections can be used concurrently. pub async fn with_connection(&self, thread_id: &str, f: F) -> Result where F: for<'a> FnOnce( &'a mut AcpConnection, - ) -> std::pin::Pin> + Send + 'a>>, + ) -> std::pin::Pin< + Box> + Send + 'a>, + >, { let conn = { let state = self.state.read().await; @@ -311,7 +318,10 @@ impl SessionPool { pub async fn cancel_session(&self, thread_id: &str) -> Result<()> { let (stdin, session_id) = { let state = self.state.read().await; - state.cancel_handles.get(thread_id).cloned() + state + .cancel_handles + .get(thread_id) + .cloned() .ok_or_else(|| anyhow!("no session for thread {thread_id}"))? }; let data = serde_json::to_string(&serde_json::json!({ @@ -414,7 +424,11 @@ impl SessionPool { // awaiting a connection lock). let snapshot: Vec<(String, Arc>)> = { let state = self.state.read().await; - state.active.iter().map(|(k, v)| (k.clone(), Arc::clone(v))).collect() + state + .active + .iter() + .map(|(k, v)| (k.clone(), Arc::clone(v))) + .collect() }; let mut session_ids: Vec<(String, String)> = Vec::new(); diff --git a/src/acp/protocol.rs b/src/acp/protocol.rs index 25cfb9374..099d98b71 100644 --- a/src/acp/protocol.rs +++ b/src/acp/protocol.rs @@ -14,7 +14,12 @@ pub struct JsonRpcRequest { impl JsonRpcRequest { pub fn new(id: u64, method: impl Into, params: Option) -> Self { - Self { jsonrpc: "2.0", id, method: method.into(), params } + Self { + jsonrpc: "2.0", + id, + method: method.into(), + params, + } } } @@ -27,7 +32,11 @@ pub struct JsonRpcResponse { impl JsonRpcResponse { pub fn new(id: u64, result: Value) -> Self { - Self { jsonrpc: "2.0", id, result } + Self { + jsonrpc: "2.0", + id, + result, + } } } @@ -46,11 +55,32 @@ pub struct JsonRpcMessage { pub struct JsonRpcError { pub code: i64, pub message: String, + /// Optional structured data from the agent (JSON-RPC `error.data`). + /// Agents like codex-acp include `{"message": "...", "codex_error_info": "..."}`. + pub data: Option, +} + +impl JsonRpcError { + /// Extract a human-readable detail from `error.data.message` if present. + /// + /// The `"message"` key is a convention used by codex-acp and aligns with + /// common JSON-RPC practice, but is NOT mandated by the ACP spec. + /// Other agents may use `"detail"`, `"reason"`, etc. — extend here if needed. + pub fn data_message(&self) -> Option<&str> { + self.data + .as_ref() + .and_then(|d| d.get("message")) + .and_then(|m| m.as_str()) + } } impl std::fmt::Display for JsonRpcError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "JSON-RPC error {}: {}", self.code, self.message) + write!(f, "JSON-RPC error {}: {}", self.code, self.message)?; + if let Some(detail) = self.data_message() { + write!(f, " — {detail}")?; + } + Ok(()) } } @@ -95,17 +125,26 @@ pub fn parse_config_options(result: &Value) -> Vec { let mut options = Vec::new(); if let Some(models) = result.get("models") { - let current = models.get("currentModelId").and_then(|v| v.as_str()).unwrap_or(""); + let current = models + .get("currentModelId") + .and_then(|v| v.as_str()) + .unwrap_or(""); if let Some(available) = models.get("availableModels").and_then(|v| v.as_array()) { let values: Vec = available .iter() .filter_map(|m| { - let id = m.get("modelId").or_else(|| m.get("id")).and_then(|v| v.as_str())?; + let id = m + .get("modelId") + .or_else(|| m.get("id")) + .and_then(|v| v.as_str())?; let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id); Some(ConfigOptionValue { value: id.to_string(), name: name.to_string(), - description: m.get("description").and_then(|v| v.as_str()).map(String::from), + description: m + .get("description") + .and_then(|v| v.as_str()) + .map(String::from), }) }) .collect(); @@ -124,7 +163,10 @@ pub fn parse_config_options(result: &Value) -> Vec { } if let Some(modes) = result.get("modes") { - let current = modes.get("currentModeId").and_then(|v| v.as_str()).unwrap_or(""); + let current = modes + .get("currentModeId") + .and_then(|v| v.as_str()) + .unwrap_or(""); if let Some(available) = modes.get("availableModes").and_then(|v| v.as_array()) { let values: Vec = available .iter() @@ -134,7 +176,10 @@ pub fn parse_config_options(result: &Value) -> Vec { Some(ConfigOptionValue { value: id.to_string(), name: name.to_string(), - description: m.get("description").and_then(|v| v.as_str()).map(String::from), + description: m + .get("description") + .and_then(|v| v.as_str()) + .map(String::from), }) }) .collect(); @@ -161,9 +206,18 @@ pub fn parse_config_options(result: &Value) -> Vec { pub enum AcpEvent { Text(String), Thinking, - ToolStart { id: String, title: String }, - ToolDone { id: String, title: String, status: String }, - ConfigUpdate { options: Vec }, + ToolStart { + id: String, + title: String, + }, + ToolDone { + id: String, + title: String, + status: String, + }, + ConfigUpdate { + options: Vec, + }, Status, } @@ -190,18 +244,32 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option { let text = update.get("content")?.get("text")?.as_str()?; Some(AcpEvent::Text(text.to_string())) } - "agent_thought_chunk" => { - Some(AcpEvent::Thinking) - } + "agent_thought_chunk" => Some(AcpEvent::Thinking), "tool_call" => { - let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = update + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); Some(AcpEvent::ToolStart { id: tool_id, title }) } "tool_call_update" => { - let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = update + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let status = update + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); if status == "completed" || status == "failed" { - Some(AcpEvent::ToolDone { id: tool_id, title, status }) + Some(AcpEvent::ToolDone { + id: tool_id, + title, + status, + }) } else { Some(AcpEvent::ToolStart { id: tool_id, title }) } diff --git a/src/adapter.rs b/src/adapter.rs index 89b2ae4db..baaf2c783 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -2,7 +2,7 @@ use anyhow::Result; use async_trait::async_trait; use serde::Serialize; use std::sync::Arc; -use tracing::error; +use tracing::{error, warn}; use crate::acp::{classify_notification, AcpEvent, ContentBlock, SessionPool}; use crate::config::{ReactionsConfig, ToolDisplay}; @@ -11,6 +11,91 @@ use crate::format; use crate::markdown::{self, TableMode}; use crate::reactions::StatusReactionController; +// --- Output directive parsing --- + +/// Parsed directives from agent output header block. +/// Consecutive `[[key:value]]` lines at the start of output are directives. +#[derive(Default, Debug)] +pub struct OutputDirectives { + /// Message ID to reply to (Discord: message_reference) + pub reply_to: Option, +} + +/// Parse `[[key:value]]` directives from the beginning of agent output. +/// Returns parsed directives and the remaining content (directives stripped). +pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) { + let mut directives = OutputDirectives::default(); + let mut content_start = 0; + let mut trailing_content: Option<&str> = None; + + for line in content.lines() { + let trimmed = line.trim(); + // Try to match [[key:value]] at the start of the line (lenient: allows trailing content) + if let Some(after_open) = trimmed.strip_prefix("[[") { + if let Some(close_pos) = after_open.find("]]") { + let inner = &after_open[..close_pos]; + if let Some((key, value)) = inner.split_once(':') { + match key.trim() { + "reply_to" => { + let v = value.trim(); + // Validate: non-empty, reasonable length, no whitespace/control chars + if !v.is_empty() && v.len() <= 64 && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') { + directives.reply_to = Some(v.to_string()); + } + } + _ => { + tracing::debug!(key = key.trim(), "unknown output directive ignored"); + } + } + // Check for trailing content after ]] + let remainder = after_open[close_pos + 2..].trim(); + if !remainder.is_empty() { + trailing_content = Some(remainder); + // Advance past this line + content_start += line.len(); + if content.as_bytes().get(content_start) == Some(&b'\r') { + content_start += 1; + } + if content.as_bytes().get(content_start) == Some(&b'\n') { + content_start += 1; + } + break; // Trailing content ends directive header + } + // Advance past this line + its line ending (handles both \n and \r\n) + content_start += line.len(); + if content.as_bytes().get(content_start) == Some(&b'\r') { + content_start += 1; + } + if content.as_bytes().get(content_start) == Some(&b'\n') { + content_start += 1; + } + } else { + // [[X]] without colon — not a directive, stop parsing + break; + } + } else { + // No closing ]] found — not a directive, stop parsing + break; + } + } else { + break; + } + } + + let remaining = if let Some(trailing) = trailing_content { + if content_start < content.len() { + format!("{}\n{}", trailing, &content[content_start..]) + } else { + trailing.to_string() + } + } else if content_start < content.len() { + content[content_start..].to_string() + } else { + String::new() + }; + (directives, remaining) +} + // --- Platform-agnostic types --- /// Identifies a channel or thread across platforms. @@ -106,6 +191,14 @@ pub struct SenderContext { /// breakage). If future additions require breaking changes, bump to v1.1+. #[serde(skip_serializing_if = "Option::is_none")] pub timestamp: Option, + /// Platform message ID. Agents can use this to reply to a specific message + /// via the `[[reply_to:]]` output directive. + #[serde(skip_serializing_if = "Option::is_none")] + pub message_id: Option, + /// The platform user ID of the receiving bot/agent. + /// Enables agents to identify themselves when multiple agents share the same backend. + #[serde(skip_serializing_if = "Option::is_none")] + pub receiver_id: Option, } // --- ChatAdapter trait --- @@ -141,6 +234,24 @@ pub trait ChatAdapter: Send + Sync + 'static { Err(anyhow::anyhow!("edit_message not supported")) } + /// Send a message as a reply to a specific message (Discord: message_reference). + /// Default: falls back to plain send_message (ignores reply_to). + async fn send_message_with_reply( + &self, + channel: &ChannelRef, + content: &str, + reply_to_message_id: &str, + ) -> Result { + let _ = reply_to_message_id; // unused in default impl + self.send_message(channel, content).await + } + + /// Delete a message. Used to remove streaming placeholders when reply_to is set. + /// Default: edits to zero-width space (fallback for platforms without delete support). + async fn delete_message(&self, msg: &MessageRef) -> Result<()> { + self.edit_message(msg, "\u{200b}").await + } + /// Whether this adapter should use streaming edit (true) or send-once (false). /// `other_bot_present` indicates if another bot has posted in the current thread. /// Streaming should be disabled in multi-bot threads to avoid edit interference. @@ -159,6 +270,9 @@ pub struct AdapterRouter { pool: Arc, reactions_config: ReactionsConfig, table_mode: TableMode, + prompt_hard_timeout: std::time::Duration, + /// Polling cadence for the recv-loop liveness check (#732). + liveness_check_interval: std::time::Duration, } impl AdapterRouter { @@ -166,11 +280,24 @@ impl AdapterRouter { pool: Arc, reactions_config: ReactionsConfig, table_mode: TableMode, + prompt_hard_timeout_secs: u64, + liveness_check_secs: u64, ) -> Self { + if liveness_check_secs >= prompt_hard_timeout_secs { + warn!( + liveness_check_secs, + prompt_hard_timeout_secs, + "pool.liveness_check_secs >= pool.prompt_hard_timeout_secs; \ + the hard ceiling will only fire after the next liveness tick \ + and may be effectively bypassed. Lower liveness_check_secs." + ); + } Self { pool, reactions_config, table_mode, + prompt_hard_timeout: std::time::Duration::from_secs(prompt_hard_timeout_secs), + liveness_check_interval: std::time::Duration::from_secs(liveness_check_secs), } } @@ -306,7 +433,15 @@ impl AdapterRouter { reactions: Arc, other_bot_present: bool, ) -> Result<()> { - self.stream_prompt_blocks(adapter, thread_key, content_blocks, thread_channel, reactions, other_bot_present).await + self.stream_prompt_blocks( + adapter, + thread_key, + content_blocks, + thread_channel, + reactions, + other_bot_present, + ) + .await } /// Drive one ACP turn with the given pre-packed ContentBlocks. @@ -327,6 +462,8 @@ impl AdapterRouter { let streaming = adapter.use_streaming(other_bot_present); let table_mode = self.table_mode; let tool_display = self.reactions_config.tool_display; + let prompt_hard_timeout = self.prompt_hard_timeout; + let liveness_check_interval = self.liveness_check_interval; self.pool .with_connection(thread_key, |conn| { @@ -335,7 +472,7 @@ impl AdapterRouter { let reset = conn.session_reset; conn.session_reset = false; - let (mut rx, _) = conn.session_prompt(content_blocks).await?; + let (mut rx, request_id) = conn.session_prompt(content_blocks).await?; reactions.set_thinking().await; let mut text_buf = String::new(); @@ -388,22 +525,46 @@ impl AdapterRouter { (None, None) }; - // Process ACP notifications + // (#732) Liveness-aware recv loop. Filters stale id-bearing + // messages and abandons cleanly on dead agent / hard ceiling + // so late responses cannot leak into the next prompt. let mut response_error: Option = None; - let recv_timeout = std::time::Duration::from_secs(600); + let prompt_start = tokio::time::Instant::now(); loop { - let notification = match tokio::time::timeout(recv_timeout, rx.recv()).await - { - Ok(Some(n)) => n, - Ok(None) => break, // channel closed - Err(_) => { - response_error = Some("Agent stopped responding".into()); - break; + let notification = tokio::select! { + msg = rx.recv() => match msg { + Some(n) => n, + // Reader saw EOF and already drained pending; nothing to abandon. + None => break, + }, + _ = tokio::time::sleep(liveness_check_interval) => { + if !conn.alive() { + response_error = Some("Agent process died".into()); + conn.abandon_request(request_id).await; + break; + } + if prompt_start.elapsed() > prompt_hard_timeout { + response_error = Some(format!( + "Agent exceeded hard timeout ({}s)", + prompt_hard_timeout.as_secs(), + )); + conn.abandon_request(request_id).await; + break; + } + continue; } }; - if notification.id.is_some() { + if let Some(notification_id) = notification.id { + if notification_id != request_id { + // Stale response from a previously-abandoned prompt. + // No automated test seam: this path only triggers when a + // real subprocess emits a late response after the broker + // already called abandon_request — covered by manual + // repro against a live agent (see #732 PR description). + continue; + } if let Some(ref err) = notification.error { - response_error = Some(format_coded_error(err.code, &err.message)); + response_error = Some(format_coded_error(err.code, &err.message, err.data_message())); } break; } @@ -486,6 +647,12 @@ impl AdapterRouter { // Stop the edit loop drop(buf_tx); + // Parse output directives from raw text_buf BEFORE compose_display. + // Directives are agent meta-layer, not content — must be stripped + // before tool lines are composed into the display output. + let (directives, stripped_text) = parse_output_directives(&text_buf); + let text_buf = stripped_text; + // Build final content let final_content = compose_display(&tool_lines, &text_buf, false, tool_display); @@ -504,17 +671,61 @@ impl AdapterRouter { let final_content = markdown::convert_tables(&final_content, table_mode); let chunks = format::split_message(&final_content, message_limit); if let Some(msg) = placeholder_msg { - // Streaming: edit first chunk into placeholder, send rest as new messages - if let Some(first) = chunks.first() { - let _ = adapter.edit_message(&msg, first).await; - } - for chunk in chunks.iter().skip(1) { - let _ = adapter.send_message(&thread_channel, chunk).await; + if let Some(ref reply_id) = directives.reply_to { + // reply_to directive: send reply first, then delete placeholder. + // Only delete if send succeeds — preserves placeholder on failure. + let mut send_ok = false; + let mut first = true; + for chunk in &chunks { + if first { + match adapter.send_message_with_reply( + &thread_channel, + chunk, + reply_id, + ).await { + Ok(_) => { send_ok = true; } + Err(e) => { + tracing::warn!(error = ?e, "reply_to send failed; preserving placeholder"); + } + } + } else { + let _ = adapter.send_message(&thread_channel, chunk).await; + } + first = false; + } + if send_ok { + if let Err(e) = adapter.delete_message(&msg).await { + tracing::warn!(error = ?e, "delete placeholder failed; placeholder will remain visible"); + } + } + } else { + // Normal streaming: edit first chunk into placeholder, send rest + if let Some(first) = chunks.first() { + let _ = adapter.edit_message(&msg, first).await; + } + for chunk in chunks.iter().skip(1) { + let _ = adapter.send_message(&thread_channel, chunk).await; + } } } else { // Send-once: all chunks as new messages + // First chunk uses reply_to directive if present + let mut first = true; for chunk in &chunks { - let _ = adapter.send_message(&thread_channel, chunk).await; + if first { + if let Some(ref reply_id) = directives.reply_to { + let _ = adapter.send_message_with_reply( + &thread_channel, + chunk, + reply_id, + ).await; + } else { + let _ = adapter.send_message(&thread_channel, chunk).await; + } + } else { + let _ = adapter.send_message(&thread_channel, chunk).await; + } + first = false; } } @@ -829,3 +1040,159 @@ mod tests { assert_eq!(out, "response text"); } } + +#[cfg(test)] +mod directive_tests { + use super::parse_output_directives; + + #[test] + fn parse_reply_to_directive() { + let input = "[[reply_to:1502606076451885136]]\nHello world"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("1502606076451885136".to_string())); + assert_eq!(content, "Hello world"); + } + + #[test] + fn parse_no_directives() { + let input = "Just plain content\nwith multiple lines"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, None); + assert_eq!(content, input); + } + + #[test] + fn parse_multiple_directives() { + let input = "[[reply_to:123456]]\n[[unknown_key:value]]\nContent here"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("123456".to_string())); + assert_eq!(content, "Content here"); + } + + #[test] + fn parse_invalid_reply_to_rejects_whitespace() { + let input = "[[reply_to:has spaces]]\nContent"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, None); + assert_eq!(content, "Content"); + } + + #[test] + fn parse_slack_ts_format_accepted() { + let input = "[[reply_to:1234567890.123456]]\nContent"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("1234567890.123456".to_string())); + assert_eq!(content, "Content"); + } + + #[test] + fn parse_empty_reply_to() { + let input = "[[reply_to:]]\nContent"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, None); + assert_eq!(content, "Content"); + } + + #[test] + fn parse_crlf_line_endings() { + let input = "[[reply_to:999]]\r\nContent with CRLF"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("999".to_string())); + assert_eq!(content, "Content with CRLF"); + } + + #[test] + fn parse_directive_only_no_content() { + let input = "[[reply_to:123]]"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("123".to_string())); + assert_eq!(content, ""); + } + + #[test] + fn parse_non_directive_line_stops_parsing() { + let input = "Normal first line\n[[reply_to:123]]\nMore content"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, None); + assert_eq!(content, input); + } + + #[test] + fn parse_duplicate_reply_to_last_wins() { + let input = "[[reply_to:111]]\n[[reply_to:222]]\nContent"; + let (directives, content) = parse_output_directives(input); + // Last value wins + assert_eq!(directives.reply_to, Some("222".to_string())); + assert_eq!(content, "Content"); + } + + #[test] + fn parse_crlf_multiple_directives() { + let input = "[[reply_to:456]]\r\n[[unknown:x]]\r\nContent after CRLF"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("456".to_string())); + assert_eq!(content, "Content after CRLF"); + } + + #[test] + fn parse_bracket_without_colon_preserved() { + // [[Note]] has no colon — not a directive, preserved as content + let input = "[[Summary]]\nThis is body text"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, None); + assert_eq!(content, input); + } + + #[test] + fn parse_reply_to_with_inline_content() { + // Agent puts content on same line as directive — should still parse + let input = "[[reply_to:1502724086474870926]] @BOT I'm on standby"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("1502724086474870926".to_string())); + assert_eq!(content, "@BOT I'm on standby"); + } + + #[test] + fn parse_reply_to_inline_with_more_lines() { + let input = "[[reply_to:123]] First line\nSecond line\nThird line"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("123".to_string())); + assert_eq!(content, "First line\nSecond line\nThird line"); + } + + #[test] + fn parse_reply_to_no_space_before_content() { + // No space between ]] and content + let input = "[[reply_to:1502724086474870926]]收到"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("1502724086474870926".to_string())); + assert_eq!(content, "收到"); + } + + #[test] + fn parse_reply_to_inline_with_mention() { + // Real-world case: directive followed by Discord mention + let input = "[[reply_to:1502724086474870926]] <@1490365068863606784> 我 standby"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("1502724086474870926".to_string())); + assert_eq!(content, "<@1490365068863606784> 我 standby"); + } + + #[test] + fn parse_reply_to_inline_only_spaces() { + // Trailing spaces only — no real content, should be empty + let input = "[[reply_to:123]] "; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("123".to_string())); + assert_eq!(content, ""); + } + + #[test] + fn parse_reply_to_with_brackets_in_content() { + // Content after ]] contains brackets — should not confuse parser + let input = "[[reply_to:456]] 看看 [[這個]] 怎麼樣"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("456".to_string())); + assert_eq!(content, "看看 [[這個]] 怎麼樣"); + } +} diff --git a/src/bot_turns.rs b/src/bot_turns.rs index 9f031d145..130fa717b 100644 --- a/src/bot_turns.rs +++ b/src/bot_turns.rs @@ -11,7 +11,12 @@ use std::collections::HashMap; /// A human message resets both soft and hard counters to 0, allowing bots to /// resume. This is *not* a lifetime total — it guards against runaway loops /// between human resets. -pub const HARD_BOT_TURN_LIMIT: u32 = 100; +pub const HARD_BOT_TURN_LIMIT: u32 = 1000; + +/// Stable prefix used in all bot turn limit warning messages. +/// Referenced by the dedup check in the Discord adapter — changing this +/// string requires updating the dedup check too. +pub const BOT_TURN_LIMIT_WARNING_PREFIX: &str = "⚠️ Bot turn limit reached"; #[derive(Debug, PartialEq, Eq)] pub enum TurnResult { @@ -34,7 +39,10 @@ pub struct BotTurnTracker { impl BotTurnTracker { pub fn new(soft_limit: u32) -> Self { - Self { soft_limit, counts: HashMap::new() } + Self { + soft_limit, + counts: HashMap::new(), + } } pub fn on_bot_message(&mut self, thread_id: &str) -> TurnResult { @@ -72,8 +80,9 @@ impl BotTurnTracker { severity: TurnSeverity::Soft, turns: n, user_message: format!( - "⚠️ Bot turn limit reached ({n}/{soft}). \ + "{} ({n}/{soft}). \ A human must reply in this thread to continue bot-to-bot conversation.", + BOT_TURN_LIMIT_WARNING_PREFIX, soft = self.soft_limit, ), }, @@ -158,6 +167,14 @@ mod tests { assert_eq!(t.on_bot_message("t1"), TurnResult::HardLimit); } + #[test] + fn hard_limit_does_not_fire_at_legacy_100() { + let mut t = BotTurnTracker::new(HARD_BOT_TURN_LIMIT + 1); + for i in 1..=100 { + assert_eq!(t.on_bot_message("t1"), TurnResult::Ok, "turn {i}"); + } + } + #[test] fn hard_limit_resets_on_human() { let mut t = BotTurnTracker::new(HARD_BOT_TURN_LIMIT + 1); @@ -265,9 +282,11 @@ mod tests { TurnAction::WarnAndStop { severity: TurnSeverity::Soft, turns: 3, - user_message: "⚠️ Bot turn limit reached (3/3). \ - A human must reply in this thread to continue bot-to-bot conversation." - .to_string(), + user_message: format!( + "{} (3/3). \ + A human must reply in this thread to continue bot-to-bot conversation.", + BOT_TURN_LIMIT_WARNING_PREFIX, + ), }, ); } @@ -307,12 +326,18 @@ mod tests { assert_eq!(t.classify_bot_message("t1"), TurnAction::Continue); assert!(matches!( t.classify_bot_message("t1"), - TurnAction::WarnAndStop { severity: TurnSeverity::Soft, .. }, + TurnAction::WarnAndStop { + severity: TurnSeverity::Soft, + .. + }, )); assert_eq!(t.classify_bot_message("t2"), TurnAction::Continue); assert!(matches!( t.classify_bot_message("t2"), - TurnAction::WarnAndStop { severity: TurnSeverity::Soft, .. }, + TurnAction::WarnAndStop { + severity: TurnSeverity::Soft, + .. + }, )); } @@ -333,7 +358,11 @@ mod tests { assert_eq!(t.classify_bot_message("t1"), TurnAction::Continue); assert!(matches!( t.classify_bot_message("t1"), - TurnAction::WarnAndStop { severity: TurnSeverity::Soft, turns: 2, .. }, + TurnAction::WarnAndStop { + severity: TurnSeverity::Soft, + turns: 2, + .. + }, )); } } diff --git a/src/config.rs b/src/config.rs index 574b26607..77aad4345 100644 --- a/src/config.rs +++ b/src/config.rs @@ -57,7 +57,10 @@ impl<'de> Deserialize<'de> for AllowBots { "off" | "none" | "false" => Ok(Self::Off), "mentions" => Ok(Self::Mentions), "all" | "true" => Ok(Self::All), - other => Err(serde::de::Error::unknown_variant(other, &["off", "mentions", "all"])), + other => Err(serde::de::Error::unknown_variant( + other, + &["off", "mentions", "all"], + )), } } } @@ -102,6 +105,10 @@ pub struct SttConfig { pub model: String, #[serde(default = "default_stt_base_url")] pub base_url: String, + /// Echo the transcribed text back to the thread (no mentions) before + /// dispatching the prompt to the agent. Lets users verify STT accuracy. + #[serde(default = "default_echo_transcript")] + pub echo_transcript: bool, } impl Default for SttConfig { @@ -111,12 +118,20 @@ impl Default for SttConfig { api_key: String::new(), model: default_stt_model(), base_url: default_stt_base_url(), + echo_transcript: default_echo_transcript(), } } } -fn default_stt_model() -> String { "whisper-large-v3-turbo".into() } -fn default_stt_base_url() -> String { "https://api.groq.com/openai/v1".into() } +fn default_stt_model() -> String { + "whisper-large-v3-turbo".into() +} +fn default_stt_base_url() -> String { + "https://api.groq.com/openai/v1".into() +} +fn default_echo_transcript() -> bool { + false +} #[derive(Debug, Deserialize)] pub struct DiscordConfig { @@ -146,6 +161,11 @@ pub struct DiscordConfig { /// Human message resets the counter. Default: 100. #[serde(default = "default_max_bot_turns")] pub max_bot_turns: u32, + /// Role IDs that trigger the bot (same as direct @mention). + /// When a message mentions a role in this list, it is treated as a bot trigger. + /// Empty (default) = role mentions do not trigger the bot. + #[serde(default)] + pub allowed_role_ids: Vec, /// Allow the bot to respond to Discord direct messages (DMs). /// Default: false (opt-in). `allowed_users` still applies in DMs. #[serde(default)] @@ -161,9 +181,15 @@ pub struct DiscordConfig { pub max_batch_tokens: usize, } -fn default_max_bot_turns() -> u32 { 100 } -fn default_max_buffered_messages() -> usize { 10 } -fn default_max_batch_tokens() -> usize { 24_000 } +fn default_max_bot_turns() -> u32 { + 100 +} +fn default_max_buffered_messages() -> usize { + 10 +} +fn default_max_batch_tokens() -> usize { + 24_000 +} /// Controls whether the bot responds to user messages in threads without @mention. /// @@ -188,7 +214,10 @@ impl<'de> Deserialize<'de> for AllowUsers { "involved" => Ok(Self::Involved), "mentions" => Ok(Self::Mentions), "multibot_mentions" => Ok(Self::MultibotMentions), - other => Err(serde::de::Error::unknown_variant(other, &["involved", "mentions", "multibot-mentions"])), + other => Err(serde::de::Error::unknown_variant( + other, + &["involved", "mentions", "multibot-mentions"], + )), } } } @@ -289,10 +318,26 @@ pub struct PoolConfig { pub max_sessions: usize, #[serde(default = "default_ttl_hours")] pub session_ttl_hours: u64, + /// Hard ceiling for a single prompt (#732). Once exceeded, the broker + /// abandons the in-flight request, sends `session/cancel` to the agent, + /// and clears the pending entry so late responses cannot leak into the + /// next prompt's subscriber. + /// + /// Precision: checked every `liveness_check_secs`, so actual cutoff is + /// ±`liveness_check_secs` from this value. + #[serde(default = "default_prompt_hard_timeout_secs")] + pub prompt_hard_timeout_secs: u64, + /// Polling cadence (seconds) for the recv-loop liveness check (#732). + /// Lower = faster reaction to a dead agent / hard ceiling at the cost of + /// more wakeups while the agent is streaming normally. + #[serde(default = "default_liveness_check_secs")] + pub liveness_check_secs: u64, } #[derive(Debug, Clone, Deserialize)] pub struct CronJobConfig { + /// Stable ID for usercron jobs that need scheduler writeback. + pub id: Option, /// Whether this cronjob is active (default: true) #[serde(default = "default_true")] pub enabled: bool, @@ -313,11 +358,31 @@ pub struct CronJobConfig { /// Timezone (default: "UTC") #[serde(default = "default_cron_timezone")] pub timezone: String, + /// Usercron-only: command to run before firing. Exit 0 plus a matching + /// `disable_on_success_match` means the goal is complete and the scheduler + /// disables the job in the usercron file. + pub disable_on_success: Option, + /// Usercron-only: required output marker for `disable_on_success`. + pub disable_on_success_match: Option, + /// Usercron-only: timeout for `disable_on_success`. + #[serde(default = "default_disable_on_success_timeout_secs")] + pub disable_on_success_timeout_secs: u64, + /// Usercron-only: working directory for `disable_on_success`. + pub disable_on_success_working_dir: Option, } -fn default_cron_platform() -> String { "discord".into() } -fn default_cron_sender() -> String { "openab-cron".into() } -fn default_cron_timezone() -> String { "UTC".into() } +fn default_cron_platform() -> String { + "discord".into() +} +fn default_cron_sender() -> String { + "openab-cron".into() +} +fn default_cron_timezone() -> String { + "UTC".into() +} +fn default_disable_on_success_timeout_secs() -> u64 { + 60 +} /// Controls how tool calls are rendered in chat messages. /// @@ -339,7 +404,10 @@ impl<'de> Deserialize<'de> for ToolDisplay { "full" => Ok(Self::Full), "compact" => Ok(Self::Compact), "none" | "off" | "hidden" => Ok(Self::None), - other => Err(serde::de::Error::unknown_variant(other, &["full", "compact", "none"])), + other => Err(serde::de::Error::unknown_variant( + other, + &["full", "compact", "none"], + )), } } } @@ -392,28 +460,71 @@ pub struct ReactionTiming { // --- defaults --- -fn default_working_dir() -> String { "/tmp".into() } -fn default_max_sessions() -> usize { 10 } -fn default_ttl_hours() -> u64 { 4 } -fn default_true() -> bool { true } - -fn emoji_queued() -> String { "👀".into() } -fn emoji_thinking() -> String { "🤔".into() } -fn emoji_tool() -> String { "🔥".into() } -fn emoji_coding() -> String { "👨‍💻".into() } -fn emoji_web() -> String { "⚡".into() } -fn emoji_done() -> String { "🆗".into() } -fn emoji_error() -> String { "😱".into() } - -fn default_debounce_ms() -> u64 { 700 } -fn default_stall_soft_ms() -> u64 { 10_000 } -fn default_stall_hard_ms() -> u64 { 30_000 } -fn default_done_hold_ms() -> u64 { 1_500 } -fn default_error_hold_ms() -> u64 { 2_500 } +fn default_working_dir() -> String { + "/tmp".into() +} +fn default_max_sessions() -> usize { + 10 +} +fn default_ttl_hours() -> u64 { + 4 +} +pub(crate) fn default_prompt_hard_timeout_secs() -> u64 { + 30 * 60 +} +pub(crate) fn default_liveness_check_secs() -> u64 { + 30 +} +fn default_true() -> bool { + true +} + +fn emoji_queued() -> String { + "👀".into() +} +fn emoji_thinking() -> String { + "🤔".into() +} +fn emoji_tool() -> String { + "🔥".into() +} +fn emoji_coding() -> String { + "👨‍💻".into() +} +fn emoji_web() -> String { + "⚡".into() +} +fn emoji_done() -> String { + "🆗".into() +} +fn emoji_error() -> String { + "😱".into() +} + +fn default_debounce_ms() -> u64 { + 700 +} +fn default_stall_soft_ms() -> u64 { + 10_000 +} +fn default_stall_hard_ms() -> u64 { + 30_000 +} +fn default_done_hold_ms() -> u64 { + 1_500 +} +fn default_error_hold_ms() -> u64 { + 2_500 +} impl Default for PoolConfig { fn default() -> Self { - Self { max_sessions: default_max_sessions(), session_ttl_hours: default_ttl_hours() } + Self { + max_sessions: default_max_sessions(), + session_ttl_hours: default_ttl_hours(), + prompt_hard_timeout_secs: default_prompt_hard_timeout_secs(), + liveness_check_secs: default_liveness_check_secs(), + } } } @@ -432,8 +543,13 @@ impl Default for ReactionsConfig { impl Default for ReactionEmojis { fn default() -> Self { Self { - queued: emoji_queued(), thinking: emoji_thinking(), tool: emoji_tool(), - coding: emoji_coding(), web: emoji_web(), done: emoji_done(), error: emoji_error(), + queued: emoji_queued(), + thinking: emoji_thinking(), + tool: emoji_tool(), + coding: emoji_coding(), + web: emoji_web(), + done: emoji_done(), + error: emoji_error(), } } } @@ -441,8 +557,10 @@ impl Default for ReactionEmojis { impl Default for ReactionTiming { fn default() -> Self { Self { - debounce_ms: default_debounce_ms(), stall_soft_ms: default_stall_soft_ms(), - stall_hard_ms: default_stall_hard_ms(), done_hold_ms: default_done_hold_ms(), + debounce_ms: default_debounce_ms(), + stall_soft_ms: default_stall_soft_ms(), + stall_hard_ms: default_stall_hard_ms(), + done_hold_ms: default_done_hold_ms(), error_hold_ms: default_error_hold_ms(), } } @@ -516,17 +634,36 @@ fn parse_config(raw: &str, source: &str) -> anyhow::Result { // and max_batch_tokens > 0 (otherwise the consumer's token-cap check forces every // batch to size 1 — functionally per-message via a confusing path). if let Some(ref d) = config.discord { - anyhow::ensure!(d.max_buffered_messages > 0, "discord.max_buffered_messages must be > 0"); - anyhow::ensure!(d.max_batch_tokens > 0, "discord.max_batch_tokens must be > 0"); + anyhow::ensure!( + d.max_buffered_messages > 0, + "discord.max_buffered_messages must be > 0" + ); + anyhow::ensure!( + d.max_batch_tokens > 0, + "discord.max_batch_tokens must be > 0" + ); } if let Some(ref s) = config.slack { - anyhow::ensure!(s.max_buffered_messages > 0, "slack.max_buffered_messages must be > 0"); + anyhow::ensure!( + s.max_buffered_messages > 0, + "slack.max_buffered_messages must be > 0" + ); anyhow::ensure!(s.max_batch_tokens > 0, "slack.max_batch_tokens must be > 0"); } if let Some(ref g) = config.gateway { - anyhow::ensure!(g.max_buffered_messages > 0, "gateway.max_buffered_messages must be > 0"); - anyhow::ensure!(g.max_batch_tokens > 0, "gateway.max_batch_tokens must be > 0"); + anyhow::ensure!( + g.max_buffered_messages > 0, + "gateway.max_buffered_messages must be > 0" + ); + anyhow::ensure!( + g.max_batch_tokens > 0, + "gateway.max_batch_tokens must be > 0" + ); } + anyhow::ensure!( + config.pool.liveness_check_secs > 0, + "pool.liveness_check_secs must be > 0 (zero would spin the recv loop)" + ); Ok(config) } @@ -586,7 +723,10 @@ command = "echo" fn parse_invalid_toml_returns_error() { let result = parse_config("not valid toml {{{}}", "test"); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("failed to parse config from test")); + assert!(result + .unwrap_err() + .to_string() + .contains("failed to parse config from test")); } #[test] @@ -608,7 +748,10 @@ command = "echo" async fn load_config_from_url_invalid_host() { let result = load_config_from_url("https://invalid.test.example/config.toml").await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("failed to fetch remote config")); + assert!(result + .unwrap_err() + .to_string() + .contains("failed to fetch remote config")); } #[test] @@ -630,7 +773,10 @@ command = "echo" assert!(gw.allow_all_channels.is_none()); // resolve_allow_all: empty lists → allow all assert!(resolve_allow_all(gw.allow_all_users, &gw.allowed_users)); - assert!(resolve_allow_all(gw.allow_all_channels, &gw.allowed_channels)); + assert!(resolve_allow_all( + gw.allow_all_channels, + &gw.allowed_channels + )); } #[test] @@ -652,7 +798,10 @@ command = "echo" assert_eq!(gw.allowed_channels, vec!["C1"]); // resolve_allow_all: non-empty lists → restricted assert!(!resolve_allow_all(gw.allow_all_users, &gw.allowed_users)); - assert!(!resolve_allow_all(gw.allow_all_channels, &gw.allowed_channels)); + assert!(!resolve_allow_all( + gw.allow_all_channels, + &gw.allowed_channels + )); } #[test] @@ -764,4 +913,29 @@ command = "echo" // explicit flag overrides non-empty list assert!(resolve_allow_all(gw.allow_all_users, &gw.allowed_users)); } + + #[test] + fn stt_echo_transcript_defaults_to_false() { + let cfg = SttConfig::default(); + assert!( + !cfg.echo_transcript, + "echo_transcript should default to false" + ); + } + + #[test] + fn stt_echo_transcript_respects_explicit_false() { + let toml = r#" +[agent] +command = "echo" + +[stt] +enabled = true +api_key = "test" +echo_transcript = false +"#; + let cfg = parse_config(toml, "test").unwrap(); + assert!(cfg.stt.enabled); + assert!(!cfg.stt.echo_transcript); + } } diff --git a/src/cron.rs b/src/cron.rs index 1aec16217..db5828b22 100644 --- a/src/cron.rs +++ b/src/cron.rs @@ -1,4 +1,4 @@ -use crate::adapter::{AdapterRouter, ChatAdapter, ChannelRef, SenderContext}; +use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, SenderContext}; use crate::config::CronJobConfig; use crate::format; use chrono::{Timelike, Utc}; @@ -9,14 +9,218 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; use std::time::SystemTime; +use tokio::process::Command; use tokio::sync::Mutex; +use toml_edit::{value, DocumentMut}; use tracing::{debug, error, info, warn}; /// Parse a 5-field POSIX cron expression into a `Schedule`. +/// /// The `cron` crate expects a 6-field expression (with seconds), so we prepend "0". -pub fn parse_cron_expr(expr: &str) -> Result { - let six_field = format!("0 {}", expr); - Schedule::from_str(&six_field) +/// +/// POSIX numeric day-of-week values (0..=7, where 0 or 7 = Sunday) are translated +/// to the `cron` crate's 1-based form (1..=7, where 1 = Sunday) before being handed +/// to the underlying parser. Without this, numeric day-of-week values are off by one +/// — e.g. `1-5` (Mon-Fri in POSIX) would be evaluated as Sun-Thu. See the +/// [`translate_posix_dow_field`] doc comment for details. +/// +/// Name-based day-of-week tokens (`Mon`, `Sun`, `Mon-Fri`, ...) are passed through +/// unchanged — the `cron` crate's internal name-to-ordinal map is consistent. +pub fn parse_cron_expr(expr: &str) -> Result { + let translated = translate_posix_cron_expr(expr)?; + let six_field = format!("0 {}", translated); + Schedule::from_str(&six_field).map_err(|e| e.to_string()) +} + +/// Translate a 5-field POSIX cron expression so the day-of-week field uses the +/// numeric convention of the `cron` crate. +/// +/// Only the 5th field (day-of-week) is rewritten; the other four fields pass +/// through unchanged. +fn translate_posix_cron_expr(expr: &str) -> Result { + let fields: Vec<&str> = expr.split_whitespace().collect(); + if fields.len() != 5 { + return Err(format!( + "expected 5 whitespace-separated cron fields, got {}: {:?}", + fields.len(), + expr + )); + } + let translated_dow = translate_posix_dow_field(fields[4])?; + Ok(format!( + "{} {} {} {} {}", + fields[0], fields[1], fields[2], fields[3], translated_dow + )) +} + +/// Translate a POSIX day-of-week field to the `cron` crate's numeric form. +/// +/// # Background +/// +/// POSIX cron (and Linux crontab, Kubernetes CronJob, GitHub Actions) uses +/// `0..=7` where `0` or `7` = Sunday, `1` = Monday, ..., `6` = Saturday. +/// +/// The `cron` crate uses `1..=7` where `1` = Sunday, `2` = Monday, ..., `7` = Saturday +/// (it matches via chrono's `Weekday::number_from_sunday()`). Without translation, +/// every numeric day-of-week value fires one day early: +/// +/// | POSIX intent | Without translation (cron crate reads as) | +/// |---------------|-------------------------------------------| +/// | `0`, `7` (Sun) | out-of-range / Sat | +/// | `1` (Mon) | Sun | +/// | `5` (Fri) | Thu | +/// | `1-5` (Mon-Fri) | Sun-Thu | +/// +/// # Algorithm +/// +/// 1. If the field contains any ASCII letter (e.g. `Mon-Fri`), pass it through — +/// the cron crate's name-to-ordinal map is internally consistent. +/// 2. Otherwise, expand each comma-separated component into the set of POSIX +/// day values it represents. Ranges (`a-b`) and step values (`a/s`, `a-b/s`, +/// `*/s`) are expanded here. `7` is normalized to `0` (both = Sunday) to +/// avoid duplication. +/// 3. If the resulting set covers all 7 days, emit `*` for brevity. +/// 4. Otherwise, shift each value by `+1` (POSIX `{0..=6}` → cron crate +/// `{1..=7}`) and emit as a comma-separated list, compacting contiguous +/// runs into ranges for readability. +/// +/// # Mixed numeric and name notation +/// +/// Mixing numeric and name tokens in the same field (e.g. `1,Mon`) is not +/// supported and will return an error. Use either all numeric (POSIX) or all +/// name-based notation. +fn translate_posix_dow_field(field: &str) -> Result { + use std::collections::BTreeSet; + + // Name-based notation is internally consistent in the cron crate — pass through. + // But reject mixed numeric+name notation (e.g. "1,Mon") which would leave the + // numeric part untranslated and silently wrong. + let has_alpha = field.chars().any(|c| c.is_ascii_alphabetic()); + let has_digit = field.chars().any(|c| c.is_ascii_digit()); + if has_alpha && has_digit { + return Err(format!( + "mixed numeric and name notation is not supported in day-of-week field: {:?}", + field + )); + } + if has_alpha { + return Ok(field.to_string()); + } + + if field.is_empty() { + return Err("empty day-of-week field".to_string()); + } + + let mut days: BTreeSet = BTreeSet::new(); + + for part in field.split(',') { + if part.is_empty() { + return Err(format!("empty component in day-of-week field: {:?}", field)); + } + + // Split off optional step: `a/s`, `a-b/s`, `*/s`. + let (range_part, step) = match part.split_once('/') { + Some((r, s)) => { + let step_n: u32 = s + .parse() + .map_err(|_| format!("invalid step value in {:?}", part))?; + if step_n == 0 { + return Err(format!("step value cannot be zero in {:?}", part)); + } + (r, step_n) + } + None => (part, 1u32), + }; + + // Expand range_part to the list of POSIX day values it represents. + // Values may include 7 (Sunday alias for 0); normalization happens below. + let raw_values: Vec = if range_part == "*" { + (0..=6).collect() + } else if let Some((a, b)) = range_part.split_once('-') { + let a_n: u32 = a + .parse() + .map_err(|_| format!("invalid range start in {:?}", part))?; + let b_n: u32 = b + .parse() + .map_err(|_| format!("invalid range end in {:?}", part))?; + if a_n > 7 || b_n > 7 { + return Err(format!( + "day-of-week value out of range (0-7) in {:?}", + part + )); + } + if a_n > b_n { + return Err(format!("invalid range {:?}: start > end", part)); + } + (a_n..=b_n).collect() + } else { + let n: u32 = range_part + .parse() + .map_err(|_| format!("invalid number in {:?}", part))?; + if n > 7 { + return Err(format!("day-of-week value out of range (0-7): {}", n)); + } + if step > 1 { + // n/step means "from n through end-of-domain, stepping by step" + // Normalize 7 (Sunday alias) to 0 before expansion. + let start = if n == 7 { 0 } else { n }; + (start..=6).collect() + } else { + vec![n] + } + }; + + // Apply step filter, normalize 7 → 0, collect into the set. + for (i, &v) in raw_values.iter().enumerate() { + if (i as u32).is_multiple_of(step) { + let normalized = if v == 7 { 0 } else { v }; + days.insert(normalized); + } + } + } + + if days.is_empty() { + return Err(format!("empty day-of-week field: {:?}", field)); + } + + // All 7 days → emit `*` for brevity. + if days.len() == 7 { + return Ok("*".to_string()); + } + + // Shift POSIX {0..=6} → cron crate {1..=7} and emit, compacting contiguous runs. + let shifted: Vec = days.iter().map(|d| d + 1).collect(); + Ok(compact_ordinal_set(&shifted)) +} + +/// Compact a sorted list of ordinals into cron-style comma-list with ranges, +/// e.g. `[2,3,4,5,6]` → `"2-6"`, `[1,3,5]` → `"1,3,5"`, `[1,2,4,5]` → `"1-2,4-5"`. +fn compact_ordinal_set(sorted: &[u32]) -> String { + if sorted.is_empty() { + return String::new(); + } + let mut out: Vec = Vec::new(); + let mut start = sorted[0]; + let mut end = sorted[0]; + for &v in &sorted[1..] { + if v == end + 1 { + end = v; + } else { + out.push(render_run(start, end)); + start = v; + end = v; + } + } + out.push(render_run(start, end)); + out.join(",") +} + +fn render_run(start: u32, end: u32) -> String { + if start == end { + format!("{}", start) + } else { + format!("{}-{}", start, end) + } } /// Check whether a cron schedule should fire right now. @@ -24,9 +228,7 @@ pub fn parse_cron_expr(expr: &str) -> Result { /// schedule has an event at exactly that minute. pub fn should_fire(schedule: &Schedule, tz: Tz) -> bool { let now = Utc::now().with_timezone(&tz); - let minute_start = now - .with_second(0).unwrap() - .with_nanosecond(0).unwrap(); + let minute_start = now.with_second(0).unwrap().with_nanosecond(0).unwrap(); let query_from = minute_start - chrono::Duration::seconds(1); schedule .after(&query_from) @@ -39,20 +241,40 @@ pub fn should_fire(schedule: &Schedule, tz: Tz) -> bool { const VALID_PLATFORMS: &[&str] = &["discord", "slack"]; /// Validate all cronjob configs (fail-fast on bad cron expressions or timezones). -pub fn validate_cronjobs(cronjobs: &[CronJobConfig], configured_platforms: &[&str]) -> anyhow::Result<()> { +pub fn validate_cronjobs( + cronjobs: &[CronJobConfig], + configured_platforms: &[&str], +) -> anyhow::Result<()> { for (i, job) in cronjobs.iter().enumerate() { - if !job.enabled { continue; } + if !job.enabled { + continue; + } parse_cron_expr(&job.schedule).map_err(|e| { - anyhow::anyhow!("cronjobs[{i}]: invalid cron expression {:?}: {e}", job.schedule) + anyhow::anyhow!( + "cronjobs[{i}]: invalid cron expression {:?}: {e}", + job.schedule + ) })?; job.timezone.parse::().map_err(|e| { anyhow::anyhow!("cronjobs[{i}]: invalid timezone {:?}: {e}", job.timezone) })?; if !VALID_PLATFORMS.contains(&job.platform.as_str()) { - anyhow::bail!("cronjobs[{i}]: unknown platform {:?} (expected one of: {VALID_PLATFORMS:?})", job.platform); + anyhow::bail!( + "cronjobs[{i}]: unknown platform {:?} (expected one of: {VALID_PLATFORMS:?})", + job.platform + ); } if !configured_platforms.contains(&job.platform.as_str()) { - anyhow::bail!("cronjobs[{i}]: platform {:?} is not configured — add [{}] to config.toml", job.platform, job.platform); + anyhow::bail!( + "cronjobs[{i}]: platform {:?} is not configured — add [{}] to config.toml", + job.platform, + job.platform + ); + } + if job.disable_on_success.is_some() { + anyhow::bail!( + "cronjobs[{i}]: disable_on_success is only supported in usercron [[jobs]], not baseline [[cron.jobs]]" + ); } } Ok(()) @@ -106,6 +328,20 @@ pub fn load_usercron_file(path: &Path, configured_platforms: &[&str]) -> Vec, } /// Parse a list of CronJobConfig into ParsedJob, filtering out disabled/invalid entries. -fn parse_job_list(configs: &[CronJobConfig], source: &str) -> Vec { +fn parse_job_list( + configs: &[CronJobConfig], + source: &str, + usercron_path: Option<&Path>, +) -> Vec { configs.iter().filter(|job| { if !job.enabled { info!(schedule = %job.schedule, channel = %job.channel, source, "cronjob disabled, skipping"); @@ -150,7 +391,12 @@ fn parse_job_list(configs: &[CronJobConfig], source: &str) -> Vec { message = %job.message, source, "cronjob registered" ); - Some(ParsedJob { schedule, tz, config: job.clone() }) + Some(ParsedJob { + schedule, + tz, + config: job.clone(), + usercron_path: usercron_path.map(Path::to_path_buf), + }) }).collect() } @@ -167,7 +413,7 @@ pub async fn run_scheduler( let platform_refs: Vec<&str> = configured_platforms.iter().map(|s| s.as_str()).collect(); // Parse baseline jobs from config.toml - let baseline_jobs = parse_job_list(&cronjobs, "config.toml"); + let baseline_jobs = parse_job_list(&cronjobs, "config.toml", None); // Load initial usercron jobs let mut usercron_jobs = if let Some(ref path) = usercron_path { @@ -175,7 +421,7 @@ pub async fn run_scheduler( if !configs.is_empty() { info!(count = configs.len(), path = %path.display(), "loaded usercron jobs"); } - parse_job_list(&configs, "cronjob.toml") + parse_job_list(&configs, "cronjob.toml", Some(path.as_path())) } else { vec![] }; @@ -183,7 +429,9 @@ pub async fn run_scheduler( if baseline_jobs.is_empty() && usercron_jobs.is_empty() { if usercron_path.is_some() { - info!("no cronjobs yet, but usercron_path is set — scheduler will watch for cronjob.toml"); + info!( + "no cronjobs yet, but usercron_path is set — scheduler will watch for cronjob.toml" + ); } else { debug!("no cronjobs configured, scheduler not started"); return; @@ -191,14 +439,26 @@ pub async fn run_scheduler( } let total = baseline_jobs.len() + usercron_jobs.len(); - info!(baseline = baseline_jobs.len(), usercron = usercron_jobs.len(), total, "cron scheduler started"); + info!( + baseline = baseline_jobs.len(), + usercron = usercron_jobs.len(), + total, + "cron scheduler started" + ); let in_flight: Arc>> = Arc::new(Mutex::new(HashSet::new())); + // Serialize usercron read-modify-write updates so concurrent jobs do not + // overwrite each other's enabled/thread_id changes. + let usercron_write_lock: Arc> = Arc::new(Mutex::new(())); // Align to next minute boundary let now = Utc::now(); let secs_into_minute = now.timestamp() % 60; - let align_delay = if secs_into_minute == 0 { 0 } else { 60 - secs_into_minute as u64 }; + let align_delay = if secs_into_minute == 0 { + 0 + } else { + 60 - secs_into_minute as u64 + }; if align_delay > 0 { debug!(align_secs = align_delay, "aligning to next minute boundary"); tokio::time::sleep(std::time::Duration::from_secs(align_delay)).await; @@ -217,18 +477,12 @@ pub async fn run_scheduler( if current_mtime != last_usercron_mtime { let configs = load_usercron_file(path, &platform_refs); info!(count = configs.len(), path = %path.display(), "usercron file changed, reloading"); - // Clear in-flight tracking for usercron jobs (indices shift on reload). - // Design note: if a still-running old usercron task's InFlightGuard - // drops after this point, the remove is a no-op (index already cleared). - // A new job at the same index *could* fire concurrently in this tick — - // probability is negligible (reload + fire on same tick + same index) - // and acceptable for a hot-reload feature. - { - let mut running = in_flight.lock().await; - let baseline_len = baseline_jobs.len(); - running.retain(|idx| *idx < baseline_len); - } - usercron_jobs = parse_job_list(&configs, "cronjob.toml"); + // Keep in-flight indices across reload. A scheduler writeback + // (thread_id or enabled=false) changes mtime deterministically; + // clearing usercron indices here would allow the same job to + // overlap on the next tick while its previous run is still active. + usercron_jobs = + parse_job_list(&configs, "cronjob.toml", Some(path.as_path())); last_usercron_mtime = current_mtime; } } @@ -257,11 +511,22 @@ pub async fn run_scheduler( in_flight.lock().await.insert(idx); let config = job.config.clone(); + let usercron_path = job.usercron_path.clone(); let router = router.clone(); let adapters = adapters.clone(); let in_flight = in_flight.clone(); + let usercron_write_lock = usercron_write_lock.clone(); tasks.spawn(async move { - fire_cronjob(idx, &config, &router, &adapters, in_flight).await; + fire_cronjob( + idx, + &config, + usercron_path, + &router, + &adapters, + in_flight, + usercron_write_lock, + ) + .await; }); } while tasks.try_join_next().is_some() {} @@ -297,11 +562,16 @@ impl Drop for InFlightGuard { async fn fire_cronjob( idx: usize, job: &CronJobConfig, + usercron_path: Option, router: &Arc, adapters: &HashMap>, in_flight: Arc>>, + usercron_write_lock: Arc>, ) { - let _guard = InFlightGuard { idx, set: in_flight }; + let _guard = InFlightGuard { + idx, + set: in_flight, + }; let adapter = match adapters.get(&job.platform) { Some(a) => a.clone(), @@ -311,6 +581,63 @@ async fn fire_cronjob( } }; + if let Some(command) = non_empty_opt(job.disable_on_success.as_deref()) { + let marker = match non_empty_opt(job.disable_on_success_match.as_deref()) { + Some(marker) => marker, + None => { + warn!( + id = job.id.as_deref().unwrap_or(""), + "disable_on_success configured without disable_on_success_match, treating as not achieved" + ); + "" + } + }; + if !marker.is_empty() { + match check_disable_on_success(job, command, marker).await { + DisableOnSuccessResult::Achieved => { + let channel = ChannelRef { + platform: job.platform.clone(), + channel_id: job.channel.clone(), + thread_id: job.thread_id.clone(), + parent_id: None, + origin_event_id: None, + }; + if let Err(e) = adapter + .send_message( + &channel, + &format!( + "✅ Goal achieved: `{}` matched `{}`. Disabling cronjob.", + command, marker + ), + ) + .await + { + error!(channel = %job.channel, error = %e, "failed to send goal achieved message"); + } + + if let (Some(path), Some(id)) = + (usercron_path.as_deref(), non_empty_opt(job.id.as_deref())) + { + let _write_guard = usercron_write_lock.lock().await; + if let Err(e) = update_usercron_job(path, id, Some(false), None) { + error!(path = %path.display(), id, error = %e, "failed to disable completed usercron job"); + } + } else { + warn!("completed disable_on_success job has no usercron path or id, cannot write enabled=false"); + } + return; + } + DisableOnSuccessResult::NotAchieved(reason) => { + info!( + id = job.id.as_deref().unwrap_or(""), + reason, + "disable_on_success not achieved, firing cronjob normally" + ); + } + } + } + } + let thread_channel = ChannelRef { platform: job.platform.clone(), channel_id: job.channel.clone(), @@ -319,7 +646,13 @@ async fn fire_cronjob( origin_event_id: None, }; - let trigger_msg = match adapter.send_message(&thread_channel, &format!("🕐 [{}]: {}", job.sender_name, job.message)).await { + let trigger_msg = match adapter + .send_message( + &thread_channel, + &format!("🕐 [{}]: {}", job.sender_name, job.message), + ) + .await + { Ok(msg) => msg, Err(e) => { error!(channel = %job.channel, error = %e, "failed to send cron message"); @@ -331,11 +664,31 @@ async fn fire_cronjob( thread_channel.clone() } else { let thread_name = format::shorten_thread_name(&job.message); - match adapter.create_thread(&thread_channel, &trigger_msg, &thread_name).await { - Ok(ch) => ch, + match adapter + .create_thread(&thread_channel, &trigger_msg, &thread_name) + .await + { + Ok(ch) => { + if let (Some(path), Some(id), Some(thread_id)) = ( + usercron_path.as_deref(), + non_empty_opt(job.id.as_deref()), + ch.thread_id.as_deref().or(Some(ch.channel_id.as_str())), + ) { + let _write_guard = usercron_write_lock.lock().await; + if let Err(e) = update_usercron_job(path, id, None, Some(thread_id)) { + warn!(path = %path.display(), id, error = %e, "failed to persist usercron thread_id"); + } + } + ch + } Err(e) => { error!(channel = %job.channel, error = %e, "failed to create cron thread"); - let _ = adapter.send_message(&thread_channel, &format!("⚠️ cronjob: failed to create thread: {e}")).await; + let _ = adapter + .send_message( + &thread_channel, + &format!("⚠️ cronjob: failed to create thread: {e}"), + ) + .await; return; } } @@ -347,10 +700,19 @@ async fn fire_cronjob( sender_name: job.sender_name.clone(), display_name: job.sender_name.clone(), channel: job.platform.clone(), - channel_id: reply_channel.parent_id.as_deref().unwrap_or(&reply_channel.channel_id).to_string(), - thread_id: reply_channel.thread_id.clone().or(Some(reply_channel.channel_id.clone())), + channel_id: reply_channel + .parent_id + .as_deref() + .unwrap_or(&reply_channel.channel_id) + .to_string(), + thread_id: reply_channel + .thread_id + .clone() + .or(Some(reply_channel.channel_id.clone())), is_bot: true, timestamp: Some(Utc::now().to_rfc3339()), + message_id: None, // cron jobs don't originate from a message + receiver_id: None, // cron jobs are self-triggered, no external receiver }; let sender_json = match serde_json::to_string(&sender) { Ok(j) => j, @@ -361,19 +723,189 @@ async fn fire_cronjob( }; if let Err(e) = router - .handle_message(&adapter, crate::adapter::MessageContext { - thread_channel: reply_channel.clone(), - sender_json, - prompt: job.message.clone(), - extra_blocks: vec![], - trigger_msg, - other_bot_present: false, - }) + .handle_message( + &adapter, + crate::adapter::MessageContext { + thread_channel: reply_channel.clone(), + sender_json, + prompt: job.message.clone(), + extra_blocks: vec![], + trigger_msg, + other_bot_present: false, + }, + ) .await { error!("cron handle_message error: {e}"); - let _ = adapter.send_message(&reply_channel, &format!("⚠️ cronjob error: {e}")).await; + let _ = adapter + .send_message(&reply_channel, &format!("⚠️ cronjob error: {e}")) + .await; + } +} + +enum DisableOnSuccessResult { + Achieved, + NotAchieved(&'static str), +} + +fn non_empty_opt(value: Option<&str>) -> Option<&str> { + value.and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +async fn check_disable_on_success( + job: &CronJobConfig, + command: &str, + marker: &str, +) -> DisableOnSuccessResult { + let timeout_secs = job.disable_on_success_timeout_secs.max(1); + let mut cmd = shell_command(command); + if let Some(dir) = non_empty_opt(job.disable_on_success_working_dir.as_deref()) { + cmd.current_dir(dir); + } + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(e) => { + warn!( + id = job.id.as_deref().unwrap_or(""), + command, + error = %e, + "disable_on_success command failed to start" + ); + return DisableOnSuccessResult::NotAchieved("command failed to start"); + } + }; + + // Take stdout/stderr handles and drain them concurrently to prevent pipe buffer deadlock. + let stdout_handle = child.stdout.take(); + let stderr_handle = child.stderr.take(); + + let stdout_task = tokio::spawn(async move { + let mut buf = Vec::new(); + if let Some(mut out) = stdout_handle { + let _ = tokio::io::AsyncReadExt::read_to_end(&mut out, &mut buf).await; + } + buf + }); + let stderr_task = tokio::spawn(async move { + let mut buf = Vec::new(); + if let Some(mut err) = stderr_handle { + let _ = tokio::io::AsyncReadExt::read_to_end(&mut err, &mut buf).await; + } + buf + }); + + let deadline = tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)); + tokio::pin!(deadline); + + tokio::select! { + status = child.wait() => { + let status = match status { + Ok(s) => s, + Err(e) => { + warn!( + id = job.id.as_deref().unwrap_or(""), + command, + error = %e, + "disable_on_success command wait failed" + ); + stdout_task.abort(); + stderr_task.abort(); + return DisableOnSuccessResult::NotAchieved("command wait failed"); + } + }; + if !status.success() { + stdout_task.abort(); + stderr_task.abort(); + return DisableOnSuccessResult::NotAchieved("command exited non-zero"); + } + let stdout_buf = stdout_task.await.unwrap_or_default(); + let stderr_buf = stderr_task.await.unwrap_or_default(); + let stdout = String::from_utf8_lossy(&stdout_buf); + let stderr = String::from_utf8_lossy(&stderr_buf); + if stdout.contains(marker) || stderr.contains(marker) { + DisableOnSuccessResult::Achieved + } else { + DisableOnSuccessResult::NotAchieved("success marker not found") + } + } + _ = &mut deadline => { + // Timeout — kill the child to avoid orphan processes. + let _ = child.kill().await; + stdout_task.abort(); + stderr_task.abort(); + warn!( + id = job.id.as_deref().unwrap_or(""), + command, + timeout_secs, + "disable_on_success command timed out" + ); + DisableOnSuccessResult::NotAchieved("command timed out") + } + } +} + +fn shell_command(command: &str) -> Command { + #[cfg(windows)] + { + let mut child = Command::new("cmd"); + child.arg("/C").arg(command); + child + } + #[cfg(not(windows))] + { + let mut child = Command::new("sh"); + child.arg("-c").arg(command); + child + } +} + +fn update_usercron_job( + path: &Path, + id: &str, + enabled: Option, + thread_id: Option<&str>, +) -> anyhow::Result<()> { + let content = std::fs::read_to_string(path)?; + let mut doc = content.parse::()?; + let jobs = doc + .get_mut("jobs") + .and_then(|item| item.as_array_of_tables_mut()) + .ok_or_else(|| anyhow::anyhow!("usercron file has no [[jobs]] array"))?; + + let mut found = false; + for table in jobs.iter_mut() { + if table.get("id").and_then(|item| item.as_str()) != Some(id) { + continue; + } + if let Some(enabled) = enabled { + table["enabled"] = value(enabled); + } + if let Some(thread_id) = thread_id { + table["thread_id"] = value(thread_id); + } + found = true; + break; } + + if !found { + anyhow::bail!("usercron job id {:?} not found", id); + } + + // Atomic write: write to temp file then rename to avoid corruption on crash. + let tmp = path.with_extension("toml.tmp"); + std::fs::write(&tmp, doc.to_string())?; + std::fs::rename(&tmp, path)?; + Ok(()) } #[cfg(test)] @@ -381,6 +913,258 @@ mod tests { use super::*; use chrono::{Datelike, Timelike}; + // --- POSIX day-of-week translator --- + + #[test] + fn translate_dow_star_passes_through() { + assert_eq!(translate_posix_dow_field("*").unwrap(), "*"); + } + + #[test] + fn translate_dow_single_sunday_zero() { + assert_eq!(translate_posix_dow_field("0").unwrap(), "1"); + } + + #[test] + fn translate_dow_single_sunday_seven() { + assert_eq!(translate_posix_dow_field("7").unwrap(), "1"); + } + + #[test] + fn translate_dow_single_monday() { + assert_eq!(translate_posix_dow_field("1").unwrap(), "2"); + } + + #[test] + fn translate_dow_single_saturday() { + assert_eq!(translate_posix_dow_field("6").unwrap(), "7"); + } + + #[test] + fn translate_dow_weekday_range() { + // POSIX 1-5 (Mon-Fri) -> cron crate 2-6 + assert_eq!(translate_posix_dow_field("1-5").unwrap(), "2-6"); + } + + #[test] + fn translate_dow_all_days_zero_to_six() { + assert_eq!(translate_posix_dow_field("0-6").unwrap(), "*"); + } + + #[test] + fn translate_dow_all_days_zero_to_seven() { + // POSIX `0-7` is a quirky but valid "all days" expression. + assert_eq!(translate_posix_dow_field("0-7").unwrap(), "*"); + } + + #[test] + fn translate_dow_all_days_one_to_seven() { + // POSIX `1-7` covers Mon..Sun = all 7 days. + assert_eq!(translate_posix_dow_field("1-7").unwrap(), "*"); + } + + #[test] + fn translate_dow_range_three_to_five() { + // POSIX 3-5 (Wed-Fri) -> cron crate 4-6 + assert_eq!(translate_posix_dow_field("3-5").unwrap(), "4-6"); + } + + #[test] + fn translate_dow_list_dedupes_zero_and_seven() { + // Both 0 and 7 = Sunday; output is a single value. + assert_eq!(translate_posix_dow_field("0,7").unwrap(), "1"); + } + + #[test] + fn translate_dow_list_non_contiguous() { + // POSIX 1,3,5 (Mon,Wed,Fri) -> cron crate 2,4,6 + assert_eq!(translate_posix_dow_field("1,3,5").unwrap(), "2,4,6"); + } + + #[test] + fn translate_dow_list_compacts_contiguous_runs() { + // POSIX 1,2,4,5 -> cron crate 2,3,5,6 -> "2-3,5-6" + assert_eq!(translate_posix_dow_field("1,2,4,5").unwrap(), "2-3,5-6"); + } + + #[test] + fn translate_dow_step_from_star() { + // POSIX */2 = 0,2,4,6 = Sun,Tue,Thu,Sat -> cron crate 1,3,5,7 + assert_eq!(translate_posix_dow_field("*/2").unwrap(), "1,3,5,7"); + } + + #[test] + fn translate_dow_step_from_range() { + // POSIX 1-5/2 = 1,3,5 = Mon,Wed,Fri -> cron crate 2,4,6 + assert_eq!(translate_posix_dow_field("1-5/2").unwrap(), "2,4,6"); + } + + #[test] + fn translate_dow_names_pass_through() { + assert_eq!(translate_posix_dow_field("Mon-Fri").unwrap(), "Mon-Fri"); + assert_eq!( + translate_posix_dow_field("Mon,Wed,Fri").unwrap(), + "Mon,Wed,Fri" + ); + assert_eq!(translate_posix_dow_field("Sun").unwrap(), "Sun"); + } + + #[test] + fn translate_dow_step_from_singleton() { + // POSIX 1/2 = from Mon through Sat, step 2 = {1,3,5} = Mon,Wed,Fri -> cron crate 2,4,6 + assert_eq!(translate_posix_dow_field("1/2").unwrap(), "2,4,6"); + } + + #[test] + fn translate_dow_step_from_singleton_sunday() { + // POSIX 0/3 = from Sun through Sat, step 3 = {0,3,6} = Sun,Wed,Sat -> cron crate 1,4,7 + assert_eq!(translate_posix_dow_field("0/3").unwrap(), "1,4,7"); + } + + #[test] + fn translate_dow_step_from_singleton_seven() { + // POSIX 7/2 = Sunday alias, same as 0/2 = {0,2,4,6} = Sun,Tue,Thu,Sat -> cron crate 1,3,5,7 + assert_eq!(translate_posix_dow_field("7/2").unwrap(), "1,3,5,7"); + } + + #[test] + fn translate_dow_rejects_mixed_notation() { + assert!(translate_posix_dow_field("1,Mon").is_err()); + assert!(translate_posix_dow_field("Mon,1").is_err()); + assert!(translate_posix_dow_field("1-Fri").is_err()); + } + + #[test] + fn translate_dow_rejects_out_of_range() { + assert!(translate_posix_dow_field("8").is_err()); + assert!(translate_posix_dow_field("0-8").is_err()); + } + + #[test] + fn translate_dow_rejects_reversed_range() { + assert!(translate_posix_dow_field("5-3").is_err()); + } + + #[test] + fn translate_dow_rejects_empty() { + assert!(translate_posix_dow_field("").is_err()); + assert!(translate_posix_dow_field(",1").is_err()); + assert!(translate_posix_dow_field("1,").is_err()); + } + + #[test] + fn translate_dow_rejects_zero_step() { + assert!(translate_posix_dow_field("*/0").is_err()); + } + + // --- parse_cron_expr rejects wrong number of fields --- + + #[test] + fn parse_rejects_too_few_fields() { + assert!(parse_cron_expr("* * * *").is_err()); + } + + // --- POSIX-semantic Schedule behavior (regression for #784) --- + + #[test] + fn weekday_schedule_does_not_fire_on_sunday() { + use chrono::TimeZone; + // Regression for the reported bug: "0 7 * * 1-5" with timezone Asia/Taipei + // was firing on Sunday 2026-05-10 because the cron crate's `1-5` means + // Sun-Thu without translation. + let schedule = parse_cron_expr("0 7 * * 1-5").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + let sunday = tz.with_ymd_and_hms(2026, 5, 10, 7, 0, 0).unwrap(); + let before = sunday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_ne!( + next, + Some(sunday), + "POSIX 1-5 must not fire on Sunday (got next = {:?})", + next + ); + } + + #[test] + fn weekday_schedule_fires_on_monday() { + use chrono::TimeZone; + let schedule = parse_cron_expr("0 7 * * 1-5").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + let monday = tz.with_ymd_and_hms(2026, 5, 11, 7, 0, 0).unwrap(); + let before = monday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(monday), "POSIX 1-5 must fire on Monday"); + } + + #[test] + fn weekday_schedule_fires_on_friday_not_saturday() { + use chrono::TimeZone; + let schedule = parse_cron_expr("0 7 * * 1-5").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + // 2026-05-15 is Friday + let friday = tz.with_ymd_and_hms(2026, 5, 15, 7, 0, 0).unwrap(); + let before = friday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(friday), "POSIX 1-5 must fire on Friday"); + + // 2026-05-16 is Saturday - should not fire + let saturday = tz.with_ymd_and_hms(2026, 5, 16, 7, 0, 0).unwrap(); + let before = saturday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_ne!(next, Some(saturday), "POSIX 1-5 must not fire on Saturday"); + } + + #[test] + fn sunday_schedule_fires_on_sunday_via_zero() { + use chrono::TimeZone; + let schedule = parse_cron_expr("0 7 * * 0").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + let sunday = tz.with_ymd_and_hms(2026, 5, 10, 7, 0, 0).unwrap(); + let before = sunday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(sunday), "POSIX `0` must fire on Sunday"); + } + + #[test] + fn sunday_schedule_fires_on_sunday_via_seven() { + use chrono::TimeZone; + let schedule = parse_cron_expr("0 7 * * 7").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + let sunday = tz.with_ymd_and_hms(2026, 5, 10, 7, 0, 0).unwrap(); + let before = sunday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(sunday), "POSIX `7` must also fire on Sunday"); + } + + #[test] + fn saturday_schedule_fires_on_saturday_via_six() { + use chrono::TimeZone; + let schedule = parse_cron_expr("0 7 * * 6").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + // 2026-05-16 is Saturday + let saturday = tz.with_ymd_and_hms(2026, 5, 16, 7, 0, 0).unwrap(); + let before = saturday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(saturday), "POSIX `6` must fire on Saturday"); + } + + #[test] + fn name_based_weekday_still_works() { + use chrono::TimeZone; + // Name-based notation should be unaffected by the translation. + let schedule = parse_cron_expr("0 7 * * Mon-Fri").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + let monday = tz.with_ymd_and_hms(2026, 5, 11, 7, 0, 0).unwrap(); + let before = monday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(monday)); + + let sunday = tz.with_ymd_and_hms(2026, 5, 10, 7, 0, 0).unwrap(); + let before = sunday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_ne!(next, Some(sunday)); + } + #[test] fn parse_valid_cron_expression() { let schedule = parse_cron_expr("0 9 * * 1-5").unwrap(); @@ -457,6 +1241,11 @@ message = "hello" assert_eq!(job.sender_name, "openab-cron"); assert_eq!(job.timezone, "UTC"); assert!(job.thread_id.is_none()); + assert!(job.id.is_none()); + assert!(job.disable_on_success.is_none()); + assert!(job.disable_on_success_match.is_none()); + assert_eq!(job.disable_on_success_timeout_secs, 60); + assert!(job.disable_on_success_working_dir.is_none()); } #[test] @@ -483,6 +1272,11 @@ platform = "slack" sender_name = "DailyOps" timezone = "Asia/Taipei" thread_id = "789" +id = "daily-report" +disable_on_success = "npm test" +disable_on_success_match = "SUCCESS" +disable_on_success_timeout_secs = 30 +disable_on_success_working_dir = "/tmp/project" "#; let cfg: UsercronFile = toml::from_str(toml_str).unwrap(); let job = &cfg.jobs[0]; @@ -490,6 +1284,14 @@ thread_id = "789" assert_eq!(job.sender_name, "DailyOps"); assert_eq!(job.timezone, "Asia/Taipei"); assert_eq!(job.thread_id.as_deref(), Some("789")); + assert_eq!(job.id.as_deref(), Some("daily-report")); + assert_eq!(job.disable_on_success.as_deref(), Some("npm test")); + assert_eq!(job.disable_on_success_match.as_deref(), Some("SUCCESS")); + assert_eq!(job.disable_on_success_timeout_secs, 30); + assert_eq!( + job.disable_on_success_working_dir.as_deref(), + Some("/tmp/project") + ); } #[test] @@ -502,12 +1304,16 @@ thread_id = "789" fn load_usercron_valid_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cronjob.toml"); - std::fs::write(&path, r#" + std::fs::write( + &path, + r#" [[jobs]] schedule = "* * * * *" channel = "123" message = "ping" -"#).unwrap(); +"#, + ) + .unwrap(); let jobs = load_usercron_file(&path, &["discord"]); assert_eq!(jobs.len(), 1); assert_eq!(jobs[0].message, "ping"); @@ -526,7 +1332,9 @@ message = "ping" fn load_usercron_skips_invalid_entries() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cronjob.toml"); - std::fs::write(&path, r#" + std::fs::write( + &path, + r#" [[jobs]] schedule = "* * * * *" channel = "123" @@ -536,7 +1344,9 @@ message = "good" schedule = "bad cron" channel = "456" message = "bad" -"#).unwrap(); +"#, + ) + .unwrap(); let jobs = load_usercron_file(&path, &["discord"]); assert_eq!(jobs.len(), 1); assert_eq!(jobs[0].message, "good"); @@ -546,7 +1356,9 @@ message = "bad" fn load_usercron_skips_unconfigured_platform() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cronjob.toml"); - std::fs::write(&path, r#" + std::fs::write( + &path, + r#" [[jobs]] schedule = "* * * * *" channel = "123" @@ -557,21 +1369,201 @@ schedule = "* * * * *" channel = "456" message = "slack job" platform = "slack" -"#).unwrap(); +"#, + ) + .unwrap(); // Only discord configured let jobs = load_usercron_file(&path, &["discord"]); assert_eq!(jobs.len(), 1); assert_eq!(jobs[0].message, "discord job"); } + #[test] + fn load_usercron_skips_disable_on_success_without_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +schedule = "* * * * *" +channel = "123" +message = "missing id" +disable_on_success = "echo SUCCESS" +disable_on_success_match = "SUCCESS" +"#, + ) + .unwrap(); + let jobs = load_usercron_file(&path, &["discord"]); + assert!(jobs.is_empty()); + } + + #[test] + fn load_usercron_skips_disable_on_success_without_match() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +id = "goal" +schedule = "* * * * *" +channel = "123" +message = "missing marker" +disable_on_success = "echo SUCCESS" +"#, + ) + .unwrap(); + let jobs = load_usercron_file(&path, &["discord"]); + assert!(jobs.is_empty()); + } + + #[test] + fn validate_cronjobs_rejects_baseline_disable_on_success() { + let jobs = vec![CronJobConfig { + id: Some("baseline-goal".into()), + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("echo SUCCESS".into()), + disable_on_success_match: Some("SUCCESS".into()), + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, + }]; + let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); + assert!(err.to_string().contains("only supported in usercron")); + } + + #[test] + fn update_usercron_job_sets_enabled_and_thread_id_by_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +id = "goal-a" +enabled = true +schedule = "* * * * *" +channel = "123" +message = "a" + +[[jobs]] +id = "goal-b" +enabled = true +schedule = "* * * * *" +channel = "456" +message = "b" +"#, + ) + .unwrap(); + + update_usercron_job(&path, "goal-b", Some(false), Some("thread-456")).unwrap(); + + let updated = std::fs::read_to_string(&path).unwrap(); + let doc = updated.parse::().unwrap(); + let jobs = doc["jobs"].as_array_of_tables().unwrap(); + let job_a = jobs.iter().next().unwrap(); + let job_b = jobs.iter().nth(1).unwrap(); + assert_eq!(job_a["id"].as_str(), Some("goal-a")); + assert_eq!(job_a["enabled"].as_bool(), Some(true)); + assert!(job_a.get("thread_id").is_none()); + assert_eq!(job_b["id"].as_str(), Some("goal-b")); + assert_eq!(job_b["enabled"].as_bool(), Some(false)); + assert_eq!(job_b["thread_id"].as_str(), Some("thread-456")); + } + + #[test] + fn update_usercron_job_errors_for_missing_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +id = "goal-a" +schedule = "* * * * *" +channel = "123" +message = "a" +"#, + ) + .unwrap(); + let err = update_usercron_job(&path, "missing", Some(false), None).unwrap_err(); + assert!(err.to_string().contains("not found")); + } + + #[tokio::test] + async fn disable_on_success_requires_exit_zero_and_marker() { + let mut job = test_cron_job(); + job.disable_on_success_timeout_secs = 5; + + assert!(matches!( + check_disable_on_success(&job, "printf SUCCESS", "SUCCESS").await, + DisableOnSuccessResult::Achieved + )); + assert!(matches!( + check_disable_on_success(&job, "printf DONE", "SUCCESS").await, + DisableOnSuccessResult::NotAchieved("success marker not found") + )); + assert!(matches!( + check_disable_on_success(&job, "printf SUCCESS; exit 1", "SUCCESS").await, + DisableOnSuccessResult::NotAchieved("command exited non-zero") + )); + } + + #[tokio::test] + async fn disable_on_success_kills_child_on_timeout() { + let mut job = test_cron_job(); + job.disable_on_success_timeout_secs = 1; + + let result = check_disable_on_success(&job, "sleep 999", "SUCCESS").await; + assert!(matches!( + result, + DisableOnSuccessResult::NotAchieved("command timed out") + )); + } + + fn test_cron_job() -> CronJobConfig { + CronJobConfig { + id: Some("goal".into()), + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("echo SUCCESS".into()), + disable_on_success_match: Some("SUCCESS".into()), + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, + } + } + // --- validate_cronjobs tests --- #[test] fn validate_cronjobs_valid_passes() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "0 9 * * 1-5".into(), channel: "123".into(), - message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + id: None, + enabled: true, + schedule: "0 9 * * 1-5".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_ok()); } @@ -579,9 +1571,19 @@ platform = "slack" #[test] fn validate_cronjobs_invalid_cron_fails() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "bad".into(), channel: "123".into(), - message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + id: None, + enabled: true, + schedule: "bad".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("invalid cron expression")); @@ -590,9 +1592,19 @@ platform = "slack" #[test] fn validate_cronjobs_invalid_timezone_fails() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "* * * * *".into(), channel: "123".into(), - message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), - thread_id: None, timezone: "Mars/Olympus".into(), + id: None, + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "Mars/Olympus".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("invalid timezone")); @@ -601,9 +1613,19 @@ platform = "slack" #[test] fn validate_cronjobs_unknown_platform_fails() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "* * * * *".into(), channel: "123".into(), - message: "hi".into(), platform: "telegram".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + id: None, + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "telegram".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("unknown platform")); @@ -612,9 +1634,19 @@ platform = "slack" #[test] fn validate_cronjobs_unconfigured_platform_fails() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "* * * * *".into(), channel: "123".into(), - message: "hi".into(), platform: "slack".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + id: None, + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "slack".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("not configured")); @@ -623,9 +1655,19 @@ platform = "slack" #[test] fn validate_cronjobs_disabled_with_invalid_cron_passes() { let jobs = vec![CronJobConfig { - enabled: false, schedule: "bad".into(), channel: "123".into(), - message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + id: None, + enabled: false, + schedule: "bad".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_ok()); } @@ -633,9 +1675,19 @@ platform = "slack" #[test] fn validate_cronjobs_enabled_with_invalid_cron_still_fails() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "bad".into(), channel: "123".into(), - message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + id: None, + enabled: true, + schedule: "bad".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_err()); } diff --git a/src/discord.rs b/src/discord.rs index 13987deae..4b61c0035 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,23 +1,29 @@ -use crate::acp::ContentBlock; use crate::acp::protocol::ConfigOption; -use crate::adapter::{AdapterRouter, ChatAdapter, ChannelRef, MessageRef, SenderContext}; -use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity}; +use crate::acp::ContentBlock; +use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, SenderContext}; +use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity, BOT_TURN_LIMIT_WARNING_PREFIX}; use crate::config::{AllowBots, AllowUsers, SttConfig}; use crate::format; use crate::media; +use crate::remind::{self, ReminderStore}; use async_trait::async_trait; -use std::sync::LazyLock; -use serenity::builder::{CreateActionRow, CreateButton, CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditMessage}; -use serenity::model::application::ButtonStyle; +use serenity::builder::{ + CreateActionRow, CreateAttachment, CreateButton, CreateCommand, CreateCommandOption, + CreateInteractionResponse, CreateInteractionResponseFollowup, CreateInteractionResponseMessage, + CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditMessage, + GetMessages, +}; use serenity::http::Http; -use serenity::model::application::{Command, ComponentInteractionDataKind, Interaction}; +use serenity::model::application::ButtonStyle; +use serenity::model::application::{Command, CommandOptionType, ComponentInteractionDataKind, Interaction}; use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, ReactionType}; use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, MessageId, UserId}; use serenity::prelude::*; use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; use std::sync::{Arc, OnceLock}; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; /// Hard cap on consecutive bot messages in a channel or thread. /// Prevents runaway loops between multiple bots in "all" mode. @@ -29,6 +35,9 @@ const PARTICIPATION_CACHE_MAX: usize = 1000; /// Discord StringSelectMenu hard limit on options. const SELECT_MENU_PAGE_SIZE: usize = 25; +/// Avoid unbounded Discord history exports from very large threads. +const THREAD_EXPORT_MESSAGE_LIMIT: usize = 5000; + // --- DiscordAdapter: implements ChatAdapter for Discord via serenity --- pub struct DiscordAdapter { @@ -57,7 +66,11 @@ impl ChatAdapter for DiscordAdapter { 2000 } - async fn send_message(&self, channel: &ChannelRef, content: &str) -> anyhow::Result { + async fn send_message( + &self, + channel: &ChannelRef, + content: &str, + ) -> anyhow::Result { let ch_id: u64 = Self::resolve_channel(channel).parse()?; let msg = ChannelId::new(ch_id).say(&self.http, content).await?; Ok(MessageRef { @@ -66,6 +79,46 @@ impl ChatAdapter for DiscordAdapter { }) } + async fn send_message_with_reply( + &self, + channel: &ChannelRef, + content: &str, + reply_to_message_id: &str, + ) -> anyhow::Result { + let ch_id: u64 = Self::resolve_channel(channel).parse()?; + let msg_id: u64 = reply_to_message_id.parse().unwrap_or(0); + if msg_id == 0 { + // Invalid message ID, fall back to plain send + return self.send_message(channel, content).await; + } + let builder = serenity::builder::CreateMessage::new() + .content(content) + .reference_message((ChannelId::new(ch_id), MessageId::new(msg_id))); + match ChannelId::new(ch_id) + .send_message(&self.http, builder) + .await + { + Ok(msg) => Ok(MessageRef { + channel: channel.clone(), + message_id: msg.id.to_string(), + }), + Err(e) => { + // Fallback to plain send if reply fails (e.g. unknown message, cross-channel) + tracing::warn!(error = ?e, reply_to = reply_to_message_id, "reply_to failed, falling back to plain send"); + self.send_message(channel, content).await + } + } + } + + async fn delete_message(&self, msg: &MessageRef) -> anyhow::Result<()> { + let ch_id: u64 = Self::resolve_channel(&msg.channel).parse()?; + let msg_id: u64 = msg.message_id.parse()?; + self.http + .delete_message(ChannelId::new(ch_id), MessageId::new(msg_id), None) + .await?; + Ok(()) + } + async fn edit_message(&self, msg: &MessageRef, content: &str) -> anyhow::Result<()> { let ch_id: u64 = Self::resolve_channel(&msg.channel).parse()?; let msg_id: u64 = msg.message_id.parse()?; @@ -147,6 +200,8 @@ pub struct Handler { pub allow_bot_messages: AllowBots, pub trusted_bot_ids: HashSet, pub allow_user_messages: AllowUsers, + /// Role IDs that trigger the bot (same as direct @mention). + pub allowed_role_ids: HashSet, /// Positive-only cache: thread channel_id → cached_at for threads where bot has participated. pub participated_threads: tokio::sync::Mutex>, /// Positive-only cache: thread channel_id → cached_at for threads where other bots have posted. @@ -162,6 +217,10 @@ pub struct Handler { pub allow_dm: bool, /// Per-thread dispatcher (Message mode uses cap=1 for FIFO; Thread/Lane use configured cap). pub dispatcher: Arc, + /// Reminder store for /remind slash command. + pub reminder_store: ReminderStore, + /// Track scheduled reminder IDs to prevent duplicate scheduling on reconnect. + pub scheduled_ids: tokio::sync::Mutex>, } impl Handler { @@ -181,11 +240,15 @@ impl Handler { // Check positive caches let cached_involved = { let cache = self.participated_threads.lock().await; - cache.get(&key).is_some_and(|ts| ts.elapsed() < self.session_ttl) + cache + .get(&key) + .is_some_and(|ts| ts.elapsed() < self.session_ttl) }; let cached_multibot = { let cache = self.multibot_threads.lock().await; - cache.get(&key).is_some_and(|ts| ts.elapsed() < self.session_ttl) + cache + .get(&key) + .is_some_and(|ts| ts.elapsed() < self.session_ttl) }; // Both cached → skip fetch entirely @@ -212,7 +275,10 @@ impl Handler { }; let involved = cached_involved || messages.iter().any(|m| m.author.id == bot_id); - let other_bot_present = cached_multibot || messages.iter().any(|m| m.author.bot && m.author.id != bot_id); + let other_bot_present = cached_multibot + || messages + .iter() + .any(|m| m.author.bot && m.author.id != bot_id); if involved && !cached_involved { let mut cache = self.participated_threads.lock().await; @@ -277,7 +343,11 @@ impl EventHandler for Handler { match tracker.classify_bot_message(&thread_key) { TurnAction::Continue => {} TurnAction::SilentStop => return, - TurnAction::WarnAndStop { severity, turns, user_message } => { + TurnAction::WarnAndStop { + severity, + turns, + user_message, + } => { match severity { TurnSeverity::Hard => tracing::warn!( channel_id = %msg.channel_id, @@ -332,7 +402,25 @@ impl EventHandler for Handler { .bot_participated_in_thread(&ctx.http, msg.channel_id, bot_id) .await; if participated { - let _ = msg.channel_id.say(&ctx.http, &user_message).await; + // Dedup: skip if another bot already posted the same + // warning in this thread. Prevents N duplicate warnings + // when N bot processes each hit the soft limit. (#530) + let recent = msg + .channel_id + .messages( + &ctx.http, + serenity::builder::GetMessages::new().limit(10), + ) + .await + .unwrap_or_default(); + let pairs: Vec<(bool, &str)> = recent + .iter() + .map(|m| (m.author.bot, m.content.as_str())) + .collect(); + let already_warned = turn_limit_warning_present(&pairs); + if !already_warned { + let _ = msg.channel_id.say(&ctx.http, &user_message).await; + } } } return; @@ -350,28 +438,41 @@ impl EventHandler for Handler { return; } - let adapter = self.adapter.get_or_init(|| { - Arc::new(DiscordAdapter::new(ctx.http.clone())) - }).clone(); + let adapter = self + .adapter + .get_or_init(|| Arc::new(DiscordAdapter::new(ctx.http.clone()))) + .clone(); let channel_id = msg.channel_id.get(); let in_allowed_channel = self.allow_all_channels || self.allowed_channels.contains(&channel_id); let is_mentioned = msg.mentions_user_id(bot_id) - || msg.content.contains(&format!("<@{}>", bot_id)); + || msg.content.contains(&format!("<@{}>", bot_id)) + || (!self.allowed_role_ids.is_empty() + && msg + .mention_roles + .iter() + .any(|r| self.allowed_role_ids.contains(&r.get()))); // Bot message gating (from upstream #321) if msg.author.bot { match self.allow_bot_messages { AllowBots::Off => return, - AllowBots::Mentions => if !is_mentioned { return; }, + AllowBots::Mentions => { + if !is_mentioned { + return; + } + } AllowBots::All => { let cap = MAX_CONSECUTIVE_BOT_TURNS as usize; let limit = std::cmp::min(MAX_CONSECUTIVE_BOT_TURNS, 100) as u8; - let history = ctx.cache.channel_messages(msg.channel_id) + let history = ctx + .cache + .channel_messages(msg.channel_id) .map(|msgs| { - let mut recent: Vec<_> = msgs.iter() + let mut recent: Vec<_> = msgs + .iter() .filter(|(mid, _)| **mid < msg.id) .map(|(_, m)| m.clone()) .collect(); @@ -384,8 +485,14 @@ impl EventHandler for Handler { let recent = if let Some(cached) = history { cached } else { - match msg.channel_id - .messages(&ctx.http, serenity::builder::GetMessages::new().before(msg.id).limit(limit)) + match msg + .channel_id + .messages( + &ctx.http, + serenity::builder::GetMessages::new() + .before(msg.id) + .limit(limit), + ) .await { Ok(msgs) => msgs, @@ -396,17 +503,20 @@ impl EventHandler for Handler { } }; - let consecutive_bot = recent.iter() + let consecutive_bot = recent + .iter() .take_while(|m| m.author.bot && m.author.id != bot_id) .count(); if consecutive_bot >= cap { tracing::warn!(channel_id = %msg.channel_id, cap, "bot turn cap reached, ignoring"); return; } - }, + } } - if !self.trusted_bot_ids.is_empty() && !self.trusted_bot_ids.contains(&msg.author.id.get()) { + if !self.trusted_bot_ids.is_empty() + && !self.trusted_bot_ids.contains(&msg.author.id.get()) + { tracing::debug!(bot_id = %msg.author.id, "bot not in trusted_bot_ids, ignoring"); return; } @@ -415,7 +525,11 @@ impl EventHandler for Handler { // Thread detection: single to_channel() call for both allowed and // non-allowed channels. Uses thread_metadata (not parent_id) to // identify threads — see detect_thread() doc comments for rationale. - let (in_thread, bot_owns_thread, thread_parent_id, is_dm) = match msg.channel_id.to_channel(&ctx.http).await { + let (in_thread, bot_owns_thread, thread_parent_id, is_dm) = match msg + .channel_id + .to_channel(&ctx.http) + .await + { Ok(serenity::model::channel::Channel::Guild(gc)) => { let parent = gc.parent_id.map(|id| id.get().to_string()); let result = detect_thread( @@ -436,7 +550,12 @@ impl EventHandler for Handler { bot_owns = ?result.1, "thread check" ); - (result.0, result.1.unwrap_or(false), if result.0 { parent } else { None }, false) + ( + result.0, + result.1.unwrap_or(false), + if result.0 { parent } else { None }, + false, + ) } Ok(serenity::model::channel::Channel::Private(_)) => { tracing::debug!(channel_id = %msg.channel_id, "DM channel"); @@ -513,14 +632,19 @@ impl EventHandler for Handler { } } - if is_denied_user(msg.author.bot, self.allow_all_users, &self.allowed_users, msg.author.id.get()) { + if is_denied_user( + msg.author.bot, + self.allow_all_users, + &self.allowed_users, + msg.author.id.get(), + ) { tracing::info!(user_id = %msg.author.id, "denied user, ignoring"); let msg_ref = discord_msg_ref(&msg); let _ = adapter.add_reaction(&msg_ref, "🚫").await; return; } - let prompt = resolve_mentions(&msg.content, bot_id); + let prompt = resolve_mentions(&msg.content, bot_id, &self.allowed_role_ids); // No text and no attachments → skip if prompt.is_empty() && msg.attachments.is_empty() { @@ -540,10 +664,15 @@ impl EventHandler for Handler { thread_parent_id.as_deref(), msg.author.bot, &msg.timestamp.to_rfc3339().unwrap_or_default(), + &msg.id.to_string(), + &bot_id.to_string(), ); - // Build extra content blocks from attachments (audio → STT, text → inline, image → encode) + // Build extra content blocks from attachments (audio -> STT, text -> inline, + // image -> encode, video -> URL for agent-side inspection). let mut extra_blocks = Vec::new(); + let mut echo_entries: Vec = Vec::new(); + let mut failed_image_files: Vec = Vec::new(); let mut text_file_bytes: u64 = 0; let mut text_file_count: u32 = 0; const TEXT_TOTAL_CAP: u64 = 1024 * 1024; // 1 MB total for all text file attachments @@ -554,25 +683,38 @@ impl EventHandler for Handler { if media::is_audio_mime(mime) { if self.stt_config.enabled { let mime_clean = mime.split(';').next().unwrap_or(mime).trim(); - if let Some(transcript) = media::download_and_transcribe( + match media::download_and_transcribe( &attachment.url, &attachment.filename, mime_clean, u64::from(attachment.size), &self.stt_config, None, - ).await { - debug!(filename = %attachment.filename, chars = transcript.len(), "voice transcript injected"); - extra_blocks.insert(0, ContentBlock::Text { - text: format!("[Voice message transcript]: {transcript}"), - }); + ) + .await + { + Some(transcript) => { + debug!(filename = %attachment.filename, chars = transcript.len(), "voice transcript injected"); + extra_blocks.insert( + 0, + ContentBlock::Text { + text: format!("[Voice message transcript]: {transcript}"), + }, + ); + echo_entries.push(crate::stt::EchoEntry::Success(transcript)); + } + None => { + warn!(filename = %attachment.filename, "STT failed for voice attachment"); + echo_entries.push(crate::stt::EchoEntry::Failed); + } } } else { tracing::warn!(filename = %attachment.filename, "skipping audio attachment (STT disabled)"); let msg_ref = discord_msg_ref(&msg); let _ = adapter.add_reaction(&msg_ref, "🎤").await; } - } else if media::is_text_file(&attachment.filename, attachment.content_type.as_deref()) { + } else if media::is_text_file(&attachment.filename, attachment.content_type.as_deref()) + { if text_file_count >= TEXT_FILE_COUNT_CAP { tracing::warn!(filename = %attachment.filename, count = text_file_count, "text file count cap reached, skipping"); continue; @@ -588,21 +730,52 @@ impl EventHandler for Handler { &attachment.filename, u64::from(attachment.size), None, - ).await { + ) + .await + { text_file_bytes += actual_bytes; text_file_count += 1; debug!(filename = %attachment.filename, "adding text file attachment"); extra_blocks.push(block); } - } else if let Some(block) = media::download_and_encode_image( - &attachment.url, - attachment.content_type.as_deref(), - &attachment.filename, - u64::from(attachment.size), - None, - ).await { - debug!(url = %attachment.url, filename = %attachment.filename, "adding image attachment"); - extra_blocks.push(block); + } else { + match media::download_and_encode_image( + &attachment.url, + attachment.content_type.as_deref(), + &attachment.filename, + u64::from(attachment.size), + None, + ) + .await + { + Ok(block) => { + debug!(url = %attachment.url, filename = %attachment.filename, "adding image attachment"); + extra_blocks.push(block); + } + Err(media::MediaFetchError::NotAnImage) => { + if media::is_video_file( + &attachment.filename, + attachment.content_type.as_deref(), + ) { + debug!(url = %attachment.url, filename = %attachment.filename, "adding video attachment link"); + extra_blocks.push(video_attachment_block( + &attachment.filename, + attachment.content_type.as_deref(), + u64::from(attachment.size), + &attachment.url, + )); + } + } + Err(e) => { + tracing::warn!( + url = %attachment.url, + filename = %attachment.filename, + error = %e, + "image attachment failed" + ); + failed_image_files.push(attachment.filename.clone()); + } + } } } @@ -632,6 +805,23 @@ impl EventHandler for Handler { } }; + // Notify user if any images couldn't be processed. + if !failed_image_files.is_empty() { + let file_list = failed_image_files + .iter() + .map(|n| format!("`{}`", n.replace('`', "'"))) + .collect::>() + .join(", "); + let warn_msg = format!( + ":warning: I couldn't process the image(s) you shared ({}). \ + The files may be inaccessible or in an unsupported format (PNG/JPEG/GIF/WebP only).", + file_list + ); + if let Err(e) = adapter.send_message(&thread_channel, &warn_msg).await { + tracing::warn!(error = %e, "failed to send image warning to user"); + } + } + let trigger_msg = discord_msg_ref(&msg); // Per-thread streaming: check if another bot is present in this thread @@ -649,15 +839,24 @@ impl EventHandler for Handler { } let dispatcher = self.dispatcher.clone(); + let stt_cfg = self.stt_config.clone(); tokio::spawn(async move { + // Best-effort echo before the agent reply so the user can verify STT. + crate::stt::post_echo( + &adapter, + &thread_channel, + &trigger_msg, + &echo_entries, + &stt_cfg, + ) + .await; + let sender_id = sender.sender_id.clone(); let sender_name = sender.sender_name.clone(); let sender_json = serde_json::to_string(&sender).unwrap(); - let thread_key = - dispatcher.key("discord", &thread_channel.channel_id, &sender_id); - let estimated_tokens = - crate::dispatch::estimate_tokens(&prompt, &extra_blocks); + let thread_key = dispatcher.key("discord", &thread_channel.channel_id, &sender_id); + let estimated_tokens = crate::dispatch::estimate_tokens(&prompt, &extra_blocks); let buf_msg = crate::dispatch::BufferedMessage { sender_json, sender_name, @@ -682,35 +881,88 @@ impl EventHandler for Handler { // Build the shared command list once. let commands = vec![ - CreateCommand::new("models") - .description("Select the AI model for this session"), - CreateCommand::new("agents") - .description("Select the agent mode for this session"), - CreateCommand::new("cancel") - .description("Cancel the current operation"), + CreateCommand::new("models").description("Select the AI model for this session"), + CreateCommand::new("agents").description("Select the agent mode for this session"), + CreateCommand::new("cancel").description("Cancel the current operation"), CreateCommand::new("cancel-all") .description("Cancel current operation and drop all buffered messages"), - CreateCommand::new("reset") - .description("Reset the conversation session"), + CreateCommand::new("reset").description("Reset the conversation session"), + CreateCommand::new("remind") + .description("Set a one-shot reminder to mention users/roles after a delay") + .add_option(CreateCommandOption::new( + CommandOptionType::String, + "targets", + "Users/roles to mention (e.g. @user1 @role1)", + ).required(true)) + .add_option(CreateCommandOption::new( + CommandOptionType::String, + "message", + "Reminder message", + ).required(true)) + .add_option(CreateCommandOption::new( + CommandOptionType::String, + "delay", + "Delay before firing (e.g. 30m, 2h, 1d)", + ).required(true)), + CreateCommand::new("export-thread") + .description("Download this thread as a text file") + .add_option(CreateCommandOption::new( + CommandOptionType::Integer, + "limit", + "Export only the most recent N messages (1–5000)", + )) + .add_option(CreateCommandOption::new( + CommandOptionType::String, + "since", + "Export messages after this message ID", + )) + .add_option(CreateCommandOption::new( + CommandOptionType::Integer, + "days", + "Export messages from the last N days (1–365)", + )) + .add_option(CreateCommandOption::new( + CommandOptionType::Boolean, + "all", + "Export all messages (up to 5000). Default is last 100.", + )), ]; - // Register global commands (works in DMs + all guilds after propagation). + // Register global commands only. Registering the same commands per-guild + // makes Discord show duplicate slash commands in guild command pickers. if let Err(e) = Command::set_global_commands(&ctx.http, commands.clone()).await { tracing::warn!(error = %e, "failed to register global slash commands"); } else { info!("registered global slash commands"); } - // Also register per-guild for instant availability (global can take up to 1h). + // One-time migration cleanup: older versions registered the same + // slash commands per-guild, and Discord persists those server-side. + // Keep guild command sets empty so only global commands are shown. for guild in &ready.guilds { let guild_id = guild.id; - if let Err(e) = guild_id - .set_commands(&ctx.http, commands.clone()) - .await - { - tracing::warn!(%guild_id, error = %e, "failed to register guild slash commands"); - } else { - info!(%guild_id, "registered guild slash commands"); + if let Err(e) = guild_id.set_commands(&ctx.http, Vec::new()).await { + tracing::warn!( + %guild_id, + error = %e, + "failed to clear stale guild slash commands" + ); + } + } + + // Re-schedule any pending reminders that survived a restart. + let pending = self.reminder_store.pending().await; + if !pending.is_empty() { + let mut scheduled = self.scheduled_ids.lock().await; + let mut count = 0; + for r in pending { + if scheduled.insert(r.id.clone()) { + remind::schedule_reminder(ctx.http.clone(), self.reminder_store.clone(), r); + count += 1; + } + } + if count > 0 { + info!(count, "re-scheduled pending reminders"); } } } @@ -718,10 +970,12 @@ impl EventHandler for Handler { async fn interaction_create(&self, ctx: Context, interaction: Interaction) { match interaction { Interaction::Command(cmd) if cmd.data.name == "models" => { - self.handle_config_command(&ctx, &cmd, "model", "model").await; + self.handle_config_command(&ctx, &cmd, "model", "model") + .await; } Interaction::Command(cmd) if cmd.data.name == "agents" => { - self.handle_config_command(&ctx, &cmd, "agent", "agent").await; + self.handle_config_command(&ctx, &cmd, "agent", "agent") + .await; } Interaction::Command(cmd) if cmd.data.name == "cancel" => { self.handle_cancel_command(&ctx, &cmd).await; @@ -732,6 +986,12 @@ impl EventHandler for Handler { Interaction::Command(cmd) if cmd.data.name == "reset" => { self.handle_reset_command(&ctx, &cmd).await; } + Interaction::Command(cmd) if cmd.data.name == "remind" => { + self.handle_remind_command(&ctx, &cmd).await; + } + Interaction::Command(cmd) if cmd.data.name == "export-thread" => { + self.handle_export_thread_command(&ctx, &cmd).await; + } Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_config_") => { self.handle_config_select(&ctx, &comp).await; } @@ -743,19 +1003,26 @@ impl EventHandler for Handler { } } - // --- Slash command & interaction handlers --- impl Handler { /// Build a Discord select menu from ACP configOptions with the given category. /// Paginates options in pages of 25 (Discord limit). The current selection is /// always placed first so it appears on page 0. - fn build_config_select(options: &[ConfigOption], category: &str, page: usize) -> Option { - let opt = options.iter().find(|o| o.category.as_deref() == Some(category))?; + fn build_config_select( + options: &[ConfigOption], + category: &str, + page: usize, + ) -> Option { + let opt = options + .iter() + .find(|o| o.category.as_deref() == Some(category))?; // Put current selection first so it always lands on page 0, // then fill remaining slots in original order. - let sorted: Vec<_> = opt.options.iter() + let sorted: Vec<_> = opt + .options + .iter() .filter(|o| o.value == opt.current_value) .chain(opt.options.iter().filter(|o| o.value != opt.current_value)) .collect(); @@ -780,13 +1047,20 @@ impl Handler { return None; } - let current_name = opt.options.iter() + let current_name = opt + .options + .iter() .find(|o| o.value == opt.current_value) .map(|o| o.name.as_str()) .unwrap_or(&opt.current_value); let total_pages = sorted.len().div_ceil(SELECT_MENU_PAGE_SIZE); let placeholder = if total_pages > 1 { - format!("Current: {} (page {}/{})", current_name, page + 1, total_pages) + format!( + "Current: {} (page {}/{})", + current_name, + page + 1, + total_pages + ) } else { format!("Current: {}", current_name) }; @@ -794,14 +1068,20 @@ impl Handler { Some( CreateSelectMenu::new( format!("acp_config_{}", opt.id), - CreateSelectMenuKind::String { options: menu_options }, + CreateSelectMenuKind::String { + options: menu_options, + }, ) - .placeholder(placeholder) + .placeholder(placeholder), ) } /// Build ◀/▶ pagination buttons. Returns None when only one page exists. - fn build_pagination_buttons(category: &str, page: usize, total_pages: usize) -> Option { + fn build_pagination_buttons( + category: &str, + page: usize, + total_pages: usize, + ) -> Option { if total_pages <= 1 { return None; } @@ -822,12 +1102,20 @@ impl Handler { /// Build the full component rows (select menu + optional pagination) for a config category. /// When `page` is `None`, auto-selects the page containing the current value. - fn build_config_components(options: &[ConfigOption], category: &str, page: Option) -> Option> { - let opt = options.iter().find(|o| o.category.as_deref() == Some(category))?; + fn build_config_components( + options: &[ConfigOption], + category: &str, + page: Option, + ) -> Option> { + let opt = options + .iter() + .find(|o| o.category.as_deref() == Some(category))?; let total_pages = opt.options.len().div_ceil(SELECT_MENU_PAGE_SIZE); let page = match page { Some(p) => p.min(total_pages.saturating_sub(1)), - None => opt.options.iter() + None => opt + .options + .iter() .position(|o| o.value == opt.current_value) .map(|i| i / SELECT_MENU_PAGE_SIZE) .unwrap_or(0), @@ -884,7 +1172,9 @@ impl Handler { }; let response = CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new().content(msg).ephemeral(true), + CreateInteractionResponseMessage::new() + .content(msg) + .ephemeral(true), ); if let Err(e) = cmd.create_response(&ctx.http, response).await { tracing::error!(error = %e, "failed to respond to /cancel command"); @@ -910,12 +1200,16 @@ impl Handler { let msg = match (cancel_result, dropped) { (Ok(()), 0) => "🛑 Cancel signal sent.".to_string(), (Ok(()), _) => "🛑 Cancel signal sent. Buffered messages cleared.".to_string(), - (Err(_), 0) => "⚠️ Nothing to cancel — no active session and no buffered messages.".to_string(), + (Err(_), 0) => { + "⚠️ Nothing to cancel — no active session and no buffered messages.".to_string() + } (Err(_), _) => "🛑 Buffered messages cleared. No active session to cancel.".to_string(), }; let response = CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new().content(msg).ephemeral(true), + CreateInteractionResponseMessage::new() + .content(msg) + .ephemeral(true), ); if let Err(e) = cmd.create_response(&ctx.http, response).await { tracing::error!(error = %e, "failed to respond to /cancel-all command"); @@ -944,17 +1238,338 @@ impl Handler { Err(_) if dropped > 0 => { format!("🔄 Dropped {dropped} buffered message(s). No active session to reset.") } - Err(_) => "⚠️ No active session to reset. Start a conversation first by @mentioning the bot.".to_string(), + Err(_) => { + "⚠️ No active session to reset. Start a conversation first by @mentioning the bot." + .to_string() + } }; let response = CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new().content(msg).ephemeral(true), + CreateInteractionResponseMessage::new() + .content(msg) + .ephemeral(true), ); if let Err(e) = cmd.create_response(&ctx.http, response).await { tracing::error!(error = %e, "failed to respond to /reset command"); } } + async fn handle_remind_command( + &self, + ctx: &Context, + cmd: &serenity::model::application::CommandInteraction, + ) { + // Only humans can use /remind + if cmd.user.bot { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ Only humans can set reminders.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + // Extract options + let opts = &cmd.data.options; + let targets_raw = opts.iter() + .find(|o| o.name == "targets") + .and_then(|o| o.value.as_str()) + .unwrap_or(""); + let message = opts.iter() + .find(|o| o.name == "message") + .and_then(|o| o.value.as_str()) + .unwrap_or(""); + let delay_raw = opts.iter() + .find(|o| o.name == "delay") + .and_then(|o| o.value.as_str()) + .unwrap_or(""); + + if targets_raw.is_empty() || message.is_empty() || delay_raw.is_empty() { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ All fields (targets, message, delay) are required.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + // Parse delay + let delay_secs = match remind::parse_delay(delay_raw) { + Ok(s) => s, + Err(e) => { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("⚠️ Invalid delay: {e}")) + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + }; + + if let Err(e) = remind::validate_message(message) { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("⚠️ {e}")) + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + // Strip @everyone / @here to prevent unintended mass pings. + let message = remind::sanitize_message(message); + + // Extract mention strings from targets (keep raw — Discord renders them) + let targets: Vec = targets_raw + .split_whitespace() + .filter(|t| t.starts_with("<@") && t.ends_with('>')) + .map(|t| t.to_string()) + .collect(); + + if targets.is_empty() { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ No valid mentions found in targets. Use @user or @role.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + if targets.len() > remind::MAX_TARGETS { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("⚠️ Too many targets (max {}). Use a @role instead.", remind::MAX_TARGETS)) + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + // F4: Per-user rate limit (max 5 active reminders) + let user_id = cmd.user.id.get(); + let pending = self.reminder_store.pending().await; + let user_count = pending.iter().filter(|r| r.sender_id == user_id).count(); + if user_count >= 5 { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ You already have 5 active reminders. Wait for some to fire before adding more.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + let fire_at = chrono::Utc::now() + chrono::Duration::seconds(delay_secs as i64); + let reminder = remind::Reminder { + id: uuid::Uuid::new_v4().to_string(), + channel_id: cmd.channel_id.get(), + sender_id: cmd.user.id.get(), + targets: targets.clone(), + message: message.clone(), + fire_at, + created_at: chrono::Utc::now(), + }; + + // Persist and schedule + self.reminder_store.add(reminder.clone()).await; + self.scheduled_ids.lock().await.insert(reminder.id.clone()); + remind::schedule_reminder(ctx.http.clone(), self.reminder_store.clone(), reminder); + + let delay_str = remind::format_delay(delay_secs); + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!( + "⏰ Reminder set! Will fire in **{delay_str}** and mention {}", + targets.join(" ") + )) + .ephemeral(true), + ); + if let Err(e) = cmd.create_response(&ctx.http, response).await { + tracing::error!(error = %e, "failed to respond to /remind command"); + } + } + + async fn handle_export_thread_command( + &self, + ctx: &Context, + cmd: &serenity::model::application::CommandInteraction, + ) { + if is_denied_user( + false, + self.allow_all_users, + &self.allowed_users, + cmd.user.id.get(), + ) { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("🚫 You are not allowed to use this bot.") + .ephemeral(true), + ); + if let Err(e) = cmd.create_response(&ctx.http, response).await { + tracing::error!(error = %e, "failed to deny /export-thread command"); + } + return; + } + + let channel_id = cmd.channel_id; + let (export_allowed, export_name) = match channel_id.to_channel(&ctx.http).await { + Ok(serenity::model::channel::Channel::Guild(gc)) => { + let in_allowed_channel = + self.allow_all_channels || self.allowed_channels.contains(&channel_id.get()); + let (in_thread, _) = detect_thread( + gc.thread_metadata.is_some(), + gc.parent_id.map(|id| id.get()), + gc.owner_id.map(|id| id.get()), + ctx.cache.current_user().id.get(), + &self.allowed_channels, + self.allow_all_channels, + in_allowed_channel, + ); + (in_thread, gc.name.clone()) + } + Ok(serenity::model::channel::Channel::Private(_)) => { + (self.allow_dm, "dm".to_string()) + } + Ok(_) => (false, "channel".to_string()), + Err(e) => { + tracing::warn!(channel_id = %channel_id, error = %e, "failed to inspect channel for export"); + (false, "channel".to_string()) + } + }; + + if !export_allowed { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ Run this command inside an allowed Discord thread or DM.") + .ephemeral(true), + ); + if let Err(e) = cmd.create_response(&ctx.http, response).await { + tracing::error!(error = %e, "failed to respond to /export-thread rejection"); + } + return; + } + + // --- Parse and validate filter params (mutual exclusion) --- + let opts = &cmd.data.options; + let limit_opt = opts.iter().find(|o| o.name == "limit").and_then(|o| o.value.as_i64()); + let since_opt = opts.iter().find(|o| o.name == "since").and_then(|o| o.value.as_str()); + let days_opt = opts.iter().find(|o| o.name == "days").and_then(|o| o.value.as_i64()); + let all_opt = opts.iter().find(|o| o.name == "all").and_then(|o| o.value.as_bool()).unwrap_or(false); + + let filter_count = limit_opt.is_some() as u8 + since_opt.is_some() as u8 + days_opt.is_some() as u8 + all_opt as u8; + if filter_count > 1 { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ Please specify only one filter: `limit`, `since`, `days`, or `all`.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + let filter = if all_opt { + ExportFilter::All + } else if let Some(n) = limit_opt { + if !(1..=5000).contains(&n) { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ `limit` must be between 1 and 5000.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + ExportFilter::Limit(n as usize) + } else if let Some(id_str) = since_opt { + match id_str.parse::() { + Ok(id) if id > 0 => ExportFilter::After(MessageId::new(id)), + _ => { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ `since` must be a valid message ID (right-click a message → Copy Message ID).") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + } + } else if let Some(d) = days_opt { + if !(1..=365).contains(&d) { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ `days` must be between 1 and 365.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + let since_ts = chrono::Utc::now() - chrono::Duration::days(d); + let ts_ms = since_ts.timestamp_millis() as u64; + ExportFilter::After(timestamp_ms_to_snowflake(ts_ms)) + } else { + // Default: export last 100 messages (use limit:N or all:true for more) + ExportFilter::Limit(100) + }; + + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Preparing thread export...") + .ephemeral(true), + ); + if let Err(e) = cmd.create_response(&ctx.http, response).await { + tracing::error!(error = %e, "failed to acknowledge /export-thread command"); + return; + } + + match export_channel_messages( + &ctx.http, + channel_id, + &export_name, + cmd.attachment_size_limit, + filter, + ) + .await + { + Ok(result) => { + let mut content = format!("Exported {} messages.", result.written); + if result.hit_cap { + content.push_str(&format!( + " Only the most recent {} messages were fetched — older messages were not included.", + result.fetched + )); + } + if result.byte_truncated { + content.push_str(&format!( + " Transcript truncated to fit Discord's attachment size limit ({} of {} fetched messages included).", + result.written, result.fetched + )); + } + let attachment = + CreateAttachment::bytes(result.transcript.into_bytes(), result.filename); + let followup = CreateInteractionResponseFollowup::new() + .content(content) + .add_file(attachment) + .ephemeral(true); + if let Err(e) = cmd.create_followup(&ctx.http, followup).await { + tracing::error!(error = %e, "failed to send /export-thread attachment"); + } + } + Err(e) => { + tracing::warn!(channel_id = %channel_id, error = %e, "failed to export thread"); + let followup = CreateInteractionResponseFollowup::new() + .content(format!("⚠️ Failed to export thread: {e}")) + .ephemeral(true); + if let Err(e) = cmd.create_followup(&ctx.http, followup).await { + tracing::error!(error = %e, "failed to send /export-thread error"); + } + } + } + } + async fn handle_config_select( &self, ctx: &Context, @@ -972,12 +1587,10 @@ impl Handler { } let selected_value = match &comp.data.kind { - ComponentInteractionDataKind::StringSelect { values } => { - match values.first() { - Some(v) => v.clone(), - None => return, - } - } + ComponentInteractionDataKind::StringSelect { values } => match values.first() { + Some(v) => v.clone(), + None => return, + }, _ => return, }; @@ -1006,7 +1619,9 @@ impl Handler { }; let response = CreateInteractionResponse::UpdateMessage( - CreateInteractionResponseMessage::new().content(response_msg).components(vec![]), + CreateInteractionResponseMessage::new() + .content(response_msg) + .components(vec![]), ); if let Err(e) = comp.create_response(&ctx.http, response).await { @@ -1071,6 +1686,283 @@ fn discord_msg_ref(msg: &Message) -> MessageRef { } } +struct ExportResult { + filename: String, + transcript: String, + /// Messages successfully pulled from Discord. + fetched: usize, + /// Messages that fit in the transcript (≤ `fetched`; differs when the + /// attachment-size limit truncates). + written: usize, + /// We stopped fetching because we hit the message cap and the thread still + /// has more messages we did not include. + hit_cap: bool, + /// Transcript was cut to keep the attachment under Discord's size limit. + byte_truncated: bool, +} + +/// Filter mode for export_channel_messages. +enum ExportFilter { + /// Fetch all messages (newest-first via `before`), capped at THREAD_EXPORT_MESSAGE_LIMIT. + All, + /// Fetch the most recent N messages (newest-first via `before`). + Limit(usize), + /// Fetch messages after a synthetic snowflake (newest-first via `before`, with boundary filtering). + After(MessageId), +} + +/// Discord epoch: 2015-01-01T00:00:00Z in milliseconds. +const DISCORD_EPOCH_MS: u64 = 1_420_070_400_000; + +/// Convert a UTC timestamp (in milliseconds since Unix epoch) to a synthetic +/// Discord snowflake suitable for use as an `after` cursor. +fn timestamp_ms_to_snowflake(timestamp_ms: u64) -> MessageId { + let discord_ms = timestamp_ms.saturating_sub(DISCORD_EPOCH_MS); + // Snowflake IDs use NonZeroU64 in serenity; ensure at least 1. + MessageId::new((discord_ms << 22).max(1)) +} + +async fn export_channel_messages( + http: &Http, + channel_id: ChannelId, + channel_name: &str, + attachment_size_limit: u32, + filter: ExportFilter, +) -> anyhow::Result { + let cap = match &filter { + ExportFilter::Limit(n) => *n, + _ => THREAD_EXPORT_MESSAGE_LIMIT, + }; + + let mut messages = Vec::new(); + let mut hit_cap = false; + + match &filter { + ExportFilter::All | ExportFilter::Limit(_) => { + // Fetch newest-first using `before` pagination, then reverse. + let mut before = None; + loop { + if messages.len() >= cap { + hit_cap = true; + break; + } + let remaining = cap - messages.len(); + let limit = remaining.min(100) as u8; + let mut request = GetMessages::new().limit(limit); + if let Some(before_id) = before { + request = request.before(before_id); + } + let batch = channel_id.messages(http, request).await?; + if batch.is_empty() { + break; + } + before = batch.last().map(|m| m.id); + let batch_len = batch.len(); + messages.extend(batch); + if batch_len < limit as usize { + break; + } + } + // Probe to confirm we actually left messages behind. + if hit_cap { + let probe = GetMessages::new().limit(1); + let probe = if let Some(before_id) = before { + probe.before(before_id) + } else { + probe + }; + if matches!(channel_id.messages(http, probe).await, Ok(b) if b.is_empty()) { + hit_cap = false; + } + } + messages.reverse(); + } + ExportFilter::After(after_id) => { + // Fetch newest-first using `before` pagination, stop when we hit + // messages at or before the filter boundary. This ensures that when + // the cap is reached, we keep the *newest* messages in the window. + let mut before = None; + loop { + if messages.len() >= cap { + hit_cap = true; + break; + } + let remaining = cap - messages.len(); + let limit = remaining.min(100) as u8; + let mut request = GetMessages::new().limit(limit); + if let Some(before_id) = before { + request = request.before(before_id); + } + let batch = channel_id.messages(http, request).await?; + if batch.is_empty() { + break; + } + before = batch.last().map(|m| m.id); + let batch_len = batch.len(); + // Filter out messages at or before the boundary. + let filtered: Vec<_> = batch.into_iter().filter(|m| m.id > *after_id).collect(); + let hit_boundary = filtered.len() < batch_len; + messages.extend(filtered); + if hit_boundary { + // We've reached the time boundary; no need to fetch older. + break; + } + if batch_len < limit as usize { + break; + } + } + // Probe only if we stopped due to cap (not boundary). + if hit_cap { + let probe = GetMessages::new().limit(1); + let probe = if let Some(before_id) = before { + probe.before(before_id) + } else { + probe + }; + if let Ok(batch) = channel_id.messages(http, probe).await { + // If the next message is beyond our filter boundary, + // we didn't actually leave relevant messages behind. + let has_more_in_window = batch.iter().any(|m| m.id > *after_id); + if !has_more_in_window { + hit_cap = false; + } + } + } + messages.reverse(); + } + } + + let filename = export_filename(channel_id, channel_name); + if attachment_size_limit < 2048 { + tracing::warn!(attachment_size_limit, "attachment_size_limit is very small; export will likely be truncated"); + } + let max_bytes = usize::try_from(attachment_size_limit) + .unwrap_or(8 * 1024 * 1024) + .saturating_sub(1024) + .max(1024); + let (transcript, written, byte_truncated) = + format_thread_export(channel_id, channel_name, &messages, max_bytes); + let fetched = messages.len(); + + Ok(ExportResult { + filename, + transcript, + fetched, + written, + hit_cap, + byte_truncated, + }) +} + +fn format_thread_export( + channel_id: ChannelId, + channel_name: &str, + messages: &[Message], + max_bytes: usize, +) -> (String, usize, bool) { + let header = format!( + "Discord thread export\nChannel: {channel_name} ({channel_id})\nMessages: {}\n\n", + messages.len() + ); + let entries: Vec = messages.iter().map(format_export_message).collect(); + assemble_export(&header, &entries, max_bytes) +} + +/// Build the transcript body from a pre-rendered header and a list of +/// already-formatted message entries, honouring `max_bytes`. +/// +/// Returns `(transcript, written, truncated)` where `written` is the number of +/// entries actually included. Split out from `format_thread_export` so the +/// truncation boundary logic can be unit-tested without constructing real +/// `serenity::model::channel::Message` values. +fn assemble_export(header: &str, entries: &[String], max_bytes: usize) -> (String, usize, bool) { + let mut out = String::from(header); + let mut written = 0; + let mut truncated = false; + + for entry in entries { + if out.len() + entry.len() > max_bytes { + truncated = true; + break; + } + out.push_str(entry); + written += 1; + } + + if truncated { + let note = "\n[Export truncated to fit Discord attachment size limit]\n"; + let room = max_bytes.saturating_sub(out.len()); + if room >= note.len() { + out.push_str(note); + } + } + + (out, written, truncated) +} + +fn format_export_message(msg: &Message) -> String { + let bot_marker = if msg.author.bot { " [bot]" } else { "" }; + let mut out = format!( + "[{}] {}{} ({})\n", + msg.timestamp, + msg.author.name, + bot_marker, + msg.author.id + ); + + if msg.content.is_empty() { + out.push_str("(no text)\n"); + } else { + out.push_str(&msg.content); + out.push('\n'); + } + + for attachment in &msg.attachments { + let mime = attachment.content_type.as_deref().unwrap_or("unknown"); + out.push_str(&format!( + "[attachment] {} ({} bytes, {}): {}\n", + attachment.filename, attachment.size, mime, attachment.url + )); + } + + out.push('\n'); + out +} + +fn export_filename(channel_id: ChannelId, channel_name: &str) -> String { + let safe_name = sanitize_filename_component(channel_name); + format!("discord-thread-{safe_name}-{channel_id}.txt") +} + +/// Reduce a free-form Discord channel/thread name to a safe ASCII filename +/// fragment. +/// +/// Non-ASCII characters are dropped silently — a purely-Chinese thread name +/// like "扈三娘的房間" yields a date-based fallback (e.g. `"20260512"`). +/// The caller appends the channel ID, which already guarantees uniqueness, +/// and an ASCII fragment plays nicer with downstream tools (mail attachments, +/// S3 keys, browser save-as dialogs). The 64-byte cap leaves room for the +/// `discord-thread-` prefix and the channel-ID suffix within typical +/// filesystem limits. +fn sanitize_filename_component(input: &str) -> String { + let mut safe = String::with_capacity(input.len()); + for ch in input.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + safe.push(ch); + } else if ch.is_whitespace() || matches!(ch, '.' | '/') { + safe.push('-'); + } + } + let safe = safe.trim_matches('-'); + if safe.is_empty() { + // Use current date as a human-friendly fallback when the thread name + // is entirely non-ASCII. + chrono::Utc::now().format("%Y%m%d").to_string() + } else { + safe.chars().take(64).collect() + } +} + async fn get_or_create_thread( ctx: &Context, adapter: &Arc, @@ -1100,7 +1992,10 @@ async fn get_or_create_thread( origin_event_id: None, }; let trigger_ref = discord_msg_ref(msg); - match adapter.create_thread(&parent, &trigger_ref, &thread_name).await { + match adapter + .create_thread(&parent, &trigger_ref, &thread_name) + .await + { Ok(ch) => Ok(ch), Err(e) if is_thread_already_exists_error(&e) => { // Another bot won the race from the same trigger message. Discord @@ -1110,9 +2005,9 @@ async fn get_or_create_thread( .channel_id .message(&ctx.http, msg.id) .await - .map_err(|fe| anyhow::anyhow!( - "thread_already_exists (race), but refetch failed: {fe}" - ))?; + .map_err(|fe| { + anyhow::anyhow!("thread_already_exists (race), but refetch failed: {fe}") + })?; let existing = refreshed.thread.ok_or_else(|| { anyhow::anyhow!( "thread_already_exists (race), but message has no thread after refetch" @@ -1147,21 +2042,45 @@ fn is_thread_already_exists_error(err: &anyhow::Error) -> bool { msg.contains("160004") || msg.contains("already been created") } -static ROLE_MENTION_RE: LazyLock = LazyLock::new(|| { - regex::Regex::new(r"<@&\d+>").unwrap() -}); +static ROLE_MENTION_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"<@&\d+>").unwrap()); -fn resolve_mentions(content: &str, bot_id: UserId) -> String { +fn resolve_mentions(content: &str, bot_id: UserId, allowed_role_ids: &HashSet) -> String { // 1. Strip the bot's own trigger mention let out = content .replace(&format!("<@{}>", bot_id), "") .replace(&format!("<@!{}>", bot_id), ""); - // 2. Other user mentions: keep <@UID> as-is so the LLM can mention back - // 3. Fallback: replace role mentions only (user mentions are preserved) + // 2. Strip allowed role mentions (they triggered the bot, not useful in prompt) + let out = if allowed_role_ids.is_empty() { + out + } else { + allowed_role_ids + .iter() + .fold(out, |s, id| s.replace(&format!("<@&{}>", id), "")) + }; + // 3. Other user mentions: keep <@UID> as-is so the LLM can mention back + // 4. Fallback: replace remaining role mentions only (user mentions are preserved) let out = ROLE_MENTION_RE.replace_all(&out, "@(role)").to_string(); out.trim().to_string() } +fn video_attachment_block( + filename: &str, + content_type: Option<&str>, + size: u64, + url: &str, +) -> ContentBlock { + ContentBlock::Text { + text: format!( + "[Video attachment]\nfilename: {}\ncontent_type: {}\nsize_bytes: {}\nurl: {}", + filename, + content_type.unwrap_or("unknown"), + size, + url + ), + } +} + /// Build a `SenderContext` for Discord messages. /// /// Pure function extracted from `EventHandler::message` for testability. @@ -1175,6 +2094,7 @@ fn resolve_mentions(content: &str, bot_id: UserId) -> String { /// Note: `ChannelRef.channel_id` uses the *opposite* convention — it holds /// the thread's channel ID for routing (Discord API sends to thread by its /// channel ID). See `ChannelRef` doc comments for details. +#[allow(clippy::too_many_arguments)] fn build_sender_context( sender_id: &str, sender_name: &str, @@ -1183,6 +2103,8 @@ fn build_sender_context( thread_parent_id: Option<&str>, is_bot: bool, timestamp: &str, + message_id: &str, + receiver_id: &str, ) -> SenderContext { SenderContext { schema: "openab.sender.v1".into(), @@ -1194,6 +2116,8 @@ fn build_sender_context( thread_id: thread_parent_id.map(|_| msg_channel_id.to_string()), is_bot, timestamp: Some(timestamp.to_string()), + message_id: Some(message_id.to_string()), + receiver_id: Some(receiver_id.to_string()), } } @@ -1236,7 +2160,12 @@ fn detect_thread( /// Returns `true` if the author should be denied by the user allowlist. /// Bot authors skip this check — they are gated by `allow_bot_messages` + `trusted_bot_ids`. -fn is_denied_user(is_bot: bool, allow_all_users: bool, allowed_users: &HashSet, user_id: u64) -> bool { +fn is_denied_user( + is_bot: bool, + allow_all_users: bool, + allowed_users: &HashSet, + user_id: u64, +) -> bool { !is_bot && !allow_all_users && !allowed_users.contains(&user_id) } @@ -1287,10 +2216,26 @@ fn should_process_user_message( } } +/// Returns true if any bot message in `messages` contains a turn limit warning. +/// Used to dedup `WarnAndStop` across multiple bot processes sharing a thread. (#530) +/// Note: this is best-effort — a narrow race window exists where two bots fetch +/// simultaneously and both see no warning, resulting in a duplicate. For most +/// deployments this is acceptable; strict once-only semantics would require +/// shared state (e.g. gateway-owned emission or distributed lock). +/// +/// Accepts `(is_bot, content)` pairs so the logic can be unit-tested without +/// constructing `serenity::model::channel::Message` values (see existing test +/// boundary comment at `format_thread_export`). +fn turn_limit_warning_present(messages: &[(bool, &str)]) -> bool { + messages + .iter() + .any(|(is_bot, content)| *is_bot && content.contains(BOT_TURN_LIMIT_WARNING_PREFIX)) +} + #[cfg(test)] mod tests { use super::*; - use crate::bot_turns::{HARD_BOT_TURN_LIMIT, TurnResult}; + use crate::bot_turns::{TurnResult, HARD_BOT_TURN_LIMIT, BOT_TURN_LIMIT_WARNING_PREFIX}; // --- resolve_mentions tests --- @@ -1298,7 +2243,7 @@ mod tests { #[test] fn resolve_mentions_strips_bot_mention() { let bot_id = UserId::new(111); - let result = resolve_mentions("hello <@111> world", bot_id); + let result = resolve_mentions("hello <@111> world", bot_id, &HashSet::new()); assert_eq!(result, "hello world"); } @@ -1306,7 +2251,7 @@ mod tests { #[test] fn resolve_mentions_strips_bot_mention_legacy() { let bot_id = UserId::new(111); - let result = resolve_mentions("hello <@!111> world", bot_id); + let result = resolve_mentions("hello <@!111> world", bot_id, &HashSet::new()); assert_eq!(result, "hello world"); } @@ -1314,7 +2259,7 @@ mod tests { #[test] fn resolve_mentions_preserves_other_user_mentions() { let bot_id = UserId::new(111); - let result = resolve_mentions("<@111> say hi to <@222>", bot_id); + let result = resolve_mentions("<@111> say hi to <@222>", bot_id, &HashSet::new()); assert_eq!(result, "say hi to <@222>"); } @@ -1322,7 +2267,7 @@ mod tests { #[test] fn resolve_mentions_replaces_role_mentions() { let bot_id = UserId::new(111); - let result = resolve_mentions("hello <@&999>", bot_id); + let result = resolve_mentions("hello <@&999>", bot_id, &HashSet::new()); assert_eq!(result, "hello @(role)"); } @@ -1330,10 +2275,48 @@ mod tests { #[test] fn resolve_mentions_empty_after_strip() { let bot_id = UserId::new(111); - let result = resolve_mentions("<@111>", bot_id); + let result = resolve_mentions("<@111>", bot_id, &HashSet::new()); assert_eq!(result, ""); } + /// Allowed role mentions are stripped from prompt (not replaced with @(role)). + #[test] + fn resolve_mentions_strips_allowed_role() { + let bot_id = UserId::new(111); + let roles: HashSet = [999].into_iter().collect(); + let result = resolve_mentions("hello <@&999> world", bot_id, &roles); + assert_eq!(result, "hello world"); + } + + /// Non-allowed role mentions are still replaced with @(role). + #[test] + fn resolve_mentions_keeps_other_roles_as_placeholder() { + let bot_id = UserId::new(111); + let roles: HashSet = [999].into_iter().collect(); + let result = resolve_mentions("<@&999> check <@&888>", bot_id, &roles); + assert_eq!(result, "check @(role)"); + } + + #[test] + fn video_attachment_block_includes_actionable_metadata() { + let block = video_attachment_block( + "demo.mp4", + Some("video/mp4"), + 12345, + "https://cdn.discordapp.com/attachments/demo.mp4", + ); + + let ContentBlock::Text { text } = block else { + panic!("video attachments must be forwarded as text metadata"); + }; + + assert!(text.contains("[Video attachment]")); + assert!(text.contains("filename: demo.mp4")); + assert!(text.contains("content_type: video/mp4")); + assert!(text.contains("size_bytes: 12345")); + assert!(text.contains("url: https://cdn.discordapp.com/attachments/demo.mp4")); + } + // --- thread-race error detection --- /// Detects the Discord error code for "thread already exists" (160004). @@ -1363,6 +2346,152 @@ mod tests { assert!(!is_thread_already_exists_error(&err)); } + // --- thread export helpers --- + + #[test] + fn sanitize_filename_component_keeps_safe_ascii() { + assert_eq!( + sanitize_filename_component("release notes_v2"), + "release-notes_v2" + ); + } + + #[test] + fn sanitize_filename_component_falls_back_for_empty_result() { + let result = sanitize_filename_component("///..."); + // Fallback is a YYYYMMDD date string + assert_eq!(result.len(), 8); + assert!(result.chars().all(|c| c.is_ascii_digit())); + } + + // --- assemble_export --- + // Split out from format_thread_export so we can test the truncation + // boundary without constructing serenity::model::channel::Message values. + + #[test] + fn assemble_export_empty_entries_returns_header_only() { + let (out, written, truncated) = assemble_export("HDR\n", &[], 1024); + assert_eq!(out, "HDR\n"); + assert_eq!(written, 0); + assert!(!truncated); + } + + #[test] + fn assemble_export_single_oversized_entry_writes_zero_and_marks_truncated() { + let entries = vec!["x".repeat(200)]; + let (out, written, truncated) = assemble_export("h\n", &entries, 50); + assert_eq!(written, 0); + assert!(truncated); + // Footer needs ~56 bytes; max_bytes 50 leaves ≤48 of room, so it is + // intentionally omitted (it can't be appended without exceeding the + // limit). The header is still present. + assert!(out.starts_with("h\n")); + assert!(!out.contains("xx")); + } + + #[test] + fn assemble_export_entry_at_exact_boundary_is_included() { + // header(2) + entry(3) == max_bytes(5); the strict-greater check + // keeps the entry in. + let (out, written, truncated) = assemble_export("h\n", &["abc".to_string()], 5); + assert_eq!(written, 1); + assert!(!truncated); + assert_eq!(out, "h\nabc"); + } + + #[test] + fn assemble_export_entry_one_byte_over_boundary_is_excluded() { + // header(2) + entry(4) == 6 > max_bytes(5); entry is dropped. + let (out, written, truncated) = assemble_export("h\n", &["abcd".to_string()], 5); + assert_eq!(written, 0); + assert!(truncated); + assert!(out.starts_with("h\n")); + assert!(!out.contains("abcd")); + } + + #[test] + fn assemble_export_appends_footer_when_room_remains() { + // First two short entries fit; the long third entry would overflow, + // and the remaining headroom is enough for the truncation footer. + let entries = vec!["a\n".to_string(), "b\n".to_string(), "c".repeat(500)]; + let (out, written, truncated) = assemble_export("h\n", &entries, 200); + assert_eq!(written, 2); + assert!(truncated); + assert!(out.contains("[Export truncated")); + } + + // --- snowflake conversion --- + + #[test] + fn timestamp_ms_to_snowflake_known_value() { + // 2026-05-10 00:00:00 UTC = 1778572800000 ms since Unix epoch + // Discord ms = 1778572800000 - 1420070400000 = 358502400000 + // Snowflake = 358502400000 << 22 = 1503238553600000000 (approx) + let ts_ms: u64 = 1_778_572_800_000; + let snowflake = timestamp_ms_to_snowflake(ts_ms); + // Verify round-trip: extract timestamp back from snowflake + let extracted_ms = (snowflake.get() >> 22) + DISCORD_EPOCH_MS; + assert_eq!(extracted_ms, ts_ms); + } + + #[test] + fn timestamp_ms_to_snowflake_at_discord_epoch_is_one() { + // At exactly the Discord epoch, discord_ms=0, shifted=0, clamped to 1 + let snowflake = timestamp_ms_to_snowflake(DISCORD_EPOCH_MS); + assert_eq!(snowflake.get(), 1); + } + + #[test] + fn timestamp_ms_to_snowflake_before_epoch_saturates() { + // Timestamp before Discord epoch should saturate to 1 + let snowflake = timestamp_ms_to_snowflake(1_000_000_000_000); + assert_eq!(snowflake.get(), 1); + } + + // --- ExportFilter cap logic --- + + #[test] + fn export_filter_default_cap_is_100() { + // Default (no params) uses Limit(100) + let filter = ExportFilter::Limit(100); + let cap = match &filter { + ExportFilter::Limit(n) => *n, + _ => THREAD_EXPORT_MESSAGE_LIMIT, + }; + assert_eq!(cap, 100); + } + + #[test] + fn export_filter_all_cap_is_5000() { + let filter = ExportFilter::All; + let cap = match &filter { + ExportFilter::Limit(n) => *n, + _ => THREAD_EXPORT_MESSAGE_LIMIT, + }; + assert_eq!(cap, THREAD_EXPORT_MESSAGE_LIMIT); + assert_eq!(cap, 5000); + } + + #[test] + fn export_filter_limit_uses_custom_cap() { + let filter = ExportFilter::Limit(250); + let cap = match &filter { + ExportFilter::Limit(n) => *n, + _ => THREAD_EXPORT_MESSAGE_LIMIT, + }; + assert_eq!(cap, 250); + } + + #[test] + fn export_filter_after_uses_global_cap() { + let filter = ExportFilter::After(MessageId::new(123456789)); + let cap = match &filter { + ExportFilter::Limit(n) => *n, + _ => THREAD_EXPORT_MESSAGE_LIMIT, + }; + assert_eq!(cap, THREAD_EXPORT_MESSAGE_LIMIT); + } + // --- should_process_user_message tests (GIVEN/WHEN/THEN) --- // Tests the multibot-mentions gating logic extracted from EventHandler::message. // The bug in #481 was that other bots' messages were filtered by bot gating @@ -1376,10 +2505,10 @@ mod tests { fn multibot_mentions_single_bot_thread_no_mention() { assert!(should_process_user_message( AllowUsers::MultibotMentions, - false, // is_mentioned - true, // in_thread - true, // involved - false, // other_bot_present + false, // is_mentioned + true, // in_thread + true, // involved + false, // other_bot_present )); } @@ -1391,10 +2520,10 @@ mod tests { fn multibot_mentions_multi_bot_thread_no_mention() { assert!(!should_process_user_message( AllowUsers::MultibotMentions, - false, // is_mentioned - true, // in_thread - true, // involved - true, // other_bot_present ← another bot posted + false, // is_mentioned + true, // in_thread + true, // involved + true, // other_bot_present ← another bot posted )); } @@ -1405,10 +2534,10 @@ mod tests { fn multibot_mentions_multi_bot_thread_with_mention() { assert!(should_process_user_message( AllowUsers::MultibotMentions, - true, // is_mentioned - true, // in_thread - true, // involved - true, // other_bot_present + true, // is_mentioned + true, // in_thread + true, // involved + true, // other_bot_present )); } @@ -1419,10 +2548,10 @@ mod tests { fn multibot_mentions_main_channel_no_mention() { assert!(!should_process_user_message( AllowUsers::MultibotMentions, - false, // is_mentioned - false, // in_thread (main channel) - false, // involved - false, // other_bot_present + false, // is_mentioned + false, // in_thread (main channel) + false, // involved + false, // other_bot_present )); } @@ -1433,10 +2562,10 @@ mod tests { fn multibot_mentions_not_involved() { assert!(!should_process_user_message( AllowUsers::MultibotMentions, - false, // is_mentioned - true, // in_thread - false, // involved ← bot hasn't posted here - false, // other_bot_present + false, // is_mentioned + true, // in_thread + false, // involved ← bot hasn't posted here + false, // other_bot_present )); } @@ -1447,10 +2576,10 @@ mod tests { fn involved_mode_ignores_multibot() { assert!(should_process_user_message( AllowUsers::Involved, - false, // is_mentioned - true, // in_thread - true, // involved - true, // other_bot_present ← ignored in involved mode + false, // is_mentioned + true, // in_thread + true, // involved + true, // other_bot_present ← ignored in involved mode )); } @@ -1461,10 +2590,10 @@ mod tests { fn mentions_mode_always_requires_mention() { assert!(!should_process_user_message( AllowUsers::Mentions, - false, // is_mentioned - true, // in_thread - true, // involved - false, // other_bot_present + false, // is_mentioned + true, // in_thread + true, // involved + false, // other_bot_present )); } @@ -1518,18 +2647,39 @@ mod tests { /// In-thread message: channel_id = parent, thread_id = thread channel ID. #[test] fn build_sender_context_in_thread() { - let ctx = build_sender_context("user1", "alice", "Alice", "thread_ch", Some("parent_ch"), false, "2026-05-01T00:00:00Z"); + let ctx = build_sender_context( + "user1", + "alice", + "Alice", + "thread_ch", + Some("parent_ch"), + false, + "2026-05-01T00:00:00Z", + "msg123", + "bot99", + ); assert_eq!(ctx.channel_id, "parent_ch"); assert_eq!(ctx.thread_id, Some("thread_ch".to_string())); assert_eq!(ctx.channel, "discord"); assert_eq!(ctx.sender_id, "user1"); assert!(!ctx.is_bot); + assert_eq!(ctx.receiver_id, Some("bot99".to_string())); } /// Non-thread message: channel_id = message channel, thread_id = None. #[test] fn build_sender_context_not_in_thread() { - let ctx = build_sender_context("user1", "alice", "Alice", "main_ch", None, false, "2026-05-01T00:00:00Z"); + let ctx = build_sender_context( + "user1", + "alice", + "Alice", + "main_ch", + None, + false, + "2026-05-01T00:00:00Z", + "msg456", + "bot99", + ); assert_eq!(ctx.channel_id, "main_ch"); assert_eq!(ctx.thread_id, None); } @@ -1537,7 +2687,17 @@ mod tests { /// Bot sender: is_bot flag propagated correctly. #[test] fn build_sender_context_bot_sender() { - let ctx = build_sender_context("bot1", "mybot", "MyBot", "ch", Some("parent"), true, "2026-05-01T00:00:00Z"); + let ctx = build_sender_context( + "bot1", + "mybot", + "MyBot", + "ch", + Some("parent"), + true, + "2026-05-01T00:00:00Z", + "msg789", + "bot99", + ); assert!(ctx.is_bot); assert_eq!(ctx.channel_id, "parent"); assert_eq!(ctx.thread_id, Some("ch".to_string())); @@ -1704,8 +2864,12 @@ mod tests { let category_id: u64 = 200; let allowed = HashSet::from([category_id]); // Category child: has parent_id (the category) but NO thread_metadata. - let (in_thread, _) = detect_thread(false, Some(category_id), None, 1000, &allowed, false, false); - assert!(!in_thread, "category child must not match allowed_channels via parent_id"); + let (in_thread, _) = + detect_thread(false, Some(category_id), None, 1000, &allowed, false, false); + assert!( + !in_thread, + "category child must not match allowed_channels via parent_id" + ); } // --- Per-thread streaming tests (#534) --- @@ -1825,10 +2989,10 @@ mod tests { // because is_mentioned=false and in_thread=false. assert!(!should_process_user_message( AllowUsers::Involved, - false, // is_mentioned (DMs don't have @mention) - false, // in_thread (DMs are not threads) - false, // involved - false, // other_bot_present + false, // is_mentioned (DMs don't have @mention) + false, // in_thread (DMs are not threads) + false, // involved + false, // other_bot_present )); } @@ -1856,4 +3020,28 @@ mod tests { fn normal_channel_creates_thread() { assert!(!should_skip_thread_creation(false, false)); } + + // --- WarnAndStop dedup tests (#530) --- + + #[test] + fn dedup_detects_existing_bot_warning() { + let msg = format!("{} (20/20). A human must reply.", BOT_TURN_LIMIT_WARNING_PREFIX); + assert!(turn_limit_warning_present(&[(true, &msg)])); + } + + #[test] + fn dedup_ignores_human_warning_text() { + let msg = format!("{} (20/20). A human must reply.", BOT_TURN_LIMIT_WARNING_PREFIX); + assert!(!turn_limit_warning_present(&[(false, &msg)])); + } + + #[test] + fn dedup_returns_false_when_no_warning() { + assert!(!turn_limit_warning_present(&[(true, "hello"), (false, "world")])); + } + + #[test] + fn dedup_returns_false_for_empty_messages() { + assert!(!turn_limit_warning_present(&[])); + } } diff --git a/src/dispatch.rs b/src/dispatch.rs index 1667521cd..013ee3d81 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -17,8 +17,8 @@ use anyhow::Result; use async_trait::async_trait; use tracing::{debug, error, info, info_span, warn}; -use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef}; use crate::acp::ContentBlock; +use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef}; use crate::config::ReactionsConfig; use crate::error_display::format_user_error; use crate::reactions::StatusReactionController; @@ -196,9 +196,19 @@ pub fn dispatch_params( ) -> (usize, BatchGrouping, Duration) { use crate::config::MessageProcessingMode; match mode { - MessageProcessingMode::Message => (1, BatchGrouping::Thread, PER_MESSAGE_CONSUMER_IDLE_TIMEOUT), - MessageProcessingMode::Thread => (max_buffered, BatchGrouping::Thread, DEFAULT_CONSUMER_IDLE_TIMEOUT), - MessageProcessingMode::Lane => (max_buffered, BatchGrouping::Lane, DEFAULT_CONSUMER_IDLE_TIMEOUT), + MessageProcessingMode::Message => { + (1, BatchGrouping::Thread, PER_MESSAGE_CONSUMER_IDLE_TIMEOUT) + } + MessageProcessingMode::Thread => ( + max_buffered, + BatchGrouping::Thread, + DEFAULT_CONSUMER_IDLE_TIMEOUT, + ), + MessageProcessingMode::Lane => ( + max_buffered, + BatchGrouping::Lane, + DEFAULT_CONSUMER_IDLE_TIMEOUT, + ), } } @@ -394,7 +404,10 @@ impl Dispatcher { let _ = adapter .send_message( &thread_channel, - &format!("⚠️ {}", format_user_error("dispatch consumer exited unexpectedly")), + &format!( + "⚠️ {}", + format_user_error("dispatch consumer exited unexpectedly") + ), ) .await; return Err(DispatchError::ConsumerDead); @@ -740,11 +753,8 @@ mod tests { #[test] fn pack_arrival_event_single() { - let blocks = AdapterRouter::pack_arrival_event( - r#"{"schema":"openab.sender.v1"}"#, - "hello", - vec![], - ); + let blocks = + AdapterRouter::pack_arrival_event(r#"{"schema":"openab.sender.v1"}"#, "hello", vec![]); // sender_context delimiter + prompt = 2 blocks assert_eq!(blocks.len(), 2); if let ContentBlock::Text { text } = &blocks[0] { @@ -765,14 +775,23 @@ mod tests { #[test] fn pack_arrival_event_with_extra_blocks() { let extra = vec![ - ContentBlock::Text { text: "[Voice transcript]: hi".into() }, - ContentBlock::Image { media_type: "image/png".into(), data: "abc".into() }, + ContentBlock::Text { + text: "[Voice transcript]: hi".into(), + }, + ContentBlock::Image { + media_type: "image/png".into(), + data: "abc".into(), + }, ]; let blocks = AdapterRouter::pack_arrival_event("{}", "prompt", extra); // delimiter + transcript + prompt + image = 4 blocks assert_eq!(blocks.len(), 4); - assert!(matches!(&blocks[0], ContentBlock::Text { text } if text.contains(""))); - assert!(matches!(&blocks[1], ContentBlock::Text { text } if text.contains("Voice transcript"))); + assert!( + matches!(&blocks[0], ContentBlock::Text { text } if text.contains("")) + ); + assert!( + matches!(&blocks[1], ContentBlock::Text { text } if text.contains("Voice transcript")) + ); assert!(matches!(&blocks[2], ContentBlock::Text { text } if text == "prompt")); assert!(matches!(&blocks[3], ContentBlock::Image { .. })); } @@ -781,8 +800,16 @@ mod tests { fn pack_arrival_event_batch_n2() { // Two arrival events concatenated → 2 (header + prompt) pairs = 4 blocks. let mut all: Vec = Vec::new(); - all.extend(AdapterRouter::pack_arrival_event(r#"{"ts":"T1"}"#, "msg1", vec![])); - all.extend(AdapterRouter::pack_arrival_event(r#"{"ts":"T2"}"#, "msg2", vec![])); + all.extend(AdapterRouter::pack_arrival_event( + r#"{"ts":"T1"}"#, + "msg1", + vec![], + )); + all.extend(AdapterRouter::pack_arrival_event( + r#"{"ts":"T2"}"#, + "msg2", + vec![], + )); assert_eq!(all.len(), 4); if let ContentBlock::Text { text } = &all[0] { assert!(text.contains(r#""ts":"T1""#)); @@ -1045,6 +1072,8 @@ mod tests { pool, crate::config::ReactionsConfig::default(), crate::markdown::TableMode::Off, + crate::config::default_prompt_hard_timeout_secs(), + crate::config::default_liveness_check_secs(), )); Dispatcher::with_idle_timeout(router, 10, 24_000, grouping, DEFAULT_CONSUMER_IDLE_TIMEOUT) } @@ -1095,7 +1124,7 @@ mod tests { insert_dummy_handle(&d, "discord:T1:userA"); insert_dummy_handle(&d, "discord:T1:userB"); insert_dummy_handle(&d, "discord:T2:userA"); // different thread - insert_dummy_handle(&d, "slack:T1:userA"); // different platform + insert_dummy_handle(&d, "slack:T1:userA"); // different platform d.cancel_buffered_thread("discord", "T1"); let map = d.per_thread.lock().unwrap(); assert!(!map.contains_key("discord:T1:userA")); @@ -1268,11 +1297,18 @@ mod tests { #[async_trait] impl ChatAdapter for MockChatAdapter { - fn platform(&self) -> &'static str { "mock" } - fn message_limit(&self) -> usize { 2000 } + fn platform(&self) -> &'static str { + "mock" + } + fn message_limit(&self) -> usize { + 2000 + } async fn send_message(&self, channel: &ChannelRef, _content: &str) -> Result { - Ok(MessageRef { channel: channel.clone(), message_id: "mock-msg".into() }) + Ok(MessageRef { + channel: channel.clone(), + message_id: "mock-msg".into(), + }) } async fn create_thread( @@ -1284,9 +1320,15 @@ mod tests { Ok(channel.clone()) } - async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { Ok(()) } - async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { Ok(()) } - fn use_streaming(&self, _other_bot_present: bool) -> bool { false } + async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } } fn make_channel(thread: &str) -> ChannelRef { @@ -1301,7 +1343,8 @@ mod tests { fn make_msg(prompt: &str, tokens: usize) -> BufferedMessage { BufferedMessage { - sender_json: r#"{"schema":"openab.sender.v1","sender_id":"u","sender_name":"u"}"#.into(), + sender_json: r#"{"schema":"openab.sender.v1","sender_id":"u","sender_name":"u"}"# + .into(), sender_name: "u".into(), prompt: prompt.into(), extra_blocks: vec![], @@ -1403,7 +1446,10 @@ mod tests { )); // Wait enough for the timeout branch + a tick for the task to finish. tokio::time::sleep(Duration::from_millis(150)).await; - assert!(consumer.is_finished(), "consumer should exit after idle timeout"); + assert!( + consumer.is_finished(), + "consumer should exit after idle timeout" + ); // No dispatches should have been recorded. assert!(mock.calls().is_empty()); drop(tx); @@ -1417,7 +1463,13 @@ mod tests { // whose consumer is still parked but whose rx has been dropped. let mock = Arc::new(MockDispatchTarget::new()); let target: Arc = mock.clone(); - let d = Dispatcher::with_idle_timeout(target, 10, 24_000, BatchGrouping::Thread, DEFAULT_CONSUMER_IDLE_TIMEOUT); + let d = Dispatcher::with_idle_timeout( + target, + 10, + 24_000, + BatchGrouping::Thread, + DEFAULT_CONSUMER_IDLE_TIMEOUT, + ); let adapter: Arc = Arc::new(MockChatAdapter); let key = "mock:T".to_string(); @@ -1444,7 +1496,11 @@ mod tests { tokio::time::sleep(Duration::from_millis(50)).await; let calls = mock.calls(); - assert_eq!(calls.len(), 1, "fresh consumer should have dispatched the retry"); + assert_eq!( + calls.len(), + 1, + "fresh consumer should have dispatched the retry" + ); // pack_arrival_event with no extra_blocks → delimiter + prompt = 2 blocks. assert_eq!(calls[0].block_count, 2); diff --git a/src/error_display.rs b/src/error_display.rs index 40f1479a2..e822f503f 100644 --- a/src/error_display.rs +++ b/src/error_display.rs @@ -16,18 +16,25 @@ pub fn format_user_error(message: &str) -> String { if let Some(start) = msg_lower.find("timeout waiting for ") { let rest = &message[start + "timeout waiting for ".len()..]; let method = rest.split_whitespace().next().unwrap_or("request"); - return format!("**Request Timeout**\nTimeout waiting for {}, please try again.", method); + return format!( + "**Request Timeout**\nTimeout waiting for {}, please try again.", + method + ); } - return "**Request Timeout**\nTimeout waiting for a response, please try again.".to_string(); + return "**Request Timeout**\nTimeout waiting for a response, please try again." + .to_string(); } if msg_lower.contains("connection closed") || msg_lower.contains("channel closed") { - return "**Connection Lost**\nThe connection to the agent was lost, please try again.".to_string(); + return "**Connection Lost**\nThe connection to the agent was lost, please try again." + .to_string(); } if msg_lower.contains("failed to spawn") || msg_lower.contains("no such file") { - return "**Agent Not Found**\nCould not start the agent — please check your configuration.".to_string(); + return "**Agent Not Found**\nCould not start the agent — please check your configuration." + .to_string(); } if msg_lower.contains("pool exhausted") { - return "**Service Busy**\nAll agent sessions are in use, please try again shortly.".to_string(); + return "**Service Busy**\nAll agent sessions are in use, please try again shortly." + .to_string(); } if msg_lower.contains("invalid api key") || msg_lower.contains("unauthorized") { return "**Unauthorized**\nPlease check your API key configuration.".to_string(); @@ -43,8 +50,9 @@ pub fn format_user_error(message: &str) -> String { /// Format coded error from ACP agent for display in Discord. /// Used for response errors that have a JSON-RPC or HTTP status code. +/// `data_message` is the optional detail extracted from `error.data.message`. /// Public for reuse by other adapters (e.g. Slack). -pub fn format_coded_error(code: i64, message: &str) -> String { +pub fn format_coded_error(code: i64, message: &str, data_message: Option<&str>) -> String { let prefix = match code { 400 => "**Bad Request**", 401 => "**Unauthorized**", @@ -63,11 +71,18 @@ pub fn format_coded_error(code: i64, message: &str) -> String { -32099..=-32000 => "**Server Error**", _ => "**Error**", }; - if message.is_empty() { + let mut out = if message.is_empty() { format!("{} (code: {})", prefix, code) } else { format!("{} (code: {})\n{}", prefix, code, message) + }; + if let Some(detail) = data_message { + if !detail.is_empty() && !message.contains(detail) { + out.push_str("\n> "); + out.push_str(detail); + } } + out } #[cfg(test)] @@ -159,7 +174,7 @@ mod tests { #[test] fn test_format_coded_error_401() { - let result = format_coded_error(401, "invalid token"); + let result = format_coded_error(401, "invalid token", None); assert!(result.contains("Unauthorized")); assert!(result.contains("401")); assert!(result.contains("invalid token")); @@ -167,7 +182,7 @@ mod tests { #[test] fn test_format_coded_error_429() { - let result = format_coded_error(429, ""); + let result = format_coded_error(429, "", None); assert!(result.contains("Rate Limited")); assert!(result.contains("429")); assert!(!result.contains("\n")); // no message, no newline @@ -175,7 +190,7 @@ mod tests { #[test] fn test_format_coded_error_503() { - let result = format_coded_error(503, "service unavailable"); + let result = format_coded_error(503, "service unavailable", None); assert!(result.contains("Service Unavailable")); assert!(result.contains("503")); assert!(result.contains("service unavailable")); @@ -183,30 +198,44 @@ mod tests { #[test] fn test_format_coded_error_json_rpc() { - let result = format_coded_error(-32602, "missing required parameter"); + let result = format_coded_error(-32602, "missing required parameter", None); assert!(result.contains("Invalid Params")); assert!(result.contains("-32602")); } #[test] fn test_format_coded_error_server_error_range() { - let result = format_coded_error(-32050, "internal failure"); + let result = format_coded_error(-32050, "internal failure", None); assert!(result.contains("Server Error")); assert!(result.contains("-32050")); } #[test] fn test_format_coded_error_connection_error() { - let result = format_coded_error(-32000, "connection refused"); + let result = format_coded_error(-32000, "connection refused", None); assert!(result.contains("Server Error")); // -32000 falls in -32099..=-32000 range assert!(result.contains("-32000")); } #[test] fn test_format_coded_error_unknown_code() { - let result = format_coded_error(999, "something happened"); + let result = format_coded_error(999, "something happened", None); assert!(result.contains("Error")); assert!(result.contains("999")); assert!(result.contains("something happened")); } + + #[test] + fn test_format_coded_error_with_data_message() { + let result = format_coded_error(-32603, "Internal error", Some("model not supported")); + assert!(result.contains("Internal Error")); + assert!(result.contains("model not supported")); + } + + #[test] + fn test_format_coded_error_data_message_not_duplicated() { + // If data_message is already in message, don't repeat it + let result = format_coded_error(-32603, "model not supported", Some("model not supported")); + assert_eq!(result.matches("model not supported").count(), 1); + } } diff --git a/src/gateway.rs b/src/gateway.rs index 8aed6aabc..bbd6da9bc 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -10,6 +10,9 @@ use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message; use tracing::{error, info, warn}; +/// Timeout for waiting on gateway reply acknowledgement. +const GATEWAY_REPLY_TIMEOUT_SECS: u64 = 5; + // --- Gateway event/reply schemas (mirrors gateway service) --- #[derive(Clone, Debug, Deserialize)] @@ -61,9 +64,13 @@ struct GwAttachment { attachment_type: String, filename: String, mime_type: String, + #[serde(default)] data: String, #[allow(dead_code)] size: u64, + /// Colocate mode: local file path (preferred over base64 `data` when present) + #[serde(default)] + path: Option, } #[derive(Serialize)] @@ -77,6 +84,11 @@ struct GatewayReply { command: Option, #[serde(skip_serializing_if = "Option::is_none")] request_id: Option, + /// When set, the gateway should send this message as a reply/quote to the specified message ID. + /// Unlike `reply_to` (routing/dedup identifier for the triggering event), this field controls + /// the visual reply/quote UI on the platform. Falls back to plain send on failure. + #[serde(skip_serializing_if = "Option::is_none")] + quote_message_id: Option, } #[derive(Serialize)] @@ -107,12 +119,16 @@ struct GatewayResponse { // --- GatewayAdapter: ChatAdapter over WebSocket --- type PendingRequests = Arc>>>; -type SharedWsTx = Arc, +type SharedWsTx = Arc< + Mutex< + futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + Message, + >, >, - Message, ->>>; +>; pub struct GatewayAdapter { ws_tx: SharedWsTx, @@ -135,6 +151,74 @@ impl GatewayAdapter { streaming, } } + + /// Internal helper for send_message / send_message_with_reply. + async fn send_gateway_reply( + &self, + channel: &ChannelRef, + content: &str, + quote_message_id: Option<&str>, + ) -> Result { + let req_id = if self.streaming { + Some(format!("req_{}", uuid::Uuid::new_v4())) + } else { + None + }; + let pending_rx = if let Some(ref id) = req_id { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.pending.lock().await.insert(id.clone(), tx); + Some(rx) + } else { + None + }; + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: channel.origin_event_id.clone().unwrap_or_default(), + platform: channel.platform.clone(), + channel: ReplyChannel { + id: channel.channel_id.clone(), + thread_id: channel.thread_id.clone(), + }, + content: ReplyContent { + content_type: "text".into(), + text: content.into(), + }, + command: None, + request_id: req_id.clone(), + quote_message_id: quote_message_id.map(|s| s.to_string()), + }; + let json = serde_json::to_string(&reply)?; + if let Err(e) = self.ws_tx.lock().await.send(Message::Text(json)).await { + if let Some(ref id) = req_id { + self.pending.lock().await.remove(id); + } + return Err(e.into()); + } + let msg_id = if let (Some(rx), Some(ref id)) = (pending_rx, &req_id) { + match tokio::time::timeout(std::time::Duration::from_secs(GATEWAY_REPLY_TIMEOUT_SECS), rx).await { + Ok(Ok(resp)) if resp.success => resp.message_id.unwrap_or_else(|| "gw_sent".into()), + Ok(Ok(_resp)) => { + tracing::warn!(request_id = %id, "gateway replied with failure"); + "gw_sent".into() + } + Ok(Err(_)) => { + tracing::warn!(request_id = %id, "gateway response channel closed"); + "gw_sent".into() + } + Err(_) => { + tracing::warn!(request_id = %id, "gateway reply timed out"); + self.pending.lock().await.remove(id); + "gw_sent".into() + } + } + } else { + "gw_sent".into() + }; + Ok(MessageRef { + channel: channel.clone(), + message_id: msg_id, + }) + } } /// Send a fire-and-forget reply via the shared WebSocket (no request-response). @@ -158,6 +242,7 @@ async fn send_fire_and_forget( }, command: None, request_id: None, + quote_message_id: None, }; let json = serde_json::to_string(&reply)?; ws_tx.lock().await.send(Message::Text(json)).await?; @@ -263,10 +348,7 @@ async fn handle_config_command( Err(e) => Some(format!("❌ Failed to switch: {e}")), }; } else { - return Some(format!( - "⚠️ Invalid number. Use 1–{}.", - all_values.len() - )); + return Some(format!("⚠️ Invalid number. Use 1–{}.", all_values.len())); } } // Exact match on value or name @@ -304,56 +386,16 @@ impl ChatAdapter for GatewayAdapter { } async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { - let req_id = if self.streaming { - Some(format!("req_{}", uuid::Uuid::new_v4())) - } else { - None - }; - - let pending_rx = if let Some(ref id) = req_id { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.pending.lock().await.insert(id.clone(), tx); - Some(rx) - } else { - None - }; - - let reply = GatewayReply { - schema: "openab.gateway.reply.v1".into(), - reply_to: channel.origin_event_id.clone().unwrap_or_default(), - platform: channel.platform.clone(), - channel: ReplyChannel { - id: channel.channel_id.clone(), - thread_id: channel.thread_id.clone(), - }, - content: ReplyContent { - content_type: "text".into(), - text: content.into(), - }, - command: None, - request_id: req_id.clone(), - }; - let json = serde_json::to_string(&reply)?; - self.ws_tx.lock().await.send(Message::Text(json)).await?; - - // When streaming is enabled, wait for gateway to return real message_id - // (needed for edit_message). Otherwise fire-and-forget. - let msg_id = if let (Some(rx), Some(ref id)) = (pending_rx, &req_id) { - match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await { - Ok(Ok(resp)) if resp.success => resp.message_id.unwrap_or_else(|| "gw_sent".into()), - _ => { - self.pending.lock().await.remove(id); - "gw_sent".into() - } - } - } else { - "gw_sent".into() - }; + self.send_gateway_reply(channel, content, None).await + } - Ok(MessageRef { - channel: channel.clone(), - message_id: msg_id, - }) + async fn send_message_with_reply( + &self, + channel: &ChannelRef, + content: &str, + reply_to_message_id: &str, + ) -> Result { + self.send_gateway_reply(channel, content, Some(reply_to_message_id)).await } async fn create_thread( @@ -381,6 +423,7 @@ impl ChatAdapter for GatewayAdapter { }, command: Some("create_topic".into()), request_id: Some(req_id.clone()), + quote_message_id: None, }; let json = serde_json::to_string(&reply)?; self.ws_tx.lock().await.send(Message::Text(json)).await?; @@ -420,6 +463,7 @@ impl ChatAdapter for GatewayAdapter { text: emoji.into(), }, command: Some("add_reaction".into()), + quote_message_id: None, request_id: None, }; let json = serde_json::to_string(&reply)?; @@ -441,6 +485,7 @@ impl ChatAdapter for GatewayAdapter { text: emoji.into(), }, command: Some("remove_reaction".into()), + quote_message_id: None, request_id: None, }; let json = serde_json::to_string(&reply)?; @@ -462,6 +507,7 @@ impl ChatAdapter for GatewayAdapter { text: content.into(), }, command: Some("edit_message".into()), + quote_message_id: None, request_id: None, }; let json = serde_json::to_string(&reply)?; @@ -487,6 +533,7 @@ pub struct GatewayParams { pub allow_all_users: bool, pub allowed_users: Vec, pub streaming: bool, + pub stt: crate::config::SttConfig, } pub async fn run_gateway_adapter( @@ -505,6 +552,7 @@ pub async fn run_gateway_adapter( let allow_all_users = params.allow_all_users; let allowed_users = params.allowed_users; let streaming = params.streaming; + let stt_config = params.stt; let connect_url = match ¶ms.token { Some(token) => { @@ -548,8 +596,12 @@ pub async fn run_gateway_adapter( let (ws_tx, mut ws_rx) = ws_stream.split(); let ws_tx: SharedWsTx = Arc::new(Mutex::new(ws_tx)); let pending: PendingRequests = Arc::new(Mutex::new(HashMap::new())); - let adapter: Arc = - Arc::new(GatewayAdapter::new(ws_tx.clone(), pending.clone(), platform, streaming)); + let adapter: Arc = Arc::new(GatewayAdapter::new( + ws_tx.clone(), + pending.clone(), + platform, + streaming, + )); let slash_ws_tx = ws_tx.clone(); // for fire-and-forget slash command responses let mut tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); @@ -637,6 +689,8 @@ pub async fn run_gateway_adapter( } else { event.timestamp.clone() }), + message_id: if event.message_id.is_empty() { None } else { Some(event.message_id.clone()) }, + receiver_id: None, // gateway does not yet resolve receiver identity }; let sender_json = serde_json::to_string(&sender_ctx) .unwrap_or_default(); @@ -655,22 +709,82 @@ pub async fn run_gateway_adapter( // Convert gateway attachments to ContentBlocks let mut extra_blocks = Vec::new(); for att in &event.content.attachments { + // Read bytes: prefer file path (colocate), fallback to base64 + let bytes_result = if let Some(ref path) = att.path { + tokio::fs::read(path).await.map_err(|e| e.to_string()) + } else if !att.data.is_empty() { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(&att.data) + .map_err(|e| e.to_string()) + } else { + Err("no path or data".into()) + }; + match att.attachment_type.as_str() { "image" => { - extra_blocks.push(ContentBlock::Image { - media_type: att.mime_type.clone(), - data: att.data.clone(), - }); + match bytes_result { + Ok(bytes) => { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + extra_blocks.push(ContentBlock::Image { + media_type: att.mime_type.clone(), + data: b64, + }); + } + Err(e) => { + tracing::warn!(filename = %att.filename, error = %e, "gateway image read failed"); + } + } } "text_file" => { - use base64::Engine; - if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&att.data) { + if let Ok(bytes) = bytes_result { let text = String::from_utf8_lossy(&bytes); extra_blocks.push(ContentBlock::Text { text: format!("```{}\n{}\n```", att.filename, text), }); } } + "audio" if stt_config.enabled => { + match bytes_result { + Ok(bytes) => { + match crate::stt::transcribe( + &crate::media::HTTP_CLIENT, + &stt_config, + bytes, + att.filename.clone(), + &att.mime_type, + ).await { + Some(transcript) => { + extra_blocks.push(ContentBlock::Text { + text: format!("[Voice message transcript]: {transcript}"), + }); + } + None => { + tracing::warn!(filename = %att.filename, "gateway audio STT failed"); + extra_blocks.push(ContentBlock::Text { + text: format!( + "[Voice message — transcription failed for {}]", + att.filename + ), + }); + } + } + } + Err(e) => { + tracing::warn!(filename = %att.filename, error = %e, "gateway audio read failed"); + extra_blocks.push(ContentBlock::Text { + text: format!( + "[Voice message — read failed for {}]", + att.filename + ), + }); + } + } + } + "audio" => { + tracing::debug!(filename = %att.filename, "audio attachment skipped — STT not enabled"); + } _ => {} } } @@ -792,4 +906,3 @@ pub async fn run_gateway_adapter( backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF); } // outer reconnect loop } - diff --git a/src/main.rs b/src/main.rs index 935100ba7..8b3e9edfe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,13 +7,14 @@ mod discord; mod dispatch; mod error_display; mod format; +mod gateway; mod markdown; mod media; mod reactions; +mod remind; mod setup; mod slack; mod stt; -mod gateway; mod timestamp; use adapter::AdapterRouter; @@ -85,7 +86,9 @@ async fn main() -> anyhow::Result<()> { ) .init(); - let cmd = Cli::parse().command.unwrap_or(Commands::Run { config: None }); + let cmd = Cli::parse() + .command + .unwrap_or(Commands::Run { config: None }); let config_arg = match cmd { Commands::Setup { output } => { @@ -117,7 +120,9 @@ async fn main() -> anyhow::Result<()> { ); if cfg.discord.is_none() && cfg.slack.is_none() && cfg.gateway.is_none() { - anyhow::bail!("no adapter configured — add [discord], [slack], and/or [gateway] to config.toml"); + anyhow::bail!( + "no adapter configured — add [discord], [slack], and/or [gateway] to config.toml" + ); } let pool = Arc::new(acp::SessionPool::new(cfg.agent, cfg.pool.max_sessions)); @@ -139,7 +144,13 @@ async fn main() -> anyhow::Result<()> { info!(model = %cfg.stt.model, base_url = %cfg.stt.base_url, "STT enabled"); } - let router = Arc::new(AdapterRouter::new(pool.clone(), cfg.reactions, cfg.markdown.tables)); + let router = Arc::new(AdapterRouter::new( + pool.clone(), + cfg.reactions, + cfg.markdown.tables, + cfg.pool.prompt_hard_timeout_secs, + cfg.pool.liveness_check_secs, + )); // Shutdown signal for Slack adapter let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); @@ -166,25 +177,36 @@ async fn main() -> anyhow::Result<()> { }); // Pre-build shared adapters for cron scheduler (avoids duplicate Http clients / rate-limit buckets) - let shared_discord_adapter: Option> = cfg.discord.as_ref().map(|dc| { - let http = Arc::new(serenity::http::Http::new(&dc.bot_token)); - Arc::new(discord::DiscordAdapter::new(http)) as Arc - }); + let shared_discord_adapter: Option> = + cfg.discord.as_ref().map(|dc| { + let http = Arc::new(serenity::http::Http::new(&dc.bot_token)); + Arc::new(discord::DiscordAdapter::new(http)) as Arc + }); let session_ttl_dur = std::time::Duration::from_secs(ttl_secs); let shared_slack_adapter: Option> = cfg.slack.as_ref().map(|s| { - Arc::new(slack::SlackAdapter::new(s.bot_token.clone(), session_ttl_dur, s.allow_bot_messages)) + Arc::new(slack::SlackAdapter::new( + s.bot_token.clone(), + session_ttl_dur, + s.allow_bot_messages, + )) }); // Validate cronjob config at startup (fail-fast on bad cron expressions or timezones) let mut configured_platforms: Vec<&str> = Vec::new(); - if cfg.discord.is_some() { configured_platforms.push("discord"); } - if cfg.slack.is_some() { configured_platforms.push("slack"); } + if cfg.discord.is_some() { + configured_platforms.push("discord"); + } + if cfg.slack.is_some() { + configured_platforms.push("slack"); + } cron::validate_cronjobs(&cfg.cron.jobs, &configured_platforms)?; // Spawn Slack adapter (background task) let slack_handle = if let Some(slack_cfg) = cfg.slack { - let allow_all_channels = config::resolve_allow_all(slack_cfg.allow_all_channels, &slack_cfg.allowed_channels); - let allow_all_users = config::resolve_allow_all(slack_cfg.allow_all_users, &slack_cfg.allowed_users); + let allow_all_channels = + config::resolve_allow_all(slack_cfg.allow_all_channels, &slack_cfg.allowed_channels); + let allow_all_users = + config::resolve_allow_all(slack_cfg.allow_all_users, &slack_cfg.allowed_users); if !allow_all_channels && slack_cfg.allowed_channels.is_empty() { warn!("allow_all_channels=false with empty allowed_channels for Slack — bot will deny all channels"); } @@ -201,7 +223,9 @@ async fn main() -> anyhow::Result<()> { let stt = cfg.stt.clone(); let max_bot_turns = slack_cfg.max_bot_turns; let slack_shutdown_rx = shutdown_rx.clone(); - let adapter = shared_slack_adapter.clone().expect("shared_slack_adapter must exist when slack config is present"); + let adapter = shared_slack_adapter + .clone() + .expect("shared_slack_adapter must exist when slack config is present"); // Dispatcher is the sole serialization path for all modes. Message = cap 1 // (each message dispatches alone, FIFO). Thread / Lane = configured cap; // grouping decides whether senders share a buffer or get their own lane. @@ -264,15 +288,24 @@ async fn main() -> anyhow::Result<()> { platform: gw_cfg.platform, token: gw_cfg.token, bot_username: gw_cfg.bot_username, - allow_all_channels: config::resolve_allow_all(gw_cfg.allow_all_channels, &gw_cfg.allowed_channels), + allow_all_channels: config::resolve_allow_all( + gw_cfg.allow_all_channels, + &gw_cfg.allowed_channels, + ), allowed_channels: gw_cfg.allowed_channels, - allow_all_users: config::resolve_allow_all(gw_cfg.allow_all_users, &gw_cfg.allowed_users), + allow_all_users: config::resolve_allow_all( + gw_cfg.allow_all_users, + &gw_cfg.allowed_users, + ), allowed_users: gw_cfg.allowed_users, streaming: gw_cfg.streaming, + stt: cfg.stt.clone(), }; let gw_router = router.clone(); Some(tokio::spawn(async move { - if let Err(e) = gateway::run_gateway_adapter(params, shutdown_rx, gw_dispatcher, gw_router).await { + if let Err(e) = + gateway::run_gateway_adapter(params, shutdown_rx, gw_dispatcher, gw_router).await + { error!("gateway adapter error: {e}"); } })) @@ -311,10 +344,19 @@ async fn main() -> anyhow::Result<()> { if let Some(ref a) = shared_slack_adapter { cron_adapters.insert("slack".into(), a.clone() as Arc); } - let cron_platforms: Vec = configured_platforms.iter().map(|s| s.to_string()).collect(); + let cron_platforms: Vec = + configured_platforms.iter().map(|s| s.to_string()).collect(); info!(baseline = cronjobs.len(), usercron = ?usercron_path, "starting cron scheduler"); Some(tokio::spawn(async move { - cron::run_scheduler(cronjobs, usercron_path, cron_platforms, cron_router, cron_adapters, shutdown_rx).await; + cron::run_scheduler( + cronjobs, + usercron_path, + cron_platforms, + cron_router, + cron_adapters, + shutdown_rx, + ) + .await; })) } else { None @@ -322,21 +364,28 @@ async fn main() -> anyhow::Result<()> { // Run Discord adapter (foreground, blocking) or wait for ctrl_c if let Some(discord_cfg) = cfg.discord { - let allow_all_channels = config::resolve_allow_all(discord_cfg.allow_all_channels, &discord_cfg.allowed_channels); - let allow_all_users = config::resolve_allow_all(discord_cfg.allow_all_users, &discord_cfg.allowed_users); + let allow_all_channels = config::resolve_allow_all( + discord_cfg.allow_all_channels, + &discord_cfg.allowed_channels, + ); + let allow_all_users = + config::resolve_allow_all(discord_cfg.allow_all_users, &discord_cfg.allowed_users); let allowed_channels = parse_id_set(&discord_cfg.allowed_channels, "discord.allowed_channels")?; if !allow_all_channels && allowed_channels.is_empty() { warn!("allow_all_channels=false with empty allowed_channels for Discord — bot will deny all channels"); } let allowed_users = parse_id_set(&discord_cfg.allowed_users, "discord.allowed_users")?; - let trusted_bot_ids = parse_id_set(&discord_cfg.trusted_bot_ids, "discord.trusted_bot_ids")?; + let trusted_bot_ids = + parse_id_set(&discord_cfg.trusted_bot_ids, "discord.trusted_bot_ids")?; + let allowed_role_ids = parse_id_set(&discord_cfg.allowed_role_ids, "discord.allowed_role_ids")?; info!( allow_all_channels, allow_all_users, channels = allowed_channels.len(), users = allowed_users.len(), trusted_bots = trusted_bot_ids.len(), + role_triggers = allowed_role_ids.len(), allow_bot_messages = ?discord_cfg.allow_bot_messages, allow_user_messages = ?discord_cfg.allow_user_messages, allow_dm = discord_cfg.allow_dm, @@ -356,6 +405,14 @@ async fn main() -> anyhow::Result<()> { )); dispatchers.lock().unwrap().push(discord_dispatcher.clone()); + // Initialize reminder store (persists to $HOME/.openab/reminders.json) + let reminder_path = std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default() + .join(".openab") + .join("reminders.json"); + let reminder_store = remind::ReminderStore::load(reminder_path); + let handler = discord::Handler { router, allow_all_channels, @@ -367,13 +424,18 @@ async fn main() -> anyhow::Result<()> { allow_bot_messages: discord_cfg.allow_bot_messages, trusted_bot_ids, allow_user_messages: discord_cfg.allow_user_messages, + allowed_role_ids, participated_threads: tokio::sync::Mutex::new(std::collections::HashMap::new()), multibot_threads: tokio::sync::Mutex::new(std::collections::HashMap::new()), session_ttl: std::time::Duration::from_secs(ttl_secs), max_bot_turns: discord_cfg.max_bot_turns, - bot_turns: tokio::sync::Mutex::new(bot_turns::BotTurnTracker::new(discord_cfg.max_bot_turns)), + bot_turns: tokio::sync::Mutex::new(bot_turns::BotTurnTracker::new( + discord_cfg.max_bot_turns, + )), allow_dm: discord_cfg.allow_dm, dispatcher: discord_dispatcher, + reminder_store: reminder_store.clone(), + scheduled_ids: tokio::sync::Mutex::new(std::collections::HashSet::new()), }; let intents = GatewayIntents::GUILD_MESSAGES @@ -506,7 +568,8 @@ mod tests { #[test] fn cli_run_with_remote_url() { - let cli = Cli::try_parse_from(["openab", "run", "-c", "https://example.com/config.toml"]).unwrap(); + let cli = Cli::try_parse_from(["openab", "run", "-c", "https://example.com/config.toml"]) + .unwrap(); match cli.command.unwrap() { Commands::Run { config } => assert!(config.unwrap().starts_with("https://")), _ => panic!("expected Run"), diff --git a/src/markdown.rs b/src/markdown.rs index 6b0aa5331..32398cc25 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -330,9 +330,7 @@ Some text after. // The table is inside a ``` block — backtick wrapping must be stripped. assert!(result.contains("value"), "cell content should be present"); // Only the fence markers themselves should contain backticks. - let inner = result - .trim_start_matches("```\n") - .trim_end_matches("```\n"); + let inner = result.trim_start_matches("```\n").trim_end_matches("```\n"); assert!( !inner.contains('`'), "no backticks should appear inside the code fence: {result:?}" @@ -343,6 +341,9 @@ Some text after. fn bullets_mode_keeps_backticks_in_code_cells() { let md = "| col |\n|-----|\n| `value` |\n"; let result = convert_tables(md, TableMode::Bullets); - assert!(result.contains("`value`"), "backticks should be kept in bullets mode"); + assert!( + result.contains("`value`"), + "backticks should be kept in bullets mode" + ); } } diff --git a/src/media.rs b/src/media.rs index 5e0c057f3..33ea59010 100644 --- a/src/media.rs +++ b/src/media.rs @@ -2,10 +2,11 @@ use crate::acp::ContentBlock; use crate::config::SttConfig; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; -use image::ImageReader; +use image::codecs::gif::GifDecoder; +use image::{AnimationDecoder, ImageReader}; use std::io::Cursor; use std::sync::LazyLock; -use tracing::{debug, error}; +use tracing::{debug, error, warn}; /// Reusable HTTP client for downloading attachments (shared across adapters). pub static HTTP_CLIENT: LazyLock = LazyLock::new(|| { @@ -21,7 +22,151 @@ const IMAGE_MAX_DIMENSION_PX: u32 = 1200; /// JPEG quality for compressed output. const IMAGE_JPEG_QUALITY: u8 = 75; +/// Error variants for `download_and_encode_image`. +#[derive(Debug)] +pub enum MediaFetchError { + /// URL empty or MIME/filename doesn't indicate an image; skip silently. + NotAnImage, + /// HTTP response Content-Type is not a supported image format. + UnsupportedResponseType { actual: Option }, + /// Response body magic bytes don't match a supported image format. + InvalidImageBody { magic_prefix_hex: String }, + /// File exceeds the configured size limit. + SizeExceeded { actual: u64, limit: u64 }, + /// Network-level error (send or body-read). + Network(reqwest::Error), + /// Server returned a non-success HTTP status. + HttpStatus(reqwest::StatusCode), + /// Body was a valid image but post-processing (resize/compress) failed. + /// Unlike `InvalidImageBody`, the bytes decoded successfully — this is an + /// unexpected processing error, not a content validation failure. Both the + /// Slack and Discord adapters surface this as a user-facing warning alongside + /// other image-validation failures. + ProcessingFailed(image::ImageError), +} + +impl std::fmt::Display for MediaFetchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotAnImage => write!(f, "not an image attachment"), + Self::UnsupportedResponseType { actual } => write!( + f, + "server returned unexpected content type (actual: {})", + actual.as_deref().unwrap_or("none"), + ), + Self::InvalidImageBody { magic_prefix_hex } => write!( + f, + "response body is not a valid image (first 8 bytes: {magic_prefix_hex})" + ), + Self::SizeExceeded { actual, limit } => { + write!(f, "file size {actual} exceeds limit {limit}") + } + Self::Network(e) => write!(f, "network error: {e}"), + Self::HttpStatus(s) => write!(f, "HTTP {s}"), + Self::ProcessingFailed(e) => write!(f, "image processing failed: {e}"), + } + } +} + +/// Strip MIME parameters and trim whitespace. `"image/png; charset=binary"` → `"image/png"`. +pub(crate) fn strip_mime_params(mime: &str) -> &str { + mime.split(';').next().unwrap_or(mime).trim() +} + +/// Format the first 8 bytes of a buffer as lowercase hex (no separator). +fn hex_prefix(body: &[u8]) -> String { + body.iter() + .take(8) + .map(|b| format!("{b:02x}")) + .collect::>() + .concat() +} + +/// Validate the HTTP response Content-Type and body magic bytes. +/// +/// If Content-Type is present and explicitly text-typed (e.g. `text/html` from +/// Slack's auth redirect when `files:read` scope is missing), rejects immediately. +/// Generic types such as `application/octet-stream` and absent headers pass through +/// to the magic-byte check, which is the authoritative gate for image validity. +/// +/// Content-Type is filtered with a block-list (`text/*`) rather than an allow-list +/// (`image/*`) because CDNs commonly serve any file type as `application/octet-stream`; +/// rejecting that header would silently break real downloads. The magic-byte check +/// examines the actual bytes regardless of what the server claims. +fn validate_image_response( + content_type: Option<&str>, + body: &[u8], +) -> Result<(), MediaFetchError> { + // Reject explicitly-text responses early (e.g. Slack HTML login page at HTTP 200). + // application/octet-stream and other generic types pass through to magic-byte check. + if let Some(ct) = content_type { + let base = strip_mime_params(ct).to_lowercase(); + if base.starts_with("text/") { + return Err(MediaFetchError::UnsupportedResponseType { actual: Some(base) }); + } + } + + let reader = match ImageReader::new(Cursor::new(body)).with_guessed_format() { + Ok(r) => r, + Err(e) => { + error!(error = %e, "image format detection I/O error"); + return Err(MediaFetchError::InvalidImageBody { + magic_prefix_hex: hex_prefix(body), + }); + } + }; + + match reader.format() { + Some(image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::WebP) => { + Ok(()) + } + Some(image::ImageFormat::Gif) => { + validate_gif_body(body).map_err(|e| { + warn!(error = %e, "GIF validation failed"); + MediaFetchError::InvalidImageBody { + magic_prefix_hex: hex_prefix(body), + } + })?; + Ok(()) + } + _ => Err(MediaFetchError::InvalidImageBody { + magic_prefix_hex: hex_prefix(body), + }), + } +} + +/// Validate a GIF body by attempting to decode exactly one frame. +/// +/// Decoding only the first frame is intentional: the GIF header and colour tables +/// must be valid before the first frame can be decoded, so this catches truncated +/// or corrupt payloads without the CPU/memory cost of decoding a large animated GIF +/// in full. +/// +/// Creates its own `Cursor` over `raw`; the caller can independently re-read the +/// same slice for resizing. +fn validate_gif_body(raw: &[u8]) -> image::ImageResult<()> { + let decoder = GifDecoder::new(Cursor::new(raw))?; + let mut frames = decoder.into_frames(); + frames.next().ok_or_else(|| { + image::ImageError::Decoding(image::error::DecodingError::new( + image::error::ImageFormatHint::Exact(image::ImageFormat::Gif), + "GIF has no frames", + )) + })??; + Ok(()) +} + /// Download an image from a URL, resize/compress it, and return as a ContentBlock. +/// +/// Returns `Err(MediaFetchError::NotAnImage)` when the URL or MIME hint don't +/// indicate an image — callers should skip silently. Returns +/// `Err(MediaFetchError::SizeExceeded)` when the declared `size` exceeds the limit +/// before any request is made, or when the downloaded body exceeds the limit. Returns +/// other `Err` variants (`Network`, `HttpStatus`, `UnsupportedResponseType`, +/// `InvalidImageBody`) after a request attempt — callers should surface these to the user. Returns +/// `Err(MediaFetchError::ProcessingFailed)` when the body is a valid image but +/// resize/compression fails — callers should warn the user and skip. +/// /// Pass `auth_token` for platforms that require authentication (e.g. Slack private files). pub async fn download_and_encode_image( url: &str, @@ -29,11 +174,11 @@ pub async fn download_and_encode_image( filename: &str, size: u64, auth_token: Option<&str>, -) -> Option { +) -> Result { const MAX_SIZE: u64 = 10 * 1024 * 1024; // 10 MB if url.is_empty() { - return None; + return Err(MediaFetchError::NotAnImage); } let mime = mime_hint.or_else(|| { @@ -51,17 +196,20 @@ pub async fn download_and_encode_image( let Some(mime) = mime else { debug!(filename, "skipping non-image attachment"); - return None; + return Err(MediaFetchError::NotAnImage); }; let mime = mime.split(';').next().unwrap_or(mime).trim(); if !mime.starts_with("image/") { debug!(filename, mime, "skipping non-image attachment"); - return None; + return Err(MediaFetchError::NotAnImage); } if size > MAX_SIZE { error!(filename, size, "image exceeds 10MB limit"); - return None; + return Err(MediaFetchError::SizeExceeded { + actual: size, + limit: MAX_SIZE, + }); } let mut req = HTTP_CLIENT.get(url); @@ -71,31 +219,67 @@ pub async fn download_and_encode_image( let response = match req.send().await { Ok(resp) => resp, - Err(e) => { error!(url, error = %e, "download failed"); return None; } + Err(e) => { + error!(url, error = %e, "download failed"); + return Err(MediaFetchError::Network(e)); + } }; if !response.status().is_success() { error!(url, status = %response.status(), "HTTP error downloading image"); - return None; + return Err(MediaFetchError::HttpStatus(response.status())); } + + // Capture Content-Type BEFORE .bytes() consumes the response. + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let bytes = match response.bytes().await { Ok(b) => b, - Err(e) => { error!(url, error = %e, "read failed"); return None; } + Err(e) => { + error!(url, error = %e, "read failed"); + return Err(MediaFetchError::Network(e)); + } }; if bytes.len() as u64 > MAX_SIZE { - error!(filename, size = bytes.len(), "downloaded image exceeds limit"); - return None; + error!( + filename, + size = bytes.len(), + "downloaded image exceeds limit" + ); + return Err(MediaFetchError::SizeExceeded { + actual: bytes.len() as u64, + limit: MAX_SIZE, + }); + } + + // Guard against HTTP 200 responses that are error pages (e.g. Slack auth redirect + // when files:read scope is missing), and against corrupted or mislabeled bodies. + if let Err(e) = validate_image_response(content_type.as_deref(), &bytes) { + error!( + filename, + mime_hint = mime, + content_type = content_type.as_deref().unwrap_or("none"), + magic = hex_prefix(&bytes), + error = %e, + "image validation failed — body is not a supported image" + ); + return Err(e); } let (output_bytes, output_mime) = match resize_and_compress(&bytes) { Ok(result) => result, Err(e) => { - if bytes.len() > 1024 * 1024 { - error!(filename, error = %e, size = bytes.len(), "resize failed and original too large, skipping"); - return None; - } - debug!(filename, error = %e, "resize failed, using original"); - (bytes.to_vec(), mime.to_string()) + error!( + filename, + error = %e, + size = bytes.len(), + "resize failed after successful validation" + ); + return Err(MediaFetchError::ProcessingFailed(e)); } }; @@ -107,7 +291,7 @@ pub async fn download_and_encode_image( ); let encoded = BASE64.encode(&output_bytes); - Some(ContentBlock::Image { + Ok(ContentBlock::Image { media_type: output_mime, data: encoded, }) @@ -135,21 +319,44 @@ pub async fn download_and_transcribe( req = req.header("Authorization", format!("Bearer {token}")); } - let resp = req.send().await.ok()?; + let resp = match req.send().await { + Ok(r) => r, + Err(e) => { + error!(url, error = %e, "audio download request failed"); + return None; + } + }; if !resp.status().is_success() { error!(url, status = %resp.status(), "audio download failed"); return None; } - let bytes = resp.bytes().await.ok()?.to_vec(); + let bytes = match resp.bytes().await { + Ok(b) => b.to_vec(), + Err(e) => { + error!(url, error = %e, "audio body read failed"); + return None; + } + }; + + if bytes.len() as u64 > MAX_SIZE { + error!(filename, size = bytes.len(), "downloaded audio exceeds 25MB limit"); + return None; + } - crate::stt::transcribe(&HTTP_CLIENT, stt_config, bytes, filename.to_string(), mime_type).await + crate::stt::transcribe( + &HTTP_CLIENT, + stt_config, + bytes, + filename.to_string(), + mime_type, + ) + .await } /// Resize image so longest side <= IMAGE_MAX_DIMENSION_PX, then encode as JPEG. /// GIFs are passed through unchanged to preserve animation. pub fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { - let reader = ImageReader::new(Cursor::new(raw)) - .with_guessed_format()?; + let reader = ImageReader::new(Cursor::new(raw)).with_guessed_format()?; let format = reader.format(); @@ -182,18 +389,45 @@ pub fn is_audio_mime(mime: &str) -> bool { mime.starts_with("audio/") } +/// Check if an attachment is a video file. +pub fn is_video_file(filename: &str, content_type: Option<&str>) -> bool { + let mime = content_type.unwrap_or(""); + let mime_base = mime.split(';').next().unwrap_or(mime).trim(); + if mime_base.starts_with("video/") { + return true; + } + + filename + .rsplit('.') + .next() + .map(|ext| { + matches!( + ext.to_lowercase().as_str(), + "mp4" | "mov" | "m4v" | "webm" | "mkv" | "avi" + ) + }) + .unwrap_or(false) +} + /// Extensions recognised as text-based files that can be inlined into the prompt. const TEXT_EXTENSIONS: &[&str] = &[ - "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", - "rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", - "rb", "sh", "bash", "zsh", "fish", "ps1", "bat", "sql", "html", "css", - "scss", "less", "ini", "cfg", "conf", "env", + "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", "rs", "py", "js", + "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh", "bash", "zsh", "fish", + "ps1", "bat", "sql", "html", "css", "scss", "less", "ini", "cfg", "conf", "env", ]; /// Exact filenames (no extension) recognised as text files. const TEXT_FILENAMES: &[&str] = &[ - "dockerfile", "makefile", "justfile", "rakefile", "gemfile", - "procfile", "vagrantfile", ".gitignore", ".dockerignore", ".editorconfig", + "dockerfile", + "makefile", + "justfile", + "rakefile", + "gemfile", + "procfile", + "vagrantfile", + ".gitignore", + ".dockerignore", + ".editorconfig", ]; /// MIME types recognised as text-based (beyond `text/*`). @@ -263,12 +497,22 @@ pub async fn download_and_read_text_file( tracing::warn!(url, status = %resp.status(), "text file download failed"); return None; } - let bytes = resp.bytes().await.ok()?; + let bytes = match resp.bytes().await { + Ok(b) => b, + Err(e) => { + tracing::warn!(url, error = %e, "text file body read failed"); + return None; + } + }; let actual_size = bytes.len() as u64; // Defense-in-depth: verify actual download size if actual_size > MAX_SIZE { - tracing::warn!(filename, size = actual_size, "downloaded text file exceeds 512KB limit, skipping"); + tracing::warn!( + filename, + size = actual_size, + "downloaded text file exceeds 512KB limit, skipping" + ); return None; } @@ -301,6 +545,21 @@ mod tests { buf.into_inner() } + fn make_jpeg(width: u32, height: u32) -> Vec { + let img = image::RgbImage::new(width, height); + let mut buf = Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap(); + buf.into_inner() + } + + fn make_gif() -> Vec { + vec![ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3B, + ] + } + #[test] fn large_image_resized_to_max_dimension() { let png = make_png(3000, 2000); @@ -348,18 +607,17 @@ mod tests { let png = make_png(3000, 2000); let (compressed, _) = resize_and_compress(&png).unwrap(); - assert!(compressed.len() < png.len(), "compressed {} should be < original {}", compressed.len(), png.len()); + assert!( + compressed.len() < png.len(), + "compressed {} should be < original {}", + compressed.len(), + png.len() + ); } #[test] fn gif_passes_through_unchanged() { - let gif: Vec = vec![ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, - 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, - 0x02, 0x02, 0x44, 0x01, 0x00, - 0x3B, - ]; + let gif = make_gif(); let (output, mime) = resize_and_compress(&gif).unwrap(); assert_eq!(mime, "image/gif"); @@ -371,4 +629,218 @@ mod tests { let garbage = vec![0x00, 0x01, 0x02, 0x03]; assert!(resize_and_compress(&garbage).is_err()); } + + #[test] + fn video_file_detects_mime_and_common_extensions() { + assert!(is_video_file("clip.bin", Some("video/mp4"))); + assert!(is_video_file("clip.mp4", None)); + assert!(is_video_file("clip.MOV", None)); + assert!(!is_video_file("notes.txt", Some("text/plain"))); + } + + // --- validate_image_response tests --- + + #[test] + fn validate_accepts_png_with_matching_content_type() { + let png = make_png(1, 1); + assert!(validate_image_response(Some("image/png"), &png).is_ok()); + } + + #[test] + fn validate_accepts_jpeg_with_matching_content_type() { + let jpeg = make_jpeg(1, 1); + assert!(validate_image_response(Some("image/jpeg"), &jpeg).is_ok()); + } + + #[test] + fn validate_accepts_gif_with_matching_content_type() { + let gif = make_gif(); + assert!(validate_image_response(Some("image/gif"), &gif).is_ok()); + } + + #[test] + fn validate_rejects_corrupt_gif_body() { + let corrupt_gif = b"GIF89a\x01\x00\x01\x00\x00\x00\x00"; + let result = validate_image_response(Some("image/gif"), corrupt_gif); + assert!(matches!( + result, + Err(MediaFetchError::InvalidImageBody { .. }) + )); + } + + #[test] + fn validate_accepts_missing_content_type_with_valid_png() { + // When Content-Type header is absent, fall back to magic-byte detection. + let png = make_png(1, 1); + assert!(validate_image_response(None, &png).is_ok()); + } + + #[test] + fn validate_content_type_strips_params() { + // "image/png; charset=binary" is a real header value — must be accepted. + let png = make_png(1, 1); + assert!(validate_image_response(Some("image/png; charset=binary"), &png).is_ok()); + } + + /// Exact reproduction of issue #776: Slack serves the workspace login HTML + /// page at HTTP 200 when the bot token lacks the `files:read` scope. + /// The Slack file metadata says `mimetype: image/png`; the response body + /// magic bytes are `Slack login"; + let result = validate_image_response(Some("image/png"), html_body); + match result { + Err(MediaFetchError::InvalidImageBody { magic_prefix_hex }) => { + assert_eq!(magic_prefix_hex, "3c21444f43545950"); + } + other => panic!("expected InvalidImageBody, got {other:?}"), + } + } + + #[test] + fn validate_rejects_text_html_content_type() { + // Even if the body were a valid image, a text/html Content-Type must be rejected. + let png = make_png(1, 1); + let result = validate_image_response(Some("text/html; charset=utf-8"), &png); + assert!(matches!( + result, + Err(MediaFetchError::UnsupportedResponseType { .. }) + )); + } + + #[test] + fn validate_rejects_mixed_case_text_content_type() { + // Mixed-case Content-Type must be normalised before rejection. + let png = make_png(1, 1); + let result = validate_image_response(Some("Text/HTML; Charset=utf-8"), &png); + assert!(matches!( + result, + Err(MediaFetchError::UnsupportedResponseType { .. }) + )); + } + + /// Regression test for the application/octet-stream fix: CDNs and generic + /// file download endpoints commonly serve any file with this Content-Type. + /// The old allow-list incorrectly rejected it before magic-byte check. + #[test] + fn validate_accepts_octet_stream_with_valid_png() { + let png = make_png(1, 1); + assert!( + validate_image_response(Some("application/octet-stream"), &png).is_ok(), + "application/octet-stream must pass through to magic-byte check" + ); + } + + /// application/json body is rejected by magic bytes, not by Content-Type. + #[test] + fn validate_rejects_json_body_by_magic_bytes() { + let json_body = b"{\"error\":\"invalid_auth\",\"ok\":false}"; + let result = validate_image_response(Some("application/json"), json_body); + assert!(matches!( + result, + Err(MediaFetchError::InvalidImageBody { .. }) + )); + } + + /// Missing Content-Type with invalid body: CDN stripping the header should + /// still be caught by magic-byte detection. + #[test] + fn validate_rejects_html_body_with_missing_content_type() { + let html_body = b"error page"; + let result = validate_image_response(None, html_body); + assert!(matches!( + result, + Err(MediaFetchError::InvalidImageBody { .. }) + )); + } + + #[test] + fn validate_rejects_empty_body() { + let result = validate_image_response(Some("image/png"), &[]); + assert!(matches!( + result, + Err(MediaFetchError::InvalidImageBody { .. }) + )); + } + + #[test] + fn validate_rejects_truncated_png_header() { + // PNG magic is 8 bytes; 4 bytes is not enough to identify the format. + let truncated = [0x89u8, 0x50, 0x4e, 0x47]; + let result = validate_image_response(Some("image/png"), &truncated); + assert!(matches!( + result, + Err(MediaFetchError::InvalidImageBody { .. }) + )); + } + + #[test] + fn truncated_png_body_must_not_produce_content_block() { + // Valid PNG magic bytes (8 bytes) + partial IHDR -- body is too short to decode. + // Previously: the <=1MB fallback in download_and_encode_image forwarded raw bytes + // after resize_and_compress failed, reproducing the #776 poisoning class. + // After removing the fallback, resize_and_compress failure must propagate as Err. + let truncated: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG magic + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // partial IHDR + ]; + assert!( + validate_image_response(Some("image/png"), truncated).is_ok(), + "magic-byte check still passes for truncated body" + ); + assert!( + resize_and_compress(truncated).is_err(), + "truncated PNG must fail at decode -- no raw-byte fallback allowed" + ); + } + + #[test] + fn media_fetch_error_display_renders() { + let _ = MediaFetchError::NotAnImage.to_string(); + let _ = MediaFetchError::UnsupportedResponseType { + actual: Some("text/html".into()), + } + .to_string(); + let s = MediaFetchError::UnsupportedResponseType { actual: None }.to_string(); + assert!(s.contains("none"), "None branch should render as 'none'"); + let _ = MediaFetchError::InvalidImageBody { + magic_prefix_hex: "3c21444f43545950".into(), + } + .to_string(); + let _ = MediaFetchError::SizeExceeded { + actual: 11_000_000, + limit: 10_000_000, + } + .to_string(); + let _ = MediaFetchError::HttpStatus(reqwest::StatusCode::UNAUTHORIZED).to_string(); + let _ = MediaFetchError::ProcessingFailed(image::ImageError::Unsupported( + image::error::UnsupportedError::from_format_and_kind( + image::error::ImageFormatHint::Unknown, + image::error::UnsupportedErrorKind::Color(image::ExtendedColorType::Rgba16), + ), + )) + .to_string(); + } + + #[test] + fn validate_accepts_webp_by_magic_bytes() { + let img = image::RgbImage::new(1, 1); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::WebP).unwrap(); + let webp_body = buf.into_inner(); + assert!(validate_image_response(Some("image/webp"), &webp_body).is_ok()); + } + + #[test] + fn hex_prefix_formats_first_8_bytes() { + let bytes = b""; + assert_eq!(hex_prefix(bytes), "3c21444f43545950"); + } + + #[test] + fn hex_prefix_handles_short_buffer() { + let bytes = [0xffu8, 0xd8]; + assert_eq!(hex_prefix(&bytes), "ffd8"); + } } diff --git a/src/reactions.rs b/src/reactions.rs index 8638d86fc..6e68f90b6 100644 --- a/src/reactions.rs +++ b/src/reactions.rs @@ -5,7 +5,13 @@ use tokio::sync::Mutex; use tokio::time::Duration; const CODING_TOKENS: &[&str] = &["exec", "process", "read", "write", "edit", "bash", "shell"]; -const WEB_TOKENS: &[&str] = &["web_search", "web_fetch", "web-search", "web-fetch", "browser"]; +const WEB_TOKENS: &[&str] = &[ + "web_search", + "web_fetch", + "web-search", + "web-fetch", + "browser", +]; fn classify_tool<'a>(name: &str, emojis: &'a ReactionEmojis) -> &'a str { let n = name.to_lowercase(); @@ -60,19 +66,25 @@ impl StatusReactionController { } pub async fn set_queued(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.queued.clone() }; self.apply_immediate(&emoji).await; } pub async fn set_thinking(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.thinking.clone() }; self.schedule_debounced(&emoji).await; } pub async fn set_tool(&self, tool_name: &str) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { let inner = self.inner.lock().await; classify_tool(tool_name, &inner.emojis).to_string() @@ -81,7 +93,9 @@ impl StatusReactionController { } pub async fn set_done(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.done.clone() }; self.finish(&emoji).await; // Add a random mood face @@ -92,18 +106,25 @@ impl StatusReactionController { } pub async fn set_error(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.error.clone() }; self.finish(&emoji).await; } pub async fn clear(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let mut inner = self.inner.lock().await; cancel_timers(&mut inner); let current = inner.current.clone(); if !current.is_empty() { - let _ = inner.adapter.remove_reaction(&inner.message, ¤t).await; + let _ = inner + .adapter + .remove_reaction(&inner.message, ¤t) + .await; inner.current.clear(); } } @@ -142,7 +163,9 @@ impl StatusReactionController { inner.debounce_handle = Some(tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(debounce_ms)).await; let mut inner = ctrl.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } let old = inner.current.clone(); inner.current = emoji.clone(); let adapter = inner.adapter.clone(); @@ -159,7 +182,9 @@ impl StatusReactionController { async fn finish(&self, emoji: &str) { let mut inner = self.inner.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } inner.finished = true; cancel_timers(&mut inner); @@ -182,8 +207,12 @@ impl StatusReactionController { } fn reset_stall_timers_inner(&self, inner: &mut Inner) { - if let Some(h) = inner.stall_soft_handle.take() { h.abort(); } - if let Some(h) = inner.stall_hard_handle.take() { h.abort(); } + if let Some(h) = inner.stall_soft_handle.take() { + h.abort(); + } + if let Some(h) = inner.stall_hard_handle.take() { + h.abort(); + } let soft_ms = inner.timing.stall_soft_ms; let hard_ms = inner.timing.stall_hard_ms; @@ -194,7 +223,9 @@ impl StatusReactionController { async move { tokio::time::sleep(Duration::from_millis(soft_ms)).await; let mut inner = ctrl.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } let old = inner.current.clone(); inner.current = "🥱".to_string(); let adapter = inner.adapter.clone(); @@ -210,7 +241,9 @@ impl StatusReactionController { inner.stall_hard_handle = Some(tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(hard_ms)).await; let mut inner = ctrl.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } let old = inner.current.clone(); inner.current = "😨".to_string(); let adapter = inner.adapter.clone(); @@ -225,11 +258,19 @@ impl StatusReactionController { } fn cancel_debounce(inner: &mut Inner) { - if let Some(h) = inner.debounce_handle.take() { h.abort(); } + if let Some(h) = inner.debounce_handle.take() { + h.abort(); + } } fn cancel_timers(inner: &mut Inner) { - if let Some(h) = inner.debounce_handle.take() { h.abort(); } - if let Some(h) = inner.stall_soft_handle.take() { h.abort(); } - if let Some(h) = inner.stall_hard_handle.take() { h.abort(); } + if let Some(h) = inner.debounce_handle.take() { + h.abort(); + } + if let Some(h) = inner.stall_soft_handle.take() { + h.abort(); + } + if let Some(h) = inner.stall_hard_handle.take() { + h.abort(); + } } diff --git a/src/remind.rs b/src/remind.rs new file mode 100644 index 000000000..471b08ff4 --- /dev/null +++ b/src/remind.rs @@ -0,0 +1,399 @@ +//! One-shot `/remind` slash command — schedules a delayed mention in a Discord channel. +//! +//! Persistence: reminders are stored in `reminders.json` and reloaded on startup. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serenity::http::Http; +use serenity::model::id::ChannelId; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{error, info, warn}; + +/// A single pending reminder. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Reminder { + pub id: String, + pub channel_id: u64, + pub sender_id: u64, + /// Raw mention strings (e.g. "<@123>", "<@&456>") + pub targets: Vec, + pub message: String, + pub fire_at: DateTime, + pub created_at: DateTime, +} + +/// Shared reminder store with file persistence. +#[derive(Clone)] +pub struct ReminderStore { + reminders: Arc>>, + path: PathBuf, +} + +impl ReminderStore { + /// Load or create the reminder store from the given path. + pub fn load(path: PathBuf) -> Self { + let reminders = match std::fs::read_to_string(&path) { + Ok(data) => serde_json::from_str(&data).unwrap_or_else(|e| { + warn!(error = %e, "failed to parse reminders.json, starting empty"); + Vec::new() + }), + Err(_) => Vec::new(), + }; + info!(count = reminders.len(), path = %path.display(), "loaded reminders"); + Self { + reminders: Arc::new(Mutex::new(reminders)), + path, + } + } + + /// Add a reminder and persist to disk. + pub async fn add(&self, reminder: Reminder) { + let snapshot = { + let mut reminders = self.reminders.lock().await; + reminders.push(reminder); + reminders.clone() + }; + self.persist(&snapshot); + } + + /// Remove a reminder by ID and persist. + pub async fn remove(&self, id: &str) { + let snapshot = { + let mut reminders = self.reminders.lock().await; + reminders.retain(|r| r.id != id); + reminders.clone() + }; + self.persist(&snapshot); + } + + /// Get all pending reminders (for startup re-scheduling). + pub async fn pending(&self) -> Vec { + self.reminders.lock().await.clone() + } + + fn persist(&self, reminders: &[Reminder]) { + match serde_json::to_string_pretty(reminders) { + Ok(data) => { + if let Some(parent) = self.path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + error!(error = %e, "failed to create reminders directory"); + return; + } + } + if let Err(e) = std::fs::write(&self.path, data) { + error!(error = %e, "failed to persist reminders.json"); + } + } + Err(e) => { + error!(error = %e, "failed to serialize reminders, skipping persist"); + } + } + } +} + +/// Maximum allowed message length for reminders. +pub const MAX_MESSAGE_LEN: usize = 1800; + +/// Maximum number of mention targets per reminder. +pub const MAX_TARGETS: usize = 10; + +/// Sanitize reminder message: neutralize @everyone/@here. +pub fn sanitize_message(msg: &str) -> String { + msg.replace("@everyone", "@\u{200b}everyone") + .replace("@here", "@\u{200b}here") +} + +/// Validate reminder message length. +pub fn validate_message(msg: &str) -> Result<(), String> { + if msg.len() > MAX_MESSAGE_LEN { + Err(format!("message too long (max {MAX_MESSAGE_LEN} characters)")) + } else { + Ok(()) + } +} + +/// Parse a human delay string like "30m", "2h", "7d" into seconds. +/// Supports combinations: "1h30m", "2d12h". +/// Range: 1m (60s) to 30d (2_592_000s). +pub fn parse_delay(input: &str) -> Result { + let s = input.trim().to_lowercase(); + if s.is_empty() { + return Err("empty delay".into()); + } + + let mut total_secs: u64 = 0; + let mut num_buf = String::new(); + + for ch in s.chars() { + if ch.is_ascii_digit() { + num_buf.push(ch); + } else { + let n: u64 = num_buf.parse().map_err(|_| format!("invalid number in delay: {input}"))?; + num_buf.clear(); + let multiplier = match ch { + 'm' => 60, + 'h' => 3600, + 'd' => 86400, + _ => return Err(format!("unknown unit '{ch}' in delay (use m/h/d)")), + }; + total_secs += n * multiplier; + } + } + + // Handle bare number (default to minutes) + if !num_buf.is_empty() { + let n: u64 = num_buf.parse().map_err(|_| format!("invalid number in delay: {input}"))?; + total_secs += n * 60; // default unit = minutes + } + + if total_secs < 60 { + return Err("minimum delay is 1m".into()); + } + if total_secs > 2_592_000 { + return Err("maximum delay is 30d".into()); + } + + Ok(total_secs) +} + +/// Format seconds into a human-readable string like "2h 30m". +pub fn format_delay(secs: u64) -> String { + let d = secs / 86400; + let h = (secs % 86400) / 3600; + let m = (secs % 3600) / 60; + let mut parts = Vec::new(); + if d > 0 { parts.push(format!("{d}d")); } + if h > 0 { parts.push(format!("{h}h")); } + if m > 0 { parts.push(format!("{m}m")); } + if parts.is_empty() { "< 1m".into() } else { parts.join(" ") } +} + +/// Spawn a tokio task that fires the reminder after the delay. +pub fn schedule_reminder( + http: Arc, + store: ReminderStore, + reminder: Reminder, +) { + let now = Utc::now(); + let delay = if reminder.fire_at > now { + (reminder.fire_at - now).to_std().unwrap_or_default() + } else { + std::time::Duration::ZERO + }; + + let id = reminder.id.clone(); + tokio::spawn(async move { + tokio::time::sleep(delay).await; + + let targets_str = reminder.targets.join(" "); + let content = format!( + "⏰ **Reminder** from <@{}>:\n\"{}\"\ncc {}", + reminder.sender_id, reminder.message, targets_str + ); + + let channel = ChannelId::new(reminder.channel_id); + match channel.say(&http, &content).await { + Ok(_) => { + info!(id = %id, channel = reminder.channel_id, "reminder fired"); + store.remove(&id).await; + } + Err(e) => { + error!(error = %e, id = %id, "failed to send reminder — keeping for retry on next restart"); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_delay_minutes() { + assert_eq!(parse_delay("5m").unwrap(), 300); + assert_eq!(parse_delay("1m").unwrap(), 60); + } + + #[test] + fn test_parse_delay_hours() { + assert_eq!(parse_delay("2h").unwrap(), 7200); + } + + #[test] + fn test_parse_delay_days() { + assert_eq!(parse_delay("1d").unwrap(), 86400); + assert_eq!(parse_delay("30d").unwrap(), 2_592_000); + } + + #[test] + fn test_parse_delay_combined() { + assert_eq!(parse_delay("1h30m").unwrap(), 5400); + assert_eq!(parse_delay("1d12h").unwrap(), 129_600); + } + + #[test] + fn test_parse_delay_bare_number_defaults_to_minutes() { + assert_eq!(parse_delay("10").unwrap(), 600); + } + + #[test] + fn test_parse_delay_too_short() { + assert!(parse_delay("0m").is_err()); + assert!(parse_delay("0h").is_err()); + } + + #[test] + fn test_parse_delay_too_long() { + assert!(parse_delay("31d").is_err()); + } + + #[test] + fn test_format_delay() { + assert_eq!(format_delay(3600), "1h"); + assert_eq!(format_delay(5400), "1h 30m"); + assert_eq!(format_delay(90000), "1d 1h"); + } + + #[test] + fn test_parse_delay_empty() { + assert!(parse_delay("").is_err()); + assert!(parse_delay(" ").is_err()); + } + + #[test] + fn test_parse_delay_invalid_unit() { + assert!(parse_delay("2x").is_err()); + assert!(parse_delay("abc").is_err()); + assert!(parse_delay("5s").is_err()); + } + + #[test] + fn test_parse_delay_case_insensitive() { + assert_eq!(parse_delay("2H").unwrap(), 7200); + assert_eq!(parse_delay("1D30M").unwrap(), 88200); + } + + #[test] + fn test_parse_delay_whitespace_trimmed() { + assert_eq!(parse_delay(" 5m ").unwrap(), 300); + } + + #[test] + fn test_parse_delay_bare_number_boundary() { + assert_eq!(parse_delay("1").unwrap(), 60); // 1 min + assert_eq!(parse_delay("30").unwrap(), 1800); // 30 min + } + + #[test] + fn test_parse_delay_exact_boundaries() { + // Exactly 1m (minimum) + assert_eq!(parse_delay("1m").unwrap(), 60); + // Exactly 30d (maximum) + assert_eq!(parse_delay("30d").unwrap(), 2_592_000); + // Just over 30d + assert!(parse_delay("30d1m").is_err()); + } + + #[test] + fn test_format_delay_zero() { + assert_eq!(format_delay(0), "< 1m"); + } + + #[test] + fn test_format_delay_pure_units() { + assert_eq!(format_delay(86400), "1d"); + assert_eq!(format_delay(120), "2m"); + assert_eq!(format_delay(7200), "2h"); + } + + #[tokio::test] + async fn test_reminder_store_add_remove() { + let dir = std::env::temp_dir().join(format!("remind_test_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("reminders.json"); + + let store = ReminderStore::load(path.clone()); + assert_eq!(store.pending().await.len(), 0); + + let r = Reminder { + id: "test-1".into(), + channel_id: 123, + sender_id: 456, + targets: vec!["<@789>".into()], + message: "hello".into(), + fire_at: Utc::now() + chrono::Duration::hours(1), + created_at: Utc::now(), + }; + + store.add(r).await; + assert_eq!(store.pending().await.len(), 1); + + store.remove("test-1").await; + assert_eq!(store.pending().await.len(), 0); + + // Verify persistence + let store2 = ReminderStore::load(path.clone()); + assert_eq!(store2.pending().await.len(), 0); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn test_reminder_store_persists_across_reload() { + let dir = std::env::temp_dir().join(format!("remind_test2_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("reminders.json"); + + let store = ReminderStore::load(path.clone()); + let r = Reminder { + id: "persist-1".into(), + channel_id: 100, + sender_id: 200, + targets: vec!["<@300>".into()], + message: "persist test".into(), + fire_at: Utc::now() + chrono::Duration::hours(2), + created_at: Utc::now(), + }; + store.add(r).await; + + // Reload from disk + let store2 = ReminderStore::load(path.clone()); + let pending = store2.pending().await; + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "persist-1"); + assert_eq!(pending[0].message, "persist test"); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_sanitize_message_strips_everyone_here() { + assert_eq!(sanitize_message("hello @everyone"), "hello @\u{200b}everyone"); + assert_eq!(sanitize_message("hey @here check"), "hey @\u{200b}here check"); + assert_eq!(sanitize_message("@everyone @here"), "@\u{200b}everyone @\u{200b}here"); + } + + #[test] + fn test_sanitize_message_no_change() { + assert_eq!(sanitize_message("normal message"), "normal message"); + assert_eq!(sanitize_message("<@123> hello"), "<@123> hello"); + } + + #[test] + fn test_validate_message_ok() { + assert!(validate_message("short message").is_ok()); + assert!(validate_message(&"a".repeat(1800)).is_ok()); + } + + #[test] + fn test_validate_message_too_long() { + assert!(validate_message(&"a".repeat(1801)).is_err()); + } + + #[test] + fn test_max_targets_constant() { + assert_eq!(MAX_TARGETS, 10); + } +} diff --git a/src/setup/config.rs b/src/setup/config.rs index 21d65e7e1..c0e7d604d 100644 --- a/src/setup/config.rs +++ b/src/setup/config.rs @@ -85,10 +85,7 @@ pub fn generate_config( }, agent: { let (command, args): (&str, Vec) = match agent_command { - "kiro" => ( - "kiro-cli", - vec!["acp".into(), "--trust-all-tools".into()], - ), + "kiro" => ("kiro-cli", vec!["acp".into(), "--trust-all-tools".into()]), "claude" => ("claude-agent-acp", vec![]), "codex" => ("codex-acp", vec![]), "gemini" => ("gemini", vec!["--acp".into()]), @@ -152,14 +149,7 @@ mod tests { #[test] fn test_generate_config_kiro_working_dir() { - let config = generate_config( - "tok", - "kiro", - vec!["ch".to_string()], - "/home/agent", - 10, - 24, - ); + let config = generate_config("tok", "kiro", vec!["ch".to_string()], "/home/agent", 10, 24); assert!(config.contains(r#"working_dir = "/home/agent""#)); assert!(config.contains("acp")); assert!(config.contains("--trust-all-tools")); diff --git a/src/setup/validate.rs b/src/setup/validate.rs index 247b1b9af..527a1a385 100644 --- a/src/setup/validate.rs +++ b/src/setup/validate.rs @@ -5,10 +5,15 @@ pub fn validate_bot_token(token: &str) -> anyhow::Result<()> { if token.is_empty() { anyhow::bail!("Token cannot be empty"); } - if !token - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '/' || c == '*' || c == '=') - { + if !token.chars().all(|c| { + c.is_ascii_alphanumeric() + || c == '-' + || c == '.' + || c == '_' + || c == '/' + || c == '*' + || c == '=' + }) { anyhow::bail!( "Token must only contain ASCII letters, numbers, dash, period, underscore, slash, or equals" ); diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index e8751172d..f5a789609 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -154,7 +154,11 @@ fn print_box(lines: &[&str]) { .unwrap_or(60); let width = width.clamp(60, 76); println!(); - cprintln!(C.cyan, "{}", "╔".to_string() + &BORDER.to_string().repeat(width + 2) + "╗"); + cprintln!( + C.cyan, + "{}", + "╔".to_string() + &BORDER.to_string().repeat(width + 2) + "╗" + ); for line in lines { let padded = format!(" {: anyhow::Result> { println!(); if guilds.is_empty() { - cprintln!( - C.yellow, - " No servers found. Enter channel IDs manually." - ); + cprintln!(C.yellow, " No servers found. Enter channel IDs manually."); let input = prompt(" Channel ID(s), comma-separated"); let ids: Vec = input .split(',') @@ -342,21 +347,11 @@ fn section_channels(client: &DiscordClient) -> anyhow::Result> { return Ok(ids); } - let channel_names: Vec = channels - .iter() - .map(|(_, n, _)| format!("#{}", n)) - .collect(); - let channel_names_refs: Vec<&str> = channel_names - .iter() - .map(|s| s.as_str()) - .collect(); + let channel_names: Vec = channels.iter().map(|(_, n, _)| format!("#{}", n)).collect(); + let channel_names_refs: Vec<&str> = channel_names.iter().map(|s| s.as_str()).collect(); - let selected = - prompt_checklist(" Select channels (by number):", &channel_names_refs); - let selected_ids: Vec = selected - .iter() - .map(|&i| channels[i].0.clone()) - .collect(); + let selected = prompt_checklist(" Select channels (by number):", &channel_names_refs); + let selected_ids: Vec = selected.iter().map(|&i| channels[i].0.clone()).collect(); println!(); cprintln!(C.green, " Selected {} channel(s)", selected_ids.len()); @@ -408,12 +403,7 @@ fn section_agent() -> (String, String, bool) { let working_dir = prompt_default(" Working directory", default_dir); - cprintln!( - C.green, - " Agent: {} | Working dir: {}", - agent, - working_dir - ); + cprintln!(C.green, " Agent: {} | Working dir: {}", agent, working_dir); println!(); (agent.to_string(), working_dir, is_local) @@ -428,9 +418,7 @@ fn section_pool() -> (usize, u64) { cprintln!(C.bold, "--- Step 4: Session Pool ---"); println!(); - let max_sessions: usize = prompt_default(" Max sessions", "10") - .parse() - .unwrap_or(10); + let max_sessions: usize = prompt_default(" Max sessions", "10").parse().unwrap_or(10); let ttl_hours: u64 = prompt_default(" Session TTL (hours)", "24") .parse() .unwrap_or(24); @@ -457,9 +445,7 @@ fn section_preview_and_save(config_content: &str, output_path: &PathBuf) -> anyh println!("{}", mask_bot_token(config_content)); println!(); - if output_path.exists() - && !prompt_yes_no(" File exists. Overwrite?", false) - { + if output_path.exists() && !prompt_yes_no(" File exists. Overwrite?", false) { println!(" Saving cancelled."); return Ok(()); } @@ -517,7 +503,10 @@ fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { if is_local { match agent { "kiro" => { - cprintln!(C.cyan, " 1. Install kiro-cli (see https://kiro.dev for installer)"); + cprintln!( + C.cyan, + " 1. Install kiro-cli (see https://kiro.dev for installer)" + ); cprintln!(C.cyan, " 2. Authenticate:"); println!(" kiro-cli login --use-device-flow"); } @@ -536,7 +525,10 @@ fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { "gemini" => { cprintln!(C.cyan, " 1. Install Gemini CLI:"); println!(" npm install -g @google/gemini-cli"); - cprintln!(C.cyan, " 2. Authenticate via Google OAuth, or set GEMINI_API_KEY in config.toml"); + cprintln!( + C.cyan, + " 2. Authenticate via Google OAuth, or set GEMINI_API_KEY in config.toml" + ); } _ => {} } @@ -552,22 +544,28 @@ fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { println!(); cprintln!(C.cyan, " 1. Deploy with Helm (or your preferred method):"); println!(" helm install openab openab/openab \\"); - println!(" --set agents.{}.discord.botToken=\"$BOT_TOKEN\"", agent); + println!( + " --set agents.{}.discord.botToken=\"$BOT_TOKEN\"", + agent + ); println!(); - cprintln!(C.cyan, " 2. Authenticate inside the pod (first time only):"); + cprintln!( + C.cyan, + " 2. Authenticate inside the pod (first time only):" + ); match agent { "kiro" => println!( " kubectl exec -it deployment/openab-kiro -- kiro-cli login --use-device-flow" ), - "claude" => println!( - " kubectl exec -it deployment/openab-claude -- claude auth login" - ), + "claude" => { + println!(" kubectl exec -it deployment/openab-claude -- claude auth login") + } "codex" => println!( " kubectl exec -it deployment/openab-codex -- codex login --device-auth" ), - "gemini" => println!( - " Set GEMINI_API_KEY via secret, or exec into the pod for OAuth" - ), + "gemini" => { + println!(" Set GEMINI_API_KEY via secret, or exec into the pod for OAuth") + } _ => {} } println!(); @@ -605,10 +603,7 @@ pub fn run_setup(output_path: Option) -> anyhow::Result<()> { println!(); let bot_token = prompt_password(" Bot Token (or press Enter to skip)"); if bot_token.is_empty() { - cprintln!( - C.yellow, - " Skipped. Set bot_token manually in config.toml" - ); + cprintln!(C.yellow, " Skipped. Set bot_token manually in config.toml"); println!(); cprintln!( C.green, @@ -632,11 +627,7 @@ pub fn run_setup(output_path: Option) -> anyhow::Result<()> { vec![] } Err(e) => { - cprintln!( - C.yellow, - " Channel fetch failed: {}. Enter manually.", - e - ); + cprintln!(C.yellow, " Channel fetch failed: {}. Enter manually.", e); let input = prompt(" Channel ID(s), comma-separated"); let ids: Vec = input .split(',') diff --git a/src/slack.rs b/src/slack.rs index 979db52b9..ff3452ba7 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -1,5 +1,5 @@ use crate::acp::ContentBlock; -use crate::adapter::{ChatAdapter, ChannelRef, MessageRef, SenderContext}; +use crate::adapter::{ChannelRef, ChatAdapter, MessageRef, SenderContext}; use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity}; use crate::config::{AllowBots, AllowUsers, SttConfig}; use crate::media; @@ -70,7 +70,11 @@ pub struct SlackAdapter { } impl SlackAdapter { - pub fn new(bot_token: String, session_ttl: std::time::Duration, _allow_bot_messages: AllowBots) -> Self { + pub fn new( + bot_token: String, + session_ttl: std::time::Duration, + _allow_bot_messages: AllowBots, + ) -> Self { Self { client: reqwest::Client::new(), bot_token, @@ -93,20 +97,29 @@ impl SlackAdapter { /// depend on fetching thread history. Idempotent. async fn note_other_bot_in_thread(&self, thread_ts: &str) { let mut cache = self.multibot_threads.lock().await; - cache.entry(thread_ts.to_string()).or_insert_with(tokio::time::Instant::now); + cache + .entry(thread_ts.to_string()) + .or_insert_with(tokio::time::Instant::now); enforce_cache_bounds(&mut cache, self.session_ttl); } /// Get the bot's own Slack user ID (cached after first call). async fn get_bot_user_id(&self) -> Option<&str> { - self.bot_user_id.get_or_try_init(|| async { - let resp = self.api_post("auth.test", serde_json::json!({})).await - .map_err(|e| anyhow!("auth.test failed: {e}"))?; - resp["user_id"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| anyhow!("no user_id in auth.test response")) - }).await.ok().map(|s| s.as_str()) + self.bot_user_id + .get_or_try_init(|| async { + let resp = self + .api_post("auth.test", serde_json::json!({})) + .await + .map_err(|e| anyhow!("auth.test failed: {e}"))?; + resp["user_id"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("no user_id in auth.test response")) + }) + .await + .inspect_err(|e| warn!(error = %e, "bot user ID unavailable; mention detection may suppress bot messages under Mentions mode")) + .ok() + .map(|s| s.as_str()) } async fn api_post(&self, method: &str, body: serde_json::Value) -> Result { @@ -160,10 +173,7 @@ impl SlackAdapter { } let resp = self - .api_post( - "users.info", - serde_json::json!({ "user": user_id }), - ) + .api_post("users.info", serde_json::json!({ "user": user_id })) .await .ok()?; let user = resp.get("user")?; @@ -176,9 +186,7 @@ impl SlackAdapter { .get("real_name") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()); - let name = user - .get("name") - .and_then(|v| v.as_str()); + let name = user.get("name").and_then(|v| v.as_str()); let resolved = display.or(real).or(name)?.to_string(); // Cache the result @@ -193,6 +201,10 @@ impl SlackAdapter { /// Resolve a Bot ID (B...) to Bot User ID (U...) via bots.info API. /// Cached permanently (bot IDs don't change). async fn resolve_bot_user_id(&self, bot_id: &str) -> Option { + if bot_id.is_empty() { + return None; + } + { let cache = self.bot_id_cache.lock().await; if let Some(user_id) = cache.get(bot_id) { @@ -203,20 +215,39 @@ impl SlackAdapter { let resp = self .api_post("bots.info", serde_json::json!({ "bot": bot_id })) .await + .inspect_err(|e| { + warn!( + bot_id, + error = %e, + "failed to resolve Slack bot ID via bots.info" + ) + }) .ok()?; - let user_id = resp.get("bot")? - .get("user_id")? - .as_str()? - .to_string(); - - self.bot_id_cache.lock().await.insert( - bot_id.to_string(), - user_id.clone(), - ); + let user_id = resp.get("bot")?.get("user_id")?.as_str()?.to_string(); + + self.bot_id_cache + .lock() + .await + .insert(bot_id.to_string(), user_id.clone()); Some(user_id) } + async fn trusted_bot_ids_contains( + &self, + trusted_bot_ids: &HashSet, + event_bot_id: &str, + ) -> bool { + if trusted_bot_ids.is_empty() { + return true; + } + if bot_id_matches_trusted(trusted_bot_ids, event_bot_id, None) { + return true; + } + let resolved = self.resolve_bot_user_id(event_bot_id).await; + bot_id_matches_trusted(trusted_bot_ids, event_bot_id, resolved.as_deref()) + } + /// Check whether the bot has participated in a Slack thread and whether /// other bots have also posted in it. /// Returns `(involved, other_bot_present)`. @@ -226,11 +257,15 @@ impl SlackAdapter { async fn bot_participated_in_thread(&self, channel: &str, thread_ts: &str) -> (bool, bool) { let cached_involved = { let cache = self.participated_threads.lock().await; - cache.get(thread_ts).is_some_and(|ts| ts.elapsed() < self.session_ttl) + cache + .get(thread_ts) + .is_some_and(|ts| ts.elapsed() < self.session_ttl) }; let cached_multibot = { let cache = self.multibot_threads.lock().await; - cache.get(thread_ts).is_some_and(|ts| ts.elapsed() < self.session_ttl) + cache + .get(thread_ts) + .is_some_and(|ts| ts.elapsed() < self.session_ttl) }; // Eager multibot detection from message events populates the cache @@ -266,20 +301,22 @@ impl SlackAdapter { return (false, false); } }; - let Some(messages) = json["messages"].as_array() else { return (false, false) }; + let Some(messages) = json["messages"].as_array() else { + return (false, false); + }; let parent_mentions_bot = messages .first() .and_then(|m| m["text"].as_str()) - .is_some_and(|text| text.contains(&format!("<@{bot_id}>"))); + .is_some_and(|text| text_mentions_uid(text, bot_id)); let bot_posted = messages.iter().any(|m| m["user"].as_str() == Some(bot_id)); let involved = parent_mentions_bot || bot_posted; let other_bot_present = cached_multibot || messages.iter().any(|m| { - let is_bot_msg = m["bot_id"].is_string() - || m["subtype"].as_str() == Some("bot_message"); + let is_bot_msg = + m["bot_id"].is_string() || m["subtype"].as_str() == Some("bot_message"); is_bot_msg && m["user"].as_str() != Some(bot_id) }); @@ -356,7 +393,6 @@ impl ChatAdapter for SlackAdapter { }) } - async fn create_thread( &self, channel: &ChannelRef, @@ -375,15 +411,16 @@ impl ChatAdapter for SlackAdapter { async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { let name = unicode_to_slack_emoji(emoji); - match self.api_post( - "reactions.add", - serde_json::json!({ - "channel": msg.channel.channel_id, - "timestamp": msg.message_id, - "name": name, - }), - ) - .await + match self + .api_post( + "reactions.add", + serde_json::json!({ + "channel": msg.channel.channel_id, + "timestamp": msg.message_id, + "name": name, + }), + ) + .await { Ok(_) => Ok(()), Err(e) if e.to_string().contains("already_reacted") => Ok(()), @@ -393,15 +430,16 @@ impl ChatAdapter for SlackAdapter { async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { let name = unicode_to_slack_emoji(emoji); - match self.api_post( - "reactions.remove", - serde_json::json!({ - "channel": msg.channel.channel_id, - "timestamp": msg.message_id, - "name": name, - }), - ) - .await + match self + .api_post( + "reactions.remove", + serde_json::json!({ + "channel": msg.channel.channel_id, + "timestamp": msg.message_id, + "name": name, + }), + ) + .await { Ok(_) => Ok(()), Err(e) if e.to_string().contains("no_reaction") => Ok(()), @@ -527,11 +565,11 @@ pub async fn run_slack_adapter( AllowBots::Mentions | AllowBots::All => { if !trusted_bot_ids.is_empty() { let event_bot_id = event["bot_id"].as_str().unwrap_or(""); - let resolved = adapter.resolve_bot_user_id(event_bot_id).await; - let is_trusted = resolved.as_ref() - .is_some_and(|uid| trusted_bot_ids.contains(uid.as_str())); + let is_trusted = adapter + .trusted_bot_ids_contains(&trusted_bot_ids, event_bot_id) + .await; if !is_trusted { - debug!(event_bot_id, resolved = ?resolved, "bot not in trusted_bot_ids, ignoring app_mention"); + debug!(event_bot_id, "bot not in trusted_bot_ids, ignoring app_mention"); continue; } } @@ -570,7 +608,7 @@ pub async fn run_slack_adapter( let bot_uid_opt = adapter.get_bot_user_id().await.map(|s| s.to_string()); let mentions_bot = bot_uid_opt .as_ref() - .is_some_and(|bot_uid| msg_text.contains(&format!("<@{bot_uid}>"))); + .is_some_and(|bot_uid| text_mentions_uid(msg_text, bot_uid)); let is_dm = channel_id.starts_with('D'); let event_user_id = event["user"].as_str(); let is_own_bot_msg = is_bot @@ -702,12 +740,11 @@ pub async fn run_slack_adapter( } // Check trusted_bot_ids if !trusted_bot_ids.is_empty() { - let resolved = adapter.resolve_bot_user_id(event_bot_id).await; - let is_trusted = resolved - .as_ref() - .is_some_and(|uid| trusted_bot_ids.contains(uid.as_str())); + let is_trusted = adapter + .trusted_bot_ids_contains(&trusted_bot_ids, event_bot_id) + .await; if !is_trusted { - debug!(event_bot_id, resolved = ?resolved, "bot not in trusted_bot_ids, ignoring"); + debug!(event_bot_id, "bot not in trusted_bot_ids, ignoring"); continue; } } @@ -867,8 +904,8 @@ async fn handle_message( Some(u) => u.to_string(), None => return, }; - let is_bot_msg = event["bot_id"].is_string() - || event["subtype"].as_str() == Some("bot_message"); + let is_bot_msg = + event["bot_id"].is_string() || event["subtype"].as_str() == Some("bot_message"); let text = match event["text"].as_str() { Some(t) => t.to_string(), None => return, @@ -920,8 +957,10 @@ async fn handle_message( const TEXT_FILE_COUNT_CAP: u32 = 5; let mut extra_blocks = Vec::new(); + let mut echo_entries: Vec = Vec::new(); let mut text_file_bytes: u64 = 0; let mut text_file_count: u32 = 0; + let mut failed_image_files: Vec = Vec::new(); if let Some(files) = files { for file in files { @@ -938,18 +977,34 @@ async fn handle_message( if media::is_audio_mime(mimetype) { if stt_config.enabled { - if let Some(transcript) = media::download_and_transcribe( + match media::download_and_transcribe( url, filename, mimetype, size, stt_config, Some(bot_token), - ).await { - debug!(filename, chars = transcript.len(), "voice transcript injected"); - extra_blocks.insert(0, ContentBlock::Text { - text: format!("[Voice message transcript]: {transcript}"), - }); + ) + .await + { + Some(transcript) => { + debug!( + filename, + chars = transcript.len(), + "voice transcript injected" + ); + extra_blocks.insert( + 0, + ContentBlock::Text { + text: format!("[Voice message transcript]: {transcript}"), + }, + ); + echo_entries.push(crate::stt::EchoEntry::Success(transcript)); + } + None => { + warn!(filename, "STT failed for voice attachment"); + echo_entries.push(crate::stt::EchoEntry::Failed); + } } } else { debug!(filename, "skipping audio attachment (STT disabled)"); @@ -967,7 +1022,11 @@ async fn handle_message( } } else if media::is_text_file(filename, Some(mimetype)) { if text_file_count >= TEXT_FILE_COUNT_CAP { - debug!(filename, count = text_file_count, "text file count cap reached, skipping"); + debug!( + filename, + count = text_file_count, + "text file count cap reached, skipping" + ); continue; } // Pre-check with Slack-reported size as a fast path when the @@ -976,15 +1035,16 @@ async fn handle_message( // authoritative cap check happens after download using // `actual_bytes`. if size > 0 && text_file_bytes + size > TEXT_TOTAL_CAP { - debug!(filename, total = text_file_bytes, "text attachments total exceeds 1MB cap, skipping remaining"); + debug!( + filename, + total = text_file_bytes, + "text attachments total exceeds 1MB cap, skipping remaining" + ); continue; } - if let Some((block, actual_bytes)) = media::download_and_read_text_file( - url, - filename, - size, - Some(bot_token), - ).await { + if let Some((block, actual_bytes)) = + media::download_and_read_text_file(url, filename, size, Some(bot_token)).await + { if text_file_bytes + actual_bytes > TEXT_TOTAL_CAP { debug!( filename, @@ -999,19 +1059,79 @@ async fn handle_message( debug!(filename, "adding text file attachment"); extra_blocks.push(block); } - } else if let Some(block) = media::download_and_encode_image( - url, - Some(mimetype), - filename, - size, - Some(bot_token), - ).await { - debug!(filename, "adding image attachment"); - extra_blocks.push(block); + } else { + match media::download_and_encode_image( + url, + Some(mimetype), + filename, + size, + Some(bot_token), + ) + .await + { + Ok(block) => { + debug!(filename, "adding image attachment"); + extra_blocks.push(block); + } + Err(media::MediaFetchError::NotAnImage) => {} + Err(media::MediaFetchError::SizeExceeded { actual, limit }) => { + warn!(filename, actual, limit, "image exceeds size limit"); + failed_image_files.push(filename.to_string()); + } + Err( + media::MediaFetchError::UnsupportedResponseType { .. } + | media::MediaFetchError::InvalidImageBody { .. }, + ) => { + warn!( + filename, + "image validation failed; server may have returned non-image content" + ); + failed_image_files.push(filename.to_string()); + } + Err(media::MediaFetchError::ProcessingFailed(ref e)) => { + warn!(filename, error = %e, "image post-processing failed"); + failed_image_files.push(filename.to_string()); + } + Err(media::MediaFetchError::HttpStatus(status)) + if status.is_client_error() => + { + warn!(filename, %status, "image download denied"); + failed_image_files.push(filename.to_string()); + } + Err(e) => { + warn!(filename, error = %e, "image download failed"); + failed_image_files.push(filename.to_string()); + } + } } } } + // Notify user if any images couldn't be processed. + if !failed_image_files.is_empty() { + let warn_channel = ChannelRef { + platform: "slack".into(), + channel_id: channel_id.clone(), + thread_id: thread_ts.clone().or_else(|| Some(ts.clone())), + parent_id: None, + origin_event_id: None, + }; + let file_list = failed_image_files + .iter() + .map(|n| sanitize_slack_filename(n)) + .collect::>() + .join("`, `"); + let msg = format!( + ":warning: I couldn't process the file(s) you shared (`{file_list}`). \ + This can happen when the bot lacks the `files:read` OAuth scope, \ + the file format isn't supported (PNG/JPEG/GIF/WebP only), \ + or the file is too large." + ); + if let Err(e) = adapter.send_message(&warn_channel, &msg).await { + warn!(error = %e, "failed to send image validation warning to user"); + } + } + // Resolve Slack display name (best-effort, fallback to user_id) let display_name = adapter .resolve_user_name(&user_id) @@ -1028,6 +1148,8 @@ async fn handle_message( thread_id: thread_ts.clone(), is_bot: is_bot_msg, timestamp: Some(crate::timestamp::slack_ts_to_iso8601(&ts)), + message_id: Some(ts.clone()), + receiver_id: bot_id.map(|id| id.to_string()), }; let trigger_msg = MessageRef { @@ -1065,9 +1187,23 @@ async fn handle_message( let adapter_dyn: Arc = adapter.clone(); let other_bot_present = { let cache = adapter.multibot_threads.lock().await; - thread_channel.thread_id.as_deref() - .is_some_and(|ts| cache.get(ts).is_some_and(|inst| inst.elapsed() < adapter.session_ttl)) + thread_channel.thread_id.as_deref().is_some_and(|ts| { + cache + .get(ts) + .is_some_and(|inst| inst.elapsed() < adapter.session_ttl) + }) }; + + // Best-effort echo before the agent reply so the user can verify STT. + crate::stt::post_echo( + &adapter_dyn, + &thread_channel, + &trigger_msg, + &echo_entries, + stt_config, + ) + .await; + let thread_id = thread_channel .thread_id .as_deref() @@ -1092,15 +1228,41 @@ async fn handle_message( } } -/// Strip only the bot's own `<@BOT_UID>` trigger mention. +/// Strip all occurrences of the bot's own `<@BOT_UID>` or `<@BOT_UID|handle>` mention. /// Other users' mentions stay intact so the LLM can @-mention them back. /// If the bot UID isn't known, fall back to returning the text trimmed — /// safer than stripping all mentions and losing user addressability. fn resolve_slack_mentions(text: &str, bot_id: Option<&str>) -> String { - match bot_id { - Some(id) => text.replace(&format!("<@{id}>"), "").trim().to_string(), - None => text.trim().to_string(), + let Some(id) = bot_id else { + return text.trim().to_string(); + }; + let prefix = format!("<@{id}"); + let mut out = String::with_capacity(text.len()); + let mut s = text; + while let Some(pos) = s.find(&prefix) { + let after = &s[pos + prefix.len()..]; + match after.as_bytes().first() { + Some(b'>') => { + out.push_str(&s[..pos]); + s = &after[1..]; + } + Some(b'|') => { + if let Some(close) = after.find('>') { + out.push_str(&s[..pos]); + s = &after[close + 1..]; + } else { + out.push_str(&s[..pos + prefix.len()]); + s = after; + } + } + _ => { + out.push_str(&s[..pos + prefix.len()]); + s = after; + } + } } + out.push_str(s); + out.trim().to_string() } /// Pick the best download URL for a Slack file object. `url_private_download` @@ -1113,11 +1275,49 @@ fn slack_file_download_url(file: &serde_json::Value) -> &str { .unwrap_or("") } -/// Strip MIME parameters like `; charset=utf-8` so type-detection helpers see -/// the bare media type. Slack occasionally sends mimetypes like -/// `text/plain; charset=utf-8`; `media::is_text_file` expects the bare form. +/// Strip MIME parameters so type-detection helpers see the bare media type. +/// Delegates to media::strip_mime_params (single source of truth). +/// Needed because Slack occasionally sends `text/plain; charset=utf-8` and +/// `media::is_text_file` expects the bare form. fn strip_mime_params(mimetype: &str) -> &str { - mimetype.split(';').next().unwrap_or(mimetype).trim() + media::strip_mime_params(mimetype) +} + +/// Sanitize a filename for safe embedding in a Slack mrkdwn message. +/// +/// Ampersands (`&`), backticks (`` ` ``), and angle brackets (`<`, `>`) are escaped. +/// `&` is encoded as `&` first because Slack decodes HTML entities before parsing +/// mrkdwn — a filename like `<@here>` would otherwise round-trip back to +/// `<@here>` and trigger a mention ping. Backticks and angle brackets are Slack +/// mrkdwn delimiters; without escaping, `` or `` `<@U123>` `` would render +/// as mentions or @-here pings. +pub(crate) fn sanitize_slack_filename(s: &str) -> String { + s.replace('&', "&").replace('`', "'").replace('<', "(").replace('>', ")") +} + +/// Returns `true` if `text` contains a Slack user mention for `uid`. +/// +/// Accepts both `<@U...>` (bare) and `<@U...|handle>` (labelled) wire forms. +/// Slack (and bots addressing peers) can emit the labelled form; `<@UID>` is +/// not a substring of `<@UID|handle>`, so a bare `contains("<@UID>")` silently +/// misses it. +fn text_mentions_uid(text: &str, uid: &str) -> bool { + let prefix = format!("<@{uid}"); + text.match_indices(&prefix) + .any(|(i, _)| matches!(text.as_bytes().get(i + prefix.len()), Some(b'>') | Some(b'|'))) +} + +fn bot_id_matches_trusted( + trusted_bot_ids: &HashSet, + event_bot_id: &str, + resolved_user_id: Option<&str>, +) -> bool { + if event_bot_id.is_empty() { + return false; + } + + trusted_bot_ids.contains(event_bot_id) + || resolved_user_id.is_some_and(|uid| trusted_bot_ids.contains(uid)) } /// True only when a Slack non-bot event represents a real user message @@ -1157,12 +1357,12 @@ fn markdown_to_mrkdwn(text: &str) -> String { LazyLock::new(|| regex::Regex::new(r"```\w+\n").unwrap()); // Order: bold first (** → placeholder), then italic (* → _), then restore bold - let text = BOLD_RE.replace_all(text, "\x01$1\x02"); // **bold** → \x01bold\x02 - let text = ITALIC_RE.replace_all(&text, "_${1}_"); // *italic* → _italic_ - // Restore bold: \x01bold\x02 → *bold* + let text = BOLD_RE.replace_all(text, "\x01$1\x02"); // **bold** → \x01bold\x02 + let text = ITALIC_RE.replace_all(&text, "_${1}_"); // *italic* → _italic_ + // Restore bold: \x01bold\x02 → *bold* let text = text.replace(['\x01', '\x02'], "*"); - let text = LINK_RE.replace_all(&text, "<$2|$1>"); // [text](url) → - let text = HEADING_RE.replace_all(&text, "*$1*"); // # heading → *heading* + let text = LINK_RE.replace_all(&text, "<$2|$1>"); // [text](url) → + let text = HEADING_RE.replace_all(&text, "*$1*"); // # heading → *heading* let text = CODE_BLOCK_LANG_RE.replace_all(&text, "```\n"); // ```rust → ``` text.into_owned() } @@ -1202,6 +1402,89 @@ mod tests { assert_eq!(out, "<@U1BOT> hi <@U2ALICE>"); } + /// Labelled form of another user's mention (`<@UID|handle>`) is preserved. + #[test] + fn resolve_mentions_preserves_labelled_other_user_mention() { + let out = resolve_slack_mentions("<@U1BOT> say hi to <@U2ALICE|alice>", Some("U1BOT")); + assert_eq!(out, "say hi to <@U2ALICE|alice>"); + } + + /// Labelled form `<@UID|handle>` is stripped the same as bare form. + #[test] + fn resolve_mentions_strips_labelled_bot_mention() { + let out = resolve_slack_mentions("<@U1BOT|my-bot> hello", Some("U1BOT")); + assert_eq!(out, "hello"); + } + + /// Labelled form mid-sentence is stripped and surrounding text preserved. + #[test] + fn resolve_mentions_strips_labelled_mid_sentence() { + let out = resolve_slack_mentions("please ask <@U1BOT|handle> to run", Some("U1BOT")); + assert_eq!(out, "please ask to run"); + } + + /// Mixed bare and labelled forms of the same UID in one string are both stripped. + #[test] + fn resolve_mentions_strips_mixed_bare_and_labelled() { + let out = resolve_slack_mentions("<@U1BOT> and <@U1BOT|handle> run", Some("U1BOT")); + assert_eq!(out, "and run"); + } + + /// Malformed unclosed `<@UID|label` (no closing `>`) is preserved verbatim. + #[test] + fn resolve_mentions_malformed_unclosed_label_preserved() { + let out = resolve_slack_mentions("ask <@U1BOT|nolabel to run", Some("U1BOT")); + assert!(out.contains("<@U1BOT")); + } + + #[test] + fn resolve_mentions_preserves_longer_uid_prefix() { + let out = resolve_slack_mentions("<@U1BOTX> hello", Some("U1BOT")); + assert_eq!(out, "<@U1BOTX> hello"); + } + + // --- text_mentions_uid tests --- + + #[test] + fn mentions_uid_bare_form() { + assert!(text_mentions_uid("<@U123BOT> hello", "U123BOT")); + } + + #[test] + fn mentions_uid_labelled_form() { + assert!(text_mentions_uid("<@U123BOT|my-bot> hello", "U123BOT")); + } + + #[test] + fn mentions_uid_labelled_form_mid_sentence() { + assert!(text_mentions_uid("please ask <@U123BOT|handle> to run", "U123BOT")); + } + + #[test] + fn mentions_uid_no_match() { + assert!(!text_mentions_uid("hello world", "U123BOT")); + } + + #[test] + fn mentions_uid_no_false_positive_on_uid_prefix() { + assert!(!text_mentions_uid("<@U123BOT> hello", "U123")); + } + + #[test] + fn mentions_uid_second_mention_matches() { + assert!(text_mentions_uid("<@U999OTHER> and <@U123BOT>", "U123BOT")); + } + + #[test] + fn mentions_uid_empty_label_form() { + assert!(text_mentions_uid("<@U123BOT|> hello", "U123BOT")); + } + + #[test] + fn mentions_uid_truncated_no_closing_delimiter() { + assert!(!text_mentions_uid("<@U123BOT", "U123BOT")); + } + // --- is_plain_user_message tests (regression for openabdev/openab#497 parity) --- /// Empty message text never counts as a user message (regardless of subtype). @@ -1287,6 +1570,45 @@ mod tests { assert_eq!(slack_file_download_url(&file), ""); } + // --- sanitize_slack_filename tests --- + + #[test] + fn sanitize_leaves_normal_filename_unchanged() { + assert_eq!(sanitize_slack_filename("photo.png"), "photo.png"); + assert_eq!(sanitize_slack_filename("my file (1).jpg"), "my file (1).jpg"); + } + + #[test] + fn sanitize_replaces_backtick() { + assert_eq!(sanitize_slack_filename("file`name.png"), "file'name.png"); + } + + #[test] + fn sanitize_replaces_angle_brackets() { + // Angle brackets are Slack mrkdwn delimiters; they must not pass through. + assert_eq!(sanitize_slack_filename("<@U123>"), "(@U123)"); + assert_eq!(sanitize_slack_filename(""), "(!here)"); + } + + #[test] + fn sanitize_combined_injection_attempt() { + // A filename constructed to inject a Slack @here ping. + assert_eq!( + sanitize_slack_filename("``"), + "'(!here)'" + ); + } + + #[test] + fn sanitize_escapes_ampersand_before_angle_brackets() { + // Slack mrkdwn decodes HTML entities before markup parsing. + // "<@here>" would round-trip back to "<@here>" and trigger a mention + // ping if & is not escaped. The & must be escaped first so downstream + // Slack entity decoding cannot reconstruct a mrkdwn delimiter. + assert_eq!(sanitize_slack_filename("<@here>"), "&lt;@here&gt;"); + assert_eq!(sanitize_slack_filename("file&name.png"), "file&name.png"); + } + // --- strip_mime_params tests --- /// MIME with charset parameter strips to bare media type. @@ -1313,13 +1635,49 @@ mod tests { assert_eq!(strip_mime_params(" text/plain "), "text/plain"); } + // --- bot_id_matches_trusted tests --- + + #[test] + fn trusted_bot_ids_accepts_raw_slack_bot_id() { + let trusted = HashSet::from(["B123BOT".to_string()]); + assert!(bot_id_matches_trusted(&trusted, "B123BOT", None)); + } + + #[test] + fn trusted_bot_ids_accepts_resolved_bot_user_id() { + let trusted = HashSet::from(["U123BOT".to_string()]); + assert!(bot_id_matches_trusted( + &trusted, + "B123BOT", + Some("U123BOT") + )); + } + + #[test] + fn trusted_bot_ids_rejects_unknown_bot_when_resolution_fails() { + let trusted = HashSet::from(["U123BOT".to_string()]); + assert!(!bot_id_matches_trusted(&trusted, "B999BOT", None)); + } + + #[test] + fn trusted_bot_ids_rejects_empty_event_bot_id() { + let trusted = HashSet::from(["".to_string()]); + assert!(!bot_id_matches_trusted(&trusted, "", None)); + } + /// Per-thread streaming: ON by default, OFF when another bot is present (#534). #[test] fn streaming_per_thread() { let ttl = std::time::Duration::from_secs(300); let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Mentions); - assert!(adapter.use_streaming(false), "should stream when no other bot"); - assert!(!adapter.use_streaming(true), "should NOT stream when other bot present"); + assert!( + adapter.use_streaming(false), + "should stream when no other bot" + ); + assert!( + !adapter.use_streaming(true), + "should NOT stream when other bot present" + ); } } diff --git a/src/stt.rs b/src/stt.rs index 122db9b68..d266e6117 100644 --- a/src/stt.rs +++ b/src/stt.rs @@ -1,6 +1,74 @@ +use crate::adapter::{ChannelRef, ChatAdapter, MessageRef}; use crate::config::SttConfig; use reqwest::multipart; -use tracing::{debug, error}; +use std::sync::Arc; +use tracing::{debug, error, warn}; + +/// Outcome of attempting STT on a single audio attachment. +/// Used by adapters to feed `post_echo`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EchoEntry { + Success(String), + Failed, +} + +/// Render a list of echo entries as a single multi-line quoted block. +/// Returns `None` for empty input so callers can short-circuit. +/// +/// Each entry produces one `> 🎤 …` line. Internal newlines inside a +/// transcript are flattened to spaces so each entry occupies exactly one +/// visual line — Discord and Slack both stop applying `>` at the next `\n`. +pub fn format_echo_message(entries: &[EchoEntry]) -> Option { + if entries.is_empty() { + return None; + } + let mut lines = Vec::with_capacity(entries.len()); + for e in entries { + match e { + EchoEntry::Success(text) => { + let flat = text.replace(['\n', '\r'], " "); + lines.push(format!("> 🎤 {flat}")); + } + EchoEntry::Failed => { + lines.push("> 🎤 (transcription failed)".to_string()); + } + } + } + Some(lines.join("\n")) +} + +/// Post a transcript echo to the thread and add a ⚠️ reaction for any failed +/// entries. No-op when the config disables echoing or when `entries` is empty. +/// +/// Errors from the adapter (send/reaction) are logged and swallowed — the +/// echo is best-effort and must never block the agent reply. +pub async fn post_echo( + adapter: &Arc, + thread: &ChannelRef, + trigger: &MessageRef, + entries: &[EchoEntry], + cfg: &SttConfig, +) { + if !cfg.echo_transcript { + return; + } + let Some(body) = format_echo_message(entries) else { + return; + }; + if let Err(e) = adapter.send_message(thread, &body).await { + warn!(error = %e, platform = adapter.platform(), "failed to send STT echo message"); + } + for entry in entries { + if matches!(entry, EchoEntry::Failed) { + if let Err(e) = adapter.add_reaction(trigger, "⚠️").await { + warn!(error = %e, platform = adapter.platform(), "failed to add STT failure reaction"); + } + // Add only one reaction even with multiple failures — emoji reactions + // are unique per (user, emoji, message), so additional calls are no-ops. + break; + } + } +} /// Transcribe audio bytes via an OpenAI-compatible `/audio/transcriptions` endpoint. pub async fn transcribe( @@ -10,7 +78,10 @@ pub async fn transcribe( filename: String, mime_type: &str, ) -> Option { - let url = format!("{}/audio/transcriptions", cfg.base_url.trim_end_matches('/')); + let url = format!( + "{}/audio/transcriptions", + cfg.base_url.trim_end_matches('/') + ); let file_part = multipart::Part::bytes(audio_bytes) .file_name(filename) @@ -59,3 +130,225 @@ pub async fn transcribe( debug!(chars = text.len(), "STT transcription complete"); Some(text) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_single_success_entry() { + let entries = vec![EchoEntry::Success("hello world".into())]; + let out = format_echo_message(&entries).expect("non-empty input → Some"); + assert_eq!(out, "> 🎤 hello world"); + } + + #[test] + fn format_single_failure_entry() { + let entries = vec![EchoEntry::Failed]; + let out = format_echo_message(&entries).expect("non-empty input → Some"); + assert_eq!(out, "> 🎤 (transcription failed)"); + } + + #[test] + fn format_multiple_mixed_entries() { + let entries = vec![ + EchoEntry::Success("first".into()), + EchoEntry::Failed, + EchoEntry::Success("third".into()), + ]; + let out = format_echo_message(&entries).expect("non-empty input → Some"); + assert_eq!(out, "> 🎤 first\n> 🎤 (transcription failed)\n> 🎤 third"); + } + + #[test] + fn format_empty_entries_returns_none() { + let entries: Vec = vec![]; + assert!(format_echo_message(&entries).is_none()); + } + + #[test] + fn format_strips_internal_newlines_in_transcript() { + // Multi-line transcripts must collapse to a single quoted line so the + // ">" prefix still applies to every visual line. + let entries = vec![EchoEntry::Success("line one\nline two".into())]; + let out = format_echo_message(&entries).expect("non-empty input → Some"); + assert_eq!(out, "> 🎤 line one line two"); + } + + use crate::adapter::{ChannelRef, ChatAdapter, MessageRef}; + use anyhow::Result; + use async_trait::async_trait; + use std::sync::{Arc, Mutex}; + + #[derive(Default)] + struct MockAdapter { + sent_messages: Mutex>, + reactions: Mutex>, + } + + #[async_trait] + impl ChatAdapter for MockAdapter { + fn platform(&self) -> &'static str { + "mock" + } + fn message_limit(&self) -> usize { + 4000 + } + async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { + self.sent_messages + .lock() + .unwrap() + .push((channel.clone(), content.to_string())); + Ok(MessageRef { + channel: channel.clone(), + message_id: "mock-msg".into(), + }) + } + async fn create_thread( + &self, + channel: &ChannelRef, + _trigger: &MessageRef, + _title: &str, + ) -> Result { + Ok(channel.clone()) + } + async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { + self.reactions + .lock() + .unwrap() + .push((msg.clone(), emoji.to_string())); + Ok(()) + } + async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } + } + + fn test_channel() -> ChannelRef { + ChannelRef { + platform: "mock".into(), + channel_id: "C1".into(), + thread_id: Some("T1".into()), + parent_id: None, + origin_event_id: None, + } + } + + fn test_trigger() -> MessageRef { + MessageRef { + channel: test_channel(), + message_id: "M1".into(), + } + } + + fn cfg(echo: bool) -> SttConfig { + SttConfig { + echo_transcript: echo, + ..SttConfig::default() + } + } + + #[tokio::test] + async fn post_echo_success_sends_one_message_no_reactions() { + let mock = Arc::new(MockAdapter::default()); + let adapter: Arc = mock.clone(); + let entries = vec![EchoEntry::Success("hello".into())]; + post_echo( + &adapter, + &test_channel(), + &test_trigger(), + &entries, + &cfg(true), + ) + .await; + + assert_eq!(mock.sent_messages.lock().unwrap().len(), 1); + assert_eq!(mock.sent_messages.lock().unwrap()[0].1, "> 🎤 hello"); + assert!(mock.reactions.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn post_echo_failure_adds_warning_reaction() { + let mock = Arc::new(MockAdapter::default()); + let adapter: Arc = mock.clone(); + let entries = vec![EchoEntry::Failed]; + post_echo( + &adapter, + &test_channel(), + &test_trigger(), + &entries, + &cfg(true), + ) + .await; + + assert_eq!(mock.sent_messages.lock().unwrap().len(), 1); + assert_eq!( + mock.sent_messages.lock().unwrap()[0].1, + "> 🎤 (transcription failed)" + ); + let reactions = mock.reactions.lock().unwrap(); + assert_eq!(reactions.len(), 1); + assert_eq!(reactions[0].1, "⚠️"); + } + + #[tokio::test] + async fn post_echo_mixed_one_message_one_reaction() { + let mock = Arc::new(MockAdapter::default()); + let adapter: Arc = mock.clone(); + let entries = vec![EchoEntry::Success("ok".into()), EchoEntry::Failed]; + post_echo( + &adapter, + &test_channel(), + &test_trigger(), + &entries, + &cfg(true), + ) + .await; + + assert_eq!(mock.sent_messages.lock().unwrap().len(), 1); + assert_eq!( + mock.sent_messages.lock().unwrap()[0].1, + "> 🎤 ok\n> 🎤 (transcription failed)" + ); + assert_eq!(mock.reactions.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn post_echo_disabled_is_noop() { + let mock = Arc::new(MockAdapter::default()); + let adapter: Arc = mock.clone(); + let entries = vec![EchoEntry::Success("hi".into()), EchoEntry::Failed]; + post_echo( + &adapter, + &test_channel(), + &test_trigger(), + &entries, + &cfg(false), + ) + .await; + + assert!(mock.sent_messages.lock().unwrap().is_empty()); + assert!(mock.reactions.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn post_echo_empty_entries_is_noop() { + let mock = Arc::new(MockAdapter::default()); + let adapter: Arc = mock.clone(); + let entries: Vec = vec![]; + post_echo( + &adapter, + &test_channel(), + &test_trigger(), + &entries, + &cfg(true), + ) + .await; + + assert!(mock.sent_messages.lock().unwrap().is_empty()); + assert!(mock.reactions.lock().unwrap().is_empty()); + } +} diff --git a/src/timestamp.rs b/src/timestamp.rs index e6c8d49f4..aa7adce46 100644 --- a/src/timestamp.rs +++ b/src/timestamp.rs @@ -64,24 +64,36 @@ mod tests { #[test] fn slack_ts_keeps_milliseconds() { // 1714204397 = 2024-04-27T07:53:17 UTC; .123456 → .123 ms - assert_eq!(slack_ts_to_iso8601("1714204397.123456"), "2024-04-27T07:53:17.123Z"); + assert_eq!( + slack_ts_to_iso8601("1714204397.123456"), + "2024-04-27T07:53:17.123Z" + ); } #[test] fn slack_ts_missing_fraction_uses_zero() { - assert_eq!(slack_ts_to_iso8601("1714204397"), "2024-04-27T07:53:17.000Z"); + assert_eq!( + slack_ts_to_iso8601("1714204397"), + "2024-04-27T07:53:17.000Z" + ); } #[test] fn slack_ts_two_digit_fraction_is_120ms_not_12ms() { // ".12" carries decimal semantics: 0.12 s = 120 ms. - assert_eq!(slack_ts_to_iso8601("1714204397.12"), "2024-04-27T07:53:17.120Z"); + assert_eq!( + slack_ts_to_iso8601("1714204397.12"), + "2024-04-27T07:53:17.120Z" + ); } #[test] fn slack_ts_one_digit_fraction_is_100ms_not_1ms() { // ".1" carries decimal semantics: 0.1 s = 100 ms. - assert_eq!(slack_ts_to_iso8601("1714204397.1"), "2024-04-27T07:53:17.100Z"); + assert_eq!( + slack_ts_to_iso8601("1714204397.1"), + "2024-04-27T07:53:17.100Z" + ); } #[test]