Main-process design notes
The main-process instructions contain the active rules and file ownership map. This document explains the decisions and the failures that led to them.
IPC coverage and privileged imports
The main process runs with the user's full privileges. Agents already use danger-full-access;
Electron sandboxing, context isolation, navigation policy and sender validation protect the process
boundary that remains. A renderer feature must be assessed as a capability available to a
compromised renderer.
IPC domain modules return typed handler groups. Missing endpoints produce TS2741; extra endpoint
names produce TS2353. Missing groups in registerIpcGroups also produce TS2741. This replaced a
source scan that found modules which the dispatcher did not call. Removing both a registrar import
and its spread now fails compilation.
The payload binding types make (input: unknown) => Result incompatible with the no-payload
() => Result form. This requires an explicit decoder. Decoding follows sender validation so an
untrusted frame cannot reach a parser.
ipc-channel-coverage.test.ts restricts the name ipcMain to trusted-ipc.ts, which holds the
sender check. An aliased import elsewhere could otherwise register a handler without a sender check.
The test restricts the trusted wrapper names to ipc/define-ipc-group.ts: the wrappers accept string
channels, so direct use could create an endpoint outside the declared groups. Direct IPC_ENDPOINTS.group.name references keep
renderer sends visible to the same coverage checks.
index.ts calls app.setPath, app.enableSandbox and
protocol.registerSchemesAsPrivileged at module scope. Tests that import it need an Electron mock.
Source coverage tests read the file instead.
Construction and shutdown
Service construction, windows, saved bounds, shutdown, development remote setup, session
configuration, renderer event forwarding and IPC handlers moved out of index.ts. The file keeps
lifecycle and dispatch work.
createApplicationServices remains one function because control-flow narrowing makes construction
order explicit: a service assigned to a const can satisfy later non-null constructor parameters.
Splitting it into stages would pass a roughly 15-field object between stages and restore ordering
hazards. It only constructs. Adding event wiring would require more inputs than outputs and invert
the dependency direction. A self-contained block with a few inputs, such as createMessagingServices,
can move to a helper in the same file that the function awaits at the same position; the helper keeps
its teardown ordinals.
Shutdown is largely in construction order, rather than reverse order. The browser host closes before its picture-in-picture window. Provider runtimes stop before the agent service that owns them. Teardown ordinals state this order beside construction instead of repeating it elsewhere.
Dependencies wired before app.whenReady() need getters because the services do not yet exist.
Capturing null there never recovers. The renderer forwarders and window module now read one
service handle each instead of eleven separate handles. Code wired during construction can receive
values directly. Both a getter and a nullable value can type-check, so the compiler cannot select
the correct lifetime.
A similar lifetime issue applies to windows: a constructor needs the current window, but a callback that runs later must obtain it again. macOS can close and rebuild the main window without ending the application.
MCP sign-in redirect address
An MCP server that asks its users to sign in sends the grant back to a redirect address. OpenBot
registers that address with the authorization server (RFC 7591) and sends it again at /authorize
and at /token. The three must agree, or the authorization server stops the sign-in on its own
page, where OpenBot can see nothing and can explain nothing. Canva does this: it accepts
openbot://mcp-auth at registration and then answers Invalid redirect URI. at /authorize.
So src/main/mcp-oauth-redirect-server.ts listens on 127.0.0.1 and the address it listens on is
the redirect address. This is the loopback redirect of RFC 8252, and it is the form every signed-in
listing in the catalog accepts. openbot://mcp-auth stays as the fallback for a machine where no
port can be bound, and src/main/deep-link-router.ts keeps that kind of link.
There is no configuration for a developer or for a packaged build:
- The port is the one the operating system gives, and it changes on each start. An authorization server must ignore the port of a loopback redirect, and the registration is dynamic, so no port is registered anywhere by hand.
- A stored registration that names a different address is replaced, but only for the attempt that
needs the browser.
clientInformation()insrc/backend/mcp-oauth-provider.tsanswersundefinedwhen the storedredirect_urisdo not hold this run's address, which makes the SDK register again. A record that still has a refresh token keeps its client for one pass first: a refresh uses no redirect address, and a refresh token spent against a newclient_idis refused and costs the user a sign-in. Two attempts never replace the registration: a silent refresh, and the exchange of a grant already in hand, which must use theclient_idthat grant was issued to. describeUnusableRedirectUrlrefuses an address that cannot receive a grant: anhttpsaddress, or a plain-text address that is not on this machine.- A registration endpoint that refuses the
127.0.0.1address withinvalid_redirect_uriorinvalid_client_metadatagets one more registration with thelocalhostform of the same address (#5584). The MCP authorization specification nameslocalhostredirects, and some servers accept only that name. The listener answers both names. A stored registration that names thelocalhostform keeps it:redirectUrlinsrc/backend/mcp-oauth-provider.tssends that form at/authorizeand at/token.
Remote services and released protocols
remote-server-manager.ts was 2,828 lines before its concerns moved to the flat remote-server-*
and remote-*-decoding family. The manager connects those modules through callbacks. The request
path does not need to know about the event stream, and the event stream does not need to know about
the error path. The callback connection preserves those dependency boundaries.
Team API routes use the same narrow dependency style as IPC handlers. Four behavior rules explain the dispatch order:
- Compatibility runs first so an old client can read how to update instead of receiving 426 on every request.
- Remote-screen delegation precedes the protocol gate because a browser that loads the viewer sends no protocol header or token.
- Authentication precedes route selection so an unknown path without a token returns 401.
- The dispatcher handles unmatched paths and methods. Released clients receive 404 for a wrong method on a known path.
One dispatcher catch keeps unexpected errors visible to the logger and returns 500. A local catch
could incorrectly convert them to 400. There is one HttpError class because instanceof would
not recognize a second definition and would turn an intended 400 into 500.
The explicit Promise<RouteOutcome> return annotation produces TS2366 when a route falls off the
end. This project has no noImplicitReturns guard. Without the annotation, inference can accept
undefined, which becomes a silent 404.
Host and preload decoders remain separate because they validate different trust boundaries. HTTPS
V1/V3 and WebRTC V2 remain separate because their encodings are released contracts. Control-plane
methods remain on RemoteTeamDirectory because they contact a different server from the host.
ipc-channel-coverage.test.ts checks a name bijection between decode*FromHost under src/main
and decode*FromMain in the preload. This prevents deleting one side and redirecting its callers
to the other, which could apply trusted-main validation to a remote server. A second test rejects a
decoder that is a bare alias of its twin. Review must still detect a wrapper that calls the other
decoder.