diff --git a/apps/ios/README.md b/apps/ios/README.md index 6817628ad92..edd99d793dd 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -105,7 +105,7 @@ pnpm ios:beta -- --build-number 7 ## APNs Expectations For Official Builds - Official/TestFlight builds register with the external push relay before they publish `push.apns.register` to the gateway. -- The gateway registration for relay mode contains an opaque relay handle and installation metadata instead of the raw APNs token. +- The gateway registration for relay mode contains an opaque relay handle, a registration-scoped send grant, and installation metadata instead of the raw APNs token. - The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect. - Relay mode requires a reachable relay base URL and uses App Attest plus the app receipt during registration. diff --git a/apps/ios/Sources/Push/PushRegistrationManager.swift b/apps/ios/Sources/Push/PushRegistrationManager.swift index fe84860b9e3..c22e142183c 100644 --- a/apps/ios/Sources/Push/PushRegistrationManager.swift +++ b/apps/ios/Sources/Push/PushRegistrationManager.swift @@ -11,6 +11,7 @@ private struct DirectGatewayPushRegistrationPayload: Encodable { private struct RelayGatewayPushRegistrationPayload: Encodable { var transport: String = PushTransportMode.relay.rawValue var relayHandle: String + var sendGrant: String var installationId: String var topic: String var environment: String @@ -76,6 +77,7 @@ actor PushRegistrationManager { return try Self.encodePayload( RelayGatewayPushRegistrationPayload( relayHandle: stored.relayHandle, + sendGrant: stored.sendGrant, installationId: installationId, topic: topic, environment: self.buildConfig.apnsEnvironment.rawValue, @@ -92,6 +94,7 @@ actor PushRegistrationManager { apnsTokenHex: apnsTokenHex) let registrationState = PushRelayRegistrationStore.RegistrationState( relayHandle: response.relayHandle, + sendGrant: response.sendGrant, relayHandleExpiresAtMs: response.expiresAtMs, tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix), lastAPNsTokenHashHex: tokenHashHex, @@ -101,6 +104,7 @@ actor PushRegistrationManager { return try Self.encodePayload( RelayGatewayPushRegistrationPayload( relayHandle: response.relayHandle, + sendGrant: response.sendGrant, installationId: installationId, topic: topic, environment: self.buildConfig.apnsEnvironment.rawValue, diff --git a/apps/ios/Sources/Push/PushRelayClient.swift b/apps/ios/Sources/Push/PushRelayClient.swift index 1f1b1ca1580..e2e179feccc 100644 --- a/apps/ios/Sources/Push/PushRelayClient.swift +++ b/apps/ios/Sources/Push/PushRelayClient.swift @@ -71,6 +71,7 @@ private struct PushRelayRegisterRequest: Encodable { struct PushRelayRegisterResponse: Decodable { var relayHandle: String + var sendGrant: String var expiresAtMs: Int64? var tokenSuffix: String? var status: String diff --git a/apps/ios/Sources/Push/PushRelayKeychainStore.swift b/apps/ios/Sources/Push/PushRelayKeychainStore.swift index 462c615bc43..02794c2aa21 100644 --- a/apps/ios/Sources/Push/PushRelayKeychainStore.swift +++ b/apps/ios/Sources/Push/PushRelayKeychainStore.swift @@ -2,6 +2,7 @@ import Foundation private struct StoredPushRelayRegistrationState: Codable { var relayHandle: String + var sendGrant: String var relayHandleExpiresAtMs: Int64? var tokenDebugSuffix: String? var lastAPNsTokenHashHex: String @@ -17,6 +18,7 @@ enum PushRelayRegistrationStore { struct RegistrationState: Codable { var relayHandle: String + var sendGrant: String var relayHandleExpiresAtMs: Int64? var tokenDebugSuffix: String? var lastAPNsTokenHashHex: String @@ -35,6 +37,7 @@ enum PushRelayRegistrationStore { } return RegistrationState( relayHandle: decoded.relayHandle, + sendGrant: decoded.sendGrant, relayHandleExpiresAtMs: decoded.relayHandleExpiresAtMs, tokenDebugSuffix: decoded.tokenDebugSuffix, lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex, @@ -46,6 +49,7 @@ enum PushRelayRegistrationStore { static func saveRegistrationState(_ state: RegistrationState) -> Bool { let stored = StoredPushRelayRegistrationState( relayHandle: state.relayHandle, + sendGrant: state.sendGrant, relayHandleExpiresAtMs: state.relayHandleExpiresAtMs, tokenDebugSuffix: state.tokenDebugSuffix, lastAPNsTokenHashHex: state.lastAPNsTokenHashHex, diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index cfb619bb226..1b44e718f71 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -329,6 +329,7 @@ describe("node.invoke APNs wake path", () => { nodeId: "ios-node-relay", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", @@ -340,7 +341,6 @@ describe("node.invoke APNs wake path", () => { ok: true, value: { baseUrl: "https://relay.example.com", - authToken: "relay-secret", timeoutMs: 1000, }, }); @@ -373,6 +373,7 @@ describe("node.invoke APNs wake path", () => { nodeId: "ios-node-relay", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index 16c632191dd..e38ff2a63e9 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -119,6 +119,7 @@ describe("push.test handler", () => { nodeId: "ios-node-1", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-1", topic: "ai.openclaw.ios", environment: "production", @@ -130,7 +131,6 @@ describe("push.test handler", () => { ok: true, value: { baseUrl: "https://relay.example.com", - authToken: "relay-secret", timeoutMs: 1000, }, }); @@ -213,6 +213,7 @@ describe("push.test handler", () => { nodeId: "ios-node-1", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", @@ -224,7 +225,6 @@ describe("push.test handler", () => { ok: true, value: { baseUrl: "https://relay.example.com", - authToken: "relay-secret", timeoutMs: 1000, }, }); @@ -252,6 +252,7 @@ describe("push.test handler", () => { nodeId: "ios-node-1", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 8ce65d26ea2..7025072469c 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -290,6 +290,7 @@ describe("node exec events", () => { payloadJSON: JSON.stringify({ transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", @@ -302,6 +303,7 @@ describe("node exec events", () => { nodeId: "node-relay", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 775412b7145..bb1fba16c2e 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -598,6 +598,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt nodeId, transport: "relay", relayHandle: typeof obj.relayHandle === "string" ? obj.relayHandle : "", + sendGrant: typeof obj.sendGrant === "string" ? obj.sendGrant : "", installationId: typeof obj.installationId === "string" ? obj.installationId : "", topic, environment, diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index ceabc712e27..7b0eafd258e 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -3,12 +3,18 @@ import type { AddressInfo } from "node:net"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; +type BeforeToolCallHookResult = + | { blocked: true; reason: string } + | { blocked: false; params: unknown }; + const hookMocks = vi.hoisted(() => ({ resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })), - runBeforeToolCallHook: vi.fn(async ({ params }: { params: unknown }) => ({ - blocked: false as const, - params, - })), + runBeforeToolCallHook: vi.fn( + async ({ params }: { params: unknown }): Promise => ({ + blocked: false, + params, + }), + ), })); let cfg: Record = {}; diff --git a/src/infra/push-apns.relay.ts b/src/infra/push-apns.relay.ts index 8c35291a621..f993e9127dc 100644 --- a/src/infra/push-apns.relay.ts +++ b/src/infra/push-apns.relay.ts @@ -4,7 +4,6 @@ export type ApnsRelayPushType = "alert" | "background"; export type ApnsRelayConfig = { baseUrl: string; - authToken: string; timeoutMs: number; }; @@ -23,6 +22,7 @@ export type ApnsRelayPushResponse = { export type ApnsRelayRequestSender = (params: { relayConfig: ApnsRelayConfig; + sendGrant: string; relayHandle: string; pushType: ApnsRelayPushType; priority: "10" | "5"; @@ -71,12 +71,10 @@ export function resolveApnsRelayConfigFromEnv( env: NodeJS.ProcessEnv = process.env, ): ApnsRelayConfigResolution { const baseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL); - const authToken = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_AUTH_TOKEN); - if (!baseUrl || !authToken) { + if (!baseUrl) { return { ok: false, - error: - "APNs relay config missing: set OPENCLAW_APNS_RELAY_BASE_URL and OPENCLAW_APNS_RELAY_AUTH_TOKEN", + error: "APNs relay config missing: set OPENCLAW_APNS_RELAY_BASE_URL", }; } @@ -106,7 +104,6 @@ export function resolveApnsRelayConfigFromEnv( ok: true, value: { baseUrl: parsed.toString().replace(/\/+$/, ""), - authToken, timeoutMs: normalizeTimeoutMs(env.OPENCLAW_APNS_RELAY_TIMEOUT_MS), }, }; @@ -121,6 +118,7 @@ export function resolveApnsRelayConfigFromEnv( async function sendApnsRelayRequest(params: { relayConfig: ApnsRelayConfig; + sendGrant: string; relayHandle: string; pushType: ApnsRelayPushType; priority: "10" | "5"; @@ -130,7 +128,7 @@ async function sendApnsRelayRequest(params: { method: "POST", redirect: "manual", headers: { - authorization: `Bearer ${params.relayConfig.authToken}`, + authorization: `Bearer ${params.sendGrant}`, "content-type": "application/json", }, body: JSON.stringify({ @@ -177,6 +175,7 @@ async function sendApnsRelayRequest(params: { export async function sendApnsRelayPush(params: { relayConfig: ApnsRelayConfig; + sendGrant: string; relayHandle: string; pushType: ApnsRelayPushType; priority: "10" | "5"; @@ -186,6 +185,7 @@ export async function sendApnsRelayPush(params: { const sender = params.requestSender ?? sendApnsRelayRequest; return await sender({ relayConfig: params.relayConfig, + sendGrant: params.sendGrant, relayHandle: params.relayHandle, pushType: params.pushType, priority: params.priority, diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index cbd1d28bdf2..aa2e9dc7b72 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -69,6 +69,7 @@ describe("push APNs registration store", () => { nodeId: "ios-node-relay", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", @@ -83,6 +84,7 @@ describe("push APNs registration store", () => { nodeId: "ios-node-relay", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", @@ -111,6 +113,7 @@ describe("push APNs registration store", () => { nodeId: "ios-node-relay", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "staging", @@ -123,6 +126,7 @@ describe("push APNs registration store", () => { nodeId: "ios-node-relay", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", @@ -140,6 +144,7 @@ describe("push APNs registration store", () => { nodeId: "ios-node-relay", transport: "relay", relayHandle: oversized, + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", @@ -152,6 +157,7 @@ describe("push APNs registration store", () => { nodeId: "ios-node-relay", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: oversized, topic: "ai.openclaw.ios", environment: "production", @@ -246,14 +252,12 @@ describe("push APNs env config", () => { it("resolves APNs relay config from env", () => { const resolved = resolveApnsRelayConfigFromEnv({ OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com", - OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret", OPENCLAW_APNS_RELAY_TIMEOUT_MS: "2500", } as NodeJS.ProcessEnv); expect(resolved).toMatchObject({ ok: true, value: { baseUrl: "https://relay.example.com", - authToken: "relay-secret", timeoutMs: 2500, }, }); @@ -262,7 +266,6 @@ describe("push APNs env config", () => { it("rejects insecure APNs relay http URLs by default", () => { const resolved = resolveApnsRelayConfigFromEnv({ OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com", - OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret", } as NodeJS.ProcessEnv); expect(resolved).toMatchObject({ ok: false, @@ -276,14 +279,12 @@ describe("push APNs env config", () => { it("allows APNs relay http URLs only when explicitly enabled", () => { const resolved = resolveApnsRelayConfigFromEnv({ OPENCLAW_APNS_RELAY_BASE_URL: "http://127.0.0.1:8787", - OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret", OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true", } as NodeJS.ProcessEnv); expect(resolved).toMatchObject({ ok: true, value: { baseUrl: "http://127.0.0.1:8787", - authToken: "relay-secret", timeoutMs: 10_000, }, }); @@ -292,7 +293,6 @@ describe("push APNs env config", () => { it("rejects http relay URLs for non-loopback hosts even when explicitly enabled", () => { const resolved = resolveApnsRelayConfigFromEnv({ OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com", - OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret", OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true", } as NodeJS.ProcessEnv); expect(resolved).toMatchObject({ @@ -307,7 +307,6 @@ describe("push APNs env config", () => { it("rejects APNs relay URLs with query, fragment, or userinfo components", () => { const withQuery = resolveApnsRelayConfigFromEnv({ OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com/path?debug=1", - OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret", } as NodeJS.ProcessEnv); expect(withQuery.ok).toBe(false); if (!withQuery.ok) { @@ -316,7 +315,6 @@ describe("push APNs env config", () => { const withUserinfo = resolveApnsRelayConfigFromEnv({ OPENCLAW_APNS_RELAY_BASE_URL: "https://user:pass@relay.example.com/path", - OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret", } as NodeJS.ProcessEnv); expect(withUserinfo.ok).toBe(false); if (!withUserinfo.ok) { @@ -468,13 +466,13 @@ describe("push APNs send semantics", () => { const result = await sendApnsAlert({ relayConfig: { baseUrl: "https://relay.example.com", - authToken: "relay-secret", timeoutMs: 1000, }, registration: { nodeId: "ios-node-relay", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", @@ -520,9 +518,9 @@ describe("push APNs send semantics", () => { const result = await sendApnsRelayPush({ relayConfig: { baseUrl: "https://relay.example.com", - authToken: "relay-secret", timeoutMs: 1000, }, + sendGrant: "send-grant-123", relayHandle: "relay-handle-123", payload: { aps: { "content-available": 1 } }, pushType: "background", @@ -568,6 +566,7 @@ describe("push APNs send semantics", () => { nodeId: "ios-node-relay", transport: "relay", relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", installationId: "install-123", topic: "ai.openclaw.ios", environment: "production", diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index b07b512e452..026ca398e8d 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -29,6 +29,7 @@ export type RelayApnsRegistration = { nodeId: string; transport: "relay"; relayHandle: string; + sendGrant: string; installationId: string; topic: string; environment: "production"; @@ -97,6 +98,7 @@ type RegisterRelayApnsParams = { nodeId: string; transport: "relay"; relayHandle: string; + sendGrant: string; installationId: string; topic: string; environment?: unknown; @@ -271,17 +273,23 @@ function normalizeDirectRegistration( } function normalizeRelayRegistration( - record: Partial & { nodeId?: unknown; relayHandle?: unknown }, + record: Partial & { + nodeId?: unknown; + relayHandle?: unknown; + sendGrant?: unknown; + }, ): RelayApnsRegistration | null { if ( typeof record.nodeId !== "string" || typeof record.relayHandle !== "string" || + typeof record.sendGrant !== "string" || typeof record.installationId !== "string" ) { return null; } const nodeId = normalizeNodeId(record.nodeId); const relayHandle = normalizeRelayHandle(record.relayHandle); + const sendGrant = record.sendGrant.trim(); const installationId = normalizeInstallationId(record.installationId); const topic = normalizeTopic(typeof record.topic === "string" ? record.topic : ""); const environment = normalizeApnsEnvironment(record.environment); @@ -293,6 +301,7 @@ function normalizeRelayRegistration( if ( !nodeId || !relayHandle || + !sendGrant || !installationId || !topic || environment !== "production" || @@ -304,6 +313,7 @@ function normalizeRelayRegistration( nodeId, transport: "relay", relayHandle, + sendGrant, installationId, topic, environment, @@ -393,6 +403,7 @@ export async function registerApnsRegistration( normalizeRelayHandle(params.relayHandle), "relayHandle", ); + const sendGrant = validateRelayIdentifier(params.sendGrant.trim(), "sendGrant"); const installationId = validateRelayIdentifier( normalizeInstallationId(params.installationId), "installationId", @@ -409,6 +420,7 @@ export async function registerApnsRegistration( nodeId, transport: "relay", relayHandle, + sendGrant, installationId, topic, environment, @@ -495,6 +507,7 @@ function isSameApnsRegistration(a: ApnsRegistration, b: ApnsRegistration): boole if (a.transport === "relay" && b.transport === "relay") { return ( a.relayHandle === b.relayHandle && + a.sendGrant === b.sendGrant && a.installationId === b.installationId && a.distribution === b.distribution && a.tokenDebugSuffix === b.tokenDebugSuffix @@ -817,6 +830,7 @@ async function sendRelayApnsPush(params: { }): Promise { const response = await sendApnsRelayPush({ relayConfig: params.relayConfig, + sendGrant: params.registration.sendGrant, relayHandle: params.registration.relayHandle, payload: params.payload, pushType: params.pushType,