From 45c01f4d91bd2f1ec7d0945fbca09372571850b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:42:04 +0800 Subject: [PATCH 01/17] chore(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0 (#1596) Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.35.0 to 0.36.0. - [Commits](https://github.com/golang/oauth2/compare/v0.35.0...v0.36.0) --- updated-dependencies: - dependency-name: golang.org/x/oauth2 dependency-version: 0.36.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f29ef72073..a2ce5c5112 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index addbab56c7..c8ee84e875 100644 --- a/go.sum +++ b/go.sum @@ -270,8 +270,8 @@ golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From dd936302d1223613693c7942364eb4d4d8597b4f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:46:54 +0800 Subject: [PATCH 02/17] chore(deps): bump github.com/mymmrac/telego from 1.6.0 to 1.7.0 (#1598) Bumps [github.com/mymmrac/telego](https://github.com/mymmrac/telego) from 1.6.0 to 1.7.0. - [Release notes](https://github.com/mymmrac/telego/releases) - [Commits](https://github.com/mymmrac/telego/compare/v1.6.0...v1.7.0) --- updated-dependencies: - dependency-name: github.com/mymmrac/telego dependency-version: 1.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index a2ce5c5112..c0e2fa60cf 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/modelcontextprotocol/go-sdk v1.3.1 - github.com/mymmrac/telego v1.6.0 + github.com/mymmrac/telego v1.7.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 github.com/rivo/tview v0.42.0 @@ -87,7 +87,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect - github.com/valyala/fastjson v1.6.7 // indirect + github.com/valyala/fastjson v1.6.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect diff --git a/go.sum b/go.sum index c8ee84e875..11be48d872 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= -github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= -github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= +github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo= +github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM= 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= @@ -216,8 +216,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= -github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= -github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= From e9d240d760bab164ceabf13f58f33d2909a45f37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:47:46 +0800 Subject: [PATCH 03/17] chore(deps): bump github.com/caarlos0/env/v11 from 11.3.1 to 11.4.0 (#1599) Bumps [github.com/caarlos0/env/v11](https://github.com/caarlos0/env) from 11.3.1 to 11.4.0. - [Release notes](https://github.com/caarlos0/env/releases) - [Commits](https://github.com/caarlos0/env/compare/v11.3.1...v11.4.0) --- updated-dependencies: - dependency-name: github.com/caarlos0/env/v11 dependency-version: 11.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c0e2fa60cf..e48b3006f0 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.22.1 github.com/bwmarrin/discordgo v0.29.0 - github.com/caarlos0/env/v11 v11.3.1 + github.com/caarlos0/env/v11 v11.4.0 github.com/ergochat/irc-go v0.5.0 github.com/ergochat/readline v0.1.3 github.com/gdamore/tcell/v2 v2.13.8 diff --git a/go.sum b/go.sum index 11be48d872..810bdac626 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= -github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc= +github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= From 2f40a8c165810a88442630b231d25fbd17937ea3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:51:55 +0800 Subject: [PATCH 04/17] chore(deps): bump github.com/anthropics/anthropic-sdk-go (#1601) Bumps [github.com/anthropics/anthropic-sdk-go](https://github.com/anthropics/anthropic-sdk-go) from 1.22.1 to 1.26.0. - [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.22.1...v1.26.0) --- updated-dependencies: - dependency-name: github.com/anthropics/anthropic-sdk-go dependency-version: 1.26.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e48b3006f0..6f5de16058 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.7 require ( github.com/adhocore/gronx v1.19.6 - github.com/anthropics/anthropic-sdk-go v1.22.1 + github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.4.0 github.com/ergochat/irc-go v0.5.0 diff --git a/go.sum b/go.sum index 810bdac626..0fb4be1b86 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,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.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0= -github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= @@ -38,6 +38,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= @@ -354,6 +356,7 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 43eb6fe20c5670fa15ce261174e0c1a2ac1f3c80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:58:18 +0800 Subject: [PATCH 05/17] chore(deps): bump github.com/github/copilot-sdk/go from 0.1.23 to 0.1.32 (#1603) Bumps [github.com/github/copilot-sdk/go](https://github.com/github/copilot-sdk) from 0.1.23 to 0.1.32. - [Release notes](https://github.com/github/copilot-sdk/releases) - [Changelog](https://github.com/github/copilot-sdk/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/copilot-sdk/compare/v0.1.23...v0.1.32) --- updated-dependencies: - dependency-name: github.com/github/copilot-sdk/go dependency-version: 0.1.32 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 6f5de16058..130db73ffd 100644 --- a/go.mod +++ b/go.mod @@ -73,7 +73,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/github/copilot-sdk/go v0.1.23 + github.com/github/copilot-sdk/go v0.1.32 github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect diff --git a/go.sum b/go.sum index 0fb4be1b86..a4d8ed3d0e 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,8 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= -github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= -github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= +github.com/github/copilot-sdk/go v0.1.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo= +github.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= From b8dfd0befc44233c2193f599797aa0c0a781c687 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:58:48 +0800 Subject: [PATCH 06/17] chore(deps): bump jotai from 2.18.0 to 2.18.1 in /web/frontend (#1605) Bumps [jotai](https://github.com/pmndrs/jotai) from 2.18.0 to 2.18.1. - [Release notes](https://github.com/pmndrs/jotai/releases) - [Commits](https://github.com/pmndrs/jotai/compare/v2.18.0...v2.18.1) --- updated-dependencies: - dependency-name: jotai dependency-version: 2.18.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 373b4d4687..8d5b77fb93 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -24,7 +24,7 @@ "dayjs": "^1.11.19", "i18next": "^25.8.14", "i18next-browser-languagedetector": "^8.2.1", - "jotai": "^2.18.0", + "jotai": "^2.18.1", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 75acacfa54..a1ea2a5129 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: ^8.2.1 version: 8.2.1 jotai: - specifier: ^2.18.0 - version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) + specifier: ^2.18.1 + version: 2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2669,8 +2669,8 @@ packages: jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} - jotai@2.18.0: - resolution: {integrity: sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA==} + jotai@2.18.1: + resolution: {integrity: sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==} engines: {node: '>=12.20.0'} peerDependencies: '@babel/core': '>=7.0.0' @@ -6501,7 +6501,7 @@ snapshots: jose@6.1.3: {} - jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): + jotai@2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): optionalDependencies: '@babel/core': 7.29.0 '@babel/template': 7.28.6 From a93bd0132933b6a6251395ca4fa8c6fcf67ab3aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:04:50 +0800 Subject: [PATCH 07/17] chore(deps-dev): bump @vitejs/plugin-react in /web/frontend (#1606) Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.1.4 to 5.2.0. - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/plugin-react@5.2.0/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.2.0/packages/plugin-react) --- updated-dependencies: - dependency-name: "@vitejs/plugin-react" dependency-version: 5.2.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 8d5b77fb93..189b93fc05 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -48,7 +48,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.56.1", - "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react": "^5.2.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index a1ea2a5129..2510d06ee3 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -109,8 +109,8 @@ importers: specifier: ^8.56.1 version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-react': - specifier: ^5.1.1 - version: 5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + specifier: ^5.2.0 + version: 5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) eslint: specifier: ^9.39.1 version: 9.39.3(jiti@2.6.1) @@ -1790,11 +1790,11 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vitejs/plugin-react@5.1.4': - resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -5641,7 +5641,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) From 3bf8a27570112488d1a0f0bdec892e80dee23fb3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:05:03 +0800 Subject: [PATCH 08/17] chore(deps): bump react-i18next from 16.5.4 to 16.5.8 in /web/frontend (#1607) Bumps [react-i18next](https://github.com/i18next/react-i18next) from 16.5.4 to 16.5.8. - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v16.5.4...v16.5.8) --- updated-dependencies: - dependency-name: react-i18next dependency-version: 16.5.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 189b93fc05..d6d13a8fd2 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -28,7 +28,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-i18next": "^16.5.4", + "react-i18next": "^16.5.8", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "remark-gfm": "^4.0.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 2510d06ee3..38820d871f 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -54,8 +54,8 @@ importers: specifier: ^19.2.0 version: 19.2.4(react@19.2.4) react-i18next: - specifier: ^16.5.4 - version: 16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + specifier: ^16.5.8 + version: 16.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -3323,8 +3323,8 @@ packages: peerDependencies: react: ^19.2.4 - react-i18next@16.5.4: - resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==} + react-i18next@16.5.8: + resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==} peerDependencies: i18next: '>= 25.6.2' react: '>= 16.8.0' @@ -7310,7 +7310,7 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-i18next@16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + react-i18next@16.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: '@babel/runtime': 7.28.6 html-parse-stringify: 3.0.1 From 99304d1f8e779294439660a443d5d45e5832cd9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:05:17 +0800 Subject: [PATCH 09/17] chore(deps): bump dayjs from 1.11.19 to 1.11.20 in /web/frontend (#1608) Bumps [dayjs](https://github.com/iamkun/dayjs) from 1.11.19 to 1.11.20. - [Release notes](https://github.com/iamkun/dayjs/releases) - [Changelog](https://github.com/iamkun/dayjs/blob/dev/CHANGELOG.md) - [Commits](https://github.com/iamkun/dayjs/compare/v1.11.19...v1.11.20) --- updated-dependencies: - dependency-name: dayjs dependency-version: 1.11.20 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index d6d13a8fd2..6a4719adb3 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -21,7 +21,7 @@ "@tanstack/react-router-devtools": "^1.163.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "i18next": "^25.8.14", "i18next-browser-languagedetector": "^8.2.1", "jotai": "^2.18.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 38820d871f..3c6c4c7cf5 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 dayjs: - specifier: ^1.11.19 - version: 1.11.19 + specifier: ^1.11.20 + version: 1.11.20 i18next: specifier: ^25.8.14 version: 25.8.14(typescript@5.9.3) @@ -2060,8 +2060,8 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - dayjs@1.11.19: - resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -5894,7 +5894,7 @@ snapshots: data-uri-to-buffer@4.0.1: {} - dayjs@1.11.19: {} + dayjs@1.11.20: {} debug@4.4.3: dependencies: From 4178b2cec5028ab0b86cc0bdd5c0ff0c2ffbca9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:05:31 +0800 Subject: [PATCH 10/17] chore(deps): bump @tanstack/react-router in /web/frontend (#1609) Bumps [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) from 1.163.3 to 1.167.0. - [Release notes](https://github.com/TanStack/router/releases) - [Changelog](https://github.com/TanStack/router/blob/main/packages/react-router/CHANGELOG.md) - [Commits](https://github.com/TanStack/router/commits/@tanstack/react-router@1.167.0/packages/react-router) --- updated-dependencies: - dependency-name: "@tanstack/react-router" dependency-version: 1.167.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 85 ++++++++++++++++++++++++++----------- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 6a4719adb3..f3bae6d6aa 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -17,7 +17,7 @@ "@tabler/icons-react": "^3.38.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.90.21", - "@tanstack/react-router": "^1.163.3", + "@tanstack/react-router": "^1.167.0", "@tanstack/react-router-devtools": "^1.163.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 3c6c4c7cf5..ac2168798e 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -21,11 +21,11 @@ importers: specifier: ^5.90.21 version: 5.90.21(react@19.2.4) '@tanstack/react-router': - specifier: ^1.163.3 - version: 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.167.0 + version: 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-router-devtools': specifier: ^1.163.3 - version: 1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -92,7 +92,7 @@ importers: version: 0.5.19(tailwindcss@4.2.1) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + version: 1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.8.1) @@ -1587,15 +1587,15 @@ packages: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.163.3': - resolution: {integrity: sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q==} + '@tanstack/react-router@1.167.0': + resolution: {integrity: sha512-U7CamtXjuC8ixg1c32Rj/4A2OFBnjtMLdbgbyOGHrFHE7ULWS/yhnZLVXff0QSyn6qF92Oecek9mDMHCaTnB2Q==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-store@0.9.1': - resolution: {integrity: sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==} + '@tanstack/react-store@0.9.2': + resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1604,6 +1604,10 @@ packages: resolution: {integrity: sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==} engines: {node: '>=20.19'} + '@tanstack/router-core@1.167.0': + resolution: {integrity: sha512-pnaaUP+vMQEyL2XjZGe2PXmtzulxvXfGyvEMUs+AEBaNEk77xWA88bl3ujiBRbUxzpK0rxfJf+eSKPdZmBMFdQ==} + engines: {node: '>=20.19'} + '@tanstack/router-devtools-core@1.163.3': resolution: {integrity: sha512-FPi64IP0PT1IkoeyGmsD6JoOVOYAb85VCH0mUbSdD90yV0+1UB6oT+D7K27GXkp7SXMJN3mBEjU5rKnNnmSCIw==} engines: {node: '>=20.19'} @@ -1646,6 +1650,9 @@ packages: '@tanstack/store@0.9.1': resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} + '@tanstack/store@0.9.2': + resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==} + '@tanstack/virtual-file-routes@1.161.4': resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==} engines: {node: '>=20.19'} @@ -2648,8 +2655,8 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isbot@5.1.35: - resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==} + isbot@5.1.36: + resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==} engines: {node: '>=18'} isexe@2.0.0: @@ -3476,10 +3483,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.1: + resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.5.0: resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} engines: {node: '>=10'} + seroval@1.5.1: + resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -5365,31 +5382,31 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.4 - '@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3) + '@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - '@tanstack/router-core': 1.163.3 + '@tanstack/router-core': 1.167.0 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.161.4 - '@tanstack/react-store': 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.163.3 - isbot: 5.1.35 + '@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.167.0 + isbot: 5.1.36 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-store@0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-store@0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/store': 0.9.1 + '@tanstack/store': 0.9.2 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) @@ -5404,9 +5421,19 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3)': + '@tanstack/router-core@1.167.0': dependencies: - '@tanstack/router-core': 1.163.3 + '@tanstack/history': 1.161.4 + '@tanstack/store': 0.9.2 + cookie-es: 2.0.0 + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3)': + dependencies: + '@tanstack/router-core': 1.167.0 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) tiny-invariant: 1.3.3 @@ -5426,7 +5453,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5442,7 +5469,7 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5463,6 +5490,8 @@ snapshots: '@tanstack/store@0.9.1': {} + '@tanstack/store@0.9.2': {} + '@tanstack/virtual-file-routes@1.161.4': {} '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)': @@ -6489,7 +6518,7 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isbot@5.1.35: {} + isbot@5.1.36: {} isexe@2.0.0: {} @@ -7517,8 +7546,14 @@ snapshots: dependencies: seroval: 1.5.0 + seroval-plugins@1.5.1(seroval@1.5.1): + dependencies: + seroval: 1.5.1 + seroval@1.5.0: {} + seroval@1.5.1: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 From c8065989b0f04336d94ed8aa815a5280778c7462 Mon Sep 17 00:00:00 2001 From: wenjie Date: Mon, 16 Mar 2026 11:58:06 +0800 Subject: [PATCH 11/17] chore(web): upgrade eslint deps to resolve flatted vulnerability (#1629) --- web/frontend/package.json | 4 ++-- web/frontend/pnpm-lock.yaml | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index f3bae6d6aa..9735865194 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -40,7 +40,7 @@ "wrap-ansi": "^10.0.0" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^9.39.3", "@tailwindcss/typography": "^0.5.19", "@tanstack/router-plugin": "^1.164.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", @@ -49,7 +49,7 @@ "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.56.1", "@vitejs/plugin-react": "^5.2.0", - "eslint": "^9.39.1", + "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index ac2168798e..20f0a73424 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -85,7 +85,7 @@ importers: version: 10.0.0 devDependencies: '@eslint/js': - specifier: ^9.39.1 + specifier: ^9.39.3 version: 9.39.3 '@tailwindcss/typography': specifier: ^0.5.19 @@ -112,7 +112,7 @@ importers: specifier: ^5.2.0 version: 5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) eslint: - specifier: ^9.39.1 + specifier: ^9.39.3 version: 9.39.3(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 @@ -469,8 +469,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -481,8 +481,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.4': - resolution: {integrity: sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.3': @@ -2362,8 +2362,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.1: + resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} @@ -4285,7 +4285,7 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 @@ -4301,7 +4301,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.4': + '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 debug: 4.4.3 @@ -6077,10 +6077,10 @@ snapshots: dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.4 + '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.3 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -6270,10 +6270,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.1 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.1: {} formdata-polyfill@4.0.10: dependencies: From 2f10b47f59f01a34ef989fd919d84c6212b5f0ad Mon Sep 17 00:00:00 2001 From: sky5454 Date: Mon, 16 Mar 2026 14:06:32 +0800 Subject: [PATCH 12/17] =?UTF-8?q?feat(credential):=20part1=20add=20AES-GCM?= =?UTF-8?q?=20encryption,=20SecureStore,=20and=20onboard=20ke=E2=80=A6=20(?= =?UTF-8?q?#1521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen - pkg/credential: new package with AES-256-GCM enc:// credential format, HKDF-SHA256 key derivation (passphrase + optional SSH key binding), ErrPassphraseRequired / ErrDecryptionFailed sentinel errors, and PassphraseProvider hook for runtime passphrase injection - pkg/credential/store: lock-free SecureStore via atomic.Pointer[string]; passphrase never written to disk or os.Environ - pkg/credential/keygen: ed25519 SSH key generation helper used by onboard - pkg/config: replace os.Getenv(PassphraseEnvVar) with credential.PassphraseProvider() at all three call sites so that LoadConfig and SaveConfig use whatever passphrase source is active - cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate picoclaw-specific SSH key, re-encrypt existing config on re-onboard - docs/credential_encryption.md: design doc for the enc:// format * fix(credential): address Copilot review comments on PR #1521 - credential.go: decouple ErrPassphraseRequired from env var name; message is now 'enc:// passphrase required' since PassphraseProvider may come from any source, not just os.Environ - credential.go: Resolver resolves symlinks via EvalSymlinks before the isWithinDir containment check, preventing symlink-based path traversal for file:// credential references - store.go: tighten comment to describe only what SecureStore guarantees (in-memory only); remove claims about how callers transport the value - store_test.go: replace the meaningless GetReturnsCopy test (Go strings are immutable, equality across two calls proves nothing) with TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under 10-goroutine concurrent Set/Get load - config_test.go: update error-message assertion to match new sentinel text - docs/credential_encryption.md: remove reference to non-existent 'picoclaw encrypt' subcommand; describe the onboard flow instead * fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte * fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode * lint: fix credential keygen lint, fix test keygen * onboard: make encryption opt-in via --enc flag Encryption (passphrase prompt + SSH key generation) is now only triggered when the user passes --enc to 'picoclaw onboard'. Without the flag, onboard skips the credential-encryption setup and writes a plain config + workspace templates directly. - Add --enc BoolFlag in NewOnboardCommand() - Pass encrypt bool into onboard() - Guard passphrase prompt, SSH key generation, and related env-var setup behind the encrypt branch - Adjust 'Next steps' output so the passphrase reminder only appears when --enc was used --- cmd/picoclaw/internal/onboard/command.go | 7 +- cmd/picoclaw/internal/onboard/command_test.go | 5 +- cmd/picoclaw/internal/onboard/helpers.go | 133 ++++++- docs/credential_encryption.md | 168 ++++++++ pkg/config/config.go | 72 +++- pkg/config/config_test.go | 365 +++++++++++++++++- pkg/credential/credential.go | 335 ++++++++++++++++ pkg/credential/credential_test.go | 283 ++++++++++++++ pkg/credential/keygen.go | 62 +++ pkg/credential/keygen_test.go | 115 ++++++ pkg/credential/store.go | 44 +++ pkg/credential/store_test.go | 81 ++++ 12 files changed, 1649 insertions(+), 21 deletions(-) create mode 100644 docs/credential_encryption.md create mode 100644 pkg/credential/credential.go create mode 100644 pkg/credential/credential_test.go create mode 100644 pkg/credential/keygen.go create mode 100644 pkg/credential/keygen_test.go create mode 100644 pkg/credential/store.go create mode 100644 pkg/credential/store_test.go diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go index ec10129594..9f8b288c6f 100644 --- a/cmd/picoclaw/internal/onboard/command.go +++ b/cmd/picoclaw/internal/onboard/command.go @@ -11,14 +11,19 @@ import ( var embeddedFiles embed.FS func NewOnboardCommand() *cobra.Command { + var encrypt bool + cmd := &cobra.Command{ Use: "onboard", Aliases: []string{"o"}, Short: "Initialize picoclaw configuration and workspace", Run: func(cmd *cobra.Command, args []string) { - onboard() + onboard(encrypt) }, } + cmd.Flags().BoolVar(&encrypt, "enc", false, + "Enable credential encryption (generates SSH key and prompts for passphrase)") + return cmd } diff --git a/cmd/picoclaw/internal/onboard/command_test.go b/cmd/picoclaw/internal/onboard/command_test.go index bc799a0792..56936190bc 100644 --- a/cmd/picoclaw/internal/onboard/command_test.go +++ b/cmd/picoclaw/internal/onboard/command_test.go @@ -24,6 +24,9 @@ func TestNewOnboardCommand(t *testing.T) { assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) - assert.False(t, cmd.HasFlags()) + assert.True(t, cmd.HasFlags()) + encFlag := cmd.Flags().Lookup("enc") + require.NotNil(t, encFlag, "expected --enc flag to be registered") + assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false") assert.False(t, cmd.HasSubCommands()) } diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go index 4db8bdc8ba..6f1d4bdd7c 100644 --- a/cmd/picoclaw/internal/onboard/helpers.go +++ b/cmd/picoclaw/internal/onboard/helpers.go @@ -6,25 +6,71 @@ import ( "os" "path/filepath" + "golang.org/x/term" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/credential" ) -func onboard() { +func onboard(encrypt bool) { configPath := internal.GetConfigPath() + configExists := false if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Config already exists at %s\n", configPath) - fmt.Print("Overwrite? (y/n): ") - var response string - fmt.Scanln(&response) - if response != "y" { - fmt.Println("Aborted.") - return + configExists = true + if encrypt { + // Only ask for confirmation when *both* config and SSH key already exist, + // indicating a full re-onboard that would reset the config to defaults. + sshKeyPath, _ := credential.DefaultSSHKeyPath() + if _, err := os.Stat(sshKeyPath); err == nil { + // Both exist — confirm a full reset. + fmt.Printf("Config already exists at %s\n", configPath) + fmt.Print("Overwrite config with defaults? (y/n): ") + var response string + fmt.Scanln(&response) + if response != "y" { + fmt.Println("Aborted.") + return + } + configExists = false // user agreed to reset; treat as fresh + } + // Config exists but SSH key is missing — keep existing config, only add SSH key. } } - cfg := config.DefaultConfig() + var err error + if encrypt { + fmt.Println("\nSet up credential encryption") + fmt.Println("-----------------------------") + passphrase, pErr := promptPassphrase() + if pErr != nil { + fmt.Printf("Error: %v\n", pErr) + os.Exit(1) + } + // Expose the passphrase to credential.PassphraseProvider (which calls + // os.Getenv by default) so that SaveConfig can encrypt api_keys. + // This process is a one-shot CLI tool; the env var is never exposed outside + // the current process and disappears when it exits. + os.Setenv(credential.PassphraseEnvVar, passphrase) + + if err = setupSSHKey(); err != nil { + fmt.Printf("Error generating SSH key: %v\n", err) + os.Exit(1) + } + } + + var cfg *config.Config + if configExists { + // Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase. + cfg, err = config.LoadConfig(configPath) + if err != nil { + fmt.Printf("Error loading existing config: %v\n", err) + os.Exit(1) + } + } else { + cfg = config.DefaultConfig() + } if err := config.SaveConfig(configPath, cfg); err != nil { fmt.Printf("Error saving config: %v\n", err) os.Exit(1) @@ -33,9 +79,17 @@ func onboard() { workspace := cfg.WorkspacePath() createWorkspaceTemplates(workspace) - fmt.Printf("%s picoclaw is ready!\n", internal.Logo) + fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo) fmt.Println("\nNext steps:") - fmt.Println(" 1. Add your API key to", configPath) + if encrypt { + fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:") + fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS") + fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd") + fmt.Println("") + fmt.Println(" 2. Add your API key to", configPath) + } else { + fmt.Println(" 1. Add your API key to", configPath) + } fmt.Println("") fmt.Println(" Recommended:") fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") @@ -43,7 +97,62 @@ func onboard() { fmt.Println("") fmt.Println(" See README.md for 17+ supported providers.") fmt.Println("") - fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") + fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") +} + +// promptPassphrase reads the encryption passphrase twice from the terminal +// (with echo disabled) and returns it. Returns an error if the passphrase is +// empty or if the two inputs do not match. +func promptPassphrase() (string, error) { + fmt.Print("Enter passphrase for credential encryption: ") + p1, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("reading passphrase: %w", err) + } + if len(p1) == 0 { + return "", fmt.Errorf("passphrase must not be empty") + } + + fmt.Print("Confirm passphrase: ") + p2, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("reading passphrase confirmation: %w", err) + } + + if string(p1) != string(p2) { + return "", fmt.Errorf("passphrases do not match") + } + return string(p1), nil +} + +// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key. +// If the key already exists the user is warned and asked to confirm overwrite. +// Answering anything other than "y" keeps the existing key (not an error). +func setupSSHKey() error { + keyPath, err := credential.DefaultSSHKeyPath() + if err != nil { + return fmt.Errorf("cannot determine SSH key path: %w", err) + } + + if _, err := os.Stat(keyPath); err == nil { + fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath) + fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.") + fmt.Print(" Overwrite? (y/n): ") + var response string + fmt.Scanln(&response) + if response != "y" { + fmt.Println("Keeping existing SSH key.") + return nil + } + } + + if err := credential.GenerateSSHKey(keyPath); err != nil { + return err + } + fmt.Printf("SSH key generated: %s\n", keyPath) + return nil } func createWorkspaceTemplates(workspace string) { diff --git a/docs/credential_encryption.md b/docs/credential_encryption.md new file mode 100644 index 0000000000..448eaaa102 --- /dev/null +++ b/docs/credential_encryption.md @@ -0,0 +1,168 @@ +# Credential Encryption + +PicoClaw supports encrypting `api_key` values in `model_list` configuration entries. +Encrypted keys are stored as `enc://` strings and decrypted automatically at startup. + +--- + +## Quick Start + +**1. Set your passphrase** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. Encrypt an API key** + +Run `picoclaw onboard` — it prompts for your passphrase and generates the SSH key, +then automatically re-encrypts any plaintext `api_key` entries in your config on +the next `SaveConfig` call. The resulting `enc://` value will look like: + +``` +enc://AAAA...base64... +``` + +**3. Paste the output into your config** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "api_key": "enc://AAAA...base64...", + "base_url": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## Supported `api_key` Formats + +| Format | Example | Behaviour | +|--------|---------|-----------| +| Plaintext | `sk-abc123` | Used as-is | +| File reference | `file://openai.key` | Content read from the same directory as the config file | +| Encrypted | `enc://` | Decrypted at startup using `PICOCLAW_KEY_PASSPHRASE` | +| Empty | `""` | Passed through unchanged (used with `auth_method: oauth`) | + +--- + +## Cryptographic Design + +### Key Derivation + +Encryption uses **HKDF-SHA256** with an optional SSH private key as a second factor. + +``` +Without SSH key (passphrase only): + + ikm = SHA256(passphrase) + aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) + + +With SSH key (recommended): + + sshHash = SHA256(ssh_private_key_file_bytes) + ikm = HMAC-SHA256(key=sshHash, message=passphrase) + aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### Encryption + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### Wire Format + +``` +enc:// +``` + +| Field | Size | Description | +|-------|------|-------------| +| `salt` | 16 bytes | Random per encryption; fed into HKDF | +| `nonce` | 12 bytes | Random per encryption; AES-GCM IV | +| `ciphertext` | variable | AES-256-GCM ciphertext + 16-byte authentication tag | + +The GCM authentication tag is appended to the ciphertext automatically. Any tampering causes decryption to fail with an error rather than returning corrupt plaintext. + +### Performance + +| Operation | Time (ARM Cortex-A) | +|-----------|---------------------| +| Key derivation (HKDF) | < 1 ms | +| AES-256-GCM decrypt | < 1 ms | +| **Total startup overhead** | **< 2 ms per key** | + +--- + +## Two-Factor Security with SSH Key + +When a SSH private key is provided, breaking the encryption requires **both**: + +1. The **passphrase** (`PICOCLAW_KEY_PASSPHRASE`) +2. The **SSH private key file** + +This means a leaked config file alone is not sufficient to recover the API key, even if the passphrase is weak. The SSH key contributes 256 bits of entropy (Ed25519) regardless of passphrase strength. + +### Threat Model + +| Attacker Has | Can Decrypt? | +|---|---| +| Config file only | No — needs passphrase + SSH key | +| SSH key only | No — needs passphrase | +| Passphrase only | No — needs SSH key | +| Config file + SSH key + passphrase | Yes — full compromise | + +--- + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `PICOCLAW_KEY_PASSPHRASE` | Yes (for `enc://`) | Passphrase used for key derivation | +| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. Set to `""` to disable auto-detection and use passphrase-only mode | + +### SSH Key Auto-Detection + +If `PICOCLAW_SSH_KEY_PATH` is not set, PicoClaw looks for the picoclaw-specific key: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +This dedicated file avoids conflicts with the user's existing SSH keys. +Run `picoclaw onboard` to generate it automatically. + +`os.UserHomeDir()` is used for cross-platform home directory resolution (reads `USERPROFILE` on Windows, `HOME` on Unix/macOS). + +To explicitly disable SSH key usage and use passphrase-only mode: + +```bash +export PICOCLAW_SSH_KEY_PATH="" +``` + +--- + +## Migration + +Because the only secret material is `PICOCLAW_KEY_PASSPHRASE` and the SSH private key file, migration is straightforward: + +1. Copy the config file to the new machine. +2. Set `PICOCLAW_KEY_PASSPHRASE` to the same value. +3. Copy the SSH private key file to the same path (or set `PICOCLAW_SSH_KEY_PATH` to its new location). + +No re-encryption is needed. + +--- + +## Security Considerations + +- **Passphrase strength matters in passphrase-only mode.** Without an SSH key, a weak passphrase can be brute-forced offline. Use `PICOCLAW_SSH_KEY_PATH=""` only in environments where no SSH key is available and the passphrase is sufficiently strong (≥ 32 random characters). +- **The SSH key is read-only at runtime.** PicoClaw never writes to or modifies the SSH key file. +- **Plaintext keys remain supported.** Existing configs without `enc://` are unaffected. +- **The `enc://` format is versioned** via the HKDF `info` field (`picoclaw-credential-v1`), allowing future algorithm upgrades without breaking existing encrypted values. diff --git a/pkg/config/config.go b/pkg/config/config.go index 1903412248..2937c36e43 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,11 +4,13 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "sync/atomic" "github.com/caarlos0/env/v11" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -837,10 +839,24 @@ func LoadConfig(path string) (*Config, error) { return nil, err } + if passphrase := credential.PassphraseProvider(); passphrase != "" { + for _, m := range cfg.ModelList { + if m.APIKey != "" && !strings.HasPrefix(m.APIKey, "enc://") && !strings.HasPrefix(m.APIKey, "file://") { + fmt.Fprintf(os.Stderr, + "picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n", + m.ModelName) + } + } + } + if err := env.Parse(cfg); err != nil { return nil, err } + if err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil { + return nil, err + } + // Migrate legacy channel config fields to new unified structures cfg.migrateChannelConfigs() @@ -857,6 +873,48 @@ func LoadConfig(path string) (*Config, error) { return cfg, nil } +// encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values +// encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or +// empty). Returns (nil, error) if any key fails to encrypt — callers must treat +// this as a hard failure to prevent a mixed plaintext/ciphertext state on disk. +// Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig +// and leave JSON marshaling to the caller. +func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelConfig, error) { + sealed := make([]ModelConfig, len(models)) + copy(sealed, models) + changed := false + for i := range sealed { + m := &sealed[i] + if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") || strings.HasPrefix(m.APIKey, "file://") { + continue + } + encrypted, err := credential.Encrypt(passphrase, "", m.APIKey) + if err != nil { + return nil, fmt.Errorf("cannot seal api_key for model %q: %w", m.ModelName, err) + } + m.APIKey = encrypted + changed = true + } + if !changed { + return nil, nil + } + return sealed, nil +} + +// resolveAPIKeys decrypts or dereferences each api_key in models in-place. +// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt). +func resolveAPIKeys(models []ModelConfig, configDir string) error { + cr := credential.NewResolver(configDir) + for i := range models { + resolved, err := cr.Resolve(models[i].APIKey) + if err != nil { + return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err) + } + models[i].APIKey = resolved + } + return nil +} + func (c *Config) migrateChannelConfigs() { // Discord: mention_only -> group_trigger.mention_only if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { @@ -871,12 +929,22 @@ func (c *Config) migrateChannelConfigs() { } func SaveConfig(path string, cfg *Config) error { + if passphrase := credential.PassphraseProvider(); passphrase != "" { + sealed, err := encryptPlaintextAPIKeys(cfg.ModelList, passphrase) + if err != nil { + return err + } + if sealed != nil { + tmp := *cfg + tmp.ModelList = sealed + cfg = &tmp + } + } + data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } - - // Use unified atomic write utility with explicit sync for flash storage reliability. return fileutil.WriteFileAtomic(path, data, 0o600) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index c5bdbf3c34..4c4dd9421e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -7,8 +7,22 @@ import ( "runtime" "strings" "testing" + + "github.com/sipeed/picoclaw/pkg/credential" ) +// mustSetupSSHKey generates a temporary Ed25519 SSH key in t.TempDir() and sets +// PICOCLAW_SSH_KEY_PATH to its path for the duration of the test. This is required +// whenever a test exercises encryption/decryption via credential.Encrypt or SaveConfig. +func mustSetupSSHKey(t *testing.T) { + t.Helper() + keyPath := filepath.Join(t.TempDir(), "picoclaw_ed25519.key") + if err := credential.GenerateSSHKey(keyPath); err != nil { + t.Fatalf("mustSetupSSHKey: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", keyPath) +} + func TestAgentModelConfig_UnmarshalString(t *testing.T) { var m AgentModelConfig if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil { @@ -482,13 +496,19 @@ func TestDefaultConfig_DMScope(t *testing.T) { } func TestDefaultConfig_WorkspacePath_Default(t *testing.T) { - // Unset to ensure we test the default t.Setenv("PICOCLAW_HOME", "") - // Set a known home for consistent test results - t.Setenv("HOME", "/tmp/home") + + var fakeHome string + if runtime.GOOS == "windows" { + fakeHome = `C:\tmp\home` + t.Setenv("USERPROFILE", fakeHome) + } else { + fakeHome = "/tmp/home" + t.Setenv("HOME", fakeHome) + } cfg := DefaultConfig() - want := filepath.Join("/tmp/home", ".picoclaw", "workspace") + want := filepath.Join(fakeHome, ".picoclaw", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want) @@ -499,7 +519,7 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) { t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home") cfg := DefaultConfig() - want := "/custom/picoclaw/home/workspace" + want := filepath.Join("/custom/picoclaw/home", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want) @@ -621,3 +641,338 @@ func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) { } }) } + +// TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext +// api_key into memory but does NOT rewrite the config file. File writes are the sole +// responsibility of SaveConfig. +func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + const original = `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` + if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + // In-memory value must be the resolved plaintext. + if cfg.ModelList[0].APIKey != "sk-plaintext" { + t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey, "sk-plaintext") + } + // The file on disk must remain unchanged — LoadConfig must not write anything. + raw, _ := os.ReadFile(cfgPath) + if string(raw) != original { + t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw)) + } +} + +// TestSaveConfig_EncryptsPlaintextAPIKey verifies that SaveConfig writes enc:// ciphertext +// to disk and that a subsequent LoadConfig decrypts it back to the original plaintext. +func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + + cfg := DefaultConfig() + cfg.ModelList = []ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + // Disk must contain enc://, not the raw key. + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "enc://") { + t.Errorf("saved file should contain enc://, got:\n%s", string(raw)) + } + if strings.Contains(string(raw), "sk-plaintext") { + t.Errorf("saved file must not contain the plaintext key") + } + + // A fresh load must decrypt back to the original plaintext. + cfg2, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig after SaveConfig: %v", err) + } + if cfg2.ModelList[0].APIKey != "sk-plaintext" { + t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey, "sk-plaintext") + } +} + +// TestLoadConfig_NoSealWithoutPassphrase verifies that api_key values are left +// unchanged when PICOCLAW_KEY_PASSPHRASE is not set. +func TestLoadConfig_NoSealWithoutPassphrase(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + if _, err := LoadConfig(cfgPath); err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + raw, _ := os.ReadFile(cfgPath) + if strings.Contains(string(raw), "enc://") { + t.Error("config file must not be modified when no passphrase is set") + } +} + +// TestLoadConfig_FileRefNotSealed verifies that file:// api_key references are not +// converted to enc:// values (they are resolved at runtime by the Resolver). +func TestLoadConfig_FileRefNotSealed(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + keyFile := filepath.Join(dir, "openai.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"file://openai.key"}]}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + if _, err := LoadConfig(cfgPath); err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "file://openai.key") { + t.Error("file:// reference should be preserved unchanged in the config file") + } + if strings.Contains(string(raw), "enc://") { + t.Error("file:// reference must not be converted to enc://") + } +} + +// TestSaveConfig_MixedKeys verifies that SaveConfig encrypts only plaintext api_keys +// and leaves already-encrypted (enc://) and file:// entries unchanged. +func TestSaveConfig_MixedKeys(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + + // Pre-encrypt one key so we have a genuine enc:// value to put in the config. + if err := SaveConfig(cfgPath, &Config{ + ModelList: []ModelConfig{ + {ModelName: "pre", Model: "openai/gpt-4", APIKey: "sk-already-plain"}, + }, + }); err != nil { + t.Fatalf("setup SaveConfig: %v", err) + } + raw, _ := os.ReadFile(cfgPath) + // Extract the enc:// value from the saved file. + var tmp struct { + ModelList []struct { + APIKey string `json:"api_key"` + } `json:"model_list"` + } + if err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 { + t.Fatalf("setup: could not parse saved config: %v", err) + } + alreadyEncrypted := tmp.ModelList[0].APIKey + if !strings.HasPrefix(alreadyEncrypted, "enc://") { + t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted) + } + + // Build a config with three models: + // 1. plaintext → must be encrypted by SaveConfig + // 2. enc:// → must be left unchanged (already encrypted) + // 3. file:// → must be left unchanged (file reference) + keyFile := filepath.Join(dir, "api.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + cfg := &Config{ + ModelList: []ModelConfig{ + {ModelName: "plain", Model: "openai/gpt-4", APIKey: "sk-new-plaintext"}, + {ModelName: "enc", Model: "openai/gpt-4", APIKey: alreadyEncrypted}, + {ModelName: "file", Model: "openai/gpt-4", APIKey: "file://api.key"}, + }, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + raw, _ = os.ReadFile(cfgPath) + s := string(raw) + + // 1. Plaintext must be encrypted. + if strings.Contains(s, "sk-new-plaintext") { + t.Error("plaintext key must not appear in saved file") + } + // 2. The pre-existing enc:// value must still be present (byte-for-byte unchanged). + if !strings.Contains(s, alreadyEncrypted) { + t.Error("pre-existing enc:// entry must be preserved unchanged") + } + // 3. file:// must be preserved. + if !strings.Contains(s, "file://api.key") { + t.Error("file:// reference must be preserved unchanged") + } + + // Now load and verify all three decrypt/resolve correctly. + cfg2, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig after SaveConfig: %v", err) + } + byName := make(map[string]string) + for _, m := range cfg2.ModelList { + byName[m.ModelName] = m.APIKey + } + if byName["plain"] != "sk-new-plaintext" { + t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext") + } + if byName["enc"] != "sk-already-plain" { + t.Errorf("enc model api_key = %q, want %q", byName["enc"], "sk-already-plain") + } + if byName["file"] != "sk-from-file" { + t.Errorf("file model api_key = %q, want %q", byName["file"], "sk-from-file") + } +} + +// TestLoadConfig_MixedKeys_NoPassphrase verifies that when PICOCLAW_KEY_PASSPHRASE +// is not set, enc:// entries cause LoadConfig to return an error, while plaintext +// and file:// entries in the same config are not affected. +func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // First encrypt a key so we have a real enc:// value. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + if err := SaveConfig(cfgPath, &Config{ + ModelList: []ModelConfig{ + {ModelName: "m", Model: "openai/gpt-4", APIKey: "sk-secret"}, + }, + }); err != nil { + t.Fatalf("setup SaveConfig: %v", err) + } + raw, _ := os.ReadFile(cfgPath) + var tmp struct { + ModelList []struct { + APIKey string `json:"api_key"` + } `json:"model_list"` + } + if err := json.Unmarshal(raw, &tmp); err != nil { + t.Fatalf("setup parse: %v", err) + } + encValue := tmp.ModelList[0].APIKey + + // Write a mixed config: enc:// + plaintext + file:// + keyFile := filepath.Join(dir, "api.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + mixed, _ := json.Marshal(map[string]any{ + "model_list": []map[string]any{ + {"model_name": "enc", "model": "openai/gpt-4", "api_key": encValue}, + {"model_name": "plain", "model": "openai/gpt-4", "api_key": "sk-plain"}, + {"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"}, + }, + }) + if err := os.WriteFile(cfgPath, mixed, 0o600); err != nil { + t.Fatalf("setup write: %v", err) + } + + // Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + + _, err := LoadConfig(cfgPath) + if err == nil { + t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set") + } + if !strings.Contains(err.Error(), "passphrase required") { + t.Errorf("error should mention passphrase required, got: %v", err) + } +} + +// TestSaveConfig_UsesPassphraseProvider verifies that SaveConfig encrypts plaintext +// api_keys using credential.PassphraseProvider() rather than os.Getenv directly. +// This matters for the launcher, which clears the environment variable and redirects +// PassphraseProvider to an in-memory SecureStore. +func TestSaveConfig_UsesPassphraseProvider(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // Ensure the env var is empty — passphrase must come from PassphraseProvider only. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + mustSetupSSHKey(t) + + // Replace PassphraseProvider with an in-memory function (simulating SecureStore). + const testPassphrase = "provider-passphrase" + orig := credential.PassphraseProvider + credential.PassphraseProvider = func() string { return testPassphrase } + t.Cleanup(func() { credential.PassphraseProvider = orig }) + + cfg := DefaultConfig() + cfg.ModelList = []ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "enc://") { + t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw) + } +} + +// TestLoadConfig_UsesPassphraseProvider verifies that LoadConfig decrypts enc:// keys +// using credential.PassphraseProvider() rather than os.Getenv directly. +func TestLoadConfig_UsesPassphraseProvider(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // Ensure the env var is empty throughout. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + mustSetupSSHKey(t) + + const testPassphrase = "provider-passphrase" + const plainKey = "sk-secret" + + // First, encrypt the key using the same passphrase. + encrypted, err := credential.Encrypt(testPassphrase, "", plainKey) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + raw, _ := json.Marshal(map[string]any{ + "model_list": []map[string]any{ + {"model_name": "test", "model": "openai/gpt-4", "api_key": encrypted}, + }, + }) + if err = os.WriteFile(cfgPath, raw, 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Redirect PassphraseProvider — env var is empty, so without this the load would fail. + orig := credential.PassphraseProvider + credential.PassphraseProvider = func() string { return testPassphrase } + t.Cleanup(func() { credential.PassphraseProvider = orig }) + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.ModelList[0].APIKey != plainKey { + t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey) + } +} diff --git a/pkg/credential/credential.go b/pkg/credential/credential.go new file mode 100644 index 0000000000..83af3fc9f4 --- /dev/null +++ b/pkg/credential/credential.go @@ -0,0 +1,335 @@ +// Package credential resolves API credential values for model_list entries. +// +// An API key is a form of authorization credential. This package centralizes +// how raw credential strings—plaintext or file references—are resolved into +// their actual values, keeping that logic out of the config loader. +// +// Supported formats for the api_key field: +// +// - Plaintext: "sk-abc123" → returned as-is +// - File ref: "file://filename.key" → content read from configDir/filename.key +// - Encrypted: "enc://" → AES-256-GCM decrypt via PICOCLAW_KEY_PASSPHRASE +// - Empty: "" → returned as-is (auth_method=oauth etc.) +// +// Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux). +// An SSH private key is required for both encryption and decryption. +// Key derivation: +// +// HKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info) +// +// SSH key path resolution priority: +// +// 1. sshKeyPath argument to Encrypt (explicit) +// 2. PICOCLAW_SSH_KEY_PATH env var +// 3. ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform) +package credential + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// PassphraseEnvVar is the environment variable that holds the encryption passphrase. +// Other packages (e.g. config) reference this constant to avoid duplicating the string. +const PassphraseEnvVar = "PICOCLAW_KEY_PASSPHRASE" + +// PassphraseProvider is the function used to retrieve the passphrase for enc:// +// credential decryption. It defaults to reading PICOCLAW_KEY_PASSPHRASE from the +// process environment. Replace it at startup to use a different source, such as +// an in-memory SecureStore, so that all LoadConfig() calls everywhere share the +// same passphrase source without needing os.Environ. +// +// Example (launcher main.go): +// +// credential.PassphraseProvider = apiHandler.passphraseStore.Get +var PassphraseProvider func() string = func() string { + return os.Getenv(PassphraseEnvVar) +} + +// ErrPassphraseRequired is returned when an enc:// credential is encountered but +// no passphrase is available from PassphraseProvider. Callers can detect this +// with errors.Is to distinguish a missing-passphrase condition from other errors. +var ErrPassphraseRequired = errors.New("credential: enc:// passphrase required") + +// ErrDecryptionFailed is returned when an enc:// credential cannot be decrypted, +// indicating a wrong passphrase or SSH key. Callers can detect this with errors.Is. +var ErrDecryptionFailed = errors.New("credential: enc:// decryption failed (wrong passphrase or SSH key?)") + +const ( + fileScheme = "file://" + encScheme = "enc://" + hkdfInfo = "picoclaw-credential-v1" + saltLen = 16 + nonceLen = 12 + keyLen = 32 + sshKeyEnv = "PICOCLAW_SSH_KEY_PATH" +) + +// Resolver resolves raw credential strings for model_list api_key fields. +// File references are resolved relative to the directory of the config file. +type Resolver struct { + configDir string + resolvedConfigDir string // symlink-resolved form of configDir +} + +// NewResolver returns a Resolver that resolves file:// references relative to +// configDir (typically filepath.Dir of the config file path). +func NewResolver(configDir string) *Resolver { + resolved := configDir + if configDir != "" { + if linkedPath, err := filepath.EvalSymlinks(configDir); err == nil { + resolved = linkedPath + } + } + return &Resolver{configDir: configDir, resolvedConfigDir: resolved} +} + +// Resolve returns the actual credential value for raw: +// +// - "" → "" (no error; auth_method=oauth needs no key) +// - "file://name.key" → trimmed content of configDir/name.key +// - anything else → raw unchanged (plaintext credential) +func (r *Resolver) Resolve(raw string) (string, error) { + if raw == "" { + return "", nil + } + + if strings.HasPrefix(raw, fileScheme) { + fileName := strings.TrimSpace(strings.TrimPrefix(raw, fileScheme)) + if fileName == "" { + return "", fmt.Errorf("credential: file:// reference has no filename") + } + + baseDir := r.resolvedConfigDir + if baseDir == "" { + baseDir = r.configDir + } + keyPath := filepath.Join(baseDir, fileName) + // Resolve symlinks before enforcing containment to prevent escaping via symlinks. + realKeyPath, err := filepath.EvalSymlinks(keyPath) + if err != nil { + return "", fmt.Errorf("credential: failed to resolve credential file path %q: %w", keyPath, err) + } + if !isWithinDir(realKeyPath, baseDir) { + return "", fmt.Errorf("credential: file:// path escapes config directory") + } + data, err := os.ReadFile(realKeyPath) + if err != nil { + return "", fmt.Errorf("credential: failed to read credential file %q: %w", realKeyPath, err) + } + + value := strings.TrimSpace(string(data)) + if value == "" { + return "", fmt.Errorf("credential: credential file %q is empty", realKeyPath) + } + + return value, nil + } + + if strings.HasPrefix(raw, encScheme) { + return resolveEncrypted(raw) + } + + // Plaintext credential — return unchanged. + return raw, nil +} + +// resolveEncrypted decrypts an enc:// credential using PassphraseProvider. +func resolveEncrypted(raw string) (string, error) { + passphrase := PassphraseProvider() + if passphrase == "" { + return "", ErrPassphraseRequired + } + + sshKeyPath := pickSSHKeyPath("") // override="": consult env then auto-detect + + b64 := strings.TrimPrefix(raw, encScheme) + blob, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", fmt.Errorf("credential: enc:// invalid base64: %w", err) + } + if len(blob) < saltLen+nonceLen+1 { + return "", fmt.Errorf("credential: enc:// payload too short") + } + + salt := blob[:saltLen] + nonce := blob[saltLen : saltLen+nonceLen] + ciphertext := blob[saltLen+nonceLen:] + + key, err := deriveKey(passphrase, sshKeyPath, salt) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("credential: enc:// cipher init: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("credential: enc:// gcm init: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrDecryptionFailed, err) + } + return string(plaintext), nil +} + +// Encrypt encrypts plaintext and returns an enc:// credential string. +// +// passphrase is required (PICOCLAW_KEY_PASSPHRASE value). +// sshKeyPath is the SSH private key file to use; pass "" to auto-detect via +// PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/picoclaw_ed25519.key. +// An SSH private key must be resolvable or Encrypt returns an error. +func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) { + if passphrase == "" { + return "", fmt.Errorf("credential: passphrase must not be empty") + } + sshKeyPath = pickSSHKeyPath(sshKeyPath) + + salt := make([]byte, saltLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", fmt.Errorf("credential: failed to generate salt: %w", err) + } + + key, err := deriveKey(passphrase, sshKeyPath, salt) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("credential: cipher init: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("credential: gcm init: %w", err) + } + + nonce := make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("credential: failed to generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), nil) + blob := make([]byte, 0, saltLen+nonceLen+len(ciphertext)) + blob = append(blob, salt...) + blob = append(blob, nonce...) + blob = append(blob, ciphertext...) + return encScheme + base64.StdEncoding.EncodeToString(blob), nil +} + +// isWithinDir reports whether path is contained within (or equal to) dir. +// Uses filepath.IsLocal on the relative path for robust cross-platform traversal detection. +func isWithinDir(path, dir string) bool { + rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path)) + return err == nil && filepath.IsLocal(rel) +} + +// allowedSSHKeyPath reports whether path is in a permitted location for SSH key files: +// - exact match with PICOCLAW_SSH_KEY_PATH env var +// - within the PICOCLAW_HOME env var directory +// - within ~/.ssh/ +func allowedSSHKeyPath(path string) bool { + if path == "" { + return true // passphrase-only mode; no file will be read + } + clean := filepath.Clean(path) + + // Exact match with PICOCLAW_SSH_KEY_PATH. + if envPath, ok := os.LookupEnv(sshKeyEnv); ok && envPath != "" { + if clean == filepath.Clean(envPath) { + return true + } + } + + // Within PICOCLAW_HOME. + if picoHome := os.Getenv("PICOCLAW_HOME"); picoHome != "" { + if isWithinDir(clean, picoHome) { + return true + } + } + + // Within ~/.ssh/. + if userHome, err := os.UserHomeDir(); err == nil { + if isWithinDir(clean, filepath.Join(userHome, ".ssh")) { + return true + } + } + + return false +} + +// deriveKey derives a 32-byte AES-256 key from passphrase and SSH private key. +// +// ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase) +// Final key: HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +// sshKeyPath must be non-empty; returns an error otherwise. +func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) { + if sshKeyPath == "" { + return nil, fmt.Errorf( + "credential: SSH private key is required but not found" + + " (set PICOCLAW_SSH_KEY_PATH or place key at ~/.ssh/picoclaw_ed25519.key)") + } + if !allowedSSHKeyPath(sshKeyPath) { + return nil, fmt.Errorf( + "credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)", + sshKeyPath, + ) + } + sshBytes, err := os.ReadFile(sshKeyPath) + if err != nil { + return nil, fmt.Errorf("credential: cannot read SSH key %q: %w", sshKeyPath, err) + } + sshHash := sha256.Sum256(sshBytes) + mac := hmac.New(sha256.New, sshHash[:]) + mac.Write([]byte(passphrase)) + ikm := mac.Sum(nil) + + key, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen) + if err != nil { + return nil, fmt.Errorf("credential: HKDF expand failed: %w", err) + } + return key, nil +} + +// pickSSHKeyPath returns the SSH private key path to use for encryption/decryption. +// +// Priority: +// 1. override (non-empty explicit argument) +// 2. PICOCLAW_SSH_KEY_PATH env var +// 3. ~/.ssh/picoclaw_ed25519.key (auto-detection) +// +// Returns "" when no key is found; deriveKey will return an error in that case. +func pickSSHKeyPath(override string) string { + if override != "" { + return override + } + if p, ok := os.LookupEnv(sshKeyEnv); ok { + return p // respect explicit setting, even if "" + } + return findDefaultSSHKey() +} + +// findDefaultSSHKey returns the picoclaw-specific SSH key path if it exists. +func findDefaultSSHKey() string { + p, err := DefaultSSHKeyPath() + if err != nil { + return "" + } + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} diff --git a/pkg/credential/credential_test.go b/pkg/credential/credential_test.go new file mode 100644 index 0000000000..138af31346 --- /dev/null +++ b/pkg/credential/credential_test.go @@ -0,0 +1,283 @@ +package credential_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/credential" +) + +func TestResolve_PlainKey(t *testing.T) { + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve("sk-plaintext-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-plaintext-key" { + t.Fatalf("got %q, want %q", got, "sk-plaintext-key") + } +} + +func TestResolve_FileKey_Success(t *testing.T) { + dir := t.TempDir() + keyFile := "openai_plain.key" + if err := os.WriteFile(filepath.Join(dir, keyFile), []byte("sk-from-file\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(dir) + got, err := r.Resolve("file://" + keyFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-from-file" { + t.Fatalf("got %q, want %q", got, "sk-from-file") + } +} + +func TestResolve_FileKey_NotFound(t *testing.T) { + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("file://missing.key") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestResolve_FileKey_Empty(t *testing.T) { + dir := t.TempDir() + keyFile := "empty.key" + if err := os.WriteFile(filepath.Join(dir, keyFile), []byte(" \n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(dir) + _, err := r.Resolve("file://" + keyFile) + if err == nil { + t.Fatal("expected error for empty credential file, got nil") + } +} + +// TestResolve_EncKey_RoundTrip tests basic encryption/decryption round-trip with an SSH key. +func TestResolve_EncKey_RoundTrip(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase-32bytes-long-ok!" + const plaintext = "sk-encrypted-secret" + + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt(passphrase, "", plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) + + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve(enc) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != plaintext { + t.Fatalf("got %q, want %q", got, plaintext) + } +} + +// TestResolve_EncKey_WithSSHKey tests that the SSH key file is incorporated into key derivation. +func TestResolve_EncKey_WithSSHKey(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-private-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase" + const plaintext = "sk-ssh-protected-secret" + + // Set PICOCLAW_SSH_KEY_PATH before Encrypt so the path passes allowedSSHKeyPath validation. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt(passphrase, sshKeyPath, plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve(enc) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != plaintext { + t.Fatalf("got %q, want %q", got, plaintext) + } +} + +func TestResolve_EncKey_NoPassphrase(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("some-passphrase", "", "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected error when PICOCLAW_KEY_PASSPHRASE is unset, got nil") + } +} + +func TestResolve_EncKey_BadCiphertext(t *testing.T) { + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("enc://!!not-valid-base64!!") + if err == nil { + t.Fatal("expected error for invalid enc:// payload, got nil") + } +} + +func TestResolve_EncKey_PayloadTooShort(t *testing.T) { + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + // Valid base64 but fewer bytes than salt(16)+nonce(12)+1 minimum. + import64 := "dG9vc2hvcnQ=" // "tooshort" = 8 bytes + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("enc://" + import64) + if err == nil { + t.Fatal("expected error for too-short enc:// payload, got nil") + } +} + +func TestResolve_EncKey_WrongPassphrase(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("correct-passphrase", "", "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "wrong-passphrase") + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected decryption error for wrong passphrase, got nil") + } +} + +func TestEncrypt_EmptyPassphrase(t *testing.T) { + _, err := credential.Encrypt("", "", "sk-secret") + if err == nil { + t.Fatal("expected error for empty passphrase, got nil") + } +} + +func TestDeriveKey_SSHKeyNotFound(t *testing.T) { + // Encrypt with a real SSH key path, then try to decrypt with a missing path. + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Register the real key path so allowedSSHKeyPath validation passes for Encrypt. + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Point to a non-existent SSH key so deriveKey's ReadFile fails. + // The path is still under the same dir, so allowedSSHKeyPath passes (exact env match). + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", filepath.Join(dir, "nonexistent_key")) + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected error when SSH key file is missing, got nil") + } +} + +// TestResolve_FileRef_PathTraversal verifies that file:// references cannot escape configDir +// via relative traversal ("../../etc/passwd") or absolute paths ("/abs/path"). +func TestResolve_FileRef_PathTraversal(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + // Create a file outside configDir that the traversal would point to. + outsideFile := filepath.Join(t.TempDir(), "secret.key") + if err := os.WriteFile(outsideFile, []byte("stolen"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(filepath.Dir(cfgPath)) + + cases := []string{ + "file://../../secret.key", + "file://../secret.key", + "file://" + outsideFile, // absolute path + } + for _, raw := range cases { + _, err := r.Resolve(raw) + if err == nil { + t.Errorf("Resolve(%q): expected path traversal error, got nil", raw) + } + } +} + +// TestResolve_FileRef_withinConfigDir verifies that a legitimate relative file:// ref works. +func TestResolve_FileRef_withinConfigDir(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "my.key"), []byte("sk-valid\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + r := credential.NewResolver(dir) + got, err := r.Resolve("file://my.key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-valid" { + t.Fatalf("got %q, want %q", got, "sk-valid") + } +} + +// TestEncrypt_SSHKeyOutsideAllowedDirs verifies that Encrypt rejects SSH key paths +// that are not under PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/. +func TestEncrypt_SSHKeyOutsideAllowedDirs(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Make sure none of the allowed env vars point here. + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + t.Setenv("PICOCLAW_HOME", "") + + _, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") + if err == nil { + t.Fatal("expected error for SSH key outside allowed directories, got nil") + } +} diff --git a/pkg/credential/keygen.go b/pkg/credential/keygen.go new file mode 100644 index 0000000000..c57564a765 --- /dev/null +++ b/pkg/credential/keygen.go @@ -0,0 +1,62 @@ +package credential + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" +) + +// DefaultSSHKeyPath returns the canonical path for the picoclaw-specific SSH key. +// The path is always ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform). +func DefaultSSHKeyPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("credential: cannot determine home directory: %w", err) + } + return filepath.Join(home, ".ssh", "picoclaw_ed25519.key"), nil +} + +// GenerateSSHKey generates an Ed25519 SSH key pair and writes the private key +// to path (permissions 0600) and the public key to path+".pub" (permissions 0644). +// The ~/.ssh/ directory is created with 0700 if it does not exist. +// If the files already exist they are overwritten. +func GenerateSSHKey(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("credential: keygen: cannot create directory %q: %w", filepath.Dir(path), err) + } + + pubRaw, privRaw, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return fmt.Errorf("credential: keygen: ed25519 key generation failed: %w", err) + } + + // Marshal private key as OpenSSH PEM. + block, err := ssh.MarshalPrivateKey(privRaw, "") + if err != nil { + return fmt.Errorf("credential: keygen: marshal private key: %w", err) + } + privPEM := pem.EncodeToMemory(block) + + if err = os.WriteFile(path, privPEM, 0o600); err != nil { + return fmt.Errorf("credential: keygen: write private key %q: %w", path, err) + } + + // Marshal public key as authorized_keys line. + sshPub, err := ssh.NewPublicKey(pubRaw) + if err != nil { + return fmt.Errorf("credential: keygen: marshal public key: %w", err) + } + pubLine := ssh.MarshalAuthorizedKey(sshPub) + + pubPath := path + ".pub" + if err := os.WriteFile(pubPath, pubLine, 0o644); err != nil { + return fmt.Errorf("credential: keygen: write public key %q: %w", pubPath, err) + } + + return nil +} diff --git a/pkg/credential/keygen_test.go b/pkg/credential/keygen_test.go new file mode 100644 index 0000000000..1e21ea0b99 --- /dev/null +++ b/pkg/credential/keygen_test.go @@ -0,0 +1,115 @@ +package credential + +import ( + "crypto/ed25519" + "os" + "path/filepath" + "runtime" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestGenerateSSHKey_CreatesFiles(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "test_ed25519.key") + + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("GenerateSSHKey() error = %v", err) + } + + // Private key must exist. + privInfo, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("private key file missing: %v", err) + } + + // Check permissions on non-Windows (Windows does not support Unix permission bits). + if runtime.GOOS != "windows" { + if got := privInfo.Mode().Perm(); got != 0o600 { + t.Errorf("private key permissions = %04o, want 0600", got) + } + } + + // Public key must exist. + pubPath := keyPath + ".pub" + pubInfo, err := os.Stat(pubPath) + if err != nil { + t.Fatalf("public key file missing: %v", err) + } + if runtime.GOOS != "windows" { + if got := pubInfo.Mode().Perm(); got != 0o644 { + t.Errorf("public key permissions = %04o, want 0644", got) + } + } + + // Private key must be parseable as an OpenSSH ed25519 key. + privPEM, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read private key: %v", err) + } + privKey, err := ssh.ParseRawPrivateKey(privPEM) + if err != nil { + t.Fatalf("parse private key: %v", err) + } + if _, ok := privKey.(*ed25519.PrivateKey); !ok { + t.Errorf("private key type = %T, want *ed25519.PrivateKey", privKey) + } + + // Public key must be parseable as authorized_keys line. + pubBytes, err := os.ReadFile(pubPath) + if err != nil { + t.Fatalf("read public key: %v", err) + } + pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(pubBytes) + if err != nil { + t.Fatalf("parse public key: %v", err) + } + if pubKey == nil { + t.Fatal("expected non-nil public key") + } + if len(rest) > 0 { + t.Errorf("unexpected trailing bytes after public key: %d bytes", len(rest)) + } +} + +func TestGenerateSSHKey_OverwritesExisting(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "test_ed25519.key") + + // Generate twice; second call must not error and must produce a different key. + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("first GenerateSSHKey() error = %v", err) + } + first, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read first key: %v", err) + } + + if err = GenerateSSHKey(keyPath); err != nil { + t.Fatalf("second GenerateSSHKey() error = %v", err) + } + second, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read second key: %v", err) + } + + // Two independently generated Ed25519 keys must differ. + if string(first) == string(second) { + t.Error("expected overwritten key to differ from original") + } +} + +func TestGenerateSSHKey_CreatesDirectory(t *testing.T) { + dir := t.TempDir() + // Nested directory that does not yet exist. + keyPath := filepath.Join(dir, "subdir", ".ssh", "picoclaw_ed25519.key") + + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("GenerateSSHKey() error = %v", err) + } + + if _, err := os.Stat(keyPath); err != nil { + t.Fatalf("private key not created: %v", err) + } +} diff --git a/pkg/credential/store.go b/pkg/credential/store.go new file mode 100644 index 0000000000..9c72974b0c --- /dev/null +++ b/pkg/credential/store.go @@ -0,0 +1,44 @@ +package credential + +import "sync/atomic" + +// SecureStore holds a passphrase in memory. +// +// Uses atomic.Pointer so reads and writes are lock-free. +// The passphrase is never written to disk; callers decide how to +// transport it outside this store (e.g., via cmd.Env or os.Environ). +type SecureStore struct { + val atomic.Pointer[string] +} + +// NewSecureStore creates an empty SecureStore. +func NewSecureStore() *SecureStore { + return &SecureStore{} +} + +// SetString stores the passphrase. An empty string clears the store. +func (s *SecureStore) SetString(passphrase string) { + if passphrase == "" { + s.val.Store(nil) + return + } + s.val.Store(&passphrase) +} + +// Get returns the stored passphrase, or "" if not set. +func (s *SecureStore) Get() string { + if p := s.val.Load(); p != nil { + return *p + } + return "" +} + +// IsSet reports whether a passphrase is currently stored. +func (s *SecureStore) IsSet() bool { + return s.val.Load() != nil +} + +// Clear removes the stored passphrase. +func (s *SecureStore) Clear() { + s.val.Store(nil) +} diff --git a/pkg/credential/store_test.go b/pkg/credential/store_test.go new file mode 100644 index 0000000000..63299743a6 --- /dev/null +++ b/pkg/credential/store_test.go @@ -0,0 +1,81 @@ +package credential + +import ( + "sync" + "testing" +) + +func TestSecureStore_SetGet(t *testing.T) { + s := NewSecureStore() + if s.IsSet() { + t.Error("expected empty store") + } + + s.SetString("hunter2") + if !s.IsSet() { + t.Error("expected store to be set") + } + if got := s.Get(); got != "hunter2" { + t.Errorf("Get() = %q, want %q", got, "hunter2") + } +} + +func TestSecureStore_Clear(t *testing.T) { + s := NewSecureStore() + s.SetString("secret") + s.Clear() + + if s.IsSet() { + t.Error("expected store to be empty after Clear()") + } + if got := s.Get(); got != "" { + t.Errorf("Get() after Clear() = %q, want empty", got) + } +} + +func TestSecureStore_SetOverwrites(t *testing.T) { + s := NewSecureStore() + s.SetString("first") + s.SetString("second") + + if got := s.Get(); got != "second" { + t.Errorf("Get() = %q, want %q", got, "second") + } +} + +func TestSecureStore_EmptyPassphrase(t *testing.T) { + s := NewSecureStore() + s.SetString("") // empty → should not mark as set + + if s.IsSet() { + t.Error("empty passphrase should not mark store as set") + } +} + +func TestSecureStore_ConcurrentSetGet(t *testing.T) { + s := NewSecureStore() + const goroutines = 10 + const iterations = 1000 + + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + if id%2 == 0 { + s.SetString("even") + } else { + s.SetString("odd") + } + _ = s.Get() + } + }(i) + } + wg.Wait() + + final := s.Get() + if final != "" && final != "even" && final != "odd" { + t.Errorf("Get() returned unexpected value %q after concurrent Set/Get", final) + } +} From 4d4243b919accb4969f2cfe71011e4a9989b80d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:08:29 +0800 Subject: [PATCH 13/17] chore(deps): bump docker/setup-buildx-action from 3 to 4 (#1595) Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-build.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index dadbed212a..c03c6346f0 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -31,7 +31,7 @@ jobs: # ── Docker Buildx ───────────────────────── - name: 🔧 Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # ── Login to GHCR ───────────────────────── - name: 🔑 Login to GitHub Container Registry diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0103fcff16..375e2e211b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -59,7 +59,7 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a584773d9..56d2f2b232 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,7 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v3 From 44ac304e5b122bac7fbc9c9b6699ff335f0da0e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:09:01 +0800 Subject: [PATCH 14/17] chore(deps): bump actions/setup-node from 4 to 6 (#1597) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 375e2e211b..b29881d6ca 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -48,7 +48,7 @@ jobs: go-version-file: go.mod - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56d2f2b232..84aade5782 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: go-version-file: go.mod - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 From f247c3bc00cb8bba874712535ce07de8c6b1b613 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:09:36 +0800 Subject: [PATCH 15/17] chore(deps): bump actions/setup-go from 5 to 6 (#1600) Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1e9a7919a2..902d4d4ebf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -34,7 +34,7 @@ jobs: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod From b7b8d1eeca7a750f3c42820727135dca8f080ced Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:10:19 +0800 Subject: [PATCH 16/17] chore(deps): bump docker/build-push-action from 6 to 7 (#1602) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6...v7) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index c03c6346f0..8b4c033c27 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -62,7 +62,7 @@ jobs: # ── Build & Push ────────────────────────── - name: 🚀 Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . push: true From 0c94e6f7b3d2a19d4e0f85bb1b5647dda14355e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:11:22 +0800 Subject: [PATCH 17/17] chore(deps): bump docker/login-action from 3 to 4 (#1604) Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v3...v4) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-build.yml | 4 ++-- .github/workflows/nightly.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 8b4c033c27..784c404a65 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -35,7 +35,7 @@ jobs: # ── Login to GHCR ───────────────────────── - name: 🔑 Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.GHCR_REGISTRY }} username: ${{ github.actor }} @@ -43,7 +43,7 @@ jobs: # ── Login to Docker Hub ──────────────────── - name: 🔑 Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.DOCKERHUB_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index b29881d6ca..e001dc3e9f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -62,14 +62,14 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84aade5782..19c8e54049 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,14 +80,14 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }}