From ed3a0a119a20cb70730256aa6ca34e4c826935b4 Mon Sep 17 00:00:00 2001 From: telli Date: Wed, 9 Sep 2026 16:54:00 -0700 Subject: [PATCH 1/3] Harden release readiness and provider resilience --- .config/dotnet-tools.json | 13 + .github/ISSUE_TEMPLATE/bug_report.yml | 38 +++ .github/branch-protection.json | 25 ++ .github/dependabot.yml | 17 ++ .github/pull_request_template.md | 15 + .github/scripts/Test-Coverage.ps1 | 24 ++ .github/scripts/Test-Packages.ps1 | 42 +++ .github/security-settings.json | 10 + .github/workflows/ci.yml | 47 ++- .github/workflows/provider-smoke.yml | 46 +++ .github/workflows/release.yml | 28 +- .gitignore | 3 + CODE_OF_CONDUCT.md | 7 + CONTRIBUTING.md | 21 ++ Directory.Build.props | 18 +- Directory.Packages.props | 24 +- README.md | 6 +- SECURITY.md | 11 + SharpClawCode.Packages.slnf | 28 ++ docs/PRD.md | 35 +-- docs/architecture.md | 2 +- docs/docfx.json | 45 +++ docs/getting-started.md | 27 +- docs/index.md | 5 + docs/providers.md | 33 ++- docs/testing.md | 4 +- docs/testing/test-run-report.md | 10 +- docs/toc.yml | 28 ++ extensions/vscode/package-lock.json | 58 ++++ .../Internal/ProviderBackedAgentKernel.cs | 271 +++++++++++------- .../SharpClaw.Code.Cli.csproj | 2 + .../Services/PlatformSecretProtector.cs | 158 +++++++++- .../Enums/PermissionMode.cs | 3 +- .../Models/AdaLGapModels.cs | 10 + .../PermissionModeJsonConverter.cs | 46 +++ .../Abstractions/IModelProviderResolver.cs | 7 + .../Configuration/ProviderCatalogOptions.cs | 10 + .../ProviderOptionsValidators.cs | 41 +++ .../ProvidersServiceCollectionExtensions.cs | 2 + .../Resilience/ResilientProviderDecorator.cs | 265 +++++++++-------- .../Services/ModelProviderResolver.cs | 26 +- .../Turns/DefaultTurnRunner.cs | 12 +- .../Storage/FileSessionStore.cs | 26 +- .../Storage/SessionSnapshotSerializer.cs | 66 +++++ .../Storage/SqliteSessionStore.cs | 48 +++- .../Diagnostics/TurnActivityScope.cs | 28 +- .../TelemetryOptions.cs | 10 + .../TelemetryOptionsValidator.cs | 5 + .../Runtime/ProviderRuntimeEventFlowTests.cs | 53 +++- .../SharpClaw.Code.MockProvider.csproj | 1 + .../PlatformSecretProtectorTests.cs | 65 +++++ .../Protocol/ProtocolJsonContextTests.cs | 22 ++ .../ProviderConfigurationBindingTests.cs | 4 + .../Providers/ResilienceTests.cs | 132 ++++++++- .../Sessions/SessionStorageTests.cs | 59 ++++ .../Telemetry/TurnActivityScopeTests.cs | 76 +++++ 56 files changed, 1796 insertions(+), 322 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/branch-protection.json create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/scripts/Test-Coverage.ps1 create mode 100644 .github/scripts/Test-Packages.ps1 create mode 100644 .github/security-settings.json create mode 100644 .github/workflows/provider-smoke.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 SharpClawCode.Packages.slnf create mode 100644 docs/docfx.json create mode 100644 docs/index.md create mode 100644 docs/toc.yml create mode 100644 extensions/vscode/package-lock.json create mode 100644 src/SharpClaw.Code.Protocol/Serialization/PermissionModeJsonConverter.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/SessionSnapshotSerializer.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Infrastructure/PlatformSecretProtectorTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Telemetry/TurnActivityScopeTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..db0b903 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "docfx": { + "version": "2.78.5", + "commands": [ + "docfx" + ], + "rollForward": false + } + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1a56e1b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,38 @@ +name: Bug report +description: Report a reproducible SharpClaw Code problem. +title: "[Bug]: " +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: What happened? + description: Include the expected and actual behavior. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Provide the smallest safe sequence that reproduces the issue. + validations: + required: true + - type: input + id: version + attributes: + label: SharpClaw Code version or commit + validations: + required: true + - type: dropdown + id: operating-system + attributes: + label: Operating system + options: [Windows, macOS, Linux, Other] + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs + description: Remove prompts, tokens, keys, personal data, and machine-specific paths first. + render: shell diff --git a/.github/branch-protection.json b/.github/branch-protection.json new file mode 100644 index 0000000..db19531 --- /dev/null +++ b/.github/branch-protection.json @@ -0,0 +1,25 @@ +{ + "required_status_checks": { + "strict": true, + "contexts": [ + "build-and-test (ubuntu-latest)", + "build-and-test (windows-latest)", + "build-and-test (macos-latest)", + "package-smoke (ubuntu-latest)", + "package-smoke (windows-latest)", + "package-smoke (macos-latest)", + "vscode-extension", + "documentation" + ] + }, + "enforce_admins": true, + "required_pull_request_reviews": null, + "restrictions": null, + "required_linear_history": true, + "allow_force_pushes": false, + "allow_deletions": false, + "block_creations": false, + "required_conversation_resolution": true, + "lock_branch": false, + "allow_fork_syncing": true +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a5d243c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + groups: + microsoft-dotnet: + patterns: ["Microsoft.*", "System.*"] + - package-ecosystem: npm + directory: /extensions/vscode + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..0b47384 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## Summary + +Describe the user-visible or architectural outcome. + +## Validation + +- [ ] `dotnet build SharpClawCode.sln --configuration Release --warnaserror` +- [ ] `dotnet test SharpClawCode.sln --configuration Release` +- [ ] Relevant package, CLI, MCP, plugin, and cross-platform checks were run + +## Risk and compatibility + +- [ ] Durable JSON/session formats remain compatible or include a migration +- [ ] Dangerous file, shell, and network operations still pass through permissions +- [ ] No credentials, local state, or generated secrets are included diff --git a/.github/scripts/Test-Coverage.ps1 b/.github/scripts/Test-Coverage.ps1 new file mode 100644 index 0000000..4445106 --- /dev/null +++ b/.github/scripts/Test-Coverage.ps1 @@ -0,0 +1,24 @@ +param( + [Parameter(Mandatory = $true)] + [string]$CoverageRoot, + [double]$MinimumLineCoverage = 38 +) + +$ErrorActionPreference = "Stop" +$reports = @(Get-ChildItem $CoverageRoot -Filter "coverage.cobertura.xml" -Recurse) +if ($reports.Count -eq 0) { throw "No Cobertura reports were found under '$CoverageRoot'." } + +$linesCovered = 0L +$linesValid = 0L +foreach ($report in $reports) { + [xml]$coverage = Get-Content $report.FullName + $linesCovered += [long]$coverage.coverage.'lines-covered' + $linesValid += [long]$coverage.coverage.'lines-valid' +} + +if ($linesValid -eq 0) { throw "Coverage reports did not contain any measurable lines." } +$percentage = 100.0 * $linesCovered / $linesValid +Write-Host ("Line coverage: {0:N2}% ({1}/{2})" -f $percentage, $linesCovered, $linesValid) +if ($percentage -lt $MinimumLineCoverage) { + throw ("Line coverage {0:N2}% is below the required {1:N2}%." -f $percentage, $MinimumLineCoverage) +} diff --git a/.github/scripts/Test-Packages.ps1 b/.github/scripts/Test-Packages.ps1 new file mode 100644 index 0000000..69d25b6 --- /dev/null +++ b/.github/scripts/Test-Packages.ps1 @@ -0,0 +1,42 @@ +param( + [string]$PackageVersion = "0.1.0-preview.1", + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path +$scratchRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("sharpclaw-package-smoke-" + [Guid]::NewGuid().ToString("N")) +$packageOutput = Join-Path $scratchRoot "packages" +$toolPath = Join-Path $scratchRoot "tool" +$consumerPath = Join-Path $scratchRoot "consumer" + +try { + New-Item -ItemType Directory -Path $packageOutput -Force | Out-Null + & dotnet pack (Join-Path $repositoryRoot "SharpClawCode.Packages.slnf") --configuration $Configuration --output $packageOutput -p:PackageVersion=$PackageVersion + if ($LASTEXITCODE -ne 0) { throw "Package creation failed." } + + $packages = @(Get-ChildItem $packageOutput -Filter "*.nupkg" | Where-Object { $_.Name -notlike "*.symbols.nupkg" }) + $unexpected = @($packages | Where-Object { $_.BaseName -notlike "SharpClaw.Code*" }) + if ($packages.Count -ne 21) { throw "Expected 21 production packages, found $($packages.Count)." } + if ($unexpected.Count -gt 0) { throw "Unexpected packages: $($unexpected.Name -join ', ')." } + + & dotnet new console --framework net10.0 --output $consumerPath --no-restore + if ($LASTEXITCODE -ne 0) { throw "Could not create package smoke consumer." } + & dotnet add (Join-Path $consumerPath "consumer.csproj") package SharpClaw.Code --version $PackageVersion --no-restore + if ($LASTEXITCODE -ne 0) { throw "Could not add the aggregate SDK package." } + & dotnet restore (Join-Path $consumerPath "consumer.csproj") --source $packageOutput --source https://api.nuget.org/v3/index.json + if ($LASTEXITCODE -ne 0) { throw "Could not restore the aggregate SDK package." } + & dotnet build (Join-Path $consumerPath "consumer.csproj") --configuration $Configuration --no-restore + if ($LASTEXITCODE -ne 0) { throw "The aggregate SDK package failed to build in a clean consumer." } + + & dotnet tool install --tool-path $toolPath SharpClaw.Code.Cli --version $PackageVersion --add-source $packageOutput --ignore-failed-sources + if ($LASTEXITCODE -ne 0) { throw "The CLI tool package failed to install." } + $toolExecutable = if ($IsWindows) { Join-Path $toolPath "sharpclaw.exe" } else { Join-Path $toolPath "sharpclaw" } + & $toolExecutable version + if ($LASTEXITCODE -ne 0) { throw "The installed CLI tool failed its version smoke test." } +} +finally { + if (Test-Path $scratchRoot) { + Remove-Item $scratchRoot -Recurse -Force + } +} diff --git a/.github/security-settings.json b/.github/security-settings.json new file mode 100644 index 0000000..bdfbc43 --- /dev/null +++ b/.github/security-settings.json @@ -0,0 +1,10 @@ +{ + "security_and_analysis": { + "secret_scanning": { + "status": "enabled" + }, + "secret_scanning_push_protection": { + "status": "enabled" + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4be4dad..6898d0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - name: Restore run: dotnet restore SharpClawCode.sln - name: Build - run: dotnet build SharpClawCode.sln --no-restore --configuration Release + run: dotnet build SharpClawCode.sln --no-restore --configuration Release --warnaserror - name: Build examples run: | dotnet build examples/WebApiAgent/WebApiAgent.csproj --no-restore --configuration Release @@ -43,3 +43,48 @@ jobs: with: name: coverage-report path: ./coverage/**/coverage.cobertura.xml + - name: Enforce coverage floor + if: matrix.os == 'ubuntu-latest' + shell: pwsh + run: ./.github/scripts/Test-Coverage.ps1 -CoverageRoot ./coverage -MinimumLineCoverage 38 + + package-smoke: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Pack and install production artifacts + shell: pwsh + run: ./.github/scripts/Test-Packages.ps1 + + vscode-extension: + runs-on: ubuntu-latest + defaults: + run: + working-directory: extensions/vscode + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: extensions/vscode/package-lock.json + - run: npm ci + - run: npm run compile + + documentation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - run: dotnet tool restore + - run: dotnet docfx docs/docfx.json --warningsAsErrors diff --git a/.github/workflows/provider-smoke.yml b/.github/workflows/provider-smoke.yml new file mode 100644 index 0000000..728b355 --- /dev/null +++ b/.github/workflows/provider-smoke.yml @@ -0,0 +1,46 @@ +name: Live provider smoke + +on: + workflow_dispatch: + inputs: + provider: + description: Provider to validate + required: true + default: openai-compatible + type: choice + options: + - openai-compatible + - anthropic + model: + description: Provider model id + required: true + default: gpt-4.1-mini + type: string + +permissions: + contents: read + +jobs: + prompt: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - run: dotnet build src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --configuration Release --warnaserror + - name: Select credential reference + shell: pwsh + run: | + $provider = '${{ inputs.provider }}' + $variableName = if ($provider -eq 'anthropic') { 'ANTHROPIC_API_KEY' } else { 'OPENAI_API_KEY' } + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($variableName))) { + throw "The $variableName repository secret is required for this smoke test." + } + dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --no-build --configuration Release -- auth set-key --provider $provider --env-var $variableName + - name: Run real provider prompt + run: dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --no-build --configuration Release -- --output-format json --model '${{ inputs.provider }}/${{ inputs.model }}' prompt 'Reply with exactly SHARPCLAW_PROVIDER_OK.' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d83e7f3..6a1e1b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,25 +5,41 @@ on: tags: ['v*'] permissions: - contents: read - packages: write + contents: write jobs: publish: runs-on: ubuntu-latest + environment: nuget steps: - uses: actions/checkout@v4 + - name: Validate semantic version tag + id: version + shell: pwsh + run: | + $version = "${{ github.ref_name }}" -replace '^v', '' + if ($version -notmatch '^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$') { throw "Tag is not a supported semantic version." } + "value=$version" >> $env:GITHUB_OUTPUT - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' - name: Restore run: dotnet restore SharpClawCode.sln + - name: Audit dependencies + run: dotnet list SharpClawCode.sln package --vulnerable --include-transitive - name: Build - run: dotnet build SharpClawCode.sln --no-restore --configuration Release + run: dotnet build SharpClawCode.sln --no-restore --configuration Release --warnaserror - name: Test run: dotnet test SharpClawCode.sln --no-build --configuration Release - - name: Pack - run: dotnet pack SharpClawCode.sln --no-build --configuration Release --output ./nupkg + - name: Pack production artifacts + run: dotnet pack SharpClawCode.Packages.slnf --no-build --configuration Release --output ./nupkg -p:PackageVersion=${{ steps.version.outputs.value }} + - name: Verify package installability + shell: pwsh + run: ./.github/scripts/Test-Packages.ps1 -PackageVersion ${{ steps.version.outputs.value }} - name: Push to NuGet - run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + run: dotnet nuget push './nupkg/*.nupkg' --api-key '${{ secrets.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create '${{ github.ref_name }}' ./nupkg/*.nupkg ./nupkg/*.snupkg --verify-tag --generate-notes --title '${{ github.ref_name }}' diff --git a/.gitignore b/.gitignore index bf5f4d3..e69295a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ BenchmarkDotNet.Artifacts/ # Test output TestResults/ coverage/ +docs/_site/ +docs/api/ *.coverage *.coveragexml *.trx @@ -37,6 +39,7 @@ coverage/ # VS Code .vscode/* +node_modules/ !.vscode/extensions.json !.vscode/settings.json !.vscode/tasks.json diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3bb3730 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,7 @@ +# Code of Conduct + +We are committed to a respectful, harassment-free community. Be considerate, welcome constructive disagreement, focus feedback on the work, and respect privacy and attribution. + +Harassment, discrimination, threats, sexualized conduct, deliberate intimidation, doxxing, and sustained disruption are not acceptable. Maintainers may edit or remove contributions and restrict participation when necessary to protect the community. + +Report conduct concerns privately to the repository maintainers through GitHub. Reports will be handled as confidentially as practical, reviewed promptly, and resolved with proportionate corrective action. Retaliation against anyone who raises a good-faith concern is prohibited. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f915708 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing to SharpClaw Code + +Thank you for helping improve SharpClaw Code. Open an issue before a large or compatibility-sensitive change so the architecture and migration path can be agreed first. + +## Development setup + +Install the .NET 10 SDK and Node.js 22 when working on the VS Code extension. Then run: + +```shell +dotnet restore SharpClawCode.sln +dotnet build SharpClawCode.sln --configuration Release --warnaserror +dotnet test SharpClawCode.sln --configuration Release +``` + +For the editor extension, run `npm ci` and `npm run compile` from `extensions/vscode`. + +## Pull requests + +Keep changes focused and preserve serialized contracts unless the pull request includes a versioned migration. Add tests for behavior changes, pass cancellation tokens through I/O, retain permission gates for dangerous actions, and update user-facing documentation. Never commit credentials, local `.sharpclaw` state, or provider output containing private prompts. + +By participating, you agree to follow the [Code of Conduct](CODE_OF_CONDUCT.md). Security vulnerabilities should be reported through the process in [SECURITY.md](SECURITY.md), not a public issue. diff --git a/Directory.Build.props b/Directory.Build.props index 34f6fc1..292f5ce 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,6 +7,8 @@ true true latest + all + $(WarningsAsErrors);NU1901;NU1902;NU1903;NU1904 true false 1591;$(NoWarn) @@ -16,6 +18,20 @@ https://github.com/clawdotnet/SharpClawCode https://github.com/clawdotnet/SharpClawCode git - Copyright (c) 2025 clawdotnet + Copyright (c) 2026 clawdotnet + 0.1.0 + preview.1 + false + true + README.md + ai;agents;coding-agent;dotnet;cli + true + true + true + snupkg + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index ea920d0..4102e9a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,19 +3,19 @@ true - + - - - - - - - - - + + + + + + + + + @@ -24,9 +24,9 @@ - + - + diff --git a/README.md b/README.md index 5740754..cca871e 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `index`, `m | [docs/acp.md](docs/acp.md) | ACP stdio host and protocol notes | | [docs/plugins.md](docs/plugins.md) | Plugin discovery, trust, and CLI flows | | [docs/testing.md](docs/testing.md) | Unit, integration, and parity-harness coverage | +| [docs/index.md](docs/index.md) | DocFX documentation entry point and generated API reference | | [ARCHITECTURE-NOTES.md](ARCHITECTURE-NOTES.md) | Architectural follow-ups and cleanup ideas | ## Configuration @@ -254,11 +255,11 @@ Key runtime configuration sections: | Section | Purpose | |---|---| -| `SharpClaw:Providers:Catalog` | Default provider, model aliases | +| `SharpClaw:Providers:Catalog` | Default provider, model aliases, ordered fallback providers | | `SharpClaw:Providers:Anthropic` | Anthropic API key, base URL, default model | | `SharpClaw:Providers:OpenAiCompatible` | OpenAI-compatible base settings plus local runtime profiles, auth mode, and default embedding model | | `SharpClaw:Web` | Web search provider name, endpoint template, user agent | -| `SharpClaw:Telemetry` | Runtime event ring buffer capacity plus webhook event export behavior | +| `SharpClaw:Telemetry` | Runtime event buffer, webhook export, and opt-in redacted prompt previews | Key `sharpclaw.jsonc` capabilities: @@ -298,6 +299,7 @@ dotnet build examples/WebApiAgent/WebApiAgent.csproj dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj dotnet build examples/McpToolAgent/McpToolAgent.csproj +pwsh .github/scripts/Test-Packages.ps1 ``` ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d137512 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security policy + +## Supported versions + +Until the first stable release, security fixes are applied to the latest preview release and the `main` branch. + +## Reporting a vulnerability + +Use GitHub's private vulnerability reporting for this repository. Do not open a public issue or include live credentials, private prompts, session files, or exploit details in public logs. Include the affected version, impact, reproduction conditions, and any suggested mitigation. Maintainers will acknowledge a complete report within five business days and coordinate remediation and disclosure. + +Local provider credentials are protected for the current operating-system user. On Windows this uses DPAPI; on macOS and Linux it uses an AES-GCM key stored with user-only file permissions under the user SharpClaw directory. This protects against accidental disclosure at rest, but it does not protect against another process already running as the same user. diff --git a/SharpClawCode.Packages.slnf b/SharpClawCode.Packages.slnf new file mode 100644 index 0000000..97ce80f --- /dev/null +++ b/SharpClawCode.Packages.slnf @@ -0,0 +1,28 @@ +{ + "solution": { + "path": "SharpClawCode.sln", + "projects": [ + "src/SharpClaw.Code/SharpClaw.Code.csproj", + "src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj", + "src/SharpClaw.Code.Agents/SharpClaw.Code.Agents.csproj", + "src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj", + "src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj", + "src/SharpClaw.Code.ExternalAgents/SharpClaw.Code.ExternalAgents.csproj", + "src/SharpClaw.Code.Git/SharpClaw.Code.Git.csproj", + "src/SharpClaw.Code.Infrastructure/SharpClaw.Code.Infrastructure.csproj", + "src/SharpClaw.Code.Mcp/SharpClaw.Code.Mcp.csproj", + "src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj", + "src/SharpClaw.Code.Permissions/SharpClaw.Code.Permissions.csproj", + "src/SharpClaw.Code.Plugins/SharpClaw.Code.Plugins.csproj", + "src/SharpClaw.Code.Protocol/SharpClaw.Code.Protocol.csproj", + "src/SharpClaw.Code.Providers/SharpClaw.Code.Providers.csproj", + "src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj", + "src/SharpClaw.Code.Sessions/SharpClaw.Code.Sessions.csproj", + "src/SharpClaw.Code.Skills/SharpClaw.Code.Skills.csproj", + "src/SharpClaw.Code.Telemetry/SharpClaw.Code.Telemetry.csproj", + "src/SharpClaw.Code.Tools/SharpClaw.Code.Tools.csproj", + "src/SharpClaw.Code.Web/SharpClaw.Code.Web.csproj", + "src/SharpClaw.Code.WorkItems/SharpClaw.Code.WorkItems.csproj" + ] + } +} diff --git a/docs/PRD.md b/docs/PRD.md index 875e315..c510f66 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -111,7 +111,7 @@ SharpClaw Code is a **complement to Microsoft Agent Framework**, not a competito --- -## 4. Phase 1 Requirements +## 4. Phase 1 Delivery Status ### 4.1 Core Runtime (Exists) @@ -128,12 +128,14 @@ The following are implemented and tested: - [x] Spec workflow mode for structured requirements generation - [x] Cross-platform support with Windows-safe behavior -### 4.2 Phase 1 Gaps (Must Build) +### 4.2 Phase 1 Completion -#### 4.2.1 Tool-Calling Loop in Agent Framework Bridge +The original seven gaps below are now implemented in the repository. NuGet publishing remains a release operation: the package set, installation smoke test, semantic-version tag workflow, and GitHub release creation are ready, but no package is considered published until the tagged workflow succeeds. + +#### 4.2.1 Tool-Calling Loop in Agent Framework Bridge (Implemented) **Priority:** P0 -**Why:** The current agent bridge streams provider responses but does not execute tools within the agent loop. This is the single biggest functional gap — without it, SharpClaw Code is a streaming wrapper, not a coding agent. +**Delivered:** The agent bridge advertises typed tools, executes requested calls through the permission-aware executor, returns tool results to the provider, records runtime events, and enforces a configurable iteration limit. **Requirements:** - Agent receives tool-use requests from the provider response @@ -142,10 +144,10 @@ The following are implemented and tested: - Multi-turn tool loops terminate on provider completion or configurable max iterations - Each tool call is recorded as a runtime event (ToolStartedEvent, ToolCompletedEvent) -#### 4.2.2 Conversation History +#### 4.2.2 Conversation History (Implemented) **Priority:** P0 -**Why:** Multi-turn conversations require prior context. Currently each prompt is stateless within the provider call. +**Delivered:** Persisted session turns are assembled into provider conversation history with workspace instructions and compaction support, including resume-safe state. **Requirements:** - Session-scoped conversation history assembled from persisted events @@ -153,23 +155,22 @@ The following are implemented and tested: - System prompt injection from workspace context (CLAUDE.md equivalent) - History survives session resume -#### 4.2.3 NuGet Package Distribution +#### 4.2.3 NuGet Package Distribution (Release-Ready) **Priority:** P1 -**Why:** Adoption requires `dotnet add package`, not `git clone`. +**Delivered:** Only production `SharpClaw.Code*` projects are packable. Packages include XML documentation, symbols, repository metadata, and a README. CI installs the aggregate SDK and CLI tool from a clean local feed before a tag can publish packages. **Requirements:** - Publish core packages to NuGet.org: - `SharpClaw.Code.Protocol` — contracts only, zero dependencies - `SharpClaw.Code.Runtime` — full runtime with DI extensions - - `SharpClaw.Code.Providers.Anthropic` — Anthropic provider - - `SharpClaw.Code.Providers.OpenAi` — OpenAI-compatible provider + - `SharpClaw.Code.Providers` — Anthropic and OpenAI-compatible providers - `SharpClaw.Code.Tools` — built-in tools and tool SDK - `SharpClaw.Code.Mcp` — MCP client integration - Stable API surface with semantic versioning - XML documentation included in packages -#### 4.2.4 Documentation and Getting Started +#### 4.2.4 Documentation and Getting Started (Implemented) **Priority:** P1 **Why:** Framework adoption lives or dies on docs. @@ -184,10 +185,10 @@ The following are implemented and tested: - Web API agent with session persistence - MCP-enabled agent with custom tools -#### 4.2.5 CI/CD Pipeline +#### 4.2.5 CI/CD Pipeline (Implemented) **Priority:** P1 -**Why:** No CI currently exists. Contributors need confidence their PRs don't break things. +**Delivered:** CI builds and tests on Linux, Windows, and macOS; enforces warnings, dependency advisories, and a coverage floor; compiles the VS Code extension; and smoke-installs release packages on all three operating systems. **Requirements:** - GitHub Actions workflow: build + test on push/PR @@ -195,10 +196,10 @@ The following are implemented and tested: - NuGet package publishing on release tags - Code coverage reporting -#### 4.2.6 Provider Resilience +#### 4.2.6 Provider Resilience (Implemented) **Priority:** P2 -**Why:** Production workloads need retry logic, rate limiting, and graceful degradation. +**Delivered:** Resilience covers provider startup and full async enumeration, avoids replay after partial output, applies timeouts and circuit breaking, and can advance through an ordered authenticated fallback chain. **Requirements:** - Configurable retry with exponential backoff for transient HTTP failures @@ -207,10 +208,10 @@ The following are implemented and tested: - Circuit breaker pattern for repeated failures - Fallback provider chain (try Anthropic, fall back to OpenAI) -#### 4.2.7 Observability +#### 4.2.7 Observability (Implemented) **Priority:** P2 -**Why:** Production deployments need more than a ring buffer. +**Delivered:** Activity spans, correlated structured runtime events, usage and duration metrics, webhook/SSE delivery, and JSON/NDJSON diagnostics are available. Prompt previews are opt-in, bounded, and redacted. **Requirements:** - OpenTelemetry activity/span integration for distributed tracing diff --git a/docs/architecture.md b/docs/architecture.md index 34cafd1..90d2ccd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,4 +62,4 @@ Providers bind to **`IConfiguration`** sections (see `docs/providers.md`). The C - Runtime details: [runtime.md](runtime.md) - Sessions layout: [sessions.md](sessions.md) -- Backlog notes: [../ARCHITECTURE-NOTES.md](../ARCHITECTURE-NOTES.md) +- Backlog notes are maintained in `ARCHITECTURE-NOTES.md` at the repository root. diff --git a/docs/docfx.json b/docs/docfx.json new file mode 100644 index 0000000..b3535a6 --- /dev/null +++ b/docs/docfx.json @@ -0,0 +1,45 @@ +{ + "metadata": [ + { + "src": [ + { + "src": "..", + "files": [ + "src/SharpClaw.Code*/SharpClaw.Code*.csproj" + ] + } + ], + "dest": "api" + } + ], + "build": { + "content": [ + { + "files": [ + "**/*.md", + "**/*.yml" + ], + "exclude": [ + "_site/**", + "api/**" + ] + }, + { + "files": [ + "api/**.yml", + "api/index.md" + ] + } + ], + "dest": "_site", + "globalMetadata": { + "_appName": "SharpClaw Code", + "_appTitle": "SharpClaw Code API", + "_enableSearch": true + }, + "template": [ + "default", + "modern" + ] + } +} diff --git a/docs/getting-started.md b/docs/getting-started.md index 6fa98af..cfb54c3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -135,22 +135,27 @@ Alternatively, configure providers in `appsettings.json`: } ``` -The runtime loads from standard .NET configuration sources: -1. Environment variables (highest priority, double-underscore path format) -2. `appsettings.{Environment}.json` -3. `appsettings.json` (default) -4. Command-line arguments +The runtime follows the standard .NET precedence used by the CLI host: + +1. Command-line arguments +2. Environment variables (double-underscore path format) +3. `appsettings.{Environment}.json` +4. `appsettings.json` ## Embed in Your Own App Use SharpClaw as a library in your .NET application. -### 1. Install the NuGet Package +### 1. Reference the Runtime + +After the first tagged package release, install the preview from NuGet: ```bash -dotnet add package SharpClaw.Code.Runtime +dotnet add package SharpClaw.Code.Runtime --prerelease ``` +Until that release is published, clone this repository and add a project reference to `src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj`. + ### 2. Register the Runtime In your application startup, add SharpClaw to the dependency injection container: @@ -182,7 +187,7 @@ var request = new RunPromptRequest( Prompt: "Analyze the current workspace", SessionId: null, // new session WorkingDirectory: Environment.CurrentDirectory, - PermissionMode: PermissionMode.Auto, + PermissionMode: PermissionMode.WorkspaceWrite, OutputFormat: OutputFormat.Markdown, Metadata: new Dictionary { @@ -210,7 +215,7 @@ var request = new RunPromptRequest( Prompt: "Continue from before", SessionId: latestSession?.Id, // Resume this session WorkingDirectory: Environment.CurrentDirectory, - PermissionMode: PermissionMode.Auto, + PermissionMode: PermissionMode.WorkspaceWrite, OutputFormat: OutputFormat.Markdown, Metadata: null ); @@ -243,7 +248,7 @@ try "What is in this directory?", SessionId: null, WorkingDirectory: Environment.CurrentDirectory, - PermissionMode: PermissionMode.Auto, + PermissionMode: PermissionMode.WorkspaceWrite, OutputFormat: OutputFormat.Markdown, Metadata: null ), @@ -317,4 +322,4 @@ Check that all prerequisites are installed and your internet connection is stabl ## Questions? - Open an issue: [github.com/clawdotnet/SharpClawCode/issues](https://github.com/clawdotnet/SharpClawCode/issues) -- Read the [README](../README.md) for a full feature overview +- Read the repository `README.md` for a full feature overview diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..869c86b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,5 @@ +# SharpClaw Code documentation + +SharpClaw Code is a C#-native, permission-aware coding-agent runtime for .NET 10. Start with the [getting-started guide](getting-started.md), then use the architecture and subsystem guides for implementation details. + +The API reference is generated from the repository's XML documentation by DocFX and is available from the API reference navigation in the built site. CI rebuilds it from the public contracts so API documentation cannot silently drift away from the code. diff --git a/docs/providers.md b/docs/providers.md index bf42ad6..b7362e5 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -11,7 +11,7 @@ Registered implementations (see **`ProvidersServiceCollectionExtensions`**): - **`AnthropicProvider`** — HTTP client from **`AnthropicProviderOptions`** - **`OpenAiCompatibleProvider`** — HTTP client from **`OpenAiCompatibleProviderOptions`** -Both are registered as **`IModelProvider`** singletons; **`ModelProviderResolver`** builds a case-insensitive dictionary by **`ProviderName`**. +Both are registered as **`IModelProvider`** singletons; **`ModelProviderResolver`** builds a case-insensitive dictionary by **`ProviderName`** and returns an ordered primary/fallback candidate chain. The provider layer also exposes **`IProviderCatalogService`**, which powers the CLI `models` command and ACP `models/list`. It centralizes: @@ -31,6 +31,10 @@ The provider layer also exposes **`IProviderCatalogService`**, which powers the Default catalog (**`ProviderCatalogOptions`**) uses **`DefaultProvider = "openai-compatible"`** if not configured. +Configure **`FallbackProviders`** as an ordered list of registered provider names. The runtime authenticates candidates before use, buffers each provider iteration until it completes, and advances to the next candidate on a failed stream. Buffering prevents partial primary output from leaking into the fallback response or executing a tool twice. + +Provider resilience is configured under **`SharpClaw:Providers:Resilience`**. Its timeout covers both request startup and async stream enumeration. Transient failures are retried only before the stream emits its first event; once output exists, the attempt fails without replay to avoid duplicate deltas. Repeated failures open the circuit breaker. + ## Configuration sections When using **`AddSharpClawRuntime(IConfiguration)`** (CLI host): @@ -40,6 +44,31 @@ When using **`AddSharpClawRuntime(IConfiguration)`** (CLI host): | `SharpClaw:Providers:Catalog` | **`ProviderCatalogOptions`** | | `SharpClaw:Providers:Anthropic` | **`AnthropicProviderOptions`** (`ProviderName` defaults to `"anthropic"`, `BaseUrl`, API key binding as in options class) | | `SharpClaw:Providers:OpenAiCompatible` | **`OpenAiCompatibleProviderOptions`** (`ProviderName` defaults to `"openai-compatible"`, supports auth mode, default embedding model, and named `LocalRuntimes`) | +| `SharpClaw:Providers:Resilience` | **`ProviderResilienceOptions`** (retry, backoff, timeout, and circuit-breaker settings) | + +Example fallback and resilience configuration: + +```json +{ + "SharpClaw": { + "Providers": { + "Catalog": { + "DefaultProvider": "anthropic", + "FallbackProviders": ["openai-compatible"], + "FallbackModels": { + "openai-compatible": "gpt-4.1-mini" + } + }, + "Resilience": { + "MaxRetries": 3, + "RequestTimeout": "00:05:00", + "CircuitBreakerFailureThreshold": 5, + "CircuitBreakerBreakDuration": "00:00:30" + } + } + } +} +``` There is no checked-in **`appsettings.json`** in the repo; add one next to the CLI project or rely on environment variables / user secrets per standard .NET configuration. @@ -60,7 +89,7 @@ At runtime the catalog service probes these profiles and surfaces health plus di ## Auth -**`IAuthFlowService`** / **`AuthFlowService`** answer whether a provider name is authenticated (used by **`ProviderBackedAgentKernel`**). If not authenticated, the kernel may return a **placeholder** completion (see kernel logs) rather than calling the remote API. +**`IAuthFlowService`** / **`AuthFlowService`** answer whether a provider name is authenticated (used by **`ProviderBackedAgentKernel`**). Unauthenticated or expired candidates are skipped when a fallback exists; if none is available, the turn fails with a classified exception instead of returning synthetic provider output. For the OpenAI-compatible provider, auth status now respects provider auth mode plus any configured auth-optional local runtimes. diff --git a/docs/testing.md b/docs/testing.md index acaf5d2..8d36c4e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -65,7 +65,7 @@ Scenarios are selected via request **`Metadata`** key **`parityScenario`** (**`P Stable scenario **ids** are listed in **`ParityScenarioIds`** (e.g. `streaming_text`, `read_file_roundtrip`, `write_file_allowed`, `write_file_denied`, `grep_chunk_assembly`, `bash_stdout_roundtrip`, `permission_prompt_approved`, `permission_prompt_denied`, `plugin_tool_roundtrip`, `mcp_partial_startup`, `recovery_after_timeout`). -**Note:** Many scenarios exercise **`IToolExecutor`** directly rather than going through the LLM agent loop (which matches current **`AgentFrameworkBridge`** behavior). +The parity suite covers both direct **`IToolExecutor`** boundaries and full provider-backed tool-loop behavior. Direct tests isolate permission and filesystem behavior; integration tests verify that provider tool requests traverse the same executor and return results to the model. ## Agent scenario harness @@ -73,4 +73,4 @@ The scenario harness lives in **`SharpClaw.Testing.Abstractions`**, **`SharpClaw ## CI -CI restores and builds the full solution, explicitly builds every example host project, runs `dotnet test`, then runs the explicit agent scenario harness through `sharpclaw test run` and `sharpclaw test gates`. Parity tests use temp directories under **`Path.GetTempPath()`** and avoid network. +CI restores and builds the full solution with warnings treated as errors, explicitly builds every example host, runs `dotnet test`, then runs the scenario harness through `sharpclaw test run` and `sharpclaw test gates`. It also enforces the line-coverage floor, compiles the VS Code extension, audits dependencies, and smoke-installs production packages on Linux, Windows, and macOS. Parity tests use temp directories under **`Path.GetTempPath()`** and avoid network. diff --git a/docs/testing/test-run-report.md b/docs/testing/test-run-report.md index 845c132..6ac5c64 100644 --- a/docs/testing/test-run-report.md +++ b/docs/testing/test-run-report.md @@ -1,6 +1,6 @@ # Agent Testing Run Report -Generated: `2026-05-10T09:13:50.5670530+00:00` +Generated: `2026-09-09T22:55:22.3197720+00:00` Gate status: **PASS** ## Gates @@ -17,10 +17,10 @@ Gate status: **PASS** | Scenario | Risk | Status | Trace | |----------|------|--------|-------| -| approval-required | High | PASS | ../../artifacts/testing/traces/approval-required-38827d895786449094bbd28d8b640055.trace.json | -| basic-tool-call | Low | PASS | ../../artifacts/testing/traces/basic-tool-call-01ea2af915aa442ea785c520fc90d869.trace.json | -| timeout-retry-placeholder | Medium | PASS | ../../artifacts/testing/traces/timeout-retry-placeholder-6621dde240b1468a87f98735d6af81cd.trace.json | -| unsafe-tool-blocked | Critical | PASS | ../../artifacts/testing/traces/unsafe-tool-blocked-8554b8c5b4304acfa242c050a835a57a.trace.json | +| approval-required | High | PASS | ../../artifacts/testing/traces/approval-required-cf9fefc8b1fb456183300ae8aafdd4d9.trace.json | +| basic-tool-call | Low | PASS | ../../artifacts/testing/traces/basic-tool-call-a5281de4bd6b48e1be313ed723de5a1d.trace.json | +| timeout-retry-placeholder | Medium | PASS | ../../artifacts/testing/traces/timeout-retry-placeholder-b895d41caba7446fa4baa0e8e353b762.trace.json | +| unsafe-tool-blocked | Critical | PASS | ../../artifacts/testing/traces/unsafe-tool-blocked-e321da12eec843dbbf35bf532022a33b.trace.json | ## Oracle Results diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 0000000..8a3d0a3 --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,28 @@ +- name: Getting started + href: getting-started.md +- name: Architecture + href: architecture.md +- name: Runtime + href: runtime.md +- name: Providers + href: providers.md +- name: Tools and permissions + items: + - name: Tools + href: tools.md + - name: Permissions + href: permissions.md +- name: Integrations + items: + - name: Agent Framework + href: agent-framework-integration.md + - name: MCP + href: mcp.md + - name: Plugins + href: plugins.md + - name: ACP + href: acp.md +- name: Testing + href: testing.md +- name: API reference + href: api/toc.yml diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json new file mode 100644 index 0000000..9f25eeb --- /dev/null +++ b/extensions/vscode/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "sharpclaw-code", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sharpclaw-code", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.15.3", + "@types/vscode": "^1.100.0", + "typescript": "^5.8.3" + }, + "engines": { + "vscode": "^1.100.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.137.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.137.0.tgz", + "integrity": "sha512-0dc/BBWxkyUsJzXIZ7PkKSalThmS4xiBT+8YEDiWdCefRKHGVV5ZNkM5NB5ULYamallYJujIfncNoXWFlyzL8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs index 389b3d8..b3fe7ec 100644 --- a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs +++ b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SharpClaw.Code.Agents.Configuration; @@ -7,6 +6,7 @@ using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Providers.Configuration; using SharpClaw.Code.Providers.Models; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Telemetry.Diagnostics; @@ -24,6 +24,7 @@ public sealed class ProviderBackedAgentKernel( IAuthFlowService authFlowService, ToolCallDispatcher toolCallDispatcher, IOptions loopOptions, + IOptions providerCatalogOptions, ISystemClock systemClock, ILogger logger) { @@ -57,68 +58,94 @@ internal async Task ExecuteAsync( Metadata: baseMetadata, ContainsImageInput: request.Context.UserContent?.Any(static block => block.Kind == ContentBlockKind.Image) == true)); + requestedModel = resolvedRequest.Model; var resolvedProviderName = resolvedRequest.ProviderName; try { - // --- Auth check --- - AuthStatus authStatus; + // Resolve and authenticate the primary provider plus configured fallbacks. + IReadOnlyList resolvedCandidates; try { - authStatus = await authFlowService.GetStatusAsync(resolvedProviderName, cancellationToken).ConfigureAwait(false); + resolvedCandidates = providerResolver.ResolveCandidates(resolvedProviderName); } catch (InvalidOperationException) { - throw CreateMissingProviderException(resolvedProviderName, requestedModel, "auth status lookup"); - } - catch (Exception exception) - { - throw new ProviderExecutionException( - resolvedProviderName, - requestedModel, - ProviderFailureKind.AuthenticationUnavailable, - $"Provider '{resolvedProviderName}' authentication probe failed.", - exception); + throw CreateMissingProviderException(resolvedProviderName, requestedModel, "provider resolution"); } - var authExpired = ProviderStreamFailureClassifier.IsExpired(authStatus, systemClock.UtcNow); - if (!authStatus.IsAuthenticated || authExpired) + var providerCandidates = new List(); + ProviderExecutionException? lastCandidateFailure = null; + foreach (var candidate in resolvedCandidates) { - logger.LogWarning( - "Provider {ProviderName} is not authenticated or its auth status expired for session {SessionId}.", - resolvedProviderName, - request.Context.SessionId); - var message = authExpired - ? $"Provider '{resolvedProviderName}' authentication expired at {authStatus.ExpiresAtUtc:O}." - : $"Provider '{resolvedProviderName}' is not authenticated."; - throw new ProviderExecutionException( - resolvedProviderName, - requestedModel, - ProviderFailureKind.AuthenticationUnavailable, - message); - } + var candidateModel = ResolveCandidateModel(providerCatalogOptions.Value, candidate.ProviderName, requestedModel); + try + { + var authStatus = string.Equals(candidate.ProviderName, resolvedProviderName, StringComparison.OrdinalIgnoreCase) + ? await authFlowService.GetStatusAsync(candidate.ProviderName, cancellationToken).ConfigureAwait(false) + : await candidate.GetAuthStatusAsync(cancellationToken).ConfigureAwait(false); + var authExpired = ProviderStreamFailureClassifier.IsExpired(authStatus, systemClock.UtcNow); + if (!authStatus.IsAuthenticated || authExpired) + { + var message = authExpired + ? $"Provider '{candidate.ProviderName}' authentication expired at {authStatus.ExpiresAtUtc:O}." + : $"Provider '{candidate.ProviderName}' is not authenticated."; + throw new ProviderExecutionException( + candidate.ProviderName, + candidateModel, + ProviderFailureKind.AuthenticationUnavailable, + message); + } - // --- Resolve provider --- - IModelProvider provider; - try - { - provider = providerResolver.Resolve(resolvedProviderName); - } - catch (InvalidOperationException) - { - throw CreateMissingProviderException(resolvedProviderName, requestedModel, "provider resolution"); + if (request.Context.UserContent?.Any(static block => block.Kind == ContentBlockKind.Image) == true + && !candidate.SupportsImageInput) + { + throw new ProviderExecutionException( + candidate.ProviderName, + candidateModel, + ProviderFailureKind.StreamFailed, + $"Provider '{candidate.ProviderName}' does not support structured image input."); + } + + providerCandidates.Add(candidate); + } + catch (OperationCanceledException) + { + throw; + } + catch (ProviderExecutionException exception) + { + lastCandidateFailure = exception; + logger.LogWarning( + exception, + "Skipping unavailable provider candidate {ProviderName} for session {SessionId}.", + candidate.ProviderName, + request.Context.SessionId); + } + catch (Exception exception) + { + lastCandidateFailure = new ProviderExecutionException( + candidate.ProviderName, + candidateModel, + ProviderFailureKind.AuthenticationUnavailable, + $"Provider '{candidate.ProviderName}' authentication probe failed.", + exception); + } } - if (request.Context.UserContent?.Any(static block => block.Kind == ContentBlockKind.Image) == true - && !provider.SupportsImageInput) + if (providerCandidates.Count == 0) { - throw new ProviderExecutionException( + throw lastCandidateFailure ?? new ProviderExecutionException( resolvedProviderName, requestedModel, - ProviderFailureKind.StreamFailed, - $"Provider '{resolvedProviderName}' does not support structured image input."); + ProviderFailureKind.AuthenticationUnavailable, + $"No authenticated provider was available for '{resolvedProviderName}'."); } + var activeProviderIndex = 0; + var activeProviderName = providerCandidates[0].ProviderName; + var activeModel = ResolveCandidateModel(providerCatalogOptions.Value, activeProviderName, requestedModel); + // --- Build initial conversation messages --- // Do not add request.Instructions as a shared "system" chat message here. // Provider adapters apply system instructions via ProviderRequest.SystemPrompt @@ -150,74 +177,113 @@ internal async Task ExecuteAsync( for (; iteration < options.MaxToolIterations; iteration++) { UsageSnapshot? iterationUsage = null; - - var providerRequest = providerRequestPreflight.Prepare(new ProviderRequest( - Id: $"provider-request-{Guid.NewGuid():N}", - SessionId: request.Context.SessionId, - TurnId: request.Context.TurnId, - ProviderName: resolvedProviderName, - Model: requestedModel, - Prompt: request.Context.Prompt, - SystemPrompt: request.Instructions, - OutputFormat: request.Context.OutputFormat, - Temperature: 0.1m, - Metadata: baseMetadata, - Messages: messages, - Tools: availableTools, - MaxTokens: options.MaxTokensPerRequest, - ContainsImageInput: messages.Any(static message => message.Content.Any(static block => block.Kind == ContentBlockKind.Image)))); - - lastProviderRequest = providerRequest; - var iterationTextSegments = new List(); var toolUseEvents = new List(); + Exception? lastStreamFailure = null; + var streamSucceeded = false; - using var providerScope = new ProviderActivityScope(resolvedProviderName, requestedModel, providerRequest.Id); - var providerSw = Stopwatch.StartNew(); - try + for (var candidateIndex = activeProviderIndex; candidateIndex < providerCandidates.Count; candidateIndex++) { - var stream = await provider.StartStreamAsync(providerRequest, cancellationToken).ConfigureAwait(false); - - await foreach (var providerEvent in stream.Events.WithCancellation(cancellationToken)) + var provider = providerCandidates[candidateIndex]; + var candidateModel = ResolveCandidateModel(providerCatalogOptions.Value, provider.ProviderName, requestedModel); + var providerRequest = providerRequestPreflight.Prepare(new ProviderRequest( + Id: $"provider-request-{Guid.NewGuid():N}", + SessionId: request.Context.SessionId, + TurnId: request.Context.TurnId, + ProviderName: provider.ProviderName, + Model: candidateModel, + Prompt: request.Context.Prompt, + SystemPrompt: request.Instructions, + OutputFormat: request.Context.OutputFormat, + Temperature: 0.1m, + Metadata: baseMetadata, + Messages: messages, + Tools: availableTools, + MaxTokens: options.MaxTokensPerRequest, + ContainsImageInput: messages.Any(static message => message.Content.Any(static block => block.Kind == ContentBlockKind.Image)))); + var candidateEvents = new List(); + var candidateTextSegments = new List(); + var candidateToolUseEvents = new List(); + UsageSnapshot? candidateUsage = null; + + using var providerScope = new ProviderActivityScope(provider.ProviderName, candidateModel, providerRequest.Id); + try { - allProviderEvents.Add(providerEvent); - - if (providerEvent.IsTerminal - && string.Equals(providerEvent.Kind, "failed", StringComparison.OrdinalIgnoreCase)) - { - var failureKind = ProviderStreamFailureClassifier.ClassifyFailedEvent(providerEvent); - throw new ProviderExecutionException( - resolvedProviderName, - requestedModel, - failureKind, - CreateProviderFailedEventMessage(resolvedProviderName, providerEvent)); - } - - if (!providerEvent.IsTerminal && !string.IsNullOrWhiteSpace(providerEvent.Content)) + var stream = await provider.StartStreamAsync(providerRequest, cancellationToken).ConfigureAwait(false); + await foreach (var providerEvent in stream.Events.WithCancellation(cancellationToken)) { - iterationTextSegments.Add(providerEvent.Content); + candidateEvents.Add(providerEvent); + if (providerEvent.IsTerminal + && string.Equals(providerEvent.Kind, "failed", StringComparison.OrdinalIgnoreCase)) + { + var failureKind = ProviderStreamFailureClassifier.ClassifyFailedEvent(providerEvent); + throw new ProviderExecutionException( + provider.ProviderName, + candidateModel, + failureKind, + CreateProviderFailedEventMessage(provider.ProviderName, providerEvent)); + } + + if (!providerEvent.IsTerminal && !string.IsNullOrWhiteSpace(providerEvent.Content)) + { + candidateTextSegments.Add(providerEvent.Content); + } + + if (!string.IsNullOrEmpty(providerEvent.ToolUseId) && !string.IsNullOrEmpty(providerEvent.ToolName)) + { + candidateToolUseEvents.Add(providerEvent); + } + + if (providerEvent.IsTerminal && providerEvent.Usage is not null) + { + candidateUsage = providerEvent.Usage; + } } - if (!string.IsNullOrEmpty(providerEvent.ToolUseId) && !string.IsNullOrEmpty(providerEvent.ToolName)) + providerScope.SetCompleted(candidateUsage?.InputTokens, candidateUsage?.OutputTokens); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + providerScope.SetError("Canceled by caller."); + throw; + } + catch (Exception exception) + { + providerScope.SetError(exception.Message); + lastStreamFailure = exception; + if (candidateIndex == providerCandidates.Count - 1) { - toolUseEvents.Add(providerEvent); + break; } - if (providerEvent.IsTerminal && providerEvent.Usage is not null) - { - iterationUsage = providerEvent.Usage; - terminalUsage = providerEvent.Usage; - } + logger.LogWarning( + exception, + "Provider {ProviderName} failed; retrying the buffered iteration with fallback provider {FallbackProviderName}.", + provider.ProviderName, + providerCandidates[candidateIndex + 1].ProviderName); + continue; } - providerSw.Stop(); - providerScope.SetCompleted(iterationUsage?.InputTokens, iterationUsage?.OutputTokens); + allProviderEvents.AddRange(candidateEvents); + iterationTextSegments.AddRange(candidateTextSegments); + toolUseEvents.AddRange(candidateToolUseEvents); + iterationUsage = candidateUsage; + terminalUsage = candidateUsage ?? terminalUsage; + lastProviderRequest = providerRequest; + activeProviderIndex = candidateIndex; + activeProviderName = provider.ProviderName; + activeModel = candidateModel; + streamSucceeded = true; + break; } - catch (Exception ex) + + if (!streamSucceeded) { - providerSw.Stop(); - providerScope.SetError(ex.Message); - throw; + throw lastStreamFailure ?? new ProviderExecutionException( + activeProviderName, + activeModel, + ProviderFailureKind.StreamFailed, + "All provider candidates failed before completing a response."); } // If no tool-use events, accumulate text and break @@ -300,9 +366,9 @@ internal async Task ExecuteAsync( { logger.LogWarning( "Provider {ProviderName} returned no stream content for session {SessionId}; returning placeholder response.", - resolvedProviderName, + activeProviderName, request.Context.SessionId); - return CreatePlaceholderResult(request, requestedModel, $"Provider '{resolvedProviderName}' returned no content; using placeholder response."); + return CreatePlaceholderResult(request, activeModel, $"Provider '{activeProviderName}' returned no content; using placeholder response."); } var usage = terminalUsage ?? new UsageSnapshot( @@ -313,8 +379,8 @@ internal async Task ExecuteAsync( EstimatedCostUsd: null); var summary = toolLoopExhausted - ? $"Provider response from {resolvedProviderName}/{requestedModel} is incomplete because the tool-calling loop reached the maximum of {options.MaxToolIterations} iterations." - : $"Streamed provider response from {resolvedProviderName}/{requestedModel}."; + ? $"Provider response from {activeProviderName}/{activeModel} is incomplete because the tool-calling loop reached the maximum of {options.MaxToolIterations} iterations." + : $"Streamed provider response from {activeProviderName}/{activeModel}."; return new ProviderInvocationResult( Output: output, @@ -384,4 +450,9 @@ private static string CreateProviderFailedEventMessage(string providerName, Prov : providerEvent.Content; return $"Provider '{providerName}' stream failed: {detail}"; } + + private static string ResolveCandidateModel(ProviderCatalogOptions options, string providerName, string primaryModel) + => options.FallbackModels.TryGetValue(providerName, out var fallbackModel) && !string.IsNullOrWhiteSpace(fallbackModel) + ? fallbackModel + : primaryModel; } diff --git a/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj b/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj index 2c67917..ddcfc7b 100644 --- a/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj +++ b/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj @@ -3,6 +3,8 @@ Exe Command-line interface and REPL for SharpClaw Code. + true + sharpclaw diff --git a/src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs b/src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs index baa9f0f..1bcea25 100644 --- a/src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs +++ b/src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs @@ -1,14 +1,22 @@ +using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using SharpClaw.Code.Infrastructure.Abstractions; namespace SharpClaw.Code.Infrastructure.Services; -/// -public sealed class PlatformSecretProtector : ISecretProtector +/// +/// Protects local secrets with Windows DPAPI or a user-only AES key on macOS and Linux. +/// +public sealed class PlatformSecretProtector(IUserProfilePaths userProfilePaths) : ISecretProtector { + private const string DpapiPrefix = "dpapi:v1:"; + private const string AesPrefix = "aesgcm:v1:"; + private const string KeyFileName = "secret-protection.key"; + private static readonly byte[] AssociatedData = "SharpClaw.Code.PlatformSecretProtector.v1"u8.ToArray(); + /// - public bool CanProtect => OperatingSystem.IsWindows(); + public bool CanProtect => OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() || OperatingSystem.IsLinux(); /// public string Protect(string plaintext) @@ -16,23 +24,153 @@ public string Protect(string plaintext) ArgumentException.ThrowIfNullOrWhiteSpace(plaintext); if (!CanProtect) { - throw new InvalidOperationException("Protected local secret storage is only available on Windows."); + throw new PlatformNotSupportedException("Protected local secret storage is unavailable on this platform."); } - var bytes = Encoding.UTF8.GetBytes(plaintext); - return Convert.ToBase64String(ProtectedData.Protect(bytes, optionalEntropy: null, DataProtectionScope.CurrentUser)); + return OperatingSystem.IsWindows() + ? DpapiPrefix + ProtectWithDpapi(plaintext) + : AesPrefix + ProtectWithAes(plaintext); } /// public string Unprotect(string protectedPayload) { ArgumentException.ThrowIfNullOrWhiteSpace(protectedPayload); - if (!CanProtect) + if (protectedPayload.StartsWith(AesPrefix, StringComparison.Ordinal)) + { + return UnprotectWithAes(protectedPayload[AesPrefix.Length..]); + } + + if (protectedPayload.StartsWith(DpapiPrefix, StringComparison.Ordinal)) + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("DPAPI-protected secrets can only be read on Windows."); + } + + return UnprotectWithDpapi(protectedPayload[DpapiPrefix.Length..]); + } + + if (OperatingSystem.IsWindows()) { - throw new InvalidOperationException("Protected local secret storage is only available on Windows."); + return UnprotectWithDpapi(protectedPayload); } - var bytes = Convert.FromBase64String(protectedPayload); - return Encoding.UTF8.GetString(ProtectedData.Unprotect(bytes, optionalEntropy: null, DataProtectionScope.CurrentUser)); + throw new CryptographicException("The protected secret uses an unknown or legacy platform format."); + } + + private string ProtectWithAes(string plaintext) + { + var key = GetOrCreateUnixKey(); + var nonce = RandomNumberGenerator.GetBytes(12); + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var ciphertext = new byte[plaintextBytes.Length]; + var tag = new byte[16]; + try + { + using var aes = new AesGcm(key, tag.Length); + aes.Encrypt(nonce, plaintextBytes, ciphertext, tag, AssociatedData); + + var payload = new byte[nonce.Length + tag.Length + ciphertext.Length]; + nonce.CopyTo(payload, 0); + tag.CopyTo(payload, nonce.Length); + ciphertext.CopyTo(payload, nonce.Length + tag.Length); + return Convert.ToBase64String(payload); + } + finally + { + CryptographicOperations.ZeroMemory(key); + CryptographicOperations.ZeroMemory(plaintextBytes); + } + } + + private string UnprotectWithAes(string encodedPayload) + { + var payload = Convert.FromBase64String(encodedPayload); + if (payload.Length < 28) + { + throw new CryptographicException("The protected secret payload is incomplete."); + } + + var key = GetOrCreateUnixKey(); + var nonce = payload.AsSpan(0, 12); + var tag = payload.AsSpan(12, 16); + var ciphertext = payload.AsSpan(28); + var plaintext = new byte[ciphertext.Length]; + try + { + using var aes = new AesGcm(key, tag.Length); + aes.Decrypt(nonce, ciphertext, tag, plaintext, AssociatedData); + return Encoding.UTF8.GetString(plaintext); + } + finally + { + CryptographicOperations.ZeroMemory(key); + CryptographicOperations.ZeroMemory(plaintext); + } + } + + private byte[] GetOrCreateUnixKey() + { + var root = userProfilePaths.GetUserSharpClawRoot(); + Directory.CreateDirectory(root); + var keyPath = Path.Combine(root, KeyFileName); + + if (!File.Exists(keyPath)) + { + var generatedKey = RandomNumberGenerator.GetBytes(32); + try + { + using var stream = new FileStream(keyPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + stream.Write(generatedKey); + } + catch (IOException) when (File.Exists(keyPath)) + { + // Another process created the same user-scoped key first. + } + finally + { + CryptographicOperations.ZeroMemory(generatedKey); + } + } + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + var key = File.ReadAllBytes(keyPath); + return key.Length == 32 + ? key + : throw new CryptographicException($"The local secret-protection key at '{keyPath}' is invalid."); + } + + [SupportedOSPlatform("windows")] + private static string ProtectWithDpapi(string plaintext) + { + var bytes = Encoding.UTF8.GetBytes(plaintext); + try + { + return Convert.ToBase64String(ProtectedData.Protect(bytes, optionalEntropy: null, DataProtectionScope.CurrentUser)); + } + finally + { + CryptographicOperations.ZeroMemory(bytes); + } + } + + [SupportedOSPlatform("windows")] + private static string UnprotectWithDpapi(string encodedPayload) + { + var protectedBytes = Convert.FromBase64String(encodedPayload); + var bytes = ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, DataProtectionScope.CurrentUser); + try + { + return Encoding.UTF8.GetString(bytes); + } + finally + { + CryptographicOperations.ZeroMemory(bytes); + } } } diff --git a/src/SharpClaw.Code.Protocol/Enums/PermissionMode.cs b/src/SharpClaw.Code.Protocol/Enums/PermissionMode.cs index 72d48ef..4a37a14 100644 --- a/src/SharpClaw.Code.Protocol/Enums/PermissionMode.cs +++ b/src/SharpClaw.Code.Protocol/Enums/PermissionMode.cs @@ -1,11 +1,12 @@ using System.Text.Json.Serialization; +using SharpClaw.Code.Protocol.Serialization; namespace SharpClaw.Code.Protocol.Enums; /// /// Describes how permission-sensitive operations should be handled. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(PermissionModeJsonConverter))] public enum PermissionMode { /// diff --git a/src/SharpClaw.Code.Protocol/Models/AdaLGapModels.cs b/src/SharpClaw.Code.Protocol/Models/AdaLGapModels.cs index 6c3406d..df1606b 100644 --- a/src/SharpClaw.Code.Protocol/Models/AdaLGapModels.cs +++ b/src/SharpClaw.Code.Protocol/Models/AdaLGapModels.cs @@ -143,24 +143,31 @@ public sealed record ResearchReport( [JsonConverter(typeof(JsonStringEnumConverter))] public enum EvolutionProposalCategory { + /// Changes prompt assembly or instruction policy. [JsonStringEnumMemberName("promptPolicy")] PromptPolicy, + /// Changes provider or model routing. [JsonStringEnumMemberName("modelRouting")] ModelRouting, + /// Changes default permission approvals. [JsonStringEnumMemberName("approvalDefaults")] ApprovalDefaults, + /// Suggests a skill to add or revise. [JsonStringEnumMemberName("skillSuggestion")] SkillSuggestion, + /// Suggests a plugin to add or revise. [JsonStringEnumMemberName("pluginSuggestion")] PluginSuggestion, + /// Refreshes durable workspace knowledge. [JsonStringEnumMemberName("knowledgeRefresh")] KnowledgeRefresh, + /// Produces a reviewable code specification. [JsonStringEnumMemberName("codeSpec")] CodeSpec, } @@ -171,12 +178,15 @@ public enum EvolutionProposalCategory [JsonConverter(typeof(JsonStringEnumConverter))] public enum EvolutionProposalStatus { + /// The proposal awaits a decision. [JsonStringEnumMemberName("open")] Open, + /// The proposal has been applied. [JsonStringEnumMemberName("applied")] Applied, + /// The proposal was declined. [JsonStringEnumMemberName("rejected")] Rejected, } diff --git a/src/SharpClaw.Code.Protocol/Serialization/PermissionModeJsonConverter.cs b/src/SharpClaw.Code.Protocol/Serialization/PermissionModeJsonConverter.cs new file mode 100644 index 0000000..eea7e17 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Serialization/PermissionModeJsonConverter.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SharpClaw.Code.Protocol.Enums; + +namespace SharpClaw.Code.Protocol.Serialization; + +/// +/// Reads canonical permission modes while preserving compatibility with pre-release names. +/// +public sealed class PermissionModeJsonConverter : JsonConverter +{ + /// + public override PermissionMode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var numericValue) + && Enum.IsDefined(typeof(PermissionMode), numericValue)) + { + return (PermissionMode)numericValue; + } + + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Permission mode must be a string or a valid numeric enum value."); + } + + return reader.GetString()?.Trim().ToLowerInvariant() switch + { + "readonly" or "read-only" => PermissionMode.ReadOnly, + "workspacewrite" or "workspace-write" or "prompt" or "autoapprovesafe" or "auto-approve-safe" => PermissionMode.WorkspaceWrite, + "dangerfullaccess" or "danger-full-access" or "fulltrust" or "full-trust" => PermissionMode.DangerFullAccess, + var value => throw new JsonException($"Unknown permission mode '{value}'."), + }; + } + + /// + public override void Write(Utf8JsonWriter writer, PermissionMode value, JsonSerializerOptions options) + { + writer.WriteStringValue(value switch + { + PermissionMode.ReadOnly => "readOnly", + PermissionMode.WorkspaceWrite => "workspaceWrite", + PermissionMode.DangerFullAccess => "dangerFullAccess", + _ => throw new JsonException($"Unknown permission mode value '{value}'."), + }); + } +} diff --git a/src/SharpClaw.Code.Providers/Abstractions/IModelProviderResolver.cs b/src/SharpClaw.Code.Providers/Abstractions/IModelProviderResolver.cs index 1f3ff6e..56b14f5 100644 --- a/src/SharpClaw.Code.Providers/Abstractions/IModelProviderResolver.cs +++ b/src/SharpClaw.Code.Providers/Abstractions/IModelProviderResolver.cs @@ -11,4 +11,11 @@ public interface IModelProviderResolver /// The provider name. /// The resolved provider. IModelProvider Resolve(string providerName); + + /// + /// Resolves the requested provider followed by any configured fallback providers. + /// + /// The primary provider name. + /// The ordered provider candidates. + IReadOnlyList ResolveCandidates(string providerName) => [Resolve(providerName)]; } diff --git a/src/SharpClaw.Code.Providers/Configuration/ProviderCatalogOptions.cs b/src/SharpClaw.Code.Providers/Configuration/ProviderCatalogOptions.cs index be60fb3..a614c00 100644 --- a/src/SharpClaw.Code.Providers/Configuration/ProviderCatalogOptions.cs +++ b/src/SharpClaw.Code.Providers/Configuration/ProviderCatalogOptions.cs @@ -10,6 +10,16 @@ public sealed class ProviderCatalogOptions /// public string DefaultProvider { get; set; } = "openai-compatible"; + /// + /// Gets the ordered provider names to try after the requested provider fails. + /// + public List FallbackProviders { get; } = []; + + /// + /// Gets optional model ids keyed by fallback provider name. + /// + public Dictionary FallbackModels { get; } = new(StringComparer.OrdinalIgnoreCase); + /// /// Gets the configured model aliases. /// diff --git a/src/SharpClaw.Code.Providers/Configuration/ProviderOptionsValidators.cs b/src/SharpClaw.Code.Providers/Configuration/ProviderOptionsValidators.cs index a75cdb0..80489bc 100644 --- a/src/SharpClaw.Code.Providers/Configuration/ProviderOptionsValidators.cs +++ b/src/SharpClaw.Code.Providers/Configuration/ProviderOptionsValidators.cs @@ -15,6 +15,47 @@ public ValidateOptionsResult Validate(string? name, ProviderCatalogOptions optio return ValidateOptionsResult.Fail("ProviderCatalogOptions.DefaultProvider must be set."); } + if (options.FallbackProviders.Any(string.IsNullOrWhiteSpace)) + { + return ValidateOptionsResult.Fail("ProviderCatalogOptions.FallbackProviders cannot contain empty provider names."); + } + + if (options.FallbackProviders.Distinct(StringComparer.OrdinalIgnoreCase).Count() != options.FallbackProviders.Count) + { + return ValidateOptionsResult.Fail("ProviderCatalogOptions.FallbackProviders cannot contain duplicate provider names."); + } + + if (options.FallbackModels.Any(entry => string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value))) + { + return ValidateOptionsResult.Fail("ProviderCatalogOptions.FallbackModels must contain non-empty provider names and model ids."); + } + + return ValidateOptionsResult.Success; + } +} + +/// +/// Validates provider resilience settings after configuration binding. +/// +public sealed class ProviderResilienceOptionsValidator : IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, ProviderResilienceOptions options) + { + if (options.MaxRetries < 0) + { + return ValidateOptionsResult.Fail("ProviderResilienceOptions.MaxRetries cannot be negative."); + } + + if (options.RequestTimeout <= TimeSpan.Zero + || options.InitialRetryDelay < TimeSpan.Zero + || options.MaxRetryDelay < options.InitialRetryDelay + || options.CircuitBreakerFailureThreshold <= 0 + || options.CircuitBreakerBreakDuration < TimeSpan.Zero) + { + return ValidateOptionsResult.Fail("Provider resilience durations and thresholds must be positive and internally consistent."); + } + return ValidateOptionsResult.Success; } } diff --git a/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs b/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs index a3af9fd..e08fe04 100644 --- a/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs @@ -104,9 +104,11 @@ private static IServiceCollection AddSharpClawProvidersCore( services.AddSingleton, ProviderCatalogOptionsValidator>(); services.AddSingleton, AnthropicProviderOptionsValidator>(); services.AddSingleton, OpenAiCompatibleProviderOptionsValidator>(); + services.AddSingleton, ProviderResilienceOptionsValidator>(); services.AddOptions().ValidateOnStart(); services.AddOptions().ValidateOnStart(); services.AddOptions().ValidateOnStart(); + services.AddOptions().ValidateOnStart(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs b/src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs index 87adf1c..0b41e7d 100644 --- a/src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs +++ b/src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Runtime.CompilerServices; using Microsoft.Extensions.Logging; using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Providers.Configuration; @@ -8,24 +9,19 @@ namespace SharpClaw.Code.Providers.Resilience; /// -/// Decorates an with retry, rate-limit handling, and circuit-breaker resilience. +/// Decorates an with full-stream timeouts, retry handling, and a circuit breaker. /// internal sealed class ResilientProviderDecorator : IModelProvider { private readonly IModelProvider _inner; private readonly ProviderResilienceOptions _options; private readonly ILogger _logger; - - // Circuit breaker state + private readonly object _lock = new(); private int _consecutiveFailures; private DateTimeOffset _circuitOpenedAt; private bool _circuitOpen; - private readonly object _lock = new(); - public ResilientProviderDecorator( - IModelProvider inner, - ProviderResilienceOptions options, - ILogger logger) + public ResilientProviderDecorator(IModelProvider inner, ProviderResilienceOptions options, ILogger logger) { _inner = inner; _options = options; @@ -43,144 +39,188 @@ public Task GetAuthStatusAsync(CancellationToken cancellationToken) => _inner.GetAuthStatusAsync(cancellationToken); /// - public async Task StartStreamAsync(ProviderRequest request, CancellationToken ct) + public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) { - // 1. Check circuit breaker - lock (_lock) - { - if (_circuitOpen) - { - var elapsed = DateTimeOffset.UtcNow - _circuitOpenedAt; - if (elapsed < _options.CircuitBreakerBreakDuration) - { - var remaining = _options.CircuitBreakerBreakDuration - elapsed; - _logger.LogWarning( - "Circuit breaker is open for provider {Provider}. Rejecting request. Circuit resets in {Remaining}.", - ProviderName, - remaining); - throw new ProviderExecutionException( - ProviderName, - request.Model, - ProviderFailureKind.StreamFailed, - $"Circuit breaker is open for provider '{ProviderName}'. Try again in {remaining.TotalSeconds:F1}s."); - } - - // Break duration elapsed — allow a probe attempt (half-open) - _circuitOpen = false; - _logger.LogInformation( - "Circuit breaker entering half-open state for provider {Provider}. Allowing probe request.", - ProviderName); - } - } + ThrowIfCircuitOpen(request); + return Task.FromResult(new ProviderStreamHandle(request, ExecuteWithResilienceAsync(request, cancellationToken))); + } - // 2. Retry loop + private async IAsyncEnumerable ExecuteWithResilienceAsync( + ProviderRequest request, + [EnumeratorCancellation] CancellationToken callerCancellationToken) + { Exception? lastException = null; for (var attempt = 0; attempt <= _options.MaxRetries; attempt++) { - ct.ThrowIfCancellationRequested(); - + callerCancellationToken.ThrowIfCancellationRequested(); using var timeoutCts = new CancellationTokenSource(_options.RequestTimeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(callerCancellationToken, timeoutCts.Token); + var emittedEvent = false; + Exception? attemptException = null; + IAsyncEnumerator? enumerator = null; try { - var result = await _inner.StartStreamAsync(request, linkedCts.Token).ConfigureAwait(false); - - // Success — reset circuit breaker - ResetCircuit(); - return result; + var handle = await _inner.StartStreamAsync(request, linkedCts.Token).ConfigureAwait(false); + enumerator = handle.Events.GetAsyncEnumerator(linkedCts.Token); } - catch (Exception ex) when (!IsTransient(ex)) + catch (Exception exception) { - // Non-transient: fail immediately without retrying - _logger.LogError( - ex, - "Non-transient failure from provider {Provider} on attempt {Attempt}. Not retrying.", - ProviderName, - attempt + 1); - RecordFailure(); - throw; + attemptException = exception; } - catch (Exception ex) when (IsTransient(ex)) + + if (enumerator is not null) { - lastException = ex; - RecordFailure(); + var streamCompleted = false; + try + { + while (true) + { + bool hasNext; + try + { + hasNext = await enumerator.MoveNextAsync().AsTask().WaitAsync(linkedCts.Token).ConfigureAwait(false); + } + catch (Exception exception) + { + attemptException = exception; + break; + } + + if (!hasNext) + { + streamCompleted = true; + break; + } + + emittedEvent = true; + yield return enumerator.Current; + } + } + finally + { + try + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) when (attemptException is not null) + { + _logger.LogDebug(exception, "Provider stream disposal failed after a stream error."); + } + } - if (attempt >= _options.MaxRetries) + if (streamCompleted) { - // All retries exhausted - break; + ResetCircuit(); + yield break; } + } - // Determine delay — respect Retry-After for 429 responses - var delay = ComputeDelay(attempt, ex); + if (attemptException is OperationCanceledException && callerCancellationToken.IsCancellationRequested) + { + throw attemptException; + } + + if (attemptException is null) + { + throw new InvalidOperationException("Provider execution failed without an exception."); + } - _logger.LogWarning( - ex, - "Transient failure from provider {Provider} on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms.", + if (!IsTransient(attemptException)) + { + RecordFailureAndOpenCircuitIfNeeded(); + _logger.LogError( + attemptException, + "Non-transient failure from provider {Provider} on attempt {Attempt}. Not retrying.", ProviderName, - attempt + 1, - _options.MaxRetries + 1, - delay.TotalMilliseconds); + attempt + 1); + throw attemptException; + } - await Task.Delay(delay, ct).ConfigureAwait(false); + lastException = attemptException; + RecordFailureAndOpenCircuitIfNeeded(); + if (emittedEvent || attempt >= _options.MaxRetries) + { + break; } - } - // All retries exhausted — open circuit if threshold reached - OpenCircuitIfThresholdReached(); + var delay = ComputeDelay(attempt, attemptException); + _logger.LogWarning( + attemptException, + "Transient failure from provider {Provider} on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms.", + ProviderName, + attempt + 1, + _options.MaxRetries + 1, + delay.TotalMilliseconds); + await Task.Delay(delay, callerCancellationToken).ConfigureAwait(false); + } throw new ProviderExecutionException( ProviderName, request.Model, ProviderFailureKind.StreamFailed, - $"Provider '{ProviderName}' failed after {_options.MaxRetries + 1} attempt(s).", + $"Provider '{ProviderName}' failed while streaming after {_options.MaxRetries + 1} attempt(s).", lastException); } - private static bool IsTransient(Exception ex) + private void ThrowIfCircuitOpen(ProviderRequest request) { - // Non-transient exceptions we should NOT retry - if (ex is ProviderExecutionException pee && - (pee.Kind is ProviderFailureKind.AuthenticationUnavailable or ProviderFailureKind.MissingProvider)) + lock (_lock) { - return false; + if (!_circuitOpen) + { + return; + } + + var elapsed = DateTimeOffset.UtcNow - _circuitOpenedAt; + if (elapsed >= _options.CircuitBreakerBreakDuration) + { + _circuitOpen = false; + _logger.LogInformation( + "Circuit breaker entering half-open state for provider {Provider}. Allowing probe request.", + ProviderName); + return; + } + + var remaining = _options.CircuitBreakerBreakDuration - elapsed; + _logger.LogWarning( + "Circuit breaker is open for provider {Provider}. Rejecting request. Circuit resets in {Remaining}.", + ProviderName, + remaining); + throw new ProviderExecutionException( + ProviderName, + request.Model, + ProviderFailureKind.StreamFailed, + $"Circuit breaker is open for provider '{ProviderName}'. Try again in {remaining.TotalSeconds:F1}s."); } + } - if (ex is ArgumentException) + private static bool IsTransient(Exception exception) + { + if (exception is ProviderExecutionException providerException + && providerException.Kind is ProviderFailureKind.AuthenticationUnavailable or ProviderFailureKind.MissingProvider) { return false; } - // Transient: HTTP errors (5xx), timeouts, IO failures - return ex is HttpRequestException or TaskCanceledException or IOException; + return exception is not ArgumentException + && exception is HttpRequestException or TaskCanceledException or TimeoutException or IOException; } - private TimeSpan ComputeDelay(int attempt, Exception ex) + private TimeSpan ComputeDelay(int attempt, Exception exception) { - // Check for 429 with Retry-After - if (ex is HttpRequestException httpEx && httpEx.StatusCode == HttpStatusCode.TooManyRequests) + if (exception is HttpRequestException { StatusCode: HttpStatusCode.TooManyRequests } httpException + && httpException.Data.Contains("Retry-After") + && httpException.Data["Retry-After"] is int retryAfterSeconds and > 0) { - // Attempt to extract Retry-After from the inner exception message or data - // HttpRequestException does not carry headers directly; providers may embed seconds in Data - if (httpEx.Data.Contains("Retry-After") && - httpEx.Data["Retry-After"] is int retryAfterSeconds and > 0) - { - var retryAfterDelay = TimeSpan.FromSeconds(retryAfterSeconds); - if (retryAfterDelay <= _options.MaxRetryDelay) - { - return retryAfterDelay; - } - } + var retryAfter = TimeSpan.FromSeconds(retryAfterSeconds); + return retryAfter <= _options.MaxRetryDelay ? retryAfter : _options.MaxRetryDelay; } - // Exponential backoff: InitialDelay * 2^attempt + jitter var exponential = _options.InitialRetryDelay.TotalMilliseconds * Math.Pow(2, attempt); - var jitter = Random.Shared.Next(0, 100); - var total = exponential + jitter; - var capped = Math.Min(total, _options.MaxRetryDelay.TotalMilliseconds); - return TimeSpan.FromMilliseconds(capped); + var jitter = _options.InitialRetryDelay == TimeSpan.Zero ? 0 : Random.Shared.Next(0, 100); + return TimeSpan.FromMilliseconds(Math.Min(exponential + jitter, _options.MaxRetryDelay.TotalMilliseconds)); } private void ResetCircuit() @@ -197,27 +237,22 @@ private void ResetCircuit() } } - private void RecordFailure() + private void RecordFailureAndOpenCircuitIfNeeded() { lock (_lock) { _consecutiveFailures++; - } - } - - private void OpenCircuitIfThresholdReached() - { - lock (_lock) - { - if (_consecutiveFailures >= _options.CircuitBreakerFailureThreshold) + if (_consecutiveFailures < _options.CircuitBreakerFailureThreshold) { - _circuitOpen = true; - _circuitOpenedAt = DateTimeOffset.UtcNow; - _logger.LogError( - "Circuit breaker opened for provider {Provider} after {Failures} consecutive failures.", - ProviderName, - _consecutiveFailures); + return; } + + _circuitOpen = true; + _circuitOpenedAt = DateTimeOffset.UtcNow; + _logger.LogError( + "Circuit breaker opened for provider {Provider} after {Failures} consecutive failures.", + ProviderName, + _consecutiveFailures); } } } diff --git a/src/SharpClaw.Code.Providers/Services/ModelProviderResolver.cs b/src/SharpClaw.Code.Providers/Services/ModelProviderResolver.cs index d9aacfb..f2c07b9 100644 --- a/src/SharpClaw.Code.Providers/Services/ModelProviderResolver.cs +++ b/src/SharpClaw.Code.Providers/Services/ModelProviderResolver.cs @@ -1,11 +1,15 @@ +using Microsoft.Extensions.Options; using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Providers.Configuration; namespace SharpClaw.Code.Providers; /// /// Resolves configured model providers by name. /// -public sealed class ModelProviderResolver(IEnumerable providers) : IModelProviderResolver +public sealed class ModelProviderResolver( + IEnumerable providers, + IOptions catalogOptions) : IModelProviderResolver { private readonly IReadOnlyDictionary _providers = providers.ToDictionary( provider => provider.ProviderName, @@ -17,4 +21,24 @@ public IModelProvider Resolve(string providerName) => _providers.TryGetValue(providerName, out var provider) ? provider : throw new InvalidOperationException($"Provider '{providerName}' is not registered."); + + /// + public IReadOnlyList ResolveCandidates(string providerName) + { + var candidates = new List { Resolve(providerName) }; + foreach (var fallbackName in catalogOptions.Value.FallbackProviders) + { + if (candidates.Any(candidate => string.Equals(candidate.ProviderName, fallbackName, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + if (_providers.TryGetValue(fallbackName, out var fallback)) + { + candidates.Add(fallback); + } + } + + return candidates; + } } diff --git a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs index a6da681..8f5d232 100644 --- a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs +++ b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Microsoft.Extensions.Options; using SharpClaw.Code.Agents.Abstractions; using SharpClaw.Code.Agents.Agents; using SharpClaw.Code.Agents.Models; @@ -7,6 +8,7 @@ using SharpClaw.Code.Runtime.Abstractions; using SharpClaw.Code.Runtime.Workflow; using SharpClaw.Code.Telemetry.Diagnostics; +using SharpClaw.Code.Telemetry; using SharpClaw.Code.Tools.Abstractions; namespace SharpClaw.Code.Runtime.Turns; @@ -18,7 +20,8 @@ public sealed class DefaultTurnRunner( IEnumerable agents, PrimaryCodingAgent primaryCodingAgentFallback, IToolExecutor toolExecutor, - IPromptContextAssembler promptContextAssembler) : ITurnRunner + IPromptContextAssembler promptContextAssembler, + IOptions telemetryOptions) : ITurnRunner { private readonly ISharpClawAgent[] agentList = agents.ToArray(); @@ -62,7 +65,12 @@ public async Task RunAsync( ApprovalSettings: request.ApprovalSettings, UserContent: promptContext.UserContent); - using var turnScope = new TurnActivityScope(session.Id, turn.Id, promptContext.Prompt); + var telemetry = telemetryOptions.Value; + using var turnScope = new TurnActivityScope( + session.Id, + turn.Id, + telemetry.CapturePromptPreview ? promptContext.Prompt : null, + telemetry.PromptPreviewMaxLength); var sw = Stopwatch.StartNew(); AgentRunResult agentResult; try diff --git a/src/SharpClaw.Code.Sessions/Storage/FileSessionStore.cs b/src/SharpClaw.Code.Sessions/Storage/FileSessionStore.cs index 599d71f..008c49a 100644 --- a/src/SharpClaw.Code.Sessions/Storage/FileSessionStore.cs +++ b/src/SharpClaw.Code.Sessions/Storage/FileSessionStore.cs @@ -1,7 +1,8 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Protocol.Models; -using SharpClaw.Code.Protocol.Serialization; using SharpClaw.Code.Sessions.Abstractions; namespace SharpClaw.Code.Sessions.Storage; @@ -9,7 +10,10 @@ namespace SharpClaw.Code.Sessions.Storage; /// /// Stores session snapshots as readable JSON files under the workspace. /// -public sealed class FileSessionStore(IFileSystem fileSystem, IRuntimeStoragePathResolver storagePathResolver) : ISessionStore +public sealed class FileSessionStore( + IFileSystem fileSystem, + IRuntimeStoragePathResolver storagePathResolver, + ILogger? logger = null) : ISessionStore { /// public Task SaveAsync(string workspacePath, ConversationSession session, CancellationToken cancellationToken) @@ -18,7 +22,7 @@ public Task SaveAsync(string workspacePath, ConversationSession session, Cancell fileSystem.CreateDirectory(sessionsRoot); var path = storagePathResolver.GetSessionSnapshotPath(workspacePath, session.Id); - var json = JsonSerializer.Serialize(session, ProtocolJsonContext.Default.ConversationSession); + var json = SessionSnapshotSerializer.Serialize(session); return fileSystem.WriteAllTextAsync(path, json, cancellationToken); } @@ -27,9 +31,19 @@ public Task SaveAsync(string workspacePath, ConversationSession session, Cancell { var path = storagePathResolver.GetSessionSnapshotPath(workspacePath, sessionId); var content = await fileSystem.ReadAllTextIfExistsAsync(path, cancellationToken).ConfigureAwait(false); - return string.IsNullOrWhiteSpace(content) - ? null - : JsonSerializer.Deserialize(content, ProtocolJsonContext.Default.ConversationSession); + try + { + return SessionSnapshotSerializer.Deserialize(content); + } + catch (JsonException exception) + { + (logger ?? NullLogger.Instance).LogWarning( + exception, + "Skipping unreadable session snapshot {SessionId} at {Path}.", + sessionId, + path); + return null; + } } /// diff --git a/src/SharpClaw.Code.Sessions/Storage/SessionSnapshotSerializer.cs b/src/SharpClaw.Code.Sessions/Storage/SessionSnapshotSerializer.cs new file mode 100644 index 0000000..a754d14 --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/SessionSnapshotSerializer.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; + +namespace SharpClaw.Code.Sessions.Storage; + +/// +/// Applies versioned compatibility migrations to durable session snapshots. +/// +internal static class SessionSnapshotSerializer +{ + internal const int CurrentSchemaVersion = 1; + + public static string Serialize(ConversationSession session) + { + var node = JsonSerializer.SerializeToNode(session, ProtocolJsonContext.Default.ConversationSession) + ?? throw new JsonException("The session snapshot could not be serialized."); + node["schemaVersion"] = CurrentSchemaVersion; + return node.ToJsonString(ProtocolJsonContext.Default.Options); + } + + public static ConversationSession? Deserialize(string? payload) + { + if (string.IsNullOrWhiteSpace(payload)) + { + return null; + } + + var node = JsonNode.Parse(payload) as JsonObject + ?? throw new JsonException("The session snapshot root must be a JSON object."); + var schemaVersion = 0; + if (node["schemaVersion"] is JsonValue schemaVersionNode + && !schemaVersionNode.TryGetValue(out schemaVersion)) + { + throw new JsonException("Session snapshot schemaVersion must be an integer."); + } + if (schemaVersion > CurrentSchemaVersion) + { + throw new JsonException($"Session snapshot schema version {schemaVersion} is newer than supported version {CurrentSchemaVersion}."); + } + + if (schemaVersion == 0) + { + MigrateVersionZero(node); + } + + return node.Deserialize(ProtocolJsonContext.Default.ConversationSession); + } + + private static void MigrateVersionZero(JsonObject node) + { + if (node["permissionMode"] is JsonValue permissionModeValue + && permissionModeValue.TryGetValue(out var permissionMode)) + { + node["permissionMode"] = permissionMode.Trim().ToLowerInvariant() switch + { + "prompt" or "autoapprovesafe" or "auto-approve-safe" => "workspaceWrite", + "fulltrust" or "full-trust" => "dangerFullAccess", + _ => permissionMode, + }; + } + + node["schemaVersion"] = CurrentSchemaVersion; + } +} diff --git a/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs b/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs index fe12f84..74cc085 100644 --- a/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs +++ b/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs @@ -1,8 +1,9 @@ using System.Text.Json; using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Protocol.Models; -using SharpClaw.Code.Protocol.Serialization; using SharpClaw.Code.Sessions.Abstractions; namespace SharpClaw.Code.Sessions.Storage; @@ -12,7 +13,8 @@ namespace SharpClaw.Code.Sessions.Storage; /// public sealed class SqliteSessionStore( IFileSystem fileSystem, - IRuntimeStoragePathResolver storagePathResolver) : ISessionStore + IRuntimeStoragePathResolver storagePathResolver, + ILogger? logger = null) : ISessionStore { /// public async Task SaveAsync(string workspacePath, ConversationSession session, CancellationToken cancellationToken) @@ -32,7 +34,7 @@ ON CONFLICT(session_id) DO UPDATE SET """; command.Parameters.AddWithValue("$sessionId", session.Id); command.Parameters.AddWithValue("$updatedAtUtc", session.UpdatedAtUtc.ToString("O")); - command.Parameters.AddWithValue("$payloadJson", JsonSerializer.Serialize(session, ProtocolJsonContext.Default.ConversationSession)); + command.Parameters.AddWithValue("$payloadJson", SessionSnapshotSerializer.Serialize(session)); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } @@ -46,7 +48,7 @@ ON CONFLICT(session_id) DO UPDATE SET command.CommandText = "SELECT payload_json FROM sessions WHERE session_id = $sessionId LIMIT 1;"; command.Parameters.AddWithValue("$sessionId", sessionId); var payload = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string; - return Deserialize(payload); + return Deserialize(payload, sessionId); } /// @@ -59,11 +61,20 @@ ON CONFLICT(session_id) DO UPDATE SET command.CommandText = """ SELECT payload_json FROM sessions - ORDER BY updated_at_utc DESC - LIMIT 1; + ORDER BY updated_at_utc DESC; """; - var payload = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string; - return Deserialize(payload); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var payload = reader.IsDBNull(0) ? null : reader.GetString(0); + var session = Deserialize(payload, sessionId: null); + if (session is not null) + { + return session; + } + } + + return null; } /// @@ -84,7 +95,7 @@ FROM sessions while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { var payload = reader.IsDBNull(0) ? null : reader.GetString(0); - var session = Deserialize(payload); + var session = Deserialize(payload, sessionId: null); if (session is not null) { sessions.Add(session); @@ -94,8 +105,19 @@ FROM sessions return sessions; } - private static ConversationSession? Deserialize(string? payload) - => string.IsNullOrWhiteSpace(payload) - ? null - : JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.ConversationSession); + private ConversationSession? Deserialize(string? payload, string? sessionId) + { + try + { + return SessionSnapshotSerializer.Deserialize(payload); + } + catch (JsonException exception) + { + (logger ?? NullLogger.Instance).LogWarning( + exception, + "Skipping unreadable SQLite session snapshot {SessionId}.", + sessionId ?? "unknown"); + return null; + } + } } diff --git a/src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs b/src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs index 0d84b9f..912fbe2 100644 --- a/src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs +++ b/src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.RegularExpressions; namespace SharpClaw.Code.Telemetry.Diagnostics; @@ -14,16 +15,19 @@ public sealed class TurnActivityScope : IDisposable /// /// Owning session identifier. /// Turn identifier. - /// Optional prompt preview. - public TurnActivityScope(string sessionId, string turnId, string? prompt = null) + /// Optional prompt preview. Callers must opt in before passing prompt content. + /// Maximum redacted preview length. + public TurnActivityScope(string sessionId, string turnId, string? prompt = null, int promptPreviewMaxLength = 200) { _activity = SharpClawActivitySource.Instance.StartActivity("sharpclaw.turn"); _activity?.SetTag("sharpclaw.session.id", sessionId); _activity?.SetTag("sharpclaw.turn.id", turnId); if (prompt is not null) { - // Truncate prompt to avoid huge spans - _activity?.SetTag("sharpclaw.turn.prompt_preview", prompt.Length > 200 ? prompt[..200] + "..." : prompt); + var redacted = RedactSecrets(prompt); + _activity?.SetTag( + "sharpclaw.turn.prompt_preview", + redacted.Length > promptPreviewMaxLength ? redacted[..promptPreviewMaxLength] + "..." : redacted); } } @@ -60,4 +64,20 @@ public void SetError(Exception exception) /// public void Dispose() => _activity?.Dispose(); + + private static string RedactSecrets(string prompt) + { + var redactedAssignments = Regex.Replace( + prompt, + @"(?i)\b(api[_-]?key|access[_-]?token|token|password|secret)\s*[:=]\s*[^\s,;]+", + "$1=[REDACTED]", + RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + return Regex.Replace( + redactedAssignments, + @"\bsk-[A-Za-z0-9_-]{8,}\b", + "[REDACTED]", + RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + } } diff --git a/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs b/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs index c2cbab3..30a44b6 100644 --- a/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs +++ b/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs @@ -5,6 +5,16 @@ namespace SharpClaw.Code.Telemetry; /// public sealed class TelemetryOptions { + /// + /// Gets or sets whether a redacted prompt preview is added to turn activities. Disabled by default. + /// + public bool CapturePromptPreview { get; set; } + + /// + /// Gets or sets the maximum redacted prompt-preview length. + /// + public int PromptPreviewMaxLength { get; set; } = 200; + /// /// Maximum number of instances retained in the ring buffer. /// diff --git a/src/SharpClaw.Code.Telemetry/TelemetryOptionsValidator.cs b/src/SharpClaw.Code.Telemetry/TelemetryOptionsValidator.cs index f0f960f..cd7ffe2 100644 --- a/src/SharpClaw.Code.Telemetry/TelemetryOptionsValidator.cs +++ b/src/SharpClaw.Code.Telemetry/TelemetryOptionsValidator.cs @@ -21,6 +21,11 @@ public ValidateOptionsResult Validate(string? name, TelemetryOptions options) $"TelemetryOptions.RuntimeEventRingBufferCapacity must be at least {MinimumBufferCapacity} (was {options.RuntimeEventRingBufferCapacity})."); } + if (options.PromptPreviewMaxLength is < 16 or > 1_000) + { + return ValidateOptionsResult.Fail("TelemetryOptions.PromptPreviewMaxLength must be between 16 and 1000."); + } + return ValidateOptionsResult.Success; } } diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs index 44b708c..333b6f6 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs @@ -5,6 +5,7 @@ using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Providers.Configuration; using SharpClaw.Code.Providers.Models; using SharpClaw.Code.Runtime; using SharpClaw.Code.Runtime.Abstractions; @@ -240,6 +241,40 @@ public async Task RunPrompt_should_fail_when_provider_is_not_authenticated() exception.Which.Kind.Should().Be(ProviderFailureKind.AuthenticationUnavailable); } + /// + /// Ensures a failed primary stream can be replaced by an authenticated fallback. + /// + [Fact] + public async Task RunPrompt_should_use_fallback_provider_after_primary_stream_failure() + { + var workspacePath = CreateTemporaryWorkspace(); + using var serviceProvider = CreateRuntimeServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.Configure(options => options.FallbackModels["fallback-provider"] = "fallback-model"); + }); + var runtime = serviceProvider.GetRequiredService(); + + var result = await runtime.RunPromptAsync( + new RunPromptRequest( + Prompt: "use a fallback", + SessionId: null, + WorkingDirectory: workspacePath, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + Metadata: new Dictionary + { + ["provider"] = "stub-provider", + ["model"] = "stub-model" + }), + CancellationToken.None); + + result.FinalOutput.Should().Be("Hello world"); + result.Events.OfType().Should().ContainSingle(e => e.ProviderName == "fallback-provider"); + } + private static string CreateTemporaryWorkspace() { var workspacePath = Path.Combine(Path.GetTempPath(), "sharpclaw-provider-tests", Guid.NewGuid().ToString("N")); @@ -300,6 +335,16 @@ private sealed class ThrowingModelProviderResolver : IModelProviderResolver public IModelProvider Resolve(string providerName) => new ThrowingModelProvider(); } + private sealed class FallbackModelProviderResolver : IModelProviderResolver + { + private readonly IModelProvider _primary = new ThrowingModelProvider(); + private readonly IModelProvider _fallback = new StubModelProvider("fallback-provider", "fallback-model"); + + public IModelProvider Resolve(string providerName) => _primary; + + public IReadOnlyList ResolveCandidates(string providerName) => [_primary, _fallback]; + } + private sealed class FailIfInvokedModelProviderResolver : IModelProviderResolver { public IModelProvider Resolve(string providerName) => new FailIfInvokedModelProvider(); @@ -310,15 +355,17 @@ private sealed class AuthFailedEventModelProviderResolver : IModelProviderResolv public IModelProvider Resolve(string providerName) => new AuthFailedEventModelProvider(); } - private sealed class StubModelProvider : IModelProvider + private sealed class StubModelProvider(string providerName = "stub-provider", string? expectedModel = null) : IModelProvider { - public string ProviderName => "stub-provider"; + public string ProviderName => providerName; public Task GetAuthStatusAsync(CancellationToken cancellationToken) => Task.FromResult(new AuthStatus("stub-subject", true, ProviderName, null, null, ["api"])); public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) - => Task.FromResult(new ProviderStreamHandle(request, StreamEventsAsync(request))); + => expectedModel is not null && !string.Equals(request.Model, expectedModel, StringComparison.Ordinal) + ? throw new InvalidOperationException($"Expected model '{expectedModel}', received '{request.Model}'.") + : Task.FromResult(new ProviderStreamHandle(request, StreamEventsAsync(request))); private static async IAsyncEnumerable StreamEventsAsync(ProviderRequest request) { diff --git a/tests/SharpClaw.Code.MockProvider/SharpClaw.Code.MockProvider.csproj b/tests/SharpClaw.Code.MockProvider/SharpClaw.Code.MockProvider.csproj index fe38d69..3251fda 100644 --- a/tests/SharpClaw.Code.MockProvider/SharpClaw.Code.MockProvider.csproj +++ b/tests/SharpClaw.Code.MockProvider/SharpClaw.Code.MockProvider.csproj @@ -11,6 +11,7 @@ + false net10.0 enable enable diff --git a/tests/SharpClaw.Code.UnitTests/Infrastructure/PlatformSecretProtectorTests.cs b/tests/SharpClaw.Code.UnitTests/Infrastructure/PlatformSecretProtectorTests.cs new file mode 100644 index 0000000..4873b3a --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Infrastructure/PlatformSecretProtectorTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using System.Security.Cryptography; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Services; + +namespace SharpClaw.Code.UnitTests.Infrastructure; + +/// +/// Verifies portable user-scoped secret protection. +/// +public sealed class PlatformSecretProtectorTests : IDisposable +{ + private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"sharpclaw-secret-{Guid.NewGuid():N}"); + + /// + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } + + /// + /// Ensures protected payloads round-trip without persisting plaintext. + /// + [Fact] + public void Protect_should_roundtrip_without_plaintext() + { + var protector = new PlatformSecretProtector(new TestUserProfilePaths(_tempDirectory)); + + var payload = protector.Protect("super-secret-value"); + + payload.Should().NotContain("super-secret-value"); + protector.Unprotect(payload).Should().Be("super-secret-value"); + protector.CanProtect.Should().BeTrue(); + } + + /// + /// Ensures tampered payloads cannot be decrypted. + /// + [Fact] + public void Unprotect_should_reject_tampered_payload() + { + var protector = new PlatformSecretProtector(new TestUserProfilePaths(_tempDirectory)); + var payload = protector.Protect("super-secret-value"); + var prefixLength = payload.LastIndexOf(':') + 1; + var bytes = Convert.FromBase64String(payload[prefixLength..]); + bytes[^1] ^= 1; + var tamperedPayload = payload[..prefixLength] + Convert.ToBase64String(bytes); + + var act = () => protector.Unprotect(tamperedPayload); + + act.Should().Throw(); + } + + private sealed class TestUserProfilePaths(string root) : IUserProfilePaths + { + public string GetUserHomeDirectory() => root; + + public string GetUserSharpClawRoot() => root; + + public string GetUserCustomCommandsDirectory() => Path.Combine(root, "commands"); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Protocol/ProtocolJsonContextTests.cs b/tests/SharpClaw.Code.UnitTests/Protocol/ProtocolJsonContextTests.cs index 7efcb18..0bbf71c 100644 --- a/tests/SharpClaw.Code.UnitTests/Protocol/ProtocolJsonContextTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Protocol/ProtocolJsonContextTests.cs @@ -13,6 +13,28 @@ namespace SharpClaw.Code.UnitTests.Protocol; /// public sealed class ProtocolJsonContextTests { + /// + /// Ensures legacy permission names remain readable while new payloads use canonical values. + /// + [Theory] + [InlineData("prompt", PermissionMode.WorkspaceWrite)] + [InlineData("auto-approve-safe", PermissionMode.WorkspaceWrite)] + [InlineData("full-trust", PermissionMode.DangerFullAccess)] + [InlineData("read-only", PermissionMode.ReadOnly)] + public void Permission_mode_should_read_legacy_aliases(string serializedValue, PermissionMode expected) + { + var mode = JsonSerializer.Deserialize($"\"{serializedValue}\""); + + mode.Should().Be(expected); + JsonSerializer.Serialize(mode).Should().Be($"\"{expected switch + { + PermissionMode.ReadOnly => "readOnly", + PermissionMode.WorkspaceWrite => "workspaceWrite", + PermissionMode.DangerFullAccess => "dangerFullAccess", + _ => throw new InvalidOperationException(), + }}\""); + } + /// /// Ensures provider requests serialize with camelCase names and string enums. /// diff --git a/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs b/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs index d158b5a..2633203 100644 --- a/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs @@ -24,6 +24,8 @@ public void AddSharpClawProviders_should_bind_options_from_configuration() .AddInMemoryCollection(new Dictionary { ["SharpClaw:Providers:Catalog:DefaultProvider"] = "anthropic", + ["SharpClaw:Providers:Catalog:FallbackProviders:0"] = "openai-compatible", + ["SharpClaw:Providers:Catalog:FallbackModels:openai-compatible"] = "gpt-4.1-mini", ["SharpClaw:Providers:Catalog:ModelAliases:sonnet:ProviderName"] = "anthropic", ["SharpClaw:Providers:Catalog:ModelAliases:sonnet:ModelId"] = "claude-3-7-sonnet-latest", ["SharpClaw:Providers:Anthropic:ApiKey"] = "anthropic-key", @@ -53,6 +55,8 @@ public void AddSharpClawProviders_should_bind_options_from_configuration() var openAi = serviceProvider.GetRequiredService>().Value; catalog.DefaultProvider.Should().Be("anthropic"); + catalog.FallbackProviders.Should().Equal("openai-compatible"); + catalog.FallbackModels["openai-compatible"].Should().Be("gpt-4.1-mini"); catalog.ModelAliases["sonnet"].Should().Be(new ModelAliasDefinition("anthropic", "claude-3-7-sonnet-latest")); anthropic.ApiKey.Should().Be("anthropic-key"); anthropic.BaseUrl.Should().Be("https://anthropic.example.com"); diff --git a/tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs b/tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs index 2d1a9ca..59174c0 100644 --- a/tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; +using System.Runtime.CompilerServices; using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Providers.Configuration; using SharpClaw.Code.Providers.Models; @@ -57,9 +58,10 @@ public async Task Retries_on_transient_failure_then_succeeds() // Act var result = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None); + await DrainAsync(result.Events); // Assert - result.Should().BeSameAs(fakeHandle); + result.Request.Should().Be(FakeRequest); mock.CallCount.Should().Be(3); } @@ -73,7 +75,11 @@ public async Task Does_not_retry_non_transient_failures() var decorator = BuildDecorator(mock); // Act - Func act = () => decorator.StartStreamAsync(FakeRequest, CancellationToken.None); + Func act = async () => + { + var stream = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None); + await DrainAsync(stream.Events); + }; // Assert: propagates immediately after a single call await act.Should().ThrowAsync(); @@ -107,7 +113,11 @@ public async Task Circuit_breaker_opens_after_threshold() for (var i = 0; i < 3; i++) { await FluentActions - .Awaiting(() => decorator.StartStreamAsync(FakeRequest, CancellationToken.None)) + .Awaiting(async () => + { + var stream = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None); + await DrainAsync(stream.Events); + }) .Should().ThrowAsync(); } @@ -147,18 +157,126 @@ public async Task Circuit_breaker_allows_probe_after_break_duration() var decorator = BuildDecorator(mock, opts); // First call: should fail and open the circuit - await FluentActions - .Awaiting(() => decorator.StartStreamAsync(FakeRequest, CancellationToken.None)) - .Should().ThrowAsync(); + await FluentActions.Awaiting(async () => + { + var stream = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None); + await DrainAsync(stream.Events); + }).Should().ThrowAsync(); mock.CallCount.Should().Be(1); // Second call: break duration has elapsed (it's zero), so probe should reach inner provider var result = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None); - result.Should().BeSameAs(fakeHandle); + await DrainAsync(result.Events); + result.Request.Should().Be(FakeRequest); mock.CallCount.Should().Be(2, "probe attempt must reach the inner provider"); } + [Fact] + public async Task Retries_when_stream_fails_before_first_event() + { + var mock = new CountingMockProvider(); + mock.Behaviors.Enqueue(() => Task.FromResult(new ProviderStreamHandle(FakeRequest, FailBeforeFirstEvent()))); + mock.Behaviors.Enqueue(() => Task.FromResult(new ProviderStreamHandle(FakeRequest, SingleEvent()))); + var decorator = BuildDecorator(mock); + + var stream = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None); + var events = await CollectAsync(stream.Events); + + events.Should().ContainSingle(); + mock.CallCount.Should().Be(2); + } + + [Fact] + public async Task Does_not_retry_after_stream_has_emitted_an_event() + { + var mock = new CountingMockProvider(); + mock.Behaviors.Enqueue(() => Task.FromResult(new ProviderStreamHandle(FakeRequest, FailAfterFirstEvent()))); + var decorator = BuildDecorator(mock); + + var stream = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None); + Func act = () => DrainAsync(stream.Events); + + await act.Should().ThrowAsync(); + mock.CallCount.Should().Be(1, "replaying a partial stream would duplicate output"); + } + + [Fact] + public async Task Request_timeout_covers_async_enumeration() + { + var options = new ProviderResilienceOptions + { + MaxRetries = 0, + InitialRetryDelay = TimeSpan.Zero, + MaxRetryDelay = TimeSpan.Zero, + RequestTimeout = TimeSpan.FromMilliseconds(25), + CircuitBreakerFailureThreshold = 5, + CircuitBreakerBreakDuration = TimeSpan.FromSeconds(30), + }; + var mock = new CountingMockProvider(); + mock.Behaviors.Enqueue(() => Task.FromResult(new ProviderStreamHandle(FakeRequest, NeverCompletes()))); + var decorator = BuildDecorator(mock, options); + + var stream = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None); + Func act = () => DrainAsync(stream.Events); + + await act.Should().ThrowAsync(); + mock.CallCount.Should().Be(1); + } + + private static async Task DrainAsync(IAsyncEnumerable events) + => _ = await CollectAsync(events); + + private static async Task> CollectAsync(IAsyncEnumerable events) + { + var result = new List(); + await foreach (var providerEvent in events) + { + result.Add(providerEvent); + } + + return result; + } + + private static async IAsyncEnumerable FailBeforeFirstEvent() + { + await Task.Yield(); + throw new IOException("stream failed"); +#pragma warning disable CS0162 + yield break; +#pragma warning restore CS0162 + } + + private static async IAsyncEnumerable FailAfterFirstEvent() + { + yield return CreateEvent("delta"); + await Task.Yield(); + throw new IOException("stream failed after output"); + } + + private static async IAsyncEnumerable SingleEvent() + { + await Task.Yield(); + yield return CreateEvent("completed"); + } + + private static async IAsyncEnumerable NeverCompletes( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + yield break; + } + + private static ProviderEvent CreateEvent(string kind) + => new( + Id: Guid.NewGuid().ToString("N"), + RequestId: FakeRequest.Id, + Kind: kind, + CreatedAtUtc: DateTimeOffset.UtcNow, + Content: null, + IsTerminal: string.Equals(kind, "completed", StringComparison.Ordinal), + Usage: null); + // ----------------------------------------------------------------------- // Test double // ----------------------------------------------------------------------- diff --git a/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs b/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs index 478e202..577cab2 100644 --- a/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Data.Sqlite; using SharpClaw.Code.Infrastructure.Services; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Events; @@ -58,6 +59,43 @@ public async Task FileSessionStore_save_and_get_roundtrip() loaded!.Id.Should().Be("s1"); loaded.Title.Should().Be("Test s1"); loaded.State.Should().Be(SessionLifecycleState.Active); + var snapshotPath = Path.Combine(_tempDir, ".sharpclaw", "sessions", "s1", "session.json"); + (await File.ReadAllTextAsync(snapshotPath)).Should().Contain("\"schemaVersion\":1"); + } + + [Fact] + public async Task FileSessionStore_loads_legacy_prompt_permission_mode() + { + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); + var session = CreateSession("legacy", DateTimeOffset.UtcNow); + await store.SaveAsync(_tempDir, session, CancellationToken.None); + var snapshotPath = Path.Combine(_tempDir, ".sharpclaw", "sessions", "legacy", "session.json"); + var legacyJson = (await File.ReadAllTextAsync(snapshotPath)) + .Replace("\"workspaceWrite\"", "\"prompt\"", StringComparison.Ordinal) + .Replace(",\"schemaVersion\":1", string.Empty, StringComparison.Ordinal); + await File.WriteAllTextAsync(snapshotPath, legacyJson); + + var loaded = await store.GetByIdAsync(_tempDir, "legacy", CancellationToken.None); + + loaded.Should().NotBeNull(); + loaded!.PermissionMode.Should().Be(PermissionMode.WorkspaceWrite); + } + + [Fact] + public async Task FileSessionStore_skips_malformed_snapshot_when_listing() + { + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); + await store.SaveAsync(_tempDir, CreateSession("valid", DateTimeOffset.UtcNow), CancellationToken.None); + var malformedDirectory = Path.Combine(_tempDir, ".sharpclaw", "sessions", "broken"); + Directory.CreateDirectory(malformedDirectory); + await File.WriteAllTextAsync(Path.Combine(malformedDirectory, "session.json"), "{not-json"); + + var sessions = await store.ListAllAsync(_tempDir, CancellationToken.None); + var latest = await store.GetLatestAsync(_tempDir, CancellationToken.None); + + sessions.Should().ContainSingle().Which.Id.Should().Be("valid"); + latest.Should().NotBeNull(); + latest!.Id.Should().Be("valid"); } [Fact] @@ -126,6 +164,27 @@ public async Task FileSessionStore_save_overwrites_existing() loaded!.Title.Should().Be("Updated"); } + [Fact] + public async Task SqliteSessionStore_skips_malformed_snapshots_when_listing() + { + var resolver = CreateStoragePathResolver(); + var store = new SqliteSessionStore(_fileSystem, resolver); + await store.SaveAsync(_tempDir, CreateSession("valid", DateTimeOffset.UtcNow), CancellationToken.None); + await store.SaveAsync(_tempDir, CreateSession("broken", DateTimeOffset.UtcNow.AddMinutes(1)), CancellationToken.None); + + await using (var connection = new SqliteConnection($"Data Source={resolver.GetSessionStoreDatabasePath(_tempDir)}")) + { + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = "UPDATE sessions SET payload_json = '{not-json' WHERE session_id = 'broken';"; + await command.ExecuteNonQueryAsync(); + } + + var sessions = await store.ListAllAsync(_tempDir, CancellationToken.None); + + sessions.Should().ContainSingle().Which.Id.Should().Be("valid"); + } + // ── NdjsonEventStore ── [Fact] diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/TurnActivityScopeTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/TurnActivityScopeTests.cs new file mode 100644 index 0000000..40da7d8 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/TurnActivityScopeTests.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; +using FluentAssertions; +using SharpClaw.Code.Telemetry.Diagnostics; + +namespace SharpClaw.Code.UnitTests.Telemetry; + +/// +/// Verifies privacy controls on turn activity tags. +/// +public sealed class TurnActivityScopeTests +{ + /// + /// Ensures opted-in prompt previews redact common credential forms before export. + /// + [Fact] + public void Prompt_preview_should_be_redacted_and_truncated() + { + Activity? completed = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == SharpClawActivitySource.SourceName, + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + if (Equals(activity.GetTagItem("sharpclaw.session.id"), "session")) + { + completed = activity; + } + }, + }; + ActivitySource.AddActivityListener(listener); + + using (new TurnActivityScope( + "session", + "turn", + "password=hunter2 api_key=top-secret sk-abcdefghijklm trailing words", + promptPreviewMaxLength: 48)) + { + } + + completed.Should().NotBeNull(); + var preview = completed!.GetTagItem("sharpclaw.turn.prompt_preview")?.ToString(); + preview.Should().NotContain("hunter2").And.NotContain("top-secret").And.NotContain("sk-abcdefghijklm"); + preview.Should().Contain("[REDACTED]"); + preview!.Length.Should().BeLessThanOrEqualTo(51); + } + + /// + /// Ensures omitting the prompt avoids creating any prompt-content tag. + /// + [Fact] + public void Prompt_preview_should_be_absent_when_not_opted_in() + { + Activity? completed = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == SharpClawActivitySource.SourceName, + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + if (Equals(activity.GetTagItem("sharpclaw.session.id"), "session")) + { + completed = activity; + } + }, + }; + ActivitySource.AddActivityListener(listener); + + using (new TurnActivityScope("session", "turn")) + { + } + + completed.Should().NotBeNull(); + completed!.GetTagItem("sharpclaw.turn.prompt_preview").Should().BeNull(); + } +} From ea4d4d002bec9bcbb67cc166fe246ffa7e4adc2d Mon Sep 17 00:00:00 2001 From: telli Date: Wed, 9 Sep 2026 18:58:50 -0700 Subject: [PATCH 2/3] Address release readiness review feedback --- .github/scripts/Set-RepositorySecurity.ps1 | 27 +++++++++++ .github/scripts/Test-Packages.ps1 | 12 ++++- .github/scripts/Test-VulnerablePackages.ps1 | 33 ++++++++++++++ .github/workflows/provider-smoke.yml | 9 +++- .github/workflows/release.yml | 7 ++- SECURITY.md | 10 +++++ .../Internal/ProviderBackedAgentKernel.cs | 28 +++++++++--- .../Services/PlatformSecretProtector.cs | 31 +++++++++++-- .../Resilience/ResilientProviderDecorator.cs | 16 +++++-- .../Storage/SqliteSessionStore.cs | 14 +++--- .../Diagnostics/TurnActivityScope.cs | 20 +++++++-- .../Runtime/ProviderRuntimeEventFlowTests.cs | 40 +++++++++++++++++ .../PlatformSecretProtectorTests.cs | 7 +++ .../Providers/ResilienceTests.cs | 45 +++++++++++++++++++ .../Sessions/SessionStorageTests.cs | 1 + .../Telemetry/TurnActivityScopeTests.cs | 37 ++++++++++++++- 16 files changed, 310 insertions(+), 27 deletions(-) create mode 100644 .github/scripts/Set-RepositorySecurity.ps1 create mode 100644 .github/scripts/Test-VulnerablePackages.ps1 diff --git a/.github/scripts/Set-RepositorySecurity.ps1 b/.github/scripts/Set-RepositorySecurity.ps1 new file mode 100644 index 0000000..66c7304 --- /dev/null +++ b/.github/scripts/Set-RepositorySecurity.ps1 @@ -0,0 +1,27 @@ +param( + [string]$Branch = "main" +) + +$ErrorActionPreference = "Stop" +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path +$repository = (& gh repo view --json nameWithOwner --jq .nameWithOwner).Trim() +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repository)) { + throw "Could not resolve the GitHub repository. Authenticate gh and run this script from the checkout." +} + +$branchProtectionPath = Join-Path $repositoryRoot ".github/branch-protection.json" +$securitySettingsPath = Join-Path $repositoryRoot ".github/security-settings.json" + +& gh api --method PUT "repos/$repository/branches/$Branch/protection" --input $branchProtectionPath --silent +if ($LASTEXITCODE -ne 0) { throw "Could not apply branch protection to '$Branch'." } + +& gh api --method PATCH "repos/$repository" --input $securitySettingsPath --silent +if ($LASTEXITCODE -ne 0) { throw "Could not apply repository security settings." } + +& gh api --method PUT "repos/$repository/vulnerability-alerts" --silent +if ($LASTEXITCODE -ne 0) { throw "Could not enable vulnerability alerts." } + +& gh api --method PUT "repos/$repository/automated-security-fixes" --silent +if ($LASTEXITCODE -ne 0) { throw "Could not enable Dependabot security updates." } + +Write-Host "Applied branch protection and repository security settings to $repository ($Branch)." diff --git a/.github/scripts/Test-Packages.ps1 b/.github/scripts/Test-Packages.ps1 index 69d25b6..0162891 100644 --- a/.github/scripts/Test-Packages.ps1 +++ b/.github/scripts/Test-Packages.ps1 @@ -9,6 +9,7 @@ $scratchRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("sharpclaw-package-s $packageOutput = Join-Path $scratchRoot "packages" $toolPath = Join-Path $scratchRoot "tool" $consumerPath = Join-Path $scratchRoot "consumer" +$nugetConfigPath = Join-Path $scratchRoot "NuGet.Config" try { New-Item -ItemType Directory -Path $packageOutput -Force | Out-Null @@ -24,7 +25,16 @@ try { if ($LASTEXITCODE -ne 0) { throw "Could not create package smoke consumer." } & dotnet add (Join-Path $consumerPath "consumer.csproj") package SharpClaw.Code --version $PackageVersion --no-restore if ($LASTEXITCODE -ne 0) { throw "Could not add the aggregate SDK package." } - & dotnet restore (Join-Path $consumerPath "consumer.csproj") --source $packageOutput --source https://api.nuget.org/v3/index.json + + [System.IO.File]::WriteAllText( + $nugetConfigPath, + '') + & dotnet nuget add source $packageOutput --name sharpclaw-local --configfile $nugetConfigPath + if ($LASTEXITCODE -ne 0) { throw "Could not configure the local package source." } + & dotnet nuget add source "https://api.nuget.org/v3/index.json" --name nuget.org --configfile $nugetConfigPath + if ($LASTEXITCODE -ne 0) { throw "Could not configure the NuGet.org package source." } + + & dotnet restore (Join-Path $consumerPath "consumer.csproj") --configfile $nugetConfigPath if ($LASTEXITCODE -ne 0) { throw "Could not restore the aggregate SDK package." } & dotnet build (Join-Path $consumerPath "consumer.csproj") --configuration $Configuration --no-restore if ($LASTEXITCODE -ne 0) { throw "The aggregate SDK package failed to build in a clean consumer." } diff --git a/.github/scripts/Test-VulnerablePackages.ps1 b/.github/scripts/Test-VulnerablePackages.ps1 new file mode 100644 index 0000000..013eef8 --- /dev/null +++ b/.github/scripts/Test-VulnerablePackages.ps1 @@ -0,0 +1,33 @@ +param( + [string]$Target = "SharpClawCode.sln" +) + +$ErrorActionPreference = "Stop" +$reportJson = (& dotnet list $Target package --vulnerable --include-transitive --format json) -join [Environment]::NewLine +if ($LASTEXITCODE -ne 0) { throw "NuGet vulnerability inspection failed." } + +$report = $reportJson | ConvertFrom-Json +$vulnerablePackages = @( + foreach ($project in @($report.projects)) { + foreach ($framework in @($project.frameworks)) { + foreach ($package in @($framework.topLevelPackages) + @($framework.transitivePackages)) { + if ($null -ne $package -and @($package.vulnerabilities).Count -gt 0) { + [PSCustomObject]@{ + Project = $project.path + Framework = $framework.framework + Package = $package.id + ResolvedVersion = $package.resolvedVersion + Vulnerabilities = @($package.vulnerabilities) + } + } + } + } + } +) + +if ($vulnerablePackages.Count -gt 0) { + $vulnerablePackages | ConvertTo-Json -Depth 8 | Write-Error + throw "NuGet reported $($vulnerablePackages.Count) vulnerable package occurrence(s)." +} + +Write-Host "NuGet vulnerability audit passed for $($report.projects.Count) projects." diff --git a/.github/workflows/provider-smoke.yml b/.github/workflows/provider-smoke.yml index 728b355..e2b3503 100644 --- a/.github/workflows/provider-smoke.yml +++ b/.github/workflows/provider-smoke.yml @@ -35,12 +35,17 @@ jobs: - run: dotnet build src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --configuration Release --warnaserror - name: Select credential reference shell: pwsh + env: + PROVIDER: ${{ inputs.provider }} run: | - $provider = '${{ inputs.provider }}' + $provider = $env:PROVIDER $variableName = if ($provider -eq 'anthropic') { 'ANTHROPIC_API_KEY' } else { 'OPENAI_API_KEY' } if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($variableName))) { throw "The $variableName repository secret is required for this smoke test." } dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --no-build --configuration Release -- auth set-key --provider $provider --env-var $variableName - name: Run real provider prompt - run: dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --no-build --configuration Release -- --output-format json --model '${{ inputs.provider }}/${{ inputs.model }}' prompt 'Reply with exactly SHARPCLAW_PROVIDER_OK.' + env: + PROVIDER: ${{ inputs.provider }} + MODEL: ${{ inputs.model }} + run: dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --no-build --configuration Release -- --output-format json --model "$PROVIDER/$MODEL" prompt 'Reply with exactly SHARPCLAW_PROVIDER_OK.' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a1e1b2..80ff3df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,8 +16,10 @@ jobs: - name: Validate semantic version tag id: version shell: pwsh + env: + REF_NAME: ${{ github.ref_name }} run: | - $version = "${{ github.ref_name }}" -replace '^v', '' + $version = $env:REF_NAME -replace '^v', '' if ($version -notmatch '^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$') { throw "Tag is not a supported semantic version." } "value=$version" >> $env:GITHUB_OUTPUT - name: Setup .NET @@ -27,7 +29,8 @@ jobs: - name: Restore run: dotnet restore SharpClawCode.sln - name: Audit dependencies - run: dotnet list SharpClawCode.sln package --vulnerable --include-transitive + shell: pwsh + run: ./.github/scripts/Test-VulnerablePackages.ps1 -Target SharpClawCode.sln - name: Build run: dotnet build SharpClawCode.sln --no-restore --configuration Release --warnaserror - name: Test diff --git a/SECURITY.md b/SECURITY.md index d137512..bfe6f9e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,3 +9,13 @@ Until the first stable release, security fixes are applied to the latest preview Use GitHub's private vulnerability reporting for this repository. Do not open a public issue or include live credentials, private prompts, session files, or exploit details in public logs. Include the affected version, impact, reproduction conditions, and any suggested mitigation. Maintainers will acknowledge a complete report within five business days and coordinate remediation and disclosure. Local provider credentials are protected for the current operating-system user. On Windows this uses DPAPI; on macOS and Linux it uses an AES-GCM key stored with user-only file permissions under the user SharpClaw directory. This protects against accidental disclosure at rest, but it does not protect against another process already running as the same user. + +## Repository security administration + +Maintainers with repository administration access can idempotently apply the checked-in branch protection, secret scanning, push protection, vulnerability alerts, and Dependabot security-update settings with: + +```powershell +./.github/scripts/Set-RepositorySecurity.ps1 -Branch main +``` + +The JSON files under `.github` are inputs to that script; committing them alone does not change GitHub settings. diff --git a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs index b3fe7ec..a9de4b1 100644 --- a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs +++ b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs @@ -78,7 +78,11 @@ internal async Task ExecuteAsync( ProviderExecutionException? lastCandidateFailure = null; foreach (var candidate in resolvedCandidates) { - var candidateModel = ResolveCandidateModel(providerCatalogOptions.Value, candidate.ProviderName, requestedModel); + var candidateModel = ResolveCandidateModel( + providerCatalogOptions.Value, + resolvedProviderName, + candidate.ProviderName, + requestedModel); try { var authStatus = string.Equals(candidate.ProviderName, resolvedProviderName, StringComparison.OrdinalIgnoreCase) @@ -144,7 +148,11 @@ internal async Task ExecuteAsync( var activeProviderIndex = 0; var activeProviderName = providerCandidates[0].ProviderName; - var activeModel = ResolveCandidateModel(providerCatalogOptions.Value, activeProviderName, requestedModel); + var activeModel = ResolveCandidateModel( + providerCatalogOptions.Value, + resolvedProviderName, + activeProviderName, + requestedModel); // --- Build initial conversation messages --- // Do not add request.Instructions as a shared "system" chat message here. @@ -185,7 +193,11 @@ internal async Task ExecuteAsync( for (var candidateIndex = activeProviderIndex; candidateIndex < providerCandidates.Count; candidateIndex++) { var provider = providerCandidates[candidateIndex]; - var candidateModel = ResolveCandidateModel(providerCatalogOptions.Value, provider.ProviderName, requestedModel); + var candidateModel = ResolveCandidateModel( + providerCatalogOptions.Value, + resolvedProviderName, + provider.ProviderName, + requestedModel); var providerRequest = providerRequestPreflight.Prepare(new ProviderRequest( Id: $"provider-request-{Guid.NewGuid():N}", SessionId: request.Context.SessionId, @@ -451,8 +463,14 @@ private static string CreateProviderFailedEventMessage(string providerName, Prov return $"Provider '{providerName}' stream failed: {detail}"; } - private static string ResolveCandidateModel(ProviderCatalogOptions options, string providerName, string primaryModel) - => options.FallbackModels.TryGetValue(providerName, out var fallbackModel) && !string.IsNullOrWhiteSpace(fallbackModel) + private static string ResolveCandidateModel( + ProviderCatalogOptions options, + string primaryProviderName, + string candidateProviderName, + string primaryModel) + => !string.Equals(candidateProviderName, primaryProviderName, StringComparison.OrdinalIgnoreCase) + && options.FallbackModels.TryGetValue(candidateProviderName, out var fallbackModel) + && !string.IsNullOrWhiteSpace(fallbackModel) ? fallbackModel : primaryModel; } diff --git a/src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs b/src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs index 1bcea25..56f1405 100644 --- a/src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs +++ b/src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs @@ -112,17 +112,34 @@ private string UnprotectWithAes(string encodedPayload) private byte[] GetOrCreateUnixKey() { + if (OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("AES key-file protection is available only on macOS and Linux."); + } + var root = userProfilePaths.GetUserSharpClawRoot(); - Directory.CreateDirectory(root); + var directoryMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + Directory.CreateDirectory(root, directoryMode); + File.SetUnixFileMode(root, directoryMode); var keyPath = Path.Combine(root, KeyFileName); if (!File.Exists(keyPath)) { + var temporaryKeyPath = Path.Combine(root, $".{KeyFileName}.{Guid.NewGuid():N}.tmp"); var generatedKey = RandomNumberGenerator.GetBytes(32); try { - using var stream = new FileStream(keyPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + using var stream = new FileStream(temporaryKeyPath, new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.WriteThrough, + UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite, + }); stream.Write(generatedKey); + stream.Flush(flushToDisk: true); + File.Move(temporaryKeyPath, keyPath, overwrite: false); } catch (IOException) when (File.Exists(keyPath)) { @@ -130,15 +147,21 @@ private byte[] GetOrCreateUnixKey() } finally { + if (File.Exists(temporaryKeyPath)) + { + File.Delete(temporaryKeyPath); + } + CryptographicOperations.ZeroMemory(generatedKey); } } - if (!OperatingSystem.IsWindows()) + if ((File.GetAttributes(keyPath) & FileAttributes.ReparsePoint) != 0) { - File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + throw new CryptographicException($"The local secret-protection key at '{keyPath}' cannot be a symbolic link."); } + File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); var key = File.ReadAllBytes(keyPath); return key.Length == 32 ? key diff --git a/src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs b/src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs index 0b41e7d..99dfbf3 100644 --- a/src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs +++ b/src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs @@ -50,9 +50,11 @@ private async IAsyncEnumerable ExecuteWithResilienceAsync( [EnumeratorCancellation] CancellationToken callerCancellationToken) { Exception? lastException = null; + var attemptsMade = 0; for (var attempt = 0; attempt <= _options.MaxRetries; attempt++) { + attemptsMade = attempt + 1; callerCancellationToken.ThrowIfCancellationRequested(); using var timeoutCts = new CancellationTokenSource(_options.RequestTimeout); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(callerCancellationToken, timeoutCts.Token); @@ -140,7 +142,7 @@ private async IAsyncEnumerable ExecuteWithResilienceAsync( lastException = attemptException; RecordFailureAndOpenCircuitIfNeeded(); - if (emittedEvent || attempt >= _options.MaxRetries) + if (emittedEvent || attempt >= _options.MaxRetries || IsCircuitOpen()) { break; } @@ -160,7 +162,7 @@ private async IAsyncEnumerable ExecuteWithResilienceAsync( ProviderName, request.Model, ProviderFailureKind.StreamFailed, - $"Provider '{ProviderName}' failed while streaming after {_options.MaxRetries + 1} attempt(s).", + $"Provider '{ProviderName}' failed while streaming after {attemptsMade} attempt(s).", lastException); } @@ -205,7 +207,7 @@ private static bool IsTransient(Exception exception) } return exception is not ArgumentException - && exception is HttpRequestException or TaskCanceledException or TimeoutException or IOException; + && exception is HttpRequestException or OperationCanceledException or TimeoutException or IOException; } private TimeSpan ComputeDelay(int attempt, Exception exception) @@ -237,6 +239,14 @@ private void ResetCircuit() } } + private bool IsCircuitOpen() + { + lock (_lock) + { + return _circuitOpen; + } + } + private void RecordFailureAndOpenCircuitIfNeeded() { lock (_lock) diff --git a/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs b/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs index 74cc085..a22190b 100644 --- a/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs +++ b/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs @@ -59,15 +59,16 @@ ON CONFLICT(session_id) DO UPDATE SET .ConfigureAwait(false); await using var command = connection.CreateCommand(); command.CommandText = """ - SELECT payload_json + SELECT session_id, payload_json FROM sessions ORDER BY updated_at_utc DESC; """; await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { - var payload = reader.IsDBNull(0) ? null : reader.GetString(0); - var session = Deserialize(payload, sessionId: null); + var sessionId = reader.IsDBNull(0) ? null : reader.GetString(0); + var payload = reader.IsDBNull(1) ? null : reader.GetString(1); + var session = Deserialize(payload, sessionId); if (session is not null) { return session; @@ -85,7 +86,7 @@ public async Task> ListAllAsync(string worksp .ConfigureAwait(false); await using var command = connection.CreateCommand(); command.CommandText = """ - SELECT payload_json + SELECT session_id, payload_json FROM sessions ORDER BY updated_at_utc DESC; """; @@ -94,8 +95,9 @@ FROM sessions await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { - var payload = reader.IsDBNull(0) ? null : reader.GetString(0); - var session = Deserialize(payload, sessionId: null); + var sessionId = reader.IsDBNull(0) ? null : reader.GetString(0); + var payload = reader.IsDBNull(1) ? null : reader.GetString(1); + var session = Deserialize(payload, sessionId); if (session is not null) { sessions.Add(session); diff --git a/src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs b/src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs index 912fbe2..ddcd743 100644 --- a/src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs +++ b/src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs @@ -27,7 +27,9 @@ public TurnActivityScope(string sessionId, string turnId, string? prompt = null, var redacted = RedactSecrets(prompt); _activity?.SetTag( "sharpclaw.turn.prompt_preview", - redacted.Length > promptPreviewMaxLength ? redacted[..promptPreviewMaxLength] + "..." : redacted); + redacted.Length > promptPreviewMaxLength + ? redacted[..(promptPreviewMaxLength - 3)] + "..." + : redacted); } } @@ -67,17 +69,29 @@ public void SetError(Exception exception) private static string RedactSecrets(string prompt) { - var redactedAssignments = Regex.Replace( + var redactedAuthorization = Regex.Replace( prompt, + @"(?i)\bauthorization\s*[:=]\s*(?:bearer|basic)\s+[^\s,;]+", + "Authorization=[REDACTED]", + RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + var redactedAssignments = Regex.Replace( + redactedAuthorization, @"(?i)\b(api[_-]?key|access[_-]?token|token|password|secret)\s*[:=]\s*[^\s,;]+", "$1=[REDACTED]", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); - return Regex.Replace( + var redactedProviderKeys = Regex.Replace( redactedAssignments, @"\bsk-[A-Za-z0-9_-]{8,}\b", "[REDACTED]", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + return Regex.Replace( + redactedProviderKeys, + @"\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b", + "[REDACTED]", + RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); } } diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs index 333b6f6..ecae98e 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/ProviderRuntimeEventFlowTests.cs @@ -275,6 +275,39 @@ public async Task RunPrompt_should_use_fallback_provider_after_primary_stream_fa result.Events.OfType().Should().ContainSingle(e => e.ProviderName == "fallback-provider"); } + /// + /// Ensures a provider's fallback-model mapping cannot replace its explicitly requested primary model. + /// + [Fact] + public async Task RunPrompt_should_preserve_requested_model_for_primary_provider() + { + var workspacePath = CreateTemporaryWorkspace(); + using var serviceProvider = CreateRuntimeServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(new ExpectedModelProviderResolver("stub-model")); + services.Configure(options => options.FallbackModels["stub-provider"] = "fallback-model"); + }); + var runtime = serviceProvider.GetRequiredService(); + + var result = await runtime.RunPromptAsync( + new RunPromptRequest( + Prompt: "preserve the primary model", + SessionId: null, + WorkingDirectory: workspacePath, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + Metadata: new Dictionary + { + ["provider"] = "stub-provider", + ["model"] = "stub-model" + }), + CancellationToken.None); + + result.FinalOutput.Should().Be("Hello world"); + } + private static string CreateTemporaryWorkspace() { var workspacePath = Path.Combine(Path.GetTempPath(), "sharpclaw-provider-tests", Guid.NewGuid().ToString("N")); @@ -345,6 +378,13 @@ private sealed class FallbackModelProviderResolver : IModelProviderResolver public IReadOnlyList ResolveCandidates(string providerName) => [_primary, _fallback]; } + private sealed class ExpectedModelProviderResolver(string expectedModel) : IModelProviderResolver + { + private readonly IModelProvider _provider = new StubModelProvider(expectedModel: expectedModel); + + public IModelProvider Resolve(string providerName) => _provider; + } + private sealed class FailIfInvokedModelProviderResolver : IModelProviderResolver { public IModelProvider Resolve(string providerName) => new FailIfInvokedModelProvider(); diff --git a/tests/SharpClaw.Code.UnitTests/Infrastructure/PlatformSecretProtectorTests.cs b/tests/SharpClaw.Code.UnitTests/Infrastructure/PlatformSecretProtectorTests.cs index 4873b3a..7379b7e 100644 --- a/tests/SharpClaw.Code.UnitTests/Infrastructure/PlatformSecretProtectorTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Infrastructure/PlatformSecretProtectorTests.cs @@ -34,6 +34,13 @@ public void Protect_should_roundtrip_without_plaintext() payload.Should().NotContain("super-secret-value"); protector.Unprotect(payload).Should().Be("super-secret-value"); protector.CanProtect.Should().BeTrue(); + if (!OperatingSystem.IsWindows()) + { + File.GetUnixFileMode(_tempDirectory).Should().Be( + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + File.GetUnixFileMode(Path.Combine(_tempDirectory, "secret-protection.key")).Should().Be( + UnixFileMode.UserRead | UnixFileMode.UserWrite); + } } /// diff --git a/tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs b/tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs index 59174c0..99ad2a2 100644 --- a/tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs @@ -224,6 +224,51 @@ public async Task Request_timeout_covers_async_enumeration() mock.CallCount.Should().Be(1); } + [Fact] + public async Task Retries_operation_canceled_failure_when_caller_is_not_canceled() + { + var fakeHandle = new ProviderStreamHandle(FakeRequest, AsyncEnumerable.Empty()); + var mock = new CountingMockProvider(); + mock.Behaviors.Enqueue(() => throw new OperationCanceledException("provider timeout")); + mock.Behaviors.Enqueue(() => Task.FromResult(fakeHandle)); + var decorator = BuildDecorator(mock); + + var stream = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None); + await DrainAsync(stream.Events); + + mock.CallCount.Should().Be(2); + } + + [Fact] + public async Task Circuit_breaker_stops_retries_in_current_request() + { + var options = new ProviderResilienceOptions + { + MaxRetries = 3, + InitialRetryDelay = TimeSpan.Zero, + MaxRetryDelay = TimeSpan.Zero, + RequestTimeout = TimeSpan.FromSeconds(30), + CircuitBreakerFailureThreshold = 2, + CircuitBreakerBreakDuration = TimeSpan.FromHours(1), + }; + var mock = new CountingMockProvider(); + for (var i = 0; i < 4; i++) + { + mock.Behaviors.Enqueue(() => throw new IOException("provider unavailable")); + } + var decorator = BuildDecorator(mock, options); + + var act = async () => + { + var stream = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None); + await DrainAsync(stream.Events); + }; + + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("after 2 attempt(s)"); + mock.CallCount.Should().Be(2); + } + private static async Task DrainAsync(IAsyncEnumerable events) => _ = await CollectAsync(events); diff --git a/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs b/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs index 577cab2..afdff53 100644 --- a/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs @@ -23,6 +23,7 @@ private SharpClaw.Code.Infrastructure.Abstractions.IRuntimeStoragePathResolver C public void Dispose() { + SqliteConnection.ClearAllPools(); if (Directory.Exists(_tempDir)) { Directory.Delete(_tempDir, recursive: true); diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/TurnActivityScopeTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/TurnActivityScopeTests.cs index 40da7d8..e42fb90 100644 --- a/tests/SharpClaw.Code.UnitTests/Telemetry/TurnActivityScopeTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/TurnActivityScopeTests.cs @@ -42,7 +42,42 @@ public void Prompt_preview_should_be_redacted_and_truncated() var preview = completed!.GetTagItem("sharpclaw.turn.prompt_preview")?.ToString(); preview.Should().NotContain("hunter2").And.NotContain("top-secret").And.NotContain("sk-abcdefghijklm"); preview.Should().Contain("[REDACTED]"); - preview!.Length.Should().BeLessThanOrEqualTo(51); + preview!.Length.Should().BeLessThanOrEqualTo(48); + } + + /// + /// Ensures authorization headers and JWT-shaped values are removed before export. + /// + [Fact] + public void Prompt_preview_should_redact_bearer_authorization() + { + Activity? completed = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == SharpClawActivitySource.SourceName, + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + if (Equals(activity.GetTagItem("sharpclaw.session.id"), "bearer-session")) + { + completed = activity; + } + }, + }; + ActivitySource.AddActivityListener(listener); + const string credential = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"; + + using (new TurnActivityScope( + "bearer-session", + "turn", + $"Authorization: Bearer {credential}", + promptPreviewMaxLength: 200)) + { + } + + var preview = completed!.GetTagItem("sharpclaw.turn.prompt_preview")?.ToString(); + preview.Should().Be("Authorization=[REDACTED]"); + preview.Should().NotContain(credential); } /// From a005830e5ffbb281fa7ceb7b7a3e23122ecfa3ee Mon Sep 17 00:00:00 2001 From: telli Date: Wed, 9 Sep 2026 19:08:18 -0700 Subject: [PATCH 3/3] Anchor repository security provisioning --- .github/scripts/Set-RepositorySecurity.ps1 | 34 +++++++++++++--------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/.github/scripts/Set-RepositorySecurity.ps1 b/.github/scripts/Set-RepositorySecurity.ps1 index 66c7304..7259915 100644 --- a/.github/scripts/Set-RepositorySecurity.ps1 +++ b/.github/scripts/Set-RepositorySecurity.ps1 @@ -4,24 +4,30 @@ param( $ErrorActionPreference = "Stop" $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path -$repository = (& gh repo view --json nameWithOwner --jq .nameWithOwner).Trim() -if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repository)) { - throw "Could not resolve the GitHub repository. Authenticate gh and run this script from the checkout." -} - $branchProtectionPath = Join-Path $repositoryRoot ".github/branch-protection.json" $securitySettingsPath = Join-Path $repositoryRoot ".github/security-settings.json" -& gh api --method PUT "repos/$repository/branches/$Branch/protection" --input $branchProtectionPath --silent -if ($LASTEXITCODE -ne 0) { throw "Could not apply branch protection to '$Branch'." } +Push-Location $repositoryRoot +try { + $repository = (& gh repo view --json nameWithOwner --jq .nameWithOwner).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repository)) { + throw "Could not resolve the GitHub repository. Authenticate gh and run this script from the checkout." + } + + & gh api --method PUT "repos/$repository/branches/$Branch/protection" --input $branchProtectionPath --silent + if ($LASTEXITCODE -ne 0) { throw "Could not apply branch protection to '$Branch'." } -& gh api --method PATCH "repos/$repository" --input $securitySettingsPath --silent -if ($LASTEXITCODE -ne 0) { throw "Could not apply repository security settings." } + & gh api --method PATCH "repos/$repository" --input $securitySettingsPath --silent + if ($LASTEXITCODE -ne 0) { throw "Could not apply repository security settings." } -& gh api --method PUT "repos/$repository/vulnerability-alerts" --silent -if ($LASTEXITCODE -ne 0) { throw "Could not enable vulnerability alerts." } + & gh api --method PUT "repos/$repository/vulnerability-alerts" --silent + if ($LASTEXITCODE -ne 0) { throw "Could not enable vulnerability alerts." } -& gh api --method PUT "repos/$repository/automated-security-fixes" --silent -if ($LASTEXITCODE -ne 0) { throw "Could not enable Dependabot security updates." } + & gh api --method PUT "repos/$repository/automated-security-fixes" --silent + if ($LASTEXITCODE -ne 0) { throw "Could not enable Dependabot security updates." } -Write-Host "Applied branch protection and repository security settings to $repository ($Branch)." + Write-Host "Applied branch protection and repository security settings to $repository ($Branch)." +} +finally { + Pop-Location +}