Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/subsystems/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ These pages describe current responsibilities, runtime paths, state, and contrac
| Claude subscription turns across Claude Code runs | [Claude subscription bridge](claude-subscription-bridge.md) |
| How the gateway picks a provider, key or account, and falls back | [Gateway routing and fallback](gateway-routing.md) |
| Gateway Session Aggregation | [Gateway Session Aggregation](gateway-sessions.md) |
| Claude for Microsoft 365 over local HTTPS | [Claude for Microsoft 365 HTTPS gateway](m365.md) |
| Connecting an agent's config to the gateway, drift, disconnect, WSL twins | [Agent wiring](agent-wiring.md) |
| Providers, keys, subscription accounts, refresh and allowance | [Providers and accounts](providers-accounts.md) |
| Instructions, MCP servers, skills and RTK written into agents | [Library](library.md) |
Expand Down
67 changes: 67 additions & 0 deletions docs/subsystems/m365.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Claude for Microsoft 365 HTTPS gateway

Claude's Word, Excel and PowerPoint add-ins run in a browser control. Their
configured gateway therefore has to be trusted HTTPS and has to answer the
browser's CORS and Private Network Access preflight. Magpie keeps its ordinary
HTTP gateway for agents and, when the user turns Microsoft 365 support on,
serves the same routing handler on a second loopback-only HTTPS listener.

## Responsibilities and sources of truth

| Part | Responsibility | Source |
| --- | --- | --- |
| Setting | `Settings.M365` enables the listener. `M365KeyID` points to the normal named gateway key used by the add-in; the credential remains in `caller-keys.json`. | [`settings.go`](../../internal/settings/settings.go) |
| HTTPS listener | Binds `127.0.0.1:8787`, TLS 1.2 or newer, and exposes only `GET /v1/models`, `POST /v1/messages` and their preflight. It passes requests to the same `Server.Handler` as the HTTP gateway, so routing, usage, limits and streaming are not duplicated. | [`m365.go`](../../internal/gateway/m365.go) |
| Certificate | Generates a local CA with critical DNS/IP NameConstraints for only `localhost`, `127.0.0.1` and `::1`, then a leaf for those names. The leaf private key is mode 0600; the CA private key is never written. | [`m365_cert.go`](../../internal/gateway/m365_cert.go) |
| Browser boundary | Enabling adds `https://pivot.claude.ai` to the existing origin allowlist; disabling removes it. The existing CORS guard answers PNA and requires an enabled named gateway key from every allowed page. | [`cors.go`](../../internal/gateway/cors.go), [`m365.go`](../../internal/gui/m365.go) |
| UI and lifecycle | Settings shows the endpoint, certificate state and key. The desktop process starts and stops the HTTPS listener with the gateway it owns; another Magpie process serving the gateway cannot be configured from this one. | [`backend.go`](../../internal/gui/backend.go), [`app.js`](../../internal/gui/assets/app.js) |

## Runtime path

1. The user turns Claude for Microsoft 365 on in Settings.
2. Magpie creates or reuses the named `Claude for Microsoft 365` gateway key,
adds the add-in origin, saves the setting and restarts its gateway.
3. The gateway creates the certificate files if needed and listens only on
`https://127.0.0.1:8787`. Certificate creation does not trust it.
4. On macOS the user explicitly chooses **Trust certificate**, which adds it
to the user's default keychain. Other systems show the certificate path for
manual trust. The automatic Windows action remains unavailable through the
UI and API until its full install, Office use and removal path has been
exercised on Windows.
5. The add-in sends its key by `x-api-key` or Bearer auth. `lanGuard`,
`corsGuard`, caller limits and the regular Anthropic route all run as for
any named gateway caller.
6. Turning support off first saves the setting as off, removes the add-in
origin and stops the HTTPS listener. On macOS it then removes Magpie's CA
from the user's trust store; other systems tell the user to remove a
manually installed CA. A trust-store error cannot leave the integration
on: it is reported with manual removal instructions. The named key remains
so a later enable can reuse it; it may be revoked from Gateway keys at any
time.

## Constraints and failure behavior

- Port 8787 is fixed because the add-in's Gateway sign-in defaults to
`https://127.0.0.1:8787`; its URL field can be edited, but Magpie keeps the
documented default. A process already using it leaves Microsoft 365 support
stopped and its error is shown in Settings, including after the switch rolls
back to off.
- The HTTPS listener never binds a LAN address and exposes none of Magpie's
quota, MCP or administration routes.
- A listed origin without an enabled key is refused, as every browser caller
is. An unlisted remote web page is refused before it reaches a provider.
- Automatic trust management is exposed on macOS. Windows keeps the command
implementation and tests, but its UI and API actions remain disabled until a
real Windows Office run verifies installation, use and removal; it and other
systems show the certificate path for manual installation.
- Regenerating an invalid, unconstrained or near-expiry certificate first
removes the old CA if it is trusted, then creates a new CA because the CA
private key is intentionally not retained; the new CA must be trusted.

## Verification

```sh
go test ./internal/gateway -run TestM365 -count=1
go test ./internal/gui -run TestM365 -count=1
go test -tags nogui ./...
```
8 changes: 8 additions & 0 deletions internal/gateway/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ func corsGuard(next http.Handler) http.Handler {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Access-Control-Expose-Headers", SessionHeader)
// Claude for Microsoft 365 asks its gateway with credentials and
// reads Anthropic's request and rate-limit headers.
if origin == M365Origin {
h.Set("Access-Control-Allow-Credentials", "true")
for _, name := range []string{"request-id", "anthropic-ratelimit-requests-limit", "anthropic-ratelimit-requests-remaining", "anthropic-ratelimit-requests-reset", "anthropic-ratelimit-tokens-limit", "anthropic-ratelimit-tokens-remaining", "anthropic-ratelimit-tokens-reset"} {
h.Add("Access-Control-Expose-Headers", name)
}
}
h.Add("Vary", "Origin")
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
Expand Down
152 changes: 152 additions & 0 deletions internal/gateway/m365.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package gateway

import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
)

const (
M365Origin = "https://pivot.claude.ai"
M365DefaultPort = 8787
)

// m365ListenAddr is a var so tests take an ephemeral port. The product's
// endpoint is fixed and predictable for the Office add-in.
var m365ListenAddr = fmt.Sprintf("127.0.0.1:%d", M365DefaultPort)

// M365Status is the second, loopback-only HTTPS endpoint Claude for
// Microsoft 365 calls. It serves the same Handler as the ordinary gateway.
type M365Status struct {
Running bool `json:"running"`
URL string `json:"url"`
Error string `json:"error,omitempty"`
Certificate M365CertificateStatus `json:"certificate"`
}

func M365URL() string { return "https://" + m365ListenAddr }

// M365Server owns only the TLS listener. Provider state and routing remain
// on Server, whose Handler is shared by both listeners.
type M365Server struct {
mu sync.Mutex
server *http.Server
ln net.Listener
err string
}

func NewM365Server() *M365Server { return &M365Server{} }

func (m *M365Server) ClearError() {
m.mu.Lock()
m.err = ""
m.mu.Unlock()
}

// SetError records a listener failure for a caller that owns lifecycle and
// rollback, such as the Settings route after a restart attempt.
func (m *M365Server) SetError(err error) { m.failed(err) }

func (m *M365Server) failed(err error) error {
m.mu.Lock()
m.err = err.Error()
m.mu.Unlock()
return err
}

func (m *M365Server) Status() M365Status {
m.mu.Lock()
running, last := m.ln != nil, m.err
m.mu.Unlock()
return M365Status{Running: running, URL: M365URL(), Error: last, Certificate: M365Certificate()}
}

// Start prepares the local certificate and serves handler over TLS. The CA
// isn't trusted here: that system change is a separate, explicit action.
func (m *M365Server) Start(ctx context.Context, handler http.Handler) error {
f := m365CertificatePaths()
if err := prepareM365Certificate(ctx, f); err != nil {
return m.failed(err)
}
pair, err := tls.LoadX509KeyPair(f.serverCert, f.serverKey)
if err != nil {
return m.failed(fmt.Errorf("load Microsoft 365 certificate: %w", err))
}
ln, err := Listen(m365ListenAddr)
if err != nil {
return m.failed(fmt.Errorf("listen for Microsoft 365 at %s: %w", M365URL(), err))
}
srv := &http.Server{
Handler: lanGuard(m365Only(handler)), ReadHeaderTimeout: 30 * time.Second, IdleTimeout: 5 * time.Minute,
TLSConfig: &tls.Config{Certificates: []tls.Certificate{pair}, MinVersion: tls.VersionTLS12},
}
tlsLn := tls.NewListener(ln, srv.TLSConfig)
m.mu.Lock()
if m.ln != nil {
m.mu.Unlock()
ln.Close()
return m.failed(errors.New("Microsoft 365 HTTPS gateway is already running"))
}
m.server, m.ln, m.err = srv, tlsLn, ""
m.mu.Unlock()
go func() {
<-ctx.Done()
c, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = m.stop(c, srv)
}()
go func() {
err := srv.Serve(tlsLn)
m.mu.Lock()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
m.err = err.Error()
}
if m.server == srv {
m.server, m.ln = nil, nil
}
m.mu.Unlock()
}()
return nil
}

func (m *M365Server) Stop(ctx context.Context) error {
m.mu.Lock()
srv := m.server
m.mu.Unlock()
return m.stop(ctx, srv)
}

// stop closes only the generation it was handed. A gateway restart can have
// started a replacement by the time the old context ends; the old one must
// never stop the new listener.
func (m *M365Server) stop(ctx context.Context, srv *http.Server) error {
if srv == nil {
return nil
}
m.mu.Lock()
if m.server == srv {
m.server, m.ln = nil, nil
}
m.mu.Unlock()
return srv.Shutdown(ctx)
}

// m365Only keeps the Office-facing listener to the two Anthropic routes the
// add-in needs. OPTIONS reaches corsGuard inside handler for its preflight.
func m365Only(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
allowed := r.Method == http.MethodOptions && (r.URL.Path == "/v1/models" || r.URL.Path == "/v1/messages") ||
r.Method == http.MethodGet && r.URL.Path == "/v1/models" ||
r.Method == http.MethodPost && r.URL.Path == "/v1/messages"
if !allowed {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
Loading
Loading