Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
MIT License

Copyright (c) 2025 ai-bridge contributors
Copyright (c) 2025 agentremote contributors
Copyright (c) 2025 Automattic Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
4 changes: 2 additions & 2 deletions bridges/ai/bridge_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ func aiBridgeProtocolIDForPortal(portal *bridgev2.Portal) string {
}
}

func applyAIBridgeInfo(portal *bridgev2.Portal, meta *PortalMetadata, content *event.BridgeEventContent) {
func applyAgentRemoteBridgeInfo(portal *bridgev2.Portal, meta *PortalMetadata, content *event.BridgeEventContent) {
if portal == nil {
return
}
agentremote.ApplyAIBridgeInfo(content, aiBridgeProtocolIDForPortal(portal), portal.RoomType, integrationPortalAIKind(meta))
agentremote.ApplyAgentRemoteBridgeInfo(content, aiBridgeProtocolIDForPortal(portal), portal.RoomType, integrationPortalAIKind(meta))
}
8 changes: 4 additions & 4 deletions bridges/ai/bridge_info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestIntegrationPortalAIKind(t *testing.T) {
})
}

func TestApplyAIBridgeInfo(t *testing.T) {
func TestApplyAgentRemoteBridgeInfo(t *testing.T) {
t.Run("visible dm rooms stay dm", func(t *testing.T) {
portal := &bridgev2.Portal{Portal: &database.Portal{
RoomType: database.RoomTypeDM,
Expand All @@ -45,7 +45,7 @@ func TestApplyAIBridgeInfo(t *testing.T) {
}}
content := &event.BridgeEventContent{}

applyAIBridgeInfo(portal, nil, content)
applyAgentRemoteBridgeInfo(portal, nil, content)

if content.Protocol.ID != aiBridgeProtocolID {
t.Fatalf("expected protocol id %q, got %q", aiBridgeProtocolID, content.Protocol.ID)
Expand All @@ -64,7 +64,7 @@ func TestApplyAIBridgeInfo(t *testing.T) {
}}
content := &event.BridgeEventContent{}

applyAIBridgeInfo(portal, nil, content)
applyAgentRemoteBridgeInfo(portal, nil, content)

if content.Protocol.ID != "beeper" {
t.Fatalf("expected protocol id %q, got %q", "beeper", content.Protocol.ID)
Expand All @@ -85,7 +85,7 @@ func TestApplyAIBridgeInfo(t *testing.T) {
}
content := &event.BridgeEventContent{}

applyAIBridgeInfo(portal, meta, content)
applyAgentRemoteBridgeInfo(portal, meta, content)

if content.BeeperRoomTypeV2 != "group" {
t.Fatalf("expected group room type, got %q", content.BeeperRoomTypeV2)
Expand Down
2 changes: 1 addition & 1 deletion bridges/ai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ func (oc *AIClient) GetApprovalHandler() agentremote.ApprovalReactionHandler {
}

const (
openRouterAppReferer = "https://developers.beeper.com/ai-bridge"
openRouterAppReferer = "https://developers.beeper.com/agentremote"
openRouterAppTitle = "AI Chats for Beeper"
)

Expand Down
2 changes: 1 addition & 1 deletion bridges/ai/constructors.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func NewAIConnector() *OpenAIConnector {
NewLogin: func() any { return &UserLoginMetadata{} },
NewGhost: func() any { return &GhostMetadata{} },
FillBridgeInfo: func(portal *bridgev2.Portal, content *event.BridgeEventContent) {
applyAIBridgeInfo(portal, portalMeta(portal), content)
applyAgentRemoteBridgeInfo(portal, portalMeta(portal), content)
},
LoadLogin: func(_ context.Context, login *bridgev2.UserLogin) error {
return oc.loadAIUserLogin(login, loginMetadata(login))
Expand Down
6 changes: 3 additions & 3 deletions bridges/ai/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,17 @@ var (
// Pre-defined bridgev2.RespError constants for consistent error responses
var (
ErrAPIKeyRequired = bridgev2.RespError{
ErrCode: "IO.AI_BRIDGE.API_KEY_REQUIRED",
ErrCode: "COM.BEEPER.AGENTREMOTE.AI.API_KEY_REQUIRED",
Err: "Enter an API key.",
StatusCode: http.StatusBadRequest,
}
ErrBaseURLRequired = bridgev2.RespError{
ErrCode: "IO.AI_BRIDGE.BASE_URL_REQUIRED",
ErrCode: "COM.BEEPER.AGENTREMOTE.AI.BASE_URL_REQUIRED",
Err: "Enter a base URL.",
StatusCode: http.StatusBadRequest,
}
ErrOpenAIOrOpenRouterRequired = bridgev2.RespError{
ErrCode: "IO.AI_BRIDGE.OPENAI_OR_OPENROUTER_REQUIRED",
ErrCode: "COM.BEEPER.AGENTREMOTE.AI.OPENAI_OR_OPENROUTER_REQUIRED",
Err: "Enter an OpenAI or OpenRouter API key.",
StatusCode: http.StatusBadRequest,
}
Expand Down
12 changes: 12 additions & 0 deletions bridges/ai/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ import (
"github.com/openai/openai-go/v3"
)

func TestAILoginErrorCodesUseAgentRemoteNamespace(t *testing.T) {
if ErrAPIKeyRequired.ErrCode != "COM.BEEPER.AGENTREMOTE.AI.API_KEY_REQUIRED" {
t.Fatalf("unexpected api key errcode: %q", ErrAPIKeyRequired.ErrCode)
}
if ErrBaseURLRequired.ErrCode != "COM.BEEPER.AGENTREMOTE.AI.BASE_URL_REQUIRED" {
t.Fatalf("unexpected base url errcode: %q", ErrBaseURLRequired.ErrCode)
}
if ErrOpenAIOrOpenRouterRequired.ErrCode != "COM.BEEPER.AGENTREMOTE.AI.OPENAI_OR_OPENROUTER_REQUIRED" {
t.Fatalf("unexpected openai/openrouter errcode: %q", ErrOpenAIOrOpenRouterRequired.ErrCode)
}
}

func testOpenAIError(statusCode int, code, typ, message string) *openai.Error {
return &openai.Error{
StatusCode: statusCode,
Expand Down
29 changes: 18 additions & 11 deletions bridges/ai/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
Expand All @@ -12,6 +13,7 @@ import (
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"

"github.com/beeper/agentremote"
"github.com/beeper/agentremote/pkg/shared/stringutil"
)

Expand All @@ -37,6 +39,11 @@ var (
_ bridgev2.LoginProcess = (*OpenAILogin)(nil)
_ bridgev2.LoginProcessWithOverride = (*OpenAILogin)(nil)
_ bridgev2.LoginProcessUserInput = (*OpenAILogin)(nil)

errAIReloginTargetInvalid = agentremote.NewLoginRespError(http.StatusBadRequest, "Invalid relogin target.", "AI", "INVALID_RELOGIN_TARGET")
errAIManagedBeeperRelogin = agentremote.NewLoginRespError(http.StatusForbidden, "Managed Beeper Cloud logins are controlled by bridge configuration.", "AI", "MANAGED_BEEPER_RELOGIN_FORBIDDEN")
errAIMissingUserContext = agentremote.NewLoginRespError(http.StatusInternalServerError, "Missing user context for login.", "AI", "MISSING_USER_CONTEXT")
errAIMissingReloginMeta = agentremote.NewLoginRespError(http.StatusInternalServerError, "Missing relogin metadata.", "AI", "MISSING_RELOGIN_METADATA")
)

// OpenAILogin maps a Matrix user to a synthetic OpenAI "login".
Expand Down Expand Up @@ -87,7 +94,7 @@ func (ol *OpenAILogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
}
return ol.finishLogin(ctx, provider, apiKey, "", serviceTokens)
default:
return nil, fmt.Errorf("login flow %s is not available", ol.FlowID)
return nil, bridgev2.ErrInvalidLoginFlowID
}
}

Expand All @@ -98,10 +105,10 @@ func (ol *OpenAILogin) StartWithOverride(ctx context.Context, old *bridgev2.User
return ol.Start(ctx)
}
if ol.User == nil || old.UserMXID != ol.User.MXID {
return nil, errors.New("invalid relogin target")
return nil, errAIReloginTargetInvalid
}
if old.ID == managedBeeperLoginID(old.UserMXID) {
return nil, errors.New("managed Beeper Cloud logins are controlled by bridge configuration")
return nil, errAIManagedBeeperRelogin
}
ol.Override = old
return ol.Start(ctx)
Expand Down Expand Up @@ -155,7 +162,7 @@ func (ol *OpenAILogin) SubmitUserInput(ctx context.Context, input map[string]str
}
return ol.finishLogin(ctx, provider, apiKey, "", serviceTokens)
default:
return nil, fmt.Errorf("login flow %s is not available", ol.FlowID)
return nil, bridgev2.ErrInvalidLoginFlowID
}
}

Expand Down Expand Up @@ -222,7 +229,7 @@ func (ol *OpenAILogin) credentialsStep() *bridgev2.LoginStep {

return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeUserInput,
StepID: "io.ai-bridge.openai.enter_credentials",
StepID: "com.beeper.agentremote.openai.enter_credentials",
Instructions: "Enter your API credentials",
UserInputParams: &bridgev2.LoginUserInputParams{
Fields: fields,
Expand All @@ -235,17 +242,17 @@ func (ol *OpenAILogin) finishLogin(ctx context.Context, provider, apiKey, baseUR
apiKey = strings.TrimSpace(apiKey)
baseURL = stringutil.NormalizeBaseURL(baseURL)
if ol.User == nil {
return nil, errors.New("missing user context for login")
return nil, errAIMissingUserContext
}

override := ol.Override
if override != nil {
overrideMeta := loginMetadata(override)
if overrideMeta == nil {
return nil, errors.New("missing relogin metadata")
return nil, errAIMissingReloginMeta
}
if !strings.EqualFold(normalizeProvider(overrideMeta.Provider), provider) {
return nil, fmt.Errorf("can't relogin %s account with %s credentials", overrideMeta.Provider, provider)
return nil, agentremote.NewLoginRespError(http.StatusBadRequest, fmt.Sprintf("Can't relogin %s account with %s credentials.", overrideMeta.Provider, provider), "AI", "PROVIDER_MISMATCH")
}
}

Expand All @@ -266,7 +273,7 @@ func (ol *OpenAILogin) finishLogin(ctx context.Context, provider, apiKey, baseUR
if override != nil {
meta, err = cloneUserLoginMetadata(loginMetadata(override))
if err != nil {
return nil, fmt.Errorf("failed to clone relogin metadata: %w", err)
return nil, agentremote.WrapLoginRespError(fmt.Errorf("failed to clone relogin metadata: %w", err), http.StatusInternalServerError, "AI", "CLONE_RELOGIN_METADATA_FAILED")
}
}
if meta == nil {
Expand All @@ -288,7 +295,7 @@ func (ol *OpenAILogin) finishLogin(ctx context.Context, provider, apiKey, baseUR
Metadata: meta,
}, nil)
if err != nil {
return nil, fmt.Errorf("failed to create login: %w", err)
return nil, agentremote.WrapLoginRespError(fmt.Errorf("failed to create login: %w", err), http.StatusInternalServerError, "AI", "CREATE_LOGIN_FAILED")
}

// Trigger connection in background with a long-lived context
Expand All @@ -297,7 +304,7 @@ func (ol *OpenAILogin) finishLogin(ctx context.Context, provider, apiKey, baseUR

return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete,
StepID: "io.ai-bridge.openai.complete",
StepID: "com.beeper.agentremote.openai.complete",
CompleteParams: &bridgev2.LoginCompleteParams{
UserLoginID: login.ID,
UserLogin: login,
Expand Down
68 changes: 68 additions & 0 deletions bridges/ai/login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package ai

import (
"context"
"errors"
"testing"

"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/id"
)

func TestOpenAILoginStartRejectsInvalidFlow(t *testing.T) {
login := &OpenAILogin{FlowID: "invalid"}
_, err := login.Start(context.Background())
if !errors.Is(err, bridgev2.ErrInvalidLoginFlowID) {
t.Fatalf("expected invalid login flow error, got %v", err)
}
}

func TestOpenAILoginStartWithOverrideRejectsInvalidTarget(t *testing.T) {
login := &OpenAILogin{User: &bridgev2.User{User: &database.User{MXID: id.UserID("@alice:example.com")}}}
old := &bridgev2.UserLogin{UserLogin: &database.UserLogin{UserMXID: id.UserID("@bob:example.com")}}
_, err := login.StartWithOverride(context.Background(), old)
var respErr bridgev2.RespError
if !errors.As(err, &respErr) {
t.Fatalf("expected RespError, got %T", err)
}
if respErr.ErrCode != "COM.BEEPER.AGENTREMOTE.AI.INVALID_RELOGIN_TARGET" {
t.Fatalf("unexpected errcode: %q", respErr.ErrCode)
}
}

func TestOpenAILoginStartWithOverrideRejectsManagedBeeperRelogin(t *testing.T) {
mxid := id.UserID("@alice:example.com")
login := &OpenAILogin{User: &bridgev2.User{User: &database.User{MXID: mxid}}}
old := &bridgev2.UserLogin{UserLogin: &database.UserLogin{ID: managedBeeperLoginID(mxid), UserMXID: mxid}}
_, err := login.StartWithOverride(context.Background(), old)
var respErr bridgev2.RespError
if !errors.As(err, &respErr) {
t.Fatalf("expected RespError, got %T", err)
}
if respErr.ErrCode != "COM.BEEPER.AGENTREMOTE.AI.MANAGED_BEEPER_RELOGIN_FORBIDDEN" {
t.Fatalf("unexpected errcode: %q", respErr.ErrCode)
}
}

func TestOpenAILoginFinishLoginRejectsProviderMismatch(t *testing.T) {
mxid := id.UserID("@alice:example.com")
login := &OpenAILogin{
User: &bridgev2.User{User: &database.User{MXID: mxid}},
Override: &bridgev2.UserLogin{
UserLogin: &database.UserLogin{
ID: "login",
UserMXID: mxid,
Metadata: &UserLoginMetadata{Provider: ProviderOpenRouter},
},
},
}
_, err := login.finishLogin(context.Background(), ProviderOpenAI, "key", "", nil)
var respErr bridgev2.RespError
if !errors.As(err, &respErr) {
t.Fatalf("expected RespError, got %T", err)
}
if respErr.ErrCode != "COM.BEEPER.AGENTREMOTE.AI.PROVIDER_MISMATCH" {
t.Fatalf("unexpected errcode: %q", respErr.ErrCode)
}
}
2 changes: 1 addition & 1 deletion bridges/ai/mcp_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func (oc *AIClient) newMCPSession(ctx context.Context, server namedMCPServer) (*
}

client := mcp.NewClient(&mcp.Implementation{
Name: "ai-bridge",
Name: "agentremote",
Version: "1.0.0",
}, nil)

Expand Down
2 changes: 1 addition & 1 deletion bridges/ai/media_understanding_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func runMediaCLI(
return "", errors.New("missing cli command")
}

outputDir, err := os.MkdirTemp("", "ai-bridge-media-cli-*")
outputDir, err := os.MkdirTemp("", "agentremote-media-cli-*")
if err != nil {
return "", err
}
Expand Down
2 changes: 1 addition & 1 deletion bridges/ai/media_understanding_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ func (oc *AIClient) runMediaUnderstandingEntry(
return nil, err
}
fileName := resolveMediaFileName(attachment.FileName, string(capability), attachment.URL)
tempDir, err := os.MkdirTemp("", "ai-bridge-media-*")
tempDir, err := os.MkdirTemp("", "agentremote-media-*")
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion bridges/ai/tool_approvals_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func (oc *AIClient) builtinToolApprovalRequirement(toolName string, args map[str
switch action {
// Read-only / non-destructive actions (do not require approval).
case "reactions", "search", "read", "member-info", "channel-info", "list-pins",
// Desktop API read-only surface (ai-bridge message tool actions).
// Desktop API read-only surface (agentremote message tool actions).
"desktop-list-chats", "desktop-search-chats", "desktop-search-messages", "desktop-download-asset":
return false, action
default:
Expand Down
2 changes: 1 addition & 1 deletion bridges/ai/tool_policy_chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func (oc *AIClient) buildToolPolicyContext(meta *PortalMetadata) toolPolicyConte
}

// Treat OpenClaw-reserved tool names as "core" for allowlist validation even if
// ai-bridge doesn't expose them in this runtime. This avoids unsafe behavior where
// agentremote doesn't expose them in this runtime. This avoids unsafe behavior where
// an allowlist like ["exec"] or ["group:runtime"] is treated as "unknown" and gets
// stripped (widening access).
for _, name := range []string{"exec", "process", "browser", "canvas", "nodes", "gateway"} {
Expand Down
2 changes: 1 addition & 1 deletion bridges/ai/tool_policy_chain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ func TestBuildToolPolicyContext_TreatsOpenClawReservedToolsAsCore(t *testing.T)
oc := &AIClient{}
ctx := oc.buildToolPolicyContext(nil)

// These tools may not be exposed by ai-bridge, but configs may refer to them.
// These tools may not be exposed by agentremote, but configs may refer to them.
// We still want them considered "core" so allowlists don't get stripped.
for _, name := range []string{"exec", "process", "browser", "canvas", "nodes", "gateway"} {
if _, ok := ctx.coreTools[name]; !ok {
Expand Down
6 changes: 3 additions & 3 deletions bridges/ai/tools_beeper_feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ func executeBeeperSendFeedback(ctx context.Context, args map[string]any) (string
feedbackType = strings.TrimSpace(t)
}

// Prepend ai-bridge tag
text = "ai-bridge: " + text
// Prepend agentremote tag
text = "agentremote: " + text

// Best-effort: include a stable login id (not PII) when available.
loginID := ""
Expand All @@ -46,7 +46,7 @@ func executeBeeperSendFeedback(ctx context.Context, args map[string]any) (string
"type": feedbackType,
"app": "beeper-a8c-desktop",
"os": runtime.GOOS,
"user_agent": "ai-bridge/1.0",
"user_agent": "agentremote/1.0",
}
if loginID != "" {
fields["login_id"] = loginID
Expand Down
Loading
Loading