feat(push): configure ios relay through gateway config

This commit is contained in:
Nimrod Gutman
2026-03-12 16:22:53 +02:00
parent ebf0bdcdfd
commit 93abfc0b22
16 changed files with 312 additions and 10 deletions

View File

@@ -1183,7 +1183,15 @@ final class NodeAppModel {
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
}
return await self.notificationAuthorizationStatus()
let updatedStatus = await self.notificationAuthorizationStatus()
if Self.isNotificationAuthorizationAllowed(updatedStatus) {
// Refresh APNs registration immediately after the first permission grant so the
// gateway can receive a push registration without requiring an app relaunch.
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
return updatedStatus
}
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
@@ -1198,6 +1206,17 @@ final class NodeAppModel {
}
}
private static func isNotificationAuthorizationAllowed(
_ status: NotificationAuthorizationStatus
) -> Bool {
switch status {
case .authorized, .provisional, .ephemeral:
true
case .denied, .notDetermined:
false
}
}
private func runNotificationCall<T: Sendable>(
timeoutSeconds: Double,
operation: @escaping @Sendable () async throws -> T

View File

@@ -407,6 +407,13 @@ enum WatchPromptNotificationBridge {
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
if !granted { return false }
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
if self.isAuthorizationStatusAllowed(updatedStatus) {
// Refresh APNs registration immediately after the first permission grant so the
// gateway can receive a push registration without requiring an app relaunch.
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
return self.isAuthorizationStatusAllowed(updatedStatus)
case .denied:
return false

View File

@@ -2445,6 +2445,14 @@ See [Plugins](/tools/plugin).
// Remove tools from the default HTTP deny list
allow: ["gateway"],
},
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
timeoutMs: 10000,
},
},
},
},
}
```
@@ -2470,6 +2478,10 @@ See [Plugins](/tools/plugin).
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side break-glass override that allows plaintext `ws://` to trusted private-network IPs; default remains loopback-only for plaintext.
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used by official/TestFlight iOS builds after they publish relay-backed registrations to the gateway.
- `gateway.push.apns.relay.timeoutMs`: gateway-to-relay send timeout in milliseconds. Defaults to `10000`.
- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true`: development-only escape hatch for loopback HTTP relay URLs. Production relay URLs should stay on HTTPS.
- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.

View File

@@ -225,6 +225,49 @@ When validation fails:
</Accordion>
<Accordion title="Enable relay-backed push for official iOS builds">
Relay-backed push is configured in `openclaw.json`.
Set this in gateway config:
```json5
{
gateway: {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
// Optional. Default: 10000
timeoutMs: 10000,
},
},
},
},
}
```
CLI equivalent:
```bash
openclaw config set gateway.push.apns.relay.baseUrl https://relay.example.com
```
What this does:
- Lets the gateway send `push.test`, wake nudges, and reconnect wakes through the external relay.
- Uses a registration-scoped send grant forwarded by the paired iOS app. The gateway does not need a deployment-wide relay token.
- Keeps local/manual iOS builds on direct APNs. Relay-backed sends apply only to official distributed builds that registered through the relay.
- Must match the relay base URL baked into the official/TestFlight iOS build, so registration and send traffic reach the same relay deployment.
Compatibility note:
- `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides.
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config.
See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow.
</Accordion>
<Accordion title="Set up heartbeat (periodic check-ins)">
```json5
{

View File

@@ -49,6 +49,53 @@ openclaw nodes status
openclaw gateway call node.list --params "{}"
```
## Relay-backed push for official builds
Official distributed iOS builds use the external push relay instead of publishing the raw APNs
token to the gateway.
Gateway-side requirement:
```json5
{
gateway: {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
},
},
},
},
}
```
How the flow works:
- The iOS app registers with the relay using App Attest and the app receipt.
- The relay returns an opaque relay handle plus a registration-scoped send grant.
- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`.
- The gateway uses that stored relay handle for `push.test`, background wakes, and wake nudges.
- The gateway relay base URL must match the relay URL baked into the official/TestFlight iOS build.
What the gateway does **not** need for this path:
- No deployment-wide relay token.
- No direct APNs key for official/TestFlight relay-backed sends.
Compatibility note:
- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway.
Local/manual builds remain on direct APNs. If you are testing those builds without the relay, the
gateway still needs direct APNs credentials:
```bash
export OPENCLAW_APNS_TEAM_ID="TEAMID"
export OPENCLAW_APNS_KEY_ID="KEYID"
export OPENCLAW_APNS_PRIVATE_KEY_P8='-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'
```
## Discovery paths
### Bonjour (LAN)

View File

@@ -386,6 +386,16 @@ export const FIELD_HELP: Record<string, string> = {
"Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.",
"gateway.controlUi.dangerouslyDisableDeviceAuth":
"Disables Control UI device identity checks and relies on token/password only. Use only for short-lived debugging on trusted networks, then turn it off immediately.",
"gateway.push":
"Push-delivery settings used by the gateway when it needs to wake or notify paired devices. Configure relay-backed APNs here for official iOS builds; direct APNs auth remains env-based for local/manual builds.",
"gateway.push.apns":
"APNs delivery settings for iOS devices paired to this gateway. Use relay settings for official/TestFlight builds that register through the external push relay.",
"gateway.push.apns.relay":
"External relay settings for relay-backed APNs sends. The gateway uses this relay for push.test, wake nudges, and reconnect wakes after a paired official iOS build publishes a relay-backed registration.",
"gateway.push.apns.relay.baseUrl":
"Base HTTPS URL for the external APNs relay service used by official/TestFlight iOS builds. Keep this aligned with the relay URL baked into the iOS build so registration and send traffic hit the same deployment.",
"gateway.push.apns.relay.timeoutMs":
"Timeout in milliseconds for relay send requests from the gateway to the APNs relay (default: 10000). Increase for slower relays or networks, or lower to fail wake attempts faster.",
"gateway.http.endpoints.chatCompletions.enabled":
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
"gateway.http.endpoints.chatCompletions.maxBodyBytes":

View File

@@ -75,6 +75,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
"gateway.controlUi.basePath": "/openclaw",
"gateway.controlUi.root": "dist/control-ui",
"gateway.controlUi.allowedOrigins": "https://control.example.com",
"gateway.push.apns.relay.baseUrl": "https://relay.example.com",
"channels.mattermost.baseUrl": "https://chat.example.com",
"agents.list[].identity.avatar": "avatars/openclaw.png",
};

View File

@@ -41,6 +41,7 @@ const TAG_PRIORITY: Record<ConfigTag, number> = {
const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
"gateway.auth.token": ["security", "auth", "access", "network"],
"gateway.auth.password": ["security", "auth", "access", "network"],
"gateway.push.apns.relay.baseUrl": ["network", "advanced"],
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [
"security",
"access",

View File

@@ -345,6 +345,21 @@ export type GatewayHttpConfig = {
securityHeaders?: GatewayHttpSecurityHeadersConfig;
};
export type GatewayPushApnsRelayConfig = {
/** Base HTTPS URL for the external iOS APNs relay service. */
baseUrl?: string;
/** Timeout in milliseconds for relay send requests (default: 10000). */
timeoutMs?: number;
};
export type GatewayPushApnsConfig = {
relay?: GatewayPushApnsRelayConfig;
};
export type GatewayPushConfig = {
apns?: GatewayPushApnsConfig;
};
export type GatewayNodesConfig = {
/** Browser routing policy for node-hosted browser proxies. */
browser?: {
@@ -393,6 +408,7 @@ export type GatewayConfig = {
reload?: GatewayReloadConfig;
tls?: GatewayTlsConfig;
http?: GatewayHttpConfig;
push?: GatewayPushConfig;
nodes?: GatewayNodesConfig;
/**
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection

View File

@@ -789,6 +789,23 @@ export const OpenClawSchema = z
})
.strict()
.optional(),
push: z
.object({
apns: z
.object({
relay: z
.object({
baseUrl: z.string().optional(),
timeoutMs: z.number().int().positive().optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
nodes: z
.object({
browser: z

View File

@@ -325,6 +325,18 @@ describe("node.invoke APNs wake path", () => {
});
it("does not clear relay registrations from wake failures", async () => {
mocks.loadConfig.mockReturnValue({
gateway: {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
},
},
},
});
mocks.loadApnsRegistration.mockResolvedValue({
nodeId: "ios-node-relay",
transport: "relay",
@@ -368,6 +380,16 @@ describe("node.invoke APNs wake path", () => {
const call = respond.mock.calls[0] as RespondCall | undefined;
expect(call?.[0]).toBe(false);
expect(call?.[2]?.message).toBe("node not connected");
expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
},
},
});
expect(mocks.shouldClearStoredApnsRegistration).toHaveBeenCalledWith({
registration: {
nodeId: "ios-node-relay",

View File

@@ -103,7 +103,7 @@ async function resolveDirectNodePushConfig() {
}
function resolveRelayNodePushConfig() {
const relay = resolveApnsRelayConfigFromEnv(process.env);
const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway);
return relay.ok
? { ok: true as const, relayConfig: relay.value }
: { ok: false as const, error: relay.error };

View File

@@ -2,6 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorCodes } from "../protocol/index.js";
import { pushHandlers } from "./push.js";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
}));
vi.mock("../../config/config.js", () => ({
loadConfig: mocks.loadConfig,
}));
vi.mock("../../infra/push-apns.js", () => ({
clearApnsRegistrationIfCurrent: vi.fn(),
loadApnsRegistration: vi.fn(),
@@ -52,6 +60,8 @@ function expectInvalidRequestResponse(
describe("push.test handler", () => {
beforeEach(() => {
mocks.loadConfig.mockClear();
mocks.loadConfig.mockReturnValue({});
vi.mocked(loadApnsRegistration).mockClear();
vi.mocked(normalizeApnsEnvironment).mockClear();
vi.mocked(resolveApnsAuthConfigFromEnv).mockClear();
@@ -115,6 +125,18 @@ describe("push.test handler", () => {
});
it("sends push test through relay registrations", async () => {
mocks.loadConfig.mockReturnValue({
gateway: {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
},
},
},
});
vi.mocked(loadApnsRegistration).mockResolvedValue({
nodeId: "ios-node-1",
transport: "relay",
@@ -153,6 +175,16 @@ describe("push.test handler", () => {
expect(resolveApnsAuthConfigFromEnv).not.toHaveBeenCalled();
expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledTimes(1);
expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
},
},
});
expect(sendApnsAlert).toHaveBeenCalledTimes(1);
const call = respond.mock.calls[0] as RespondCall | undefined;
expect(call?.[0]).toBe(true);

View File

@@ -1,3 +1,4 @@
import { loadConfig } from "../../config/config.js";
import {
clearApnsRegistrationIfCurrent,
loadApnsRegistration,
@@ -74,7 +75,7 @@ export const pushHandlers: GatewayRequestHandlers = {
});
})()
: await (async () => {
const relay = resolveApnsRelayConfigFromEnv(process.env);
const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway);
if (!relay.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, relay.error));
return null;

View File

@@ -1,4 +1,5 @@
import { URL } from "node:url";
import type { GatewayConfig } from "../config/types.gateway.js";
export type ApnsRelayPushType = "alert" | "background";
@@ -36,9 +37,10 @@ function normalizeNonEmptyString(value: string | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function normalizeTimeoutMs(value: string | undefined): number {
const raw = value?.trim();
if (!raw) {
function normalizeTimeoutMs(value: string | number | undefined): number {
const raw =
typeof value === "number" ? value : typeof value === "string" ? value.trim() : undefined;
if (raw === undefined || raw === "") {
return DEFAULT_APNS_RELAY_TIMEOUT_MS;
}
const parsed = Number(raw);
@@ -69,12 +71,20 @@ function parseReason(value: unknown): string | undefined {
export function resolveApnsRelayConfigFromEnv(
env: NodeJS.ProcessEnv = process.env,
gatewayConfig?: GatewayConfig,
): ApnsRelayConfigResolution {
const baseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL);
const configuredRelay = gatewayConfig?.push?.apns?.relay;
const envBaseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL);
const configBaseUrl = normalizeNonEmptyString(configuredRelay?.baseUrl);
const baseUrl = envBaseUrl ?? configBaseUrl;
const baseUrlSource = envBaseUrl
? "OPENCLAW_APNS_RELAY_BASE_URL"
: "gateway.push.apns.relay.baseUrl";
if (!baseUrl) {
return {
ok: false,
error: "APNs relay config missing: set OPENCLAW_APNS_RELAY_BASE_URL",
error:
"APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL",
};
}
@@ -104,14 +114,16 @@ export function resolveApnsRelayConfigFromEnv(
ok: true,
value: {
baseUrl: parsed.toString().replace(/\/+$/, ""),
timeoutMs: normalizeTimeoutMs(env.OPENCLAW_APNS_RELAY_TIMEOUT_MS),
timeoutMs: normalizeTimeoutMs(
env.OPENCLAW_APNS_RELAY_TIMEOUT_MS ?? configuredRelay?.timeoutMs,
),
},
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
error: `invalid OPENCLAW_APNS_RELAY_BASE_URL (${baseUrl}): ${message}`,
error: `invalid ${baseUrlSource} (${baseUrl}): ${message}`,
};
}
}

View File

@@ -263,6 +263,52 @@ describe("push APNs env config", () => {
});
});
it("resolves APNs relay config from gateway config", () => {
const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com/base/",
timeoutMs: 2500,
},
},
},
});
expect(resolved).toMatchObject({
ok: true,
value: {
baseUrl: "https://relay.example.com/base",
timeoutMs: 2500,
},
});
});
it("lets relay env overrides win over gateway config", () => {
const resolved = resolveApnsRelayConfigFromEnv(
{
OPENCLAW_APNS_RELAY_BASE_URL: "https://relay-override.example.com",
OPENCLAW_APNS_RELAY_TIMEOUT_MS: "3000",
} as NodeJS.ProcessEnv,
{
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
timeoutMs: 2500,
},
},
},
},
);
expect(resolved).toMatchObject({
ok: true,
value: {
baseUrl: "https://relay-override.example.com",
timeoutMs: 3000,
},
});
});
it("rejects insecure APNs relay http URLs by default", () => {
const resolved = resolveApnsRelayConfigFromEnv({
OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com",
@@ -321,6 +367,22 @@ describe("push APNs env config", () => {
expect(withUserinfo.error).toContain("userinfo is not allowed");
}
});
it("reports the config key name for invalid gateway relay URLs", () => {
const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com/path?debug=1",
},
},
},
});
expect(resolved.ok).toBe(false);
if (!resolved.ok) {
expect(resolved.error).toContain("gateway.push.apns.relay.baseUrl");
}
});
});
describe("push APNs send semantics", () => {