Merge branch 'main' into ui/dashboard-v2.1.3

This commit is contained in:
Val Alexander
2026-03-13 19:15:53 -05:00
committed by GitHub
8 changed files with 1196 additions and 712 deletions

View File

@@ -0,0 +1,125 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
createTarEntryPreflightChecker,
fileExists,
readJsonFile,
resolveArchiveKind,
resolvePackedRootDir,
withTimeout,
} from "./archive.js";
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-archive-helper-test-");
afterEach(async () => {
vi.useRealTimers();
await tempDirs.cleanup();
});
describe("archive helpers", () => {
it.each([
{ input: "/tmp/file.zip", expected: "zip" },
{ input: "/tmp/file.TAR.GZ", expected: "tar" },
{ input: "/tmp/file.tgz", expected: "tar" },
{ input: "/tmp/file.tar", expected: "tar" },
{ input: "/tmp/file.txt", expected: null },
])("detects archive kind for $input", ({ input, expected }) => {
expect(resolveArchiveKind(input)).toBe(expected);
});
it("resolves packed roots from package dir or single extracted root dir", async () => {
const directDir = await createTempDir();
const fallbackDir = await createTempDir();
await fs.mkdir(path.join(directDir, "package"), { recursive: true });
await fs.mkdir(path.join(fallbackDir, "bundle-root"), { recursive: true });
await expect(resolvePackedRootDir(directDir)).resolves.toBe(path.join(directDir, "package"));
await expect(resolvePackedRootDir(fallbackDir)).resolves.toBe(
path.join(fallbackDir, "bundle-root"),
);
});
it("rejects unexpected packed root layouts", async () => {
const multipleDir = await createTempDir();
const emptyDir = await createTempDir();
await fs.mkdir(path.join(multipleDir, "a"), { recursive: true });
await fs.mkdir(path.join(multipleDir, "b"), { recursive: true });
await fs.writeFile(path.join(emptyDir, "note.txt"), "hi", "utf8");
await expect(resolvePackedRootDir(multipleDir)).rejects.toThrow(/unexpected archive layout/i);
await expect(resolvePackedRootDir(emptyDir)).rejects.toThrow(/unexpected archive layout/i);
});
it("returns work results and propagates errors before timeout", async () => {
await expect(withTimeout(Promise.resolve("ok"), 100, "extract zip")).resolves.toBe("ok");
await expect(
withTimeout(Promise.reject(new Error("boom")), 100, "extract zip"),
).rejects.toThrow("boom");
});
it("rejects when archive work exceeds the timeout", async () => {
vi.useFakeTimers();
const late = new Promise<string>((resolve) => setTimeout(() => resolve("ok"), 50));
const result = withTimeout(late, 1, "extract tar");
const pending = expect(result).rejects.toThrow("extract tar timed out after 1ms");
await vi.advanceTimersByTimeAsync(1);
await pending;
});
it("preflights tar entries for blocked link types, path escapes, and size budgets", () => {
const checker = createTarEntryPreflightChecker({
rootDir: "/tmp/dest",
limits: {
maxEntries: 1,
maxEntryBytes: 8,
maxExtractedBytes: 12,
},
});
expect(() => checker({ path: "package/link", type: "SymbolicLink", size: 0 })).toThrow(
"tar entry is a link: package/link",
);
expect(() => checker({ path: "../escape.txt", type: "File", size: 1 })).toThrow(
/escapes destination|absolute/i,
);
checker({ path: "package/ok.txt", type: "File", size: 8 });
expect(() => checker({ path: "package/second.txt", type: "File", size: 1 })).toThrow(
"archive entry count exceeds limit",
);
});
it("treats stripped-away tar entries as no-ops and enforces extracted byte budgets", () => {
const checker = createTarEntryPreflightChecker({
rootDir: "/tmp/dest",
stripComponents: 1,
limits: {
maxEntries: 4,
maxEntryBytes: 16,
maxExtractedBytes: 10,
},
});
expect(() => checker({ path: "package", type: "Directory", size: 0 })).not.toThrow();
checker({ path: "package/a.txt", type: "File", size: 6 });
expect(() => checker({ path: "package/b.txt", type: "File", size: 6 })).toThrow(
"archive extracted size exceeds limit",
);
});
it("reads JSON files and reports file existence", async () => {
const dir = await createTempDir();
const jsonPath = path.join(dir, "data.json");
const badPath = path.join(dir, "bad.json");
await fs.writeFile(jsonPath, '{"ok":true}', "utf8");
await fs.writeFile(badPath, "{not json", "utf8");
await expect(readJsonFile<{ ok: boolean }>(jsonPath)).resolves.toEqual({ ok: true });
await expect(readJsonFile(badPath)).rejects.toThrow();
await expect(fileExists(jsonPath)).resolves.toBe(true);
await expect(fileExists(path.join(dir, "missing.json"))).resolves.toBe(false);
});
});

View File

@@ -6,7 +6,7 @@ import * as tar from "tar";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { withRealpathSymlinkRebindRace } from "../test-utils/symlink-rebind-race.js";
import type { ArchiveSecurityError } from "./archive.js";
import { extractArchive, resolveArchiveKind, resolvePackedRootDir } from "./archive.js";
import { extractArchive, resolvePackedRootDir } from "./archive.js";
let fixtureRoot = "";
let fixtureCount = 0;
@@ -82,19 +82,6 @@ afterAll(async () => {
});
describe("archive utils", () => {
it("detects archive kinds", () => {
const cases = [
{ input: "/tmp/file.zip", expected: "zip" },
{ input: "/tmp/file.tgz", expected: "tar" },
{ input: "/tmp/file.tar.gz", expected: "tar" },
{ input: "/tmp/file.tar", expected: "tar" },
{ input: "/tmp/file.txt", expected: null },
] as const;
for (const testCase of cases) {
expect(resolveArchiveKind(testCase.input), testCase.input).toBe(testCase.expected);
}
});
it.each([{ ext: "zip" as const }, { ext: "tar" as const }])(
"extracts $ext archives",
async ({ ext }) => {
@@ -329,14 +316,6 @@ describe("archive utils", () => {
},
);
it("fails resolvePackedRootDir when extract dir has multiple root dirs", async () => {
const workDir = await makeTempDir("packed-root");
const extractDir = path.join(workDir, "extract");
await fs.mkdir(path.join(extractDir, "a"), { recursive: true });
await fs.mkdir(path.join(extractDir, "b"), { recursive: true });
await expect(resolvePackedRootDir(extractDir)).rejects.toThrow(/unexpected archive layout/i);
});
it("rejects tar entries with absolute extraction paths", async () => {
await withArchiveCase("tar", async ({ workDir, archivePath, extractDir }) => {
const inputDir = path.join(workDir, "input");

View File

@@ -1,130 +1,169 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
issueDeviceBootstrapToken,
verifyDeviceBootstrapToken,
} from "./device-bootstrap.js";
const tempRoots: string[] = [];
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-device-bootstrap-test-");
async function createBaseDir(): Promise<string> {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-bootstrap-"));
tempRoots.push(baseDir);
return baseDir;
function resolveBootstrapPath(baseDir: string): string {
return path.join(baseDir, "devices", "bootstrap.json");
}
afterEach(async () => {
vi.useRealTimers();
await Promise.all(
tempRoots.splice(0).map(async (root) => await rm(root, { recursive: true, force: true })),
);
await tempDirs.cleanup();
});
describe("device bootstrap tokens", () => {
it("accepts the first successful verification", async () => {
const baseDir = await createBaseDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "pub-1",
role: "node",
scopes: ["node.invoke"],
baseDir,
}),
).resolves.toEqual({ ok: true });
});
it("rejects replay after the first successful verification", async () => {
const baseDir = await createBaseDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "pub-1",
role: "node",
scopes: ["node.invoke"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "pub-1",
role: "operator",
scopes: ["operator.read"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
it("rejects reuse from a different device after consumption", async () => {
const baseDir = await createBaseDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "pub-1",
role: "node",
scopes: ["node.invoke"],
baseDir,
});
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-2",
publicKey: "pub-2",
role: "node",
scopes: ["node.invoke"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
it("expires bootstrap tokens after the ttl window", async () => {
it("issues bootstrap tokens and persists them with an expiry", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-12T10:00:00Z"));
const baseDir = await createBaseDir();
vi.setSystemTime(new Date("2026-03-14T12:00:00Z"));
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
vi.setSystemTime(new Date(Date.now() + DEVICE_BOOTSTRAP_TOKEN_TTL_MS + 1));
expect(issued.token).toMatch(/^[A-Za-z0-9_-]+$/);
expect(issued.expiresAtMs).toBe(Date.now() + DEVICE_BOOTSTRAP_TOKEN_TTL_MS);
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
const parsed = JSON.parse(raw) as Record<
string,
{ token: string; ts: number; issuedAtMs: number }
>;
expect(parsed[issued.token]).toMatchObject({
token: issued.token,
ts: Date.now(),
issuedAtMs: Date.now(),
});
});
it("verifies valid bootstrap tokens once and deletes them after success", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "pub-1",
role: "node",
scopes: ["node.invoke"],
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
});
it("keeps the token when required verification fields are blank", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: " ",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
expect(raw).toContain(issued.token);
});
it("rejects blank or unknown tokens", async () => {
const baseDir = await createTempDir();
await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: " ",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(
verifyDeviceBootstrapToken({
token: "missing-token",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
it("persists only token state that verification actually consumes", async () => {
const baseDir = await createBaseDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
const raw = await readFile(join(baseDir, "devices", "bootstrap.json"), "utf8");
const state = JSON.parse(raw) as Record<string, Record<string, unknown>>;
const record = state[issued.token];
it("accepts legacy records that only stored issuedAtMs and prunes expired tokens", async () => {
vi.useFakeTimers();
const baseDir = await createTempDir();
const bootstrapPath = resolveBootstrapPath(baseDir);
await fs.mkdir(path.dirname(bootstrapPath), { recursive: true });
expect(record).toMatchObject({
token: issued.token,
});
expect(record).not.toHaveProperty("channel");
expect(record).not.toHaveProperty("senderId");
expect(record).not.toHaveProperty("accountId");
expect(record).not.toHaveProperty("threadId");
vi.setSystemTime(new Date("2026-03-14T12:00:00Z"));
await fs.writeFile(
bootstrapPath,
`${JSON.stringify(
{
legacyToken: {
token: "legacyToken",
issuedAtMs: Date.now(),
},
expiredToken: {
token: "expiredToken",
issuedAtMs: Date.now() - DEVICE_BOOTSTRAP_TOKEN_TTL_MS - 1,
},
},
null,
2,
)}\n`,
"utf8",
);
await expect(
verifyDeviceBootstrapToken({
token: "legacyToken",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(
verifyDeviceBootstrapToken({
token: "expiredToken",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
});

View File

@@ -0,0 +1,182 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
normalizeApnsEnvironment,
resolveApnsAuthConfigFromEnv,
shouldClearStoredApnsRegistration,
shouldInvalidateApnsRegistration,
} from "./push-apns.js";
const tempDirs: string[] = [];
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-auth-test-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
await fs.rm(dir, { recursive: true, force: true });
}
}
});
describe("push APNs auth and helper coverage", () => {
it("normalizes APNs environment values", () => {
expect(normalizeApnsEnvironment("sandbox")).toBe("sandbox");
expect(normalizeApnsEnvironment(" PRODUCTION ")).toBe("production");
expect(normalizeApnsEnvironment("staging")).toBeNull();
expect(normalizeApnsEnvironment(null)).toBeNull();
});
it("prefers inline APNs private key values and unescapes newlines", async () => {
const resolved = await resolveApnsAuthConfigFromEnv({
OPENCLAW_APNS_TEAM_ID: "TEAM123",
OPENCLAW_APNS_KEY_ID: "KEY123",
OPENCLAW_APNS_PRIVATE_KEY_P8:
"-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----", // pragma: allowlist secret
OPENCLAW_APNS_PRIVATE_KEY: "ignored",
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
ok: true,
value: {
teamId: "TEAM123",
keyId: "KEY123",
},
});
if (resolved.ok) {
expect(resolved.value.privateKey).toContain("\nline-a\n");
expect(resolved.value.privateKey).not.toBe("ignored");
}
});
it("falls back to OPENCLAW_APNS_PRIVATE_KEY when OPENCLAW_APNS_PRIVATE_KEY_P8 is blank", async () => {
const resolved = await resolveApnsAuthConfigFromEnv({
OPENCLAW_APNS_TEAM_ID: "TEAM123",
OPENCLAW_APNS_KEY_ID: "KEY123",
OPENCLAW_APNS_PRIVATE_KEY_P8: " ",
OPENCLAW_APNS_PRIVATE_KEY:
"-----BEGIN PRIVATE KEY-----\\nline-c\\nline-d\\n-----END PRIVATE KEY-----", // pragma: allowlist secret
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
ok: true,
value: {
teamId: "TEAM123",
keyId: "KEY123",
privateKey: "-----BEGIN PRIVATE KEY-----\nline-c\nline-d\n-----END PRIVATE KEY-----",
},
});
});
it("reads APNs private keys from OPENCLAW_APNS_PRIVATE_KEY_PATH", async () => {
const dir = await makeTempDir();
const keyPath = path.join(dir, "apns-key.p8");
await fs.writeFile(
keyPath,
"-----BEGIN PRIVATE KEY-----\\nline-e\\nline-f\\n-----END PRIVATE KEY-----\n",
"utf8",
);
const resolved = await resolveApnsAuthConfigFromEnv({
OPENCLAW_APNS_TEAM_ID: "TEAM123",
OPENCLAW_APNS_KEY_ID: "KEY123",
OPENCLAW_APNS_PRIVATE_KEY_PATH: keyPath,
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
ok: true,
value: {
teamId: "TEAM123",
keyId: "KEY123",
privateKey: "-----BEGIN PRIVATE KEY-----\nline-e\nline-f\n-----END PRIVATE KEY-----",
},
});
});
it("reports missing auth fields and path read failures", async () => {
const dir = await makeTempDir();
const missingPath = path.join(dir, "missing-key.p8");
await expect(resolveApnsAuthConfigFromEnv({} as NodeJS.ProcessEnv)).resolves.toEqual({
ok: false,
error: "APNs auth missing: set OPENCLAW_APNS_TEAM_ID and OPENCLAW_APNS_KEY_ID",
});
const missingKey = await resolveApnsAuthConfigFromEnv({
OPENCLAW_APNS_TEAM_ID: "TEAM123",
OPENCLAW_APNS_KEY_ID: "KEY123",
OPENCLAW_APNS_PRIVATE_KEY_PATH: missingPath,
} as NodeJS.ProcessEnv);
expect(missingKey.ok).toBe(false);
if (!missingKey.ok) {
expect(missingKey.error).toContain(
`failed reading OPENCLAW_APNS_PRIVATE_KEY_PATH (${missingPath})`,
);
}
});
it("invalidates only real bad-token APNs failures", () => {
expect(shouldInvalidateApnsRegistration({ status: 410, reason: "Unregistered" })).toBe(true);
expect(shouldInvalidateApnsRegistration({ status: 400, reason: " BadDeviceToken " })).toBe(
true,
);
expect(shouldInvalidateApnsRegistration({ status: 400, reason: "BadTopic" })).toBe(false);
expect(shouldInvalidateApnsRegistration({ status: 429, reason: "BadDeviceToken" })).toBe(false);
});
it("clears only direct registrations without an environment override mismatch", () => {
expect(
shouldClearStoredApnsRegistration({
registration: {
nodeId: "ios-node-direct",
transport: "direct",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
updatedAtMs: 1,
},
result: { status: 400, reason: "BadDeviceToken" },
}),
).toBe(true);
expect(
shouldClearStoredApnsRegistration({
registration: {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
updatedAtMs: 1,
},
result: { status: 410, reason: "Unregistered" },
}),
).toBe(false);
expect(
shouldClearStoredApnsRegistration({
registration: {
nodeId: "ios-node-direct",
transport: "direct",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
updatedAtMs: 1,
},
result: { status: 400, reason: "BadDeviceToken" },
overrideEnvironment: "production",
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,284 @@
import { generateKeyPairSync } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
deriveDeviceIdFromPublicKey,
publicKeyRawBase64UrlFromPem,
verifyDeviceSignature,
} from "./device-identity.js";
import { resolveApnsRelayConfigFromEnv, sendApnsRelayPush } from "./push-apns.relay.js";
const relayGatewayIdentity = (() => {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString();
const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem);
const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);
if (!deviceId) {
throw new Error("failed to derive test gateway device id");
}
return {
deviceId,
publicKey: publicKeyRaw,
privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
};
})();
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("push-apns.relay", () => {
describe("resolveApnsRelayConfigFromEnv", () => {
it("returns a missing-config error when no relay base URL is configured", () => {
expect(resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv)).toEqual({
ok: false,
error:
"APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL",
});
});
it("lets env overrides win and clamps tiny timeout values", () => {
const resolved = resolveApnsRelayConfigFromEnv(
{
OPENCLAW_APNS_RELAY_BASE_URL: " https://relay-override.example.com/base/ ",
OPENCLAW_APNS_RELAY_TIMEOUT_MS: "999",
} 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/base",
timeoutMs: 1000,
},
});
});
it("allows loopback http URLs for alternate truthy env values", () => {
const resolved = resolveApnsRelayConfigFromEnv({
OPENCLAW_APNS_RELAY_BASE_URL: "http://[::1]:8787",
OPENCLAW_APNS_RELAY_ALLOW_HTTP: "yes",
OPENCLAW_APNS_RELAY_TIMEOUT_MS: "nope",
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
ok: true,
value: {
baseUrl: "http://[::1]:8787",
timeoutMs: 10_000,
},
});
});
it.each([
{
name: "unsupported protocol",
env: { OPENCLAW_APNS_RELAY_BASE_URL: "ftp://relay.example.com" },
expected: "unsupported protocol",
},
{
name: "http non-loopback host",
env: {
OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com",
OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true",
},
expected: "loopback hosts",
},
{
name: "query string",
env: { OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com/path?debug=1" },
expected: "query and fragment are not allowed",
},
{
name: "userinfo",
env: { OPENCLAW_APNS_RELAY_BASE_URL: "https://user:pass@relay.example.com/path" },
expected: "userinfo is not allowed",
},
])("rejects invalid relay URL: $name", ({ env, expected }) => {
const resolved = resolveApnsRelayConfigFromEnv(env as NodeJS.ProcessEnv);
expect(resolved.ok).toBe(false);
if (!resolved.ok) {
expect(resolved.error).toContain(expected);
}
});
});
describe("sendApnsRelayPush", () => {
it("signs relay payloads and forwards the request through the injected sender", async () => {
vi.spyOn(Date, "now").mockReturnValue(123_456_789);
const sender = vi.fn().mockResolvedValue({
ok: true,
status: 200,
apnsId: "relay-apns-id",
environment: "production",
tokenSuffix: "abcd1234",
});
const result = await sendApnsRelayPush({
relayConfig: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
sendGrant: "send-grant-123",
relayHandle: "relay-handle-123",
payload: { aps: { alert: { title: "Wake", body: "Ping" } } },
pushType: "alert",
priority: "10",
gatewayIdentity: relayGatewayIdentity,
requestSender: sender,
});
expect(sender).toHaveBeenCalledTimes(1);
const sent = sender.mock.calls[0]?.[0];
expect(sent).toMatchObject({
relayConfig: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
sendGrant: "send-grant-123",
relayHandle: "relay-handle-123",
gatewayDeviceId: relayGatewayIdentity.deviceId,
signedAtMs: 123_456_789,
pushType: "alert",
priority: "10",
payload: { aps: { alert: { title: "Wake", body: "Ping" } } },
});
expect(sent?.bodyJson).toBe(
JSON.stringify({
relayHandle: "relay-handle-123",
pushType: "alert",
priority: 10,
payload: { aps: { alert: { title: "Wake", body: "Ping" } } },
}),
);
expect(
verifyDeviceSignature(
relayGatewayIdentity.publicKey,
[
"openclaw-relay-send-v1",
sent?.gatewayDeviceId,
String(sent?.signedAtMs),
sent?.bodyJson,
].join("\n"),
sent?.signature ?? "",
),
).toBe(true);
expect(result).toMatchObject({
ok: true,
status: 200,
apnsId: "relay-apns-id",
environment: "production",
tokenSuffix: "abcd1234",
});
});
it("does not follow relay redirects", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 302,
json: vi.fn().mockRejectedValue(new Error("no body")),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const result = await sendApnsRelayPush({
relayConfig: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
sendGrant: "send-grant-123",
relayHandle: "relay-handle-123",
payload: { aps: { "content-available": 1 } },
pushType: "background",
priority: "5",
gatewayIdentity: relayGatewayIdentity,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" });
expect(result).toMatchObject({
ok: false,
status: 302,
reason: "RelayRedirectNotAllowed",
environment: "production",
});
});
it("falls back to fetch status when the relay body is not JSON", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 202,
json: vi.fn().mockRejectedValue(new Error("bad json")),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
await expect(
sendApnsRelayPush({
relayConfig: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
sendGrant: "send-grant-123",
relayHandle: "relay-handle-123",
payload: { aps: { "content-available": 1 } },
pushType: "background",
priority: "5",
gatewayIdentity: relayGatewayIdentity,
}),
).resolves.toEqual({
ok: true,
status: 202,
apnsId: undefined,
reason: undefined,
environment: "production",
tokenSuffix: undefined,
});
});
it("normalizes relay JSON response fields", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 202,
json: vi.fn().mockResolvedValue({
ok: false,
status: 410,
apnsId: " relay-apns-id ",
reason: " Unregistered ",
tokenSuffix: " abcd1234 ",
}),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
await expect(
sendApnsRelayPush({
relayConfig: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
sendGrant: "send-grant-123",
relayHandle: "relay-handle-123",
payload: { aps: { "content-available": 1 } },
pushType: "background",
priority: "5",
gatewayIdentity: relayGatewayIdentity,
}),
).resolves.toEqual({
ok: false,
status: 410,
apnsId: "relay-apns-id",
reason: "Unregistered",
environment: "production",
tokenSuffix: "abcd1234",
});
});
});
});

View File

@@ -0,0 +1,308 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearApnsRegistration,
clearApnsRegistrationIfCurrent,
loadApnsRegistration,
registerApnsRegistration,
registerApnsToken,
} from "./push-apns.js";
const tempDirs: string[] = [];
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-store-test-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
await fs.rm(dir, { recursive: true, force: true });
}
}
});
describe("push APNs registration store", () => {
it("stores and reloads direct APNs registrations", async () => {
const baseDir = await makeTempDir();
const saved = await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
baseDir,
});
const loaded = await loadApnsRegistration("ios-node-1", baseDir);
expect(loaded).toMatchObject({
nodeId: "ios-node-1",
transport: "direct",
topic: "ai.openclaw.ios",
environment: "sandbox",
updatedAtMs: saved.updatedAtMs,
});
expect(loaded && loaded.transport === "direct" ? loaded.token : null).toBe(
"abcd1234abcd1234abcd1234abcd1234",
);
});
it("stores relay-backed registrations without a raw token", async () => {
const baseDir = await makeTempDir();
const saved = await registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
tokenDebugSuffix: " abcd-1234 ",
baseDir,
});
const loaded = await loadApnsRegistration("ios-node-relay", baseDir);
expect(saved.transport).toBe("relay");
expect(loaded).toMatchObject({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
tokenDebugSuffix: "abcd1234",
});
expect(loaded && "token" in loaded).toBe(false);
});
it("normalizes legacy direct records from disk and ignores invalid entries", async () => {
const baseDir = await makeTempDir();
const statePath = path.join(baseDir, "push", "apns-registrations.json");
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(
statePath,
`${JSON.stringify(
{
registrationsByNodeId: {
" ios-node-legacy ": {
nodeId: " ios-node-legacy ",
token: "<ABCD1234ABCD1234ABCD1234ABCD1234>",
topic: " ai.openclaw.ios ",
environment: " PRODUCTION ",
updatedAtMs: 3,
},
" ": {
nodeId: " ios-node-fallback ",
token: "<ABCD1234ABCD1234ABCD1234ABCD1234>",
topic: " ai.openclaw.ios ",
updatedAtMs: 2,
},
"ios-node-bad-relay": {
transport: "relay",
nodeId: "ios-node-bad-relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "beta",
updatedAtMs: 1,
},
},
},
null,
2,
)}\n`,
"utf8",
);
await expect(loadApnsRegistration("ios-node-legacy", baseDir)).resolves.toMatchObject({
nodeId: "ios-node-legacy",
transport: "direct",
token: "abcd1234abcd1234abcd1234abcd1234",
topic: "ai.openclaw.ios",
environment: "production",
updatedAtMs: 3,
});
await expect(loadApnsRegistration("ios-node-fallback", baseDir)).resolves.toMatchObject({
nodeId: "ios-node-fallback",
transport: "direct",
topic: "ai.openclaw.ios",
environment: "sandbox",
updatedAtMs: 2,
});
await expect(loadApnsRegistration("ios-node-bad-relay", baseDir)).resolves.toBeNull();
});
it("falls back cleanly for malformed or missing registration state", async () => {
const baseDir = await makeTempDir();
const statePath = path.join(baseDir, "push", "apns-registrations.json");
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(statePath, "[]", "utf8");
await expect(loadApnsRegistration("ios-node-missing", baseDir)).resolves.toBeNull();
await expect(loadApnsRegistration(" ", baseDir)).resolves.toBeNull();
await expect(clearApnsRegistration(" ", baseDir)).resolves.toBe(false);
await expect(clearApnsRegistration("ios-node-missing", baseDir)).resolves.toBe(false);
});
it("rejects invalid direct and relay registration inputs", async () => {
const baseDir = await makeTempDir();
const oversized = "x".repeat(257);
await expect(
registerApnsToken({
nodeId: "ios-node-1",
token: "not-a-token",
topic: "ai.openclaw.ios",
baseDir,
}),
).rejects.toThrow("invalid APNs token");
await expect(
registerApnsToken({
nodeId: "n".repeat(257),
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
baseDir,
}),
).rejects.toThrow("nodeId required");
await expect(
registerApnsToken({
nodeId: "ios-node-1",
token: "A".repeat(513),
topic: "ai.openclaw.ios",
baseDir,
}),
).rejects.toThrow("invalid APNs token");
await expect(
registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "a".repeat(256),
baseDir,
}),
).rejects.toThrow("topic required");
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "staging",
distribution: "official",
baseDir,
}),
).rejects.toThrow("relay registrations must use production environment");
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "beta",
baseDir,
}),
).rejects.toThrow("relay registrations must use official distribution");
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: oversized,
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
baseDir,
}),
).rejects.toThrow("relayHandle too long");
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: oversized,
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
baseDir,
}),
).rejects.toThrow("installationId too long");
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "x".repeat(1025),
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
baseDir,
}),
).rejects.toThrow("sendGrant too long");
});
it("persists with a trailing newline and clears registrations", async () => {
const baseDir = await makeTempDir();
await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
baseDir,
});
const statePath = path.join(baseDir, "push", "apns-registrations.json");
await expect(fs.readFile(statePath, "utf8")).resolves.toMatch(/\n$/);
await expect(clearApnsRegistration("ios-node-1", baseDir)).resolves.toBe(true);
await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toBeNull();
});
it("only clears a registration when the stored entry still matches", async () => {
vi.useFakeTimers();
try {
const baseDir = await makeTempDir();
vi.setSystemTime(new Date("2026-03-11T00:00:00Z"));
const stale = await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
baseDir,
});
vi.setSystemTime(new Date("2026-03-11T00:00:01Z"));
const fresh = await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
baseDir,
});
await expect(
clearApnsRegistrationIfCurrent({
nodeId: "ios-node-1",
registration: stale,
baseDir,
}),
).resolves.toBe(false);
await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toEqual(fresh);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -1,53 +1,10 @@
import { generateKeyPairSync } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
deriveDeviceIdFromPublicKey,
publicKeyRawBase64UrlFromPem,
verifyDeviceSignature,
} from "./device-identity.js";
import {
clearApnsRegistration,
clearApnsRegistrationIfCurrent,
loadApnsRegistration,
normalizeApnsEnvironment,
registerApnsRegistration,
registerApnsToken,
resolveApnsAuthConfigFromEnv,
resolveApnsRelayConfigFromEnv,
sendApnsAlert,
sendApnsBackgroundWake,
shouldClearStoredApnsRegistration,
shouldInvalidateApnsRegistration,
} from "./push-apns.js";
import { sendApnsRelayPush } from "./push-apns.relay.js";
import { sendApnsAlert, sendApnsBackgroundWake } from "./push-apns.js";
const tempDirs: string[] = [];
const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
.privateKey.export({ format: "pem", type: "pkcs8" })
.toString();
const relayGatewayIdentity = (() => {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString();
const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem);
const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);
if (!deviceId) {
throw new Error("failed to derive test gateway device id");
}
return {
deviceId,
publicKey: publicKeyRaw,
privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
};
})();
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-"));
tempDirs.push(dir);
return dir;
}
function createDirectApnsSendFixture(params: {
nodeId: string;
@@ -74,398 +31,6 @@ function createDirectApnsSendFixture(params: {
afterEach(async () => {
vi.unstubAllGlobals();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
await fs.rm(dir, { recursive: true, force: true });
}
}
});
describe("push APNs registration store", () => {
it("stores and reloads node APNs registration", async () => {
const baseDir = await makeTempDir();
const saved = await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
baseDir,
});
const loaded = await loadApnsRegistration("ios-node-1", baseDir);
expect(loaded).not.toBeNull();
expect(loaded?.nodeId).toBe("ios-node-1");
expect(loaded?.transport).toBe("direct");
expect(loaded && loaded.transport === "direct" ? loaded.token : null).toBe(
"abcd1234abcd1234abcd1234abcd1234",
);
expect(loaded?.topic).toBe("ai.openclaw.ios");
expect(loaded?.environment).toBe("sandbox");
expect(loaded?.updatedAtMs).toBe(saved.updatedAtMs);
});
it("stores and reloads relay-backed APNs registrations without a raw token", async () => {
const baseDir = await makeTempDir();
const saved = await registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
tokenDebugSuffix: "abcd1234",
baseDir,
});
const loaded = await loadApnsRegistration("ios-node-relay", baseDir);
expect(saved.transport).toBe("relay");
expect(loaded).toMatchObject({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
tokenDebugSuffix: "abcd1234",
});
expect(loaded && "token" in loaded).toBe(false);
});
it("rejects invalid APNs tokens", async () => {
const baseDir = await makeTempDir();
await expect(
registerApnsToken({
nodeId: "ios-node-1",
token: "not-a-token",
topic: "ai.openclaw.ios",
baseDir,
}),
).rejects.toThrow("invalid APNs token");
});
it("rejects oversized direct APNs registration fields", async () => {
const baseDir = await makeTempDir();
await expect(
registerApnsToken({
nodeId: "n".repeat(257),
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
baseDir,
}),
).rejects.toThrow("nodeId required");
await expect(
registerApnsToken({
nodeId: "ios-node-1",
token: "A".repeat(513),
topic: "ai.openclaw.ios",
baseDir,
}),
).rejects.toThrow("invalid APNs token");
await expect(
registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "a".repeat(256),
baseDir,
}),
).rejects.toThrow("topic required");
});
it("rejects relay registrations that do not use production/official values", async () => {
const baseDir = await makeTempDir();
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "staging",
distribution: "official",
baseDir,
}),
).rejects.toThrow("relay registrations must use production environment");
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "beta",
baseDir,
}),
).rejects.toThrow("relay registrations must use official distribution");
});
it("rejects oversized relay registration identifiers", async () => {
const baseDir = await makeTempDir();
const oversized = "x".repeat(257);
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: oversized,
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
baseDir,
}),
).rejects.toThrow("relayHandle too long");
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: oversized,
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
baseDir,
}),
).rejects.toThrow("installationId too long");
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "x".repeat(1025),
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
baseDir,
}),
).rejects.toThrow("sendGrant too long");
});
it("clears registrations", async () => {
const baseDir = await makeTempDir();
await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
baseDir,
});
await expect(clearApnsRegistration("ios-node-1", baseDir)).resolves.toBe(true);
await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toBeNull();
});
it("only clears a registration when the stored entry still matches", async () => {
vi.useFakeTimers();
try {
const baseDir = await makeTempDir();
vi.setSystemTime(new Date("2026-03-11T00:00:00Z"));
const stale = await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
baseDir,
});
vi.setSystemTime(new Date("2026-03-11T00:00:01Z"));
const fresh = await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
baseDir,
});
await expect(
clearApnsRegistrationIfCurrent({
nodeId: "ios-node-1",
registration: stale,
baseDir,
}),
).resolves.toBe(false);
await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toEqual(fresh);
} finally {
vi.useRealTimers();
}
});
});
describe("push APNs env config", () => {
it("normalizes APNs environment values", () => {
expect(normalizeApnsEnvironment("sandbox")).toBe("sandbox");
expect(normalizeApnsEnvironment("PRODUCTION")).toBe("production");
expect(normalizeApnsEnvironment("staging")).toBeNull();
});
it("resolves inline private key and unescapes newlines", async () => {
const env = {
OPENCLAW_APNS_TEAM_ID: "TEAM123",
OPENCLAW_APNS_KEY_ID: "KEY123",
OPENCLAW_APNS_PRIVATE_KEY_P8:
"-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----", // pragma: allowlist secret
} as NodeJS.ProcessEnv;
const resolved = await resolveApnsAuthConfigFromEnv(env);
expect(resolved.ok).toBe(true);
if (!resolved.ok) {
return;
}
expect(resolved.value.privateKey).toContain("\nline-a\n");
expect(resolved.value.teamId).toBe("TEAM123");
expect(resolved.value.keyId).toBe("KEY123");
});
it("returns an error when required APNs auth vars are missing", async () => {
const resolved = await resolveApnsAuthConfigFromEnv({} as NodeJS.ProcessEnv);
expect(resolved.ok).toBe(false);
if (resolved.ok) {
return;
}
expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID");
});
it("resolves APNs relay config from env", () => {
const resolved = resolveApnsRelayConfigFromEnv({
OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com",
OPENCLAW_APNS_RELAY_TIMEOUT_MS: "2500",
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
ok: true,
value: {
baseUrl: "https://relay.example.com",
timeoutMs: 2500,
},
});
});
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",
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
ok: false,
});
if (resolved.ok) {
return;
}
expect(resolved.error).toContain("OPENCLAW_APNS_RELAY_ALLOW_HTTP=true");
});
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_ALLOW_HTTP: "true",
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
ok: true,
value: {
baseUrl: "http://127.0.0.1:8787",
timeoutMs: 10_000,
},
});
});
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_ALLOW_HTTP: "true",
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
ok: false,
});
if (resolved.ok) {
return;
}
expect(resolved.error).toContain("loopback hosts");
});
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",
} as NodeJS.ProcessEnv);
expect(withQuery.ok).toBe(false);
if (!withQuery.ok) {
expect(withQuery.error).toContain("query and fragment are not allowed");
}
const withUserinfo = resolveApnsRelayConfigFromEnv({
OPENCLAW_APNS_RELAY_BASE_URL: "https://user:pass@relay.example.com/path",
} as NodeJS.ProcessEnv);
expect(withUserinfo.ok).toBe(false);
if (!withUserinfo.ok) {
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", () => {
@@ -577,158 +142,4 @@ describe("push APNs send semantics", () => {
},
});
});
it("routes relay-backed alert pushes through the relay sender", async () => {
const send = vi.fn().mockResolvedValue({
ok: true,
status: 200,
apnsId: "relay-apns-id",
environment: "production",
tokenSuffix: "abcd1234",
});
const result = await sendApnsAlert({
relayConfig: {
baseUrl: "https://relay.example.com",
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",
distribution: "official",
updatedAtMs: 1,
tokenDebugSuffix: "abcd1234",
},
nodeId: "ios-node-relay",
title: "Wake",
body: "Ping",
relayGatewayIdentity: relayGatewayIdentity,
relayRequestSender: send,
});
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0]?.[0]).toMatchObject({
relayHandle: "relay-handle-123",
gatewayDeviceId: relayGatewayIdentity.deviceId,
pushType: "alert",
priority: "10",
payload: {
aps: {
alert: { title: "Wake", body: "Ping" },
sound: "default",
},
},
});
const sent = send.mock.calls[0]?.[0];
expect(typeof sent?.signature).toBe("string");
expect(typeof sent?.signedAtMs).toBe("number");
const signedPayload = [
"openclaw-relay-send-v1",
sent?.gatewayDeviceId,
String(sent?.signedAtMs),
sent?.bodyJson,
].join("\n");
expect(
verifyDeviceSignature(relayGatewayIdentity.publicKey, signedPayload, sent?.signature),
).toBe(true);
expect(result).toMatchObject({
ok: true,
status: 200,
transport: "relay",
environment: "production",
tokenSuffix: "abcd1234",
});
});
it("does not follow relay redirects", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 302,
json: vi.fn().mockRejectedValue(new Error("no body")),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const result = await sendApnsRelayPush({
relayConfig: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
sendGrant: "send-grant-123",
relayHandle: "relay-handle-123",
payload: { aps: { "content-available": 1 } },
pushType: "background",
priority: "5",
gatewayIdentity: relayGatewayIdentity,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" });
expect(result).toMatchObject({
ok: false,
status: 302,
reason: "RelayRedirectNotAllowed",
environment: "production",
});
});
it("flags invalid device responses for registration invalidation", () => {
expect(shouldInvalidateApnsRegistration({ status: 400, reason: "BadDeviceToken" })).toBe(true);
expect(shouldInvalidateApnsRegistration({ status: 410, reason: "Unregistered" })).toBe(true);
expect(shouldInvalidateApnsRegistration({ status: 429, reason: "TooManyRequests" })).toBe(
false,
);
});
it("only clears stored registrations for direct APNs failures without an override mismatch", () => {
expect(
shouldClearStoredApnsRegistration({
registration: {
nodeId: "ios-node-direct",
transport: "direct",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
updatedAtMs: 1,
},
result: { status: 400, reason: "BadDeviceToken" },
}),
).toBe(true);
expect(
shouldClearStoredApnsRegistration({
registration: {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
updatedAtMs: 1,
},
result: { status: 410, reason: "Unregistered" },
}),
).toBe(false);
expect(
shouldClearStoredApnsRegistration({
registration: {
nodeId: "ios-node-direct",
transport: "direct",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
updatedAtMs: 1,
},
result: { status: 400, reason: "BadDeviceToken" },
overrideEnvironment: "production",
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,156 @@
import { X509Certificate } from "node:crypto";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
import { normalizeFingerprint } from "./fingerprint.js";
import { loadGatewayTlsRuntime } from "./gateway.js";
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-gateway-tls-test-");
const KEY_PEM = [
"-----BEGIN PRIVATE KEY-----", // pragma: allowlist secret
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrur5CWp4psMMb",
"DTPY1aN46HPDxRchGgh8XedNkrlc4z1KFiyLUsXpVIhuyoXq1fflpTDz7++pGEDJ",
"Q5pEdChn3fuWgi7gC+pvd5VQ1eAX/7qVE72fhx14NxhaiZU3hCzXjG2SflTEEExk",
"UkQTm0rdHSjgLVMhTM3Pqm6Kzfdgtm9ZyXwlAsorE/pvgbUxG3Q4xKNBGzbirZ+1",
"EzPDwsjf3fitNtakZJkymu6Kg5lsUihQVXOP0U7f989FmevoTMvJmkvJzsoTRd7s",
"XNSOjzOwJr8da8C4HkXi21md1yEccyW0iSh7tWvDrpWDAgW6RMuMHC0tW4bkpDGr",
"FpbQOgzVAgMBAAECggEAIMhwf8Ve9CDVTWyNXpU9fgnj2aDOCeg3MGaVzaO/XCPt",
"KOHDEaAyDnRXYgMP0zwtFNafo3klnSBWmDbq3CTEXseQHtsdfkKh+J0KmrqXxval",
"YeikKSyvBEIzRJoYMqeS3eo1bddcXgT/Pr9zIL/qzivpPJ4JDttBzyTeaTbiNaR9",
"KphGNueo+MTQMLreMqw5VAyJ44gy7Z/2TMiMEc/d95wfubcOSsrIfpOKnMvWd/rl",
"vxIS33s95L7CjREkixskj5Yo5Wpt3Yf5b0Zi70YiEsCfAZUDrPW7YzMlylzmhMzm",
"MARZKfN1Tmo74SGpxUrBury+iPwf1sYcRnsHR+zO8QKBgQD6ISQHRzPboZ3J/60+",
"fRLETtrBa9WkvaH9c+woF7l47D4DIlvlv9D3N1KGkUmhMnp2jNKLIlalBNDxBdB+",
"iwZP1kikGz4629Ch3/KF/VYscLTlAQNPE42jOo7Hj7VrdQx9zQrK9ZBLteXmSvOh",
"bB3aXwXPF3HoTMt9gQ9thhXZJQKBgQDxQxUnQSw43dRlqYOHzPUEwnJkGkuW/qxn",
"aRc8eopP5zUaebiDFmqhY36x2Wd+HnXrzufy2o4jkXkWTau8Ns+OLhnIG3PIU9L/",
"LYzJMckGb75QYiK1YKMUUSQzlNCS8+TFVCTAvG2u2zCCk7oTIe8aT516BQNjWDjK",
"gWo2f87N8QKBgHoVANO4kfwJxszXyMPuIeHEpwquyijNEap2EPaEldcKXz4CYB4j",
"4Cc5TkM12F0gGRuRohWcnfOPBTgOYXPSATOoX+4RCe+KaCsJ9gIl4xBvtirrsqS+",
"42ue4h9O6fpXt9AS6sii0FnTnzEmtgC8l1mE9X3dcJA0I0HPYytOvY0tAoGAAYJj",
"7Xzw4+IvY/ttgTn9BmyY/ptTgbxSI8t6g7xYhStzH5lHWDqZrCzNLBuqFBXosvL2",
"bISFgx9z3Hnb6y+EmOUc8C2LyeMMXOBSEygmk827KRGUGgJiwsvHKDN0Ipc4BSwD",
"ltkW7pMceJSoA1qg/k8lMxA49zQkFtA8c97U0mECgYEAk2DDN78sRQI8RpSECJWy",
"l1O1ikVUAYVeh5HdZkpt++ddfpo695Op9OeD2Eq27Y5EVj8Xl58GFxNk0egLUnYq",
"YzSbjcNkR2SbVvuLaV1zlQKm6M5rfvhj4//YrzrrPUQda7Q4eR0as/3q91uzAO2O",
"++pfnSCVCyp/TxSkhEDEawU=",
"-----END PRIVATE KEY-----",
].join("\n");
const CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUel0Lv05cjrViyI/H3tABBJxM7NgwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDEyMDEyMjEzMloXDTI2MDEy
MTEyMjEzMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEA67q+QlqeKbDDGw0z2NWjeOhzw8UXIRoIfF3nTZK5XOM9
ShYsi1LF6VSIbsqF6tX35aUw8+/vqRhAyUOaRHQoZ937loIu4Avqb3eVUNXgF/+6
lRO9n4cdeDcYWomVN4Qs14xtkn5UxBBMZFJEE5tK3R0o4C1TIUzNz6puis33YLZv
Wcl8JQLKKxP6b4G1MRt0OMSjQRs24q2ftRMzw8LI3934rTbWpGSZMpruioOZbFIo
UFVzj9FO3/fPRZnr6EzLyZpLyc7KE0Xe7FzUjo8zsCa/HWvAuB5F4ttZndchHHMl
tIkoe7Vrw66VgwIFukTLjBwtLVuG5KQxqxaW0DoM1QIDAQABo1MwUTAdBgNVHQ4E
FgQUwNdNkEQtd0n/aofzN7/EeYPPPbIwHwYDVR0jBBgwFoAUwNdNkEQtd0n/aofz
N7/EeYPPPbIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAnOnw
o8Az/bL0A6bGHTYra3L9ArIIljMajT6KDHxylR4LhliuVNAznnhP3UkcZbUdjqjp
MNOM0lej2pNioondtQdXUskZtqWy6+dLbTm1RYQh1lbCCZQ26o7o/oENzjPksLAb
jRM47DYxRweTyRWQ5t9wvg/xL0Yi1tWq4u4FCNZlBMgdwAEnXNwVWTzRR9RHwy20
lmUzM8uQ/p42bk4EvPEV4PI1h5G0khQ6x9CtkadCTDs/ZqoUaJMwZBIDSrdJJSLw
4Vh8Lqzia1CFB4um9J4S1Gm/VZMBjjeGGBJk7VSYn4ZmhPlbPM+6z39lpQGEG0x4
r1USnb+wUdA7Zoj/mQ==
-----END CERTIFICATE-----`;
afterEach(async () => {
await tempDirs.cleanup();
});
describe("loadGatewayTlsRuntime", () => {
it("disables tls when config is absent or disabled", async () => {
await expect(loadGatewayTlsRuntime(undefined)).resolves.toEqual({
enabled: false,
required: false,
});
await expect(loadGatewayTlsRuntime({ enabled: false })).resolves.toEqual({
enabled: false,
required: false,
});
});
it("loads existing cert, key, and optional ca files", async () => {
const dir = await createTempDir();
const certPath = path.join(dir, "gateway-cert.pem");
const keyPath = path.join(dir, "gateway-key.pem");
const caPath = path.join(dir, "gateway-ca.pem");
await writeFile(certPath, CERT_PEM, "utf8");
await writeFile(keyPath, KEY_PEM, "utf8");
await writeFile(caPath, CERT_PEM, "utf8");
const result = await loadGatewayTlsRuntime({
enabled: true,
certPath,
keyPath,
caPath,
autoGenerate: false,
});
expect(result).toMatchObject({
enabled: true,
required: true,
certPath,
keyPath,
caPath,
fingerprintSha256: normalizeFingerprint(new X509Certificate(CERT_PEM).fingerprint256 ?? ""),
tlsOptions: {
cert: CERT_PEM,
key: KEY_PEM,
ca: CERT_PEM,
minVersion: "TLSv1.3",
},
});
expect(result.error).toBeUndefined();
});
it("fails closed when cert/key are missing and auto generation is disabled", async () => {
const dir = await createTempDir();
const certPath = path.join(dir, "missing-cert.pem");
const keyPath = path.join(dir, "missing-key.pem");
await expect(
loadGatewayTlsRuntime({
enabled: true,
certPath,
keyPath,
autoGenerate: false,
}),
).resolves.toMatchObject({
enabled: false,
required: true,
certPath,
keyPath,
error: "gateway tls: cert/key missing",
});
});
it("reports load failures for invalid pem files", async () => {
const dir = await createTempDir();
const certPath = path.join(dir, "gateway-cert.pem");
const keyPath = path.join(dir, "gateway-key.pem");
await writeFile(certPath, "not a certificate\n", "utf8");
await writeFile(keyPath, KEY_PEM, "utf8");
const result = await loadGatewayTlsRuntime({
enabled: true,
certPath,
keyPath,
autoGenerate: false,
});
expect(result).toMatchObject({
enabled: false,
required: true,
certPath,
keyPath,
});
expect(result.error).toContain("gateway tls: failed to load cert");
});
});