Compare commits

..

88 Commits

Author SHA1 Message Date
LC 2cf030d2fd fix(providers): surface friendly auth error messages (#3198) 2026-06-30 17:42:07 +08:00
Mauro 52320f4875 Merge pull request #3181 from Alix-007/fix/gateway-startup-info-ok
fix(gateway): guard startup info assertions
2026-06-26 23:06:34 +02:00
Mauro c15aac21fe Merge pull request #3143 from lc6464/fix/isatap-ssrf-guard
fix(web): block private IPv4 embeds in ISATAP literals
2026-06-26 22:59:41 +02:00
Mauro 7ee4f41b7e Merge pull request #3187 from chengzhichao-xydt/codex/http-guard-test-body-close
test(utils): explicitly ignore resp.Body.Close() errors in tests
2026-06-26 18:11:24 +02:00
Mauro d65d592a40 Merge pull request #3188 from chengzhichao-xydt/codex/health-encode-errors
fix(health): explicitly ignore json.Encode errors in HTTP handler responses
2026-06-26 18:11:05 +02:00
Mauro 06830c9b19 Merge pull request #3186 from chengzhichao-xydt/codex/membench-body-close
fix(membench): explicitly ignore resp.Body.Close() error after io.ReadAll
2026-06-26 18:10:36 +02:00
Mauro 9bc8d15685 Merge pull request #3185 from chengzhichao-xydt/codex/updater-checksum-body-close
fix(updater): explicitly ignore resp2.Body.Close() error after io.ReadAll
2026-06-26 18:10:21 +02:00
Mauro 979f440af7 Merge pull request #3184 from chengzhichao-xydt/codex/ws-body-close
fix(channels): explicitly ignore resp.Body.Close() errors in websocket dial cleanup
2026-06-26 18:10:06 +02:00
Mauro 377c25e7c5 Merge pull request #3183 from chengzhichao-xydt/codex/onebot-body-close
fix(onebot): explicitly ignore resp.Body.Close() error after websocket dial
2026-06-26 18:09:04 +02:00
程智超0668000959 2cdfadaa8b test(utils): explicitly ignore resp.Body.Close() errors in tests 2026-06-26 23:03:32 +08:00
程智超0668000959 0eb721fff7 fix(health): explicitly ignore json.Encode errors in HTTP handler responses 2026-06-26 22:57:48 +08:00
程智超0668000959 14c9a1f1f8 fix(membench): explicitly ignore resp.Body.Close() error after io.ReadAll 2026-06-26 22:51:51 +08:00
程智超0668000959 500ce72436 fix(onebot): explicitly ignore resp.Body.Close() error after websocket dial 2026-06-26 22:42:43 +08:00
程智超0668000959 583d9e1e14 fix(updater): explicitly ignore resp2.Body.Close() error after io.ReadAll 2026-06-26 22:15:29 +08:00
程智超0668000959 1bd2e84527 fix(channels): explicitly ignore resp.Body.Close() errors in websocket dial cleanup 2026-06-26 22:15:03 +08:00
Alix-007 36d72a0112 fix(gateway): guard startup info assertions 2026-06-26 16:11:54 +08:00
Mauro 1260fd8e2f Merge pull request #3176 from sipeed/dependabot/go_modules/github.com/mymmrac/telego-1.10.0
build(deps): bump github.com/mymmrac/telego from 1.9.0 to 1.10.0
2026-06-26 08:43:46 +02:00
Mauro 54f1ab75e2 Merge pull request #3175 from sipeed/dependabot/go_modules/fyne.io/systray-1.12.2
build(deps): bump fyne.io/systray from 1.12.1 to 1.12.2
2026-06-26 08:43:02 +02:00
Mauro 7f90ce7a98 Merge pull request #3174 from sipeed/dependabot/go_modules/github.com/line/line-bot-sdk-go/v8-8.20.1
build(deps): bump github.com/line/line-bot-sdk-go/v8 from 8.20.0 to 8.20.1
2026-06-26 08:42:42 +02:00
Mauro 70b2364f39 Merge pull request #3173 from sipeed/dependabot/go_modules/modernc.org/sqlite-1.53.0
build(deps): bump modernc.org/sqlite from 1.51.0 to 1.53.0
2026-06-26 08:42:17 +02:00
Mauro 124c529bf1 Merge pull request #3172 from chengzhichao-xydt/codex/close-error-checks-clean
fix: explicitly ignore Close() errors in error paths and retry loops
2026-06-26 08:41:31 +02:00
Mauro 5feeb91c60 Merge pull request #3170 from chengzhichao-xydt/codex/base64-encoder-close-clean
fix(agent): close base64 encoder on io.Copy error path
2026-06-26 08:40:58 +02:00
dependabot[bot] 9287df415b build(deps): bump github.com/mymmrac/telego from 1.9.0 to 1.10.0
Bumps [github.com/mymmrac/telego](https://github.com/mymmrac/telego) from 1.9.0 to 1.10.0.
- [Release notes](https://github.com/mymmrac/telego/releases)
- [Commits](https://github.com/mymmrac/telego/compare/v1.9.0...v1.10.0)

---
updated-dependencies:
- dependency-name: github.com/mymmrac/telego
  dependency-version: 1.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 17:14:13 +00:00
dependabot[bot] ae81ae93cf build(deps): bump fyne.io/systray from 1.12.1 to 1.12.2
Bumps [fyne.io/systray](https://github.com/fyne-io/systray) from 1.12.1 to 1.12.2.
- [Changelog](https://github.com/fyne-io/systray/blob/master/CHANGELOG.md)
- [Commits](https://github.com/fyne-io/systray/compare/v1.12.1...v1.12.2)

---
updated-dependencies:
- dependency-name: fyne.io/systray
  dependency-version: 1.12.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 17:14:09 +00:00
dependabot[bot] e4bda8c4a1 build(deps): bump github.com/line/line-bot-sdk-go/v8
Bumps [github.com/line/line-bot-sdk-go/v8](https://github.com/line/line-bot-sdk-go) from 8.20.0 to 8.20.1.
- [Release notes](https://github.com/line/line-bot-sdk-go/releases)
- [Commits](https://github.com/line/line-bot-sdk-go/compare/v8.20.0...v8.20.1)

---
updated-dependencies:
- dependency-name: github.com/line/line-bot-sdk-go/v8
  dependency-version: 8.20.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 17:14:04 +00:00
dependabot[bot] 3400221527 build(deps): bump modernc.org/sqlite from 1.51.0 to 1.53.0
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.51.0 to 1.53.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.51.0...v1.53.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 17:13:40 +00:00
程智超0668000959 7ee4ee3b64 fix: correct indentation in shell.go and updater.go for gci linter 2026-06-25 16:11:34 +08:00
程智超0668000959 62a2b0015a fix: explicitly ignore Close() errors in error paths and retry loops 2026-06-25 15:38:41 +08:00
程智超0668000959 d6371fcb68 fix(agent): close base64 encoder on io.Copy error path
Previously when io.Copy to the base64 encoder failed, encoder.Close()
was skipped. This left the encoder's internal buffer unflushed.
Now always call Close() and handle both copy and close errors
explicitly.
2026-06-25 15:23:34 +08:00
Mauro dff16dbb91 Merge pull request #3169 from Alix-007/fix/evolution-heartbeat-coldpath
fix(evolution): skip cold path for heartbeat turns
2026-06-25 08:59:03 +02:00
Mauro 0a47fc8eb1 Merge pull request #3168 from Alix-007/fix/model-list-http-error-read
fix(model): handle error response read failures
2026-06-25 08:42:36 +02:00
Alix-007 f53e6e68e2 fix(model): avoid shadowing response error 2026-06-24 23:35:06 +08:00
Alix-007 288013ca0f fix(evolution): skip cold path for heartbeat turns 2026-06-24 23:29:46 +08:00
Alix-007 5e3fd4cd0f fix(model): handle error response read failures 2026-06-24 23:29:46 +08:00
Alix-007 540f81ff9e fix(openai_compat): use structured logger for native_search warning 2026-06-24 18:30:44 +08:00
Mauro a75b3d15bb Merge pull request #3053 from chengzhichao-xydt/codex/store-lock-type-assert
fix(evolution): add ok check for LoadOrStore type assertion in lockStoreFile
2026-06-23 00:04:42 +02:00
Mauro eb8b6ca7c8 Merge pull request #3101 from sipeed/dependabot/npm_and_yarn/web/frontend/vite-8.0.16
build(deps-dev): bump vite from 8.0.13 to 8.0.16 in /web/frontend
2026-06-22 23:13:12 +02:00
Mauro ec9c6cde8e Merge pull request #3091 from chengzhichao-xydt/codex/provider-native-search-type-assert
fix(openai_compat): add ok check for native_search type assertion
2026-06-22 23:12:51 +02:00
dependabot[bot] 646c2db1e9 build(deps-dev): bump vite from 8.0.13 to 8.0.16 in /web/frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.13 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 21:04:17 +00:00
Mauro dc46c05aac Merge pull request #3105 from sipeed/dependabot/npm_and_yarn/web/frontend/eslint-10.4.1
build(deps-dev): bump eslint from 10.2.1 to 10.4.1 in /web/frontend
2026-06-22 23:01:39 +02:00
Mauro 43c671abdc Merge pull request #3152 from phoeagon/main
add installation instructions to picoclaw skills search.
2026-06-22 22:59:12 +02:00
phoeagon a63b44ac74 add installation instructions to picoclaw skills search. 2026-06-21 14:15:27 -07:00
Mauro 287853ab58 Merge pull request #3144 from sipeed/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 6 to 7
2026-06-18 22:09:12 +02:00
Mauro 0d1ed99007 Merge pull request #3146 from sipeed/dependabot/go_modules/golang.org/x/term-0.44.0
build(deps): bump golang.org/x/term from 0.43.0 to 0.44.0
2026-06-18 22:08:40 +02:00
Mauro 70afab59e6 Merge pull request #3147 from sipeed/dependabot/go_modules/github.com/Azure/azure-sdk-for-go/sdk/azidentity-1.14.0
build(deps): bump github.com/Azure/azure-sdk-for-go/sdk/azidentity from 1.13.1 to 1.14.0
2026-06-18 22:08:22 +02:00
Mauro a3be5a6b25 Merge pull request #3149 from sipeed/dependabot/go_modules/github.com/anthropics/anthropic-sdk-go-1.50.2
build(deps): bump github.com/anthropics/anthropic-sdk-go from 1.46.0 to 1.50.2
2026-06-18 22:07:53 +02:00
Mauro 62206befe1 Merge pull request #3148 from sipeed/dependabot/go_modules/golang.org/x/sys-0.46.0
build(deps): bump golang.org/x/sys from 0.45.0 to 0.46.0
2026-06-18 22:07:28 +02:00
dependabot[bot] 38a1d823c3 build(deps): bump github.com/anthropics/anthropic-sdk-go
Bumps [github.com/anthropics/anthropic-sdk-go](https://github.com/anthropics/anthropic-sdk-go) from 1.46.0 to 1.50.2.
- [Release notes](https://github.com/anthropics/anthropic-sdk-go/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-go/compare/v1.46.0...v1.50.2)

---
updated-dependencies:
- dependency-name: github.com/anthropics/anthropic-sdk-go
  dependency-version: 1.50.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 17:14:29 +00:00
dependabot[bot] 35db389025 build(deps): bump golang.org/x/sys from 0.45.0 to 0.46.0
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.45.0 to 0.46.0.
- [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 17:14:24 +00:00
dependabot[bot] aeab7b15a4 build(deps): bump github.com/Azure/azure-sdk-for-go/sdk/azidentity
Bumps [github.com/Azure/azure-sdk-for-go/sdk/azidentity](https://github.com/Azure/azure-sdk-for-go) from 1.13.1 to 1.14.0.
- [Release notes](https://github.com/Azure/azure-sdk-for-go/releases)
- [Commits](https://github.com/Azure/azure-sdk-for-go/compare/sdk/azidentity/v1.13.1...sdk/azcore/v1.14.0)

---
updated-dependencies:
- dependency-name: github.com/Azure/azure-sdk-for-go/sdk/azidentity
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 17:14:21 +00:00
dependabot[bot] 6d0ca80f21 build(deps): bump golang.org/x/term from 0.43.0 to 0.44.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.43.0 to 0.44.0.
- [Commits](https://github.com/golang/term/compare/v0.43.0...v0.44.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 17:14:11 +00:00
dependabot[bot] 4514d54ff5 build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 17:13:51 +00:00
lc6464 d4adbf9418 fix(web): block private IPv4 embeds in ISATAP literals 2026-06-18 17:12:38 +08:00
Mauro 99291182e7 Merge pull request #3141 from jincheng-xydt/fix/3125-brave-search-logging
fix(web_search): add diagnostic logging for Brave empty results
2026-06-18 08:16:14 +02:00
Mauro 6c31f9ffdd Merge pull request #3136 from ZOOWH/fix/3111-gemini-thought-signature
fix(gemini): set both camelCase and snake_case thought_signature in tool call request body
2026-06-17 20:40:13 +02:00
Mauro e3464f4388 Merge pull request #2917 from PierreLeGuen/nearai-provider
feat(provider): add NEAR AI Cloud provider
2026-06-17 20:37:38 +02:00
徐闻涵0668001344 08192ddf1b style(test): split long t.Fatalf line to satisfy golines 120-char limit 2026-06-17 17:49:52 +08:00
肆月 7b5fe01187 fix(web): update sogou search regex to match new HTML structure (#3139)
- reSogouTitle: make class attribute quotes optional (class="?resultLink"?)
- reSogouSnippet: allow additional CSS classes after clamp (clamp\d*[^"]*)

Sogou WAP search page changed its HTML format:
- class=resultLink -> class="resultLink" (added quotes)
- class="clamp3" -> class="clamp2 fz-mid click-sugg-content" (extra classes)

Fixes web_search tool failing to parse sogou search results.
2026-06-17 17:02:54 +08:00
lc6464 1420875921 fix(onebot): tighten inbound media download handling 2026-06-17 17:00:24 +08:00
lc6464 234bd03018 fix(onebot): block private inbound media fetches 2026-06-17 17:00:24 +08:00
徐金城0668000897 b2fbe83565 fix(web_search): add diagnostic logging for Brave empty results
When the Brave Search API returns HTTP 200 with zero results, log a
warning with the response body preview to help diagnose silent failures.
This addresses cases where the API response format has changed or a
non-standard error response is misinterpreted as a successful empty
result, leaving the LLM with a misleading "No results" message.

Previously, empty results were silently returned to the LLM as "No
results for: <query>", making it impossible to distinguish between
genuinely empty results and API format mismatches or silent errors.

Closes #3125
2026-06-17 16:23:25 +08:00
Mauro a16a1e1535 Merge pull request #3137 from jp39/cron-command-remotes
feat: allow configured remote cron commands
2026-06-16 22:16:05 +02:00
Mauro 910bfbe6a4 Merge pull request #3120 from carlosprados/feat/register-channel-settings
feat(config): add RegisterChannelSettings hook for out-of-tree channels
2026-06-16 22:09:00 +02:00
Mauro 03b022c2ac Merge pull request #3127 from chengzhichao-xydt/codex/filesystem-dirfile-close
fix: explicitly ignore Close() errors on directory file descriptors
2026-06-16 22:08:31 +02:00
Mauro cc4055b6d4 Merge pull request #3129 from chengzhichao-xydt/codex/tts-close-error
fix(tts): explicitly ignore file.Close() error in write error path
2026-06-16 22:08:10 +02:00
Mauro 86221d470b Merge pull request #3135 from ZOOWH/fix/3110-telegram-forum-thread-id
fix(telegram): use compositeChatID in InboundContext.ChatID for forum topics
2026-06-16 22:06:31 +02:00
Mauro e720be0dd7 Merge pull request #3132 from SiYue-ZO/fix/goroutine-recover
fix: add panic recovery to core-path goroutines
2026-06-16 22:05:31 +02:00
Mauro c9f5872e02 Merge pull request #3130 from chengzhichao-xydt/codex/seahorse-marshal-errors
fix(seahorse): handle json.Marshal errors in grep and expand tools
2026-06-16 22:04:45 +02:00
jp39 0c1e6b5279 docs: warn about cron remote wildcard
Document that command_allowed_remotes = ["*"] is potentially dangerous
because any remote channel that can talk to PicoClaw can schedule shell
commands. Clarify that it should only be used when all enabled remote
channels and chats are trusted.
2026-06-16 17:26:45 +02:00
jp39 981ab3affe fix: scope remote cron command access
Allow allowlisted remote channels to list, get, and update their own
command cron jobs while preserving exact channel/chat ownership checks.

Apply the same access control to remove, enable, and disable so remote
callers cannot mutate jobs outside their channel/chat scope. Keep wildcard
remote allowlist behavior constrained by exact job ownership.

Add regression tests for allowlisted, non-allowlisted, wildcard, internal,
and non-command reminder access paths.
2026-06-16 17:26:45 +02:00
jp39 3056a540bf feat: allow configured remote cron commands
Add tools.cron.command_allowed_remotes so remote channels can be
explicitly allowlisted for command-executing cron jobs. Entries support
channel names, channel:chat_id pairs, and a literal * to allow every
non-empty channel while preserving the default deny posture.

The existing allow_command and command_confirm checks still apply after the
channel gate. Update tests, config examples, and cron documentation for the
new option.
2026-06-16 17:26:45 +02:00
徐闻涵0668001344 5b41f18c3d fix(gemini): set both camelCase and snake_case thought_signature in tool call request body
The Gemini HTTP API provider only set thoughtSignature (camelCase) when
building tool call request bodies. Gemini 2.5 models accept camelCase,
but Gemini 3.5 Flash Agentic reasoning requires thought_signature
(snake_case) — matching the format it returns in API responses. When
only the camelCase variant was sent, Gemini 3.5 Flash rejected the
request with a 400 Bad Request for missing thought_signature.

The fix sets both ThoughtSignature and ThoughtSignatureSnake in the
geminiPart struct, matching the pattern already used in
antigravity_provider.go. This ensures compatibility with both Gemini
2.x (camelCase) and Gemini 3.x Agentic (snake_case) models.

Closes #3111
2026-06-16 20:51:59 +08:00
徐闻涵0668001344 7019143c8e fix(telegram): use compositeChatID in InboundContext.ChatID for forum topics
When handling inbound Telegram messages from forum topics, InboundContext.ChatID was set to the plain chat ID (without thread). This caused outbound messages to lose the thread routing, sending replies to the General topic instead of the specific forum topic — even though typing indicators correctly targeted the right thread because StartTyping received the compositeChatID.

The fix sets InboundContext.ChatID to compositeChatID (chatID/threadID format) for forum messages, matching the format already used for session routing and typing indicators. TopicID is still preserved as a redundant fallback for any path that strips the composite format.

Closes #3110
2026-06-16 20:43:37 +08:00
Guoguo 083e68b49a docs: add PicoPaw banners to READMEs (#3096) 2026-06-16 10:17:21 +08:00
SiYue-ZO fcc2882ffa fix: resolve golines lint issue in subscription.go
Split long log.Printf lines to comply with 120-char line length limit.
2026-06-16 00:53:18 +08:00
SiYue-ZO b292defd95 fix: add panic recovery to core-path goroutines
Add defer-recover to 11 goroutines across 4 files to prevent
ungoroutine panics from crashing the entire process:

- pkg/tools/toolloop.go: parallel tool execution
- pkg/channels/manager.go: HTTP server (x2), channel registration
- pkg/events/subscription.go: concurrent dispatch, timeout handler,
  watchContext
- pkg/tools/shell.go: cmd.Wait, PTY cmd.Wait, PTY read, pipe read

Key design decisions:
- Recover handlers send fallback values to channels (shell done,
  subscription done) to prevent deadlocks when the producer panics
- PTY cmd.Wait sets session.Status='error' on panic for consistency
- toolloop sets ErrorResult on panic so the LLM gets a meaningful
  response instead of a nil result
- subscription.go uses log.Printf to match existing invokeHandler style
- Other files use project logger (ErrorCF) with stack traces

Refs: FIX-PLAN-0.3.0 #2
2026-06-15 23:06:11 +08:00
程智超0668000959 ed55715b6b fix(seahorse): handle json.Marshal errors in grep and expand tools 2026-06-15 20:31:34 +08:00
程智超0668000959 2f8c462366 fix(tts): explicitly ignore file.Close() error in write error path 2026-06-15 20:30:09 +08:00
程智超0668000959 d2156a982c fix(fileutil): explicitly ignore Close() error on directory file descriptor 2026-06-15 20:25:04 +08:00
Carlos Prados 9eadffda1e test(config): cover RegisterChannelSettings hook
Unit tests prove an out-of-tree channel type becomes valid and decodable after
RegisterChannelSettings, including the full InitChannelList -> GetDecoded path that
previously failed with "unknown type".
2026-06-14 12:07:54 +02:00
Carlos Prados e55ddf2e24 feat(config): add RegisterChannelSettings hook for out-of-tree channels
Expose a public registration hook so external packages that register a channel
factory via channels.RegisterFactory can also register the channel's settings
struct prototype. Without this, InitChannelList rejects any channel type absent
from the private channelSettingsFactory map, making out-of-tree channels
impossible without forking. The map access is now guarded by a RWMutex.

This is additive and changes no existing behavior.
2026-06-14 11:52:32 +02:00
Pierre LE GUEN 7de3d1f7e3 feat(provider): add NEAR AI Cloud provider 2026-06-12 10:18:34 +00:00
程智超0668000959 d26e4eca7d chore(openai_compat): fix golines formatting for CI linter 2026-06-12 13:44:34 +08:00
程智超0668000959 bbdf746bcb fix(evolution): use CompareAndSwap for atomic lockStoreFile repair
Replace the Delete+LoadOrStore pattern with CompareAndSwap to
atomically swap in a fresh mutex when a corrupted entry is detected.

Changes:
- Use a retry loop with CAS instead of Delete+LoadOrStore, avoiding
  the race where two goroutines could create different mutexes
- Add nil check: a nil *sync.Mutex passes the type assertion but
  panics on Lock() — now handled by the CAS recovery path
- Use continue-loop retry instead of unchecked second assertion

CompareAndSwap guarantees that when multiple goroutines detect a
corrupted entry, they converge on the same replacement mutex.
2026-06-12 11:25:14 +08:00
程智超0668000959 409cc051e7 fix(openai_compat): log warning instead of silently discarding native_search ok check
Replace the no-op _ = ok with a warning log when native_search has an
unexpected type. When the type assertion fails, nativeSearch already
defaults to false, which is conservative — but the caller should know
their option was malformed.
2026-06-12 11:23:13 +08:00
dependabot[bot] c5a98a5f47 build(deps-dev): bump eslint from 10.2.1 to 10.4.1 in /web/frontend
Bumps [eslint](https://github.com/eslint/eslint) from 10.2.1 to 10.4.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.1...v10.4.1)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 17:16:28 +00:00
程智超0668000959 351ecf0182 fix(openai_compat): add ok check for native_search type assertion 2026-06-10 17:36:53 +08:00
程智超0668000959 f5add27d39 fix(evolution): add ok check for LoadOrStore type assertion in lockStoreFile 2026-06-10 14:05:12 +08:00
84 changed files with 3039 additions and 665 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Run Docker-backed integration suites
run: bash ./scripts/run-integration-tests.sh
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: main
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: main
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
steps:
# ── Checkout ──────────────────────────────
- name: 📥 Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ inputs.tag }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
+4 -4
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
@@ -32,7 +32,7 @@ jobs:
GOFLAGS: -tags=goolm,stdjson
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
@@ -52,7 +52,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
@@ -70,7 +70,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Run Docker-backed integration suites
run: bash ./scripts/run-integration-tests.sh
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
fi
- name: Checkout tag
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.tag }}
+16 -2
View File
@@ -20,6 +20,17 @@
[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English**
<p>
<a href="https://picopaw.ai">
<img src="assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div>
---
@@ -49,13 +60,15 @@
> **Security Notice**
>
> * **NO CRYPTO:** PicoClaw has **not** issued any official tokens or cryptocurrency. All claims on `pump.fun` or other trading platforms are **scams**.
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
> * **BEWARE:** Many `.ai/.org/.com/.net/...` domains have been registered by third parties. Do not trust them.
> * **OFFICIAL DOMAIN:** The **ONLY** official PicoClaw website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
> * **BEWARE:** Many lookalike `.ai/.org/.com/.net/...` domains have been registered by third parties. Only trust domains explicitly linked from this README.
> * **NOTE:** PicoClaw is in early rapid development. There may be unresolved security issues. Do not deploy to production before v1.0.
> * **NOTE:** PicoClaw has recently merged many PRs. Recent builds may use 10-20MB RAM. Resource optimization is planned after feature stabilization.
## 📢 News
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
<p align="center">
@@ -420,6 +433,7 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Required | Mistral Large, Codestral |
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Required | NVIDIA hosted models |
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Required | Fast inference |
| [NEAR AI Cloud](https://near.ai/) | `nearai/` | Required | TEE inference, OpenAI-compatible |
| [Novita AI](https://novita.ai/) | `novita/` | Required | Various open models |
| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Required | MiMo models |
| [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted |
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

+1 -1
View File
@@ -155,7 +155,7 @@ func (c *LLMClient) Complete(ctx context.Context, systemPrompt, userPrompt strin
}
respBody, lastErr = io.ReadAll(resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
if lastErr != nil {
continue
}
+16
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
@@ -68,6 +69,21 @@ func TestFetchOpenAIModels_HTTPError(t *testing.T) {
assert.Contains(t, err.Error(), "HTTP 401")
}
func TestFetchOpenAIModels_HTTPErrorReadFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
body := "short"
w.Header().Set("Content-Length", strconv.Itoa(len(body)+1))
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
_, err := fetchOpenAIModels(srv.URL, "bad")
require.Error(t, err)
assert.Contains(t, err.Error(), "read error response")
assert.Contains(t, err.Error(), "unexpected EOF")
}
func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[]}`))
+4 -1
View File
@@ -45,7 +45,10 @@ func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
if readErr != nil {
return nil, fmt.Errorf("read error response: %w", readErr)
}
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
+5
View File
@@ -302,6 +302,11 @@ func skillsSearchCmd(query string) {
if result.Version != "" {
fmt.Printf(" Version: %s\n", result.Version)
}
if result.RegistryName == "github" {
fmt.Printf(" Install: picoclaw skills install %s\n", result.Slug)
} else {
fmt.Printf(" Install: picoclaw skills install --registry=%s %s\n", result.RegistryName, result.Slug)
}
fmt.Println()
}
}
+8 -1
View File
@@ -67,6 +67,11 @@
"model": "venice/venice-uncensored",
"api_keys": ["your-venice-api-key"]
},
{
"model_name": "nearai-glm",
"model": "nearai/zai-org/GLM-5.1-FP8",
"api_keys": ["your-nearai-api-key"]
},
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
@@ -380,7 +385,9 @@
},
"cron": {
"enabled": true,
"exec_timeout_minutes": 5
"exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
},
"mcp": {
"enabled": false,
+5 -1
View File
@@ -1154,7 +1154,9 @@ PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can
"tools": {
"cron": {
"enabled": true,
"exec_timeout_minutes": 5
"exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
}
}
}
@@ -1162,6 +1164,8 @@ PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can
Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace/cron/`.
Command cron jobs can execute shell commands. By default, remote channels cannot schedule command jobs. To allow specific remote channels, set `command_allowed_remotes` to entries such as `"telegram"` or `"telegram:1234567890"`; use `"*"` only if every non-empty channel should be allowed. The `"*"` wildcard is potentially dangerous because any remote channel that can talk to PicoClaw can schedule shell commands. This does not bypass `allow_command`, `command_confirm`, or exec safety checks.
### Advanced Topics
| Topic | Description |
+16 -1
View File
@@ -17,6 +17,7 @@
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `venice` | LLM (Venice AI direct) | [venice.ai](https://venice.ai) |
| `nearai` | LLM (NEAR AI Cloud TEE inference) | [near.ai](https://near.ai) |
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
@@ -50,6 +51,7 @@ This design also enables **multi-agent support** with flexible provider selectio
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
| **NEAR AI Cloud** | `nearai` | `https://cloud-api.near.ai/v1` | OpenAI | [Get Key](https://near.ai) |
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
@@ -256,6 +258,17 @@ Notes that matter:
}
```
**NEAR AI Cloud**
```json
{
"model_name": "nearai-glm",
"provider": "nearai",
"model": "zai-org/GLM-5.1-FP8",
"api_keys": ["your-nearai-api-key"]
}
```
**VolcEngine (Doubao)**
```json
@@ -647,7 +660,9 @@ picoclaw agent -m "Hello"
}
},
"cron": {
"exec_timeout_minutes": 5
"exec_timeout_minutes": 5,
"allow_command": true,
"command_allowed_remotes": []
}
},
"heartbeat": {
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div>
---
@@ -57,6 +68,8 @@
## 📢 Actualités
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw disponible sur AliExpress !** Vous pouvez désormais acheter le LicheeRV-Claw sur [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), ce qui facilite l'essai de PicoClaw sur du matériel RISC-V compact.
<p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div>
---
@@ -56,6 +67,8 @@
## 📢 Berita
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Kini Anda dapat membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), sehingga lebih mudah mencoba PicoClaw di hardware RISC-V ringkas.
<p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div>
---
@@ -56,6 +67,8 @@
## 📢 Novità
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw disponibile su AliExpress!** Ora puoi acquistare LicheeRV-Claw su [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), rendendo più semplice provare PicoClaw su hardware RISC-V compatto.
<p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div>
---
@@ -56,6 +67,8 @@
## 📢 ニュース
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw が AliExpress で購入可能に!** [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) から LicheeRV-Claw を購入できるようになり、コンパクトな RISC-V ハードウェアで PicoClaw を試しやすくなりました。
<p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div>
---
@@ -56,6 +67,8 @@
## 📢 뉴스
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw를 AliExpress에서 구매할 수 있습니다!** 이제 [AliExpress](https://www.aliexpress.com/item/1005006519668532.html)에서 LicheeRV-Claw를 구매해 소형 RISC-V 하드웨어에서 PicoClaw를 더 쉽게 사용해 볼 수 있습니다.
<p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div>
---
@@ -56,6 +67,8 @@
## 📢 Berita
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Anda kini boleh membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), menjadikannya lebih mudah untuk mencuba PicoClaw pada perkakasan RISC-V yang kompak.
<p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div>
---
@@ -56,6 +67,8 @@
## 📢 Novidades
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw no AliExpress!** Agora você pode comprar o LicheeRV-Claw no [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), facilitando testar o PicoClaw em hardware RISC-V compacto.
<p align="center">
+13
View File
@@ -20,6 +20,17 @@
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai">
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
</p>
</div>
---
@@ -56,6 +67,8 @@
## 📢 Tin tức
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
2026-05-11 🛒 **LicheeRV-Claw đã có trên AliExpress!** Bạn hiện có thể mua LicheeRV-Claw trên [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), giúp việc thử PicoClaw trên phần cứng RISC-V nhỏ gọn dễ dàng hơn.
<p align="center">
+15 -2
View File
@@ -20,6 +20,17 @@
**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
<p>
<a href="https://picopaw.ai/zh">
<img src="../../assets/picopaw-banner-zh.webp" alt="PicoPaw AI:你的 AI 桌面伙伴" width="100%">
</a>
</p>
<p>
<strong>PicoPaw AI 已在 <a href="https://picopaw.ai/zh">picopaw.ai/zh</a> 上线。</strong><br>
创建、预览并分享面向 PicoClaw 生态的 AI 桌面伙伴。
</p>
</div>
---
@@ -49,13 +60,15 @@
> **🚨 安全声明**
>
> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀域名被第三方抢注,请勿轻信
> - **官方域名:** 唯一的 PicoClaw 官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
> - **警惕:** 许多相似的 `.ai/.org/.com/.net/...` 后缀域名被第三方抢注。请只信任本 README 明确链接的域名
> - **注意:** PicoClaw 正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在 1.0 正式版发布前,请不要将其部署到生产环境中。
> - **注意:** PicoClaw 最近合并了大量 PR,近期版本可能内存占用较大 (10~20MB),我们将在功能较为收敛后进行资源占用优化。
## 📢 新闻
2026-06-11 🐾 **PicoPaw AI 上线!** 访问 [picopaw.ai/zh](https://picopaw.ai/zh),体验全新的 PicoPaw 桌面伙伴,预览会动的 AI 宠物,并关注 PicoClaw 生态更新。
2026-05-11 🛒 **LicheeRV-Claw 已上架淘宝!** 现在可以在 [淘宝](https://item.taobao.com/item.htm?abbucket=20&id=764939520376) 购买 LicheeRV-Claw,更方便地在小型 RISC-V 硬件上体验 PicoClaw。
<p align="center">
+25 -5
View File
@@ -43,7 +43,8 @@ the original prompt, delivery target, or command payload.
Remote channel access is scoped to the current `channel/chat_id`: remote callers
can only list, get, or update jobs whose saved `payload.channel` and `payload.to`
match the current conversation. Command jobs include a shell command payload, so
they can only be listed, inspected, or updated from internal channels.
they can only be listed, inspected, or updated from internal channels or remote
channels allowed by `tools.cron.command_allowed_remotes`.
Example tool calls:
@@ -59,7 +60,7 @@ Example tool calls:
(`at_seconds`, `every_seconds`, or `cron_expr`).
Omit `command` to preserve it, set `command` to a non-empty string to replace
it, or set `command` to `""` to clear it. Command updates require the same
internal channel and confirmation gates as command creation.
channel allowlist and confirmation gates as command creation.
## Execution Modes
@@ -104,7 +105,7 @@ If `tools.exec.enabled` is `false`:
- new command jobs are rejected by the cron tool
- existing command jobs publish a `command execution is disabled` error when they fire
`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling already requires an internal channel when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels.
`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling has its own channel gate when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels and configured remote channels.
### `allow_command`
@@ -112,7 +113,23 @@ If `tools.exec.enabled` is `false`:
This is not a hard disable switch. If you set `allow_command` to `false`, PicoClaw still allows a command job when the caller explicitly passes `command_confirm: true`.
Command jobs also require an internal channel. Non-command reminders do not have that restriction.
Command jobs also require either an internal channel or a remote channel allowed by `tools.cron.command_allowed_remotes`. Non-command reminders do not have that restriction.
### `command_allowed_remotes`
`tools.cron.command_allowed_remotes` defaults to an empty list. With the default empty list, remote channels cannot schedule command jobs.
Entries can be either a channel name or a channel plus chat id:
- `telegram` allows command jobs from any Telegram chat.
- `telegram:1234567890` allows command jobs only from that exact Telegram chat id.
- `*` allows command jobs from every non-empty channel.
Warning: `*` is potentially dangerous because any remote channel that can talk
to PicoClaw can schedule shell commands. Use it only when every enabled remote
channel and chat is trusted to request command execution.
This setting only controls the remote-channel gate. It does not bypass `tools.cron.allow_command`, `command_confirm`, `tools.exec.enabled`, or the exec tool's command safety checks.
Example:
@@ -122,7 +139,10 @@ Example:
"cron": {
"enabled": true,
"exec_timeout_minutes": 5,
"allow_command": true
"allow_command": true,
"command_allowed_remotes": [
"telegram:1234567890"
]
},
"exec": {
"enabled": true
+6 -5
View File
@@ -311,11 +311,12 @@ as containers, VMs, or an approval flow around build-and-run commands.
The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description |
|------------------------|------|---------|------------------------------------------------|
| `enabled` | bool | true | Register the agent-facing cron tool |
| `allow_command` | bool | true | Allow command jobs without extra confirmation |
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
| Config | Type | Default | Description |
|---------------------------|----------|---------|----------------------------------------------------------------|
| `enabled` | bool | true | Register the agent-facing cron tool |
| `allow_command` | bool | true | Allow command jobs without extra confirmation |
| `command_allowed_remotes` | string[] | [] | Remote channels or `channel:chat_id` values allowed for command jobs; `*` allows every channel and is dangerous unless all remote channels are trusted |
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
For schedule types, execution modes (`deliver`, agent turn, and command jobs), persistence, and the current command-security gates, see [Scheduled Tasks and Cron Jobs](cron.md).
+15 -15
View File
@@ -3,12 +3,12 @@ module github.com/sipeed/picoclaw
go 1.25.11
require (
fyne.io/systray v1.12.1
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
fyne.io/systray v1.12.2
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0
github.com/SevereCloud/vksdk/v3 v3.3.1
github.com/adhocore/gronx v1.20.0
github.com/anthropics/anthropic-sdk-go v1.46.0
github.com/anthropics/anthropic-sdk-go v1.50.2
github.com/atc0005/go-teams-notify/v2 v2.14.0
github.com/aws/aws-sdk-go-v2 v1.42.0
github.com/aws/aws-sdk-go-v2/config v1.32.25
@@ -26,12 +26,12 @@ require (
github.com/h2non/filetype v1.1.3
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62
github.com/larksuite/oapi-sdk-go/v3 v3.9.4
github.com/line/line-bot-sdk-go/v8 v8.20.0
github.com/line/line-bot-sdk-go/v8 v8.20.1
github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/muesli/termenv v0.16.0
github.com/mymmrac/telego v1.9.0
github.com/mymmrac/telego v1.10.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/pion/rtp v1.10.2
@@ -46,12 +46,12 @@ require (
go.mau.fi/util v0.9.8
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.43.0
golang.org/x/term v0.44.0
golang.org/x/time v0.15.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.27.0
modernc.org/sqlite v1.51.0
modernc.org/sqlite v1.53.0
rsc.io/qr v0.2.0
)
@@ -59,7 +59,7 @@ require (
aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
@@ -91,15 +91,15 @@ require (
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/invopop/jsonschema v0.14.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
@@ -111,16 +111,16 @@ require (
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/text v0.37.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
@@ -128,7 +128,7 @@ require (
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/github/copilot-sdk/go v0.2.0
@@ -151,7 +151,7 @@ require (
golang.org/x/crypto v0.51.0
golang.org/x/net v0.55.0
golang.org/x/sync v0.21.0
golang.org/x/sys v0.45.0
golang.org/x/sys v0.46.0
)
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
+42 -43
View File
@@ -3,20 +3,20 @@ aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
@@ -29,8 +29,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anthropics/anthropic-sdk-go v1.46.0 h1:yl3n+el5ZfNgiCtQ7zQ7s/NXxB11YbrKXdc3uLPNWlU=
github.com/anthropics/anthropic-sdk-go v1.46.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo=
github.com/anthropics/anthropic-sdk-go v1.50.2 h1:K+YJWWzeN2h5MAbh9xeUWY8yAB2oOMp2xLLAODrVBXA=
github.com/anthropics/anthropic-sdk-go v1.50.2/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
@@ -75,8 +75,8 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
@@ -180,9 +180,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62 h1:nyUi7Wel3KlVSa5ArgX/snlizqfaxU48qtvXS/JK5GE=
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62/go.mod h1:vONkS+clG730HSKOw3nZVa22TjB21r6csKYzYt0a9zI=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
@@ -206,12 +205,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/larksuite/oapi-sdk-go/v3 v3.9.4 h1:oMgcY7NBjJv1QXJqFAfcoN/TbScCkCuRZfbb1mCwZmI=
github.com/larksuite/oapi-sdk-go/v3 v3.9.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/line/line-bot-sdk-go/v8 v8.20.0 h1:Jv22DV3JuQ5qZvniqUbg504bJrVzffXs2CMpyoiuIZU=
github.com/line/line-bot-sdk-go/v8 v8.20.0/go.mod h1:QMXJwPka2ysSeVQKWXkBp8DzBFs+CFAXFNo75KJtWho=
github.com/line/line-bot-sdk-go/v8 v8.20.1 h1:OE7qwJbuZPz9MGOHHNI0E6rQNtIU7ENHyFXpvbVLwII=
github.com/line/line-bot-sdk-go/v8 v8.20.1/go.mod h1:QMXJwPka2ysSeVQKWXkBp8DzBFs+CFAXFNo75KJtWho=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -228,8 +225,8 @@ github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/u
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
github.com/mymmrac/telego v1.10.0 h1:Upe0TqYyiK+yE5RFXXuQWVHGfLZnqvUfj4KZVjTcgWE=
github.com/mymmrac/telego v1.10.0/go.mod h1:LsQKDA6EwssPP9XkORPXwwOFUGIRf/Wf2Wb8y3YyJdE=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@@ -244,6 +241,8 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
@@ -324,8 +323,6 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -354,6 +351,8 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -371,8 +370,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -422,16 +421,16 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -450,8 +449,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -483,20 +482,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM=
maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -505,8 +504,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+11 -3
View File
@@ -172,14 +172,22 @@ func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int)
buf.WriteString(prefix)
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
if _, err := io.Copy(encoder, f); err != nil {
_, copyErr := io.Copy(encoder, f)
closeErr := encoder.Close()
if copyErr != nil {
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
"path": localPath,
"error": err.Error(),
"error": copyErr.Error(),
})
return ""
}
if closeErr != nil {
logger.WarnCF("agent", "Failed to close base64 encoder", map[string]any{
"path": localPath,
"error": closeErr.Error(),
})
return ""
}
encoder.Close()
return buf.String()
}
+1 -2
View File
@@ -6,7 +6,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -21,7 +20,7 @@ func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, ses
if errors.Is(err, context.Canceled) {
return false
}
al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, fmt.Sprintf("Error processing message: %v", err))
al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, formatProcessingError(err))
return true
}
+36
View File
@@ -0,0 +1,36 @@
package agent
import (
"fmt"
"github.com/sipeed/picoclaw/pkg/providers"
)
func formatProcessingError(err error) string {
if err == nil {
return ""
}
if kind, ok := providers.ClassifyAuthError(err); ok {
return fmt.Sprintf(
"Error processing message: %s\n\nOriginal error:\n%s",
authErrorFriendlyMessage(kind),
err.Error(),
)
}
return fmt.Sprintf("Error processing message: %v", err)
}
func authErrorFriendlyMessage(kind providers.AuthErrorKind) string {
switch kind {
case providers.AuthErrorInvalidAPIKey:
return "Authentication failed: the API key appears to be invalid. Check the API key configured for this model or provider."
case providers.AuthErrorMissingAPIKey:
return "Authentication failed: no API key is configured for this model or provider. Add an API key in the model settings or config."
case providers.AuthErrorExpiredToken:
return "Authentication failed: the saved login or token appears to be expired. Re-authenticate the provider."
default:
return "Authentication failed: check the API key, token, OAuth login, or provider permissions for this model."
}
}
+52
View File
@@ -0,0 +1,52 @@
package agent
import (
"errors"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/providers/common"
)
func TestFormatProcessingError_InvalidAPIKey(t *testing.T) {
err := errors.New(
`LLM call failed after retries: API request failed: Status: 401 Body: {"error":{"message":"Incorrect API key provided"}}`,
)
got := formatProcessingError(err)
if !strings.Contains(got, "API key appears to be invalid") {
t.Fatalf("formatted error missing friendly API key hint: %q", got)
}
if !strings.Contains(got, "Original error:") {
t.Fatalf("formatted error missing original error label: %q", got)
}
if !strings.Contains(got, err.Error()) {
t.Fatalf("formatted error missing original error: %q", got)
}
}
func TestFormatProcessingError_GenericAuthHTTPError(t *testing.T) {
err := &common.HTTPError{
StatusCode: 401,
BodyPreview: `{"error":"unauthorized"}`,
ContentType: "application/json",
APIBase: "https://api.example.com",
}
got := formatProcessingError(err)
if !strings.Contains(got, "check the API key, token, OAuth login, or provider permissions") {
t.Fatalf("formatted error missing generic auth hint: %q", got)
}
if !strings.Contains(got, "Original error:") {
t.Fatalf("formatted error missing original error: %q", got)
}
}
func TestFormatProcessingError_NonAuth(t *testing.T) {
err := errors.New("connection reset by peer")
got := formatProcessingError(err)
want := "Error processing message: connection reset by peer"
if got != want {
t.Fatalf("formatted error = %q, want %q", got, want)
}
}
+7
View File
@@ -202,6 +202,9 @@ func (b *evolutionBridge) handleTurnEndAsync(meta EventMeta, payload TurnEndPayl
FinalSuccessfulPath: append([]string(nil), payload.FinalSuccessfulPath...),
SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots),
}
if isEvolutionHeartbeatInput(input) {
return false
}
b.rememberScheduledColdPathWorkspace(input.Workspace)
b.closeMu.Lock()
@@ -228,6 +231,10 @@ func (b *evolutionBridge) handleTurnEndAsync(meta EventMeta, payload TurnEndPayl
return true
}
func isEvolutionHeartbeatInput(input evolution.TurnCaseInput) bool {
return strings.EqualFold(strings.TrimSpace(input.SessionKey), "heartbeat")
}
func (b *evolutionBridge) subscribeRuntimeEvents(ch runtimeevents.EventChannel) error {
if b == nil || ch == nil {
return nil
+25
View File
@@ -513,6 +513,31 @@ func TestEvolutionBridge_DraftModeAutomaticallyRunsColdPathAndCreatesDraftFile(t
waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
}
func TestEvolutionBridge_DraftModeDoesNotRunColdPathForHeartbeat(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "draft",
}, &simpleMockProvider{
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"unexpected heartbeat draft","body_or_patch":"## Unexpected"}`,
})
defer al.Close()
resp, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1")
if err != nil {
t.Fatalf("ProcessHeartbeat failed: %v", err)
}
if resp == "" {
t.Fatal("expected non-empty heartbeat response")
}
time.Sleep(150 * time.Millisecond)
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "task-records.jsonl"))
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"))
}
func TestEvolutionBridge_ScheduledModeDoesNotRunColdPathAfterTurn(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
+1 -1
View File
@@ -146,7 +146,7 @@ func SynthesizeAndStore(
_, err = io.Copy(file, stream)
if err != nil {
file.Close()
_ = file.Close()
return "", fmt.Errorf("failed to write tts audio: %w", err)
}
+30
View File
@@ -13,6 +13,7 @@ import (
"math"
"net"
"net/http"
"runtime/debug"
"sort"
"strings"
"sync"
@@ -1279,6 +1280,16 @@ func (m *Manager) StartAll(ctx context.Context) error {
for _, listener := range m.httpListeners {
ln := listener
go func() {
defer func() {
if r := recover(); r != nil {
logger.ErrorCF("channels", "HTTP server goroutine panic recovered",
map[string]any{
"addr": ln.Addr().String(),
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
}
}()
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
"addr": ln.Addr().String(),
})
@@ -1292,6 +1303,16 @@ func (m *Manager) StartAll(ctx context.Context) error {
}
} else {
go func() {
defer func() {
if r := recover(); r != nil {
logger.ErrorCF("channels", "HTTP server goroutine panic recovered",
map[string]any{
"addr": m.httpServer.Addr,
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
}
}()
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
"addr": m.httpServer.Addr,
})
@@ -1943,6 +1964,15 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
// Commit hashes only on full success.
m.channelHashes = list
go func() {
defer func() {
if r := recover(); r != nil {
logger.ErrorCF("channels", "channel registration goroutine panic recovered",
map[string]any{
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
}
}()
for _, f := range deferFuncs {
f()
}
+14 -7
View File
@@ -24,6 +24,7 @@ import (
type OneBotChannel struct {
*channels.BaseChannel
config *config.OneBotSettings
downloadFn func(urlStr, filename string) string
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc
@@ -194,7 +195,7 @@ func (c *OneBotChannel) connect() error {
conn, resp, err := dialer.Dial(c.config.WSUrl, header)
if resp != nil {
resp.Body.Close()
_ = resp.Body.Close()
}
if err != nil {
return err
@@ -795,9 +796,7 @@ func (c *OneBotChannel) parseMessageSegments(
} else if n, ok := data["name"].(string); ok && n != "" {
filename = n
}
localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{
LoggerPrefix: "onebot",
})
localPath := c.downloadInboundFile(url, filename)
if localPath != "" {
mediaRefs = append(mediaRefs, storeFile(localPath, filename))
textParts = append(textParts, fmt.Sprintf("[%s]", segType))
@@ -809,9 +808,7 @@ func (c *OneBotChannel) parseMessageSegments(
if data != nil {
url, _ := data["url"].(string)
if url != "" {
localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{
LoggerPrefix: "onebot",
})
localPath := c.downloadInboundFile(url, "voice.amr")
if localPath != "" {
textParts = append(textParts, "[voice]")
mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr"))
@@ -847,6 +844,16 @@ func (c *OneBotChannel) parseMessageSegments(
}
}
func (c *OneBotChannel) downloadInboundFile(urlStr, filename string) string {
if c.downloadFn != nil {
return c.downloadFn(urlStr, filename)
}
return utils.DownloadFile(urlStr, filename, utils.DownloadOptions{
LoggerPrefix: "onebot",
BlockPrivateTargets: true,
})
}
func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
switch raw.PostType {
case "message":
+126
View File
@@ -0,0 +1,126 @@
package onebot
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/media"
)
func TestParseMessageSegments_BlocksLoopbackInboundMediaURL(t *testing.T) {
ch := &OneBotChannel{}
store := media.NewFileMediaStore()
raw := json.RawMessage(`[
{"type":"text","data":{"text":"see attachment"}},
{"type":"image","data":{"url":"http://127.0.0.1:8080/evil.png","file":"evil.png"}}
]`)
result := ch.parseMessageSegments(raw, 0, store, "onebot:test:msg1")
if got := result.Text; got != "see attachment" {
t.Fatalf("Text = %q, want %q", got, "see attachment")
}
if len(result.Media) != 0 {
t.Fatalf("Media count = %d, want 0", len(result.Media))
}
}
func TestParseMessageSegments_BlocksInboundMediaRedirectToLoopback(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("secret"))
}))
defer target.Close()
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.String(), "http://example.com/evil.png"; got != want {
t.Fatalf("proxy request URL = %q, want %q", got, want)
}
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer proxy.Close()
t.Setenv("HTTP_PROXY", proxy.URL)
t.Setenv("http_proxy", proxy.URL)
t.Setenv("HTTPS_PROXY", "")
t.Setenv("https_proxy", "")
t.Setenv("ALL_PROXY", "")
t.Setenv("all_proxy", "")
t.Setenv("NO_PROXY", "")
t.Setenv("no_proxy", "")
ch := &OneBotChannel{}
store := media.NewFileMediaStore()
raw := json.RawMessage(`[
{"type":"text","data":{"text":"see attachment"}},
{"type":"image","data":{"url":"http://example.com/evil.png","file":"evil.png"}}
]`)
result := ch.parseMessageSegments(raw, 0, store, "onebot:test:msg-redirect")
if got := result.Text; got != "see attachment" {
t.Fatalf("Text = %q, want %q", got, "see attachment")
}
if len(result.Media) != 0 {
t.Fatalf("Media count = %d, want 0", len(result.Media))
}
}
func TestParseMessageSegments_StoresDownloadedMediaRef(t *testing.T) {
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "image.png")
if err := os.WriteFile(localPath, []byte("fake-image"), 0o600); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
ch := &OneBotChannel{
downloadFn: func(urlStr, filename string) string {
if urlStr != "https://cdn.example.com/image.png" {
t.Fatalf("download url = %q, want %q", urlStr, "https://cdn.example.com/image.png")
}
if filename != "image.png" {
t.Fatalf("download filename = %q, want %q", filename, "image.png")
}
return localPath
},
}
store := media.NewFileMediaStore()
raw := json.RawMessage(`[
{"type":"text","data":{"text":"see attachment"}},
{"type":"image","data":{"url":"https://cdn.example.com/image.png","file":"image.png"}}
]`)
result := ch.parseMessageSegments(raw, 0, store, "onebot:test:msg2")
if got := result.Text; got != "see attachment[image]" {
t.Fatalf("Text = %q, want %q", got, "see attachment[image]")
}
if len(result.Media) != 1 {
t.Fatalf("Media count = %d, want 1", len(result.Media))
}
if !strings.HasPrefix(result.Media[0], "media://") {
t.Fatalf("media ref = %q, want media:// prefix", result.Media[0])
}
resolvedPath, meta, err := store.ResolveWithMeta(result.Media[0])
if err != nil {
t.Fatalf("ResolveWithMeta() error = %v", err)
}
if resolvedPath != localPath {
t.Fatalf("resolved path = %q, want %q", resolvedPath, localPath)
}
if meta.Source != "onebot" {
t.Fatalf("meta.Source = %q, want %q", meta.Source, "onebot")
}
if meta.Filename != "image.png" {
t.Fatalf("meta.Filename = %q, want %q", meta.Filename, "image.png")
}
}
+1 -1
View File
@@ -88,7 +88,7 @@ func (c *PicoClientChannel) dial() error {
ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header)
if resp != nil && resp.Body != nil {
resp.Body.Close()
_ = resp.Body.Close()
}
if err != nil {
return err
+1 -1
View File
@@ -1185,7 +1185,7 @@ func (c *TelegramChannel) handleMessages(ctx context.Context, messages []*telego
inboundCtx := bus.InboundContext{
Channel: c.Name(),
ChatID: fmt.Sprintf("%d", chatID),
ChatID: compositeChatID,
ChatType: peerKind,
SenderID: platformID,
MessageID: messageID,
+32 -2
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
@@ -213,6 +215,33 @@ func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {
assert.Equal(t, "caption", constructor.calls[1].Parameters["caption"])
}
func TestDownloadFileWithInfo_AllowsLocalConfiguredBaseURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.Path, "/file/bot"+testToken+"/photos/image"; got != want {
t.Fatalf("request path = %q, want %q", got, want)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("telegram-local-bot-api"))
}))
defer server.Close()
ch, err := NewTelegramChannel(
&config.Channel{Type: config.ChannelTelegram, Enabled: true},
&config.TelegramSettings{
Token: *config.NewSecureString(testToken),
BaseURL: server.URL,
},
nil,
)
require.NoError(t, err)
path := ch.downloadFileWithInfo(&telego.File{FilePath: "photos/image"}, "")
if path == "" {
t.Fatal("expected local base_url download to succeed")
}
defer os.Remove(path)
}
func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
constructor := &multipartRecordingConstructor{}
caller := &stubCaller{
@@ -1212,8 +1241,9 @@ func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
inbound, ok := <-messageBus.InboundChan()
require.True(t, ok, "expected inbound message")
// ChatID remains the parent chat; TopicID isolates the sub-conversation.
assert.Equal(t, "-1001234567890", inbound.ChatID)
// ChatID includes the thread ID for forum topics so outbound
// delivery resolves the correct topic without relying solely on TopicID fallback.
assert.Equal(t, "-1001234567890/42", inbound.ChatID)
assert.Equal(t, "group", inbound.Context.ChatType)
assert.Equal(t, "42", inbound.Context.TopicID)
}
+1 -1
View File
@@ -62,7 +62,7 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error {
conn, resp, err := dialer.Dial(c.url, nil)
if resp != nil {
resp.Body.Close()
_ = resp.Body.Close()
}
if err != nil {
c.cancel()
+5 -3
View File
@@ -1028,9 +1028,11 @@ type WebToolsConfig struct {
}
type CronToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
ExecTimeoutMinutes int ` json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
AllowCommand bool ` json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"`
ToolConfig `envPrefix:"PICOCLAW_TOOLS_CRON_"`
// 0 means no timeout.
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"`
AllowCommand bool `json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"`
CommandAllowedRemotes []string `json:"command_allowed_remotes" env:"PICOCLAW_TOOLS_CRON_COMMAND_ALLOWED_REMOTES"`
}
type ExecConfig struct {
+19
View File
@@ -7,6 +7,7 @@ import (
"reflect"
"strconv"
"strings"
"sync"
"github.com/caarlos0/env/v11"
"gopkg.in/yaml.v3"
@@ -655,6 +656,8 @@ func filterSecureFields(r RawNode, secureFields map[string]struct{}) RawNode {
// channelSettingsFactory maps channel type to a zero-value prototype of the
// corresponding Settings struct. InitChannelList uses reflect.New to create
// fresh instances, avoiding repeated closure boilerplate.
var channelSettingsMu sync.RWMutex
var channelSettingsFactory = map[string]any{
ChannelPico: (PicoSettings{}),
ChannelPicoClient: (PicoClientSettings{}),
@@ -679,10 +682,24 @@ var channelSettingsFactory = map[string]any{
ChannelSlackWebHook: (SlackWebhookSettings{}),
}
// RegisterChannelSettings registers a settings struct prototype for a custom
// channel type. External packages (out-of-tree channels registered via
// channels.RegisterFactory) call this from an init() so their channel type
// passes config validation (isValidChannelType) and its settings block decodes
// into the right struct (newChannelSettings). The prototype must be a struct
// value, e.g. RegisterChannelSettings("my_channel", MyChannelSettings{}).
func RegisterChannelSettings(channelType string, prototype any) {
channelSettingsMu.Lock()
defer channelSettingsMu.Unlock()
channelSettingsFactory[channelType] = prototype
}
// newChannelSettings creates a fresh zero-value pointer for the given channel type.
// Returns nil if the type is not registered.
func newChannelSettings(channelType string) any {
channelSettingsMu.RLock()
proto, ok := channelSettingsFactory[channelType]
channelSettingsMu.RUnlock()
if !ok {
return nil
}
@@ -691,7 +708,9 @@ func newChannelSettings(channelType string) any {
// isValidChannelType returns true if the channel type is a known, registered type.
func isValidChannelType(channelType string) bool {
channelSettingsMu.RLock()
_, ok := channelSettingsFactory[channelType]
channelSettingsMu.RUnlock()
return ok
}
+36
View File
@@ -1540,6 +1540,16 @@ func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
}
}
func TestDefaultConfig_CronCommandAllowedRemotesEmpty(t *testing.T) {
cfg := DefaultConfig()
if len(cfg.Tools.Cron.CommandAllowedRemotes) != 0 {
t.Fatalf(
"DefaultConfig().Tools.Cron.CommandAllowedRemotes = %#v, want empty",
cfg.Tools.Cron.CommandAllowedRemotes,
)
}
}
func TestDefaultConfig_HooksDefaults(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Hooks.Enabled {
@@ -1600,6 +1610,32 @@ func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {
}
}
func TestLoadConfig_CronCommandAllowedRemotes(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(
configPath,
[]byte(`{"version":1,"tools":{"cron":{"command_allowed_remotes":["telegram:1234567890","discord"]}}}`),
0o600,
); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
want := []string{"telegram:1234567890", "discord"}
if len(cfg.Tools.Cron.CommandAllowedRemotes) != len(want) {
t.Fatalf("CommandAllowedRemotes = %#v, want %#v", cfg.Tools.Cron.CommandAllowedRemotes, want)
}
for i := range want {
if cfg.Tools.Cron.CommandAllowedRemotes[i] != want[i] {
t.Fatalf("CommandAllowedRemotes = %#v, want %#v", cfg.Tools.Cron.CommandAllowedRemotes, want)
}
}
}
func TestLoadConfig_WebToolsProxy(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
+8
View File
@@ -108,6 +108,14 @@ func DefaultConfig() *Config {
APIBase: "https://api.venice.ai/api/v1",
},
// NEAR AI Cloud TEE inference - https://near.ai
{
ModelName: "nearai-glm",
Provider: "nearai",
Model: "zai-org/GLM-5.1-FP8",
APIBase: "https://cloud-api.near.ai/v1",
},
// Google Gemini - https://ai.google.dev/
{
ModelName: "gemini-2.0-flash",
@@ -0,0 +1,54 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// customChannelSettings stands in for an out-of-tree channel's settings struct.
type customChannelSettings struct {
Token string `json:"token"`
}
// TestRegisterChannelSettings verifies the public registration hook makes a
// previously-unknown channel type valid and decodable — the behavior out-of-tree
// channels rely on (they call RegisterChannelSettings from init()).
func TestRegisterChannelSettings(t *testing.T) {
const typ = "custom_test_channel"
assert.False(t, isValidChannelType(typ), "type should be unknown before registration")
RegisterChannelSettings(typ, customChannelSettings{})
assert.True(t, isValidChannelType(typ), "type should be valid after registration")
got := newChannelSettings(typ)
_, ok := got.(*customChannelSettings)
assert.Truef(t, ok, "newChannelSettings(%q) = %T, want *customChannelSettings", typ, got)
}
// TestRegisterChannelSettings_InitChannelList verifies that a config carrying a
// registered out-of-tree channel type passes InitChannelList and decodes its
// settings — the full path that previously errored with "unknown type".
func TestRegisterChannelSettings_InitChannelList(t *testing.T) {
const typ = "custom_initlist_channel"
RegisterChannelSettings(typ, customChannelSettings{})
channels := ChannelsConfig{
"mychan": {
Type: typ,
Enabled: true,
Settings: RawNode(`{"token":"secret-123"}`),
},
}
require.NoError(t, InitChannelList(channels))
decoded, err := channels["mychan"].GetDecoded()
require.NoError(t, err)
cfg, ok := decoded.(*customChannelSettings)
require.True(t, ok)
assert.Equal(t, "secret-123", cfg.Token)
}
+23
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"log"
"runtime/debug"
"sync"
"sync/atomic"
"time"
@@ -227,6 +228,11 @@ func (s *eventSubscription) dispatch(ctx context.Context, evt Event) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("events: subscriber %q goroutine panic recovered: %v\n%s", s.name, r, debug.Stack())
}
}()
s.handle(ctx, evt)
}()
case Keyed:
@@ -253,6 +259,15 @@ func (s *eventSubscription) handle(ctx context.Context, evt Event) {
done := make(chan handlerResult, 1)
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf(
"events: subscriber %q timeout-handler goroutine panic recovered: %v\n%s",
s.name, r, debug.Stack(),
)
done <- handlerResult{panicked: true}
}
}()
done <- s.invokeHandler(ctx, evt)
}()
@@ -302,6 +317,14 @@ func (s *eventSubscription) watchContext(ctx context.Context) {
}
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf(
"events: subscriber %q watchContext goroutine panic recovered: %v\n%s",
s.name, r, debug.Stack(),
)
}
}()
select {
case <-ctx.Done():
_ = s.Close()
+14 -4
View File
@@ -558,10 +558,20 @@ func isInvalidJSON(err error) bool {
}
func lockStoreFile(path string) func() {
actual, _ := storeFileLocks.LoadOrStore(path, &sync.Mutex{})
mu := actual.(*sync.Mutex)
mu.Lock()
return mu.Unlock
for {
actual, _ := storeFileLocks.LoadOrStore(path, &sync.Mutex{})
mu, ok := actual.(*sync.Mutex)
if !ok || mu == nil {
// Corrupted entry (wrong type or nil *sync.Mutex).
// Atomically swap in a fresh mutex via CompareAndSwap.
// If CAS fails, another goroutine already replaced it —
// just retry the loop to pick up the valid entry.
storeFileLocks.CompareAndSwap(path, actual, &sync.Mutex{})
continue
}
mu.Lock()
return mu.Unlock
}
}
func (s *Store) profilePath(workspaceID, skillName string) (string, error) {
+1 -1
View File
@@ -110,7 +110,7 @@ func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
// This prevents the renamed file from disappearing after a crash
if dirFile, err := os.Open(dir); err == nil {
_ = dirFile.Sync()
dirFile.Close()
_ = dirFile.Close()
}
// Success: skip cleanup (file was renamed, no temp to remove)
+52 -11
View File
@@ -205,18 +205,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
publishGatewayEvent(agentLoop, runtimeevents.KindGatewayStart, startedAt, nil)
fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo()
toolsInfo := startupInfo["tools"].(map[string]any)
skillsInfo := startupInfo["skills"].(map[string]any)
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"])
startupStatus := collectGatewayStartupStatus(agentLoop.GetStartupInfo())
fmt.Printf(" • Tools: %d loaded\n", startupStatus.toolsCount)
fmt.Printf(" • Skills: %d/%d available\n", startupStatus.skillsAvailable, startupStatus.skillsTotal)
logger.InfoCF("agent", "Agent initialized",
map[string]any{
"tools_count": toolsInfo["count"],
"skills_total": skillsInfo["total"],
"skills_available": skillsInfo["available"],
})
logger.InfoCF("agent", "Agent initialized", startupStatus.logFields)
runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token, listenResult)
if err != nil {
@@ -315,6 +308,54 @@ func preCheckConfig(cfg *config.Config) error {
return nil
}
type gatewayStartupStatus struct {
toolsCount int
skillsAvailable int
skillsTotal int
logFields map[string]any
}
func collectGatewayStartupStatus(startupInfo map[string]any) gatewayStartupStatus {
status := gatewayStartupStatus{logFields: map[string]any{}}
if toolsInfo, ok := startupInfo["tools"].(map[string]any); ok {
if count, ok := startupInfoInt(toolsInfo["count"]); ok {
status.toolsCount = count
status.logFields["tools_count"] = count
}
}
if skillsInfo, ok := startupInfo["skills"].(map[string]any); ok {
if total, ok := startupInfoInt(skillsInfo["total"]); ok {
status.skillsTotal = total
status.logFields["skills_total"] = total
}
if available, ok := startupInfoInt(skillsInfo["available"]); ok {
status.skillsAvailable = available
status.logFields["skills_available"] = available
}
}
return status
}
func startupInfoInt(value any) (int, bool) {
switch v := value.(type) {
case int:
return v, true
case int32:
return int(v), true
case int64:
return int(v), true
case float32:
return int(v), true
case float64:
return int(v), true
default:
return 0, false
}
}
func executeReload(
ctx context.Context,
agentLoop *agent.AgentLoop,
+88
View File
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
@@ -113,6 +114,93 @@ func TestGatewayRunStartupFailureHelper(t *testing.T) {
os.Exit(0)
}
func TestCollectGatewayStartupStatusHandlesMalformedInfo(t *testing.T) {
t.Parallel()
tests := []struct {
name string
startupInfo map[string]any
wantToolsCount int
wantSkillsAvailable int
wantSkillsTotal int
wantLogFields map[string]any
}{
{
name: "missing info",
startupInfo: map[string]any{},
wantLogFields: map[string]any{},
},
{
name: "wrong map shapes",
startupInfo: map[string]any{
"tools": "unexpected",
"skills": []any{"unexpected"},
},
wantLogFields: map[string]any{},
},
{
name: "valid startup info",
startupInfo: map[string]any{
"tools": map[string]any{
"count": 3,
},
"skills": map[string]any{
"available": 2,
"total": 5,
},
},
wantToolsCount: 3,
wantSkillsAvailable: 2,
wantSkillsTotal: 5,
wantLogFields: map[string]any{
"tools_count": 3,
"skills_available": 2,
"skills_total": 5,
},
},
{
name: "json number startup info",
startupInfo: map[string]any{
"tools": map[string]any{
"count": float64(4),
},
"skills": map[string]any{
"available": float64(1),
"total": float64(6),
},
},
wantToolsCount: 4,
wantSkillsAvailable: 1,
wantSkillsTotal: 6,
wantLogFields: map[string]any{
"tools_count": 4,
"skills_available": 1,
"skills_total": 6,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := collectGatewayStartupStatus(tt.startupInfo)
if got.toolsCount != tt.wantToolsCount {
t.Fatalf("toolsCount = %d, want %d", got.toolsCount, tt.wantToolsCount)
}
if got.skillsAvailable != tt.wantSkillsAvailable {
t.Fatalf("skillsAvailable = %d, want %d", got.skillsAvailable, tt.wantSkillsAvailable)
}
if got.skillsTotal != tt.wantSkillsTotal {
t.Fatalf("skillsTotal = %d, want %d", got.skillsTotal, tt.wantSkillsTotal)
}
if !reflect.DeepEqual(got.logFields, tt.wantLogFields) {
t.Fatalf("logFields = %#v, want %#v", got.logFields, tt.wantLogFields)
}
})
}
}
func TestPublishGatewayEvent(t *testing.T) {
eventBus := runtimeevents.NewBus()
t.Cleanup(func() {
+5 -5
View File
@@ -162,7 +162,7 @@ func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"})
_ = json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"})
}
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
@@ -176,7 +176,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
PID: os.Getpid(),
}
json.NewEncoder(w).Encode(resp)
_ = json.NewEncoder(w).Encode(resp)
}
func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
@@ -190,7 +190,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
if !ready {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{
_ = json.NewEncoder(w).Encode(StatusResponse{
Status: "not ready",
Checks: checks,
})
@@ -200,7 +200,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
for _, check := range checks {
if check.Status == "fail" {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{
_ = json.NewEncoder(w).Encode(StatusResponse{
Status: "not ready",
Checks: checks,
})
@@ -210,7 +210,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
uptime := time.Since(s.startTime)
json.NewEncoder(w).Encode(StatusResponse{
_ = json.NewEncoder(w).Encode(StatusResponse{
Status: "ready",
Uptime: uptime.String(),
Checks: checks,
+142
View File
@@ -0,0 +1,142 @@
package providers
import (
"errors"
"regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/providers/common"
)
type AuthErrorKind string
const (
AuthErrorInvalidAPIKey AuthErrorKind = "invalid_api_key"
AuthErrorMissingAPIKey AuthErrorKind = "missing_api_key"
AuthErrorExpiredToken AuthErrorKind = "expired_token"
AuthErrorGeneric AuthErrorKind = "auth"
)
var (
invalidAPIKeyPattern = regexp.MustCompile(
`(?i)\b(?:invalid|incorrect|malformed|wrong)[-_\s]+(?:api[-_\s]*)?key\b|\b(?:api[-_\s]*)?key[-_\s]+(?:is[-_\s]+)?(?:invalid|incorrect|malformed|wrong)\b|\binvalid[-_]?api[-_]?key\b`,
)
missingAPIKeyPattern = regexp.MustCompile(
`(?i)\b(?:missing|no|not[-_\s]+configured)[-_\s]+(?:api[-_\s]*)?key\b|\bno[-_\s]+credentials[-_\s]+found\b|\bapi[-_\s]+key[-_\s]+not[-_\s]+configured\b`,
)
expiredTokenPattern = regexp.MustCompile(
`(?i)\b(?:oauth[-_\s]+token[-_\s]+refresh[-_\s]+failed|re-authenticate|token[-_\s]+(?:has[-_\s]+)?expired|expired[-_\s]+token)\b`,
)
genericAuthPattern = regexp.MustCompile(
`(?i)\b(?:unauthorized|forbidden|authentication[-_\s]+(?:failed|required)|access[-_\s]+denied)\b`,
)
)
func ClassifyAuthError(err error) (AuthErrorKind, bool) {
if err == nil {
return "", false
}
var exhausted *FallbackExhaustedError
if errors.As(err, &exhausted) && exhausted != nil {
return classifyFallbackExhaustedAuthError(exhausted)
}
msg := authErrorText(err)
if missingAPIKeyPattern.MatchString(msg) {
return AuthErrorMissingAPIKey, true
}
if expiredTokenPattern.MatchString(msg) {
return AuthErrorExpiredToken, true
}
if invalidAPIKeyPattern.MatchString(msg) {
return AuthErrorInvalidAPIKey, true
}
if hasStructuredAuthError(err) || genericAuthPattern.MatchString(msg) {
return AuthErrorGeneric, true
}
return "", false
}
func authErrorText(err error) string {
var parts []string
if err != nil {
parts = append(parts, err.Error())
}
var httpErr *common.HTTPError
if errors.As(err, &httpErr) && httpErr != nil {
parts = append(parts, httpErr.BodyPreview)
}
return strings.Join(parts, "\n")
}
func hasStructuredAuthError(err error) bool {
var failErr *FailoverError
if errors.As(err, &failErr) && failErr != nil && failErr.Reason == FailoverAuth {
return true
}
var httpErr *common.HTTPError
if errors.As(err, &httpErr) && httpErr != nil {
return httpErr.StatusCode == 401 || httpErr.StatusCode == 403
}
return false
}
func classifyFallbackExhaustedAuthError(err *FallbackExhaustedError) (AuthErrorKind, bool) {
if err == nil {
return "", false
}
var authMessages []string
nonSkipped := 0
for _, attempt := range err.Attempts {
if attempt.Skipped {
continue
}
nonSkipped++
if !attemptIsAuthFailure(attempt) {
return "", false
}
if attempt.Error != nil {
authMessages = append(authMessages, authErrorText(attempt.Error))
}
}
if nonSkipped == 0 {
return "", false
}
msg := strings.Join(authMessages, "\n")
if missingAPIKeyPattern.MatchString(msg) {
return AuthErrorMissingAPIKey, true
}
if expiredTokenPattern.MatchString(msg) {
return AuthErrorExpiredToken, true
}
if invalidAPIKeyPattern.MatchString(msg) {
return AuthErrorInvalidAPIKey, true
}
return AuthErrorGeneric, true
}
func attemptIsAuthFailure(attempt FallbackAttempt) bool {
if attempt.Reason == FailoverAuth {
return true
}
if attempt.Error == nil {
return false
}
var failErr *FailoverError
if errors.As(attempt.Error, &failErr) && failErr != nil && failErr.Reason == FailoverAuth {
return true
}
var httpErr *common.HTTPError
if errors.As(attempt.Error, &httpErr) && httpErr != nil {
return httpErr.StatusCode == 401 || httpErr.StatusCode == 403
}
return false
}
+133
View File
@@ -0,0 +1,133 @@
package providers
import (
"errors"
"fmt"
"testing"
"github.com/sipeed/picoclaw/pkg/providers/common"
)
func TestClassifyError_HTTPErrorStatus(t *testing.T) {
err := fmt.Errorf("provider request: %w", &common.HTTPError{
StatusCode: httpStatusUnauthorized,
BodyPreview: `{"error":"unauthorized"}`,
ContentType: "application/json",
APIBase: "https://api.example.com",
})
result := ClassifyError(err, "openai", "gpt-4")
if result == nil {
t.Fatal("expected classified error")
}
if result.Reason != FailoverAuth {
t.Fatalf("reason = %q, want %q", result.Reason, FailoverAuth)
}
if result.Status != httpStatusUnauthorized {
t.Fatalf("status = %d, want %d", result.Status, httpStatusUnauthorized)
}
}
func TestClassifyAuthError(t *testing.T) {
tests := []struct {
name string
err error
want AuthErrorKind
}{
{
name: "invalid api key",
err: errors.New(
`API request failed: Status: 401 Body: {"error":{"message":"Incorrect API key provided"}}`,
),
want: AuthErrorInvalidAPIKey,
},
{
name: "missing api key",
err: errors.New("API key not configured"),
want: AuthErrorMissingAPIKey,
},
{
name: "expired token",
err: errors.New("oauth token refresh failed: token has expired"),
want: AuthErrorExpiredToken,
},
{
name: "structured generic auth",
err: &common.HTTPError{
StatusCode: httpStatusUnauthorized,
BodyPreview: `{"error":"unauthorized"}`,
ContentType: "application/json",
APIBase: "https://api.example.com",
},
want: AuthErrorGeneric,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ClassifyAuthError(tt.err)
if !ok {
t.Fatal("expected auth classification")
}
if got != tt.want {
t.Fatalf("kind = %q, want %q", got, tt.want)
}
})
}
}
func TestClassifyAuthError_FallbackExhaustedAllAuth(t *testing.T) {
err := &FallbackExhaustedError{
Attempts: []FallbackAttempt{
{
Reason: FailoverAuth,
Error: &FailoverError{
Reason: FailoverAuth,
Wrapped: errors.New("invalid api key"),
},
},
{
Reason: FailoverAuth,
Error: &FailoverError{
Reason: FailoverAuth,
Wrapped: errors.New("unauthorized"),
},
},
},
}
got, ok := ClassifyAuthError(err)
if !ok {
t.Fatal("expected auth classification")
}
if got != AuthErrorInvalidAPIKey {
t.Fatalf("kind = %q, want %q", got, AuthErrorInvalidAPIKey)
}
}
func TestClassifyAuthError_FallbackExhaustedMixedFailures(t *testing.T) {
err := &FallbackExhaustedError{
Attempts: []FallbackAttempt{
{
Reason: FailoverAuth,
Error: &FailoverError{
Reason: FailoverAuth,
Wrapped: errors.New("invalid api key"),
},
},
{
Reason: FailoverRateLimit,
Error: &FailoverError{
Reason: FailoverRateLimit,
Wrapped: errors.New("rate limit exceeded"),
},
},
},
}
if got, ok := ClassifyAuthError(err); ok {
t.Fatalf("kind = %q, want no auth classification for mixed failures", got)
}
}
const httpStatusUnauthorized = 401
+43 -13
View File
@@ -371,6 +371,36 @@ func DecodeToolCallArguments(raw json.RawMessage, name string) map[string]any {
// --- HTTP response helpers ---
// HTTPError captures structured details for non-OK provider HTTP responses.
// Error preserves the previous user/log-facing text format for compatibility.
type HTTPError struct {
StatusCode int
BodyPreview string
ContentType string
APIBase string
IsHTML bool
}
func (e *HTTPError) Error() string {
if e == nil {
return "API request failed"
}
if e.IsHTML {
return fmt.Sprintf(
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
e.APIBase,
e.ContentType,
e.StatusCode,
e.BodyPreview,
)
}
return fmt.Sprintf(
"API request failed:\n Status: %d\n Body: %s",
e.StatusCode,
e.BodyPreview,
)
}
// HandleErrorResponse reads a non-200 response body and returns an appropriate error.
func HandleErrorResponse(resp *http.Response, apiBase string) error {
contentType := resp.Header.Get("Content-Type")
@@ -381,11 +411,12 @@ func HandleErrorResponse(resp *http.Response, apiBase string) error {
if LooksLikeHTML(body, contentType) {
return WrapHTMLResponseError(resp.StatusCode, body, contentType, apiBase)
}
return fmt.Errorf(
"API request failed:\n Status: %d\n Body: %s",
resp.StatusCode,
ResponsePreview(body, 128),
)
return &HTTPError{
StatusCode: resp.StatusCode,
BodyPreview: ResponsePreview(body, 128),
ContentType: contentType,
APIBase: apiBase,
}
}
// ReadAndParseResponse peeks at the response body to detect HTML errors,
@@ -422,14 +453,13 @@ func LooksLikeHTML(body []byte, contentType string) bool {
// WrapHTMLResponseError creates a descriptive error for HTML responses.
func WrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {
respPreview := ResponsePreview(body, 128)
return fmt.Errorf(
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
apiBase,
contentType,
statusCode,
respPreview,
)
return &HTTPError{
StatusCode: statusCode,
BodyPreview: ResponsePreview(body, 128),
ContentType: contentType,
APIBase: apiBase,
IsHTML: true,
}
}
// ResponsePreview returns a truncated preview of response body for error messages.
+59
View File
@@ -0,0 +1,59 @@
package common
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandleErrorResponse_ReturnsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}))
defer server.Close()
resp, err := http.Get(server.URL)
if err != nil {
t.Fatalf("http.Get() error = %v", err)
}
defer resp.Body.Close()
err = HandleErrorResponse(resp, server.URL)
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("HandleErrorResponse() error = %T, want *HTTPError", err)
}
if httpErr.StatusCode != http.StatusUnauthorized {
t.Fatalf("StatusCode = %d, want %d", httpErr.StatusCode, http.StatusUnauthorized)
}
if httpErr.BodyPreview != `{"error":"unauthorized"}` {
t.Fatalf("BodyPreview = %q", httpErr.BodyPreview)
}
if !strings.Contains(err.Error(), "Status: 401") {
t.Fatalf("Error() should preserve status text, got %q", err.Error())
}
}
func TestWrapHTMLResponseError_ReturnsHTTPError(t *testing.T) {
err := WrapHTMLResponseError(
http.StatusBadGateway,
[]byte("<html>bad</html>"),
"text/html",
"https://api.example.com",
)
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("WrapHTMLResponseError() error = %T, want *HTTPError", err)
}
if !httpErr.IsHTML {
t.Fatal("expected IsHTML")
}
if !strings.Contains(err.Error(), "HTML instead of JSON") {
t.Fatalf("Error() should preserve HTML message, got %q", err.Error())
}
}
+16
View File
@@ -8,6 +8,8 @@ import (
"regexp"
"strings"
"syscall"
"github.com/sipeed/picoclaw/pkg/providers/common"
)
// Common patterns in Go HTTP error messages
@@ -88,6 +90,8 @@ var (
}
authPatterns = []errorPattern{
rxp(`\b(?:invalid|incorrect|malformed|wrong)[-_\s]+(?:api[-_\s]*)?key\b`),
rxp(`\b(?:api[-_\s]*)?key[-_\s]+(?:is[-_\s]+)?(?:invalid|incorrect|malformed|wrong)\b`),
rxp(`invalid[_ ]?api[_ ]?key`),
substr("incorrect api key"),
substr("invalid token"),
@@ -188,6 +192,18 @@ func ClassifyError(err error, provider, model string) *FailoverError {
}
// Try HTTP status code extraction first.
var httpErr *common.HTTPError
if errors.As(err, &httpErr) && httpErr != nil {
if reason := classifyByStatus(httpErr.StatusCode); reason != "" {
return &FailoverError{
Reason: reason,
Provider: provider,
Model: model,
Status: httpErr.StatusCode,
Wrapped: err,
}
}
}
if status := extractHTTPStatus(msg); status > 0 {
if reason := classifyByStatus(status); reason != "" {
return &FailoverError{
+1 -1
View File
@@ -201,7 +201,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return finalizeProviderFromConfig(provider, modelID, cfg)
case "litellm", "lmstudio", "gpt4free", "openrouter", "groq", "zhipu", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras",
"nearai", "ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral",
"avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo":
// All other OpenAI-compatible HTTP providers
+48
View File
@@ -223,6 +223,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
}{
{"openai", "openai"},
{"venice", "venice"},
{"nearai", "nearai"},
{"groq", "groq"},
{"novita", "novita"},
{"openrouter", "openrouter"},
@@ -288,6 +289,15 @@ func TestGetDefaultAPIBase_Venice(t *testing.T) {
}
}
func TestGetDefaultAPIBase_NearAI(t *testing.T) {
if got := getDefaultAPIBase("nearai"); got != "https://cloud-api.near.ai/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "nearai", got, "https://cloud-api.near.ai/v1")
}
if got := getDefaultAPIBase("near-ai"); got != "https://cloud-api.near.ai/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "near-ai", got, "https://cloud-api.near.ai/v1")
}
}
func TestGetDefaultAPIBase_SiliconFlow(t *testing.T) {
if got := getDefaultAPIBase("siliconflow"); got != "https://api.siliconflow.cn/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "siliconflow", got, "https://api.siliconflow.cn/v1")
@@ -525,6 +535,28 @@ func TestCreateProviderFromConfig_Venice(t *testing.T) {
}
}
func TestCreateProviderFromConfig_NearAI(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-nearai",
Model: "nearai/zai-org/GLM-5.1-FP8",
}
cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "zai-org/GLM-5.1-FP8" {
t.Errorf("modelID = %q, want %q", modelID, "zai-org/GLM-5.1-FP8")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_SiliconFlow(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-siliconflow",
@@ -1076,6 +1108,22 @@ func TestModelProviderOptions(t *testing.T) {
"https://api.siliconflow.cn/v1",
)
}
if option, ok := seen["nearai"]; !ok {
t.Fatal("nearai option missing")
} else {
if option.DisplayName != "NEAR AI Cloud" {
t.Fatalf("nearai display_name = %q, want %q", option.DisplayName, "NEAR AI Cloud")
}
if option.DefaultAPIBase != "https://cloud-api.near.ai/v1" {
t.Fatalf("nearai default_api_base = %q, want %q", option.DefaultAPIBase, "https://cloud-api.near.ai/v1")
}
if !option.SupportsFetch {
t.Fatal("nearai should support upstream model listing")
}
if len(option.CommonModels) == 0 {
t.Fatal("nearai common_models should not be empty")
}
}
if option, ok := seen["anthropic"]; !ok {
t.Fatal("anthropic option missing")
} else if option.DefaultAPIBase != "https://api.anthropic.com/v1" {
+1
View File
@@ -285,6 +285,7 @@ func (p *GeminiProvider) buildRequestBody(
}
if thoughtSignature != "" {
part.ThoughtSignature = thoughtSignature
part.ThoughtSignatureSnake = thoughtSignature
}
content.Parts = append(content.Parts, part)
}
@@ -453,7 +453,7 @@ func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) {
}
}
func TestGeminiProvider_BuildRequestBody_UsesCamelCaseThoughtSignatureOnly(t *testing.T) {
func TestGeminiProvider_BuildRequestBody_SetsBothThoughtSignatureFormats(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody(
@@ -484,8 +484,12 @@ func TestGeminiProvider_BuildRequestBody_UsesCamelCaseThoughtSignatureOnly(t *te
if !strings.Contains(jsonBody, `"thoughtSignature":"sig-1"`) {
t.Fatalf("request body = %s, expected camelCase thoughtSignature", jsonBody)
}
if strings.Contains(jsonBody, `"thought_signature"`) {
t.Fatalf("request body = %s, unexpected snake_case thought_signature", jsonBody)
if !strings.Contains(jsonBody, `"thought_signature":"sig-1"`) {
t.Fatalf(
"request body = %s, expected snake_case thought_signature for "+
"Gemini 3.5 Flash Agentic compatibility",
jsonBody,
)
}
}
+16 -1
View File
@@ -53,6 +53,7 @@ const (
var stripModelPrefixProviders = map[string]struct{}{
"litellm": {},
"nearai": {},
"venice": {},
"moonshot": {},
"nvidia": {},
@@ -153,7 +154,21 @@ func (p *Provider) buildRequestBody(
}
// When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview.
nativeSearch, _ := options["native_search"].(bool)
nativeSearch, ok := options["native_search"].(bool)
if !ok {
// If the option is present but not a bool, log a warning and
// treat it as false — web_search_preview must not be injected
// when the caller cannot express a well-typed intent.
if _, present := options["native_search"]; present {
logger.WarnCF(
"provider.openai_compat",
"native_search option has unexpected type, ignoring",
map[string]any{
"type": fmt.Sprintf("%T", options["native_search"]),
},
)
}
}
nativeSearch = nativeSearch && isNativeSearchHost(p.apiBase)
if len(tools) > 0 || nativeSearch {
requestBody["tools"] = buildToolsList(tools, nativeSearch)
+18
View File
@@ -398,6 +398,24 @@ var modelProviderOptionsByName = map[string]ModelProviderOption{
Priority: 45,
httpAPI: true,
},
"nearai": {
ID: "nearai",
DisplayName: "NEAR AI Cloud",
Domain: "near.ai",
DefaultAPIBase: "https://cloud-api.near.ai/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 44.5,
CommonModels: []string{
"zai-org/GLM-5.1-FP8",
"openai/gpt-oss-120b",
"Qwen/Qwen3-30B-A3B-Instruct-2507",
"Qwen/Qwen3.6-35B-A3B-FP8",
},
Aliases: []string{"near-ai", "near-ai-cloud"},
httpAPI: true,
},
"shengsuanyun": {
ID: "shengsuanyun",
DisplayName: "ShengsuanYun",
+4 -1
View File
@@ -124,6 +124,9 @@ func (t *ExpandTool) Execute(ctx context.Context, args map[string]any) *tools.To
"tokenCount": result.TokenCount,
"messages": messages,
}
data, _ := json.Marshal(output)
data, err := json.Marshal(output)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("failed to marshal expand result: %v", err))
}
return tools.NewToolResult(string(data))
}
+4 -1
View File
@@ -167,6 +167,9 @@ func (t *GrepTool) Execute(ctx context.Context, args map[string]any) *tools.Tool
output["hint"] = result.Hint
}
data, _ := json.Marshal(output)
data, err := json.Marshal(output)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("failed to marshal grep result: %v", err))
}
return tools.NewToolResult(string(data))
}
+79 -26
View File
@@ -26,12 +26,13 @@ type JobExecutor interface {
// CronTool provides scheduling capabilities for the agent
type CronTool struct {
cronService *cron.CronService
executor JobExecutor
msgBus *bus.MessageBus
execTool *ExecTool
allowCommand bool
execEnabled bool
cronService *cron.CronService
executor JobExecutor
msgBus *bus.MessageBus
execTool *ExecTool
allowCommand bool
execEnabled bool
commandAllowedRemotes []string
}
// NewCronTool creates a new CronTool
@@ -42,9 +43,11 @@ func NewCronTool(
) (*CronTool, error) {
allowCommand := true
execEnabled := true
var commandAllowedRemotes []string
if config != nil {
allowCommand = config.Tools.Cron.AllowCommand
execEnabled = config.Tools.Exec.Enabled
commandAllowedRemotes = config.Tools.Cron.CommandAllowedRemotes
}
var execTool *ExecTool
@@ -60,12 +63,13 @@ func NewCronTool(
execTool.SetTimeout(execTimeout)
}
return &CronTool{
cronService: cronService,
executor: executor,
msgBus: msgBus,
execTool: execTool,
allowCommand: allowCommand,
execEnabled: execEnabled,
cronService: cronService,
executor: executor,
msgBus: msgBus,
execTool: execTool,
allowCommand: allowCommand,
execEnabled: execEnabled,
commandAllowedRemotes: commandAllowedRemotes,
}, nil
}
@@ -150,11 +154,11 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult
case "update":
return t.updateJob(ctx, args)
case "remove":
return t.removeJob(args)
return t.removeJob(ctx, args)
case "enable":
return t.enableJob(args, true)
return t.enableJob(ctx, args, true)
case "disable":
return t.enableJob(args, false)
return t.enableJob(ctx, args, false)
default:
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
}
@@ -217,8 +221,10 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
if !t.execEnabled {
return ErrorResult("command execution is disabled")
}
if !constants.IsInternalChannel(channel) {
return ErrorResult("scheduling command execution is restricted to internal channels")
if !constants.IsInternalChannel(channel) && !isCommandAllowedRemote(channel, chatID, t.commandAllowedRemotes) {
return ErrorResult(
"scheduling command execution is restricted to internal channels or configured remote channels",
)
}
if !t.allowCommand && !commandConfirm {
return ErrorResult("command_confirm=true is required when allow_command is disabled")
@@ -371,12 +377,20 @@ func (t *CronTool) updateJob(ctx context.Context, args map[string]any) *ToolResu
return SilentResult(fmt.Sprintf("Cron job updated:\n%s", formatCronJobJSON(updated)))
}
func (t *CronTool) removeJob(args map[string]any) *ToolResult {
func (t *CronTool) removeJob(ctx context.Context, args map[string]any) *ToolResult {
jobID, ok := args["job_id"].(string)
if !ok || jobID == "" {
return ErrorResult("job_id is required for remove")
}
job, ok := t.cronService.GetJob(jobID)
if !ok {
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
}
if !t.canAccessJob(ctx, job) {
return ErrorResult(fmt.Sprintf("Job %s is not accessible from this channel", jobID))
}
if t.cronService.RemoveJob(jobID) {
return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID))
}
@@ -483,11 +497,15 @@ func positiveSeconds(args map[string]any, key string) (int64, *ToolResult) {
}
func (t *CronTool) validateCommandMutation(ctx context.Context, args map[string]any) *ToolResult {
channel := ToolChannel(ctx)
chatID := ToolChatID(ctx)
if !t.execEnabled {
return ErrorResult("command execution is disabled")
}
if !constants.IsInternalChannel(ToolChannel(ctx)) {
return ErrorResult("updating command execution is restricted to internal channels")
if !constants.IsInternalChannel(channel) && !isCommandAllowedRemote(channel, chatID, t.commandAllowedRemotes) {
return ErrorResult(
"updating command execution is restricted to internal channels or configured remote channels",
)
}
commandConfirm, _ := args["command_confirm"].(bool)
if !t.allowCommand && !commandConfirm {
@@ -496,19 +514,46 @@ func (t *CronTool) validateCommandMutation(ctx context.Context, args map[string]
return nil
}
func isCommandAllowedRemote(channel, chatID string, allowed []string) bool {
if channel == "" {
return false
}
target := channel
if chatID != "" {
target = channel + ":" + chatID
}
for _, entry := range allowed {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if entry == "*" || entry == channel || entry == target {
return true
}
}
return false
}
func (t *CronTool) canAccessJob(ctx context.Context, job *cron.CronJob) bool {
channel := ToolChannel(ctx)
if constants.IsInternalChannel(channel) {
return true
}
chatID := ToolChatID(ctx)
if channel == "" || chatID == "" {
return false
}
if job.Payload.Command != "" {
if job.Payload.Channel != channel || job.Payload.To != chatID {
return false
}
return job.Payload.Channel == channel && job.Payload.To == chatID
if job.Payload.Command != "" {
return isCommandAllowedRemote(channel, chatID, t.commandAllowedRemotes)
}
return true
}
func formatCronJobJSON(job *cron.CronJob) string {
@@ -519,14 +564,22 @@ func formatCronJobJSON(job *cron.CronJob) string {
return string(data)
}
func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult {
func (t *CronTool) enableJob(ctx context.Context, args map[string]any, enable bool) *ToolResult {
jobID, ok := args["job_id"].(string)
if !ok || jobID == "" {
return ErrorResult("job_id is required for enable/disable")
}
job := t.cronService.EnableJob(jobID, enable)
if job == nil {
job, ok := t.cronService.GetJob(jobID)
if !ok {
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
}
if !t.canAccessJob(ctx, job) {
return ErrorResult(fmt.Sprintf("Job %s is not accessible from this channel", jobID))
}
updatedJob := t.cronService.EnableJob(jobID, enable)
if updatedJob == nil {
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
}
@@ -534,7 +587,7 @@ func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult {
if !enable {
status = "disabled"
}
return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status))
return SilentResult(fmt.Sprintf("Cron job '%s' %s", updatedJob.Name, status))
}
// ExecuteJob executes a cron job through the agent
+455 -3
View File
@@ -87,7 +87,29 @@ func parseCronJobResult(t *testing.T, result *ToolResult) cron.CronJob {
return job
}
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels
func addTestCronJob(t *testing.T, tool *CronTool, name, channel, chatID, command string) *cron.CronJob {
t.Helper()
everyMS := int64(60_000)
job, err := tool.cronService.AddJob(
name,
cron.CronSchedule{Kind: "every", EveryMS: &everyMS},
name+" message",
channel,
chatID,
)
if err != nil {
t.Fatalf("AddJob() error: %v", err)
}
if command != "" {
job.Payload.Command = command
if err := tool.cronService.UpdateJob(job); err != nil {
t.Fatalf("UpdateJob() error: %v", err)
}
}
return job
}
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted by default.
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
@@ -102,8 +124,148 @@ func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
if !result.IsError {
t.Fatal("expected command scheduling to be blocked from remote channel")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels") {
t.Errorf("expected 'restricted to internal channels', got: %s", result.ForLLM)
if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedFromRemoteChannelAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if result.IsError {
t.Fatalf("expected command scheduling from allowed remote channel to succeed, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedFromRemoteChatIDAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{" telegram:1234567890 "}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "1234567890")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if result.IsError {
t.Fatalf("expected command scheduling from allowed remote chat to succeed, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedFromRemoteWildcardAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if result.IsError {
t.Fatalf("expected wildcard allowlist to allow remote command scheduling, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedRemoteWildcardRequiresNonEmptyChannel(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected missing channel to remain blocked even with wildcard allowlist")
}
if !strings.Contains(result.ForLLM, "no session context") {
t.Errorf("expected session context error, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandBlockedFromDifferentRemoteChatID(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram:1234567890"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "other-chat")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"command_confirm": true,
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected command scheduling from non-allowlisted remote chat to fail")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
}
}
func TestCronTool_CommandAllowedRemoteRequiresConfirmWhenAllowCommandDisabled(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.AllowCommand = false
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected allowlisted remote command scheduling to require confirm when allow_command is disabled")
}
if !strings.Contains(result.ForLLM, "command_confirm=true") {
t.Errorf("expected command_confirm requirement message, got: %s", result.ForLLM)
}
}
func TestCronTool_AllowCommandDoesNotBypassRemoteAllowlist(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.AllowCommand = true
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected allow_command=true not to bypass remote allowlist")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
}
}
@@ -554,6 +716,296 @@ func TestCronTool_RemoteCannotAccessCommandJob(t *testing.T) {
}
}
func TestCronTool_AllowlistedRemoteCanAccessOwnCommandJob(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram:chat-1"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
if listResult.IsError || !strings.Contains(listResult.ForLLM, job.ID) {
t.Fatalf("expected list to include own command job, got: %+v", listResult)
}
getResult := tool.Execute(ctx, map[string]any{"action": "get", "job_id": job.ID})
if getResult.IsError {
t.Fatalf("expected get to access own command job, got: %s", getResult.ForLLM)
}
got := parseCronJobResult(t, getResult)
if got.ID != job.ID || got.Payload.Command != "df -h" {
t.Fatalf("get returned wrong command job: %+v", got)
}
updateResult := tool.Execute(ctx, map[string]any{
"action": "update",
"job_id": job.ID,
"message": "updated command description",
})
if updateResult.IsError {
t.Fatalf("expected update to access own command job, got: %s", updateResult.ForLLM)
}
updated, _ := tool.cronService.GetJob(job.ID)
if updated.Payload.Message != "updated command description" || updated.Payload.Command != "df -h" {
t.Fatalf("update returned wrong command payload: %+v", updated.Payload)
}
}
func TestCronTool_AllowlistedRemoteCannotAccessOtherChatCommandJob(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-2", "df -h")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
if listResult.IsError || strings.Contains(listResult.ForLLM, job.ID) {
t.Fatalf("expected list to hide other chat command job, got: %+v", listResult)
}
for _, action := range []string{"get", "update"} {
args := map[string]any{"action": action, "job_id": job.ID}
if action == "update" {
args["message"] = "changed"
}
result := tool.Execute(ctx, args)
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
t.Fatalf("expected %s to reject other chat command job, got: %+v", action, result)
}
}
}
func TestCronTool_NonAllowlistedRemoteCannotAccessOwnCommandJob(t *testing.T) {
tool := newTestCronTool(t)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
if listResult.IsError || strings.Contains(listResult.ForLLM, job.ID) {
t.Fatalf("expected list to hide non-allowlisted command job, got: %+v", listResult)
}
for _, action := range []string{"get", "update"} {
args := map[string]any{"action": action, "job_id": job.ID}
if action == "update" {
args["message"] = "changed"
}
result := tool.Execute(ctx, args)
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
t.Fatalf("expected %s to reject non-allowlisted command job, got: %+v", action, result)
}
}
}
func TestCronTool_WildcardRemoteCanAccessOwnCommandJob(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
other := addTestCronJob(t, tool, "other", "telegram", "chat-2", "uptime")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
if listResult.IsError || !strings.Contains(listResult.ForLLM, job.ID) {
t.Fatalf("expected wildcard list to include own command job, got: %+v", listResult)
}
if strings.Contains(listResult.ForLLM, other.ID) {
t.Fatalf("wildcard list should still hide other chat job, got: %s", listResult.ForLLM)
}
getResult := tool.Execute(ctx, map[string]any{"action": "get", "job_id": job.ID})
if getResult.IsError {
t.Fatalf("expected wildcard get to access own command job, got: %s", getResult.ForLLM)
}
}
func TestCronTool_InternalChannelCanAccessAllCommandJobs(t *testing.T) {
tool := newTestCronTool(t)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
ctx := WithToolContext(context.Background(), "cli", "direct")
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
if listResult.IsError || !strings.Contains(listResult.ForLLM, job.ID) {
t.Fatalf("expected internal list to include command job, got: %+v", listResult)
}
getResult := tool.Execute(ctx, map[string]any{"action": "get", "job_id": job.ID})
if getResult.IsError {
t.Fatalf("expected internal get to access command job, got: %s", getResult.ForLLM)
}
updateResult := tool.Execute(ctx, map[string]any{
"action": "update",
"job_id": job.ID,
"message": "internal update",
})
if updateResult.IsError {
t.Fatalf("expected internal update to access command job, got: %s", updateResult.ForLLM)
}
}
func TestCronTool_AllowlistedRemoteCanManageOwnCommandJob(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram:chat-1"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
if action == "enable" {
tool.cronService.EnableJob(job.ID, false)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if result.IsError {
t.Fatalf("expected %s to access own command job, got: %s", action, result.ForLLM)
}
saved, ok := tool.cronService.GetJob(job.ID)
switch action {
case "remove":
if ok {
t.Fatalf("remove should delete own command job: %+v", saved)
}
case "enable":
if !ok || !saved.Enabled {
t.Fatalf("enable should enable own command job: %+v", saved)
}
case "disable":
if !ok || saved.Enabled {
t.Fatalf("disable should disable own command job: %+v", saved)
}
}
})
}
}
func TestCronTool_RemoteCannotManageOtherChatJob(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-2", "df -h")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
t.Fatalf("expected %s to reject other chat job, got: %+v", action, result)
}
saved, ok := tool.cronService.GetJob(job.ID)
if !ok {
t.Fatalf("%s should not remove other chat job", action)
}
if !saved.Enabled {
t.Fatalf("%s should not disable other chat job: %+v", action, saved)
}
})
}
}
func TestCronTool_RemoteCannotManageCommandJobUnlessAllowlisted(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
tool := newTestCronTool(t)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
t.Fatalf("expected %s to reject non-allowlisted command job, got: %+v", action, result)
}
saved, ok := tool.cronService.GetJob(job.ID)
if !ok {
t.Fatalf("%s should not remove non-allowlisted command job", action)
}
if !saved.Enabled {
t.Fatalf("%s should not disable non-allowlisted command job: %+v", action, saved)
}
})
}
}
func TestCronTool_InternalChannelCanManageAllJobs(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
tool := newTestCronTool(t)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
if action == "enable" {
tool.cronService.EnableJob(job.ID, false)
}
ctx := WithToolContext(context.Background(), "cli", "direct")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if result.IsError {
t.Fatalf("expected internal %s to access command job, got: %s", action, result.ForLLM)
}
saved, ok := tool.cronService.GetJob(job.ID)
switch action {
case "remove":
if ok {
t.Fatalf("internal remove should delete command job: %+v", saved)
}
case "enable":
if !ok || !saved.Enabled {
t.Fatalf("internal enable should enable command job: %+v", saved)
}
case "disable":
if !ok || saved.Enabled {
t.Fatalf("internal disable should disable command job: %+v", saved)
}
}
})
}
}
func TestCronTool_RemoteCanManageOwnNonCommandJob(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
tool := newTestCronTool(t)
job := addTestCronJob(t, tool, "reminder", "telegram", "chat-1", "")
if action == "enable" {
tool.cronService.EnableJob(job.ID, false)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if result.IsError {
t.Fatalf("expected %s to access own non-command job, got: %s", action, result.ForLLM)
}
})
}
}
func TestCronTool_WildcardRemoteCanManageOwnCommandJob(t *testing.T) {
for _, action := range []string{"remove", "enable", "disable"} {
t.Run(action, func(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
tool := newTestCronToolWithConfig(t, cfg)
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
if action == "enable" {
tool.cronService.EnableJob(job.ID, false)
}
other := addTestCronJob(t, tool, "other", "telegram", "chat-2", "uptime")
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
if result.IsError {
t.Fatalf("expected wildcard %s to access own command job, got: %s", action, result.ForLLM)
}
otherResult := tool.Execute(ctx, map[string]any{"action": action, "job_id": other.ID})
if !otherResult.IsError || !strings.Contains(otherResult.ForLLM, "not accessible") {
t.Fatalf("wildcard %s should still reject other chat job, got: %+v", action, otherResult)
}
})
}
}
func TestCronTool_CommandUpdateSafetyGates(t *testing.T) {
t.Run("exec disabled", func(t *testing.T) {
cfg := config.DefaultConfig()
+37 -266
View File
@@ -55,9 +55,9 @@ var (
`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`,
)
reSogouTitle = regexp.MustCompile(
`<a\s+class=resultLink\s+href="([^"]+)"[^>]*id="sogou_vr_\d+_\d+"[^>]*>\s*(.*?)\s*</a>`,
`<a\s+class="?resultLink"?\s+href="([^"]+)"[^>]*id="sogou_vr_\d+_\d+"[^>]*>\s*(.*?)\s*</a>`,
)
reSogouSnippet = regexp.MustCompile(`<div class="clamp\d*">\s*(.*?)\s*</div>`)
reSogouSnippet = regexp.MustCompile(`<div class="clamp\d*[^"]*">\s*(.*?)\s*</div>`)
reSogouRealURL = regexp.MustCompile(`url=([^&]+)`)
)
@@ -350,6 +350,19 @@ func (p *BraveSearchProvider) Search(
results := searchResp.Web.Results
if len(results) == 0 {
// Log a warning when the API returned 200 but no results.
// This helps diagnose API format changes or silent errors
// where the response body does not match the expected structure.
bodyPreview := string(body)
if len(bodyPreview) > 300 {
bodyPreview = bodyPreview[:300]
}
logger.WarnCF("web_search", "Brave API returned empty results",
map[string]any{
"query": query,
"status": resp.StatusCode,
"body_preview": bodyPreview,
})
return fmt.Sprintf("No results for: %s", query), nil
}
@@ -1996,16 +2009,9 @@ type WebFetchTool struct {
client *http.Client
format string
fetchLimitBytes int64
whitelist *privateHostWhitelist
whitelist *utils.PrivateHostWhitelist
}
type privateHostWhitelist struct {
exact map[string]struct{}
cidrs []*net.IPNet
}
type webFetchAllowedFirstHopHostKey struct{}
func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) {
// createHTTPClient cannot fail with an empty proxy string.
return NewWebFetchToolWithConfig(maxChars, "", format, fetchLimitBytes, nil)
@@ -2035,31 +2041,22 @@ func NewWebFetchToolWithConfig(
if maxChars <= 0 {
maxChars = defaultMaxChars
}
whitelist, err := newPrivateHostWhitelist(privateHostWhitelist)
whitelist, err := utils.NewPrivateHostWhitelist(privateHostWhitelist)
if err != nil {
return nil, fmt.Errorf("failed to parse web fetch private host whitelist: %w", err)
}
client, err := utils.CreateHTTPClient(proxy, fetchTimeout)
client, err := utils.CreateSafeHTTPClient(utils.SafeHTTPClientOptions{
ProxyURL: proxy,
Timeout: fetchTimeout,
PrivateHostWhitelist: privateHostWhitelist,
AllowPrivateHosts: func() bool {
return allowPrivateWebFetchHosts.Load()
},
MaxRedirects: maxRedirects,
})
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
}
if transport, ok := client.Transport.(*http.Transport); ok {
dialer := &net.Dialer{
Timeout: 15 * time.Second,
KeepAlive: 30 * time.Second,
}
transport.DialContext = newSafeDialContext(dialer, whitelist)
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
if isObviousPrivateHost(req.URL.Hostname(), whitelist) {
return fmt.Errorf("redirect target is private or local network host")
}
allowConfiguredProxyFirstHop(req, client.Transport)
return nil
}
if fetchLimitBytes <= 0 {
fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback
}
@@ -2121,7 +2118,9 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
// Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution.
// The real SSRF guard is newSafeDialContext at connect time.
hostname := parsedURL.Hostname()
if isObviousPrivateHost(hostname, t.whitelist) {
if utils.IsObviousPrivateHost(hostname, t.whitelist, func() bool {
return allowPrivateWebFetchHosts.Load()
}) {
return ErrorResult("fetching private or local network hosts is not allowed")
}
@@ -2137,7 +2136,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
if reqErr != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", reqErr)
}
allowConfiguredProxyFirstHop(req, t.client.Transport)
utils.AllowConfiguredProxyFirstHop(req, t.client.Transport)
req.Header.Set("User-Agent", ua)
resp, doErr := t.client.Do(req)
if doErr != nil {
@@ -2325,247 +2324,19 @@ func (t *WebFetchTool) extractText(htmlContent string) string {
return strings.Join(cleanLines, "\n")
}
// newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU)
// where a hostname resolves to a public IP during pre-flight but a private IP at connect time.
func newSafeDialContext(
dialer *net.Dialer,
whitelist *privateHostWhitelist,
whitelist *utils.PrivateHostWhitelist,
) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
if allowPrivateWebFetchHosts.Load() {
return dialer.DialContext(ctx, network, address)
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid target address %q: %w", address, err)
}
if host == "" {
return nil, fmt.Errorf("empty target host")
}
if isAllowedFirstHopHost(ctx, host) {
return dialer.DialContext(ctx, network, address)
}
if ip := net.ParseIP(host); ip != nil {
if shouldBlockPrivateIP(ip, whitelist) {
return nil, fmt.Errorf("blocked private or local target: %s", host)
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}
ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("failed to resolve %s: %w", host, err)
}
attempted := 0
var lastErr error
for _, ipAddr := range ipAddrs {
if shouldBlockPrivateIP(ipAddr.IP, whitelist) {
continue
}
attempted++
conn, err := dialer.DialContext(
ctx,
network,
net.JoinHostPort(ipAddr.IP.String(), port),
)
if err == nil {
return conn, nil
}
lastErr = err
}
if attempted == 0 {
return nil, fmt.Errorf(
"all resolved addresses for %s are private, restricted, or not whitelisted",
host,
)
}
if lastErr != nil {
return nil, fmt.Errorf(
"failed connecting to public addresses for %s: %w",
host,
lastErr,
)
}
return nil, fmt.Errorf("failed connecting to public addresses for %s", host)
}
return utils.NewSafeDialContext(dialer, whitelist, func() bool {
return allowPrivateWebFetchHosts.Load()
})
}
func allowConfiguredProxyFirstHop(req *http.Request, rt http.RoundTripper) {
if req == nil {
return
}
transport, ok := rt.(*http.Transport)
if !ok || transport.Proxy == nil {
return
}
proxyURL, err := transport.Proxy(req)
if err != nil || proxyURL == nil {
return
}
host := normalizeAllowedFirstHopHost(proxyURL.Hostname())
if host == "" {
return
}
*req = *req.WithContext(context.WithValue(
req.Context(),
webFetchAllowedFirstHopHostKey{},
host,
))
func newPrivateHostWhitelist(entries []string) (*utils.PrivateHostWhitelist, error) {
return utils.NewPrivateHostWhitelist(entries)
}
func isAllowedFirstHopHost(ctx context.Context, host string) bool {
allowed, ok := ctx.Value(webFetchAllowedFirstHopHostKey{}).(string)
if !ok || allowed == "" {
return false
}
return allowed == normalizeAllowedFirstHopHost(host)
}
func normalizeAllowedFirstHopHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
return strings.TrimSuffix(host, ".")
}
func newPrivateHostWhitelist(entries []string) (*privateHostWhitelist, error) {
if len(entries) == 0 {
return nil, nil
}
whitelist := &privateHostWhitelist{
exact: make(map[string]struct{}),
cidrs: make([]*net.IPNet, 0, len(entries)),
}
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if ip := net.ParseIP(entry); ip != nil {
whitelist.exact[normalizeWhitelistIP(ip).String()] = struct{}{}
continue
}
_, network, err := net.ParseCIDR(entry)
if err != nil {
return nil, fmt.Errorf("invalid entry %q: expected IP or CIDR", entry)
}
whitelist.cidrs = append(whitelist.cidrs, network)
}
if len(whitelist.exact) == 0 && len(whitelist.cidrs) == 0 {
return nil, nil
}
return whitelist, nil
}
func (w *privateHostWhitelist) Contains(ip net.IP) bool {
if w == nil || ip == nil {
return false
}
normalized := normalizeWhitelistIP(ip)
if _, ok := w.exact[normalized.String()]; ok {
return true
}
for _, network := range w.cidrs {
if network.Contains(normalized) {
return true
}
}
return false
}
func normalizeWhitelistIP(ip net.IP) net.IP {
if ip == nil {
return nil
}
if ip4 := ip.To4(); ip4 != nil {
return ip4
}
return ip
}
func shouldBlockPrivateIP(ip net.IP, whitelist *privateHostWhitelist) bool {
return isPrivateOrRestrictedIP(ip) && !whitelist.Contains(ip)
}
// isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts.
// It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS —
// the real SSRF guard is newSafeDialContext which checks IPs at connect time.
func isObviousPrivateHost(host string, whitelist *privateHostWhitelist) bool {
if allowPrivateWebFetchHosts.Load() {
return false
}
h := strings.ToLower(strings.TrimSpace(host))
h = strings.TrimSuffix(h, ".")
if h == "" {
return true
}
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
return true
}
if ip := net.ParseIP(h); ip != nil {
return shouldBlockPrivateIP(ip, whitelist)
}
return false
}
// isPrivateOrRestrictedIP returns true for IPs that should never be reached via web_fetch:
// RFC 1918, loopback, link-local (incl. cloud metadata 169.254.x.x), carrier-grade NAT,
// benchmark (198.18.0.0/15), IPv6 unique-local (fc00::/7), 6to4 (2002::/16), and
// Teredo (2001:0000::/32).
func isPrivateOrRestrictedIP(ip net.IP) bool {
if ip == nil {
return true
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsUnspecified() {
return true
}
if ip4 := ip.To4(); ip4 != nil {
// IPv4 private, loopback, link-local, and carrier-grade NAT ranges.
if ip4[0] == 10 ||
ip4[0] == 127 ||
ip4[0] == 0 ||
(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||
(ip4[0] == 192 && ip4[1] == 168) ||
(ip4[0] == 169 && ip4[1] == 254) ||
(ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127) ||
(ip4[0] == 198 && ip4[1] >= 18 && ip4[1] <= 19) {
return true
}
return false
}
if len(ip) == net.IPv6len {
// IPv6 unique local addresses (fc00::/7)
if (ip[0] & 0xfe) == 0xfc {
return true
}
// 6to4 addresses (2002::/16): check the embedded IPv4 at bytes [2:6].
if ip[0] == 0x20 && ip[1] == 0x02 {
embedded := net.IPv4(ip[2], ip[3], ip[4], ip[5])
return isPrivateOrRestrictedIP(embedded)
}
// Teredo (2001:0000::/32): client IPv4 is at bytes [12:16], XOR-inverted.
if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 {
client := net.IPv4(ip[12]^0xff, ip[13]^0xff, ip[14]^0xff, ip[15]^0xff)
return isPrivateOrRestrictedIP(client)
}
}
return false
return utils.IsPrivateOrRestrictedIP(ip)
}
+25
View File
@@ -845,6 +845,25 @@ func TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) {
}
}
// TestWebFetch_BlocksISATAPWithPrivateEmbed verifies ISATAP with private embedded IPv4 is blocked
func TestWebFetch_BlocksISATAPWithPrivateEmbed(t *testing.T) {
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
// 2001:db8:1234::5efe:127.0.0.1 embeds 127.0.0.1
result := tool.Execute(context.Background(), map[string]any{
"url": "http://[2001:db8:1234::5efe:127.0.0.1]:0",
})
if !result.IsError {
t.Error("expected error for ISATAP with private embedded IPv4, got success")
}
if !strings.Contains(result.ForLLM, "private or local network hosts is not allowed") {
t.Fatalf("expected private-host guard rejection, got %q", result.ForLLM)
}
}
// TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked
func TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) {
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
@@ -981,6 +1000,12 @@ func TestIsPrivateOrRestrictedIP_Table(t *testing.T) {
{"2002:7f00:0001::1", true, "6to4 with embedded 127.x (private)"},
{"2002:0a00:0001::1", true, "6to4 with embedded 10.0.0.1 (private)"},
{"2002:0801:0101::1", false, "6to4 with embedded 8.1.1.1 (public)"},
{"2001:db8:1234::5efe:127.0.0.1", true, "ISATAP with embedded 127.0.0.1 (private)"},
{"2001:db8:1234::5efe:10.0.0.1", true, "ISATAP with embedded 10.0.0.1 (private)"},
{"2001:db8:1234::5efe:8.8.8.8", false, "ISATAP with embedded 8.8.8.8 (public)"},
{"2001:db8:1234:0:0200:5efe:127.0.0.1", true, "ISATAP 0200 with embedded 127.0.0.1 (private)"},
{"2001:db8:1234:0:0200:5efe:10.0.0.1", true, "ISATAP 0200 with embedded 10.0.0.1 (private)"},
{"2001:db8:1234:0:0200:5efe:8.8.8.8", false, "ISATAP 0200 with embedded 8.8.8.8 (public)"},
{"2001:0000:4136:e378:8000:63bf:f5ff:fffe", true, "Teredo with client 10.0.0.1 (private)"},
{"2001:0000:4136:e378:8000:63bf:f7f6:fefe", false, "Teredo with client 8.9.1.1 (public)"},
{"2607:f8b0:4004:800::200e", false, "public IPv6 (Google)"},
+43 -2
View File
@@ -12,6 +12,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"runtime/debug"
"strings"
"sync"
"time"
@@ -417,6 +418,16 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult
done := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
logger.ErrorCF("shell", "cmd.Wait goroutine panic recovered",
map[string]any{
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
done <- fmt.Errorf("panic in cmd.Wait: %v", r)
}
}()
done <- cmd.Wait()
}()
@@ -558,7 +569,7 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn
// with synchronous exec runs.
if err := isolation.Start(cmd); err != nil {
if session.ptyMaster != nil {
session.ptyMaster.Close()
_ = session.ptyMaster.Close()
}
return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
}
@@ -573,6 +584,18 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn
// so we need cmd.Wait() in a separate goroutine to detect process exit.
if session.PTY && session.ptyMaster != nil {
go func() {
defer func() {
if r := recover(); r != nil {
logger.ErrorCF("shell", "PTY cmd.Wait goroutine panic recovered",
map[string]any{
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
session.mu.Lock()
session.Status = "error"
session.mu.Unlock()
}
}()
cmd.Wait() // Wait for process to exit
session.mu.Lock()
if cmd.ProcessState != nil {
@@ -583,6 +606,15 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn
}()
go func() {
defer func() {
if r := recover(); r != nil {
logger.ErrorCF("shell", "PTY read goroutine panic recovered",
map[string]any{
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
}
}()
buf := make([]byte, 4096)
for {
n, err := session.ptyMaster.Read(buf)
@@ -613,6 +645,15 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn
// When Read() returns EOF (pipe closed), we break.
// When process exits, OS closes pipe write end → Read() returns EOF → we exit.
go func() {
defer func() {
if r := recover(); r != nil {
logger.ErrorCF("shell", "pipe read goroutine panic recovered",
map[string]any{
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
}
}()
buf := make([]byte, 4096)
// Read stdout
@@ -657,7 +698,7 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn
// All pipes closed, get exit status
if stdinWriter != nil {
stdinWriter.Close()
_ = stdinWriter.Close()
}
cmd.Wait()
+12
View File
@@ -10,6 +10,7 @@ import (
"context"
"encoding/json"
"fmt"
"runtime/debug"
"sync"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -165,6 +166,17 @@ func RunToolLoop(
wg.Add(1)
go func(idx int, tc providers.ToolCall) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
logger.ErrorCF("toolloop", "tool execution goroutine panic recovered",
map[string]any{
"tool": tc.Name,
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
results[idx].result = ErrorResult(fmt.Sprintf("internal panic in tool %s", tc.Name))
}
}()
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)
+5 -5
View File
@@ -352,7 +352,7 @@ func findAssetInfo(releaseURL, platform, arch string) (string, string, error) {
continue
}
bs, err := io.ReadAll(resp2.Body)
resp2.Body.Close()
_ = resp2.Body.Close()
if err != nil {
continue
}
@@ -576,16 +576,16 @@ func extractZip(archivePath, destDir string) error {
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.FileInfo().Mode())
if err != nil {
rc.Close()
_ = rc.Close()
return err
}
if _, err := io.Copy(out, rc); err != nil {
rc.Close()
out.Close()
_ = rc.Close()
_ = out.Close()
return err
}
if err := rc.Close(); err != nil {
out.Close()
_ = out.Close()
return fmt.Errorf("close zip entry reader: %w", err)
}
if err := out.Close(); err != nil {
+325
View File
@@ -0,0 +1,325 @@
package utils
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
)
type SafeHTTPClientOptions struct {
ProxyURL string
Timeout time.Duration
PrivateHostWhitelist []string
AllowPrivateHosts func() bool
MaxRedirects int
}
type PrivateHostWhitelist struct {
exact map[string]struct{}
cidrs []*net.IPNet
}
type allowedFirstHopHostKey struct{}
func NewPrivateHostWhitelist(entries []string) (*PrivateHostWhitelist, error) {
if len(entries) == 0 {
return nil, nil
}
whitelist := &PrivateHostWhitelist{
exact: make(map[string]struct{}),
cidrs: make([]*net.IPNet, 0, len(entries)),
}
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if ip := net.ParseIP(entry); ip != nil {
whitelist.exact[normalizeWhitelistIP(ip).String()] = struct{}{}
continue
}
_, network, err := net.ParseCIDR(entry)
if err != nil {
return nil, fmt.Errorf("invalid entry %q: expected IP or CIDR", entry)
}
whitelist.cidrs = append(whitelist.cidrs, network)
}
if len(whitelist.exact) == 0 && len(whitelist.cidrs) == 0 {
return nil, nil
}
return whitelist, nil
}
func (w *PrivateHostWhitelist) Contains(ip net.IP) bool {
if w == nil || ip == nil {
return false
}
normalized := normalizeWhitelistIP(ip)
if _, ok := w.exact[normalized.String()]; ok {
return true
}
for _, network := range w.cidrs {
if network.Contains(normalized) {
return true
}
}
return false
}
func CreateSafeHTTPClient(opts SafeHTTPClientOptions) (*http.Client, error) {
client, err := CreateHTTPClient(opts.ProxyURL, opts.Timeout)
if err != nil {
return nil, err
}
whitelist, err := NewPrivateHostWhitelist(opts.PrivateHostWhitelist)
if err != nil {
return nil, err
}
transport, ok := client.Transport.(*http.Transport)
if ok {
dialer := &net.Dialer{
Timeout: 15 * time.Second,
KeepAlive: 30 * time.Second,
}
transport.DialContext = NewSafeDialContext(dialer, whitelist, opts.AllowPrivateHosts)
}
maxRedirects := opts.MaxRedirects
if maxRedirects <= 0 {
maxRedirects = 10
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
if IsObviousPrivateHost(req.URL.Hostname(), whitelist, opts.AllowPrivateHosts) {
return fmt.Errorf("redirect target is private or local network host")
}
AllowConfiguredProxyFirstHop(req, client.Transport)
return nil
}
return client, nil
}
func ValidateSafeHTTPURL(urlStr string, whitelist *PrivateHostWhitelist, allowPrivateHosts func() bool) error {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("only http/https URLs are allowed")
}
if parsedURL.Host == "" {
return fmt.Errorf("missing domain in URL")
}
if IsObviousPrivateHost(parsedURL.Hostname(), whitelist, allowPrivateHosts) {
return fmt.Errorf("fetching private or local network hosts is not allowed")
}
return nil
}
func NewSafeDialContext(
dialer *net.Dialer,
whitelist *PrivateHostWhitelist,
allowPrivateHosts func() bool,
) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
if allowPrivateHosts != nil && allowPrivateHosts() {
return dialer.DialContext(ctx, network, address)
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid target address %q: %w", address, err)
}
if host == "" {
return nil, fmt.Errorf("empty target host")
}
if isAllowedFirstHopHost(ctx, host) {
return dialer.DialContext(ctx, network, address)
}
if ip := net.ParseIP(host); ip != nil {
if shouldBlockPrivateIP(ip, whitelist) {
return nil, fmt.Errorf("blocked private or local target: %s", host)
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}
ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("failed to resolve %s: %w", host, err)
}
attempted := 0
var lastErr error
for _, ipAddr := range ipAddrs {
if shouldBlockPrivateIP(ipAddr.IP, whitelist) {
continue
}
attempted++
conn, err := dialer.DialContext(
ctx,
network,
net.JoinHostPort(ipAddr.IP.String(), port),
)
if err == nil {
return conn, nil
}
lastErr = err
}
if attempted == 0 {
return nil, fmt.Errorf(
"all resolved addresses for %s are private, restricted, or not whitelisted",
host,
)
}
if lastErr != nil {
return nil, fmt.Errorf(
"failed connecting to public addresses for %s: %w",
host,
lastErr,
)
}
return nil, fmt.Errorf("failed connecting to public addresses for %s", host)
}
}
func AllowConfiguredProxyFirstHop(req *http.Request, rt http.RoundTripper) {
if req == nil {
return
}
transport, ok := rt.(*http.Transport)
if !ok || transport.Proxy == nil {
return
}
proxyURL, err := transport.Proxy(req)
if err != nil || proxyURL == nil {
return
}
host := normalizeAllowedFirstHopHost(proxyURL.Hostname())
if host == "" {
return
}
*req = *req.WithContext(context.WithValue(
req.Context(),
allowedFirstHopHostKey{},
host,
))
}
func IsObviousPrivateHost(
host string,
whitelist *PrivateHostWhitelist,
allowPrivateHosts func() bool,
) bool {
if allowPrivateHosts != nil && allowPrivateHosts() {
return false
}
h := strings.ToLower(strings.TrimSpace(host))
h = strings.TrimSuffix(h, ".")
if h == "" {
return true
}
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
return true
}
if ip := net.ParseIP(h); ip != nil {
return shouldBlockPrivateIP(ip, whitelist)
}
return false
}
func IsPrivateOrRestrictedIP(ip net.IP) bool {
if ip == nil {
return true
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsUnspecified() {
return true
}
if ip4 := ip.To4(); ip4 != nil {
if ip4[0] == 10 ||
ip4[0] == 127 ||
ip4[0] == 0 ||
(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||
(ip4[0] == 192 && ip4[1] == 168) ||
(ip4[0] == 169 && ip4[1] == 254) ||
(ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127) ||
(ip4[0] == 198 && ip4[1] >= 18 && ip4[1] <= 19) {
return true
}
return false
}
if len(ip) == net.IPv6len {
if (ip[0] & 0xfe) == 0xfc {
return true
}
if ip[0] == 0x20 && ip[1] == 0x02 {
embedded := net.IPv4(ip[2], ip[3], ip[4], ip[5])
return IsPrivateOrRestrictedIP(embedded)
}
if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 {
client := net.IPv4(ip[12]^0xff, ip[13]^0xff, ip[14]^0xff, ip[15]^0xff)
return IsPrivateOrRestrictedIP(client)
}
// ISATAP interface identifiers embed an IPv4 address behind either
// 00:00:5e:fe or 02:00:5e:fe.
if ((ip[8] == 0x00 && ip[9] == 0x00) || (ip[8] == 0x02 && ip[9] == 0x00)) &&
ip[10] == 0x5e && ip[11] == 0xfe {
embedded := net.IPv4(ip[12], ip[13], ip[14], ip[15])
return IsPrivateOrRestrictedIP(embedded)
}
}
return false
}
func isAllowedFirstHopHost(ctx context.Context, host string) bool {
allowed, ok := ctx.Value(allowedFirstHopHostKey{}).(string)
if !ok || allowed == "" {
return false
}
return allowed == normalizeAllowedFirstHopHost(host)
}
func normalizeAllowedFirstHopHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
return strings.TrimSuffix(host, ".")
}
func normalizeWhitelistIP(ip net.IP) net.IP {
if ip == nil {
return nil
}
if ip4 := ip.To4(); ip4 != nil {
return ip4
}
return ip
}
func shouldBlockPrivateIP(ip net.IP, whitelist *PrivateHostWhitelist) bool {
return IsPrivateOrRestrictedIP(ip) && !whitelist.Contains(ip)
}
+243
View File
@@ -0,0 +1,243 @@
package utils
import (
"context"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
func TestCreateSafeHTTPClient_AllowsLoopbackProxy(t *testing.T) {
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.String() != "http://example.com/proxied" {
t.Fatalf("proxy received URL %q, want %q", r.URL.String(), "http://example.com/proxied")
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("proxied"))
}))
defer proxy.Close()
client, err := CreateSafeHTTPClient(SafeHTTPClientOptions{
ProxyURL: proxy.URL,
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("CreateSafeHTTPClient() error: %v", err)
}
req, err := http.NewRequest(http.MethodGet, "http://example.com/proxied", nil)
if err != nil {
t.Fatalf("http.NewRequest() error: %v", err)
}
AllowConfiguredProxyFirstHop(req, client.Transport)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("client.Do() error: %v", err)
}
defer resp.Body.Close()
}
func TestCreateSafeHTTPClient_BlocksPrivateRedirect(t *testing.T) {
allowPrivateHosts := true
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://127.0.0.1/secret", http.StatusFound)
}))
defer server.Close()
client, err := CreateSafeHTTPClient(SafeHTTPClientOptions{
Timeout: 5 * time.Second,
AllowPrivateHosts: func() bool {
return allowPrivateHosts
},
MaxRedirects: 5,
})
if err != nil {
t.Fatalf("CreateSafeHTTPClient() error: %v", err)
}
allowPrivateHosts = false
resp, err := client.Get(server.URL)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if err == nil {
t.Fatal("expected redirect to private host to fail")
}
if !strings.Contains(err.Error(), "private or local network host") &&
!strings.Contains(err.Error(), "blocked private or local target") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateSafeHTTPURL_BlocksLoopback(t *testing.T) {
err := ValidateSafeHTTPURL("http://127.0.0.1:8080/file", nil, nil)
if err == nil {
t.Fatal("expected loopback URL to be blocked")
}
if !strings.Contains(err.Error(), "private or local network hosts") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateSafeHTTPURL_AllowsWhitelistedPrivateHost(t *testing.T) {
whitelist, err := NewPrivateHostWhitelist([]string{"127.0.0.1"})
if err != nil {
t.Fatalf("NewPrivateHostWhitelist() error: %v", err)
}
err = ValidateSafeHTTPURL("http://127.0.0.1:8080/file", whitelist, nil)
if err != nil {
t.Fatalf("expected whitelisted private host to pass, got %v", err)
}
}
func TestNewSafeDialContext_BlocksPrivateDNSResolutionWithoutWhitelist(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen on loopback: %v", err)
}
defer listener.Close()
_, port, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
t.Fatalf("failed to split listener address: %v", err)
}
dialContext := NewSafeDialContext(&net.Dialer{Timeout: time.Second}, nil, nil)
_, err = dialContext(context.Background(), "tcp", net.JoinHostPort("localhost", port))
if err == nil {
t.Fatal("expected localhost DNS resolution to be blocked without whitelist")
}
if !strings.Contains(err.Error(), "private") && !strings.Contains(err.Error(), "whitelisted") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNewSafeDialContext_AllowsWhitelistedPrivateDNSResolution(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen on loopback: %v", err)
}
defer listener.Close()
accepted := make(chan struct{}, 1)
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
conn.Close()
accepted <- struct{}{}
}()
_, port, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
t.Fatalf("failed to split listener address: %v", err)
}
whitelist, err := NewPrivateHostWhitelist([]string{"127.0.0.0/8"})
if err != nil {
t.Fatalf("failed to parse whitelist: %v", err)
}
dialContext := NewSafeDialContext(&net.Dialer{Timeout: time.Second}, whitelist, nil)
conn, err := dialContext(context.Background(), "tcp", net.JoinHostPort("localhost", port))
if err != nil {
t.Fatalf("expected localhost DNS resolution to succeed with whitelist, got %v", err)
}
conn.Close()
select {
case <-accepted:
case <-time.After(time.Second):
t.Fatal("expected localhost listener to accept a connection")
}
}
func TestIsPrivateOrRestrictedIP_Table(t *testing.T) {
tests := []struct {
ip string
blocked bool
}{
{"127.0.0.1", true},
{"10.0.0.1", true},
{"172.16.0.1", true},
{"192.168.1.1", true},
{"169.254.169.254", true},
{"100.64.0.1", true},
{"198.18.0.1", true},
{"198.20.0.1", false},
{"0.0.0.0", true},
{"8.8.8.8", false},
{"::1", true},
{"::ffff:127.0.0.1", true},
{"fc00::1", true},
{"2002:7f00:0001::1", true},
{"2002:0801:0101::1", false},
{"2001:db8:1234::5efe:127.0.0.1", true},
{"2001:db8:1234::5efe:10.0.0.1", true},
{"2001:db8:1234::5efe:8.8.8.8", false},
{"2001:db8:1234:0:0200:5efe:127.0.0.1", true},
{"2001:db8:1234:0:0200:5efe:10.0.0.1", true},
{"2001:db8:1234:0:0200:5efe:8.8.8.8", false},
{"2001:0000:4136:e378:8000:63bf:f5ff:fffe", true},
{"2607:f8b0:4004:800::200e", false},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
ip := net.ParseIP(tt.ip)
if ip == nil {
t.Fatalf("failed to parse IP: %s", tt.ip)
}
got := IsPrivateOrRestrictedIP(ip)
if got != tt.blocked {
t.Fatalf("IsPrivateOrRestrictedIP(%s) = %v, want %v", tt.ip, got, tt.blocked)
}
})
}
}
func TestDownloadFile_DefaultAllowsLoopbackURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("local download"))
}))
defer server.Close()
path := DownloadFile(server.URL, "file.txt", DownloadOptions{
LoggerPrefix: "test",
Timeout: 5 * time.Second,
})
if path == "" {
t.Fatal("expected default DownloadFile to allow loopback URL")
}
defer os.Remove(path)
}
func TestDownloadFile_BlockPrivateTargetsBlocksRedirectToLoopback(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("secret"))
}))
defer target.Close()
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer proxy.Close()
path := DownloadFile("http://example.com/file.txt", "file.txt", DownloadOptions{
LoggerPrefix: "test",
Timeout: 5 * time.Second,
ProxyURL: proxy.URL,
BlockPrivateTargets: true,
})
if path != "" {
t.Fatalf("expected safe DownloadFile to block redirect to loopback, got %q", path)
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response,
for i := range maxRetries {
if i > 0 && resp != nil {
resp.Body.Close()
_ = resp.Body.Close()
}
resp, err = client.Do(req)
@@ -42,7 +42,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response,
if i < maxRetries-1 {
if err = sleepWithCtx(req.Context(), retryDelayForAttempt(resp, i)); err != nil {
if resp != nil {
resp.Body.Close()
_ = resp.Body.Close()
}
return nil, fmt.Errorf("failed to sleep: %w", err)
}
+6 -6
View File
@@ -68,11 +68,11 @@ func TestDoRequestWithRetry(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
_ = resp.Body.Close()
} else {
require.NotNil(t, resp)
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
resp.Body.Close()
_ = resp.Body.Close()
}
assert.Equal(t, tc.wantAttempts, attempts)
@@ -111,7 +111,7 @@ func TestDoRequestWithRetry_RetryAfter429Honored(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
_ = resp.Body.Close()
require.Equal(t, 2, attempts)
assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 900*time.Millisecond)
@@ -148,7 +148,7 @@ func TestDoRequestWithRetry_RetryAfter429InvalidFallsBack(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
_ = resp.Body.Close()
require.Equal(t, 2, attempts)
assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 45*time.Millisecond)
@@ -200,7 +200,7 @@ func TestDoRequestWithRetry_ContextCancel(t *testing.T) {
resp, err := DoRequestWithRetry(client, req)
if resp != nil {
resp.Body.Close()
_ = resp.Body.Close()
}
require.Error(t, err, "expected error from context cancellation")
assert.Nil(t, resp, "expected nil response when context is canceled")
@@ -275,7 +275,7 @@ func TestDoRequestWithRetry_Delay(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
_ = resp.Body.Close()
assert.GreaterOrEqual(t, delays[2], time.Millisecond)
}
+49 -21
View File
@@ -64,10 +64,11 @@ func SanitizeFilename(filename string) string {
// DownloadOptions holds optional parameters for downloading files
type DownloadOptions struct {
Timeout time.Duration
ExtraHeaders map[string]string
LoggerPrefix string
ProxyURL string
Timeout time.Duration
ExtraHeaders map[string]string
LoggerPrefix string
ProxyURL string
BlockPrivateTargets bool
}
// DownloadFile downloads a file from URL to a local temp directory.
@@ -93,8 +94,46 @@ func DownloadFile(urlStr, filename string, opts DownloadOptions) string {
safeName := SanitizeFilename(filename)
localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_"+safeName)
// Create HTTP request
req, err := http.NewRequest("GET", urlStr, nil)
var client *http.Client
var err error
if opts.BlockPrivateTargets {
validateErr := ValidateSafeHTTPURL(urlStr, nil, nil)
if validateErr != nil {
logger.ErrorCF(opts.LoggerPrefix, "Blocked unsafe download URL", map[string]any{
"error": validateErr.Error(),
"url": urlStr,
})
return ""
}
client, err = CreateSafeHTTPClient(SafeHTTPClientOptions{
ProxyURL: opts.ProxyURL,
Timeout: opts.Timeout,
MaxRedirects: 10,
})
if err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to create safe download client", map[string]any{
"error": err.Error(),
})
return ""
}
} else {
client = &http.Client{Timeout: opts.Timeout}
if opts.ProxyURL != "" {
proxyURL, parseErr := url.Parse(opts.ProxyURL)
if parseErr != nil {
logger.ErrorCF(opts.LoggerPrefix, "Invalid proxy URL for download", map[string]any{
"error": parseErr.Error(),
"proxy": opts.ProxyURL,
})
return ""
}
client.Transport = &http.Transport{
Proxy: http.ProxyURL(proxyURL),
}
}
}
req, err := http.NewRequest(http.MethodGet, urlStr, nil)
if err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]any{
"error": err.Error(),
@@ -106,21 +145,10 @@ func DownloadFile(urlStr, filename string, opts DownloadOptions) string {
for key, value := range opts.ExtraHeaders {
req.Header.Set(key, value)
}
client := &http.Client{Timeout: opts.Timeout}
if opts.ProxyURL != "" {
proxyURL, parseErr := url.Parse(opts.ProxyURL)
if parseErr != nil {
logger.ErrorCF(opts.LoggerPrefix, "Invalid proxy URL for download", map[string]any{
"error": parseErr.Error(),
"proxy": opts.ProxyURL,
})
return ""
}
client.Transport = &http.Transport{
Proxy: http.ProxyURL(proxyURL),
}
if opts.BlockPrivateTargets {
AllowConfiguredProxyFirstHop(req, client.Transport)
}
resp, err := client.Do(req)
if err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]any{
@@ -148,7 +176,7 @@ func DownloadFile(urlStr, filename string, opts DownloadOptions) string {
}
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close()
_ = out.Close()
os.Remove(localPath)
logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]any{
"error": err.Error(),
+52 -1
View File
@@ -734,9 +734,10 @@ type upstreamModel struct {
func fetchUpstreamModels(ctx context.Context, provider, apiBase, apiKey string) ([]upstreamModel, error) {
apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/")
provider = providers.NormalizeProvider(provider)
var fetchURL string
switch strings.ToLower(provider) {
switch provider {
case "ollama":
// Strip /v1 suffix if present to get the Ollama root
root := apiBase
@@ -746,6 +747,9 @@ func fetchUpstreamModels(ctx context.Context, provider, apiBase, apiKey string)
root = strings.TrimRight(root, "/")
fetchURL = root + "/api/tags"
return fetchOllamaModels(ctx, fetchURL)
case "nearai":
fetchURL = apiBase + "/model/list"
return fetchNearAIModels(ctx, fetchURL, apiKey)
default:
// OpenAI-compatible: /v1/models
fetchURL = apiBase + "/models"
@@ -753,6 +757,53 @@ func fetchUpstreamModels(ctx context.Context, provider, apiBase, apiKey string)
}
}
func fetchNearAIModels(ctx context.Context, fetchURL, apiKey string) ([]upstreamModel, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil)
if err != nil {
return nil, err
}
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("nearai returned status %d", resp.StatusCode)
}
var parsed struct {
Models []struct {
ModelID string `json:"modelId"`
OwnedBy string `json:"ownedBy"`
Metadata struct {
OwnedBy string `json:"ownedBy"`
} `json:"metadata"`
} `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return nil, err
}
models := make([]upstreamModel, 0, len(parsed.Models))
for _, m := range parsed.Models {
id := strings.TrimSpace(m.ModelID)
if id == "" {
continue
}
ownedBy := strings.TrimSpace(m.OwnedBy)
if ownedBy == "" {
ownedBy = strings.TrimSpace(m.Metadata.OwnedBy)
}
models = append(models, upstreamModel{ID: id, OwnedBy: ownedBy})
}
return models, nil
}
func fetchOpenAICompatibleModels(ctx context.Context, fetchURL, apiKey string) ([]upstreamModel, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil)
if err != nil {
+66
View File
@@ -1961,6 +1961,13 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration
"https://api.siliconflow.cn/v1",
)
}
if option, ok := optionsByID["nearai"]; !ok {
t.Fatal("nearai provider option missing")
} else if option.DefaultAPIBase != "https://cloud-api.near.ai/v1" {
t.Fatalf("nearai default_api_base = %q, want %q", option.DefaultAPIBase, "https://cloud-api.near.ai/v1")
} else if !option.SupportsFetch {
t.Fatal("nearai provider option should report supports_fetch")
}
if option, ok := optionsByID["bedrock"]; !ok {
t.Fatal("bedrock provider option missing")
} else if !option.CreateAllowed {
@@ -2592,6 +2599,65 @@ func TestHandleFetchModels_SiliconFlowUsesOpenAICompatibleEndpoint(t *testing.T)
}
}
func TestHandleFetchModels_NearAIUsesPublicModelListEndpoint(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
var gotPath string
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"models":[`+
`{"modelId":"zai-org/GLM-5.1-FP8","metadata":{"ownedBy":"nearai"}},`+
`{"modelId":"openai/gpt-oss-120b","metadata":{"ownedBy":"nearai"}},`+
`{"modelId":""}]}`)
}))
defer srv.Close()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/models/fetch", bytes.NewBufferString(fmt.Sprintf(`{
"provider":"nearai",
"api_key":"nearai-key",
"api_base":"%s"
}`, srv.URL)))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if gotPath != "/model/list" {
t.Fatalf("path = %q, want %q", gotPath, "/model/list")
}
if gotAuth != "Bearer nearai-key" {
t.Fatalf("Authorization = %q, want %q", gotAuth, "Bearer nearai-key")
}
var resp struct {
Models []upstreamModel `json:"models"`
Total int `json:"total"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if resp.Total != 2 || len(resp.Models) != 2 {
t.Fatalf("response = %+v, want two fetched models", resp)
}
if resp.Models[0].ID != "zai-org/GLM-5.1-FP8" || resp.Models[0].OwnedBy != "nearai" {
t.Fatalf("models[0] = %+v, want GLM model owned by nearai", resp.Models[0])
}
if resp.Models[1].ID != "openai/gpt-oss-120b" || resp.Models[1].OwnedBy != "nearai" {
t.Fatalf("models[1] = %+v, want GPT OSS model owned by nearai", resp.Models[1])
}
}
func TestHandleFetchModels_ModelIndexUsesStoredKey(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+2 -2
View File
@@ -59,7 +59,7 @@
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
@@ -68,6 +68,6 @@
"prettier-plugin-tailwindcss": "^0.8.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.59.3",
"vite": "^8.0.13"
"vite": "^8.0.16"
}
}
+214 -181
View File
@@ -19,7 +19,7 @@ importers:
version: 3.43.0(react@19.2.5)
'@tailwindcss/vite':
specifier: ^4.3.0
version: 4.3.0(vite@8.0.13(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
version: 4.3.0(vite@8.0.16(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
'@tanstack/react-query':
specifier: ^5.99.0
version: 5.99.0(react@19.2.5)
@@ -104,13 +104,13 @@ importers:
devDependencies:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.2.1(jiti@2.7.0))
version: 10.0.1(eslint@10.4.1(jiti@2.7.0))
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.3.0)
'@tanstack/router-plugin':
specifier: ^1.164.0
version: 1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.13(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
version: 1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.16(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2
version: 6.0.2(prettier@3.8.3)
@@ -125,22 +125,22 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@typescript-eslint/eslint-plugin':
specifier: ^8.58.2
version: 8.58.2(@typescript-eslint/parser@8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
version: 8.58.2(@typescript-eslint/parser@8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
'@vitejs/plugin-react':
specifier: ^6.0.1
version: 6.0.1(vite@8.0.13(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
version: 6.0.1(vite@8.0.16(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))
eslint:
specifier: ^10.2.1
version: 10.2.1(jiti@2.7.0)
specifier: ^10.4.1
version: 10.4.1(jiti@2.7.0)
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@10.2.1(jiti@2.7.0))
version: 10.1.8(eslint@10.4.1(jiti@2.7.0))
eslint-plugin-react-hooks:
specifier: ^7.1.1
version: 7.1.1(eslint@10.2.1(jiti@2.7.0))
version: 7.1.1(eslint@10.4.1(jiti@2.7.0))
eslint-plugin-react-refresh:
specifier: ^0.5.2
version: 0.5.2(eslint@10.2.1(jiti@2.7.0))
version: 0.5.2(eslint@10.4.1(jiti@2.7.0))
globals:
specifier: ^17.6.0
version: 17.6.0
@@ -155,10 +155,10 @@ importers:
version: 5.9.3
typescript-eslint:
specifier: ^8.59.3
version: 8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
version: 8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
vite:
specifier: ^8.0.13
version: 8.0.13(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
specifier: ^8.0.16
version: 8.0.16(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
packages:
@@ -489,8 +489,8 @@ packages:
resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/config-helpers@0.5.5':
resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==}
'@eslint/config-helpers@0.6.0':
resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/core@1.2.1':
@@ -510,8 +510,8 @@ packages:
resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/plugin-kit@0.7.1':
resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==}
'@eslint/plugin-kit@0.7.2':
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@floating-ui/core@1.7.5':
@@ -538,12 +538,16 @@ packages:
peerDependencies:
hono: ^4
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
'@humanfs/node@0.16.7':
resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
'@humanfs/node@0.16.8':
resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
engines: {node: '>=18.18.0'}
'@humanfs/types@0.15.0':
resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
engines: {node: '>=18.18.0'}
'@humanwhocodes/module-importer@1.0.1':
@@ -619,8 +623,8 @@ packages:
resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==}
engines: {node: '>=18'}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
'@napi-rs/wasm-runtime@1.1.5':
resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
peerDependencies:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
@@ -661,8 +665,8 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@oxc-project/types@0.130.0':
resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1354,97 +1358,97 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@rolldown/binding-android-arm64@1.0.1':
resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==}
'@rolldown/binding-android-arm64@1.0.3':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.0.1':
resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==}
'@rolldown/binding-darwin-arm64@1.0.3':
resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.0.1':
resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==}
'@rolldown/binding-darwin-x64@1.0.3':
resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.0.1':
resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==}
'@rolldown/binding-freebsd-x64@1.0.3':
resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.0.1':
resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==}
'@rolldown/binding-linux-arm-gnueabihf@1.0.3':
resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.0.1':
resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==}
'@rolldown/binding-linux-arm64-gnu@1.0.3':
resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.1':
resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==}
'@rolldown/binding-linux-arm64-musl@1.0.3':
resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.1':
resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==}
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.1':
resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==}
'@rolldown/binding-linux-s390x-gnu@1.0.3':
resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.1':
resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==}
'@rolldown/binding-linux-x64-gnu@1.0.3':
resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.1':
resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==}
'@rolldown/binding-linux-x64-musl@1.0.3':
resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.1':
resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==}
'@rolldown/binding-openharmony-arm64@1.0.3':
resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.0.1':
resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==}
'@rolldown/binding-wasm32-wasi@1.0.3':
resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.0.1':
resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==}
'@rolldown/binding-win32-arm64-msvc@1.0.3':
resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.1':
resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==}
'@rolldown/binding-win32-x64-msvc@1.0.3':
resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -1697,8 +1701,8 @@ packages:
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
@@ -1880,6 +1884,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.17.0:
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -1892,8 +1901,8 @@ packages:
ajv:
optional: true
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
@@ -1962,8 +1971,8 @@ packages:
brace-expansion@2.0.3:
resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==}
brace-expansion@5.0.5:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
@@ -2297,8 +2306,8 @@ packages:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@10.2.1:
resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==}
eslint@10.4.1:
resolution: {integrity: sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true
peerDependencies:
@@ -3142,6 +3151,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.15:
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -3302,6 +3316,10 @@ packages:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
@@ -3539,8 +3557,8 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rolldown@1.0.1:
resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==}
rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -3736,8 +3754,8 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tldts-core@7.0.30:
@@ -3937,8 +3955,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@8.0.13:
resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==}
vite@8.0.16:
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -4375,9 +4393,9 @@ snapshots:
'@esbuild/win32-x64@0.27.4':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.7.0))':
'@eslint-community/eslint-utils@4.9.1(eslint@10.4.1(jiti@2.7.0))':
dependencies:
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
@@ -4390,7 +4408,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@eslint/config-helpers@0.5.5':
'@eslint/config-helpers@0.6.0':
dependencies:
'@eslint/core': 1.2.1
@@ -4398,13 +4416,13 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/js@10.0.1(eslint@10.2.1(jiti@2.7.0))':
'@eslint/js@10.0.1(eslint@10.4.1(jiti@2.7.0))':
optionalDependencies:
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
'@eslint/object-schema@3.0.5': {}
'@eslint/plugin-kit@0.7.1':
'@eslint/plugin-kit@0.7.2':
dependencies:
'@eslint/core': 1.2.1
levn: 0.4.1
@@ -4432,13 +4450,18 @@ snapshots:
dependencies:
hono: 4.12.18
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/core': 0.19.1
'@humanfs/types': 0.15.0
'@humanfs/node@0.16.8':
dependencies:
'@humanfs/core': 0.19.2
'@humanfs/types': 0.15.0
'@humanwhocodes/retry': 0.4.3
'@humanfs/types@0.15.0': {}
'@humanwhocodes/module-importer@1.0.1': {}
'@humanwhocodes/retry@0.4.3': {}
@@ -4520,7 +4543,7 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
'@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
@@ -4558,7 +4581,7 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@oxc-project/types@0.130.0': {}
'@oxc-project/types@0.133.0': {}
'@radix-ui/number@1.1.1': {}
@@ -5307,53 +5330,53 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@rolldown/binding-android-arm64@1.0.1':
'@rolldown/binding-android-arm64@1.0.3':
optional: true
'@rolldown/binding-darwin-arm64@1.0.1':
'@rolldown/binding-darwin-arm64@1.0.3':
optional: true
'@rolldown/binding-darwin-x64@1.0.1':
'@rolldown/binding-darwin-x64@1.0.3':
optional: true
'@rolldown/binding-freebsd-x64@1.0.1':
'@rolldown/binding-freebsd-x64@1.0.3':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.1':
'@rolldown/binding-linux-arm-gnueabihf@1.0.3':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.1':
'@rolldown/binding-linux-arm64-gnu@1.0.3':
optional: true
'@rolldown/binding-linux-arm64-musl@1.0.1':
'@rolldown/binding-linux-arm64-musl@1.0.3':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.1':
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.1':
'@rolldown/binding-linux-s390x-gnu@1.0.3':
optional: true
'@rolldown/binding-linux-x64-gnu@1.0.1':
'@rolldown/binding-linux-x64-gnu@1.0.3':
optional: true
'@rolldown/binding-linux-x64-musl@1.0.1':
'@rolldown/binding-linux-x64-musl@1.0.3':
optional: true
'@rolldown/binding-openharmony-arm64@1.0.1':
'@rolldown/binding-openharmony-arm64@1.0.3':
optional: true
'@rolldown/binding-wasm32-wasi@1.0.1':
'@rolldown/binding-wasm32-wasi@1.0.3':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
'@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.1':
'@rolldown/binding-win32-arm64-msvc@1.0.3':
optional: true
'@rolldown/binding-win32-x64-msvc@1.0.1':
'@rolldown/binding-win32-x64-msvc@1.0.3':
optional: true
'@rolldown/pluginutils@1.0.0-rc.7': {}
@@ -5437,12 +5460,12 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.3.0
'@tailwindcss/vite@4.3.0(vite@8.0.13(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))':
'@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
'@tailwindcss/node': 4.3.0
'@tailwindcss/oxide': 4.3.0
tailwindcss: 4.3.0
vite: 8.0.13(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
vite: 8.0.16(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
'@tanstack/history@1.161.6': {}
@@ -5515,7 +5538,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.13(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))':
'@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.16(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
@@ -5532,7 +5555,7 @@ snapshots:
zod: 3.25.76
optionalDependencies:
'@tanstack/react-router': 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
vite: 8.0.13(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
vite: 8.0.16(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
transitivePeerDependencies:
- supports-color
@@ -5546,7 +5569,7 @@ snapshots:
babel-dead-code-elimination: 1.0.12
diff: 8.0.4
pathe: 2.0.3
tinyglobby: 0.2.16
tinyglobby: 0.2.17
transitivePeerDependencies:
- supports-color
@@ -5587,9 +5610,9 @@ snapshots:
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/estree@1.0.8': {}
'@types/estree@1.0.9': {}
'@types/hast@3.0.4':
dependencies:
@@ -5627,15 +5650,15 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
'@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.58.2
'@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/type-utils': 8.58.2(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/utils': 8.58.2(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.58.2
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -5643,15 +5666,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.59.3
'@typescript-eslint/type-utils': 8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/utils': 8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/type-utils': 8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/utils': 8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.59.3
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -5659,14 +5682,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/parser@8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.59.3
'@typescript-eslint/types': 8.59.3
'@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.59.3
debug: 4.4.3
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -5707,25 +5730,25 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/type-utils@8.58.2(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.58.2
'@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
'@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/utils': 8.58.2(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
debug: 4.4.3
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/type-utils@8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/type-utils@8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.59.3
'@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3)
'@typescript-eslint/utils': 8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/utils': 8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
debug: 4.4.3
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -5744,7 +5767,7 @@ snapshots:
debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.0
tinyglobby: 0.2.16
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -5759,30 +5782,30 @@ snapshots:
debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.0
tinyglobby: 0.2.16
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/utils@8.58.2(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0))
'@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.58.2
'@typescript-eslint/types': 8.58.2
'@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/utils@8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0))
'@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.59.3
'@typescript-eslint/types': 8.59.3
'@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3)
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -5799,29 +5822,31 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@6.0.1(vite@8.0.13(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))':
'@vitejs/plugin-react@6.0.1(vite@8.0.16(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
vite: 8.0.13(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
vite: 8.0.16(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
negotiator: 1.0.0
acorn-jsx@5.3.2(acorn@8.16.0):
acorn-jsx@5.3.2(acorn@8.17.0):
dependencies:
acorn: 8.16.0
acorn: 8.17.0
acorn@8.16.0: {}
acorn@8.17.0: {}
agent-base@7.1.4: {}
ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
ajv@6.14.0:
ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
@@ -5899,7 +5924,7 @@ snapshots:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.5:
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.4
@@ -6169,29 +6194,29 @@ snapshots:
escape-string-regexp@5.0.0: {}
eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.7.0)):
eslint-config-prettier@10.1.8(eslint@10.4.1(jiti@2.7.0)):
dependencies:
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.7.0)):
eslint-plugin-react-hooks@7.1.1(eslint@10.4.1(jiti@2.7.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.2
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
hermes-parser: 0.25.1
zod: 4.3.6
zod-validation-error: 4.0.2(zod@4.3.6)
transitivePeerDependencies:
- supports-color
eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.7.0)):
eslint-plugin-react-refresh@0.5.2(eslint@10.4.1(jiti@2.7.0)):
dependencies:
eslint: 10.2.1(jiti@2.7.0)
eslint: 10.4.1(jiti@2.7.0)
eslint-scope@9.1.2:
dependencies:
'@types/esrecurse': 4.3.1
'@types/estree': 1.0.8
'@types/estree': 1.0.9
esrecurse: 4.3.0
estraverse: 5.3.0
@@ -6199,19 +6224,19 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@10.2.1(jiti@2.7.0):
eslint@10.4.1(jiti@2.7.0):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0))
'@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1(jiti@2.7.0))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.5
'@eslint/config-helpers': 0.5.5
'@eslint/config-helpers': 0.6.0
'@eslint/core': 1.2.1
'@eslint/plugin-kit': 0.7.1
'@humanfs/node': 0.16.7
'@eslint/plugin-kit': 0.7.2
'@humanfs/node': 0.16.8
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.14.0
'@types/estree': 1.0.9
ajv: 6.15.0
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
@@ -6238,8 +6263,8 @@ snapshots:
espree@11.2.0:
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
acorn: 8.17.0
acorn-jsx: 5.3.2(acorn@8.17.0)
eslint-visitor-keys: 5.0.1
esprima@4.0.1: {}
@@ -6536,7 +6561,7 @@ snapshots:
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/hast': 3.0.4
'@types/unist': 3.0.3
comma-separated-tokens: 2.0.3
@@ -7226,7 +7251,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.5
brace-expansion: 5.0.6
minimatch@9.0.9:
dependencies:
@@ -7265,6 +7290,8 @@ snapshots:
nanoid@3.3.12: {}
nanoid@3.3.15: {}
natural-compare@1.4.0: {}
negotiator@1.0.0: {}
@@ -7425,6 +7452,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.15:
dependencies:
nanoid: 3.3.15
picocolors: 1.1.1
source-map-js: 1.2.1
powershell-utils@0.1.0: {}
prelude-ls@1.2.1: {}
@@ -7687,26 +7720,26 @@ snapshots:
reusify@1.1.0: {}
rolldown@1.0.1:
rolldown@1.0.3:
dependencies:
'@oxc-project/types': 0.130.0
'@oxc-project/types': 0.133.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.1
'@rolldown/binding-darwin-arm64': 1.0.1
'@rolldown/binding-darwin-x64': 1.0.1
'@rolldown/binding-freebsd-x64': 1.0.1
'@rolldown/binding-linux-arm-gnueabihf': 1.0.1
'@rolldown/binding-linux-arm64-gnu': 1.0.1
'@rolldown/binding-linux-arm64-musl': 1.0.1
'@rolldown/binding-linux-ppc64-gnu': 1.0.1
'@rolldown/binding-linux-s390x-gnu': 1.0.1
'@rolldown/binding-linux-x64-gnu': 1.0.1
'@rolldown/binding-linux-x64-musl': 1.0.1
'@rolldown/binding-openharmony-arm64': 1.0.1
'@rolldown/binding-wasm32-wasi': 1.0.1
'@rolldown/binding-win32-arm64-msvc': 1.0.1
'@rolldown/binding-win32-x64-msvc': 1.0.1
'@rolldown/binding-android-arm64': 1.0.3
'@rolldown/binding-darwin-arm64': 1.0.3
'@rolldown/binding-darwin-x64': 1.0.3
'@rolldown/binding-freebsd-x64': 1.0.3
'@rolldown/binding-linux-arm-gnueabihf': 1.0.3
'@rolldown/binding-linux-arm64-gnu': 1.0.3
'@rolldown/binding-linux-arm64-musl': 1.0.3
'@rolldown/binding-linux-ppc64-gnu': 1.0.3
'@rolldown/binding-linux-s390x-gnu': 1.0.3
'@rolldown/binding-linux-x64-gnu': 1.0.3
'@rolldown/binding-linux-x64-musl': 1.0.3
'@rolldown/binding-openharmony-arm64': 1.0.3
'@rolldown/binding-wasm32-wasi': 1.0.3
'@rolldown/binding-win32-arm64-msvc': 1.0.3
'@rolldown/binding-win32-x64-msvc': 1.0.3
router@2.2.0:
dependencies:
@@ -7935,7 +7968,7 @@ snapshots:
tiny-invariant@1.3.3: {}
tinyglobby@0.2.16:
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
@@ -8000,13 +8033,13 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.2
typescript-eslint@8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3):
typescript-eslint@8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3)
'@typescript-eslint/utils': 8.59.3(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)
eslint: 10.2.1(jiti@2.7.0)
'@typescript-eslint/utils': 8.59.3(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
eslint: 10.4.1(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -8137,13 +8170,13 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@8.0.13(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0):
vite@8.0.16(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.14
rolldown: 1.0.1
tinyglobby: 0.2.16
postcss: 8.5.15
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 25.6.0
esbuild: 0.27.4