fix(imessage): move reference checks off the Gateway thread (#155981)

Read uncached message-to-chat bindings through the existing Messages SQLite
worker and await cleanup while retaining account and conversation authority.

Proof: original/fixed real-SQLite regressions, 259 focused iMessage tests,
both extension typecheck lanes, scoped lint, storage and import guards,
and clean independent P0-P2 review. Synthetic transport only; no full build.

Inherited gate limitation at 78d5bed: the full changed check reports the
unchanged TSGO_CORE_TEST_MAX_ROOTS dead export. Canonical commit 1619aff
owns that repair; no duplicate tooling change is included here.
This commit is contained in:
Peter Steinberger 2026-09-22 16:49:37 -07:00 • committed by GitHub
parent 46dfea6a42
commit 8e7b4b3b6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 210 additions and 52 deletions

View file

@ -230,6 +230,13 @@ This execution cutover does not change schemas, stored bytes, retention, config,
or update behavior. A change to those contracts follows the
[storage review checkpoint](/reference/database-schemas/storage-changes#review-checkpoint-for-material-changes).
iMessage resource authorization reads uncached message-to-chat membership through
its existing read-only Messages database worker and joins reader cleanup before
returning. The resource owner retains local executable attestation, exclusive
account binding, and conversation matching; reply sends recheck live caller
authority after the read. Missing or failed reads retain the existing delegated
refusal and direct-operator behavior, without falling back to host SQLite.
Administrative skill archive uploads use the shared-state worker for staging,
expiry cleanup, commit, installation claims, lease renewal, and consumption. The
host retains per-upload locks and temporary archive materialization. Installation

View file

@ -10,6 +10,10 @@ import type { IMessageTarget } from "./targets.js";
export type IMessageChatDbOperations = {
startupWatermark: { input: undefined; output: number | null };
messageGuid: { input: { messageId: string }; output: string | null };
messageChats: {
input: { messageGuid: string };
output: { chatId: unknown; chatGuid: unknown; chatIdentifier: unknown }[];
};
latestSentGuid: {
input: { target: IMessageTarget; text: string; sentAfterMs?: number };
output: string | null;
@ -20,7 +24,7 @@ type ReadReceiptGuid = (
command: SqliteWorkerCommand<Pick<IMessageChatDbOperations, "messageGuid" | "latestSentGuid">>,
) => Promise<string | null>;
function openIMessageChatDbReader(databasePath: string) {
export function openIMessageChatDbReader(databasePath: string) {
return openSqliteWorkerStore<IMessageChatDbOperations>({
moduleUrl: resolveRuntimeWorkerUrl({
currentModuleUrl: import.meta.url,

View file

@ -57,6 +57,21 @@ export function openExistingSqliteWorkerBackend(
);
return typeof row?.guid === "string" ? row.guid : null;
}
if (command.type === "messageChats") {
return executeSqliteQuerySync(
db,
query
.selectFrom("message as m")
.innerJoin("chat_message_join as cmj", "cmj.message_id", "m.ROWID")
.innerJoin("chat as c", "c.ROWID", "cmj.chat_id")
.select([
"cmj.chat_id as chatId",
"c.guid as chatGuid",
"c.chat_identifier as chatIdentifier",
])
.where("m.guid", "=", command.input.messageGuid),
).rows;
}
const { target, text, sentAfterMs } = command.input;
let selection = query
.selectFrom("message as m")

View file

@ -1,14 +1,10 @@
// Imessage plugin module verifies provider message ownership in the local Messages database.
import { openNodeSqliteDatabase } from "openclaw/plugin-sdk/sqlite-runtime";
import type { SqliteWorkerStore } from "openclaw/plugin-sdk/sqlite-runtime";
import { isIMessageEmailChatIdentifier, type IMessageChatContext } from "./chat-context.js";
import { openIMessageChatDbReader, type IMessageChatDbOperations } from "./chat-db.js";
import { resolveLocalIMessageChatDbPath } from "./cli-path.js";
type IMessageResourceBinding = "match" | "mismatch" | "unavailable";
type IMessageChatRow = {
chatGuid: unknown;
chatId: unknown;
chatIdentifier: unknown;
};
export function normalizeIMessageMessageGuidForLookup(messageId: string): string {
const trimmed = messageId.trim();
@ -79,13 +75,13 @@ function matchesAnyChatCandidate(stored: unknown, candidates: string[]): boolean
return candidates.some((candidate) => matchesChatCandidate(stored, candidate));
}
export function checkIMessageResourceBinding(params: {
export async function checkIMessageResourceBinding(params: {
chatContext: IMessageChatContext;
cliPath: string;
dbPath?: string;
messageId: string;
remoteHost?: string;
}): IMessageResourceBinding {
}): Promise<IMessageResourceBinding> {
const dbPath = resolveLocalIMessageChatDbPath(params);
if (!dbPath) {
return "unavailable";
@ -110,25 +106,13 @@ export function checkIMessageResourceBinding(params: {
return "unavailable";
}
let db: import("node:sqlite").DatabaseSync | undefined;
let store: SqliteWorkerStore<IMessageChatDbOperations> | undefined;
try {
db = openNodeSqliteDatabase(dbPath, { readOnly: true });
const rows: IMessageChatRow[] = db
.prepare(
`SELECT cmj.chat_id AS chatId,
c.guid AS chatGuid,
c.chat_identifier AS chatIdentifier
FROM message m
JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
JOIN chat c ON c.ROWID = cmj.chat_id
WHERE m.guid = ?`,
)
.all(messageGuid)
.map((row) => ({
chatId: row.chatId,
chatGuid: row.chatGuid,
chatIdentifier: row.chatIdentifier,
}));
store = await openIMessageChatDbReader(dbPath);
if (!store) {
return "unavailable";
}
const rows = await store.execute({ type: "messageChats", input: { messageGuid } });
const matched = rows.some(
(row) =>
(!hasChatId || row.chatId === chatId) &&
@ -144,7 +128,7 @@ export function checkIMessageResourceBinding(params: {
return "unavailable";
} finally {
try {
db?.close();
await store?.close();
} catch {
// Best-effort cleanup after a read-only authorization query.
}

View file

@ -1,14 +1,16 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { DatabaseSync, StatementSync } from "node:sqlite";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { chatContextFromIMessageTarget } from "./chat-context.js";
import { checkIMessageResourceBinding } from "./message-resource-db.js";
import { IMessageRpcClient } from "./client.js";
import { loadFreshIMessageReplyCacheForTest } from "./test-support/runtime.js";
type MessageResourceModule = typeof import("./message-resource.js");
type ReplyCacheModule = typeof import("./monitor-reply-cache.js");
let checkIMessageResourceBinding: (typeof import("./message-resource-db.js"))["checkIMessageResourceBinding"];
let authorizeIMessageResourceReference: MessageResourceModule["authorizeIMessageResourceReference"];
let rememberIMessageReplyCache: ReplyCacheModule["rememberIMessageReplyCache"];
let resolveIMessageCachedResourceBinding: ReplyCacheModule["resolveIMessageCachedResourceBinding"];
@ -21,6 +23,7 @@ beforeEach(async () => {
({ rememberIMessageReplyCache, resolveIMessageCachedResourceBinding } =
await loadFreshIMessageReplyCacheForTest());
({ authorizeIMessageResourceReference } = await import("./message-resource.js"));
({ checkIMessageResourceBinding } = await import("./message-resource-db.js"));
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imessage-resource-"));
dbPath = path.join(tempDir, "chat.db");
const binDir = path.join(tempDir, "bin");
@ -57,10 +60,125 @@ beforeEach(async () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe("iMessage provider resource binding", () => {
it.each(["action", "reply"] as const)(
"authorizes an uncached %s through real SQLite without caller-thread native queries",
async (entrypoint) => {
const { imessageMessageActions } = await import("./actions.js");
const { sendMessageIMessage } = await import("./send.js");
const { setCachedIMessagePrivateApiStatus } = await import("./private-api-status.js");
const cli = await import("./cli-output.js");
setCachedIMessagePrivateApiStatus(cliPath, {
available: true,
v2Ready: true,
selectors: {},
rpcMethods: [],
});
const nativeSend = vi.spyOn(cli, "runIMessageCliJsonCommand").mockResolvedValue({});
const client = new IMessageRpcClient({ dbPath });
const request = vi.spyOn(client, "request").mockResolvedValue({ guid: "sent-guid" });
const prepare = vi.spyOn(DatabaseSync.prototype, "prepare");
const all = vi.spyOn(StatementSync.prototype, "all");
const close = vi.spyOn(DatabaseSync.prototype, "close");
const calibration = new DatabaseSync(dbPath, { readOnly: true });
calibration.prepare("SELECT guid FROM message").all();
calibration.close();
expect(prepare).toHaveBeenCalledOnce();
expect(all).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
vi.clearAllMocks();
const config = { channels: { imessage: { cliPath, dbPath } } };
const invoke = (chatGuid: string) =>
entrypoint === "action"
? imessageMessageActions.handleAction!({
channel: "imessage",
action: "react",
cfg: config,
params: { chatGuid, messageId: "message-guid", emoji: "❤️" },
conversationReadOrigin: "delegated",
})
: sendMessageIMessage(`chat_guid:${chatGuid}`, "synthetic reply", {
config,
client,
replyToId: "message-guid",
conversationReadOrigin: "delegated",
});
await invoke("iMessage;-;+15550001111");
expect(entrypoint === "action" ? nativeSend : request).toHaveBeenCalledOnce();
await expect(invoke("iMessage;+;other")).rejects.toThrow(
"does not belong to the selected conversation",
);
expect(entrypoint === "action" ? nativeSend : request).toHaveBeenCalledOnce();
expect(prepare.mock.calls.filter(([sql]) => /\bFROM\s+"?message"?\b/iu.test(sql))).toEqual(
[],
);
expect(all).not.toHaveBeenCalled();
expect(close).not.toHaveBeenCalled();
},
);
it("joins reader cleanup before reply dispatch and rechecks the caller's live authority", async () => {
// Hydrate the independent reply cache before retaining the Messages reader below.
await resolveIMessageCachedResourceBinding("message-guid", { accountId: "default", chatId: 1 });
const { sendMessageIMessage } = await import("./send.js");
const sqlite = await import("openclaw/plugin-sdk/sqlite-runtime");
const open = sqlite.openSqliteWorkerStore;
const closing = createDeferred<void>();
const release = createDeferred<void>();
vi.spyOn(sqlite, "openSqliteWorkerStore").mockImplementation(async (options) => {
const store = await open(options);
if (!store) {
throw new Error("synthetic Messages database unavailable");
}
return {
execute: (command, executeOptions) => store.execute(command, executeOptions),
close: async () => {
closing.resolve();
await release.promise;
await store.close();
},
};
});
const client = new IMessageRpcClient({ dbPath });
const request = vi.spyOn(client, "request").mockResolvedValue({ guid: "should-not-send" });
let active = true;
let settled = false;
const sending = sendMessageIMessage("chat_id:1", "synthetic reply", {
config: { channels: { imessage: { cliPath, dbPath } } },
client,
replyToId: "message-guid",
conversationReadOrigin: "delegated",
assertDirectAdapterHandoff: () => {
if (!active) {
throw new Error("synthetic caller revoked");
}
},
});
void sending.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
try {
await Promise.race([closing.promise, sending]);
expect(settled).toBe(false);
expect(request).not.toHaveBeenCalled();
active = false;
} finally {
release.resolve();
}
await expect(sending).rejects.toThrow("synthetic caller revoked");
expect(request).not.toHaveBeenCalled();
});
it("only treats canonical handles as authoritative chat identifiers", () => {
expect(
chatContextFromIMessageTarget({ kind: "handle", to: "Jane Appleseed", service: "auto" }),
@ -163,9 +281,9 @@ describe("iMessage provider resource binding", () => {
).toBe("unknown");
});
it("matches part-prefixed message ids only in their database chat", () => {
it("matches part-prefixed message ids only in their database chat", async () => {
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatId: 1 },
cliPath,
dbPath,
@ -173,7 +291,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("match");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatGuid: "imessage;-;+15550001111" },
cliPath,
dbPath,
@ -181,7 +299,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("match");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatGuid: "sms;-;+15550002222" },
cliPath,
dbPath,
@ -189,7 +307,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("match");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatIdentifier: "iMessage;-;üser@example.com" },
cliPath,
dbPath,
@ -197,7 +315,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("match");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatGuid: "iMessage;-;üser@example.com" },
cliPath,
dbPath,
@ -205,7 +323,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("match");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatIdentifier: "iMessage;-;other@example.com" },
cliPath,
dbPath,
@ -213,7 +331,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("mismatch");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatId: 2 },
cliPath,
dbPath,
@ -221,7 +339,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("mismatch");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatGuid: "iMessage;+;+15550001111" },
cliPath,
dbPath,
@ -229,7 +347,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("mismatch");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatGuid: "iMessage;+;Some@example.com" },
cliPath,
dbPath,
@ -237,7 +355,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("match");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatGuid: "iMessage;+;some@example.com" },
cliPath,
dbPath,
@ -245,7 +363,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("mismatch");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatIdentifier: "SMS;-;+15550001111" },
cliPath,
dbPath,
@ -253,7 +371,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("mismatch");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: {
chatId: 1,
chatGuid: "any;-;+15550001111",
@ -265,7 +383,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("match");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: {
chatGuid: "iMessage;+;other",
chatIdentifier: "iMessage;-;+15550001111",
@ -276,7 +394,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("mismatch");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatId: 1, chatGuid: "iMessage;+;other" },
cliPath,
dbPath,
@ -284,7 +402,7 @@ describe("iMessage provider resource binding", () => {
}),
).toBe("mismatch");
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatIdentifier: "unknown;-;+15550001111" },
cliPath,
dbPath,
@ -454,9 +572,39 @@ describe("iMessage provider resource binding", () => {
).resolves.toBeUndefined();
});
it.each(["missing", "malformed"] as const)(
"preserves delegated refusal when the Messages database is %s",
async (databaseState) => {
fs.rmSync(dbPath);
if (databaseState === "malformed") {
fs.writeFileSync(dbPath, "synthetic invalid database");
}
const params = {
accountId: "default",
chatContext: { chatId: 1 },
cliPath,
dbPath,
hasExclusiveLocalDatabase: true,
messageId: "message-guid",
};
await expect(authorizeIMessageResourceReference(params)).rejects.toThrow(
"require a current same-account conversation binding",
);
await expect(
authorizeIMessageResourceReference({
...params,
conversationReadOrigin: "direct-operator",
}),
).resolves.toBeUndefined();
if (databaseState === "missing") {
expect(fs.existsSync(dbPath)).toBe(false);
}
},
);
it("treats provider-resolved handle aliases as unavailable binding evidence", async () => {
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: {},
cliPath,
dbPath,
@ -487,14 +635,14 @@ describe("iMessage provider resource binding", () => {
).rejects.toThrow("require a current same-account conversation binding");
});
it("does not treat a configured database as local for an SSH imsg wrapper", () => {
it("does not treat a configured database as local for an SSH imsg wrapper", async () => {
const wrapperDir = path.join(tempDir, "wrapper");
const wrapperPath = path.join(wrapperDir, "imsg");
fs.mkdirSync(wrapperDir);
fs.writeFileSync(wrapperPath, '#!/bin/sh\nexec ssh qa.example.invalid imsg "$@"\n');
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatId: 1 },
cliPath: wrapperPath,
dbPath,
@ -503,7 +651,7 @@ describe("iMessage provider resource binding", () => {
).toBe("unavailable");
});
it("does not trust a PATH wrapper whose remote command is hidden behind variables", () => {
it("does not trust a PATH wrapper whose remote command is hidden behind variables", async () => {
const wrapperDir = path.join(tempDir, "path-wrapper");
const wrapperPath = path.join(wrapperDir, "imsg");
fs.mkdirSync(wrapperDir);
@ -515,7 +663,7 @@ describe("iMessage provider resource binding", () => {
vi.stubEnv("PATH", wrapperDir);
expect(
checkIMessageResourceBinding({
await checkIMessageResourceBinding({
chatContext: { chatId: 1 },
cliPath: "imsg",
dbPath,

View file

@ -98,7 +98,7 @@ export async function authorizeIMessageResourceReference(
}
const providerBinding = params.hasExclusiveLocalDatabase
? checkIMessageResourceBinding(params)
? await checkIMessageResourceBinding(params)
: "unavailable";
if (providerBinding === "match") {
return;