# Desktop RPC

> window.desktop.agent getInferenceRouter, setInferenceRouter, getBoxRuntime, setBoxRuntime, and secrets upsert/list channels.

- Repository: sashimikun/grok-bot-0.18-reconstructed
- GitHub: https://github.com/sashimikun/grok-bot-0.18-reconstructed
- Human docs: https://grok-wiki.com/public/docs/sashimikun-grok-bot-0-18-reconstructed-c774cc9a5c15
- Complete Markdown: https://grok-wiki.com/public/docs/sashimikun-grok-bot-0-18-reconstructed-c774cc9a5c15/llms-full.txt

## Source Files

- `source/shared/rpc/main.ts`
- `source/electron-main/main-edge.ts`
- `source/electron-preload/preload.ts`
- `source/electron-preload/main-rpc-runtime.ts`
- `source/shared/rpc/coordinator.ts`
- `source/electron-main/secrets/secrets-ipc.ts`

---

---
title: "Desktop RPC"
description: "window.desktop.agent getInferenceRouter, setInferenceRouter, getBoxRuntime, setBoxRuntime, and secrets upsert/list channels."
---

`window.desktop` is the context-bridge object the primary preload exposes into the renderer. Settings → Router calls `window.desktop.agent.getInferenceRouter`, `setInferenceRouter`, `getBoxRuntime`, and `setBoxRuntime` over the `main` RPC edge (`sand-rpc:main:m:<method>`). Secrets stay off that edge: `window.desktop.secrets.list` / `upsert` / `reveal` / `remove` invoke `sand:secrets-*` IPC, even though `MAIN_METHOD_TABLE` still lists `listSecrets` and `upsertSecrets` as unserved names.

## Transport split

Preload installs two renderer globals: `desktop` (this page) and `coordinatorPort` (MessagePort to the node-agent coordinator). Do not send router, box-runtime, or user-secret mutations through `coordinatorPort`.

```mermaid
flowchart TB
  subgraph renderer["Renderer"]
    Patch["Settings Router patch"]
    Desktop["window.desktop"]
  end
  subgraph preload["dist/electron-preload/preload.cjs"]
    Bridge["createDesktopPreloadBridge"]
    Edge["bridgeRpcEdge(main)"]
  end
  subgraph mainProc["Electron main"]
    Serve["serveEdge trust=appWindow"]
    Handlers["createMainEdgeHandlers"]
    SecretsIpc["registerSecretsIpc"]
    Settings["SandSettingsStore"]
    Docker["startLocalDockerBox / stopLocalDockerBox"]
    UserSecrets["SandUserSecretsStore"]
    Push["createBoxSecretsPush"]
  end
  Patch --> Desktop
  Desktop --> Bridge
  Bridge -->|"sand-rpc:main:m:*"| Edge
  Edge --> Serve
  Serve --> Handlers
  Handlers --> Settings
  Handlers --> Docker
  Bridge -->|"sand:secrets-*"| SecretsIpc
  SecretsIpc --> UserSecrets
  SecretsIpc --> Push
```

| Surface | Renderer API | Wire | Served by |
|---|---|---|---|
| Inference router | `window.desktop.agent.getInferenceRouter` / `setInferenceRouter` | `sand-rpc:main:m:getInferenceRouter`, `sand-rpc:main:m:setInferenceRouter` | `createMainEdgeHandlers` |
| Box runtime | `window.desktop.agent.getBoxRuntime` / `setBoxRuntime` | `sand-rpc:main:m:getBoxRuntime`, `sand-rpc:main:m:setBoxRuntime` | `createMainEdgeHandlers` |
| Secrets | `window.desktop.secrets.list` / `reveal` / `upsert` / `remove` | `sand:secrets-list`, `sand:secrets-reveal`, `sand:secrets-upsert`, `sand:secrets-delete` | `registerSecretsIpc` |
| Unserved main names | Do not call `edge("listSecrets")` / `upsertSecrets` | Same `sand-rpc:main:m:*` family | Throws `main/unserved-method` |

Contract constants:

- Edge name: `main` (`MAIN_RPC_CONTRACT_NAME`)
- Method channel: `sand-rpc:${edge}:m:${method}`
- Event channel: `sand-rpc:${edge}:e:${event}`
- Args: `get*` methods send `{}`; `setInferenceRouter` / `setBoxRuntime` send a single object

<Note>
`MAIN_METHOD_TABLE` still includes `listSecrets`, `revealSecret`, `upsertSecrets`, and `removeSecrets` so the shipped method registry stays complete. Those four handlers are `unserved()` and fail with `main/unserved-method` / `This method still rides its hand-wired preload channel.`
</Note>

## Trust

Every served main-edge method, including the four router/runtime methods, uses trust policy `appWindow`:

- Test: `sender.isAppWindowTopFrame === true`
- Denial: `The main edge is only accessible from the Sand app window's top frame.`
- Failure envelope: `{ ok: false, failure: { code: "edge/untrusted-sender", detail: "<denial>" } }`

Secrets and client-persistence IPC require the sender and sender frame to match the trusted app-window contents and main frame.

- Secrets denial: `Secrets are only accessible from the Sand app window.` (`UntrustedSecretsSenderError`)
- Client persistence denial: `Client persistence is only accessible from the Sand app window.`

## Reply envelope

`bridgeRpcEdge` unwraps `{ ok: true, value }` or throws `EdgeCallFailure` from `{ ok: false, failure: { code, detail } }`.

| Code | When |
|---|---|
| `edge/untrusted-sender` | Sender is not the app window top frame |
| `edge/unknown-method` | IPC invoke itself failed |
| `edge/handler-failed` | Handler threw a non-`EdgeCallFailure` (includes `Unknown inference provider.` and `Unknown box runtime.`) |
| `main/unserved-method` | Method is registered but still on a hand-wired channel |
| `edge/handler-failed` with `The edge replied outside its envelope.` | Reply was not `{ ok, ... }` |

## Inference router

Preload wrappers:

```ts
getInferenceRouter: () => edge("getInferenceRouter")
setInferenceRouter: (provider: string) => edge("setInferenceRouter", { provider })
```

Valid `SandInferenceProvider` ids: `cursor`, `claude-code`, `codex`, `openrouter`. Default stored value is `cursor`.

:::endpoint INVOKE sand-rpc:main:m:getInferenceRouter Read persisted provider, usage, and local CLI status
Get does not take arguments. It reads `settingsStore.getInferenceProvider()`, then tries `readHostSettingsFromBox()` for usage. A host-settings failure is swallowed; usage falls back to `settingsStore.getInferenceRouterUsage()`. An unknown stored provider becomes `"cursor"`.

<ResponseField name="provider" type="SandInferenceProvider">
`cursor` \| `claude-code` \| `codex` \| `openrouter`
</ResponseField>
<ResponseField name="usage" type="SandInferenceRouterUsage | null">
`schemaVersion: 1` plus per-provider `{ requests, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, lastUsedAt }`. `lastUsedAt` is an ISO string or `null`.
</ResponseField>
<ResponseField name="local" type="object">
Local CLI probe for `codex` and `claude-code` only: `{ installed, authenticated, executablePath }`.
</ResponseField>
:::

:::endpoint INVOKE sand-rpc:main:m:setInferenceRouter Persist provider and echo usage plus local status
<ParamField body="provider" type="SandInferenceProvider" required>
Must pass `isSandInferenceProvider`. Anything else throws `Unknown inference provider.`
</ParamField>

Handler sequence:

1. `settingsStore.setInferenceProvider(provider)` (atomic `settings.json` persist)
2. `syncHostSettingsToBox({ inferenceProvider: provider })` — failure is caught and treated as `null`
3. Return `{ provider, usage, local }` with usage from the host result, else the local store

This call does **not** roll back the local persist if the box is unreachable.
:::

<RequestExample>
```json title="setInferenceRouter payload"
{ "provider": "openrouter" }
```
</RequestExample>

<ResponseExample>
```json title="getInferenceRouter / setInferenceRouter value"
{
  "provider": "openrouter",
  "usage": {
    "schemaVersion": 1,
    "providers": {
      "cursor": { "requests": 0, "inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, "lastUsedAt": null },
      "claude-code": { "requests": 0, "inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, "lastUsedAt": null },
      "codex": { "requests": 0, "inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, "lastUsedAt": null },
      "openrouter": { "requests": 0, "inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, "lastUsedAt": null }
    }
  },
  "local": {
    "codex": { "installed": false, "authenticated": false, "executablePath": null },
    "claude-code": { "installed": false, "authenticated": false, "executablePath": null }
  }
}
```
</ResponseExample>

`local` meaning:

| Key | `installed` | `authenticated` |
|---|---|---|
| `codex` | `CODEX_HOME`/`~/.codex` `auth.json` exists | File is a regular file, mode has no group/other bits, `auth_mode === "chatgpt"`, and access/refresh/id tokens plus `account_id` are non-empty |
| `claude-code` | `CLAUDE_CODE_PATH` or a `claude` binary on disk | `~/.claude/.credentials.json` exists **or** `ANTHROPIC_API_KEY` is non-empty |

The packaged Settings panel listens for `sand-router-provider-changed` after a successful `setInferenceRouter`. That custom event is renderer-local; main does not broadcast a router-changed RPC event.

## Box runtime

Preload wrappers:

```ts
getBoxRuntime: () => edge("getBoxRuntime")
setBoxRuntime: (mode: string) => edge("setBoxRuntime", { mode })
```

Valid `SandBoxRuntime` values: `remote` (default) and `local-docker`.

:::endpoint INVOKE sand-rpc:main:m:getBoxRuntime Read mode plus Docker VM status
Unknown stored mode throws `Unknown box runtime.` Status always comes from `getLocalDockerStatus(settingsStore.settingsPath)`, including when mode is `remote`.
:::

:::endpoint INVOKE sand-rpc:main:m:setBoxRuntime Persist mode, start or stop the VM, restart coordinator
<ParamField body="mode" type="SandBoxRuntime" required>
`remote` or `local-docker`. Invalid values throw `Unknown box runtime.`
</ParamField>

Handler sequence:

1. Persist `boxRuntime` immediately
2. `local-docker` → `startLocalDockerBox(settingsPath)`; `remote` → `stopLocalDockerBox()`
3. On start/stop failure, persist the opposite mode and rethrow (coordinator is not restarted)
4. On success, `boxRecovery.restartCoordinator()`
5. Return `{ mode, status }` from a fresh `getLocalDockerStatus`
:::

<ResponseField name="mode" type="SandBoxRuntime">
The persisted runtime after a successful switch.
</ResponseField>
<ResponseField name="status.available" type="boolean">
Docker daemon answered `docker info`.
</ResponseField>
<ResponseField name="status.running" type="boolean">
Container `grok-bot-local-vm` is running.
</ResponseField>
<ResponseField name="status.ready" type="boolean">
Owned container is running and `http://127.0.0.1:1340/health` succeeds with the local token.
</ResponseField>
<ResponseField name="status.containerName" type="string">
Always `grok-bot-local-vm`.
</ResponseField>
<ResponseField name="status.image" type="string">
Pinned image `public.ecr.aws/k0i0n2g5/cursorenvironments/universal:sand-box-latest`, or the inspected image if the container exists.
</ResponseField>
<ResponseField name="status.detail" type="string">
Human status string shown under Settings → Router → Use local Docker VM.
</ResponseField>

Typical `status.detail` values:

| Condition | `detail` |
|---|---|
| Docker missing / not running | daemon stderr, or `Docker is not running.` |
| No container | `Ready to create the local VM.` |
| Name taken by another owner | `Container grok-bot-local-vm exists but is not owned by Grok Bot.` |
| Gateway healthy | `Local Docker VM is ready.` |
| Container up, gateway not yet | `Container is starting.` |
| Owned, not running | `Local Docker VM is stopped.` |

Start failures that roll `boxRuntime` back to `remote` include Docker unavailable, an unowned `grok-bot-local-vm`, unexpected image, missing reconstructed `host-main.cjs` / box-exec daemon, create/start errors, and gateway not ready within three minutes.

## Secrets channels

Preload:

```ts
secrets: {
  list: () => ipc.invoke("sand:secrets-list"),
  reveal: (key: string) => ipc.invoke("sand:secrets-reveal", { key }),
  upsert: (entries: Record<string, string>) => ipc.invoke("sand:secrets-upsert", { entries }),
  remove: (keys: readonly string[]) => ipc.invoke("sand:secrets-delete", { keys }),
}
```

Settings → Router stores the OpenRouter key as `OPENROUTER_API_KEY` via `secrets.upsert`. Cursor, Claude Code, and Codex do not write this store for auth; they use account or local CLI status from `getInferenceRouter().local`.

:::endpoint INVOKE sand:secrets-list List secret keys
Returns `{ keys: string[], isPersistent: boolean }`. `keys` is the sorted union of encrypted disk keys and in-memory session keys for the current account slot. `isPersistent` is Electron `safeStorage` availability.
:::

:::endpoint INVOKE sand:secrets-reveal Decrypt one secret
<ParamField body="key" type="string" required>
Non-string `key` returns `null`.
</ParamField>
Returns the plaintext string, or `null` if missing / decrypt fails / secure storage is unavailable for disk values.
:::

:::endpoint INVOKE sand:secrets-upsert Merge entries and push to the box
<ParamField body="entries" type="Record<string, string>" required>
`parseSecretEntries` keeps only string values. Non-objects become `{}`.
</ParamField>
Requires a signed-in account scope (`SandSecretsAccountRequiredError`: `Box secrets can only change while an account is signed in`). Validates the merged map with `validateBoxSecrets`, encrypts with `safeStorage` when available (otherwise session-only), then `pushBoxSecrets()`. Return: `{ synced: boolean }`.
:::

:::endpoint INVOKE sand:secrets-delete Remove keys and push
<ParamField body="keys" type="string[]" required>
Non-string array elements are dropped.
</ParamField>
Same account-scope requirement as upsert. Return: `{ synced: boolean }`.
:::

`synced` is `true` only when `setBoxSecrets` on the host succeeds. Keychain lock (`OS secure storage is unavailable`) or host unreachable records telemetry and returns `synced: false` without throwing from the IPC handler (the upsert/remove itself already completed locally unless validation failed).

Disk file: `user-secrets.json` under Electron `app.getPath("userData")`, written `0o600`, schema `{ version: 2, accounts: { [accountSlot]: { [key]: base64Ciphertext } } }`. Legacy `{ version: 1, secrets }` migrates into the current account slot from `legacy`.

`validateBoxSecrets` constraints:

| Limit | Value |
|---|---|
| Max keys | `100` |
| Max value length | `32768` |
| Max combined key+value length | `98304` |
| Key pattern | `^[A-Za-z_][A-Za-z0-9_]*$` |
| Reserved exact names | `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `PWD`, `DISPLAY`, `CLOUD_AGENT_INJECTED_SECRET_NAMES` |
| Reserved prefixes | `SAND_`, `__CURSOR`, `LD_` |
| Reserved substring | names matching `/CURSOR_SANDBOX/i` |

Quit quiesces box secret pushes (`Box secrets pushes are quiesced for quit`).

## Adjacent `desktop.agent` IPC

`window.desktop.agent.clientPersistence` is a separate trusted-sender family, not the secrets store:

| Method | Channel |
|---|---|
| `read(key)` | `sand:client-persistence-read` |
| `write(key, value)` | `sand:client-persistence-write` |
| `remove(key)` | `sand:client-persistence-remove` |
| `listKeys(prefix)` | `sand:client-persistence-list-keys` |
| `migrateFromLocalStorage(entries)` | `sand:client-persistence-migrate` |

Write requires string `key` and `value` or throws `client persistence: write needs a string key and value`. Files are written `0o600`.

## Packaged Settings consumer

`npm run package` patches the shipped renderer so Settings → Router calls these APIs. The frontend Vite workspace is not the packaged UI unless `GROK_BOT_RENDERER_SOURCE` overrides it.

<Tabs>
<Tab title="Inference provider">
Load: `window.desktop.agent.getInferenceRouter()`. Save: `setInferenceRouter(id)` then `CustomEvent("sand-router-provider-changed")`. Errors surface as `error: String(message)`.
</Tab>
<Tab title="OpenRouter key">
Load keys: `window.desktop.secrets.list()` → `keys`. Save: `window.desktop.secrets.upsert({ OPENROUTER_API_KEY: trimmed })`. Empty input is a no-op.
</Tab>
<Tab title="Local Docker VM">
Load: `getBoxRuntime()`. Toggle sends the opposite of current `mode`. Failure restores the previous `mode` in UI state from the thrown `EdgeCallFailure` detail.
</Tab>
</Tabs>

`tests/publication-packaging.test.mjs` asserts the preload wrappers and the patched `desktop.agent.getInferenceRouter()` / `setInferenceRouter` / `getBoxRuntime` / `setBoxRuntime` call sites.

## Failures

| Symptom | Cause | What to do |
|---|---|---|
| `Unknown inference provider.` | `provider` not in the four ids | Send `cursor`, `claude-code`, `codex`, or `openrouter` |
| `Unknown box runtime.` | `mode` not `remote` or `local-docker` | Send one of those two strings |
| `main/unserved-method` on secrets | Renderer called the main-edge names | Use `window.desktop.secrets.*` |
| `edge/untrusted-sender` | Call did not originate in the app window top frame | Keep calls on the primary BrowserWindow renderer |
| `Box secrets can only change while an account is signed in` | No account scope | Sign in before upsert/remove |
| `synced: false` after upsert | Keychain locked or host unreachable | Check OS secure storage; retry when the box is up |
| Local Docker toggle reverts | `startLocalDockerBox` / `stopLocalDockerBox` threw | See Router and sandbox failures; `boxRuntime` is already reverted on disk |
| OpenRouter option shows "Paste API key" after save | `list()` keys do not include `OPENROUTER_API_KEY` | Confirm upsert did not throw validation/account errors |

## Next

<CardGroup>
<Card title="Inference router" href="/inference-router">
Provider ids, default `cursor`, transcript schemaVersion 2, usage schemaVersion 1.
</Card>
<Card title="Box runtime" href="/box-runtime">
`remote` versus `local-docker`, coordinator restart, loopback gateway ownership.
</Card>
<Card title="Settings schema" href="/settings-schema">
`settings.json` version 1 fields including `inferenceProvider` and `boxRuntime`.
</Card>
<Card title="Choose an inference provider" href="/choose-inference-provider">
Settings → Router persist path and per-provider auth prerequisites.
</Card>
<Card title="Enable the local Docker sandbox" href="/enable-local-docker">
Toggle Use local Docker VM, health on `127.0.0.1:1340`, rollback on start failure.
</Card>
<Card title="Route Grok Bot plugin tools" href="/route-mcp-tools">
Coordinator-port `listRoutedMcpTools` / `executeRoutedMcpTool` (not `window.desktop`).
</Card>
<Card title="Router and sandbox failures" href="/router-failures">
Unknown provider, Codex auth.json, Claude Code missing, OpenRouter key, unowned VM.
</Card>
</CardGroup>
