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/.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/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..7a5617d3c --- /dev/null +++ b/docs/codewhale-provider.md @@ -0,0 +1,36 @@ +# Codewhale LMM OAuth adapter + +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, +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, 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`. +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 b/packages/codewhale-lmm-provider new file mode 160000 index 000000000..8c78be0f9 --- /dev/null +++ b/packages/codewhale-lmm-provider @@ -0,0 +1 @@ +Subproject commit 8c78be0f936fb8f508badabc0195cdb21442a75d diff --git a/packages/lmm-scripts b/packages/lmm-scripts index 16b709885..32a636358 160000 --- a/packages/lmm-scripts +++ b/packages/lmm-scripts @@ -1 +1 @@ -Subproject commit 16b70988562e57a1b583b4134e59c360844bc15d +Subproject commit 32a636358c80274bd4431c939fb6c060e61d1145