From 6cd7ae779e3ffa8660b4c8210ea2aafb9970fd2b Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 02:28:42 +0800 Subject: [PATCH 1/9] feat(codewhale): add tested LMM OAuth companion adapter and native client registration Stage independent package under packages/codewhale-lmm-provider pending remote repository creation. Add PKCE, private refresh journal, group-bound streaming bridge, tests, documentation and explicit submodule publication script. No changes to existing submodules or production OAuth activation flags. --- .github/workflows/codewhale-lmm-provider.yml | 40 ++++ .../oauthserver/codewhale_integration_test.go | 72 ++++++ apps/api-go/service/oauth_server.go | 12 +- docs/codewhale-provider.md | 38 ++++ .../.github/workflows/ci.yml | 19 ++ packages/codewhale-lmm-provider/.gitignore | 6 + packages/codewhale-lmm-provider/LICENSE | 15 ++ packages/codewhale-lmm-provider/README.md | 113 +++++++++ packages/codewhale-lmm-provider/package.json | 15 ++ packages/codewhale-lmm-provider/plugin.json | 6 + .../scripts/publish-subproject.mjs | 72 ++++++ .../skills/lmm/SKILL.md | 27 +++ packages/codewhale-lmm-provider/src/cli.mjs | 78 +++++++ packages/codewhale-lmm-provider/src/oauth.mjs | 215 ++++++++++++++++++ .../codewhale-lmm-provider/src/provider.mjs | 183 +++++++++++++++ .../codewhale-lmm-provider/src/session.mjs | 147 ++++++++++++ .../codewhale-lmm-provider/test/fixture.mjs | 86 +++++++ .../test/oauth.test.mjs | 80 +++++++ .../test/provider.test.mjs | 151 ++++++++++++ .../test/session.test.mjs | 110 +++++++++ 20 files changed, 1484 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/codewhale-lmm-provider.yml create mode 100644 apps/api-go/oauthserver/codewhale_integration_test.go create mode 100644 docs/codewhale-provider.md create mode 100644 packages/codewhale-lmm-provider/.github/workflows/ci.yml create mode 100644 packages/codewhale-lmm-provider/.gitignore create mode 100644 packages/codewhale-lmm-provider/LICENSE create mode 100644 packages/codewhale-lmm-provider/README.md create mode 100644 packages/codewhale-lmm-provider/package.json create mode 100644 packages/codewhale-lmm-provider/plugin.json create mode 100755 packages/codewhale-lmm-provider/scripts/publish-subproject.mjs create mode 100644 packages/codewhale-lmm-provider/skills/lmm/SKILL.md create mode 100755 packages/codewhale-lmm-provider/src/cli.mjs create mode 100644 packages/codewhale-lmm-provider/src/oauth.mjs create mode 100644 packages/codewhale-lmm-provider/src/provider.mjs create mode 100644 packages/codewhale-lmm-provider/src/session.mjs create mode 100644 packages/codewhale-lmm-provider/test/fixture.mjs create mode 100644 packages/codewhale-lmm-provider/test/oauth.test.mjs create mode 100644 packages/codewhale-lmm-provider/test/provider.test.mjs create mode 100644 packages/codewhale-lmm-provider/test/session.test.mjs diff --git a/.github/workflows/codewhale-lmm-provider.yml b/.github/workflows/codewhale-lmm-provider.yml new file mode 100644 index 000000000..4e75664d5 --- /dev/null +++ b/.github/workflows/codewhale-lmm-provider.yml @@ -0,0 +1,40 @@ +name: Codewhale LMM provider +on: + pull_request: + paths: + - 'packages/codewhale-lmm-provider/**' + - 'packages/codewhale-lmm-provider' + - '.gitmodules' + - 'apps/api-go/service/oauth_server.go' + - 'apps/api-go/oauthserver/codewhale_integration_test.go' + - '.github/workflows/codewhale-lmm-provider.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + adapter: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - name: Initialize adapter when extracted as a submodule + run: | + if git ls-files --stage packages/codewhale-lmm-provider | grep -q '^160000 '; then + git submodule update --init --depth 1 -- packages/codewhale-lmm-provider + fi + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm run check && npm test && npm run pack:check + working-directory: packages/codewhale-lmm-provider + registration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: apps/api-go/go.mod + cache-dependency-path: apps/api-go/go.sum + - run: go test ./oauthserver -run TestCodewhale -count=1 + working-directory: apps/api-go diff --git a/apps/api-go/oauthserver/codewhale_integration_test.go b/apps/api-go/oauthserver/codewhale_integration_test.go new file mode 100644 index 000000000..bfe1a9da1 --- /dev/null +++ b/apps/api-go/oauthserver/codewhale_integration_test.go @@ -0,0 +1,72 @@ +package oauthserver_test + +import ( + "context" + "net/url" + "strings" + "testing" + "time" + + "github.com/LIghtJUNction/api.lmm.best/model" + "github.com/LIghtJUNction/api.lmm.best/oauthserver" + "github.com/LIghtJUNction/api.lmm.best/service" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestCodewhaleClientLifecycleAndIsolation(t *testing.T) { + oauthserver.ForTestDatabases(t, func(t *testing.T, db, _ *gorm.DB) { + s := productionPolicyFixture(t, db, false) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + query, err := url.ParseQuery(productionPolicyQuery(s)) + require.NoError(t, err) + query.Set("client_id", service.OAuthCodewhaleClientID) + query.Set("scope", strings.Join([]string{service.OAuthCatalogScope, service.OAuthBalanceScope, service.OAuthUsageScope, service.OAuthInvokeScope}, " ")) + user := &model.User{Group: "default"} + expanded, groups, err := s.ConsentQuery(query.Encode(), user) + require.NoError(t, err) + require.Equal(t, []string{"default"}, groups) + pending, err := s.Core.BeginAuthorization(ctx, expanded, policyBrowser) + require.NoError(t, err) + consent, err := s.Core.TrustedPrepareConsent(ctx, pending.Transaction, policyBrowser, 42) + require.NoError(t, err) + approved, err := s.Core.TrustedApprove(ctx, consent.Transaction, policyBrowser, consent.Secret) + require.NoError(t, err) + redirect, err := url.Parse(approved.RedirectURI) + require.NoError(t, err) + form := url.Values{"grant_type": {"authorization_code"}, "client_id": {service.OAuthCodewhaleClientID}, "code": {redirect.Query().Get("code")}, "redirect_uri": {policyRedirect}, "code_verifier": {policyVerifier}, "resource": {s.Resource}} + tokens, err := s.Core.Exchange(ctx, form.Encode(), oauthserver.SenderBinding{}) + require.NoError(t, err) + grant, err := s.Core.ValidateAccess(ctx, oauthserver.AccessRequest{Token: tokens.AccessToken, Resource: s.Resource, RequiredScopes: []string{service.OAuthInvokeScope}}) + require.NoError(t, err) + require.Equal(t, service.OAuthCodewhaleClientID, grant.ClientID) + for _, scope := range []string{service.OAuthMCPBountiesScope, service.OAuthMCPDrawingScope, service.OAuthMarketDiscoverScope, service.OAuthMarketManageScope} { + _, err = s.Core.ValidateAccess(ctx, oauthserver.AccessRequest{Token: tokens.AccessToken, Resource: s.Resource, RequiredScopes: []string{scope}}) + require.Error(t, err) + } + refresh := url.Values{"grant_type": {"refresh_token"}, "client_id": {service.OAuthCodewhaleClientID}, "refresh_token": {tokens.RefreshToken}, "resource": {s.Resource}} + rotated, err := s.Core.Exchange(ctx, refresh.Encode(), oauthserver.SenderBinding{}) + require.NoError(t, err) + require.NotEqual(t, tokens.RefreshToken, rotated.RefreshToken) + grant, err = s.Core.ValidateAccess(ctx, oauthserver.AccessRequest{Token: rotated.AccessToken, Resource: s.Resource, RequiredScopes: []string{service.OAuthUsageScope}}) + require.NoError(t, err) + require.Equal(t, service.OAuthCodewhaleClientID, grant.ClientID) + refresh.Set("client_id", service.OAuthPiClientID) + refresh.Set("refresh_token", rotated.RefreshToken) + _, err = s.Core.Exchange(ctx, refresh.Encode(), oauthserver.SenderBinding{}) + require.Error(t, err, "Pi must not refresh Codewhale credentials") + }) +} + +func TestCodewhaleConsentRejectsUnsupportedScopes(t *testing.T) { + oauthserver.ForTestDatabases(t, func(t *testing.T, db, _ *gorm.DB) { + s := productionPolicyFixture(t, db, false) + base := "catalog:read balance:read usage:read models:invoke" + for _, scope := range []string{base + " mcp:bounties mcp:drawing", base + " market:discover", "catalog:read balance:read", base + " group:" + service.OAuthGroupID("default")} { + query := url.Values{"client_id": {service.OAuthCodewhaleClientID}, "scope": {scope}} + _, _, err := s.ConsentQuery(query.Encode(), &model.User{Group: "default"}) + require.Error(t, err) + } + }) +} diff --git a/apps/api-go/service/oauth_server.go b/apps/api-go/service/oauth_server.go index c22fb7cae..4a2903bdc 100644 --- a/apps/api-go/service/oauth_server.go +++ b/apps/api-go/service/oauth_server.go @@ -25,6 +25,8 @@ const ( OAuthPiClientName = "LMM for Pi" OAuthDshClientID = "lmm-dsh" OAuthDshClientName = "LMM for DSH" + OAuthCodewhaleClientID = "lmm-codewhale" + OAuthCodewhaleClientName = "LMM for Codewhale" OAuthCLIClientID = "lmm" OAuthCLIClientName = "LMM CLI" OAuthNativeRedirect = "http://127.0.0.1/oauth/lmm/callback" @@ -125,6 +127,12 @@ func NewOAuthIntegration(db *gorm.DB, cfg OAuthServerConfig) (*OAuthIntegration, {ID: OAuthPiClientID, Name: OAuthPiClientName, RedirectURIs: []string{OAuthNativeRedirect}, Resources: []string{integration.Resource}, Scopes: scopes}, {ID: OAuthDshClientID, Name: OAuthDshClientName, RedirectURIs: []string{OAuthNativeRedirect}, Resources: []string{integration.Resource}, Scopes: scopes}, } + // Codewhale's companion adapter has no MCP or marketplace integration. + codewhaleScopes := []string{OAuthCatalogScope, OAuthBalanceScope, OAuthUsageScope, OAuthInvokeScope} + for _, group := range groups { + codewhaleScopes = append(codewhaleScopes, OAuthGroupScope(group)) + } + clients = append(clients, oauthserver.NativeClient{ID: OAuthCodewhaleClientID, Name: OAuthCodewhaleClientName, RedirectURIs: []string{OAuthNativeRedirect}, Resources: []string{integration.Resource}, Scopes: codewhaleScopes}) // CLI discovery does not authorize relay, MCP or account administration. cliScopes := []string{OAuthCatalogScope, OAuthBalanceScope} for _, group := range groups { @@ -216,7 +224,7 @@ func (s *OAuthIntegration) GrantedGroups(user *model.User, grant oauthserver.Gra // validation. Database/cache failures never imply access. It does not mutate // OAuth tables or acquire a second pool connection while core owns a transaction. func (s *OAuthIntegration) Authorize(ctx context.Context, tx *gorm.DB, grant oauthserver.Grant) error { - if (grant.ClientID != OAuthPiClientID && grant.ClientID != OAuthDshClientID && grant.ClientID != OAuthCLIClientID) || grant.Resource != s.Resource { + if (grant.ClientID != OAuthPiClientID && grant.ClientID != OAuthDshClientID && grant.ClientID != OAuthCLIClientID && grant.ClientID != OAuthCodewhaleClientID) || grant.Resource != s.Resource { return ErrOAuthDenied } if tx == nil { @@ -277,6 +285,8 @@ func (s *OAuthIntegration) ConsentQuery(raw string, user *model.User) (string, [ } if query.Get("client_id") == OAuthCLIClientID { profiles = [][]string{{OAuthCatalogScope, OAuthBalanceScope}} + } else if query.Get("client_id") == OAuthCodewhaleClientID { + profiles = [][]string{currentBase} } slices.Sort(requested) validProfile := false diff --git a/docs/codewhale-provider.md b/docs/codewhale-provider.md new file mode 100644 index 000000000..de8e63364 --- /dev/null +++ b/docs/codewhale-provider.md @@ -0,0 +1,38 @@ +# Codewhale LMM OAuth adapter + +Source: `packages/codewhale-lmm-provider`. This is presently an ordinary directory, +not a Git submodule: the remote repository creation operation is not available +through the active GitHub connector. The package's explicit maintainer publication +script creates `TokenNotIncluded/codewhale-lmm-provider`, pushes committed source, +and proposes conversion into a pinned submodule in a separate parent PR. No +nonexistent remote is added to `.gitmodules`. + +The backend registers public native client `lmm-codewhale` / `LMM for Codewhale`. +It reuses the existing PKCE S256, state/issuer-bound loopback callback, resource, +refresh rotation and revocation contract. Its initial profile is exactly +`catalog:read balance:read usage:read models:invoke`, plus only the groups added +by explicit consent. MCP and marketplace permissions are not registered or added. +Pi/DSH/CLI grants and existing OAuth deployment switches are unchanged. + +Codewhale's current plugin bundle API has no executable provider/auth adapter. +This package therefore uses a companion CLI and the documented named +`openai-compatible` provider configuration. Its native bundle contains a help +skill, not a fictitious OAuth provider entry. Only models advertising +`openai-completions` are admitted. Responses/Messages-only models, Pi-specific +model-picker integration and MCP/marketplace features are not claimed. + +The CLI holds LMM credentials; the host only receives a random per-run loopback +capability through `api_key_env`. It must be launched with `codewhale-lmm run`. +The temporary provider config does not overwrite the existing Codewhale config. +The bridge rechecks catalog/grant on each request, replaces the exact synthetic +model ID with the upstream model, and sets `X-LMM-Group` without fallback. +Streaming bytes are passed through, disconnects cancel the upstream, and the +adapter does not retry inference POSTs. The host may have its own retry policy. + +Local verification: Linux, Node.js 22.16.0, 33 passing tests and syntax/package +checks, including mock OAuth HTTP, separate-process refresh locking, and a mock +host process. This is not live Codewhale or production/billing acceptance. +Backend tests: `cd apps/api-go && go test ./oauthserver -run TestCodewhale -count=1`. +They are supplied but were not executable in the dependency-unavailable local +review environment. Production rollout remains gated by real interoperability +and billing tests; registering a client does not enable or deploy OAuth. diff --git a/packages/codewhale-lmm-provider/.github/workflows/ci.yml b/packages/codewhale-lmm-provider/.github/workflows/ci.yml new file mode 100644 index 000000000..751793948 --- /dev/null +++ b/packages/codewhale-lmm-provider/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: Adapter checks +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + node: ['22', '24'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: npm run check + - run: npm test + - run: npm run pack:check diff --git a/packages/codewhale-lmm-provider/.gitignore b/packages/codewhale-lmm-provider/.gitignore new file mode 100644 index 000000000..4be90b357 --- /dev/null +++ b/packages/codewhale-lmm-provider/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +*.tgz +coverage/ +.env +session.json +session.lock diff --git a/packages/codewhale-lmm-provider/LICENSE b/packages/codewhale-lmm-provider/LICENSE new file mode 100644 index 000000000..c241f52d4 --- /dev/null +++ b/packages/codewhale-lmm-provider/LICENSE @@ -0,0 +1,15 @@ +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2026 TokenNotIncluded contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License version 3 +as published by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +The full license text is available at: +https://www.gnu.org/licenses/agpl-3.0.txt diff --git a/packages/codewhale-lmm-provider/README.md b/packages/codewhale-lmm-provider/README.md new file mode 100644 index 000000000..255f7f315 --- /dev/null +++ b/packages/codewhale-lmm-provider/README.md @@ -0,0 +1,113 @@ +# Codewhale LMM Provider + +通过 LMM 网页授权登录,在 Codewhale 中使用 LMM 的模型与分组,不需要复制 API Key。 + +**当前版本:0.1.0-alpha.1。** 这是 OAuth 伴随适配器,不是 Codewhale 原生 `/login` provider 插件。依据 Codewhale `b367f6248715510cb5d527e57e4e352db14cb0cd` 的插件和自定义 provider 接口实现。该版本的插件接口不能注册模型提供商或 OAuth 回调,因此使用独立 CLI 完成授权,通过临时本地 provider 连接 Codewhale;`plugin.json` 提供可选的使用说明 skill。 + +目前支持目录中声明 `openai-completions` 的模型、工具调用请求与 SSE 透传。**不支持 Responses-only、Anthropic Messages-only 模型,也未移植 Pi 的 MCP、市场工具和原生模型选择器集成。** 不会伪造模型能力、上下文长度或价格。 + +## 安装 + +需要 Node.js 22+ 和已经安装的官方 `codewhale` 可执行程序。本包没有第三方运行时依赖。 + +在父项目仓库根目录执行: + +```sh +npm install --global ./packages/codewhale-lmm-provider +``` + +独立源码包解压后,也可以在本目录执行 `npm install --global .`,或不安装,直接运行 `node src/cli.mjs --help`。 + +## 使用 + +```sh +codewhale-lmm login +codewhale-lmm models +codewhale-lmm run --model '' +``` + +登录打开 LMM 授权页;回到终端后即可使用。模型 ID 包含分组,不能用上游模型名替代;多个模型时不会静默选择第一个。 + +```sh +codewhale-lmm run --model '<完整 id>' -- exec '检查这个项目的测试' +codewhale-lmm status +codewhale-lmm balance +codewhale-lmm usage +codewhale-lmm logout +``` + +`status` 只读本地状态;`balance` 和 `usage` 读取 LMM 账户余额与授权允许的日聚合用量。未知价格保持 `null`,不是免费。 + +启动时创建临时 provider 配置,设置 `CODEWHALE_CONFIG_PATH`,并传入临时本地连接凭据。不会覆盖用户原有配置,也不会继承原配置中的自定义运行设置。关闭 Codewhale 后清理本地监听和临时配置。适配器不重试模型 POST;Codewhale 自身的重试策略仍由宿主管理。 + +可选环境变量: + +| 变量 | 用途 | +| --- | --- | +| `LMM_ISSUER` | 默认 `https://api.lmm.best`;也可用 `--issuer` | +| `LMM_CODEWHALE_HOME` | 独立凭据目录,默认 `$CODEWHALE_HOME/lmm-provider` 或 `~/.codewhale/lmm-provider` | +| `LMM_CODEWHALE_BIN` | 官方 Codewhale 可执行文件路径 | + +Termux 优先用 `termux-open-url` 打开浏览器。`login --no-browser` 仅输出授权 URL,仍需浏览器能访问当前机器的回环回调;这不是 device-code 登录,不能直接解决远程 SSH 的浏览器回调问题。 + +## 可选的 Codewhale 插件说明 + +在 Codewhale 会话中执行: + +```text +/plugin install ./packages/codewhale-lmm-provider +/plugin validate codewhale-lmm-provider +/plugin enable codewhale-lmm-provider +``` + +按 Codewhale 显示的内容与权限哈希自行审查、信任,再启用。这里安装的是帮助 skill;它不会自动运行登录,也不能替代 `codewhale-lmm run`。不写入或绕过宿主的信任记录。 + +## 服务端接入 + +父项目必须先部署 `lmm-codewhale` 客户端注册补丁。该客户端使用授权码 + PKCE S256,独立于 `lmm-pi` / `lmm-dsh`。初始 scope 仅为: + +```text +catalog:read balance:read usage:read models:invoke +``` + +分组权限由用户同意时的服务端快照追加。没有 MCP、市场工具或账户管理权限。授权码、刷新和撤销均绑定此客户端。 + +保留既有 `OAUTH_SERVER_ENABLED`、issuer 和分组白名单门槛,不自动启用或部署生产 OAuth。旧服务端尚未登记新客户端时登录会失败,不能拿 Pi 的 client ID 顶替。 + +## 凭据与故障恢复 + +OAuth access/refresh token 保存在适配器私有目录,不进入 Codewhale 的配置、命令行或环境。凭据文件为 POSIX `0600`,目录为 `0700`;不安全权限会拒绝使用。不同 issuer 不能混用存储目录。相同 OS 用户仍能读取文件;这不是与 Codewhale 进程隔离的系统沙箱。Windows 的独立 ACL 保护尚未实现,请不要将此预览版用于共享 Windows 主机。 + +刷新以跨进程锁串行执行;请求前原子写入 `refresh_pending`,成功后原子替换整对令牌。发生响应丢失或崩溃时停止自动刷新,不重放可能已消费的 refresh token。重新授权前先 `logout`;无法完成远端撤销时,本地凭据会保留。明确使用 `logout --local-only` 只删除本地文件,**不代表服务端授权已撤销**。锁的拥有者已退出时可用 `unlock` 清理;不会抢占活跃进程的锁。 + +本地桥只监听 `127.0.0.1` 随机端口,要求随机 Bearer,拒绝浏览器 Origin、任意上游、未授权分组和不支持的路径。上游只收到 OAuth Bearer、目录给出的 `X-LMM-Group` 与原协议请求;错误不回显服务端响应体或凭据,断开客户端时取消流式请求。 + +## 验证 + +```sh +npm test +npm run check +npm run pack:check +``` + +本地 Linux / Node.js 22.16.0 已执行 33 项测试,全部通过,包括真实 HTTP 回环 PKCE、跨进程刷新互斥、崩溃日志、撤销失败保留、分组隔离、SSE 与取消、临时配置清理,以及模拟宿主进程调用本地桥。模拟宿主不是官方 Codewhale 二进制;尚未完成真实生产授权、Codewhale TUI、账单核对或 Windows/Termux 实机验收。Go 注册测试随父项目提交,需在父项目依赖可用的环境运行。 + +## 独立仓库与子模块 + +当前交付暂存于父项目的普通目录中,尚未创建远端独立仓库,也没有向 `.gitmodules` 写入失效地址。 + +在本 PR 合并后的干净父项目克隆中,使用已登录且可建组织仓库的 GitHub CLI 执行: + +```sh +node packages/codewhale-lmm-provider/scripts/publish-subproject.mjs +``` + +该命令创建公开仓库 `TokenNotIncluded/codewhale-lmm-provider` 并推送已提交源码,然后在独立 worktree 中将父项目目录替换为真实 Git submodule,推送分支并打开 PR。不会删除原工作区源码、覆盖已有远端仓库或自动合并。远端创建成功而后续失败时会停止并报告,不会假装回滚远端。 + +## 接口依据 + +- https://github.com/Hmbown/Codewhale/blob/b367f6248715510cb5d527e57e4e352db14cb0cd/docs/PLUGIN_BUNDLES.md +- https://github.com/Hmbown/Codewhale/blob/b367f6248715510cb5d527e57e4e352db14cb0cd/docs/CONFIGURATION.md +- https://github.com/TokenNotIncluded/api.lmm.best/blob/main/apps/api-go/service/oauth_contract.md + +AGPL-3.0-only. 本项目不隶属于 Codewhale。 diff --git a/packages/codewhale-lmm-provider/package.json b/packages/codewhale-lmm-provider/package.json new file mode 100644 index 000000000..281eee80e --- /dev/null +++ b/packages/codewhale-lmm-provider/package.json @@ -0,0 +1,15 @@ +{ + "name": "@tokennotincluded/codewhale-lmm-provider", + "version": "0.1.0-alpha.1", + "description": "LMM OAuth companion adapter and guidance bundle for Codewhale", + "type": "module", + "license": "AGPL-3.0-only", + "engines": { "node": ">=22" }, + "bin": { "codewhale-lmm": "./src/cli.mjs" }, + "files": ["src", "skills", "plugin.json", "README.md", "LICENSE"], + "scripts": { + "test": "node --test test/*.test.mjs", + "check": "node --check src/oauth.mjs && node --check src/session.mjs && node --check src/provider.mjs && node --check src/cli.mjs", + "pack:check": "npm pack --dry-run --ignore-scripts" + } +} diff --git a/packages/codewhale-lmm-provider/plugin.json b/packages/codewhale-lmm-provider/plugin.json new file mode 100644 index 000000000..dba0f920c --- /dev/null +++ b/packages/codewhale-lmm-provider/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "codewhale-lmm-provider", + "version": "0.1.0-alpha.1", + "description": "LMM OAuth adapter usage and troubleshooting. Provider execution uses the companion codewhale-lmm CLI.", + "license": "AGPL-3.0-only" +} diff --git a/packages/codewhale-lmm-provider/scripts/publish-subproject.mjs b/packages/codewhale-lmm-provider/scripts/publish-subproject.mjs new file mode 100755 index 000000000..89df79ead --- /dev/null +++ b/packages/codewhale-lmm-provider/scripts/publish-subproject.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Explicit maintainer operation: never called by npm installation or the plugin. +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, copyFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repo = 'TokenNotIncluded/codewhale-lmm-provider'; +const parentRepo = 'TokenNotIncluded/api.lmm.best'; +const subpath = 'packages/codewhale-lmm-provider'; +const source = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const run = (cmd, args, cwd, capture = false) => execFileSync(cmd, args, { + cwd, encoding: 'utf8', stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', shell: false, +}); +let temporary, worktree, parent, created = false, complete = false; +try { + parent = run('git', ['rev-parse', '--show-toplevel'], source, true).trim(); + if (resolve(parent, subpath) !== source) throw new Error('Run from the staged package in the parent repository.'); + if (run('git', ['status', '--porcelain'], parent, true).trim()) throw new Error('Parent checkout must be clean.'); + const remote = run('git', ['remote', 'get-url', 'origin'], parent, true).trim(); + if (!/^(https:\/\/github\.com\/|git@github\.com:)TokenNotIncluded\/api\.lmm\.best(?:\.git)?$/.test(remote)) throw new Error('Origin is not the expected parent repository.'); + run('gh', ['auth', 'status'], parent); + const defaultBranch = run('gh', ['repo', 'view', parentRepo, '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name'], parent, true).trim(); + const names = JSON.parse(run('gh', ['api', '--paginate', '--slurp', 'orgs/TokenNotIncluded/repos?per_page=100&type=all'], parent, true)).flat(); + if (names.some(item => item.name.toLowerCase() === 'codewhale-lmm-provider')) throw new Error('Destination already exists; no remote or source will be overwritten.'); + temporary = mkdtempSync(join(tmpdir(), 'lmm-codewhale-publish-')); + const independent = join(temporary, 'repository'); + mkdirSync(independent); + const files = run('git', ['ls-files', '-z', '--', subpath], parent, true).split('\0').filter(Boolean); + if (!files.some(file => file === `${subpath}/src/cli.mjs`)) throw new Error('Package source is not committed as ordinary files.'); + for (const file of files) { + const destination = join(independent, file.slice(subpath.length + 1)); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(join(parent, file), destination); + } + run('git', ['init', '-b', 'main'], independent); + run('git', ['add', '.'], independent); + run('git', ['commit', '-m', 'feat: add Codewhale LMM OAuth companion adapter'], independent); + // gh creation fails rather than replacing an existing repository; the prior list is diagnostic. + run('gh', ['repo', 'create', repo, '--public', '--description', 'LMM OAuth companion adapter for Codewhale'], independent); + created = true; + run('git', ['remote', 'add', 'origin', `https://github.com/${repo}.git`], independent); + run('git', ['push', '-u', 'origin', 'main'], independent); + const sha = run('git', ['rev-parse', 'HEAD'], independent, true).trim(); + run('git', ['fetch', 'origin', defaultBranch], parent); + const branch = `chore/codewhale-submodule-${Date.now()}`; + worktree = join(temporary, 'parent'); + run('git', ['worktree', 'add', '-b', branch, worktree, `origin/${defaultBranch}`], parent); + if (!existsSync(join(worktree, subpath, 'src', 'cli.mjs'))) throw new Error('Merge the adapter source PR before converting it into a submodule.'); + run('git', ['rm', '-r', '--', subpath], worktree); + run('git', ['submodule', 'add', `https://github.com/${repo}.git`, subpath], worktree); + run('git', ['checkout', '--detach', sha], join(worktree, subpath)); + run('git', ['add', '.gitmodules', subpath], worktree); + run('git', ['commit', '-m', 'chore: extract Codewhale provider to independent submodule'], worktree); + run('git', ['push', '-u', 'origin', branch], worktree); + run('gh', ['pr', 'create', '--repo', parentRepo, '--base', defaultBranch, '--head', branch, + '--title', 'chore: add Codewhale provider as an independent submodule', + '--body', `Extracts the committed adapter to ${repo} and pins submodule ${subpath} to ${sha}. No Pi/DSH submodule changes. Review before merging.`], worktree); + complete = true; + console.log(`Published ${repo}; submodule conversion is in the new parent PR.`); +} catch (error) { + console.error(error.message); + if (created) console.error(`The remote ${repo} was created. It was NOT deleted or rolled back.`); + if (temporary) console.error(`Recovery files remain at ${temporary}`); + process.exitCode = 1; +} finally { + if (complete) { + if (worktree) run('git', ['worktree', 'remove', '--force', worktree], parent); + if (temporary) rmSync(temporary, { recursive: true, force: true }); + } +} diff --git a/packages/codewhale-lmm-provider/skills/lmm/SKILL.md b/packages/codewhale-lmm-provider/skills/lmm/SKILL.md new file mode 100644 index 000000000..a1bc2e535 --- /dev/null +++ b/packages/codewhale-lmm-provider/skills/lmm/SKILL.md @@ -0,0 +1,27 @@ +--- +name: lmm +description: Help the user sign in to LMM and launch Codewhale using the OAuth companion adapter. +--- + +Use `codewhale-lmm` for the LMM integration. This bundle does not register a +native provider-auth hook. Do not claim `/login LMM` works inside Codewhale. + +The user signs in from their own terminal with `codewhale-lmm login`, approves +in their browser, then lists `codewhale-lmm models` and launches +`codewhale-lmm run --model `. Do not request credentials, +authorization codes, session files or callback URLs in the conversation. + +`codewhale-lmm status` reads local status without refreshing. `balance` and +`usage` make read-only LMM requests. `logout` revokes the authorization before +removing local credentials. `logout --local-only` deletes only local storage; +it must not be presented as server revocation. + +Only offer model/group IDs returned by `models`. Do not invent model prices, +capabilities, group names or context windows. The current named custom-provider +route supports Chat Completions, not Responses-only or Messages-only models. +OAuth refresh tokens and LMM access tokens stay in the companion process, not +Codewhale's model configuration. Never print or inspect session.json. + +If rotation is interrupted, do not retry the old refresh token. Advise logout +and a new login. After a crashed process, `unlock` refuses to remove a live +process's lock. Do not bypass Codewhale's plugin review/trust requirements. diff --git a/packages/codewhale-lmm-provider/src/cli.mjs b/packages/codewhale-lmm-provider/src/cli.mjs new file mode 100755 index 000000000..28e397404 --- /dev/null +++ b/packages/codewhale-lmm-provider/src/cli.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util'; +import { spawn } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { OAuth, check, safeMessage } from './oauth.mjs'; +import { SessionStore } from './session.mjs'; +import { catalog, runCodewhale } from './provider.mjs'; + +const help = `LMM OAuth adapter for Codewhale (Node.js 22+)\n\n codewhale-lmm login [--no-browser]\n codewhale-lmm models\n codewhale-lmm run --model [-- ]\n codewhale-lmm status | balance | usage\n codewhale-lmm logout [--local-only]\n codewhale-lmm unlock\n\nOptions: --issuer \nEnvironment: LMM_ISSUER, LMM_CODEWHALE_HOME, LMM_CODEWHALE_BIN\n\nThis is a companion adapter, not a native /login provider hook.\nThe current custom-provider route supports Chat Completions models only.\n`; + +export async function openBrowser(url, noBrowser = false) { + console.error(`Approve LMM in your browser:\n${url}\n`); + if (noBrowser) return; + const [command, args] = process.platform === 'darwin' ? ['open', [url]] + : process.platform === 'win32' ? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]] + : process.env.TERMUX_VERSION || process.env.PREFIX?.includes('com.termux') ? ['termux-open-url', [url]] + : ['xdg-open', [url]]; + // Browser opening is best effort. The printed URL remains usable without a GUI opener. + await new Promise(resolve => { + const child = spawn(command, args, { stdio: 'ignore', shell: false }); + child.once('error', resolve); + child.once('spawn', () => { child.unref(); resolve(); }); + }); +} + +export async function main(argv = process.argv.slice(2), signal) { + const split = argv.indexOf('--'); + const own = split < 0 ? argv : argv.slice(0, split); + const forwarded = split < 0 ? [] : argv.slice(split + 1); + const { values, positionals } = parseArgs({ args: own, allowPositionals: true, options: { + issuer: { type: 'string' }, model: { type: 'string' }, 'no-browser': { type: 'boolean' }, + 'local-only': { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, + } }); + check(positionals.length <= 1, 'Unexpected arguments. Use -- before Codewhale arguments.'); + const command = values.help ? 'help' : positionals[0] || 'help'; + check(['help', 'login', 'logout', 'models', 'status', 'balance', 'usage', 'run', 'unlock'].includes(command), 'Unknown command. Run codewhale-lmm --help.'); + check(!values['local-only'] || command === 'logout', '--local-only is only valid with logout.'); + check(!values['no-browser'] || command === 'login', '--no-browser is only valid with login.'); + check(!values.model || command === 'run', '--model is only valid with run.'); + check(!forwarded.length || command === 'run', 'Only run accepts Codewhale arguments.'); + check(!forwarded.some(arg => /^--(?:provider|model|base-url|api-key|config|config-path)(?:=|$)/.test(arg)), 'Do not override provider/model/credentials/config after --; choose the LMM model with --model.'); + if (command === 'help') { console.log(help); return 0; } + const store = new SessionStore(new OAuth(values.issuer || process.env.LMM_ISSUER)); + const output = value => console.log(JSON.stringify(value, null, 2)); + switch (command) { + case 'login': + await store.login(url => openBrowser(url, values['no-browser']), AbortSignal.any([AbortSignal.timeout(180_000), ...(signal ? [signal] : [])])); + console.log('LMM login saved. Run codewhale-lmm models, then run --model .'); + break; + case 'logout': + await store.logout(values['local-only'], signal); + console.log(values['local-only'] ? 'Local credentials deleted; server authorization was NOT revoked.' : 'LMM authorization revoked and local credentials deleted.'); + break; + case 'status': output(await store.status(signal)); break; + case 'models': { + const { models } = await catalog(store, signal); + output(models.map(({ id, name, group, upstream_model, pricing }) => ({ id, name, group, upstream_model, pricing }))); + break; + } + case 'balance': case 'usage': { + const session = await store.access(signal); + output(await store.oauth.request(command === 'balance' ? '/api/oauth2/balance' : '/api/oauth2/usage/activity', { access: session.access, signal })); + break; + } + case 'run': return runCodewhale(store, values.model, forwarded, { signal, binary: process.env.LMM_CODEWHALE_BIN || 'codewhale' }); + case 'unlock': await store.unlock(); console.log('No stale LMM session lock remains.'); break; + } + return 0; +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const controller = new AbortController(); + const stop = () => controller.abort(); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + try { process.exitCode = await main(undefined, controller.signal); } + catch (error) { console.error(safeMessage(error)); process.exitCode = controller.signal.aborted ? 130 : 1; } + finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); } +} diff --git a/packages/codewhale-lmm-provider/src/oauth.mjs b/packages/codewhale-lmm-provider/src/oauth.mjs new file mode 100644 index 000000000..2847579ff --- /dev/null +++ b/packages/codewhale-lmm-provider/src/oauth.mjs @@ -0,0 +1,215 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { createServer } from 'node:http'; + +export const CLIENT_ID = 'lmm-codewhale'; +export const SCOPES = ['catalog:read', 'balance:read', 'usage:read', 'models:invoke']; +export const CALLBACK_PATH = '/oauth/lmm/callback'; +export class LmmError extends Error {} +export function check(condition, message = 'Invalid LMM response.') { + if (!condition) throw new LmmError(message); +} +export function safeMessage(error) { + return error instanceof LmmError ? error.message : 'LMM operation failed. No credential was printed.'; +} +export function text(value, max = 4096) { + check(typeof value === 'string' && value.length > 0 && value.length <= max && !/[\p{Cc}\p{Cf}]/u.test(value)); + return value; +} +export function issuerURL(value) { + let url; + try { url = new URL(value); } catch { throw new LmmError('Invalid LMM issuer URL.'); } + check(url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === '127.0.0.1'), 'LMM requires HTTPS (127.0.0.1 HTTP is allowed for local tests).'); + check(!url.username && !url.password && !url.search && !url.hash && url.pathname === '/', 'Use an issuer origin, without a path, credentials, query or fragment.'); + return url.origin; +} +export function token(value) { + check(typeof value === 'string' && /^lmm_at_[A-Za-z0-9_-]{1,4089}$/.test(value), 'Expected an LMM OAuth access token, not an API key.'); + return value; +} +export function scopes(value) { + const list = text(value, 16384).split(' '); + check(new Set(list).size === list.length); + for (const scope of list) { + if (SCOPES.includes(scope)) continue; + check(scope.startsWith('group:')); + canonicalID(scope.slice(6)); + } + return list; +} +export function canonicalID(value) { + text(value); + check(/^[A-Za-z0-9_-]+$/.test(value)); + const decoded = Buffer.from(value, 'base64url').toString('utf8'); + text(decoded); + check(Buffer.from(decoded).toString('base64url') === value); + return value; +} +export async function boundedBody(body, limit = 2_000_000) { + let size = 0; + const parts = []; + for await (const part of body) { + const bytes = Buffer.from(part); + size += bytes.length; + check(size <= limit, 'Response or request exceeds the size limit.'); + parts.push(bytes); + } + return Buffer.concat(parts); +} +export function parseJSON(bytes) { + try { return JSON.parse(bytes.toString('utf8')); } catch { throw new LmmError('Invalid JSON response or request.'); } +} +export function object(value) { + check(value && typeof value === 'object' && !Array.isArray(value)); + return value; +} + +// The listener exists before the browser is opened. Bad callbacks never consume a login. +export async function listenCallback(issuer, state, signal) { + signal?.throwIfAborted(); + let resolve, reject; + const code = new Promise((yes, no) => { resolve = yes; reject = no; }); + code.catch(() => {}); + let settled = false, redirectUri; + const finish = (error, value) => { + if (settled) return; + settled = true; + error ? reject(error) : resolve(value); + }; + const server = createServer({ maxHeaderSize: 8192 }, (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'"); + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + try { + check(!settled && req.method === 'GET' && req.headers.host === new URL(redirectUri).host && !req.headers.origin); + check(req.url?.startsWith('/') && !req.url.startsWith('//') && req.url.length < 8192); + const url = new URL(req.url, redirectUri); + check(url.origin === new URL(redirectUri).origin && url.pathname === CALLBACK_PATH && !url.hash); + const params = url.searchParams; + for (const key of ['state', 'iss']) check(params.getAll(key).length === 1); + check(params.get('state') === state && params.get('iss') === issuer); + check(params.getAll('code').length + params.getAll('error').length === 1); + if (params.has('error')) { + res.writeHead(400).end('Authorization was declined. Return to your terminal.'); + finish(new LmmError('LMM authorization was declined. Run login again.')); + return; + } + const value = text(params.get('code')); + res.end('LMM login approved. Return to Codewhale.'); + finish(null, value); + } catch { res.writeHead(400).end('Invalid OAuth callback.'); } + }); + server.requestTimeout = 5000; + server.headersTimeout = 5000; + const abort = () => { + finish(new LmmError('LMM login cancelled or timed out. Run login again.')); + server.close(); + server.closeAllConnections(); + }; + server.on('error', () => finish(new LmmError('Cannot start the local OAuth callback listener.'))); + await new Promise((yes, no) => { + server.once('error', no); + server.listen(0, '127.0.0.1', () => { server.removeListener('error', no); yes(); }); + }); + redirectUri = `http://127.0.0.1:${server.address().port}${CALLBACK_PATH}`; + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + return { + code, redirectUri, + close() { + signal?.removeEventListener('abort', abort); + finish(new LmmError('LMM login was closed.')); + server.close(); + server.closeAllConnections(); + }, + }; +} + +export class OAuth { + constructor(issuer = 'https://api.lmm.best', fetchImpl = fetch) { + this.issuer = issuerURL(issuer); + this.resource = `${this.issuer}/api/oauth2`; + this.fetch = fetchImpl; + } + async request(path, { access, form, signal } = {}) { + check(path.startsWith('/') && !path.startsWith('//')); + const response = await this.fetch(`${this.issuer}${path}`, { + method: form ? 'POST' : 'GET', redirect: 'error', + headers: { + Accept: 'application/json', + ...(access ? { Authorization: `Bearer ${token(access)}` } : {}), + ...(form ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}), + }, + body: form ? new URLSearchParams(form) : undefined, + signal: AbortSignal.any([AbortSignal.timeout(20_000), ...(signal ? [signal] : [])]), + }); + if (!response.ok) { + await response.body?.cancel(); + throw new LmmError(response.status === 401 ? 'LMM authorization expired or was revoked. Run login again.' : `LMM request rejected (HTTP ${response.status}).`); + } + const bytes = response.body ? await boundedBody(response.body) : Buffer.alloc(0); + return bytes.length ? object(parseJSON(bytes)) : {}; + } + async discover(signal) { + const [auth, resource] = await Promise.all([ + this.request('/.well-known/oauth-authorization-server', { signal }), + this.request('/.well-known/oauth-protected-resource/api/oauth2', { signal }), + ]); + check(auth.issuer === this.issuer && auth.authorization_endpoint === `${this.resource}/authorize` && + auth.token_endpoint === `${this.resource}/token` && auth.revocation_endpoint === `${this.resource}/revoke`, 'OAuth discovery does not match the configured LMM issuer.'); + check(auth.authorization_response_iss_parameter_supported === true && + Array.isArray(auth.code_challenge_methods_supported) && auth.code_challenge_methods_supported.includes('S256') && + Array.isArray(auth.response_types_supported) && auth.response_types_supported.includes('code'), 'LMM must support PKCE S256 and issuer-bound authorization responses.'); + check(resource.resource === this.resource && Array.isArray(resource.authorization_servers) && + resource.authorization_servers.length === 1 && resource.authorization_servers[0] === this.issuer, 'Protected-resource metadata does not match LMM.'); + } + parseTokens(response, startedAt, previous) { + check(response.token_type === 'Bearer'); + const access = token(response.access_token); + const refresh = text(response.refresh_token); + check(!/\s/.test(refresh) && refresh !== access); + check(Number.isSafeInteger(response.expires_in) && response.expires_in > 0 && response.expires_in <= 86400); + const scope = response.scope ?? previous?.scope; + const granted = scopes(scope); + if (previous) { + const old = new Set(scopes(previous.scope)); + check(granted.every(s => old.has(s)) && refresh !== previous.refresh, 'Refresh must rotate the token and must not widen scopes. Run login again.'); + } else { + check(SCOPES.every(s => granted.includes(s)), 'LMM did not grant the required application permissions.'); + } + return { version: 1, client_id: CLIENT_ID, issuer: this.issuer, resource: this.resource, access, refresh, scope, expires: startedAt + response.expires_in * 1000, refresh_pending: false }; + } + async login(openBrowser, signal = AbortSignal.timeout(180_000)) { + await this.discover(signal); + const verifier = randomBytes(32).toString('base64url'); + const state = randomBytes(32).toString('base64url'); + const callback = await listenCallback(this.issuer, state, signal); + try { + const url = new URL(`${this.resource}/authorize`); + url.search = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: callback.redirectUri, + scope: SCOPES.join(' '), resource: this.resource, state, code_challenge_method: 'S256', + code_challenge: createHash('sha256').update(verifier).digest('base64url') }).toString(); + await openBrowser(url.href); + const code = await callback.code; + callback.close(); + const startedAt = Date.now(); + const response = await this.request('/api/oauth2/token', { signal, form: { + grant_type: 'authorization_code', client_id: CLIENT_ID, code, redirect_uri: callback.redirectUri, + code_verifier: verifier, resource: this.resource, + } }); + return this.parseTokens(response, startedAt); + } finally { callback.close(); } + } + async refresh(session, signal) { + const startedAt = Date.now(); + const response = await this.request('/api/oauth2/token', { signal, form: { + grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token: session.refresh, resource: this.resource, + } }); + return this.parseTokens(response, startedAt, session); + } + async revoke(session, signal) { + await this.request('/api/oauth2/revoke', { signal, form: { + client_id: CLIENT_ID, token: token(session.access), token_type_hint: 'access_token', + } }); + } +} diff --git a/packages/codewhale-lmm-provider/src/provider.mjs b/packages/codewhale-lmm-provider/src/provider.mjs new file mode 100644 index 000000000..a819cc323 --- /dev/null +++ b/packages/codewhale-lmm-provider/src/provider.mjs @@ -0,0 +1,183 @@ +import { createServer } from 'node:http'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { boundedBody, canonicalID, check, LmmError, object, parseJSON, scopes, text } from './oauth.mjs'; + +export function parseCatalog(value, session) { + object(value); + check(value.schema_version === 1 && value.resource === session.resource && Array.isArray(value.groups) && Array.isArray(value.models)); + check(value.groups.length <= 1000 && value.models.length <= 10000); + const allowed = new Set(scopes(session.scope)); + const groups = new Map(); + for (const raw of value.groups) { + const group = object(raw), id = canonicalID(group.id); + check(!groups.has(id) && id === Buffer.from(text(group.name)).toString('base64url') && group.scope === `group:${id}`); + check(allowed.has(group.scope), 'Catalog contains a group outside this login grant.'); + groups.set(id, group); + } + const models = [], seen = new Set(); + for (const raw of value.models) { + const model = object(raw), group = groups.get(model.group_id); + check(group && model.group === group.name); + text(model.upstream_model); text(model.name); + check(model.id === `lmm:${group.id}:${Buffer.from(model.upstream_model).toString('base64url')}` && !seen.has(model.id)); + seen.add(model.id); + check(Array.isArray(model.apis) && model.apis.every(api => typeof api === 'string')); + // Codewhale's documented named custom-provider interface is Chat Completions. + // Do not silently route a Responses/Messages-only model through a different wire API. + if (model.apis.includes('openai-completions')) models.push(model); + } + return models; +} +export async function catalog(store, signal) { + const session = await store.access(signal); + const raw = await store.oauth.request('/api/oauth2/catalog', { access: session.access, signal }); + return { session, models: parseCatalog(raw, session) }; +} +export function selectModel(models, id) { + if (id) { + const selected = models.find(model => model.id === id); + check(selected, 'That model/group is not available on the Chat Completions route. Run models and use an exact ID.'); + return selected; + } + check(models.length === 1, 'Choose an explicit model/group with --model ; run codewhale-lmm models to list IDs.'); + return models[0]; +} +function equalSecret(value, expected) { + if (typeof value !== 'string') return false; + const received = Buffer.from(value), wanted = Buffer.from(expected); + return received.length === wanted.length && timingSafeEqual(received, wanted); +} +function json(res, status, data) { + res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(data)); +} + +export async function startBridge(store, { timeoutMs = 600_000 } = {}) { + const secret = randomBytes(32).toString('base64url'); + const active = new Set(); + let authority; + const server = createServer({ maxHeaderSize: 16384 }, async (req, res) => { + // No cookie auth, no CORS, no public listener, no caller-selected upstream. + if (req.headers.host !== authority || req.headers.origin || !equalSecret(req.headers.authorization, `Bearer ${secret}`)) { + json(res, 401, { error: { message: 'Local bridge authorization required.' } }); + return; + } + const list = req.method === 'GET' && req.url === '/v1/models'; + const invoke = req.method === 'POST' && req.url === '/v1/chat/completions'; + if (!list && !invoke) { json(res, 404, { error: { message: 'Unsupported LMM bridge endpoint.' } }); return; } + const controller = new AbortController(); + const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]); + active.add(controller); + const disconnect = () => controller.abort(); + res.once('close', disconnect); + req.once('aborted', disconnect); + try { + let body; + if (invoke) { + check(req.headers['content-type']?.split(';')[0].trim().toLowerCase() === 'application/json', 'Expected a JSON model request.'); + check(!req.headers['content-encoding'], 'Compressed requests are not supported.'); + body = object(parseJSON(await boundedBody(req, 16_000_000))); + } + const { session, models } = await catalog(store, signal); + if (list) { + json(res, 200, { object: 'list', data: models.map(model => ({ id: model.id, object: 'model', owned_by: 'lmm' })) }); + return; + } + check(scopes(session.scope).includes('models:invoke'), 'This login no longer permits model invocation.'); + const model = selectModel(models, body.model); + check(body.model === model.id, 'Each request must name an exact model/group ID.'); + // Re-read grants for every request. There is no cross-group fallback and no POST retry. + const upstream = await store.oauth.fetch(`${store.oauth.issuer}/v1/chat/completions`, { + method: 'POST', redirect: 'error', signal, + headers: { Authorization: `Bearer ${session.access}`, 'X-LMM-Group': model.group_id, + 'Content-Type': 'application/json', Accept: 'text/event-stream, application/json' }, + body: JSON.stringify({ ...body, model: model.upstream_model }), + }); + if (!upstream.ok) { + await upstream.body?.cancel(); + json(res, upstream.status, { error: { message: `LMM inference rejected (HTTP ${upstream.status}); no request was replayed by the adapter.` } }); + return; + } + check(upstream.body, 'LMM returned an empty inference response.'); + const contentType = upstream.headers.get('content-type') || ''; + check(/^(text\/event-stream|application\/json)(;|$)/i.test(contentType), 'LMM returned an unsupported inference content type.'); + res.writeHead(upstream.status, { 'Content-Type': contentType, 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' }); + res.flushHeaders(); + await pipeline(Readable.fromWeb(upstream.body), res, { signal }); + } catch (error) { + // Never echo server bodies, OAuth codes, credentials, or user prompts in diagnostics. + if (!res.destroyed && !res.headersSent) json(res, 502, { error: { message: error instanceof LmmError ? error.message : 'LMM bridge request failed; it was not replayed.' } }); + else if (!res.destroyed) res.destroy(); + } finally { + controller.abort(); + active.delete(controller); + res.removeListener('close', disconnect); + req.removeListener('aborted', disconnect); + } + }); + server.requestTimeout = 30_000; + server.headersTimeout = 10_000; + await new Promise((yes, no) => { + server.once('error', no); + server.listen(0, '127.0.0.1', () => { server.removeListener('error', no); yes(); }); + }); + authority = `127.0.0.1:${server.address().port}`; + return { secret, baseURL: `http://${authority}/v1`, async close() { + for (const controller of active) controller.abort(); + const stopped = new Promise(resolve => server.close(resolve)); + server.closeAllConnections(); + await stopped; + } }; +} + +export function configuration(baseURL, model) { + // JSON quoted strings are a compatible subset of TOML basic strings here. + text(model); + return `provider = "lmm"\ndefault_text_model = ${JSON.stringify(model)}\n\n[providers.lmm]\nkind = "openai-compatible"\nbase_url = ${JSON.stringify(baseURL)}\napi_key_env = "LMM_CODEWHALE_BRIDGE_TOKEN"\nmodel = ${JSON.stringify(model)}\n`; +} +export function childEnvironment(configPath, secret, source = process.env) { + const env = { ...source }; + for (const key of Object.keys(env)) { + // Keep ambient route overrides from replacing the selected, credential-bound route. + if (/^(CODEWHALE|DEEPSEEK|OPENAI)_(API_KEY|BASE_URL|MODEL|DEFAULT_TEXT_MODEL|PROVIDER|HTTP_HEADERS|CONFIG_PATH|CONFIG_FILE)$/.test(key)) delete env[key]; + } + env.CODEWHALE_CONFIG_PATH = configPath; + env.LMM_CODEWHALE_BRIDGE_TOKEN = secret; + return env; +} +export async function runCodewhale(store, modelID, args = [], { binary = 'codewhale', signal } = {}) { + const selected = selectModel((await catalog(store, signal)).models, modelID); + const bridge = await startBridge(store); + let directory; + try { + directory = await mkdtemp(join(store.directory, 'run-')); + const configPath = join(directory, 'config.toml'); + await writeFile(configPath, configuration(bridge.baseURL, selected.id), { mode: 0o600 }); + // Only an ephemeral local capability enters the child. LMM OAuth tokens stay in the adapter. + const child = spawn(binary, ['--provider', 'lmm', '--model', selected.id, ...args], { + env: childEnvironment(configPath, bridge.secret), stdio: 'inherit', shell: false, + }); + let killTimer; + const terminate = () => { + child.kill('SIGTERM'); + killTimer = setTimeout(() => child.kill('SIGKILL'), 3000); + killTimer.unref(); + }; + signal?.addEventListener('abort', terminate, { once: true }); + if (signal?.aborted) terminate(); + try { + return await new Promise((resolve, reject) => { + child.once('error', () => reject(new LmmError('Cannot launch codewhale. Install the official binary or set LMM_CODEWHALE_BIN.'))); + child.once('exit', (code, exitSignal) => resolve(code ?? (exitSignal === 'SIGINT' ? 130 : 1))); + }); + } finally { signal?.removeEventListener('abort', terminate); clearTimeout(killTimer); } + } finally { + await bridge.close(); + if (directory) await rm(directory, { recursive: true, force: true }); + } +} diff --git a/packages/codewhale-lmm-provider/src/session.mjs b/packages/codewhale-lmm-provider/src/session.mjs new file mode 100644 index 000000000..710cdd6ad --- /dev/null +++ b/packages/codewhale-lmm-provider/src/session.mjs @@ -0,0 +1,147 @@ +import { constants } from 'node:fs'; +import { mkdir, open, rename, rm, lstat, readFile, realpath } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import { CLIENT_ID, LmmError, check, object, scopes, text, token } from './oauth.mjs'; + +export function defaultHome(env = process.env) { + return resolve(env.LMM_CODEWHALE_HOME || join(env.CODEWHALE_HOME || join(homedir(), '.codewhale'), 'lmm-provider')); +} +export class SessionStore { + constructor(oauth, directory = defaultHome()) { + this.oauth = oauth; + this.directory = resolve(directory); + this.path = join(this.directory, 'session.json'); + this.lockPath = join(this.directory, 'session.lock'); + } + async prepare() { + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + const stat = await lstat(this.directory); + check(stat.isDirectory() && !stat.isSymbolicLink(), 'LMM credential directory must be a real private directory.'); + if (process.platform !== 'win32') { + check(stat.uid === process.getuid() && (stat.mode & 0o077) === 0, 'LMM credential directory must be owned by you with mode 0700.'); + } + // Canonicalize system aliases (e.g. macOS /var) once; reject a symlink leaf above. + this.directory = await realpath(this.directory); + this.path = join(this.directory, 'session.json'); + this.lockPath = join(this.directory, 'session.lock'); + } + async lock(fn, signal) { + await this.prepare(); + const until = Date.now() + 25_000; + let handle; + while (!handle) { + signal?.throwIfAborted(); + try { handle = await open(this.lockPath, 'wx', 0o600); } + catch (error) { + if (error.code !== 'EEXIST') throw error; + check(Date.now() < until, 'LMM session is locked. Close other login processes; after a crash run codewhale-lmm unlock.'); + await delay(40, undefined, { signal }); + } + } + try { + await handle.writeFile(JSON.stringify({ pid: process.pid, id: randomUUID() })); + await handle.sync(); + return await fn(); + } finally { + await handle.close(); + await rm(this.lockPath, { force: true }); + } + } + async read() { + let handle; + try { + handle = await open(this.path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)); + const stat = await handle.stat(); + check(stat.isFile() && stat.nlink === 1 && stat.size < 65536, 'Invalid LMM credential file.'); + if (process.platform !== 'win32') check(stat.uid === process.getuid() && (stat.mode & 0o077) === 0, 'LMM credential file must have mode 0600.'); + const value = object(JSON.parse(await handle.readFile('utf8'))); + check(value.version === 1 && value.client_id === CLIENT_ID && value.issuer === this.oauth.issuer && value.resource === this.oauth.resource, 'Stored login belongs to another issuer or client. Use a separate LMM_CODEWHALE_HOME.'); + token(value.access); text(value.refresh); scopes(value.scope); + check(Number.isSafeInteger(value.expires) && value.expires > 0 && typeof value.refresh_pending === 'boolean'); + return value; + } catch (error) { + if (error.code === 'ENOENT') return null; + if (error instanceof LmmError) throw error; + throw new LmmError('Cannot read the LMM credential file. It was not overwritten.'); + } finally { await handle?.close(); } + } + // One atomic record carries BOTH tokens and the rotation journal marker. + async write(value) { + const temp = join(this.directory, `.session-${randomUUID()}.tmp`); + const handle = await open(temp, 'wx', 0o600); + try { + try { + await handle.writeFile(JSON.stringify(value) + '\n'); + await handle.sync(); + } finally { await handle.close(); } + await rename(temp, this.path); + if (process.platform !== 'win32') { + const directory = await open(this.directory, 'r'); + try { await directory.sync(); } finally { await directory.close(); } + } + } finally { await rm(temp, { force: true }); } + } + async login(openBrowser, signal) { + return this.lock(async () => { + // Validate existing state before replacing it; a different issuer needs its own store. + const old = await this.read(); + check(!old, 'Already signed in. Run logout (or logout --local-only) before signing in again.'); + const session = await this.oauth.login(openBrowser, signal); + try { await this.write(session); } + catch (error) { await this.oauth.revoke(session).catch(() => {}); throw error; } + return session; + }, signal); + } + async access(signal) { + return this.lock(async () => { + const session = await this.read(); + check(session, 'Not signed in. Run codewhale-lmm login.'); + check(!session.refresh_pending, 'A previous token rotation did not commit. Run logout, then login; the old refresh token will not be replayed.'); + if (session.expires > Date.now() + 60_000) return session; + // Persist this BEFORE the network operation. Ambiguous failure is not retried. + await this.write({ ...session, refresh_pending: true }); + const replacement = await this.oauth.refresh(session, signal); + await this.write(replacement); + return replacement; + }, signal); + } + async status(signal) { + return this.lock(async () => { + const session = await this.read(); + return session ? { + signed_in: true, issuer: session.issuer, client_id: session.client_id, + expires_at: new Date(session.expires).toISOString(), expired: session.expires <= Date.now(), + refresh_pending: session.refresh_pending, scopes: scopes(session.scope), + } : { signed_in: false, issuer: this.oauth.issuer }; + }, signal); + } + async logout(localOnly = false, signal) { + return this.lock(async () => { + const session = await this.read(); + if (session && !localOnly) await this.oauth.revoke(session, signal); + // Failed revocation preserves the credential so the user can retry explicitly. + await rm(this.path, { force: true }); + }, signal); + } + async unlock() { + await this.prepare(); + const before = await lstat(this.lockPath).catch(error => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (!before) return; + check(before.isFile() && !before.isSymbolicLink() && before.size < 256); + let lock; + try { lock = JSON.parse(await readFile(this.lockPath, 'utf8')); } + catch { throw new LmmError('Incomplete lock file. Stop all adapter processes and remove session.lock manually.'); } + check(Number.isSafeInteger(lock.pid) && lock.pid > 0); + try { process.kill(lock.pid, 0); throw new LmmError('The lock owner is still running. Stop that process first.'); } + catch (error) { if (error.code !== 'ESRCH') throw error; } + const after = await lstat(this.lockPath); + check(after.ino === before.ino && after.mtimeMs === before.mtimeMs, 'Lock changed; it was not removed.'); + await rm(this.lockPath); + } +} diff --git a/packages/codewhale-lmm-provider/test/fixture.mjs b/packages/codewhale-lmm-provider/test/fixture.mjs new file mode 100644 index 000000000..1898fd4e6 --- /dev/null +++ b/packages/codewhale-lmm-provider/test/fixture.mjs @@ -0,0 +1,86 @@ +import { createServer } from 'node:http'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { OAuth, SCOPES, CLIENT_ID } from '../src/oauth.mjs'; +import { SessionStore } from '../src/session.mjs'; + +export async function fixture(t) { + const home = await mkdtemp(join(tmpdir(), 'codewhale-lmm-test-')); + const groupID = Buffer.from('default').toString('base64url'); + const modelID = `lmm:${groupID}:${Buffer.from('example-model').toString('base64url')}`; + const scope = [...SCOPES, `group:${groupID}`].join(' '); + const state = { authorizations: new Map(), refreshes: 0, invocations: [], revocations: 0, expires: 3600, + refreshFailure: false, revokeFailure: false, inferenceFailure: false, stream: false, streamClosed: false, grants: scope }; + let issuer; + const json = (res, status, data) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); }; + const tokenResponse = n => ({ token_type: 'Bearer', access_token: `lmm_at_access${n}`, refresh_token: `lmm_rt_refresh${n}`, expires_in: state.expires, scope: state.grants }); + const server = createServer(async (req, res) => { + const url = new URL(req.url, issuer); + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf8'); + if (url.pathname === '/.well-known/oauth-authorization-server') { + json(res, 200, { issuer, authorization_endpoint: `${issuer}/api/oauth2/authorize`, token_endpoint: `${issuer}/api/oauth2/token`, + revocation_endpoint: `${issuer}/api/oauth2/revoke`, code_challenge_methods_supported: ['S256'], response_types_supported: ['code'], authorization_response_iss_parameter_supported: true }); + } else if (url.pathname === '/.well-known/oauth-protected-resource/api/oauth2') { + json(res, 200, { resource: `${issuer}/api/oauth2`, authorization_servers: [issuer] }); + } else if (url.pathname === '/api/oauth2/authorize') { + const params = url.searchParams; + if (params.get('client_id') !== CLIENT_ID || params.get('scope') !== SCOPES.join(' ') || params.get('resource') !== `${issuer}/api/oauth2`) { json(res, 400, {}); return; } + state.lastAuthorization = params; + const code = `test-code-${state.authorizations.size}`; + state.authorizations.set(code, params); + const redirect = new URL(params.get('redirect_uri')); + redirect.search = new URLSearchParams({ code, state: params.get('state'), iss: issuer }).toString(); + res.writeHead(302, { Location: redirect.href }).end(); + } else if (url.pathname === '/api/oauth2/token') { + const body = new URLSearchParams(raw); + if (body.get('client_id') !== CLIENT_ID || body.get('resource') !== `${issuer}/api/oauth2`) { json(res, 400, {}); return; } + if (body.get('grant_type') === 'authorization_code') { + const auth = state.authorizations.get(body.get('code')); + if (!auth || auth.get('redirect_uri') !== body.get('redirect_uri') || auth.get('code_challenge') !== createHash('sha256').update(body.get('code_verifier')).digest('base64url')) { json(res, 400, {}); return; } + state.authorizations.delete(body.get('code')); + json(res, 200, tokenResponse(0)); + } else { + state.refreshes++; + if (state.refreshFailure) { json(res, 503, { error: 'SECRET_MUST_NOT_LEAK' }); return; } + if (body.get('refresh_token') !== `lmm_rt_refresh${state.refreshes - 1}`) { json(res, 400, {}); return; } + await new Promise(resolve => setTimeout(resolve, 20)); + json(res, 200, tokenResponse(state.refreshes)); + } + } else if (url.pathname === '/api/oauth2/catalog') { + if (!req.headers.authorization?.startsWith('Bearer lmm_at_')) { json(res, 401, {}); return; } + json(res, 200, { schema_version: 1, resource: `${issuer}/api/oauth2`, groups: [{ id: groupID, name: 'default', scope: `group:${groupID}` }], + models: [{ id: modelID, group_id: groupID, group: 'default', upstream_model: 'example-model', name: 'Example model', apis: ['openai-completions'], pricing: { input: null, output: null } }] }); + } else if (url.pathname === '/api/oauth2/revoke') { + if (state.revokeFailure) { json(res, 503, { error: 'SECRET_MUST_NOT_LEAK' }); return; } + state.revocations++; + res.writeHead(200).end(); + } else if (url.pathname === '/v1/chat/completions') { + state.invocations.push({ headers: req.headers, body: JSON.parse(raw) }); + if (state.inferenceFailure) { json(res, 429, { error: 'SECRET_MUST_NOT_LEAK' }); return; } + if (state.stream) { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write('data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'); + const timer = setInterval(() => res.write(': heartbeat\n\n'), 50); + res.on('close', () => { clearInterval(timer); state.streamClosed = true; }); + } else json(res, 200, { choices: [{ message: { role: 'assistant', content: 'hello' } }] }); + } else json(res, 404, {}); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + issuer = `http://127.0.0.1:${server.address().port}`; + const oauth = new OAuth(issuer), store = new SessionStore(oauth, home); + const approve = async url => { + const response = await fetch(url, { redirect: 'manual' }); + if (response.status !== 302) throw new Error('Mock consent failed'); + await fetch(response.headers.get('location')); + }; + t.after(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + await rm(home, { recursive: true, force: true }); + }); + return { state, issuer, oauth, store, home, approve, modelID, groupID, scope }; +} diff --git a/packages/codewhale-lmm-provider/test/oauth.test.mjs b/packages/codewhale-lmm-provider/test/oauth.test.mjs new file mode 100644 index 000000000..ab8e8c9ee --- /dev/null +++ b/packages/codewhale-lmm-provider/test/oauth.test.mjs @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { OAuth, CLIENT_ID, issuerURL, listenCallback, safeMessage } from '../src/oauth.mjs'; +import { fixture } from './fixture.mjs'; + +test('OAuth authorization-code flow binds client, resource, state, issuer and PKCE', async t => { + const f = await fixture(t); + const session = await f.store.login(f.approve); + assert.equal(session.client_id, CLIENT_ID); + assert.equal(session.access, 'lmm_at_access0'); + assert.equal(session.scope, f.scope); + assert.equal(f.state.lastAuthorization.get('code_challenge_method'), 'S256'); + assert.equal(f.state.lastAuthorization.get('code_challenge').length, 43); + assert.equal(f.state.lastAuthorization.get('state').length, 43); +}); + +test('callback rejects wrong state/issuer and duplicate fields without consuming login', async () => { + const callback = await listenCallback('https://api.lmm.test', 'expected-state', AbortSignal.timeout(3000)); + try { + for (const query of [ + 'code=secret&state=wrong&iss=https://api.lmm.test', + 'code=secret&state=expected-state&iss=https://attacker.test', + 'code=secret&code=second&state=expected-state&iss=https://api.lmm.test', + 'code=secret&state=expected-state&state=second&iss=https://api.lmm.test', + 'code=secret&error=access_denied&state=expected-state&iss=https://api.lmm.test', + ]) { + const response = await fetch(`${callback.redirectUri}?${query}`); + assert.equal(response.status, 400); + assert.equal((await response.text()).includes('secret'), false); + } + const good = new URL(callback.redirectUri); + good.search = new URLSearchParams({ code: 'good-code', state: 'expected-state', iss: 'https://api.lmm.test' }).toString(); + assert.equal((await fetch(good)).status, 200); + assert.equal(await callback.code, 'good-code'); + } finally { callback.close(); } +}); + +test('callback cancellation closes the listener', async () => { + const controller = new AbortController(); + const callback = await listenCallback('https://api.lmm.test', 's', controller.signal); + controller.abort(); + await assert.rejects(callback.code, /cancelled/); + await assert.rejects(fetch(callback.redirectUri)); + callback.close(); +}); + +test('issuer rejects plaintext remote servers and origin confusion', () => { + for (const value of ['http://evil.test', 'https://u:p@api.test', 'https://api.test/path', 'https://api.test/?x=1', 'https://api.test/#x']) assert.throws(() => issuerURL(value)); + assert.equal(issuerURL('https://API.TEST/'), 'https://api.test'); +}); + +test('discovery endpoint mismatch fails before opening a browser', async () => { + let opened = false; + const oauth = new OAuth('https://api.test', async () => new Response(JSON.stringify({ issuer: 'https://evil.test' }))); + await assert.rejects(oauth.login(() => { opened = true; }), /discovery/); + assert.equal(opened, false); +}); + +test('HTTP credentials are not forwarded across redirects', async t => { + const f = await fixture(t); + let options; + const oauth = new OAuth(f.issuer, async (_url, value) => { options = value; return new Response('{}'); }); + await oauth.request('/api/oauth2/balance', { access: 'lmm_at_test' }); + assert.equal(options.redirect, 'error'); + assert.equal(options.headers.Authorization, 'Bearer lmm_at_test'); +}); + +test('token responses cannot widen scope or reuse a refresh token', async t => { + const f = await fixture(t), session = await f.store.login(f.approve); + const response = { token_type: 'Bearer', access_token: 'lmm_at_next', refresh_token: 'lmm_rt_next', expires_in: 3600, scope: `${f.scope} group:b3RoZXI` }; + assert.throws(() => f.oauth.parseTokens(response, Date.now(), session), /widen/); + response.scope = f.scope; response.refresh_token = session.refresh; + assert.throws(() => f.oauth.parseTokens(response, Date.now(), session), /rotate/); + response.refresh_token = 'lmm_rt_next'; delete response.scope; + assert.equal(f.oauth.parseTokens(response, Date.now(), session).scope, session.scope); +}); + +test('unknown exceptions are redacted', () => { + assert.equal(safeMessage(new Error('lmm_at_secret')), 'LMM operation failed. No credential was printed.'); +}); diff --git a/packages/codewhale-lmm-provider/test/provider.test.mjs b/packages/codewhale-lmm-provider/test/provider.test.mjs new file mode 100644 index 000000000..f7c9e24b0 --- /dev/null +++ b/packages/codewhale-lmm-provider/test/provider.test.mjs @@ -0,0 +1,151 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, writeFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fixture } from './fixture.mjs'; +import { catalog, parseCatalog, selectModel, startBridge, configuration, childEnvironment, runCodewhale } from '../src/provider.mjs'; + +async function bridgeFixture(t) { + const f = await fixture(t); + await f.store.login(f.approve); + const bridge = await startBridge(f.store); + t.after(() => bridge.close()); + const request = (path, options = {}) => fetch(bridge.baseURL + path, { + ...options, headers: { Authorization: `Bearer ${bridge.secret}`, ...options.headers }, + }); + const invoke = (body = {}, headers = {}) => request('/chat/completions', { + method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify({ model: f.modelID, messages: [{ role: 'user', content: 'hello' }], ...body }), + }); + return { ...f, bridge, request, invoke }; +} + +test('bridge maps the exact catalog model and group without forwarding caller credentials', async t => { + const f = await bridgeFixture(t); + const response = await f.invoke({}, { 'x-api-key': 'DO_NOT_FORWARD', Cookie: 'DO_NOT_FORWARD' }); + assert.equal(response.status, 200); + assert.equal((await response.json()).choices[0].message.content, 'hello'); + assert.equal(f.state.invocations.length, 1); + const { headers, body } = f.state.invocations[0]; + assert.equal(body.model, 'example-model'); + assert.equal(headers['x-lmm-group'], f.groupID); + assert.equal(headers.authorization, 'Bearer lmm_at_access0'); + assert.equal(headers['x-api-key'], undefined); + assert.equal(headers.cookie, undefined); + assert.ok(!JSON.stringify(headers).includes(f.bridge.secret)); +}); +test('bridge requires its random local capability and rejects browser origins', async t => { + const f = await bridgeFixture(t); + assert.equal((await fetch(f.bridge.baseURL + '/models')).status, 401); + assert.equal((await f.request('/models', { headers: { Origin: 'https://example.invalid' } })).status, 401); + assert.equal((await f.request('/models', { headers: { Authorization: 'Bearer wrong' } })).status, 401); + assert.equal(f.state.invocations.length, 0); +}); +test('only the model-list and Chat Completions paths are exposed', async t => { + const f = await bridgeFixture(t); + for (const path of ['/responses', '/messages', '/models?key=secret', '/../api/oauth2/token']) { + assert.equal((await f.request(path)).status, 404); + } + const response = await f.request('/models'); + assert.deepEqual((await response.json()).data.map(m => m.id), [f.modelID]); +}); +test('unknown or raw upstream model never falls back to a group', async t => { + const f = await bridgeFixture(t); + for (const model of ['example-model', 'lmm:wrong:model', null]) { + assert.equal((await f.invoke({ model })).status, 502); + } + assert.equal(f.state.invocations.length, 0); +}); +test('request content type and compressed payload are rejected before inference', async t => { + const f = await bridgeFixture(t); + assert.equal((await f.invoke({}, { 'Content-Type': 'text/plain' })).status, 502); + assert.equal((await f.invoke({}, { 'Content-Encoding': 'gzip' })).status, 502); + assert.equal(f.state.invocations.length, 0); +}); +test('upstream errors preserve status but hide bodies and are never replayed', async t => { + const f = await bridgeFixture(t); + f.state.inferenceFailure = true; + const response = await f.invoke(); + assert.equal(response.status, 429); + const text = await response.text(); + assert.ok(!text.includes('SECRET_MUST_NOT_LEAK')); + assert.ok(!text.includes('lmm_at_')); + assert.equal(f.state.invocations.length, 1); +}); +test('SSE arrives before completion and disconnect cancels upstream streaming', async t => { + const f = await bridgeFixture(t); + f.state.stream = true; + const response = await f.invoke({ stream: true }); + assert.match(response.headers.get('content-type'), /text\/event-stream/); + const reader = response.body.getReader(); + const first = await reader.read(); + assert.equal(first.done, false); + assert.match(Buffer.from(first.value).toString(), /hello/); + assert.equal(f.state.streamClosed, false); + await reader.cancel(); + for (let i = 0; i < 100 && !f.state.streamClosed; i++) await delay(10); + assert.equal(f.state.streamClosed, true); +}); +test('refresh before inference uses the replacement access token', async t => { + const f = await bridgeFixture(t); + await f.store.lock(async () => f.store.write({ ...await f.store.read(), expires: Date.now() - 1 })); + const response = await f.invoke(); + assert.equal(response.status, 200); + await response.arrayBuffer(); + assert.equal(f.state.refreshes, 1); + assert.equal(f.state.invocations[0].headers.authorization, 'Bearer lmm_at_access1'); +}); +test('catalog refuses groups outside the persisted grant and preserves unknown pricing', async t => { + const f = await fixture(t); + await f.store.login(f.approve); + const { session, models } = await catalog(f.store); + assert.equal(models[0].pricing.input, null); + const raw = await f.oauth.request('/api/oauth2/catalog', { access: session.access }); + assert.throws(() => parseCatalog(raw, { ...session, scope: 'catalog:read' }), /outside/); + raw.models[0].apis = ['anthropic-messages', 'openai-responses']; + assert.deepEqual(parseCatalog(raw, session), []); +}); +test('model selection is exact and never chooses the first of multiple models', () => { + assert.throws(() => selectModel([], undefined)); + assert.throws(() => selectModel([{ id: 'a' }, { id: 'b' }], undefined)); + assert.deepEqual(selectModel([{ id: 'a' }, { id: 'b' }], 'b'), { id: 'b' }); +}); +test('provider config contains only local capability binding, no LMM credentials', () => { + const config = configuration('http://127.0.0.1:9999/v1', 'lmm:ZGVmYXVsdA:ZXhhbXBsZQ'); + assert.match(config, /kind = "openai-compatible"/); + assert.match(config, /api_key_env = "LMM_CODEWHALE_BRIDGE_TOKEN"/); + assert.ok(!config.includes('lmm_at_')); + const env = childEnvironment('/private/config.toml', 'LOCAL', { PATH: '/bin', OPENAI_API_KEY: 'WRONG', CODEWHALE_BASE_URL: 'WRONG', DEEPSEEK_HTTP_HEADERS: 'WRONG' }); + assert.equal(env.PATH, '/bin'); + assert.equal(env.CODEWHALE_CONFIG_PATH, '/private/config.toml'); + assert.equal(env.LMM_CODEWHALE_BRIDGE_TOKEN, 'LOCAL'); + assert.equal(env.OPENAI_API_KEY, undefined); + assert.equal(env.DEEPSEEK_HTTP_HEADERS, undefined); +}); +test('missing executable cleans up private per-run config', async t => { + const f = await fixture(t); + await f.store.login(f.approve); + await assert.rejects(runCodewhale(f.store, f.modelID, [], { binary: join(f.home, 'missing-executable') }), /Cannot launch/); + assert.equal((await readdir(f.home)).filter(p => p.startsWith('run-')).length, 0); +}); +test('stub host receives local credentials and makes a real bridge request', { skip: process.platform === 'win32' }, async t => { + const f = await fixture(t); + await f.store.login(f.approve); + const executable = join(f.home, 'codewhale-stub.mjs'); + const receipt = join(f.home, 'receipt.json'); + await writeFile(executable, `#!${process.execPath}\nimport { readFile, writeFile } from 'node:fs/promises'; +const config = await readFile(process.env.CODEWHALE_CONFIG_PATH, 'utf8'); +const base = JSON.parse(config.match(/^base_url = (.+)$/m)[1]); +const model = JSON.parse(config.match(/^model = (.+)$/m)[1]); +const response = await fetch(base + '/chat/completions', { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer ' + process.env.LMM_CODEWHALE_BRIDGE_TOKEN }, body:JSON.stringify({ model, messages:[] }) }); +await writeFile(${JSON.stringify(receipt)}, JSON.stringify({args:process.argv.slice(2),config,status:response.status,body:await response.json(),credentialValues:Object.values(process.env).filter(v=>/^lmm_(at|rt)_/.test(v))})); +`, { mode: 0o700 }); + assert.equal(await runCodewhale(f.store, f.modelID, ['exec', 'test prompt'], { binary: executable }), 0); + const result = JSON.parse(await readFile(receipt)); + assert.deepEqual(result.args, ['--provider', 'lmm', '--model', f.modelID, 'exec', 'test prompt']); + assert.equal(result.status, 200); + assert.deepEqual(result.credentialValues, []); + assert.equal(f.state.invocations.length, 1); + assert.equal((await readdir(f.home)).filter(p => p.startsWith('run-')).length, 0); +}); diff --git a/packages/codewhale-lmm-provider/test/session.test.mjs b/packages/codewhale-lmm-provider/test/session.test.mjs new file mode 100644 index 000000000..4481d1078 --- /dev/null +++ b/packages/codewhale-lmm-provider/test/session.test.mjs @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, stat, writeFile, symlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { SessionStore } from '../src/session.mjs'; +import { OAuth } from '../src/oauth.mjs'; +import { fixture } from './fixture.mjs'; + +async function expire(f) { + const session = await f.store.read(); + await f.store.write({ ...session, expires: Date.now() - 1 }); +} + +test('status never reveals tokens or refreshes credentials', async t => { + const f = await fixture(t); + assert.equal((await f.store.status()).signed_in, false); + await f.store.login(f.approve); await expire(f); + const status = await f.store.status(); + assert.equal(status.expired, true); + assert.equal(JSON.stringify(status).includes('lmm_at_'), false); + assert.equal(JSON.stringify(status).includes('lmm_rt_'), false); + assert.equal(f.state.refreshes, 0); +}); + +test('owner-only credential storage on POSIX', { skip: process.platform === 'win32' }, async t => { + const f = await fixture(t); await f.store.login(f.approve); + assert.equal((await stat(f.home)).mode & 0o777, 0o700); + assert.equal((await stat(f.store.path)).mode & 0o777, 0o600); +}); + +test('concurrent refreshes serialize and persist one replacement', async t => { + const f = await fixture(t); await f.store.login(f.approve); await expire(f); + const other = new SessionStore(f.oauth, f.home); + const sessions = await Promise.all([f.store.access(), other.access(), f.store.access(), other.access()]); + assert.equal(f.state.refreshes, 1); + assert.ok(sessions.every(session => session.access === 'lmm_at_access1')); + assert.equal((await f.store.read()).refresh_pending, false); +}); + +test('separate Node processes serialize refreshes using the same file lock', async t => { + const f = await fixture(t); await f.store.login(f.approve); await expire(f); + const code = `import {OAuth} from ${JSON.stringify(new URL('../src/oauth.mjs', import.meta.url).href)};\nimport {SessionStore} from ${JSON.stringify(new URL('../src/session.mjs', import.meta.url).href)};\nawait new SessionStore(new OAuth(process.env.TEST_ISSUER), process.env.TEST_HOME).access();`; + const child = () => new Promise((resolve, reject) => { + const proc = spawn(process.execPath, ['--input-type=module', '-e', code], { env: { ...process.env, TEST_HOME: f.home, TEST_ISSUER: f.issuer }, stdio: 'ignore' }); + proc.once('error', reject); proc.once('exit', code => code === 0 ? resolve() : reject(new Error('Child failed'))); + }); + await Promise.all([child(), child()]); + assert.equal(f.state.refreshes, 1); +}); + +test('ambiguous refresh failure persists journal and prevents token replay', async t => { + const f = await fixture(t); await f.store.login(f.approve); await expire(f); + f.state.refreshFailure = true; + await assert.rejects(f.store.access(), /HTTP 503/); + assert.equal((await f.store.read()).refresh_pending, true); + f.state.refreshFailure = false; + await assert.rejects(f.store.access(), /rotation did not commit/); + assert.equal(f.state.refreshes, 1); +}); + +test('crash marker blocks refresh even in a newly constructed adapter', async t => { + const f = await fixture(t); await f.store.login(f.approve); + await f.store.write({ ...await f.store.read(), refresh_pending: true }); + await assert.rejects(new SessionStore(f.oauth, f.home).access(), /rotation did not commit/); + assert.equal(f.state.refreshes, 0); +}); + +test('failed revocation keeps local credentials; local-only deletion is explicit', async t => { + const f = await fixture(t); await f.store.login(f.approve); + f.state.revokeFailure = true; + await assert.rejects(f.store.logout(), /HTTP 503/); + assert.ok(await f.store.read()); + await f.store.logout(true); + assert.equal(await f.store.read(), null); + assert.equal(f.state.revocations, 0); +}); + +test('logout revokes the token family and then deletes local credentials', async t => { + const f = await fixture(t); await f.store.login(f.approve); await f.store.logout(); + assert.equal(f.state.revocations, 1); + assert.equal(await f.store.read(), null); +}); + +test('different issuers cannot consume or replace the same login', async t => { + const f = await fixture(t); await f.store.login(f.approve); + const other = new SessionStore(new OAuth('https://another.test'), f.home); + await assert.rejects(other.access(), /another issuer/); + await assert.rejects(other.login(() => {}), /another issuer/); + assert.equal((await f.store.read()).issuer, f.issuer); +}); + +test('corrupt storage is preserved rather than silently overwritten', async t => { + const f = await fixture(t); await f.store.prepare(); + await writeFile(f.store.path, 'not-json', { mode: 0o600 }); + await assert.rejects(f.store.login(() => {}), /not overwritten/); + assert.equal(await readFile(f.store.path, 'utf8'), 'not-json'); +}); + +test('unlock refuses a live process lock', async t => { + const f = await fixture(t); await f.store.prepare(); + await writeFile(f.store.lockPath, JSON.stringify({ pid: process.pid }), { mode: 0o600 }); + await assert.rejects(f.store.unlock(), /still running/); +}); + +test('credential directory symlinks are rejected', { skip: process.platform === 'win32' }, async t => { + const f = await fixture(t); + const alias = join(f.home, 'alias'); await symlink(f.home, alias); + await assert.rejects(new SessionStore(f.oauth, alias).prepare(), /real private directory/); +}); From 650b18dff577b34da0d582794bd8564b7980b9a6 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 02:33:23 +0800 Subject: [PATCH 2/9] fix(codewhale): execute npm symlink entrypoint and cover installed CLI Resolve argv[1] to its real path before ESM entrypoint detection. Add direct, symlink and argument-redaction regressions. 36 tests pass locally; actual packed installation prints help. Initial remote registration and adapter CI jobs also passed. --- docs/codewhale-provider.md | 6 ++--- packages/codewhale-lmm-provider/README.md | 2 +- packages/codewhale-lmm-provider/src/cli.mjs | 7 +++++- .../codewhale-lmm-provider/test/cli.test.mjs | 25 +++++++++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 packages/codewhale-lmm-provider/test/cli.test.mjs diff --git a/docs/codewhale-provider.md b/docs/codewhale-provider.md index de8e63364..d218edbef 100644 --- a/docs/codewhale-provider.md +++ b/docs/codewhale-provider.md @@ -29,10 +29,10 @@ model ID with the upstream model, and sets `X-LMM-Group` without fallback. Streaming bytes are passed through, disconnects cancel the upstream, and the adapter does not retry inference POSTs. The host may have its own retry policy. -Local verification: Linux, Node.js 22.16.0, 33 passing tests and syntax/package +Local verification: Linux, Node.js 22.16.0, 36 passing tests and syntax/package checks, including mock OAuth HTTP, separate-process refresh locking, and a mock host process. This is not live Codewhale or production/billing acceptance. Backend tests: `cd apps/api-go && go test ./oauthserver -run TestCodewhale -count=1`. -They are supplied but were not executable in the dependency-unavailable local -review environment. Production rollout remains gated by real interoperability +The registration tests passed in the initial GitHub CI run 35638700004; they +were not run in the dependency-unavailable local review environment. Production rollout remains gated by real interoperability and billing tests; registering a client does not enable or deploy OAuth. diff --git a/packages/codewhale-lmm-provider/README.md b/packages/codewhale-lmm-provider/README.md index 255f7f315..b31f9e394 100644 --- a/packages/codewhale-lmm-provider/README.md +++ b/packages/codewhale-lmm-provider/README.md @@ -90,7 +90,7 @@ npm run check npm run pack:check ``` -本地 Linux / Node.js 22.16.0 已执行 33 项测试,全部通过,包括真实 HTTP 回环 PKCE、跨进程刷新互斥、崩溃日志、撤销失败保留、分组隔离、SSE 与取消、临时配置清理,以及模拟宿主进程调用本地桥。模拟宿主不是官方 Codewhale 二进制;尚未完成真实生产授权、Codewhale TUI、账单核对或 Windows/Termux 实机验收。Go 注册测试随父项目提交,需在父项目依赖可用的环境运行。 +本地 Linux / Node.js 22.16.0 已执行 36 项测试,全部通过,包括真实 HTTP 回环 PKCE、跨进程刷新互斥、崩溃日志、撤销失败保留、分组隔离、SSE 与取消、临时配置清理,以及模拟宿主进程调用本地桥。模拟宿主不是官方 Codewhale 二进制;尚未完成真实生产授权、Codewhale TUI、账单核对或 Windows/Termux 实机验收。Go 注册测试已在父项目首轮 GitHub CI 通过;本地没有运行 Go 依赖环境。 ## 独立仓库与子模块 diff --git a/packages/codewhale-lmm-provider/src/cli.mjs b/packages/codewhale-lmm-provider/src/cli.mjs index 28e397404..d4c6de4b2 100755 --- a/packages/codewhale-lmm-provider/src/cli.mjs +++ b/packages/codewhale-lmm-provider/src/cli.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { realpathSync } from 'node:fs'; import { parseArgs } from 'node:util'; import { spawn } from 'node:child_process'; import { pathToFileURL } from 'node:url'; @@ -67,7 +68,11 @@ export async function main(argv = process.argv.slice(2), signal) { } return 0; } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +function isMainModule() { + try { return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } + catch { return false; } +} +if (isMainModule()) { const controller = new AbortController(); const stop = () => controller.abort(); process.once('SIGINT', stop); diff --git a/packages/codewhale-lmm-provider/test/cli.test.mjs b/packages/codewhale-lmm-provider/test/cli.test.mjs new file mode 100644 index 000000000..9458d0a46 --- /dev/null +++ b/packages/codewhale-lmm-provider/test/cli.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtemp, symlink, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); +test('CLI direct entrypoint prints help without touching account credentials', () => { + assert.match(execFileSync(process.execPath, [cli, '--help'], { encoding: 'utf8' }), /codewhale-lmm login/); +}); +test('npm-style symlink entrypoint actually executes the CLI', { skip: process.platform === 'win32' }, async t => { + const directory = await mkdtemp(join(tmpdir(), 'codewhale-bin-test-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const bin = join(directory, 'codewhale-lmm'); + await symlink(cli, bin); + assert.match(execFileSync(bin, ['--help'], { encoding: 'utf8' }), /codewhale-lmm login/); +}); +test('unknown arguments fail without leaking argument values through exceptions', () => { + const result = spawnSync(process.execPath, [cli, '--FAKE_SECRET_MUST_NOT_LEAK'], { encoding: 'utf8' }); + assert.equal(result.status, 1); + assert.doesNotMatch(result.stderr, /FAKE_SECRET_MUST_NOT_LEAK/); + assert.match(result.stderr, /No credential was printed/); +}); From 86671f67832d8d4378fb95f553e860bdbacc0213 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:08:29 +0800 Subject: [PATCH 3/9] chore: extract Codewhale provider to submodule --- .gitmodules | 3 + packages/codewhale-lmm-provider | 1 + .../.github/workflows/ci.yml | 19 -- packages/codewhale-lmm-provider/.gitignore | 6 - packages/codewhale-lmm-provider/LICENSE | 15 -- packages/codewhale-lmm-provider/README.md | 113 --------- packages/codewhale-lmm-provider/package.json | 15 -- packages/codewhale-lmm-provider/plugin.json | 6 - .../scripts/publish-subproject.mjs | 72 ------ .../skills/lmm/SKILL.md | 27 --- packages/codewhale-lmm-provider/src/cli.mjs | 83 ------- packages/codewhale-lmm-provider/src/oauth.mjs | 215 ------------------ .../codewhale-lmm-provider/src/provider.mjs | 183 --------------- .../codewhale-lmm-provider/src/session.mjs | 147 ------------ .../codewhale-lmm-provider/test/cli.test.mjs | 25 -- .../codewhale-lmm-provider/test/fixture.mjs | 86 ------- .../test/oauth.test.mjs | 80 ------- .../test/provider.test.mjs | 151 ------------ .../test/session.test.mjs | 110 --------- 19 files changed, 4 insertions(+), 1353 deletions(-) create mode 160000 packages/codewhale-lmm-provider delete mode 100644 packages/codewhale-lmm-provider/.github/workflows/ci.yml delete mode 100644 packages/codewhale-lmm-provider/.gitignore delete mode 100644 packages/codewhale-lmm-provider/LICENSE delete mode 100644 packages/codewhale-lmm-provider/README.md delete mode 100644 packages/codewhale-lmm-provider/package.json delete mode 100644 packages/codewhale-lmm-provider/plugin.json delete mode 100755 packages/codewhale-lmm-provider/scripts/publish-subproject.mjs delete mode 100644 packages/codewhale-lmm-provider/skills/lmm/SKILL.md delete mode 100755 packages/codewhale-lmm-provider/src/cli.mjs delete mode 100644 packages/codewhale-lmm-provider/src/oauth.mjs delete mode 100644 packages/codewhale-lmm-provider/src/provider.mjs delete mode 100644 packages/codewhale-lmm-provider/src/session.mjs delete mode 100644 packages/codewhale-lmm-provider/test/cli.test.mjs delete mode 100644 packages/codewhale-lmm-provider/test/fixture.mjs delete mode 100644 packages/codewhale-lmm-provider/test/oauth.test.mjs delete mode 100644 packages/codewhale-lmm-provider/test/provider.test.mjs delete mode 100644 packages/codewhale-lmm-provider/test/session.test.mjs diff --git a/.gitmodules b/.gitmodules index 3f3b78262..b69434a4f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "packages/lmm-scripts"] path = packages/lmm-scripts url = https://github.com/TokenNotIncluded/lmm-scripts.git +[submodule "packages/codewhale-lmm-provider"] + path = packages/codewhale-lmm-provider + url = https://github.com/TokenNotIncluded/codewhale-lmm-provider.git diff --git a/packages/codewhale-lmm-provider b/packages/codewhale-lmm-provider new file mode 160000 index 000000000..651b874e6 --- /dev/null +++ b/packages/codewhale-lmm-provider @@ -0,0 +1 @@ +Subproject commit 651b874e63d0ff5fb75c21ee47a444c145b6c6ba diff --git a/packages/codewhale-lmm-provider/.github/workflows/ci.yml b/packages/codewhale-lmm-provider/.github/workflows/ci.yml deleted file mode 100644 index 751793948..000000000 --- a/packages/codewhale-lmm-provider/.github/workflows/ci.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Adapter checks -on: [push, pull_request] -permissions: - contents: read -jobs: - test: - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - node: ['22', '24'] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node }} - - run: npm run check - - run: npm test - - run: npm run pack:check diff --git a/packages/codewhale-lmm-provider/.gitignore b/packages/codewhale-lmm-provider/.gitignore deleted file mode 100644 index 4be90b357..000000000 --- a/packages/codewhale-lmm-provider/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules/ -*.tgz -coverage/ -.env -session.json -session.lock diff --git a/packages/codewhale-lmm-provider/LICENSE b/packages/codewhale-lmm-provider/LICENSE deleted file mode 100644 index c241f52d4..000000000 --- a/packages/codewhale-lmm-provider/LICENSE +++ /dev/null @@ -1,15 +0,0 @@ -SPDX-License-Identifier: AGPL-3.0-only - -Copyright (C) 2026 TokenNotIncluded contributors - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License version 3 -as published by the Free Software Foundation. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -The full license text is available at: -https://www.gnu.org/licenses/agpl-3.0.txt diff --git a/packages/codewhale-lmm-provider/README.md b/packages/codewhale-lmm-provider/README.md deleted file mode 100644 index b31f9e394..000000000 --- a/packages/codewhale-lmm-provider/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# Codewhale LMM Provider - -通过 LMM 网页授权登录,在 Codewhale 中使用 LMM 的模型与分组,不需要复制 API Key。 - -**当前版本:0.1.0-alpha.1。** 这是 OAuth 伴随适配器,不是 Codewhale 原生 `/login` provider 插件。依据 Codewhale `b367f6248715510cb5d527e57e4e352db14cb0cd` 的插件和自定义 provider 接口实现。该版本的插件接口不能注册模型提供商或 OAuth 回调,因此使用独立 CLI 完成授权,通过临时本地 provider 连接 Codewhale;`plugin.json` 提供可选的使用说明 skill。 - -目前支持目录中声明 `openai-completions` 的模型、工具调用请求与 SSE 透传。**不支持 Responses-only、Anthropic Messages-only 模型,也未移植 Pi 的 MCP、市场工具和原生模型选择器集成。** 不会伪造模型能力、上下文长度或价格。 - -## 安装 - -需要 Node.js 22+ 和已经安装的官方 `codewhale` 可执行程序。本包没有第三方运行时依赖。 - -在父项目仓库根目录执行: - -```sh -npm install --global ./packages/codewhale-lmm-provider -``` - -独立源码包解压后,也可以在本目录执行 `npm install --global .`,或不安装,直接运行 `node src/cli.mjs --help`。 - -## 使用 - -```sh -codewhale-lmm login -codewhale-lmm models -codewhale-lmm run --model '' -``` - -登录打开 LMM 授权页;回到终端后即可使用。模型 ID 包含分组,不能用上游模型名替代;多个模型时不会静默选择第一个。 - -```sh -codewhale-lmm run --model '<完整 id>' -- exec '检查这个项目的测试' -codewhale-lmm status -codewhale-lmm balance -codewhale-lmm usage -codewhale-lmm logout -``` - -`status` 只读本地状态;`balance` 和 `usage` 读取 LMM 账户余额与授权允许的日聚合用量。未知价格保持 `null`,不是免费。 - -启动时创建临时 provider 配置,设置 `CODEWHALE_CONFIG_PATH`,并传入临时本地连接凭据。不会覆盖用户原有配置,也不会继承原配置中的自定义运行设置。关闭 Codewhale 后清理本地监听和临时配置。适配器不重试模型 POST;Codewhale 自身的重试策略仍由宿主管理。 - -可选环境变量: - -| 变量 | 用途 | -| --- | --- | -| `LMM_ISSUER` | 默认 `https://api.lmm.best`;也可用 `--issuer` | -| `LMM_CODEWHALE_HOME` | 独立凭据目录,默认 `$CODEWHALE_HOME/lmm-provider` 或 `~/.codewhale/lmm-provider` | -| `LMM_CODEWHALE_BIN` | 官方 Codewhale 可执行文件路径 | - -Termux 优先用 `termux-open-url` 打开浏览器。`login --no-browser` 仅输出授权 URL,仍需浏览器能访问当前机器的回环回调;这不是 device-code 登录,不能直接解决远程 SSH 的浏览器回调问题。 - -## 可选的 Codewhale 插件说明 - -在 Codewhale 会话中执行: - -```text -/plugin install ./packages/codewhale-lmm-provider -/plugin validate codewhale-lmm-provider -/plugin enable codewhale-lmm-provider -``` - -按 Codewhale 显示的内容与权限哈希自行审查、信任,再启用。这里安装的是帮助 skill;它不会自动运行登录,也不能替代 `codewhale-lmm run`。不写入或绕过宿主的信任记录。 - -## 服务端接入 - -父项目必须先部署 `lmm-codewhale` 客户端注册补丁。该客户端使用授权码 + PKCE S256,独立于 `lmm-pi` / `lmm-dsh`。初始 scope 仅为: - -```text -catalog:read balance:read usage:read models:invoke -``` - -分组权限由用户同意时的服务端快照追加。没有 MCP、市场工具或账户管理权限。授权码、刷新和撤销均绑定此客户端。 - -保留既有 `OAUTH_SERVER_ENABLED`、issuer 和分组白名单门槛,不自动启用或部署生产 OAuth。旧服务端尚未登记新客户端时登录会失败,不能拿 Pi 的 client ID 顶替。 - -## 凭据与故障恢复 - -OAuth access/refresh token 保存在适配器私有目录,不进入 Codewhale 的配置、命令行或环境。凭据文件为 POSIX `0600`,目录为 `0700`;不安全权限会拒绝使用。不同 issuer 不能混用存储目录。相同 OS 用户仍能读取文件;这不是与 Codewhale 进程隔离的系统沙箱。Windows 的独立 ACL 保护尚未实现,请不要将此预览版用于共享 Windows 主机。 - -刷新以跨进程锁串行执行;请求前原子写入 `refresh_pending`,成功后原子替换整对令牌。发生响应丢失或崩溃时停止自动刷新,不重放可能已消费的 refresh token。重新授权前先 `logout`;无法完成远端撤销时,本地凭据会保留。明确使用 `logout --local-only` 只删除本地文件,**不代表服务端授权已撤销**。锁的拥有者已退出时可用 `unlock` 清理;不会抢占活跃进程的锁。 - -本地桥只监听 `127.0.0.1` 随机端口,要求随机 Bearer,拒绝浏览器 Origin、任意上游、未授权分组和不支持的路径。上游只收到 OAuth Bearer、目录给出的 `X-LMM-Group` 与原协议请求;错误不回显服务端响应体或凭据,断开客户端时取消流式请求。 - -## 验证 - -```sh -npm test -npm run check -npm run pack:check -``` - -本地 Linux / Node.js 22.16.0 已执行 36 项测试,全部通过,包括真实 HTTP 回环 PKCE、跨进程刷新互斥、崩溃日志、撤销失败保留、分组隔离、SSE 与取消、临时配置清理,以及模拟宿主进程调用本地桥。模拟宿主不是官方 Codewhale 二进制;尚未完成真实生产授权、Codewhale TUI、账单核对或 Windows/Termux 实机验收。Go 注册测试已在父项目首轮 GitHub CI 通过;本地没有运行 Go 依赖环境。 - -## 独立仓库与子模块 - -当前交付暂存于父项目的普通目录中,尚未创建远端独立仓库,也没有向 `.gitmodules` 写入失效地址。 - -在本 PR 合并后的干净父项目克隆中,使用已登录且可建组织仓库的 GitHub CLI 执行: - -```sh -node packages/codewhale-lmm-provider/scripts/publish-subproject.mjs -``` - -该命令创建公开仓库 `TokenNotIncluded/codewhale-lmm-provider` 并推送已提交源码,然后在独立 worktree 中将父项目目录替换为真实 Git submodule,推送分支并打开 PR。不会删除原工作区源码、覆盖已有远端仓库或自动合并。远端创建成功而后续失败时会停止并报告,不会假装回滚远端。 - -## 接口依据 - -- https://github.com/Hmbown/Codewhale/blob/b367f6248715510cb5d527e57e4e352db14cb0cd/docs/PLUGIN_BUNDLES.md -- https://github.com/Hmbown/Codewhale/blob/b367f6248715510cb5d527e57e4e352db14cb0cd/docs/CONFIGURATION.md -- https://github.com/TokenNotIncluded/api.lmm.best/blob/main/apps/api-go/service/oauth_contract.md - -AGPL-3.0-only. 本项目不隶属于 Codewhale。 diff --git a/packages/codewhale-lmm-provider/package.json b/packages/codewhale-lmm-provider/package.json deleted file mode 100644 index 281eee80e..000000000 --- a/packages/codewhale-lmm-provider/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@tokennotincluded/codewhale-lmm-provider", - "version": "0.1.0-alpha.1", - "description": "LMM OAuth companion adapter and guidance bundle for Codewhale", - "type": "module", - "license": "AGPL-3.0-only", - "engines": { "node": ">=22" }, - "bin": { "codewhale-lmm": "./src/cli.mjs" }, - "files": ["src", "skills", "plugin.json", "README.md", "LICENSE"], - "scripts": { - "test": "node --test test/*.test.mjs", - "check": "node --check src/oauth.mjs && node --check src/session.mjs && node --check src/provider.mjs && node --check src/cli.mjs", - "pack:check": "npm pack --dry-run --ignore-scripts" - } -} diff --git a/packages/codewhale-lmm-provider/plugin.json b/packages/codewhale-lmm-provider/plugin.json deleted file mode 100644 index dba0f920c..000000000 --- a/packages/codewhale-lmm-provider/plugin.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "codewhale-lmm-provider", - "version": "0.1.0-alpha.1", - "description": "LMM OAuth adapter usage and troubleshooting. Provider execution uses the companion codewhale-lmm CLI.", - "license": "AGPL-3.0-only" -} diff --git a/packages/codewhale-lmm-provider/scripts/publish-subproject.mjs b/packages/codewhale-lmm-provider/scripts/publish-subproject.mjs deleted file mode 100755 index 89df79ead..000000000 --- a/packages/codewhale-lmm-provider/scripts/publish-subproject.mjs +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node -// Explicit maintainer operation: never called by npm installation or the plugin. -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, copyFileSync, existsSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repo = 'TokenNotIncluded/codewhale-lmm-provider'; -const parentRepo = 'TokenNotIncluded/api.lmm.best'; -const subpath = 'packages/codewhale-lmm-provider'; -const source = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const run = (cmd, args, cwd, capture = false) => execFileSync(cmd, args, { - cwd, encoding: 'utf8', stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', shell: false, -}); -let temporary, worktree, parent, created = false, complete = false; -try { - parent = run('git', ['rev-parse', '--show-toplevel'], source, true).trim(); - if (resolve(parent, subpath) !== source) throw new Error('Run from the staged package in the parent repository.'); - if (run('git', ['status', '--porcelain'], parent, true).trim()) throw new Error('Parent checkout must be clean.'); - const remote = run('git', ['remote', 'get-url', 'origin'], parent, true).trim(); - if (!/^(https:\/\/github\.com\/|git@github\.com:)TokenNotIncluded\/api\.lmm\.best(?:\.git)?$/.test(remote)) throw new Error('Origin is not the expected parent repository.'); - run('gh', ['auth', 'status'], parent); - const defaultBranch = run('gh', ['repo', 'view', parentRepo, '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name'], parent, true).trim(); - const names = JSON.parse(run('gh', ['api', '--paginate', '--slurp', 'orgs/TokenNotIncluded/repos?per_page=100&type=all'], parent, true)).flat(); - if (names.some(item => item.name.toLowerCase() === 'codewhale-lmm-provider')) throw new Error('Destination already exists; no remote or source will be overwritten.'); - temporary = mkdtempSync(join(tmpdir(), 'lmm-codewhale-publish-')); - const independent = join(temporary, 'repository'); - mkdirSync(independent); - const files = run('git', ['ls-files', '-z', '--', subpath], parent, true).split('\0').filter(Boolean); - if (!files.some(file => file === `${subpath}/src/cli.mjs`)) throw new Error('Package source is not committed as ordinary files.'); - for (const file of files) { - const destination = join(independent, file.slice(subpath.length + 1)); - mkdirSync(dirname(destination), { recursive: true }); - copyFileSync(join(parent, file), destination); - } - run('git', ['init', '-b', 'main'], independent); - run('git', ['add', '.'], independent); - run('git', ['commit', '-m', 'feat: add Codewhale LMM OAuth companion adapter'], independent); - // gh creation fails rather than replacing an existing repository; the prior list is diagnostic. - run('gh', ['repo', 'create', repo, '--public', '--description', 'LMM OAuth companion adapter for Codewhale'], independent); - created = true; - run('git', ['remote', 'add', 'origin', `https://github.com/${repo}.git`], independent); - run('git', ['push', '-u', 'origin', 'main'], independent); - const sha = run('git', ['rev-parse', 'HEAD'], independent, true).trim(); - run('git', ['fetch', 'origin', defaultBranch], parent); - const branch = `chore/codewhale-submodule-${Date.now()}`; - worktree = join(temporary, 'parent'); - run('git', ['worktree', 'add', '-b', branch, worktree, `origin/${defaultBranch}`], parent); - if (!existsSync(join(worktree, subpath, 'src', 'cli.mjs'))) throw new Error('Merge the adapter source PR before converting it into a submodule.'); - run('git', ['rm', '-r', '--', subpath], worktree); - run('git', ['submodule', 'add', `https://github.com/${repo}.git`, subpath], worktree); - run('git', ['checkout', '--detach', sha], join(worktree, subpath)); - run('git', ['add', '.gitmodules', subpath], worktree); - run('git', ['commit', '-m', 'chore: extract Codewhale provider to independent submodule'], worktree); - run('git', ['push', '-u', 'origin', branch], worktree); - run('gh', ['pr', 'create', '--repo', parentRepo, '--base', defaultBranch, '--head', branch, - '--title', 'chore: add Codewhale provider as an independent submodule', - '--body', `Extracts the committed adapter to ${repo} and pins submodule ${subpath} to ${sha}. No Pi/DSH submodule changes. Review before merging.`], worktree); - complete = true; - console.log(`Published ${repo}; submodule conversion is in the new parent PR.`); -} catch (error) { - console.error(error.message); - if (created) console.error(`The remote ${repo} was created. It was NOT deleted or rolled back.`); - if (temporary) console.error(`Recovery files remain at ${temporary}`); - process.exitCode = 1; -} finally { - if (complete) { - if (worktree) run('git', ['worktree', 'remove', '--force', worktree], parent); - if (temporary) rmSync(temporary, { recursive: true, force: true }); - } -} diff --git a/packages/codewhale-lmm-provider/skills/lmm/SKILL.md b/packages/codewhale-lmm-provider/skills/lmm/SKILL.md deleted file mode 100644 index a1bc2e535..000000000 --- a/packages/codewhale-lmm-provider/skills/lmm/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: lmm -description: Help the user sign in to LMM and launch Codewhale using the OAuth companion adapter. ---- - -Use `codewhale-lmm` for the LMM integration. This bundle does not register a -native provider-auth hook. Do not claim `/login LMM` works inside Codewhale. - -The user signs in from their own terminal with `codewhale-lmm login`, approves -in their browser, then lists `codewhale-lmm models` and launches -`codewhale-lmm run --model `. Do not request credentials, -authorization codes, session files or callback URLs in the conversation. - -`codewhale-lmm status` reads local status without refreshing. `balance` and -`usage` make read-only LMM requests. `logout` revokes the authorization before -removing local credentials. `logout --local-only` deletes only local storage; -it must not be presented as server revocation. - -Only offer model/group IDs returned by `models`. Do not invent model prices, -capabilities, group names or context windows. The current named custom-provider -route supports Chat Completions, not Responses-only or Messages-only models. -OAuth refresh tokens and LMM access tokens stay in the companion process, not -Codewhale's model configuration. Never print or inspect session.json. - -If rotation is interrupted, do not retry the old refresh token. Advise logout -and a new login. After a crashed process, `unlock` refuses to remove a live -process's lock. Do not bypass Codewhale's plugin review/trust requirements. diff --git a/packages/codewhale-lmm-provider/src/cli.mjs b/packages/codewhale-lmm-provider/src/cli.mjs deleted file mode 100755 index d4c6de4b2..000000000 --- a/packages/codewhale-lmm-provider/src/cli.mjs +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node -import { realpathSync } from 'node:fs'; -import { parseArgs } from 'node:util'; -import { spawn } from 'node:child_process'; -import { pathToFileURL } from 'node:url'; -import { OAuth, check, safeMessage } from './oauth.mjs'; -import { SessionStore } from './session.mjs'; -import { catalog, runCodewhale } from './provider.mjs'; - -const help = `LMM OAuth adapter for Codewhale (Node.js 22+)\n\n codewhale-lmm login [--no-browser]\n codewhale-lmm models\n codewhale-lmm run --model [-- ]\n codewhale-lmm status | balance | usage\n codewhale-lmm logout [--local-only]\n codewhale-lmm unlock\n\nOptions: --issuer \nEnvironment: LMM_ISSUER, LMM_CODEWHALE_HOME, LMM_CODEWHALE_BIN\n\nThis is a companion adapter, not a native /login provider hook.\nThe current custom-provider route supports Chat Completions models only.\n`; - -export async function openBrowser(url, noBrowser = false) { - console.error(`Approve LMM in your browser:\n${url}\n`); - if (noBrowser) return; - const [command, args] = process.platform === 'darwin' ? ['open', [url]] - : process.platform === 'win32' ? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]] - : process.env.TERMUX_VERSION || process.env.PREFIX?.includes('com.termux') ? ['termux-open-url', [url]] - : ['xdg-open', [url]]; - // Browser opening is best effort. The printed URL remains usable without a GUI opener. - await new Promise(resolve => { - const child = spawn(command, args, { stdio: 'ignore', shell: false }); - child.once('error', resolve); - child.once('spawn', () => { child.unref(); resolve(); }); - }); -} - -export async function main(argv = process.argv.slice(2), signal) { - const split = argv.indexOf('--'); - const own = split < 0 ? argv : argv.slice(0, split); - const forwarded = split < 0 ? [] : argv.slice(split + 1); - const { values, positionals } = parseArgs({ args: own, allowPositionals: true, options: { - issuer: { type: 'string' }, model: { type: 'string' }, 'no-browser': { type: 'boolean' }, - 'local-only': { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, - } }); - check(positionals.length <= 1, 'Unexpected arguments. Use -- before Codewhale arguments.'); - const command = values.help ? 'help' : positionals[0] || 'help'; - check(['help', 'login', 'logout', 'models', 'status', 'balance', 'usage', 'run', 'unlock'].includes(command), 'Unknown command. Run codewhale-lmm --help.'); - check(!values['local-only'] || command === 'logout', '--local-only is only valid with logout.'); - check(!values['no-browser'] || command === 'login', '--no-browser is only valid with login.'); - check(!values.model || command === 'run', '--model is only valid with run.'); - check(!forwarded.length || command === 'run', 'Only run accepts Codewhale arguments.'); - check(!forwarded.some(arg => /^--(?:provider|model|base-url|api-key|config|config-path)(?:=|$)/.test(arg)), 'Do not override provider/model/credentials/config after --; choose the LMM model with --model.'); - if (command === 'help') { console.log(help); return 0; } - const store = new SessionStore(new OAuth(values.issuer || process.env.LMM_ISSUER)); - const output = value => console.log(JSON.stringify(value, null, 2)); - switch (command) { - case 'login': - await store.login(url => openBrowser(url, values['no-browser']), AbortSignal.any([AbortSignal.timeout(180_000), ...(signal ? [signal] : [])])); - console.log('LMM login saved. Run codewhale-lmm models, then run --model .'); - break; - case 'logout': - await store.logout(values['local-only'], signal); - console.log(values['local-only'] ? 'Local credentials deleted; server authorization was NOT revoked.' : 'LMM authorization revoked and local credentials deleted.'); - break; - case 'status': output(await store.status(signal)); break; - case 'models': { - const { models } = await catalog(store, signal); - output(models.map(({ id, name, group, upstream_model, pricing }) => ({ id, name, group, upstream_model, pricing }))); - break; - } - case 'balance': case 'usage': { - const session = await store.access(signal); - output(await store.oauth.request(command === 'balance' ? '/api/oauth2/balance' : '/api/oauth2/usage/activity', { access: session.access, signal })); - break; - } - case 'run': return runCodewhale(store, values.model, forwarded, { signal, binary: process.env.LMM_CODEWHALE_BIN || 'codewhale' }); - case 'unlock': await store.unlock(); console.log('No stale LMM session lock remains.'); break; - } - return 0; -} -function isMainModule() { - try { return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } - catch { return false; } -} -if (isMainModule()) { - const controller = new AbortController(); - const stop = () => controller.abort(); - process.once('SIGINT', stop); - process.once('SIGTERM', stop); - try { process.exitCode = await main(undefined, controller.signal); } - catch (error) { console.error(safeMessage(error)); process.exitCode = controller.signal.aborted ? 130 : 1; } - finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); } -} diff --git a/packages/codewhale-lmm-provider/src/oauth.mjs b/packages/codewhale-lmm-provider/src/oauth.mjs deleted file mode 100644 index 2847579ff..000000000 --- a/packages/codewhale-lmm-provider/src/oauth.mjs +++ /dev/null @@ -1,215 +0,0 @@ -import { createHash, randomBytes } from 'node:crypto'; -import { createServer } from 'node:http'; - -export const CLIENT_ID = 'lmm-codewhale'; -export const SCOPES = ['catalog:read', 'balance:read', 'usage:read', 'models:invoke']; -export const CALLBACK_PATH = '/oauth/lmm/callback'; -export class LmmError extends Error {} -export function check(condition, message = 'Invalid LMM response.') { - if (!condition) throw new LmmError(message); -} -export function safeMessage(error) { - return error instanceof LmmError ? error.message : 'LMM operation failed. No credential was printed.'; -} -export function text(value, max = 4096) { - check(typeof value === 'string' && value.length > 0 && value.length <= max && !/[\p{Cc}\p{Cf}]/u.test(value)); - return value; -} -export function issuerURL(value) { - let url; - try { url = new URL(value); } catch { throw new LmmError('Invalid LMM issuer URL.'); } - check(url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === '127.0.0.1'), 'LMM requires HTTPS (127.0.0.1 HTTP is allowed for local tests).'); - check(!url.username && !url.password && !url.search && !url.hash && url.pathname === '/', 'Use an issuer origin, without a path, credentials, query or fragment.'); - return url.origin; -} -export function token(value) { - check(typeof value === 'string' && /^lmm_at_[A-Za-z0-9_-]{1,4089}$/.test(value), 'Expected an LMM OAuth access token, not an API key.'); - return value; -} -export function scopes(value) { - const list = text(value, 16384).split(' '); - check(new Set(list).size === list.length); - for (const scope of list) { - if (SCOPES.includes(scope)) continue; - check(scope.startsWith('group:')); - canonicalID(scope.slice(6)); - } - return list; -} -export function canonicalID(value) { - text(value); - check(/^[A-Za-z0-9_-]+$/.test(value)); - const decoded = Buffer.from(value, 'base64url').toString('utf8'); - text(decoded); - check(Buffer.from(decoded).toString('base64url') === value); - return value; -} -export async function boundedBody(body, limit = 2_000_000) { - let size = 0; - const parts = []; - for await (const part of body) { - const bytes = Buffer.from(part); - size += bytes.length; - check(size <= limit, 'Response or request exceeds the size limit.'); - parts.push(bytes); - } - return Buffer.concat(parts); -} -export function parseJSON(bytes) { - try { return JSON.parse(bytes.toString('utf8')); } catch { throw new LmmError('Invalid JSON response or request.'); } -} -export function object(value) { - check(value && typeof value === 'object' && !Array.isArray(value)); - return value; -} - -// The listener exists before the browser is opened. Bad callbacks never consume a login. -export async function listenCallback(issuer, state, signal) { - signal?.throwIfAborted(); - let resolve, reject; - const code = new Promise((yes, no) => { resolve = yes; reject = no; }); - code.catch(() => {}); - let settled = false, redirectUri; - const finish = (error, value) => { - if (settled) return; - settled = true; - error ? reject(error) : resolve(value); - }; - const server = createServer({ maxHeaderSize: 8192 }, (req, res) => { - res.setHeader('Cache-Control', 'no-store'); - res.setHeader('Referrer-Policy', 'no-referrer'); - res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'"); - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - try { - check(!settled && req.method === 'GET' && req.headers.host === new URL(redirectUri).host && !req.headers.origin); - check(req.url?.startsWith('/') && !req.url.startsWith('//') && req.url.length < 8192); - const url = new URL(req.url, redirectUri); - check(url.origin === new URL(redirectUri).origin && url.pathname === CALLBACK_PATH && !url.hash); - const params = url.searchParams; - for (const key of ['state', 'iss']) check(params.getAll(key).length === 1); - check(params.get('state') === state && params.get('iss') === issuer); - check(params.getAll('code').length + params.getAll('error').length === 1); - if (params.has('error')) { - res.writeHead(400).end('Authorization was declined. Return to your terminal.'); - finish(new LmmError('LMM authorization was declined. Run login again.')); - return; - } - const value = text(params.get('code')); - res.end('LMM login approved. Return to Codewhale.'); - finish(null, value); - } catch { res.writeHead(400).end('Invalid OAuth callback.'); } - }); - server.requestTimeout = 5000; - server.headersTimeout = 5000; - const abort = () => { - finish(new LmmError('LMM login cancelled or timed out. Run login again.')); - server.close(); - server.closeAllConnections(); - }; - server.on('error', () => finish(new LmmError('Cannot start the local OAuth callback listener.'))); - await new Promise((yes, no) => { - server.once('error', no); - server.listen(0, '127.0.0.1', () => { server.removeListener('error', no); yes(); }); - }); - redirectUri = `http://127.0.0.1:${server.address().port}${CALLBACK_PATH}`; - signal?.addEventListener('abort', abort, { once: true }); - if (signal?.aborted) abort(); - return { - code, redirectUri, - close() { - signal?.removeEventListener('abort', abort); - finish(new LmmError('LMM login was closed.')); - server.close(); - server.closeAllConnections(); - }, - }; -} - -export class OAuth { - constructor(issuer = 'https://api.lmm.best', fetchImpl = fetch) { - this.issuer = issuerURL(issuer); - this.resource = `${this.issuer}/api/oauth2`; - this.fetch = fetchImpl; - } - async request(path, { access, form, signal } = {}) { - check(path.startsWith('/') && !path.startsWith('//')); - const response = await this.fetch(`${this.issuer}${path}`, { - method: form ? 'POST' : 'GET', redirect: 'error', - headers: { - Accept: 'application/json', - ...(access ? { Authorization: `Bearer ${token(access)}` } : {}), - ...(form ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}), - }, - body: form ? new URLSearchParams(form) : undefined, - signal: AbortSignal.any([AbortSignal.timeout(20_000), ...(signal ? [signal] : [])]), - }); - if (!response.ok) { - await response.body?.cancel(); - throw new LmmError(response.status === 401 ? 'LMM authorization expired or was revoked. Run login again.' : `LMM request rejected (HTTP ${response.status}).`); - } - const bytes = response.body ? await boundedBody(response.body) : Buffer.alloc(0); - return bytes.length ? object(parseJSON(bytes)) : {}; - } - async discover(signal) { - const [auth, resource] = await Promise.all([ - this.request('/.well-known/oauth-authorization-server', { signal }), - this.request('/.well-known/oauth-protected-resource/api/oauth2', { signal }), - ]); - check(auth.issuer === this.issuer && auth.authorization_endpoint === `${this.resource}/authorize` && - auth.token_endpoint === `${this.resource}/token` && auth.revocation_endpoint === `${this.resource}/revoke`, 'OAuth discovery does not match the configured LMM issuer.'); - check(auth.authorization_response_iss_parameter_supported === true && - Array.isArray(auth.code_challenge_methods_supported) && auth.code_challenge_methods_supported.includes('S256') && - Array.isArray(auth.response_types_supported) && auth.response_types_supported.includes('code'), 'LMM must support PKCE S256 and issuer-bound authorization responses.'); - check(resource.resource === this.resource && Array.isArray(resource.authorization_servers) && - resource.authorization_servers.length === 1 && resource.authorization_servers[0] === this.issuer, 'Protected-resource metadata does not match LMM.'); - } - parseTokens(response, startedAt, previous) { - check(response.token_type === 'Bearer'); - const access = token(response.access_token); - const refresh = text(response.refresh_token); - check(!/\s/.test(refresh) && refresh !== access); - check(Number.isSafeInteger(response.expires_in) && response.expires_in > 0 && response.expires_in <= 86400); - const scope = response.scope ?? previous?.scope; - const granted = scopes(scope); - if (previous) { - const old = new Set(scopes(previous.scope)); - check(granted.every(s => old.has(s)) && refresh !== previous.refresh, 'Refresh must rotate the token and must not widen scopes. Run login again.'); - } else { - check(SCOPES.every(s => granted.includes(s)), 'LMM did not grant the required application permissions.'); - } - return { version: 1, client_id: CLIENT_ID, issuer: this.issuer, resource: this.resource, access, refresh, scope, expires: startedAt + response.expires_in * 1000, refresh_pending: false }; - } - async login(openBrowser, signal = AbortSignal.timeout(180_000)) { - await this.discover(signal); - const verifier = randomBytes(32).toString('base64url'); - const state = randomBytes(32).toString('base64url'); - const callback = await listenCallback(this.issuer, state, signal); - try { - const url = new URL(`${this.resource}/authorize`); - url.search = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: callback.redirectUri, - scope: SCOPES.join(' '), resource: this.resource, state, code_challenge_method: 'S256', - code_challenge: createHash('sha256').update(verifier).digest('base64url') }).toString(); - await openBrowser(url.href); - const code = await callback.code; - callback.close(); - const startedAt = Date.now(); - const response = await this.request('/api/oauth2/token', { signal, form: { - grant_type: 'authorization_code', client_id: CLIENT_ID, code, redirect_uri: callback.redirectUri, - code_verifier: verifier, resource: this.resource, - } }); - return this.parseTokens(response, startedAt); - } finally { callback.close(); } - } - async refresh(session, signal) { - const startedAt = Date.now(); - const response = await this.request('/api/oauth2/token', { signal, form: { - grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token: session.refresh, resource: this.resource, - } }); - return this.parseTokens(response, startedAt, session); - } - async revoke(session, signal) { - await this.request('/api/oauth2/revoke', { signal, form: { - client_id: CLIENT_ID, token: token(session.access), token_type_hint: 'access_token', - } }); - } -} diff --git a/packages/codewhale-lmm-provider/src/provider.mjs b/packages/codewhale-lmm-provider/src/provider.mjs deleted file mode 100644 index a819cc323..000000000 --- a/packages/codewhale-lmm-provider/src/provider.mjs +++ /dev/null @@ -1,183 +0,0 @@ -import { createServer } from 'node:http'; -import { randomBytes, timingSafeEqual } from 'node:crypto'; -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; -import { mkdtemp, writeFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { spawn } from 'node:child_process'; -import { boundedBody, canonicalID, check, LmmError, object, parseJSON, scopes, text } from './oauth.mjs'; - -export function parseCatalog(value, session) { - object(value); - check(value.schema_version === 1 && value.resource === session.resource && Array.isArray(value.groups) && Array.isArray(value.models)); - check(value.groups.length <= 1000 && value.models.length <= 10000); - const allowed = new Set(scopes(session.scope)); - const groups = new Map(); - for (const raw of value.groups) { - const group = object(raw), id = canonicalID(group.id); - check(!groups.has(id) && id === Buffer.from(text(group.name)).toString('base64url') && group.scope === `group:${id}`); - check(allowed.has(group.scope), 'Catalog contains a group outside this login grant.'); - groups.set(id, group); - } - const models = [], seen = new Set(); - for (const raw of value.models) { - const model = object(raw), group = groups.get(model.group_id); - check(group && model.group === group.name); - text(model.upstream_model); text(model.name); - check(model.id === `lmm:${group.id}:${Buffer.from(model.upstream_model).toString('base64url')}` && !seen.has(model.id)); - seen.add(model.id); - check(Array.isArray(model.apis) && model.apis.every(api => typeof api === 'string')); - // Codewhale's documented named custom-provider interface is Chat Completions. - // Do not silently route a Responses/Messages-only model through a different wire API. - if (model.apis.includes('openai-completions')) models.push(model); - } - return models; -} -export async function catalog(store, signal) { - const session = await store.access(signal); - const raw = await store.oauth.request('/api/oauth2/catalog', { access: session.access, signal }); - return { session, models: parseCatalog(raw, session) }; -} -export function selectModel(models, id) { - if (id) { - const selected = models.find(model => model.id === id); - check(selected, 'That model/group is not available on the Chat Completions route. Run models and use an exact ID.'); - return selected; - } - check(models.length === 1, 'Choose an explicit model/group with --model ; run codewhale-lmm models to list IDs.'); - return models[0]; -} -function equalSecret(value, expected) { - if (typeof value !== 'string') return false; - const received = Buffer.from(value), wanted = Buffer.from(expected); - return received.length === wanted.length && timingSafeEqual(received, wanted); -} -function json(res, status, data) { - res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify(data)); -} - -export async function startBridge(store, { timeoutMs = 600_000 } = {}) { - const secret = randomBytes(32).toString('base64url'); - const active = new Set(); - let authority; - const server = createServer({ maxHeaderSize: 16384 }, async (req, res) => { - // No cookie auth, no CORS, no public listener, no caller-selected upstream. - if (req.headers.host !== authority || req.headers.origin || !equalSecret(req.headers.authorization, `Bearer ${secret}`)) { - json(res, 401, { error: { message: 'Local bridge authorization required.' } }); - return; - } - const list = req.method === 'GET' && req.url === '/v1/models'; - const invoke = req.method === 'POST' && req.url === '/v1/chat/completions'; - if (!list && !invoke) { json(res, 404, { error: { message: 'Unsupported LMM bridge endpoint.' } }); return; } - const controller = new AbortController(); - const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]); - active.add(controller); - const disconnect = () => controller.abort(); - res.once('close', disconnect); - req.once('aborted', disconnect); - try { - let body; - if (invoke) { - check(req.headers['content-type']?.split(';')[0].trim().toLowerCase() === 'application/json', 'Expected a JSON model request.'); - check(!req.headers['content-encoding'], 'Compressed requests are not supported.'); - body = object(parseJSON(await boundedBody(req, 16_000_000))); - } - const { session, models } = await catalog(store, signal); - if (list) { - json(res, 200, { object: 'list', data: models.map(model => ({ id: model.id, object: 'model', owned_by: 'lmm' })) }); - return; - } - check(scopes(session.scope).includes('models:invoke'), 'This login no longer permits model invocation.'); - const model = selectModel(models, body.model); - check(body.model === model.id, 'Each request must name an exact model/group ID.'); - // Re-read grants for every request. There is no cross-group fallback and no POST retry. - const upstream = await store.oauth.fetch(`${store.oauth.issuer}/v1/chat/completions`, { - method: 'POST', redirect: 'error', signal, - headers: { Authorization: `Bearer ${session.access}`, 'X-LMM-Group': model.group_id, - 'Content-Type': 'application/json', Accept: 'text/event-stream, application/json' }, - body: JSON.stringify({ ...body, model: model.upstream_model }), - }); - if (!upstream.ok) { - await upstream.body?.cancel(); - json(res, upstream.status, { error: { message: `LMM inference rejected (HTTP ${upstream.status}); no request was replayed by the adapter.` } }); - return; - } - check(upstream.body, 'LMM returned an empty inference response.'); - const contentType = upstream.headers.get('content-type') || ''; - check(/^(text\/event-stream|application\/json)(;|$)/i.test(contentType), 'LMM returned an unsupported inference content type.'); - res.writeHead(upstream.status, { 'Content-Type': contentType, 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' }); - res.flushHeaders(); - await pipeline(Readable.fromWeb(upstream.body), res, { signal }); - } catch (error) { - // Never echo server bodies, OAuth codes, credentials, or user prompts in diagnostics. - if (!res.destroyed && !res.headersSent) json(res, 502, { error: { message: error instanceof LmmError ? error.message : 'LMM bridge request failed; it was not replayed.' } }); - else if (!res.destroyed) res.destroy(); - } finally { - controller.abort(); - active.delete(controller); - res.removeListener('close', disconnect); - req.removeListener('aborted', disconnect); - } - }); - server.requestTimeout = 30_000; - server.headersTimeout = 10_000; - await new Promise((yes, no) => { - server.once('error', no); - server.listen(0, '127.0.0.1', () => { server.removeListener('error', no); yes(); }); - }); - authority = `127.0.0.1:${server.address().port}`; - return { secret, baseURL: `http://${authority}/v1`, async close() { - for (const controller of active) controller.abort(); - const stopped = new Promise(resolve => server.close(resolve)); - server.closeAllConnections(); - await stopped; - } }; -} - -export function configuration(baseURL, model) { - // JSON quoted strings are a compatible subset of TOML basic strings here. - text(model); - return `provider = "lmm"\ndefault_text_model = ${JSON.stringify(model)}\n\n[providers.lmm]\nkind = "openai-compatible"\nbase_url = ${JSON.stringify(baseURL)}\napi_key_env = "LMM_CODEWHALE_BRIDGE_TOKEN"\nmodel = ${JSON.stringify(model)}\n`; -} -export function childEnvironment(configPath, secret, source = process.env) { - const env = { ...source }; - for (const key of Object.keys(env)) { - // Keep ambient route overrides from replacing the selected, credential-bound route. - if (/^(CODEWHALE|DEEPSEEK|OPENAI)_(API_KEY|BASE_URL|MODEL|DEFAULT_TEXT_MODEL|PROVIDER|HTTP_HEADERS|CONFIG_PATH|CONFIG_FILE)$/.test(key)) delete env[key]; - } - env.CODEWHALE_CONFIG_PATH = configPath; - env.LMM_CODEWHALE_BRIDGE_TOKEN = secret; - return env; -} -export async function runCodewhale(store, modelID, args = [], { binary = 'codewhale', signal } = {}) { - const selected = selectModel((await catalog(store, signal)).models, modelID); - const bridge = await startBridge(store); - let directory; - try { - directory = await mkdtemp(join(store.directory, 'run-')); - const configPath = join(directory, 'config.toml'); - await writeFile(configPath, configuration(bridge.baseURL, selected.id), { mode: 0o600 }); - // Only an ephemeral local capability enters the child. LMM OAuth tokens stay in the adapter. - const child = spawn(binary, ['--provider', 'lmm', '--model', selected.id, ...args], { - env: childEnvironment(configPath, bridge.secret), stdio: 'inherit', shell: false, - }); - let killTimer; - const terminate = () => { - child.kill('SIGTERM'); - killTimer = setTimeout(() => child.kill('SIGKILL'), 3000); - killTimer.unref(); - }; - signal?.addEventListener('abort', terminate, { once: true }); - if (signal?.aborted) terminate(); - try { - return await new Promise((resolve, reject) => { - child.once('error', () => reject(new LmmError('Cannot launch codewhale. Install the official binary or set LMM_CODEWHALE_BIN.'))); - child.once('exit', (code, exitSignal) => resolve(code ?? (exitSignal === 'SIGINT' ? 130 : 1))); - }); - } finally { signal?.removeEventListener('abort', terminate); clearTimeout(killTimer); } - } finally { - await bridge.close(); - if (directory) await rm(directory, { recursive: true, force: true }); - } -} diff --git a/packages/codewhale-lmm-provider/src/session.mjs b/packages/codewhale-lmm-provider/src/session.mjs deleted file mode 100644 index 710cdd6ad..000000000 --- a/packages/codewhale-lmm-provider/src/session.mjs +++ /dev/null @@ -1,147 +0,0 @@ -import { constants } from 'node:fs'; -import { mkdir, open, rename, rm, lstat, readFile, realpath } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; -import { homedir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import { setTimeout as delay } from 'node:timers/promises'; -import { CLIENT_ID, LmmError, check, object, scopes, text, token } from './oauth.mjs'; - -export function defaultHome(env = process.env) { - return resolve(env.LMM_CODEWHALE_HOME || join(env.CODEWHALE_HOME || join(homedir(), '.codewhale'), 'lmm-provider')); -} -export class SessionStore { - constructor(oauth, directory = defaultHome()) { - this.oauth = oauth; - this.directory = resolve(directory); - this.path = join(this.directory, 'session.json'); - this.lockPath = join(this.directory, 'session.lock'); - } - async prepare() { - await mkdir(this.directory, { recursive: true, mode: 0o700 }); - const stat = await lstat(this.directory); - check(stat.isDirectory() && !stat.isSymbolicLink(), 'LMM credential directory must be a real private directory.'); - if (process.platform !== 'win32') { - check(stat.uid === process.getuid() && (stat.mode & 0o077) === 0, 'LMM credential directory must be owned by you with mode 0700.'); - } - // Canonicalize system aliases (e.g. macOS /var) once; reject a symlink leaf above. - this.directory = await realpath(this.directory); - this.path = join(this.directory, 'session.json'); - this.lockPath = join(this.directory, 'session.lock'); - } - async lock(fn, signal) { - await this.prepare(); - const until = Date.now() + 25_000; - let handle; - while (!handle) { - signal?.throwIfAborted(); - try { handle = await open(this.lockPath, 'wx', 0o600); } - catch (error) { - if (error.code !== 'EEXIST') throw error; - check(Date.now() < until, 'LMM session is locked. Close other login processes; after a crash run codewhale-lmm unlock.'); - await delay(40, undefined, { signal }); - } - } - try { - await handle.writeFile(JSON.stringify({ pid: process.pid, id: randomUUID() })); - await handle.sync(); - return await fn(); - } finally { - await handle.close(); - await rm(this.lockPath, { force: true }); - } - } - async read() { - let handle; - try { - handle = await open(this.path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)); - const stat = await handle.stat(); - check(stat.isFile() && stat.nlink === 1 && stat.size < 65536, 'Invalid LMM credential file.'); - if (process.platform !== 'win32') check(stat.uid === process.getuid() && (stat.mode & 0o077) === 0, 'LMM credential file must have mode 0600.'); - const value = object(JSON.parse(await handle.readFile('utf8'))); - check(value.version === 1 && value.client_id === CLIENT_ID && value.issuer === this.oauth.issuer && value.resource === this.oauth.resource, 'Stored login belongs to another issuer or client. Use a separate LMM_CODEWHALE_HOME.'); - token(value.access); text(value.refresh); scopes(value.scope); - check(Number.isSafeInteger(value.expires) && value.expires > 0 && typeof value.refresh_pending === 'boolean'); - return value; - } catch (error) { - if (error.code === 'ENOENT') return null; - if (error instanceof LmmError) throw error; - throw new LmmError('Cannot read the LMM credential file. It was not overwritten.'); - } finally { await handle?.close(); } - } - // One atomic record carries BOTH tokens and the rotation journal marker. - async write(value) { - const temp = join(this.directory, `.session-${randomUUID()}.tmp`); - const handle = await open(temp, 'wx', 0o600); - try { - try { - await handle.writeFile(JSON.stringify(value) + '\n'); - await handle.sync(); - } finally { await handle.close(); } - await rename(temp, this.path); - if (process.platform !== 'win32') { - const directory = await open(this.directory, 'r'); - try { await directory.sync(); } finally { await directory.close(); } - } - } finally { await rm(temp, { force: true }); } - } - async login(openBrowser, signal) { - return this.lock(async () => { - // Validate existing state before replacing it; a different issuer needs its own store. - const old = await this.read(); - check(!old, 'Already signed in. Run logout (or logout --local-only) before signing in again.'); - const session = await this.oauth.login(openBrowser, signal); - try { await this.write(session); } - catch (error) { await this.oauth.revoke(session).catch(() => {}); throw error; } - return session; - }, signal); - } - async access(signal) { - return this.lock(async () => { - const session = await this.read(); - check(session, 'Not signed in. Run codewhale-lmm login.'); - check(!session.refresh_pending, 'A previous token rotation did not commit. Run logout, then login; the old refresh token will not be replayed.'); - if (session.expires > Date.now() + 60_000) return session; - // Persist this BEFORE the network operation. Ambiguous failure is not retried. - await this.write({ ...session, refresh_pending: true }); - const replacement = await this.oauth.refresh(session, signal); - await this.write(replacement); - return replacement; - }, signal); - } - async status(signal) { - return this.lock(async () => { - const session = await this.read(); - return session ? { - signed_in: true, issuer: session.issuer, client_id: session.client_id, - expires_at: new Date(session.expires).toISOString(), expired: session.expires <= Date.now(), - refresh_pending: session.refresh_pending, scopes: scopes(session.scope), - } : { signed_in: false, issuer: this.oauth.issuer }; - }, signal); - } - async logout(localOnly = false, signal) { - return this.lock(async () => { - const session = await this.read(); - if (session && !localOnly) await this.oauth.revoke(session, signal); - // Failed revocation preserves the credential so the user can retry explicitly. - await rm(this.path, { force: true }); - }, signal); - } - async unlock() { - await this.prepare(); - const before = await lstat(this.lockPath).catch(error => { - if (error.code === 'ENOENT') return null; - throw error; - }); - if (!before) return; - check(before.isFile() && !before.isSymbolicLink() && before.size < 256); - let lock; - try { lock = JSON.parse(await readFile(this.lockPath, 'utf8')); } - catch { throw new LmmError('Incomplete lock file. Stop all adapter processes and remove session.lock manually.'); } - check(Number.isSafeInteger(lock.pid) && lock.pid > 0); - try { process.kill(lock.pid, 0); throw new LmmError('The lock owner is still running. Stop that process first.'); } - catch (error) { if (error.code !== 'ESRCH') throw error; } - const after = await lstat(this.lockPath); - check(after.ino === before.ino && after.mtimeMs === before.mtimeMs, 'Lock changed; it was not removed.'); - await rm(this.lockPath); - } -} diff --git a/packages/codewhale-lmm-provider/test/cli.test.mjs b/packages/codewhale-lmm-provider/test/cli.test.mjs deleted file mode 100644 index 9458d0a46..000000000 --- a/packages/codewhale-lmm-provider/test/cli.test.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdtemp, symlink, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); -test('CLI direct entrypoint prints help without touching account credentials', () => { - assert.match(execFileSync(process.execPath, [cli, '--help'], { encoding: 'utf8' }), /codewhale-lmm login/); -}); -test('npm-style symlink entrypoint actually executes the CLI', { skip: process.platform === 'win32' }, async t => { - const directory = await mkdtemp(join(tmpdir(), 'codewhale-bin-test-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const bin = join(directory, 'codewhale-lmm'); - await symlink(cli, bin); - assert.match(execFileSync(bin, ['--help'], { encoding: 'utf8' }), /codewhale-lmm login/); -}); -test('unknown arguments fail without leaking argument values through exceptions', () => { - const result = spawnSync(process.execPath, [cli, '--FAKE_SECRET_MUST_NOT_LEAK'], { encoding: 'utf8' }); - assert.equal(result.status, 1); - assert.doesNotMatch(result.stderr, /FAKE_SECRET_MUST_NOT_LEAK/); - assert.match(result.stderr, /No credential was printed/); -}); diff --git a/packages/codewhale-lmm-provider/test/fixture.mjs b/packages/codewhale-lmm-provider/test/fixture.mjs deleted file mode 100644 index 1898fd4e6..000000000 --- a/packages/codewhale-lmm-provider/test/fixture.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { createServer } from 'node:http'; -import { createHash } from 'node:crypto'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { OAuth, SCOPES, CLIENT_ID } from '../src/oauth.mjs'; -import { SessionStore } from '../src/session.mjs'; - -export async function fixture(t) { - const home = await mkdtemp(join(tmpdir(), 'codewhale-lmm-test-')); - const groupID = Buffer.from('default').toString('base64url'); - const modelID = `lmm:${groupID}:${Buffer.from('example-model').toString('base64url')}`; - const scope = [...SCOPES, `group:${groupID}`].join(' '); - const state = { authorizations: new Map(), refreshes: 0, invocations: [], revocations: 0, expires: 3600, - refreshFailure: false, revokeFailure: false, inferenceFailure: false, stream: false, streamClosed: false, grants: scope }; - let issuer; - const json = (res, status, data) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); }; - const tokenResponse = n => ({ token_type: 'Bearer', access_token: `lmm_at_access${n}`, refresh_token: `lmm_rt_refresh${n}`, expires_in: state.expires, scope: state.grants }); - const server = createServer(async (req, res) => { - const url = new URL(req.url, issuer); - const chunks = []; - for await (const chunk of req) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf8'); - if (url.pathname === '/.well-known/oauth-authorization-server') { - json(res, 200, { issuer, authorization_endpoint: `${issuer}/api/oauth2/authorize`, token_endpoint: `${issuer}/api/oauth2/token`, - revocation_endpoint: `${issuer}/api/oauth2/revoke`, code_challenge_methods_supported: ['S256'], response_types_supported: ['code'], authorization_response_iss_parameter_supported: true }); - } else if (url.pathname === '/.well-known/oauth-protected-resource/api/oauth2') { - json(res, 200, { resource: `${issuer}/api/oauth2`, authorization_servers: [issuer] }); - } else if (url.pathname === '/api/oauth2/authorize') { - const params = url.searchParams; - if (params.get('client_id') !== CLIENT_ID || params.get('scope') !== SCOPES.join(' ') || params.get('resource') !== `${issuer}/api/oauth2`) { json(res, 400, {}); return; } - state.lastAuthorization = params; - const code = `test-code-${state.authorizations.size}`; - state.authorizations.set(code, params); - const redirect = new URL(params.get('redirect_uri')); - redirect.search = new URLSearchParams({ code, state: params.get('state'), iss: issuer }).toString(); - res.writeHead(302, { Location: redirect.href }).end(); - } else if (url.pathname === '/api/oauth2/token') { - const body = new URLSearchParams(raw); - if (body.get('client_id') !== CLIENT_ID || body.get('resource') !== `${issuer}/api/oauth2`) { json(res, 400, {}); return; } - if (body.get('grant_type') === 'authorization_code') { - const auth = state.authorizations.get(body.get('code')); - if (!auth || auth.get('redirect_uri') !== body.get('redirect_uri') || auth.get('code_challenge') !== createHash('sha256').update(body.get('code_verifier')).digest('base64url')) { json(res, 400, {}); return; } - state.authorizations.delete(body.get('code')); - json(res, 200, tokenResponse(0)); - } else { - state.refreshes++; - if (state.refreshFailure) { json(res, 503, { error: 'SECRET_MUST_NOT_LEAK' }); return; } - if (body.get('refresh_token') !== `lmm_rt_refresh${state.refreshes - 1}`) { json(res, 400, {}); return; } - await new Promise(resolve => setTimeout(resolve, 20)); - json(res, 200, tokenResponse(state.refreshes)); - } - } else if (url.pathname === '/api/oauth2/catalog') { - if (!req.headers.authorization?.startsWith('Bearer lmm_at_')) { json(res, 401, {}); return; } - json(res, 200, { schema_version: 1, resource: `${issuer}/api/oauth2`, groups: [{ id: groupID, name: 'default', scope: `group:${groupID}` }], - models: [{ id: modelID, group_id: groupID, group: 'default', upstream_model: 'example-model', name: 'Example model', apis: ['openai-completions'], pricing: { input: null, output: null } }] }); - } else if (url.pathname === '/api/oauth2/revoke') { - if (state.revokeFailure) { json(res, 503, { error: 'SECRET_MUST_NOT_LEAK' }); return; } - state.revocations++; - res.writeHead(200).end(); - } else if (url.pathname === '/v1/chat/completions') { - state.invocations.push({ headers: req.headers, body: JSON.parse(raw) }); - if (state.inferenceFailure) { json(res, 429, { error: 'SECRET_MUST_NOT_LEAK' }); return; } - if (state.stream) { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - res.write('data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'); - const timer = setInterval(() => res.write(': heartbeat\n\n'), 50); - res.on('close', () => { clearInterval(timer); state.streamClosed = true; }); - } else json(res, 200, { choices: [{ message: { role: 'assistant', content: 'hello' } }] }); - } else json(res, 404, {}); - }); - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); - issuer = `http://127.0.0.1:${server.address().port}`; - const oauth = new OAuth(issuer), store = new SessionStore(oauth, home); - const approve = async url => { - const response = await fetch(url, { redirect: 'manual' }); - if (response.status !== 302) throw new Error('Mock consent failed'); - await fetch(response.headers.get('location')); - }; - t.after(async () => { - server.closeAllConnections(); - await new Promise(resolve => server.close(resolve)); - await rm(home, { recursive: true, force: true }); - }); - return { state, issuer, oauth, store, home, approve, modelID, groupID, scope }; -} diff --git a/packages/codewhale-lmm-provider/test/oauth.test.mjs b/packages/codewhale-lmm-provider/test/oauth.test.mjs deleted file mode 100644 index ab8e8c9ee..000000000 --- a/packages/codewhale-lmm-provider/test/oauth.test.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { OAuth, CLIENT_ID, issuerURL, listenCallback, safeMessage } from '../src/oauth.mjs'; -import { fixture } from './fixture.mjs'; - -test('OAuth authorization-code flow binds client, resource, state, issuer and PKCE', async t => { - const f = await fixture(t); - const session = await f.store.login(f.approve); - assert.equal(session.client_id, CLIENT_ID); - assert.equal(session.access, 'lmm_at_access0'); - assert.equal(session.scope, f.scope); - assert.equal(f.state.lastAuthorization.get('code_challenge_method'), 'S256'); - assert.equal(f.state.lastAuthorization.get('code_challenge').length, 43); - assert.equal(f.state.lastAuthorization.get('state').length, 43); -}); - -test('callback rejects wrong state/issuer and duplicate fields without consuming login', async () => { - const callback = await listenCallback('https://api.lmm.test', 'expected-state', AbortSignal.timeout(3000)); - try { - for (const query of [ - 'code=secret&state=wrong&iss=https://api.lmm.test', - 'code=secret&state=expected-state&iss=https://attacker.test', - 'code=secret&code=second&state=expected-state&iss=https://api.lmm.test', - 'code=secret&state=expected-state&state=second&iss=https://api.lmm.test', - 'code=secret&error=access_denied&state=expected-state&iss=https://api.lmm.test', - ]) { - const response = await fetch(`${callback.redirectUri}?${query}`); - assert.equal(response.status, 400); - assert.equal((await response.text()).includes('secret'), false); - } - const good = new URL(callback.redirectUri); - good.search = new URLSearchParams({ code: 'good-code', state: 'expected-state', iss: 'https://api.lmm.test' }).toString(); - assert.equal((await fetch(good)).status, 200); - assert.equal(await callback.code, 'good-code'); - } finally { callback.close(); } -}); - -test('callback cancellation closes the listener', async () => { - const controller = new AbortController(); - const callback = await listenCallback('https://api.lmm.test', 's', controller.signal); - controller.abort(); - await assert.rejects(callback.code, /cancelled/); - await assert.rejects(fetch(callback.redirectUri)); - callback.close(); -}); - -test('issuer rejects plaintext remote servers and origin confusion', () => { - for (const value of ['http://evil.test', 'https://u:p@api.test', 'https://api.test/path', 'https://api.test/?x=1', 'https://api.test/#x']) assert.throws(() => issuerURL(value)); - assert.equal(issuerURL('https://API.TEST/'), 'https://api.test'); -}); - -test('discovery endpoint mismatch fails before opening a browser', async () => { - let opened = false; - const oauth = new OAuth('https://api.test', async () => new Response(JSON.stringify({ issuer: 'https://evil.test' }))); - await assert.rejects(oauth.login(() => { opened = true; }), /discovery/); - assert.equal(opened, false); -}); - -test('HTTP credentials are not forwarded across redirects', async t => { - const f = await fixture(t); - let options; - const oauth = new OAuth(f.issuer, async (_url, value) => { options = value; return new Response('{}'); }); - await oauth.request('/api/oauth2/balance', { access: 'lmm_at_test' }); - assert.equal(options.redirect, 'error'); - assert.equal(options.headers.Authorization, 'Bearer lmm_at_test'); -}); - -test('token responses cannot widen scope or reuse a refresh token', async t => { - const f = await fixture(t), session = await f.store.login(f.approve); - const response = { token_type: 'Bearer', access_token: 'lmm_at_next', refresh_token: 'lmm_rt_next', expires_in: 3600, scope: `${f.scope} group:b3RoZXI` }; - assert.throws(() => f.oauth.parseTokens(response, Date.now(), session), /widen/); - response.scope = f.scope; response.refresh_token = session.refresh; - assert.throws(() => f.oauth.parseTokens(response, Date.now(), session), /rotate/); - response.refresh_token = 'lmm_rt_next'; delete response.scope; - assert.equal(f.oauth.parseTokens(response, Date.now(), session).scope, session.scope); -}); - -test('unknown exceptions are redacted', () => { - assert.equal(safeMessage(new Error('lmm_at_secret')), 'LMM operation failed. No credential was printed.'); -}); diff --git a/packages/codewhale-lmm-provider/test/provider.test.mjs b/packages/codewhale-lmm-provider/test/provider.test.mjs deleted file mode 100644 index f7c9e24b0..000000000 --- a/packages/codewhale-lmm-provider/test/provider.test.mjs +++ /dev/null @@ -1,151 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile, writeFile, readdir } from 'node:fs/promises'; -import { join } from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; -import { fixture } from './fixture.mjs'; -import { catalog, parseCatalog, selectModel, startBridge, configuration, childEnvironment, runCodewhale } from '../src/provider.mjs'; - -async function bridgeFixture(t) { - const f = await fixture(t); - await f.store.login(f.approve); - const bridge = await startBridge(f.store); - t.after(() => bridge.close()); - const request = (path, options = {}) => fetch(bridge.baseURL + path, { - ...options, headers: { Authorization: `Bearer ${bridge.secret}`, ...options.headers }, - }); - const invoke = (body = {}, headers = {}) => request('/chat/completions', { - method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, - body: JSON.stringify({ model: f.modelID, messages: [{ role: 'user', content: 'hello' }], ...body }), - }); - return { ...f, bridge, request, invoke }; -} - -test('bridge maps the exact catalog model and group without forwarding caller credentials', async t => { - const f = await bridgeFixture(t); - const response = await f.invoke({}, { 'x-api-key': 'DO_NOT_FORWARD', Cookie: 'DO_NOT_FORWARD' }); - assert.equal(response.status, 200); - assert.equal((await response.json()).choices[0].message.content, 'hello'); - assert.equal(f.state.invocations.length, 1); - const { headers, body } = f.state.invocations[0]; - assert.equal(body.model, 'example-model'); - assert.equal(headers['x-lmm-group'], f.groupID); - assert.equal(headers.authorization, 'Bearer lmm_at_access0'); - assert.equal(headers['x-api-key'], undefined); - assert.equal(headers.cookie, undefined); - assert.ok(!JSON.stringify(headers).includes(f.bridge.secret)); -}); -test('bridge requires its random local capability and rejects browser origins', async t => { - const f = await bridgeFixture(t); - assert.equal((await fetch(f.bridge.baseURL + '/models')).status, 401); - assert.equal((await f.request('/models', { headers: { Origin: 'https://example.invalid' } })).status, 401); - assert.equal((await f.request('/models', { headers: { Authorization: 'Bearer wrong' } })).status, 401); - assert.equal(f.state.invocations.length, 0); -}); -test('only the model-list and Chat Completions paths are exposed', async t => { - const f = await bridgeFixture(t); - for (const path of ['/responses', '/messages', '/models?key=secret', '/../api/oauth2/token']) { - assert.equal((await f.request(path)).status, 404); - } - const response = await f.request('/models'); - assert.deepEqual((await response.json()).data.map(m => m.id), [f.modelID]); -}); -test('unknown or raw upstream model never falls back to a group', async t => { - const f = await bridgeFixture(t); - for (const model of ['example-model', 'lmm:wrong:model', null]) { - assert.equal((await f.invoke({ model })).status, 502); - } - assert.equal(f.state.invocations.length, 0); -}); -test('request content type and compressed payload are rejected before inference', async t => { - const f = await bridgeFixture(t); - assert.equal((await f.invoke({}, { 'Content-Type': 'text/plain' })).status, 502); - assert.equal((await f.invoke({}, { 'Content-Encoding': 'gzip' })).status, 502); - assert.equal(f.state.invocations.length, 0); -}); -test('upstream errors preserve status but hide bodies and are never replayed', async t => { - const f = await bridgeFixture(t); - f.state.inferenceFailure = true; - const response = await f.invoke(); - assert.equal(response.status, 429); - const text = await response.text(); - assert.ok(!text.includes('SECRET_MUST_NOT_LEAK')); - assert.ok(!text.includes('lmm_at_')); - assert.equal(f.state.invocations.length, 1); -}); -test('SSE arrives before completion and disconnect cancels upstream streaming', async t => { - const f = await bridgeFixture(t); - f.state.stream = true; - const response = await f.invoke({ stream: true }); - assert.match(response.headers.get('content-type'), /text\/event-stream/); - const reader = response.body.getReader(); - const first = await reader.read(); - assert.equal(first.done, false); - assert.match(Buffer.from(first.value).toString(), /hello/); - assert.equal(f.state.streamClosed, false); - await reader.cancel(); - for (let i = 0; i < 100 && !f.state.streamClosed; i++) await delay(10); - assert.equal(f.state.streamClosed, true); -}); -test('refresh before inference uses the replacement access token', async t => { - const f = await bridgeFixture(t); - await f.store.lock(async () => f.store.write({ ...await f.store.read(), expires: Date.now() - 1 })); - const response = await f.invoke(); - assert.equal(response.status, 200); - await response.arrayBuffer(); - assert.equal(f.state.refreshes, 1); - assert.equal(f.state.invocations[0].headers.authorization, 'Bearer lmm_at_access1'); -}); -test('catalog refuses groups outside the persisted grant and preserves unknown pricing', async t => { - const f = await fixture(t); - await f.store.login(f.approve); - const { session, models } = await catalog(f.store); - assert.equal(models[0].pricing.input, null); - const raw = await f.oauth.request('/api/oauth2/catalog', { access: session.access }); - assert.throws(() => parseCatalog(raw, { ...session, scope: 'catalog:read' }), /outside/); - raw.models[0].apis = ['anthropic-messages', 'openai-responses']; - assert.deepEqual(parseCatalog(raw, session), []); -}); -test('model selection is exact and never chooses the first of multiple models', () => { - assert.throws(() => selectModel([], undefined)); - assert.throws(() => selectModel([{ id: 'a' }, { id: 'b' }], undefined)); - assert.deepEqual(selectModel([{ id: 'a' }, { id: 'b' }], 'b'), { id: 'b' }); -}); -test('provider config contains only local capability binding, no LMM credentials', () => { - const config = configuration('http://127.0.0.1:9999/v1', 'lmm:ZGVmYXVsdA:ZXhhbXBsZQ'); - assert.match(config, /kind = "openai-compatible"/); - assert.match(config, /api_key_env = "LMM_CODEWHALE_BRIDGE_TOKEN"/); - assert.ok(!config.includes('lmm_at_')); - const env = childEnvironment('/private/config.toml', 'LOCAL', { PATH: '/bin', OPENAI_API_KEY: 'WRONG', CODEWHALE_BASE_URL: 'WRONG', DEEPSEEK_HTTP_HEADERS: 'WRONG' }); - assert.equal(env.PATH, '/bin'); - assert.equal(env.CODEWHALE_CONFIG_PATH, '/private/config.toml'); - assert.equal(env.LMM_CODEWHALE_BRIDGE_TOKEN, 'LOCAL'); - assert.equal(env.OPENAI_API_KEY, undefined); - assert.equal(env.DEEPSEEK_HTTP_HEADERS, undefined); -}); -test('missing executable cleans up private per-run config', async t => { - const f = await fixture(t); - await f.store.login(f.approve); - await assert.rejects(runCodewhale(f.store, f.modelID, [], { binary: join(f.home, 'missing-executable') }), /Cannot launch/); - assert.equal((await readdir(f.home)).filter(p => p.startsWith('run-')).length, 0); -}); -test('stub host receives local credentials and makes a real bridge request', { skip: process.platform === 'win32' }, async t => { - const f = await fixture(t); - await f.store.login(f.approve); - const executable = join(f.home, 'codewhale-stub.mjs'); - const receipt = join(f.home, 'receipt.json'); - await writeFile(executable, `#!${process.execPath}\nimport { readFile, writeFile } from 'node:fs/promises'; -const config = await readFile(process.env.CODEWHALE_CONFIG_PATH, 'utf8'); -const base = JSON.parse(config.match(/^base_url = (.+)$/m)[1]); -const model = JSON.parse(config.match(/^model = (.+)$/m)[1]); -const response = await fetch(base + '/chat/completions', { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer ' + process.env.LMM_CODEWHALE_BRIDGE_TOKEN }, body:JSON.stringify({ model, messages:[] }) }); -await writeFile(${JSON.stringify(receipt)}, JSON.stringify({args:process.argv.slice(2),config,status:response.status,body:await response.json(),credentialValues:Object.values(process.env).filter(v=>/^lmm_(at|rt)_/.test(v))})); -`, { mode: 0o700 }); - assert.equal(await runCodewhale(f.store, f.modelID, ['exec', 'test prompt'], { binary: executable }), 0); - const result = JSON.parse(await readFile(receipt)); - assert.deepEqual(result.args, ['--provider', 'lmm', '--model', f.modelID, 'exec', 'test prompt']); - assert.equal(result.status, 200); - assert.deepEqual(result.credentialValues, []); - assert.equal(f.state.invocations.length, 1); - assert.equal((await readdir(f.home)).filter(p => p.startsWith('run-')).length, 0); -}); diff --git a/packages/codewhale-lmm-provider/test/session.test.mjs b/packages/codewhale-lmm-provider/test/session.test.mjs deleted file mode 100644 index 4481d1078..000000000 --- a/packages/codewhale-lmm-provider/test/session.test.mjs +++ /dev/null @@ -1,110 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile, stat, writeFile, symlink } from 'node:fs/promises'; -import { join } from 'node:path'; -import { spawn } from 'node:child_process'; -import { SessionStore } from '../src/session.mjs'; -import { OAuth } from '../src/oauth.mjs'; -import { fixture } from './fixture.mjs'; - -async function expire(f) { - const session = await f.store.read(); - await f.store.write({ ...session, expires: Date.now() - 1 }); -} - -test('status never reveals tokens or refreshes credentials', async t => { - const f = await fixture(t); - assert.equal((await f.store.status()).signed_in, false); - await f.store.login(f.approve); await expire(f); - const status = await f.store.status(); - assert.equal(status.expired, true); - assert.equal(JSON.stringify(status).includes('lmm_at_'), false); - assert.equal(JSON.stringify(status).includes('lmm_rt_'), false); - assert.equal(f.state.refreshes, 0); -}); - -test('owner-only credential storage on POSIX', { skip: process.platform === 'win32' }, async t => { - const f = await fixture(t); await f.store.login(f.approve); - assert.equal((await stat(f.home)).mode & 0o777, 0o700); - assert.equal((await stat(f.store.path)).mode & 0o777, 0o600); -}); - -test('concurrent refreshes serialize and persist one replacement', async t => { - const f = await fixture(t); await f.store.login(f.approve); await expire(f); - const other = new SessionStore(f.oauth, f.home); - const sessions = await Promise.all([f.store.access(), other.access(), f.store.access(), other.access()]); - assert.equal(f.state.refreshes, 1); - assert.ok(sessions.every(session => session.access === 'lmm_at_access1')); - assert.equal((await f.store.read()).refresh_pending, false); -}); - -test('separate Node processes serialize refreshes using the same file lock', async t => { - const f = await fixture(t); await f.store.login(f.approve); await expire(f); - const code = `import {OAuth} from ${JSON.stringify(new URL('../src/oauth.mjs', import.meta.url).href)};\nimport {SessionStore} from ${JSON.stringify(new URL('../src/session.mjs', import.meta.url).href)};\nawait new SessionStore(new OAuth(process.env.TEST_ISSUER), process.env.TEST_HOME).access();`; - const child = () => new Promise((resolve, reject) => { - const proc = spawn(process.execPath, ['--input-type=module', '-e', code], { env: { ...process.env, TEST_HOME: f.home, TEST_ISSUER: f.issuer }, stdio: 'ignore' }); - proc.once('error', reject); proc.once('exit', code => code === 0 ? resolve() : reject(new Error('Child failed'))); - }); - await Promise.all([child(), child()]); - assert.equal(f.state.refreshes, 1); -}); - -test('ambiguous refresh failure persists journal and prevents token replay', async t => { - const f = await fixture(t); await f.store.login(f.approve); await expire(f); - f.state.refreshFailure = true; - await assert.rejects(f.store.access(), /HTTP 503/); - assert.equal((await f.store.read()).refresh_pending, true); - f.state.refreshFailure = false; - await assert.rejects(f.store.access(), /rotation did not commit/); - assert.equal(f.state.refreshes, 1); -}); - -test('crash marker blocks refresh even in a newly constructed adapter', async t => { - const f = await fixture(t); await f.store.login(f.approve); - await f.store.write({ ...await f.store.read(), refresh_pending: true }); - await assert.rejects(new SessionStore(f.oauth, f.home).access(), /rotation did not commit/); - assert.equal(f.state.refreshes, 0); -}); - -test('failed revocation keeps local credentials; local-only deletion is explicit', async t => { - const f = await fixture(t); await f.store.login(f.approve); - f.state.revokeFailure = true; - await assert.rejects(f.store.logout(), /HTTP 503/); - assert.ok(await f.store.read()); - await f.store.logout(true); - assert.equal(await f.store.read(), null); - assert.equal(f.state.revocations, 0); -}); - -test('logout revokes the token family and then deletes local credentials', async t => { - const f = await fixture(t); await f.store.login(f.approve); await f.store.logout(); - assert.equal(f.state.revocations, 1); - assert.equal(await f.store.read(), null); -}); - -test('different issuers cannot consume or replace the same login', async t => { - const f = await fixture(t); await f.store.login(f.approve); - const other = new SessionStore(new OAuth('https://another.test'), f.home); - await assert.rejects(other.access(), /another issuer/); - await assert.rejects(other.login(() => {}), /another issuer/); - assert.equal((await f.store.read()).issuer, f.issuer); -}); - -test('corrupt storage is preserved rather than silently overwritten', async t => { - const f = await fixture(t); await f.store.prepare(); - await writeFile(f.store.path, 'not-json', { mode: 0o600 }); - await assert.rejects(f.store.login(() => {}), /not overwritten/); - assert.equal(await readFile(f.store.path, 'utf8'), 'not-json'); -}); - -test('unlock refuses a live process lock', async t => { - const f = await fixture(t); await f.store.prepare(); - await writeFile(f.store.lockPath, JSON.stringify({ pid: process.pid }), { mode: 0o600 }); - await assert.rejects(f.store.unlock(), /still running/); -}); - -test('credential directory symlinks are rejected', { skip: process.platform === 'win32' }, async t => { - const f = await fixture(t); - const alias = join(f.home, 'alias'); await symlink(f.home, alias); - await assert.rejects(new SessionStore(f.oauth, alias).prepare(), /real private directory/); -}); From 2bf8d4b03d9589b5c70667e997d031fb40a49ad1 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:08:55 +0800 Subject: [PATCH 4/9] docs: describe Codewhale provider submodule --- docs/codewhale-provider.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/codewhale-provider.md b/docs/codewhale-provider.md index d218edbef..7a5617d3c 100644 --- a/docs/codewhale-provider.md +++ b/docs/codewhale-provider.md @@ -1,11 +1,9 @@ # Codewhale LMM OAuth adapter -Source: `packages/codewhale-lmm-provider`. This is presently an ordinary directory, -not a Git submodule: the remote repository creation operation is not available -through the active GitHub connector. The package's explicit maintainer publication -script creates `TokenNotIncluded/codewhale-lmm-provider`, pushes committed source, -and proposes conversion into a pinned submodule in a separate parent PR. No -nonexistent remote is added to `.gitmodules`. +Source: `packages/codewhale-lmm-provider`, pinned as a Git submodule to +`TokenNotIncluded/codewhale-lmm-provider`. Adapter source, tests, CI and package +metadata live in the independent repository; this parent repository owns the +server-side OAuth client registration and the pinned submodule revision. The backend registers public native client `lmm-codewhale` / `LMM for Codewhale`. It reuses the existing PKCE S256, state/issuer-bound loopback callback, resource, From d90a62090e3c24cd0f9e7d5f9e44eefe14954095 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:09:20 +0800 Subject: [PATCH 5/9] chore: update Codewhale provider submodule --- packages/codewhale-lmm-provider | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/codewhale-lmm-provider b/packages/codewhale-lmm-provider index 651b874e6..8c78be0f9 160000 --- a/packages/codewhale-lmm-provider +++ b/packages/codewhale-lmm-provider @@ -1 +1 @@ -Subproject commit 651b874e63d0ff5fb75c21ee47a444c145b6c6ba +Subproject commit 8c78be0f936fb8f508badabc0195cdb21442a75d From 9feb6891302c70f522bc45ae96157d5e8b840427 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:37:28 +0800 Subject: [PATCH 6/9] chore(scripts): pin Codewhale one-click setup and updated menus References TokenNotIncluded/lmm-scripts#5. Update only the lmm-scripts gitlink; preserve the provider, Pi and DSH revisions. --- packages/lmm-scripts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lmm-scripts b/packages/lmm-scripts index 16b709885..856bf5c34 160000 --- a/packages/lmm-scripts +++ b/packages/lmm-scripts @@ -1 +1 @@ -Subproject commit 16b70988562e57a1b583b4134e59c360844bc15d +Subproject commit 856bf5c3481a5f184397afe90db978a83be3b5a7 From ee6c8d6393bb87d2212078faf60b9d2903baad70 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:42:18 +0800 Subject: [PATCH 7/9] chore(scripts): update Codewhale installer CI fixes --- packages/lmm-scripts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lmm-scripts b/packages/lmm-scripts index 856bf5c34..d7dd5e6bd 160000 --- a/packages/lmm-scripts +++ b/packages/lmm-scripts @@ -1 +1 @@ -Subproject commit 856bf5c3481a5f184397afe90db978a83be3b5a7 +Subproject commit d7dd5e6bd512e5d9e8057dc936cd1c87c9158c1f From 28c2f94939f1579ea4880ce2c23a976af1723acd Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:47:33 +0800 Subject: [PATCH 8/9] chore(scripts): pin verified installer and PowerShell test fixes --- packages/lmm-scripts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lmm-scripts b/packages/lmm-scripts index d7dd5e6bd..b76a40bd9 160000 --- a/packages/lmm-scripts +++ b/packages/lmm-scripts @@ -1 +1 @@ -Subproject commit d7dd5e6bd512e5d9e8057dc936cd1c87c9158c1f +Subproject commit b76a40bd971846bd883731e022395358c6202a9d From 4c8ce20f7cb68a09603b8aed3283a32c67158f0e Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 22 Sep 2026 03:51:23 +0800 Subject: [PATCH 9/9] chore(scripts): pin Codewhale installer with cross-platform argv regression coverage --- packages/lmm-scripts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lmm-scripts b/packages/lmm-scripts index b76a40bd9..32a636358 160000 --- a/packages/lmm-scripts +++ b/packages/lmm-scripts @@ -1 +1 @@ -Subproject commit b76a40bd971846bd883731e022395358c6202a9d +Subproject commit 32a636358c80274bd4431c939fb6c060e61d1145