diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6b7e91..978fc563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ Historical entries retain their original delivery coordinates. ## Unreleased +## 0.18.71 + +Automate selected group conversations in Beeper, iMessage, and WhatsApp. + +- Let clients discover and enroll a group with its exact account and participant + list. Direct conversations remain the default. +- Begin each group enrollment with new messages, excluding earlier history. +- Stop an enrollment and revoke its sends when the account, conversation, or + participant list changes, including changes observed before a restart. +- Recheck the group before sending and require a new enrollment after a change. +- Keep analytics preferences compact and their panel inside the viewport. +- Use one product navigation row across marketing pages and documentation. + ## 0.18.70 Preserve provider text when projecting profile and rental listing data. diff --git a/README.md b/README.md index e64d2465..1c218236 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Install [Bun 1.3.14](https://bun.sh/docs/installation) if needed, then install GhostGet and read a public page: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.70/hraness-ghostget-0.18.70.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.71/hraness-ghostget-0.18.71.tgz ghostget read https://example.com ``` @@ -49,9 +49,9 @@ which always names the latest published release. Upgrading from Wrench? Read the The optional Agent Skill teaches your agent when and how to use GhostGet: ```sh -npx skills add hraness/ghostget#v0.18.70 +npx skills add hraness/ghostget#v0.18.71 # With Bun instead: -bunx skills add hraness/ghostget#v0.18.70 +bunx skills add hraness/ghostget#v0.18.71 ``` Start a new agent session, then ask: “Use GhostGet to read https://example.com @@ -183,7 +183,7 @@ firewall. ## Built-in provider catalog -This v0.18.70 source tree supports executable actions for 21 services: Beeper, +This v0.18.71 source tree supports executable actions for 21 services: Beeper, Bluesky, ClasificadosOnline, Facebook, Facebook Groups, Facebook Marketplace, GitHub, Gmail, Hacker News, Instagram, iMessage, LinkedIn, Reddit, Substack, Threads, TikTok, Twitch, WebMCP Registry, WhatsApp, X, and YouTube. LinkedIn @@ -267,7 +267,7 @@ For that same released coordinate, install GhostGet in an agent or application that owns its own model, planning, tool loop, approvals, and interface: ```sh -bun add https://github.com/hraness/ghostget/releases/download/v0.18.70/hraness-ghostget-0.18.70.tgz +bun add https://github.com/hraness/ghostget/releases/download/v0.18.71/hraness-ghostget-0.18.71.tgz ``` ```ts diff --git a/costs.json b/costs.json index 0efd7156..a0d21456 100644 --- a/costs.json +++ b/costs.json @@ -1,6 +1,15 @@ { "version": 1, "surfaces": { + "local:messaging-group-history-exclusions": { + "kind": "authoritative", + "retention": "tombstone", + "owner": "src/messaging-automation.ts", + "source": "Enrollment-scoped IDs of baseline, historical, or future-dated group messages; no message bodies", + "deletion": "src/messaging-automation.ts", + "expiry": "Retained with the enrollment; never evicted independently or copied to another enrollment", + "budget": { "maxEntries": 50000, "maxBytesPerMessageId": 256, "maxJournalBytes": 201326592 } + }, "local:contract-repair-inbox": { "kind": "derived", "retention": "ttl:P30D", diff --git a/dist/apple-photos-client.js b/dist/apple-photos-client.js index 85a5185f..43577781 100644 --- a/dist/apple-photos-client.js +++ b/dist/apple-photos-client.js @@ -1,7 +1,7 @@ // @bun import { GHOSTGET_VERSION -} from "./index-4bq0p2rh.js"; +} from "./index-k6mpf68q.js"; import { canonicalJson, sha256 diff --git a/dist/beeper-client.js b/dist/beeper-client.js index d27cd3db..6f12e766 100644 --- a/dist/beeper-client.js +++ b/dist/beeper-client.js @@ -4,7 +4,7 @@ import { } from "./index-26yq8q16.js"; import { GHOSTGET_VERSION -} from "./index-4bq0p2rh.js"; +} from "./index-k6mpf68q.js"; import { canonicalJson, canonicalJsonSha256Matches, diff --git a/dist/imessage-direct-install-5fbzef0t.js b/dist/imessage-direct-install-rc7txgp5.js similarity index 99% rename from dist/imessage-direct-install-5fbzef0t.js rename to dist/imessage-direct-install-rc7txgp5.js index 15c388e8..36be3eb1 100644 --- a/dist/imessage-direct-install-5fbzef0t.js +++ b/dist/imessage-direct-install-rc7txgp5.js @@ -2,7 +2,7 @@ import { ensureImsgNativeResources, verifyImsgNativeResources -} from "./index-ptenxvf8.js"; +} from "./index-pngvnj0q.js"; import { ensurePrivateStateDirectory, ghostgetStateHome diff --git a/dist/index-01eeae9e.js b/dist/index-01eeae9e.js deleted file mode 100644 index 4bb325d4..00000000 --- a/dist/index-01eeae9e.js +++ /dev/null @@ -1,5 +0,0 @@ -// @bun -// src/messaging-automation-types.ts -var MESSAGING_AUTOMATION_PROTOCOL = "ghostget.messaging-automation/1"; - -export { MESSAGING_AUTOMATION_PROTOCOL }; diff --git a/dist/index-42adge2e.js b/dist/index-cs9v6bqx.js similarity index 86% rename from dist/index-42adge2e.js rename to dist/index-cs9v6bqx.js index 50cec115..c3d4eef1 100644 --- a/dist/index-42adge2e.js +++ b/dist/index-cs9v6bqx.js @@ -2,6 +2,7 @@ // src/messaging-automation-validation.ts import { types } from "util"; var AUTOMATION_ACTION_KINDS = Object.freeze(["text", "attachment", "reaction", "sticker", "link", "poll", "app-clip", "experience"]); +var AUTOMATION_WHATSAPP_GROUP_JID = /^[1-9][0-9]{4,19}(?:-[1-9][0-9]{0,19})?@g\.us$/u; function automationRecord(value, keys) { if (types.isProxy(value) || typeof value !== "object" || value === null || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new Error("Messaging automation value must be a plain object."); @@ -72,10 +73,31 @@ function parseAutomationCoordinate(value) { return Object.freeze({ provider, accountId: automationText(r2.accountId, 512), conversationId: automationText(r2.conversationId, 2048) }); } const r = automationRecord(value, ["provider", "conversationJid"]); - if (provider !== "whatsapp" || typeof r.conversationJid !== "string" || !/^(?:[1-9][0-9]{4,14}@s\.whatsapp\.net|[1-9][0-9]{4,19}@lid)$/u.test(r.conversationJid)) - throw new Error("Only exact individual WhatsApp conversations are supported."); + if (provider !== "whatsapp" || typeof r.conversationJid !== "string" || !/^(?:[1-9][0-9]{4,14}@s\.whatsapp\.net|[1-9][0-9]{4,19}@lid)$/u.test(r.conversationJid) && !AUTOMATION_WHATSAPP_GROUP_JID.test(r.conversationJid)) + throw new Error("Only exact WhatsApp conversation JIDs are supported."); return Object.freeze({ provider, conversationJid: r.conversationJid }); } +function parseAutomationConversation(value) { + const r = automationRecord(value, ["coordinate", "title", "kind", "participants"]); + const coordinate = parseAutomationCoordinate(r.coordinate); + if (r.kind !== "single" && r.kind !== "group") + throw new Error("Messaging automation requires a verified conversation kind."); + const participants = automationArray(r.participants, r.kind === "group" ? 500 : 2).map((value2) => automationText(value2, 512)); + if (participants.length < 1 || new Set(participants).size !== participants.length) + throw new Error("Messaging automation requires a complete distinct participant roster."); + if (coordinate.provider === "whatsapp" && AUTOMATION_WHATSAPP_GROUP_JID.test(coordinate.conversationJid) !== (r.kind === "group")) + throw new Error("WhatsApp conversation kind and exact JID disagree."); + return Object.freeze({ coordinate, title: r.title === null ? null : automationText(r.title, 512), kind: r.kind, participants: Object.freeze([...participants].sort()) }); +} +function automationGroupOptions(value, keys) { + if (types.isProxy(value)) + throw new Error("Messaging automation value must be a plain object."); + const present = value !== null && typeof value === "object" && Object.hasOwn(value, "includeGroups"); + const r = automationRecord(value, present ? [...keys, "includeGroups"] : keys); + if (present && typeof r.includeGroups !== "boolean") + throw new Error("Messaging group selection must be boolean."); + return { ...r, includeGroups: present ? r.includeGroups : false }; +} function parseAutomationIdentity(value) { const r = automationRecord(value, ["provider", "authId", "accountIdentity", "accountSubject", "implementationIdentity", "sourceGeneration"]); if (r.provider !== "imessage" && r.provider !== "whatsapp" && r.provider !== "beeper") @@ -308,4 +330,4 @@ class OperationDeadline { } } -export { OperationDeadlineError, AUTOMATION_ACTION_KINDS, automationRecord, automationText, automationId, automationDigest, automationInstant, automationInteger, automationArray, automationDate, parseAutomationCoordinate, parseAutomationIdentity, parseAutomationActionKind, parseAutomationAction, parseAutomationMessage }; +export { OperationDeadlineError, AUTOMATION_ACTION_KINDS, AUTOMATION_WHATSAPP_GROUP_JID, automationRecord, automationText, automationId, automationDigest, automationInstant, automationInteger, automationArray, automationDate, parseAutomationCoordinate, parseAutomationConversation, automationGroupOptions, parseAutomationIdentity, parseAutomationActionKind, parseAutomationAction, parseAutomationMessage }; diff --git a/dist/index-4bq0p2rh.js b/dist/index-k6mpf68q.js similarity index 62% rename from dist/index-4bq0p2rh.js rename to dist/index-k6mpf68q.js index 28a76425..73ccfe53 100644 --- a/dist/index-4bq0p2rh.js +++ b/dist/index-k6mpf68q.js @@ -1,5 +1,5 @@ // @bun // src/version.ts -var GHOSTGET_VERSION = "0.18.70"; +var GHOSTGET_VERSION = "0.18.71"; export { GHOSTGET_VERSION }; diff --git a/dist/index-m84wqtz6.js b/dist/index-m84wqtz6.js new file mode 100644 index 00000000..a118500b --- /dev/null +++ b/dist/index-m84wqtz6.js @@ -0,0 +1,17 @@ +// @bun +// src/messaging-automation-types.ts +var MESSAGING_AUTOMATION_PROTOCOL = "ghostget.messaging-automation/1"; +var AUTOMATION_BINDING_CHANGED_REASON = "ghostget.binding-changed.v1"; + +class AutomationGroupBindingChangedError extends Error { + identity; + coordinate; + constructor(identity, coordinate) { + super(AUTOMATION_BINDING_CHANGED_REASON); + this.identity = identity; + this.coordinate = coordinate; + this.name = "AutomationGroupBindingChangedError"; + } +} + +export { MESSAGING_AUTOMATION_PROTOCOL, AUTOMATION_BINDING_CHANGED_REASON, AutomationGroupBindingChangedError }; diff --git a/dist/index-ptenxvf8.js b/dist/index-pngvnj0q.js similarity index 97% rename from dist/index-ptenxvf8.js rename to dist/index-pngvnj0q.js index ff0da878..05eb9330 100644 --- a/dist/index-ptenxvf8.js +++ b/dist/index-pngvnj0q.js @@ -26,10 +26,10 @@ var MESSAGING_NATIVE_ARTIFACTS = Object.freeze({ }, whatsapp: { file: "wacli-darwin-arm64.gz", - sha256: "9b77ffb810d028fde725ca02b1451f1725b5ff5312a46a54468a9a38533d4cea", - bytes: 21963810, - compressedSha256: "1c1650d6c79b74db7f8f335b4746398c802031260a90468312dcaac0374a5166", - compressedBytes: 7682949 + sha256: "85a4c2b6f538103df08425f75984657559169a95161a256c6d096daf9de38f47", + bytes: 21980338, + compressedSha256: "61c9aef8d1a2c38f4831e8546fea0ae9907c365a301b8cde7388e47adbb2d697", + compressedBytes: 7694763 }, phoneMetadata: { file: "phone-number-metadata.json.gz", @@ -217,11 +217,11 @@ async function installBundledMessagingRuntime(provider, environment = process.en await file.close(); } if (provider === "imessage") { - const { installReviewedImsgBinary } = await import("./imessage-direct-install-5fbzef0t.js"); + const { installReviewedImsgBinary } = await import("./imessage-direct-install-rc7txgp5.js"); const result = await installReviewedImsgBinary(path, environment); return { version: result.version, sha256: result.executableSha256, alreadyPresent: result.alreadyPresent }; } - const { installReviewedWhatsAppAutomationBinary } = await import("./whatsapp-automation-runtime-xc7yvjsj.js"); + const { installReviewedWhatsAppAutomationBinary } = await import("./whatsapp-automation-runtime-2v9x3j80.js"); return await installReviewedWhatsAppAutomationBinary(path, environment); } finally { await unlink(path); diff --git a/dist/messaging-automation-api.js b/dist/messaging-automation-api.js index 304e3ad6..fc8fa49e 100644 --- a/dist/messaging-automation-api.js +++ b/dist/messaging-automation-api.js @@ -1,21 +1,25 @@ // @bun import { + AUTOMATION_BINDING_CHANGED_REASON, + AutomationGroupBindingChangedError, MESSAGING_AUTOMATION_PROTOCOL -} from "./index-01eeae9e.js"; +} from "./index-m84wqtz6.js"; import { __require } from "./index-z1w83f81.js"; // src/messaging-automation-api.ts async function createMessagingAutomationHost(providers, environment) { - const { MessagingAutomationHost } = await import("./messaging-automation-2ybn110m.js"); + const { MessagingAutomationHost } = await import("./messaging-automation-tn4gf00y.js"); return new MessagingAutomationHost(providers, environment); } async function installBundledMessagingRuntime(provider, environment) { - return (await import("./messaging-native-install-gmczhw4b.js")).installBundledMessagingRuntime(provider, environment); + return (await import("./messaging-native-install-a1jy4mse.js")).installBundledMessagingRuntime(provider, environment); } export { installBundledMessagingRuntime, createMessagingAutomationHost, - MESSAGING_AUTOMATION_PROTOCOL + MESSAGING_AUTOMATION_PROTOCOL, + AutomationGroupBindingChangedError, + AUTOMATION_BINDING_CHANGED_REASON }; diff --git a/dist/messaging-automation-2ybn110m.js b/dist/messaging-automation-tn4gf00y.js similarity index 72% rename from dist/messaging-automation-2ybn110m.js rename to dist/messaging-automation-tn4gf00y.js index 9d9ccbb3..546f1e89 100644 --- a/dist/messaging-automation-2ybn110m.js +++ b/dist/messaging-automation-tn4gf00y.js @@ -5,6 +5,7 @@ import { automationArray, automationDate, automationDigest, + automationGroupOptions, automationId, automationInstant, automationInteger, @@ -12,10 +13,11 @@ import { automationText, parseAutomationAction, parseAutomationActionKind, + parseAutomationConversation, parseAutomationCoordinate, parseAutomationIdentity, parseAutomationMessage -} from "./index-42adge2e.js"; +} from "./index-cs9v6bqx.js"; import { ensurePrivateStateDirectory, ghostgetStateHome, @@ -30,8 +32,10 @@ import { sha256 } from "./index-xa1qz35x.js"; import { + AUTOMATION_BINDING_CHANGED_REASON, + AutomationGroupBindingChangedError, MESSAGING_AUTOMATION_PROTOCOL -} from "./index-01eeae9e.js"; +} from "./index-m84wqtz6.js"; import"./index-z1w83f81.js"; // src/messaging-automation.ts @@ -61,7 +65,7 @@ var stopped = (signal) => { if (signal?.aborted) throw new Error("Messaging automation operation was cancelled."); }; -var CAPACITY = Object.freeze({ enrollments: 1000, grants: 1e4, plans: 20000, runs: 20000, messages: 50000, events: 50000 }); +var CAPACITY = Object.freeze({ enrollments: 1000, grants: 1e4, plans: 20000, runs: 20000, messages: 50000, events: 50000, group_history_exclusions: 50000 }); function grantData(value) { const r = automationRecord(value, ["enrollmentId", "expectedBindingDigest", "actions", "expiresAt", "maximumActions", "minimumIntervalMs"]); const actions = automationArray(r.actions, AUTOMATION_ACTION_KINDS.length).map(parseAutomationActionKind); @@ -81,14 +85,6 @@ function planData(value) { throw new Error("Messaging plan binding is invalid."); return Object.freeze({ ...binding, id, digest }); } -function conversation(value) { - const r = automationRecord(value, ["coordinate", "title", "kind", "participants"]); - const coordinate = parseAutomationCoordinate(r.coordinate); - const participants = automationArray(r.participants, 2).map((value2) => automationText(value2, 512)); - if (r.kind !== "single" || participants.length < 1 || new Set(participants).size !== participants.length) - throw new Error("Messaging automation requires one verified individual conversation."); - return Object.freeze({ coordinate, title: r.title === null ? null : automationText(r.title, 512), kind: "single", participants: Object.freeze([...participants].sort()) }); -} function checkedPage(value, expected, coordinate) { const r = automationRecord(value, ["identity", "messages", "nextCursor", "caughtUp", "gap"]); const identity = parseAutomationIdentity(r.identity); @@ -156,15 +152,17 @@ class MessagingAutomationHost { try { this.db.exec("PRAGMA busy_timeout=250; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA trusted_schema=OFF; PRAGMA secure_delete=ON; PRAGMA max_page_count=65536;"); const version = this.db.query("PRAGMA user_version").get()?.user_version; - if (version !== 0 && version !== 1 && version !== 2) + if (version !== 0 && version !== 1 && version !== 2 && version !== 3) throw new Error("Unsupported messaging journal schema."); this.db.exec("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS enrollments (id TEXT PRIMARY KEY,data TEXT NOT NULL,cursor TEXT NOT NULL,revision INTEGER NOT NULL,ready INTEGER NOT NULL,reason TEXT); CREATE TABLE IF NOT EXISTS grants (id TEXT PRIMARY KEY,data TEXT NOT NULL,revoked INTEGER NOT NULL,consumed INTEGER NOT NULL,last_dispatch INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS plans (id TEXT PRIMARY KEY,data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY,plan_id TEXT NOT NULL,intent_id TEXT NOT NULL UNIQUE,enrollment_id TEXT NOT NULL,state TEXT NOT NULL,accepted TEXT NOT NULL,total INTEGER NOT NULL,reason TEXT); CREATE INDEX IF NOT EXISTS runs_contact ON runs(enrollment_id,state); CREATE TABLE IF NOT EXISTS messages (enrollment_id TEXT NOT NULL,event_key TEXT NOT NULL,data TEXT NOT NULL,PRIMARY KEY(enrollment_id,event_key)); CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT,enrollment_id TEXT NOT NULL,revision INTEGER NOT NULL,data TEXT NOT NULL);"); - if (version !== 2) + if (version !== 2 && version !== 3) this.db.transaction(() => { this.db.exec("ALTER TABLE enrollments ADD COLUMN baselining INTEGER NOT NULL DEFAULT 1; ALTER TABLE enrollments ADD COLUMN gap INTEGER NOT NULL DEFAULT 0; UPDATE enrollments SET gap=CASE WHEN reason LIKE '%unresolved gap%' THEN 1 ELSE 0 END,ready=0; PRAGMA user_version=2;"); }).immediate(); - else - this.db.exec("PRAGMA user_version=2;"); + if (version !== 3) + this.db.transaction(() => { + this.db.exec("ALTER TABLE enrollments ADD COLUMN history_floor TEXT; CREATE TABLE group_history_exclusions (enrollment_id TEXT NOT NULL,message_id TEXT NOT NULL,PRIMARY KEY(enrollment_id,message_id)); PRAGMA user_version=3;"); + }).immediate(); this.db.exec("CREATE TABLE IF NOT EXISTS grant_intents (id TEXT PRIMARY KEY,request_digest TEXT NOT NULL,grant_id TEXT NOT NULL UNIQUE REFERENCES grants(id));"); this.db.query("INSERT OR IGNORE INTO metadata(key,value) VALUES('cursor-key',?)").run(randomBytes(32).toString("hex")); this.cursorKey = Buffer.from(automationDigest(this.db.query("SELECT value FROM metadata WHERE key='cursor-key'").get()?.value), "hex"); @@ -217,20 +215,81 @@ class MessagingAutomationHost { enrollment(row) { const r = automationRecord(JSON.parse(row.data), ["identity", "conversation", "bindingDigest"]); const identity = parseAutomationIdentity(r.identity); - const selected = conversation(r.conversation); + const selected = parseAutomationConversation(r.conversation); const bindingDigest = automationDigest(r.bindingDigest); if (!canonicalJsonSha256Matches(bindingDigest, authority(identity, selected))) throw new Error("Messaging enrollment binding is invalid."); const ready = automationInteger(row.ready, 0, 1); const baselining = automationInteger(row.baselining, 0, 1); const gap = automationInteger(row.gap, 0, 1); - if (ready && (baselining || gap)) + if (selected.kind === "group") + automationInstant(row.history_floor); + else if (row.history_floor !== null) + throw new Error("Individual conversation has a group history boundary."); + if (ready && (baselining || gap || row.reason === AUTOMATION_BINDING_CHANGED_REASON)) throw new Error("Messaging enrollment readiness is inconsistent."); return Object.freeze({ id: automationId(row.id), identity, conversation: selected, bindingDigest, revision: automationInteger(row.revision, 0, Number.MAX_SAFE_INTEGER), ready: ready === 1, reason: row.reason === null ? null : automationText(row.reason, 1024) }); } - enrollments() { + features() { this.ready(); - return this.db.query("SELECT * FROM enrollments ORDER BY id LIMIT 1001").all().map((row) => this.enrollment(row)); + return Object.freeze({ groupConversations: Object.freeze({ version: 1 }) }); + } + enrollments(input = {}) { + this.ready(); + const { includeGroups } = automationGroupOptions(input, []); + return this.db.query("SELECT * FROM enrollments ORDER BY id LIMIT 1001").all().map((row) => this.enrollment(row)).filter((row) => includeGroups || row.conversation.kind === "single"); + } + invalidateBinding(id) { + this.db.transaction(() => { + this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=?").run(AUTOMATION_BINDING_CHANGED_REASON, id); + this.db.query("UPDATE grants SET revoked=1 WHERE json_extract(data,'$.enrollmentId')=?").run(id); + }).immediate(); + return this.enrollment(this.row(id)); + } + async observeBinding(provider, enrollment, signal) { + if (enrollment.reason === AUTOMATION_BINDING_CHANGED_REASON) + return false; + let route; + try { + route = await this.resolve(provider, enrollment.conversation.coordinate, signal); + } catch (error) { + if (error instanceof AutomationGroupBindingChangedError && this.row(enrollment.id).reason === AUTOMATION_BINDING_CHANGED_REASON) + return false; + throw error; + } + stopped(signal); + if (route.identity.authId === enrollment.identity.authId) + this.observeGroupIdentity(route.identity); + if (!same(parseAutomationIdentity(route.identity), enrollment.identity) || !sameConversation(parseAutomationConversation(route.conversation), enrollment.conversation)) { + this.invalidateBinding(enrollment.id); + return false; + } + return true; + } + observeGroupIdentity(identity) { + for (const prior of this.enrollments({ includeGroups: true })) { + if (prior.conversation.kind === "group" && prior.identity.provider === identity.provider && prior.identity.authId === identity.authId && !same(prior.identity, identity)) + this.invalidateBinding(prior.id); + } + } + async resolve(provider, coordinate, signal) { + try { + const resolved = await provider.resolve(coordinate, signal), identity = parseAutomationIdentity(resolved.identity), selected = parseAutomationConversation(resolved.conversation); + if (identity.provider !== provider.provider || !same(selected.coordinate, coordinate)) + throw new Error("Messaging route resolution changed scope."); + return { identity, conversation: selected }; + } catch (error) { + if (error instanceof AutomationGroupBindingChangedError) { + const identity = parseAutomationIdentity(error.identity), observed = parseAutomationCoordinate(error.coordinate); + if (identity.provider !== provider.provider || observed.provider !== provider.provider || !same(observed, coordinate)) + throw new Error("Group binding invalidation escaped its exact scope."); + for (const prior of this.enrollments({ includeGroups: true })) { + if (prior.conversation.kind === "group" && prior.identity.provider === identity.provider && prior.identity.authId === identity.authId && same(prior.conversation.coordinate, coordinate)) + this.invalidateBinding(prior.id); + } + } + throw error; + } } async status(provider, signal) { stopped(signal); @@ -256,14 +315,14 @@ class MessagingAutomationHost { } async conversations(raw, signal) { this.ready(); - const r = automationRecord(raw, ["provider", "limit"]); + const r = automationGroupOptions(raw, ["provider", "limit"]); const provider = this.provider(automationId(r.provider)), limit = automationInteger(r.limit, 1, 200); let phase = "host-status"; let code = "failed"; try { const status = await this.status(provider, signal); phase = "host-response"; - const rawResult = await provider.conversations({ limit }, signal); + const rawResult = await provider.conversations({ limit, ...r.includeGroups && provider.groupConversations?.version === 1 ? { includeGroups: true } : {} }, signal); stopped(signal); code = "schema-invalid"; const result = automationRecord(rawResult, ["identity", "conversations", "complete"]); @@ -274,7 +333,10 @@ class MessagingAutomationHost { throw new Error("Messaging discovery identity changed."); phase = "host-response"; code = "schema-invalid"; - const rows = automationArray(result.conversations, limit).filter((value) => automationRecord(value, ["coordinate", "title", "kind", "participants"]).kind === "single").map(conversation); + const rows = automationArray(result.conversations, limit).filter((value) => { + const kind = automationRecord(value, ["coordinate", "title", "kind", "participants"]).kind; + return kind === "single" || kind === "group" && r.includeGroups && provider.groupConversations?.version === 1; + }).map(parseAutomationConversation); if (rows.some((row) => row.coordinate.provider !== identity.provider) || new Set(rows.map((row) => canonicalJson(row.coordinate))).size !== rows.length) throw new Error("Messaging discovery contains conflicting conversations."); return Object.freeze({ identity, conversations: Object.freeze(rows), complete: result.complete }); @@ -302,6 +364,10 @@ class MessagingAutomationHost { const enrollment = this.enrollment(this.row(automationId(r.enrollmentId))); if (!enrollment.ready) throw new Error("Messaging enrollment is unavailable."); + if (enrollment.conversation.kind === "group") { + const rows = this.db.query("SELECT data FROM messages WHERE rowid IN (SELECT MAX(rowid) FROM messages WHERE enrollment_id=? GROUP BY json_extract(data,'$.id')) AND (? IS NULL OR json_extract(data,'$.occurredAt')=?) ORDER BY json_extract(data,'$.occurredAt') DESC,rowid DESC LIMIT ?").all(enrollment.id, before, before, after, after, limit); + return Object.freeze({ enrollment, messages: Object.freeze(rows.map((row) => parseAutomationMessage(JSON.parse(row.data))).reverse()) }); + } const provider = this.provider(enrollment.identity.provider); const status = await this.status(provider, signal); if (!same(status.identity, enrollment.identity)) @@ -316,31 +382,72 @@ class MessagingAutomationHost { this.ready(); const request = automationRecord(raw, ["provider", "coordinate"]); const coordinate = parseAutomationCoordinate(request.coordinate); + const historyFloor = new Date(this.now()).toISOString(); if (request.provider !== coordinate.provider) throw new Error("Messaging provider and coordinate disagree."); const provider = this.provider(coordinate.provider); const status = await this.status(provider, signal); - const resolved = await provider.resolve(coordinate, signal); + this.observeGroupIdentity(status.identity); + const resolved = await this.resolve(provider, coordinate, signal); stopped(signal); - if (!same(parseAutomationIdentity(resolved.identity), status.identity)) - throw new Error("Messaging identity changed during enrollment."); - const selected = conversation(resolved.conversation); + const selected = parseAutomationConversation(resolved.conversation); if (!same(selected.coordinate, coordinate)) throw new Error("Messaging route resolution changed target."); - const history = checkedPage(await provider.history({ coordinate, limit: 200 }, signal), status.identity, coordinate); + const resolvedIdentity = parseAutomationIdentity(resolved.identity); + if (resolvedIdentity.provider === status.identity.provider && resolvedIdentity.authId === status.identity.authId) + this.observeGroupIdentity(resolvedIdentity); + if (!same(resolvedIdentity, status.identity)) + throw new Error("Messaging identity changed during enrollment."); + if (selected.kind === "group" && provider.groupConversations?.version !== 1) + throw new Error("Verified group conversations are unavailable for this provider."); + for (const prior of this.enrollments({ includeGroups: true })) { + if (prior.identity.provider === status.identity.provider && prior.identity.authId === status.identity.authId && same(prior.conversation.coordinate, coordinate) && (prior.conversation.kind === "group" || selected.kind === "group") && (!same(prior.identity, status.identity) || !sameConversation(prior.conversation, selected))) + this.invalidateBinding(prior.id); + } + const rawHistory = await provider.history({ coordinate, limit: 200 }, signal); stopped(signal); + const historyIdentity = selected.kind === "group" ? parseAutomationIdentity(rawHistory.identity) : status.identity; + const history = checkedPage(rawHistory, historyIdentity, coordinate); + if (selected.kind === "group") { + if (historyIdentity.provider === status.identity.provider && historyIdentity.authId === status.identity.authId) + this.observeGroupIdentity(historyIdentity); + if (!same(historyIdentity, status.identity)) + throw new Error("Messaging identity changed during enrollment history."); + } + if (selected.kind === "group") { + const after = await this.resolve(provider, coordinate, signal); + stopped(signal); + const afterIdentity = parseAutomationIdentity(after.identity), afterConversation = parseAutomationConversation(after.conversation); + if (!same(afterConversation.coordinate, coordinate)) + throw new Error("Messaging route resolution changed target."); + if (!same(afterIdentity, status.identity) || !sameConversation(afterConversation, selected)) { + for (const prior of this.enrollments({ includeGroups: true })) { + if (prior.conversation.kind === "group" && prior.identity.provider === status.identity.provider && prior.identity.authId === status.identity.authId && same(prior.conversation.coordinate, coordinate) && (!same(prior.identity, afterIdentity) || !sameConversation(prior.conversation, afterConversation))) + this.invalidateBinding(prior.id); + } + throw new Error("Messaging binding changed during group enrollment."); + } + } const binding = { identity: status.identity, conversation: selected }; const bindingDigest = sha256(canonicalJson(authority(status.identity, selected))); const id = `enrollment:${randomUUID()}`; this.ready(); this.db.transaction(() => { this.capacity("enrollments"); - this.capacity("messages", history.messages.length); - if (this.enrollments().some((enrollment) => same(enrollment.identity, status.identity) && same(enrollment.conversation.coordinate, coordinate))) + this.capacity("messages", selected.kind === "group" ? 0 : history.messages.length); + const previous = this.enrollments({ includeGroups: true }).filter((enrollment) => enrollment.identity.provider === status.identity.provider && enrollment.identity.authId === status.identity.authId && same(enrollment.conversation.coordinate, coordinate)); + if (previous.some((enrollment) => enrollment.reason !== AUTOMATION_BINDING_CHANGED_REASON && same(enrollment.identity, status.identity) && sameConversation(enrollment.conversation, selected))) throw new Error("Conversation is already enrolled."); - this.db.query("INSERT INTO enrollments(id,data,cursor,revision,ready,reason,baselining,gap) VALUES(?,?,?,0,?,?,?,?)").run(id, canonicalJson({ ...binding, bindingDigest }), history.nextCursor, !history.gap && history.caughtUp ? 1 : 0, history.gap ? "Provider history has an unresolved gap." : history.caughtUp ? null : "Initial history catchup is incomplete.", history.caughtUp ? 0 : 1, history.gap ? 1 : 0); - for (const message of history.messages) - this.db.query("INSERT OR IGNORE INTO messages VALUES(?,?,?)").run(id, sha256(canonicalJson(message)), canonicalJson(message)); + for (const enrollment of previous) + if (enrollment.conversation.kind === "group" || selected.kind === "group") + this.invalidateBinding(enrollment.id); + this.db.query("INSERT INTO enrollments(id,data,cursor,revision,ready,reason,baselining,gap,history_floor) VALUES(?,?,?,0,?,?,?,?,?)").run(id, canonicalJson({ ...binding, bindingDigest }), history.nextCursor, !history.gap && history.caughtUp ? 1 : 0, history.gap ? "Provider history has an unresolved gap." : history.caughtUp ? null : "Initial history catchup is incomplete.", history.caughtUp ? 0 : 1, history.gap ? 1 : 0, selected.kind === "group" ? historyFloor : null); + if (selected.kind === "group") + for (const message of history.messages) + this.excludeHistory(id, message.id); + if (selected.kind !== "group") + for (const message of history.messages) + this.db.query("INSERT OR IGNORE INTO messages VALUES(?,?,?)").run(id, sha256(canonicalJson(message)), canonicalJson(message)); }).immediate(); this.checkFiles(); return this.enrollment(this.row(id)); @@ -350,7 +457,7 @@ class MessagingAutomationHost { const r = automationRecord(raw, ["enrollmentId", "expectedBindingDigest", "actions", "expiresAt", "maximumActions", "minimumIntervalMs"]); const enrollment = this.enrollment(this.row(automationId(r.enrollmentId))); const expectedBindingDigest = automationDigest(r.expectedBindingDigest); - if (enrollment.bindingDigest !== expectedBindingDigest) + if (enrollment.bindingDigest !== expectedBindingDigest || enrollment.reason === AUTOMATION_BINDING_CHANGED_REASON) throw new Error("Messaging enrollment changed before grant issuance."); const actions = automationArray(r.actions, AUTOMATION_ACTION_KINDS.length).map(parseAutomationActionKind); if (actions.length === 0 || new Set(actions).size !== actions.length) @@ -433,18 +540,35 @@ class MessagingAutomationHost { return { page: { identity, messages: page.messages, nextCursor: page.nextCursor, caughtUp: page.caughtUp, gap: page.gap } }; }); } + excludeHistory(enrollmentId, messageId) { + if (this.db.query("SELECT 1 FROM group_history_exclusions WHERE enrollment_id=? AND message_id=?").get(enrollmentId, messageId) !== null) + return; + this.capacity("group_history_exclusions"); + this.db.query("INSERT INTO group_history_exclusions VALUES(?,?)").run(enrollmentId, messageId); + } ingest(enrollmentId, initial, page) { this.db.transaction(() => { const current = this.row(enrollmentId); - if (current.cursor !== initial.cursor || current.revision !== initial.revision || current.baselining !== initial.baselining || current.gap !== initial.gap || this.activeRun(enrollmentId)) + if (current.reason === AUTOMATION_BINDING_CHANGED_REASON || current.cursor !== initial.cursor || current.revision !== initial.revision || current.baselining !== initial.baselining || current.gap !== initial.gap || this.activeRun(enrollmentId)) throw new Error("Messaging poll lost its concurrent cursor claim."); + const group = this.enrollment(current).conversation.kind === "group"; this.capacity("messages", page.messages.length); if (!initial.baselining) this.capacity("events", page.messages.length); let revision = initial.revision; for (const [index, message] of page.messages.entries()) { + if (group) { + if (initial.baselining === 1 || Date.parse(message.occurredAt) < Date.parse(current.history_floor) || Date.parse(message.occurredAt) > this.now()) { + this.excludeHistory(enrollmentId, message.id); + continue; + } + if (this.db.query("SELECT 1 FROM group_history_exclusions WHERE enrollment_id=? AND message_id=?").get(enrollmentId, message.id) !== null) + continue; + } const data = canonicalJson(message); const previous = this.db.query("SELECT data FROM messages WHERE enrollment_id=? AND json_extract(data,'$.id')=? ORDER BY rowid DESC LIMIT 1").get(enrollmentId, message.id); + if (group && message.kind !== "message" && previous === null && (message.kind !== "reaction" || message.relatedMessageId === null || this.db.query("SELECT 1 FROM messages WHERE enrollment_id=? AND json_extract(data,'$.id')=? LIMIT 1").get(enrollmentId, message.relatedMessageId) === null)) + continue; if (previous?.data === data) continue; this.db.query("INSERT INTO messages VALUES(?,?,?)").run(enrollmentId, sha256(canonicalJson({ cursor: page.nextCursor, index, message })), data); @@ -483,6 +607,10 @@ class MessagingAutomationHost { } try { const enrollment = this.enrollment(initial); + if (enrollment.reason === AUTOMATION_BINDING_CHANGED_REASON) { + results.set(id, { enrollmentId: id, enrollment, error: null }); + continue; + } if (this.activeRun(id)) { results.set(id, { enrollmentId: id, enrollment, error: null }); continue; @@ -499,6 +627,7 @@ class MessagingAutomationHost { let status; try { status = await this.status(group.provider, signal); + this.observeGroupIdentity(status.identity); observed?.set(group.provider, status); } catch (error) { const message = error instanceof Error ? error.message : "Messaging provider status is unavailable."; @@ -510,11 +639,26 @@ class MessagingAutomationHost { for (const item of group.items) { const id = item.enrollment.id; if (!same(status.identity, item.enrollment.identity)) { - this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=?").run("Provider identity changed; enroll the conversation again.", id); + if (item.enrollment.conversation.kind === "group") + this.invalidateBinding(id); + else + this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=? AND (reason IS NULL OR reason<>?)").run("Provider identity changed; enroll the conversation again.", id, AUTOMATION_BINDING_CHANGED_REASON); results.set(id, { enrollmentId: id, enrollment: this.enrollment(this.row(id)), error: "Messaging provider identity changed." }); } else if (!status.connected || !status.events.available) { - this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=?").run(status.events.reason ?? "Provider events are unavailable.", id); + this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=? AND (reason IS NULL OR reason<>?)").run(status.events.reason ?? "Provider events are unavailable.", id, AUTOMATION_BINDING_CHANGED_REASON); results.set(id, { enrollmentId: id, enrollment: this.enrollment(this.row(id)), error: null }); + } else if (item.enrollment.conversation.kind === "group") { + try { + if (group.provider.groupConversations?.version !== 1) + throw new Error("Verified group events are unavailable for this provider."); + if (await this.observeBinding(group.provider, item.enrollment, signal)) + live.push(item); + else + results.set(id, { enrollmentId: id, enrollment: this.enrollment(this.row(id)), error: null }); + } catch (error) { + this.db.query("UPDATE enrollments SET ready=0 WHERE id=?").run(id); + results.set(id, { enrollmentId: id, enrollment: this.tryEnrollment(id), error: error instanceof Error ? error.message : "Group binding could not be verified." }); + } } else live.push(item); } @@ -540,9 +684,23 @@ class MessagingAutomationHost { continue; } try { - const page = checkedPage(entry.page, item.enrollment.identity, item.enrollment.conversation.coordinate); + const pageRecord = automationRecord(entry.page, ["identity", "messages", "nextCursor", "caughtUp", "gap"]); + const pageIdentity = item.enrollment.conversation.kind === "group" ? parseAutomationIdentity(pageRecord.identity) : item.enrollment.identity; + const page = checkedPage(entry.page, pageIdentity, item.enrollment.conversation.coordinate); + if (item.enrollment.conversation.kind === "group") { + if (pageIdentity.provider === item.enrollment.identity.provider && pageIdentity.authId === item.enrollment.identity.authId) + this.observeGroupIdentity(pageIdentity); + if (!same(pageIdentity, item.enrollment.identity)) + throw new Error("Messaging provider identity changed while polling."); + } + if (item.enrollment.conversation.kind === "group" && !await this.observeBinding(group.provider, item.enrollment, signal)) { + results.set(id, { enrollmentId: id, enrollment: this.enrollment(this.row(id)), error: null }); + continue; + } results.set(id, { enrollmentId: id, enrollment: this.ingest(id, item.initial, page), error: null }); } catch (error) { + if (item.enrollment.conversation.kind === "group") + this.db.query("UPDATE enrollments SET ready=0 WHERE id=?").run(id); results.set(id, { enrollmentId: id, enrollment: this.tryEnrollment(id), error: error instanceof Error ? error.message : "Messaging poll failed." }); } } @@ -688,10 +846,15 @@ class MessagingAutomationHost { const enrollment = polled.enrollment; const provider = this.provider(enrollment.identity.provider); const status = observed.get(provider) ?? await this.status(provider, signal); - const route = await provider.resolve(enrollment.conversation.coordinate, signal); + const route = await this.resolve(provider, enrollment.conversation.coordinate, signal); stopped(signal); - if (!same(parseAutomationIdentity(route.identity), enrollment.identity) || !sameConversation(conversation(route.conversation), enrollment.conversation) || !same(status.identity, enrollment.identity) || !status.connected) + if (enrollment.conversation.kind === "group" && route.identity.authId === enrollment.identity.authId) + this.observeGroupIdentity(route.identity); + if (!same(parseAutomationIdentity(route.identity), enrollment.identity) || !sameConversation(parseAutomationConversation(route.conversation), enrollment.conversation) || !same(status.identity, enrollment.identity) || !status.connected) { + if (enrollment.conversation.kind === "group") + this.invalidateBinding(enrollment.id); throw new Error("Messaging route or identity changed before dispatch."); + } for (const action of plan.actions) if (!status.actions[parseAutomationAction(action).kind].available) throw new Error("Messaging action is unavailable for this provider."); @@ -735,12 +898,19 @@ class MessagingAutomationHost { const grantRow = this.db.query("SELECT * FROM grants WHERE id=?").get(grantId); const grant = grantRow === null ? null : grantData(JSON.parse(grantRow.data)); const current = this.enrollment(this.row(plan.enrollmentId)); - if (signal.aborted || grantRow?.revoked !== 0 || grant === null || Date.parse(grant.expiresAt) <= this.now() || Date.parse(plan.expiresAt) <= this.now() || grant.enrollmentId !== current.id || grant.expectedBindingDigest !== current.bindingDigest || current.bindingDigest !== plan.bindingDigest || !grant.actions.includes(action.kind)) { + if (signal.aborted || grantRow?.revoked !== 0 || grant === null || Date.parse(grant.expiresAt) <= this.now() || Date.parse(plan.expiresAt) <= this.now() || !current.ready || current.reason === AUTOMATION_BINDING_CHANGED_REASON || grant.enrollmentId !== current.id || grant.expectedBindingDigest !== current.bindingDigest || current.bindingDigest !== plan.bindingDigest || !grant.actions.includes(action.kind)) { state = accepted.length ? "partial" : "failed"; reason = "Dispatch permission expired or changed before the next action."; break; } - const result = checkedResult(await provider.send({ identity: enrollment.identity, coordinate: enrollment.conversation.coordinate, action, intentId: `${plan.intentId}:${index}` }, signal)); + if (enrollment.conversation.kind === "group" && !await this.observeBinding(provider, enrollment, signal)) { + state = accepted.length ? "partial" : "failed"; + reason = AUTOMATION_BINDING_CHANGED_REASON; + break; + } + const result = checkedResult(await provider.send({ identity: enrollment.identity, coordinate: enrollment.conversation.coordinate, ...enrollment.conversation.kind === "group" ? { conversation: enrollment.conversation } : {}, action, intentId: `${plan.intentId}:${index}` }, signal)); + if (result.state === "not-started" && result.reason === AUTOMATION_BINDING_CHANGED_REASON) + this.invalidateBinding(enrollment.id); if (result.state !== "accepted") { state = result.state === "indeterminate" ? "indeterminate" : accepted.length ? "partial" : "failed"; reason = result.reason; @@ -780,5 +950,7 @@ class MessagingAutomationHost { } export { MessagingAutomationHost, - MESSAGING_AUTOMATION_PROTOCOL + MESSAGING_AUTOMATION_PROTOCOL, + AutomationGroupBindingChangedError, + AUTOMATION_BINDING_CHANGED_REASON }; diff --git a/dist/messaging-native-install-gmczhw4b.js b/dist/messaging-native-install-a1jy4mse.js similarity index 94% rename from dist/messaging-native-install-gmczhw4b.js rename to dist/messaging-native-install-a1jy4mse.js index a2bc423e..7da24257 100644 --- a/dist/messaging-native-install-gmczhw4b.js +++ b/dist/messaging-native-install-a1jy4mse.js @@ -5,7 +5,7 @@ import { materializeImsgNativeResources, readBundledMessagingAsset, verifyImsgNativeResources -} from "./index-ptenxvf8.js"; +} from "./index-pngvnj0q.js"; import"./index-s52dfzqt.js"; import"./index-6fv50zce.js"; import"./index-5m1wfgkw.js"; diff --git a/dist/whatsapp-automation-runtime-xc7yvjsj.js b/dist/whatsapp-automation-runtime-2v9x3j80.js similarity index 94% rename from dist/whatsapp-automation-runtime-xc7yvjsj.js rename to dist/whatsapp-automation-runtime-2v9x3j80.js index 27cc311f..60a97a49 100644 --- a/dist/whatsapp-automation-runtime-xc7yvjsj.js +++ b/dist/whatsapp-automation-runtime-2v9x3j80.js @@ -1,9 +1,11 @@ // @bun import { + AUTOMATION_WHATSAPP_GROUP_JID, + automationArray, automationDigest, automationRecord, automationText -} from "./index-42adge2e.js"; +} from "./index-cs9v6bqx.js"; import { assertSafeStatePath, captureProcessOwnerIdentity, @@ -20,9 +22,11 @@ import"./index-26yq8q16.js"; import { canonicalJson } from "./index-xa1qz35x.js"; +import"./index-m84wqtz6.js"; import"./index-z1w83f81.js"; // src/providers/whatsapp-automation-runtime.ts +import { types } from "util"; import { Database } from "bun:sqlite"; import { createHash, randomBytes } from "crypto"; import { constants } from "fs"; @@ -449,8 +453,8 @@ class CanonicalSessionFrameDecoder { // src/providers/whatsapp-automation-runtime.ts var WHATSAPP_AUTOMATION_PROTOCOL = "ghostget.whatsapp-private/1"; -var WHATSAPP_AUTOMATION_VERSION = "0.15.0+ghostget-private.1"; -var WHATSAPP_AUTOMATION_BINARY_SHA256 = "9b77ffb810d028fde725ca02b1451f1725b5ff5312a46a54468a9a38533d4cea"; +var WHATSAPP_AUTOMATION_VERSION = "0.15.0+ghostget-private.2"; +var WHATSAPP_AUTOMATION_BINARY_SHA256 = "85a4c2b6f538103df08425f75984657559169a95161a256c6d096daf9de38f47"; var directJid = /^(?:[1-9][0-9]{4,14}@s\.whatsapp\.net|[1-9][0-9]{4,19}@lid)$/u; var sha = (value) => createHash("sha256").update(canonicalJson(value)).digest("hex"); function linked(auth) { @@ -549,16 +553,24 @@ async function installReviewedWhatsAppAutomationBinary(source, environment = pro return { version: WHATSAPP_AUTOMATION_VERSION, sha256: WHATSAPP_AUTOMATION_BINARY_SHA256 }; } function parseWhatsAppPrivateResponse(value) { - const row = automationRecord(value, ["protocol", "requestId", "generation", "account", "state", "to", "messageId", "connected"]); + if (!value || typeof value !== "object" || types.isProxy(value)) + throw new Error("Invalid WhatsApp response"); + const roster = Object.hasOwn(value, "participants"), changed = Object.hasOwn(value, "bindingChanged"); + const row = automationRecord(value, ["protocol", "requestId", "generation", "account", "state", "to", "messageId", "connected", ...roster ? ["participants"] : [], ...changed ? ["bindingChanged"] : []]); if (row.protocol !== WHATSAPP_AUTOMATION_PROTOCOL || !["ready", "accepted", "not-started", "indeterminate"].includes(String(row.state)) || typeof row.connected !== "boolean") throw new Error("Unsupported WhatsApp transport response"); for (const field of ["requestId", "to", "messageId"]) if (typeof row[field] !== "string" || Buffer.byteLength(row[field]) > 256 || /[\u0000-\u001f\u007f]/u.test(row[field])) throw new Error("Invalid WhatsApp receipt field"); const generation = automationDigest(row.generation), account = automationText(row.account, 128); - if (!directJid.test(account) || row.requestId !== "" && !/^[a-f0-9]{64}$/u.test(row.requestId) || row.to !== "" && !directJid.test(row.to) || row.state === "accepted" && (row.messageId === "" || row.requestId === "" || row.to === "")) + if (!directJid.test(account) || row.requestId !== "" && !/^[a-f0-9]{64}$/u.test(row.requestId) || row.to !== "" && !directJid.test(row.to) && !AUTOMATION_WHATSAPP_GROUP_JID.test(row.to) || row.state === "accepted" && (row.messageId === "" || row.requestId === "" || row.to === "")) throw new Error("Invalid WhatsApp receipt identity"); - return { protocol: WHATSAPP_AUTOMATION_PROTOCOL, requestId: row.requestId, generation, account, state: row.state, to: row.to, messageId: row.messageId, connected: row.connected }; + const participants = roster ? automationArray(row.participants, 500).map((value2) => automationText(value2, 128)) : undefined; + if (participants && (row.state !== "ready" || row.requestId === "" || row.messageId !== "" || !AUTOMATION_WHATSAPP_GROUP_JID.test(row.to) || participants.length === 0 || participants.some((participant, index) => !directJid.test(participant) || index > 0 && participants[index - 1] >= participant))) + throw new Error("Incomplete or noncanonical WhatsApp group roster"); + if (changed && (row.bindingChanged !== true || row.state !== "not-started" || row.requestId === "" || row.messageId !== "" || !AUTOMATION_WHATSAPP_GROUP_JID.test(row.to) || roster)) + throw new Error("Invalid WhatsApp membership-change proof"); + return { protocol: WHATSAPP_AUTOMATION_PROTOCOL, requestId: row.requestId, generation, account, state: row.state, to: row.to, messageId: row.messageId, connected: row.connected, ...participants ? { participants } : {}, ...changed ? { bindingChanged: true } : {} }; } var blankStatus = () => ({ protocol: WHATSAPP_AUTOMATION_PROTOCOL, kind: "status", requestId: "", generation: "", account: "", to: "", message: "", file: "", filename: "", mime: "", id: "", reaction: "", question: "", options: [], selectable: 0 }); async function socketRequest(path, identity, request, signal, beforeWrite) { @@ -615,7 +627,7 @@ async function socketRequest(path, identity, request, signal, beforeWrite) { finish(new Error("WhatsApp response was not bytes")); return; } - if (bytes.length + chunk.length > 4096) { + if (bytes.length + chunk.length > (request.kind === "group-info" ? 32768 : 4096)) { finish(new Error("WhatsApp response exceeds its byte bound")); return; } @@ -627,7 +639,7 @@ async function socketRequest(path, identity, request, signal, beforeWrite) { if (newline !== bytes.length - 1) throw new Error("Trailing WhatsApp response data"); const response = parseWhatsAppPrivateResponse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, newline)))); - if (request.kind === "status" ? response.state !== "ready" || response.requestId !== "" || response.to !== "" || response.messageId !== "" : response.requestId !== request.requestId || response.to !== request.to || response.account !== request.account || response.generation !== request.generation || response.state === "ready") + if (request.kind === "status" ? response.state !== "ready" || response.requestId !== "" || response.to !== "" || response.messageId !== "" || response.participants !== undefined || response.bindingChanged !== undefined : response.requestId !== request.requestId || response.to !== request.to || response.account !== request.account || response.generation !== request.generation || (request.kind === "group-info" ? response.state !== "not-started" && (response.state !== "ready" || response.participants === undefined) || response.bindingChanged !== undefined : response.state === "ready" || response.participants !== undefined)) throw new Error("WhatsApp response did not bind its request"); finish(undefined, response); } catch { diff --git a/docs/assurance.md b/docs/assurance.md index c1ee1e10..f0fa427b 100644 --- a/docs/assurance.md +++ b/docs/assurance.md @@ -8,12 +8,12 @@ A claim is *evidenced* when its layer runs in CI, *planned* when a plan phase sc ## Summary -The register holds 247 claims: 228 evidenced, 0 planned, and 19 not verified. It maps 93 guidelines from 5 guides; 70 list claims and 23 are exempt. +The register holds 248 claims: 229 evidenced, 0 planned, and 19 not verified. It maps 93 guidelines from 5 guides; 70 list claims and 23 are exempt. | Layer | Evidenced | Planned | Not verified | | --- | ---: | ---: | ---: | | example test | 148 | 0 | 0 | -| property test | 23 | 0 | 0 | +| property test | 24 | 0 | 0 | | stateful model | 24 | 0 | 0 | | Quint model with production trace replay | 21 | 0 | 0 | | Lean proof with differential test | 8 | 0 | 0 | @@ -112,7 +112,7 @@ Each claim holds only while its listed assumptions hold. | --- | --- | ---: | | `bun-runtime` | Bun and JavaScriptCore execute the sources and the test runner as specified. | 8 | | `filesystem-atomic-rename` | Same-volume rename and link are atomic. | 33 | -| `filesystem-durability` | Data and directory entries that were fsynced persist across a crash or power loss. | 31 | +| `filesystem-durability` | Data and directory entries that were fsynced persist across a crash or power loss. | 32 | | `same-user-trusted` | Processes running as the same operating-system user are trusted; file modes and owner-only sockets separate users. | 34 | | `process-liveness` | Process ID, process start time, and boot identity readings are truthful. | 10 | | `monotonic-clock` | The injected monotonic clock never runs backward. | 6 | @@ -121,7 +121,7 @@ Each claim holds only while its listed assumptions hold. | `sha256` | SHA-256 is collision resistant. | 3 | | `encryption` | The authenticated encryption primitives and the operating-system key storage are sound. | 4 | | `media-tools` | yt-dlp, ffmpeg, and whisper.cpp report metadata faithfully and honor the arguments they are given. | 11 | -| `provider-behaviour` | Third-party providers behave as their observed contracts describe. | 30 | +| `provider-behaviour` | Third-party providers behave as their observed contracts describe. | 31 | | `plugin-trusted` | Source plugins are trusted in-process code; portable execution contains ordinary failures, not hostile code. | 13 | | `onepassword` | The 1Password SDK and account return the requested secret faithfully. | 2 | | `github-api` | GitHub's REST, GraphQL, and Actions APIs report repository, run, and Release state truthfully. | 73 | @@ -1030,7 +1030,7 @@ The media provider identity and source asset key, the authorization-context dige - The vectors are a fixed corpus; they pin the byte layout, not a property of all inputs. - Injectivity of the length framing is `hash-framing-injective`, which Phase 5 addresses. -### `messaging` (10 claims) +### `messaging` (11 claims) #### `messaging-composite-ordered-prefix` @@ -1130,6 +1130,20 @@ Explicit messaging output paths are distinct from each other and outside the Gho - Assumptions: `filesystem-durability`, `provider-behaviour` - Not verified: Only the enumerated example cases are checked. +#### `messaging-automation-group-epochs` + +Observed group binding drift permanently disables the old enrollment and its grants; a replacement enrollment has a new ID and cannot import prior-roster baseline bodies, historical backfill or unknown message mutations. + +- Evidenced by property test. +- Source: `docs/messaging-automation.md`: “Observed group binding drift permanently invalidates that enrollment.” +- Evidence: `src/messaging-automation-groups.test.ts`, `verification/mutants.json`, `src/messaging-automation.test.ts`, `src/messaging-automation-server.test.ts`, `src/providers/imessage-automation.test.ts`, `src/providers/beeper-automation.test.ts`, `src/providers/whatsapp-automation.test.ts`, `src/providers/whatsapp-automation-runtime.test.ts` +- Property tests: `src/messaging-automation-groups.test.ts`: “bounded group lifecycle schedules never revive an observed old binding” +- Assumptions: `filesystem-durability`, `provider-behaviour` +- Not verified: + - The model samples 30 schedules of up to 14 actions plus retained workload seeds over the production SQLite host with injected providers. It covers polling, sending, roster changes (including an authoritative empty roster) and restoration, restart, title changes and lookup faults; it does not exhaust arbitrary concurrent schedules or prove provider liveness. Two seeded defects, transient binding invalidation and group reads from the unrestricted provider archive, are killed by their named regression tests. + - History isolation trusts the reviewed provider's original creation timestamp and cursor semantics. It does not cover a compromised provider or host, or a roster change restored between every authoritative observation. + - The provider effect-boundary checks use synthetic adapters; no real account, pairing, message send or delivery qualification is implied. + #### `messaging-automation-grant-scoped` The owner messaging host requires explicit allow grants for messaging.automation.* operations; old messaging.send allow, ask, deny, or unmanaged policy provide no unattended authority, and changed manifests or closures invalidate grants. diff --git a/docs/contracts.md b/docs/contracts.md index 009be10c..20a321d9 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -128,7 +128,7 @@ matches some IDs lists the installed ones and exits 0. { "ok": true, "contract": "ghostget.contract-catalog.v1", - "ghostget": { "version": "0.18.70" }, + "ghostget": { "version": "0.18.71" }, "generatedAt": "2026-09-21T20:00:00.000Z", "vocabulary": { "risks": ["R1", "R2", "R3", "R4"], @@ -186,7 +186,7 @@ does not parse is an error (exit 3) and no catalog is read. { "ok": false, "contract": "ghostget.contract-check.v1", - "ghostget": { "version": "0.18.70" }, + "ghostget": { "version": "0.18.71" }, "plan": { "collectionKey": "hraness-social-profile-statistics", "reads": 15 }, "reads": [ { "index": 0, "accountKey": "x-hraness", "adapter": "x-web", "operation": "profiles.read", diff --git a/docs/messaging-automation.md b/docs/messaging-automation.md index 385e2c5c..1556dfdf 100644 --- a/docs/messaging-automation.md +++ b/docs/messaging-automation.md @@ -71,17 +71,19 @@ pages and internal send-validation history keep attachment metadata disabled. | Method | Exact params | Result | | --- | --- | --- | | initialize | providers: 1–3 `{provider,authId}`, distinct networks | `{initialized:true}` | +| features | empty object | `{groupConversations:{version:1}}` | | status | provider | Current identity and permissions-intersected capabilities | | start | provider | Current status; WhatsApp sync starts explicitly | -| conversations | provider, limit (1–200) | Current individual conversations | +| conversations | provider, limit (1–200), optional includeGroups (boolean) | Current individual conversations; verified groups only when explicitly requested | | enroll | provider, coordinate | Exact identity/participants enrollment and historical baseline | -| enrollments | empty object | Persisted enrollments | +| enrollments | optional includeGroups (boolean) | Persisted individual enrollments; groups only when explicitly requested | | grant | intentId, enrollmentId, expectedBindingDigest, actions, expiresAt, maximumActions, minimumIntervalMs | Bounded owner grant | | grant.by-intent | intentId | `{grant}`; null only when no matching durable grant exists | | grant.get | grantId | Current grant expiry, revocation and consumed quota | | revoke | grantId | `{revoked:true}` | | poll | enrollmentId | Refresh and admit new events; gaps remain explicit | | history | enrollmentId, limit (1–200) | Stored admitted history and enrollment; no additional provider read | +| history.window | enrollmentId, limit (1–200), before and after (nullable canonical UTC) | Individual provider history or group enrollment-local history within the window | | events | enrollmentIds, cursor (nullable), limit (1–500) | Durable scoped event page | | asset | bytesBase64, sha256 | assetId, byte count, digest, expiry | | prepare | enrollmentId, expectedRevision, intentId, actions | Exact two-minute plan | @@ -119,6 +121,50 @@ acceptance is reported separately from delivery or read status. Available rich actions depend on the current pinned helper and explicit permissions; app-clips and arbitrary mini-app experiences remain unavailable. +Group conversations use the same protocol version and explicit grants. Clients +request `features` before sending `includeGroups:true`; omitting the flag keeps +both discovery and enrollment listings limited to individual conversations. +An older host may report an unavailable operation for `features`. This means +group support could not be established; clients can still request individual +conversations without negotiation. A malformed successful feature response is +an error, never permission to try a group operation. + +A group binds its exact provider, account, coordinate, kind and complete sorted +roster of 1–500 distinct participant identities. WhatsApp groups use their exact +`@g.us` JID, including a legacy hyphenated JID when supplied; no recipient alias +is substituted. Beeper requires `hasMore:false` and an exact participant count. +A group has no self-chat privileges. Titles can change without changing authority. + +Observed group binding drift permanently invalidates that enrollment. +Its original digest remains unchanged, `ready` becomes false, and `reason` is +`ghostget.binding-changed.v1`. Existing grants are revoked, prepared actions +cannot run, and restoring the old roster does not restore authority. Enroll the +group again to obtain a new ID and grant; each action also rechecks the complete +roster immediately before sending. + +The pinned iMessage helper and Beeper's complete participant response can also +prove an explicit empty group roster. Their trusted adapters report +`AutomationGroupBindingChangedError` with the exact +account identity and coordinate; mutation paths validate that scope before +permanently disabling old group enrollments. This does not admit empty groups. +An omitted roster, partial response or failed lookup has no such authority. + +Group history starts at enrollment. The host records a local time floor and +silently establishes the provider cursor without admitting baseline bodies. +Messages retain their original creation time through edits and backfill; a +group message older than the floor or later than the host's current receive +time is excluded. IDs encountered in the baseline or excluded history stay +excluded across restart. Edits, deletions and reactions cannot import the body +of an unknown older message. Group `history.window` reads only this enrollment's +admitted history, never the provider archive. A replacement enrollment receives +no history or memory from the former roster. + +These checks rely on the reviewed provider preserving its original creation +timestamp, cursor and exact account semantics. They do not establish delivery, +detect an unobserved roster change that is restored between provider reads, or +protect against a compromised host or provider. Read-only listings and history +queries never update the journal; enrollment, polling and sending record drift. + Native iMessage links use the pinned `.3` helper and require the compatible bridge to report `send.rich` available. The host sets `fetch_metadata: false` to build a native URL/host-title card without helper metadata or image fetching. diff --git a/docs/publishing.md b/docs/publishing.md index 32ae90ac..da825aea 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -28,7 +28,9 @@ covers static checks, package and isolated Bun-consumer checks, every whole-file source shard, serialized omni tests, standalone checks, selected macOS checks, and the formal-verification checks. Explicit focused local/native and coupled reproductions still apply under -`CONTRIBUTING.md`; the independently required npm-mirror full check is unchanged. +`CONTRIBUTING.md`. The npm mirror consumes the attested canonical archive after +exact-source CI admission and independently checks its identity, digest, and +provenance. It does not rebuild or rerun source checks. `scripts/release-source-ci.ts` reads GitHub's current run attempt directly. It requires the exact repository, active workflow ID/path, main-push source and tree, @@ -243,12 +245,12 @@ delivery proceeds through a new source-qualified version. ## Install the canonical release -These commands require the matching published immutable v0.18.70 release. +These commands require the matching published immutable v0.18.71 release. For the CLI: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.70/hraness-ghostget-0.18.70.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.71/hraness-ghostget-0.18.71.tgz ghostget --version ghostget doctor --json ``` diff --git a/kb/launch/social-kit.md b/kb/launch/social-kit.md index 1a798652..f083f0e9 100644 --- a/kb/launch/social-kit.md +++ b/kb/launch/social-kit.md @@ -57,7 +57,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.70. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.71. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -115,7 +115,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.70. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.71. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -173,7 +173,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.70. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.71. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -197,7 +197,7 @@ GhostGet is for Claude Code, Codex, Cursor, and other agents that run commands o The plan is for GhostGet to stay small. Your agent does the thinking, and GhostGet runs only actions someone has reviewed. Each new service arrives as reviewed actions with their own previews. -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.70. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.71. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -224,8 +224,8 @@ Topics: Developer Tools, Artificial Intelligence, Open Source - Sometimes a post goes through but the answer gets lost on the way back. GhostGet writes down every send before it leaves and never sends it again on its own until it knows what happened. - Anything beyond a read starts as a preview that shows the service, the account, and exactly what will be sent. Your agent can prepare it. Nothing is sent until someone confirms that exact preview. - GhostGet is for Claude Code, Codex, Cursor, and other agents that run commands on your Mac or Linux machine and need to read the web and use the accounts you already have. -- GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.70. -- Latest release: v0.18.70. https://ghostget.com/blog/introducing-ghostget/ +- GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.71. +- Latest release: v0.18.71. https://ghostget.com/blog/introducing-ghostget/ ## Beats @@ -248,7 +248,7 @@ Topics: Developer Tools, Artificial Intelligence, Open Source - measuredOn: September 22, 2026. website/source/index.html, Measured table summary - serviceCount: 21. website/provider-presentation.ts createProviderDirectory().providerCount - claimsNotVerified: 19. verification/claims.json, status not-verified -- claimsTotal: 247. verification/claims.json, every claim -- claimsEvidenced: 228. verification/claims.json, status evidenced +- claimsTotal: 248. verification/claims.json, every claim +- claimsEvidenced: 229. verification/claims.json, status evidenced - claimsConfigReadback: 15. verification/claims.json, layer configuration-readback -- status: Latest release: v0.18.70. package.json version +- status: Latest release: v0.18.71. package.json version diff --git a/package.json b/package.json index d4ef3319..97ac8958 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/ghostget", - "version": "0.18.70", + "version": "0.18.71", "description": "GhostGet gives your AI agent named web actions: read a page, archive one media item, or use a connected account, without credentials or a browser to steer.", "license": "MIT", "type": "module", diff --git a/scripts/messaging-runtime-provenance.test.ts b/scripts/messaging-runtime-provenance.test.ts index 2d504dd1..73c6f390 100644 --- a/scripts/messaging-runtime-provenance.test.ts +++ b/scripts/messaging-runtime-provenance.test.ts @@ -6,9 +6,19 @@ import { tmpdir } from "node:os"; import { gunzipSync } from "node:zlib"; import { MESSAGING_NATIVE_ARTIFACTS } from "../src/providers/messaging-native-artifacts"; -import { verify } from "./messaging-runtime-provenance"; +import { assertPatchStackIdentity, verify } from "./messaging-runtime-provenance"; describe("messaging runtime provenance", () => { + test("pinned source trees tolerate reconstructed commit metadata but reject source drift", () => { + const stack = { tipCommit: "1".repeat(40), sourceTree: "2".repeat(40), patches: [] }; + expect(() => assertPatchStackIdentity("wacli", stack, "3".repeat(40), stack.sourceTree)).not.toThrow(); + expect(() => assertPatchStackIdentity("wacli", stack, stack.tipCommit, "4".repeat(40))).toThrow("source tree"); + expect(() => assertPatchStackIdentity("wacli", { ...stack, sourceTree: "" }, stack.tipCommit, "")).toThrow("source tree"); + const legacy = { tipCommit: stack.tipCommit, patches: [] }; + expect(() => assertPatchStackIdentity("imsg", legacy, stack.tipCommit, "4".repeat(40))).not.toThrow(); + expect(() => assertPatchStackIdentity("imsg", legacy, "3".repeat(40), "4".repeat(40))).toThrow("patch stack landed"); + }); + test("the committed compressed and executable pins verify from the checked-in bytes", async () => { await verify(); }); diff --git a/scripts/messaging-runtime-provenance.ts b/scripts/messaging-runtime-provenance.ts index dff14323..f7feab0c 100644 --- a/scripts/messaging-runtime-provenance.ts +++ b/scripts/messaging-runtime-provenance.ts @@ -8,7 +8,7 @@ // // bun run ./scripts/messaging-runtime-provenance.ts rebuild wacli|imsg // Clone the pinned upstream commit, apply the reviewed patches (each at -// its pinned SHA-256, asserting the recorded tip commit), build with the +// its pinned SHA-256, asserting the recorded source tree or legacy tip), build with the // pinned toolchain and command, and compare or qualify the produced // binary per the provenance record's own evidence level. macOS-only. import { createHash } from "node:crypto"; @@ -29,6 +29,7 @@ type Provenance = { readonly upstream: { readonly repository: string; readonly version: string; readonly baseCommit: string }; readonly reviewedPatchStack: { readonly tipCommit: string; + readonly sourceTree?: string; readonly patches: readonly { file: string; sha256: string; purpose?: string }[]; }; readonly artifact: { @@ -77,6 +78,18 @@ function fail(message: string): never { throw new Error(`provenance: ${message}`); } +export function assertPatchStackIdentity(name: string, stack: Provenance["reviewedPatchStack"], tip: string, tree: string): void { + if (stack.sourceTree !== undefined) { + // git am preserves the reviewed source but writes fresh committer metadata. + // Compare the entire Git tree, including file modes, after pinning each patch. + if (!/^[0-9a-f]{40}$/u.test(stack.sourceTree) || tree !== stack.sourceTree) { + fail(`${name} patch stack source tree ${tree} differs from ${stack.sourceTree}`); + } + } else if (tip !== stack.tipCommit) { + fail(`${name} patch stack landed at ${tip}, expected ${stack.tipCommit}`); + } +} + function loadProvenance(vendorDir: string): Provenance { const path = join(ROOT, vendorDir, "provenance.json"); const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); @@ -184,9 +197,8 @@ async function rebuild(name: keyof typeof TARGETS): Promise { await must(["git", "am", "--3way", staged], clone, `${name} git am ${patch.file}`); } const tip = await must(["git", "rev-parse", "HEAD"], clone, `${name} tip`); - if (tip !== provenance.reviewedPatchStack.tipCommit) { - fail(`${name} patch stack landed at ${tip}, expected ${provenance.reviewedPatchStack.tipCommit}`); - } + const tree = await must(["git", "rev-parse", "HEAD^{tree}"], clone, `${name} source tree`); + assertPatchStackIdentity(name, provenance.reviewedPatchStack, tip, tree); if (name === "wacli") { // The provenance record pins the Go toolchain archive itself; admit the diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 1754a1c3..5c74756d 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -1220,7 +1220,7 @@ describe("npm publication contract", () => { (MAX_UNPACKED_BYTES + MAX_PACKED_ENTRIES * 1_023 + 1_024) / 512, ) * 512, ); - expect(MAX_PACKAGE_TAR_BYTES).toBe(24_700_416); + expect(MAX_PACKAGE_TAR_BYTES).toBe(24_787_968); expect(MAX_PACKAGE_TAR_BYTES % 512).toBe(0); expect(artifact).toContain("maxOutputLength: MAX_PACKAGE_TAR_BYTES"); expect(artifact).not.toContain("const maximumTarBytes"); @@ -1430,9 +1430,9 @@ describe("npm publication contract", () => { expect(budget).toContain("785b8fa60c329d7ac46bc8fcf4d959b5fa9d96455bba9e7cdea63f6a3827c4f6"); expect(Object.isFrozen(repairPackageMeasurement)).toBeTrue(); expect(repairPackageMeasurement).toMatchObject({ - scope: "GhostGet 0.18.70 provider text projection repair over the verified 0.18.69 runtime", - archiveSha256: "809a89df745595fbc2d0715ca43d1f4ff2ee738a0acca1ae0feaa3339fa17b92", - packedBytes: 12_144_529, unpackedBytes: 24_059_132, entryCount: 625, + scope: "GhostGet 0.18.71 roster-bound group automation over the verified 0.18.65 package", + archiveSha256: "869505039ff55197d8dbab2c49f923e4effaeb71ea124ee2e59852a84127ab4e", + packedBytes: 12_183_657, unpackedBytes: 24_145_741, entryCount: 626, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, payloadAllowance: 65, }); @@ -1448,8 +1448,8 @@ describe("npm publication contract", () => { expect(budget).toContain("24,024,705 + 353 + 65 = 24,025,123 unpacked"); expect(budget).toContain("12,152,562 + 12,387 + 4,096 = 12,169,045 packed"); expect(budget).toContain("24,093,786 + 353 + 65 = 24,094,204 unpacked"); - expect(MAX_PACKED_BYTES).toBe(12_161_012); - expect(MAX_PACKED_BYTES).toBe(12_144_529 + 12_387 + 4_096); + expect(MAX_PACKED_BYTES).toBe(12_200_140); + expect(MAX_PACKED_BYTES).toBe(12_183_657 + 12_387 + 4_096); expect(budget).toContain("aa127b3193c9bb3b0cb5deece5927be60ccb7111a50169320d322ffdeaa13f39"); expect(budget).toContain("0c331bab3ab3df69a108e18f5f29845b0db90c281cbd6455c0d90fa0b24081e2"); expect(budget).toContain("873cad8139fda303e2d19c6afd61cf549cf9b4d1d76b2a1d6d632a6afe6bd0d1"); @@ -1544,8 +1544,8 @@ describe("npm publication contract", () => { expect(budget).toContain("11,696,091 + 4,096 = 11,700,187"); expect(budget).toContain("35449445752 attempt 1, package job 105913938839"); expect(budget).toContain("exactly 596 files"); - expect(MAX_PACKED_ENTRIES).toBe(625); - expect(MAX_PACKED_FILES).toBe(625); + expect(MAX_PACKED_ENTRIES).toBe(626); + expect(MAX_PACKED_FILES).toBe(626); expect(budget).toContain("Ghostget 0.18.6 same-boot setup-cleanup candidate over main edbe567"); expect(budget).toContain("11,656,173"); expect(budget).toContain("22,513,450 payload bytes across exactly 557 files"); @@ -1570,7 +1570,7 @@ describe("npm publication contract", () => { expect(budget).toContain("47684b3e2eb5cf3ed07fbb520aade8c7251d993f75262fbf1af627d9081a1a5f"); expect(budget).toContain("23,688,277 + 353 + 65 = 23,688,695"); expect(budget).toContain("23,759,283 + 353 + 65 = 23,759,701"); - expect(MAX_UNPACKED_BYTES).toBe(24_059_550); + expect(MAX_UNPACKED_BYTES).toBe(24_146_159); expect(budget).toContain("23,037,873 + 65 = 23,037,938"); expect(budget).toContain("f9f3ab38a682690ceaa2699a7309997512030f0fa500a9dc29dcd108123dc41f"); expect(budget).toContain("23,038,557 + 65 = 23,038,622"); @@ -1603,7 +1603,7 @@ describe("npm publication contract", () => { expect(budget).toContain("01875f12ab73a49d6c7d6bf520dc3d318db816addee2fa7981889f35c958cf7c"); expect(budget).toContain("b12909f08f7c19460ced56e30619f4860a1183f4b0106170c07837dae577a937"); expect(budget).toContain("0b212ac291218528dcf979370110a36f10850e046ca90a536057d9a44e807d1d"); - expect(MAX_UNPACKED_BYTES).toBe(24_059_132 + 353 + 65); + expect(MAX_UNPACKED_BYTES).toBe(24_145_741 + 353 + 65); expect(budget).toContain("22,794,052 + 65 = 22,794,117"); expect(budget).toContain("c482efe748f880e3717727d6d39fd92a68953e6eea766642b329ba47ae772d80"); expect(budget).toContain("22,759,423 + 65 = 22,759,488"); @@ -1637,10 +1637,10 @@ describe("npm publication contract", () => { expect(Object.isFrozen(range)).toBe(true); } expect(packageArtifactBudget).toEqual({ - entryCount: { min: 625, max: 625 }, - fileCount: { min: 625, max: 625 }, - packedBytes: { min: 1_600_000, max: 12_161_012 }, - unpackedBytes: { min: 9_000_000, max: 24_059_550 }, + entryCount: { min: 626, max: 626 }, + fileCount: { min: 626, max: 626 }, + packedBytes: { min: 1_600_000, max: 12_200_140 }, + unpackedBytes: { min: 9_000_000, max: 24_146_159 }, }); }); diff --git a/scripts/package-artifact.ts b/scripts/package-artifact.ts index 1ebb2b7e..ba500e21 100644 --- a/scripts/package-artifact.ts +++ b/scripts/package-artifact.ts @@ -65,6 +65,7 @@ const requiredPaths = Object.freeze([ "src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch", "src/plugins/imessage-direct/vendor/0004-fix-enforce-exact-chat-service-before-dispatch.patch", "src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch", + "src/plugins/whatsapp-linked-device/vendor/0002-ghostget-private-groups.patch", "src/plugins/whatsapp-linked-device/vendor/provenance.json", "src/providers/imessage-direct-install.ts", "src/provider-plugin-registry.ts", diff --git a/scripts/package-budget.ts b/scripts/package-budget.ts index d35e731c..db30144f 100644 --- a/scripts/package-budget.ts +++ b/scripts/package-budget.ts @@ -2390,31 +2390,42 @@ // Preserve the same projections and allowances: // 12,154,912 + 12,387 + 4,096 = 12,171,395 packed; // 24,054,352 + 353 + 65 = 24,054,770 payload. -// The 0.18.69 WhatsApp projection repair release retains the 625-entry -// inventory after rebuilding dist. Two byte-identical packs with the same -// qualified toolchain measured 12,144,212 packed and 24,058,506 payload bytes; -// archive SHA-256 -// 75283db7bb2b528bf5c483dcb165b274874fddc1d755236e8d1357ec5085a1a1. -// Retain the reviewed projections and allowances: -// 12,144,212 + 12,387 + 4,096 = 12,160,695 packed; -// 24,058,506 + 353 + 65 = 24,058,924 payload. -// The 0.18.70 provider text projection repair retains all 625 entries. -// Two byte-identical packs with the same toolchain measured 12,144,529 packed -// and 24,059,132 payload bytes; SHA-256 -// 809a89df745595fbc2d0715ca43d1f4ff2ee738a0acca1ae0feaa3339fa17b92. -// The two parser repairs and release metadata add 317 packed and 626 payload -// bytes over 0.18.69. Preserve every existing projection and allowance: -// 12,144,529 + 12,387 + 4,096 = 12,161,012 packed; -// 24,059,132 + 353 + 65 = 24,059,550 payload. +// The 0.18.65 editorial release preserves that runtime and file inventory. +// Two byte-identical packs with the same qualified toolchain measured 625 +// entries, 12,155,065 packed and 24,054,930 payload bytes. The 578-byte payload +// increase is the new changelog entry; article and image files are website-only. +// Retain every projection and allowance: +// 12,155,065 + 12,387 + 4,096 = 12,171,548 packed; +// 24,054,930 + 353 + 65 = 24,055,348 payload. +// The 0.18.65 archive SHA-256 is +// 09e69ff231a08b63c399662cdcb0fe9a08c5f5faa563e5e855e2c6b832e8003f. +// The analytics-only 0.18.66 preparation measured 625 entries, 12,143,254 +// packed bytes and 24,055,132 payload bytes with local archive SHA-256 +// b462796a666a3f7acccbbf052590f6f7b3db4f956307567dca313c6b13d3393a. +// Group automation in 0.18.71 adds the pinned WhatsApp group source patch, +// updates its native runtime, and expands the owner host and provider adapters. +// The preceding projection-only 0.18.67 preparation measured 625 entries, +// 12,144,036 packed and 24,057,867 payload bytes with local archive SHA-256 +// 0d15ceffd2d5603f73a9beb1696726da8ae87aca8a2dfb4d8d4f61ac5d20ec41. +// The group release preserves that repair, subsequent 0.18.68–0.18.70 +// analytics/provider fixes, and every historical release note. +// Two byte-identical npm 11.19.0 packs under the same qualified Node/zlib +// toolchain measured 626 entries, 12,183,657 packed and 24,145,741 payload bytes. +// The single added entry is the source patch; the net addition over 0.18.65 +// is 28,592 packed and 90,811 payload bytes, including rebuilt SDK chunks and +// documentation. Every archive entry matches its retained source bytes. +// Keep all existing projections and allowances: +// 12,183,657 + 12,387 + 4,096 = 12,200,140 packed; +// 24,145,741 + 353 + 65 = 24,146,159 payload. export const repairPackageMeasurement = Object.freeze({ - scope: "GhostGet 0.18.70 provider text projection repair over the verified 0.18.69 runtime", + scope: "GhostGet 0.18.71 roster-bound group automation over the verified 0.18.65 package", command: "npm pack --ignore-scripts", npmVersion: "11.19.0", platform: "darwin-arm64", - archiveSha256: "809a89df745595fbc2d0715ca43d1f4ff2ee738a0acca1ae0feaa3339fa17b92", - packedBytes: 12_144_529, - unpackedBytes: 24_059_132, - entryCount: 625, + archiveSha256: "869505039ff55197d8dbab2c49f923e4effaeb71ea124ee2e59852a84127ab4e", + packedBytes: 12_183_657, + unpackedBytes: 24_145_741, + entryCount: 626, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, diff --git a/skills/ghostget/references/install.md b/skills/ghostget/references/install.md index 269c16ee..25eef858 100644 --- a/skills/ghostget/references/install.md +++ b/skills/ghostget/references/install.md @@ -17,14 +17,14 @@ If Bun is missing, stop and direct the user to the official [Bun installation guide](https://bun.sh/docs/installation). Do not switch package managers or pipe an unreviewed installer into a shell. -This reference is authored for the exact v0.18.70 release coordinate. Use it +This reference is authored for the exact v0.18.71 release coordinate. Use it only from the matching release-bound Agent Skill after its canonical archive and immutable GitHub Release exist. If the coordinate is not public, stop instead of substituting `main`, another tag, or a different package version. Install that exact release and verify a public-page read: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.70/hraness-ghostget-0.18.70.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.71/hraness-ghostget-0.18.71.tgz ghostget read https://example.com ``` @@ -47,21 +47,21 @@ for the requested workflow; an unconnected provider or missing media tool does not prevent a public-page read, and overall provider readiness can be false on a fresh installation. -The package is `@hraness/ghostget`; `@hraness/ghostget@0.18.70` is an optional npm +The package is `@hraness/ghostget`; `@hraness/ghostget@0.18.71` is an optional npm mirror only after verified registry publication. Canonical installation does not wait for registry publication. When upgrading from Wrench, use `ghostget` for new commands and `GHOSTGET_STATE_HOME` for an explicit state root. Existing state is selected in place; do not rename, copy, or delete a state directory as part of the upgrade. -The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.70/docs/ghostget-migration.md) +The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.71/docs/ghostget-migration.md) explains the retained state aliases and durable protocol names. Do not clone the repository merely to run the CLI. Importing the SDK is a separate project dependency and does not install a global command: ```sh -bun add https://github.com/hraness/ghostget/releases/download/v0.18.70/hraness-ghostget-0.18.70.tgz +bun add https://github.com/hraness/ghostget/releases/download/v0.18.71/hraness-ghostget-0.18.71.tgz ``` `ghostget adapter sync-bundled` upgrades exact bundled baselines, including an diff --git a/src/assets/messaging-runtime/wacli-darwin-arm64.gz b/src/assets/messaging-runtime/wacli-darwin-arm64.gz index 491bd949..da001965 100644 Binary files a/src/assets/messaging-runtime/wacli-darwin-arm64.gz and b/src/assets/messaging-runtime/wacli-darwin-arm64.gz differ diff --git a/src/beeper-client-types.ts b/src/beeper-client-types.ts index 7a9c9386..6c30870e 100644 --- a/src/beeper-client-types.ts +++ b/src/beeper-client-types.ts @@ -96,7 +96,7 @@ export type BeeperContactInteractionExportReceipt = Readonly<{ implementation: Readonly<{ producer: Readonly<{ package: "@hraness/ghostget"; - version: "0.18.70"; + version: "0.18.71"; }>; officialCli: Readonly<{ implementation: "github.com/beeper/cli"; diff --git a/src/cli.ts b/src/cli.ts index 3b5a7594..e65f3d8c 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -114,7 +114,7 @@ export function isImmediateGhostgetVersionRequest( && (second === undefined || second === "--json"); } -/** `ghostget 0.18.70`, or `{"name":"ghostget","version":"0.18.70"}` with `--json`. */ +/** `ghostget 0.18.71`, or `{"name":"ghostget","version":"0.18.71"}` with `--json`. */ export function ghostgetVersionText(rawArguments: readonly string[]): string { return rawArguments.includes("--json") ? `${JSON.stringify({ name: "ghostget", version: GHOSTGET_VERSION })}\n` diff --git a/src/contracts-check.test.ts b/src/contracts-check.test.ts index 274094f3..b6d580ae 100644 --- a/src/contracts-check.test.ts +++ b/src/contracts-check.test.ts @@ -35,7 +35,7 @@ describe("checkCollectionPlan", () => { const check = checkCollectionPlan(examplePlan(), exampleCatalog()); expect(check.ok).toBeTrue(); expect(check.contract).toBe("ghostget.contract-check.v1"); - expect(check.ghostget).toEqual({ version: "0.18.70" }); + expect(check.ghostget).toEqual({ version: "0.18.71" }); expect(check.plan).toEqual({ collectionKey: "example-social-statistics", reads: 3 }); expect(check.reads[0]).toEqual({ index: 0, diff --git a/src/contracts.test-support.ts b/src/contracts.test-support.ts index 40bdd5de..1e58a1f7 100644 --- a/src/contracts.test-support.ts +++ b/src/contracts.test-support.ts @@ -95,7 +95,7 @@ export function exampleCatalog(): ContractCatalogV1 { return { ok: true, contract: CONTRACT_CATALOG_V1, - ghostget: { version: "0.18.70" }, + ghostget: { version: "0.18.71" }, generatedAt: "2026-09-21T20:00:00.000Z", vocabulary: catalogVocabularyValue, adapters: [ diff --git a/src/media/manifest.test.ts b/src/media/manifest.test.ts index 60b13970..2c0383f3 100644 --- a/src/media/manifest.test.ts +++ b/src/media/manifest.test.ts @@ -465,7 +465,7 @@ function trackedYtDlpManifest( describe("Ghostget media manifest", () => { test("uses one Ghostget-owned schema and transcriber identity", () => { expect(GHOSTGET_MEDIA_SCHEMA_VERSION).toBe(1); - expect(GHOSTGET_MEDIA_VERSION).toBe("0.18.70"); + expect(GHOSTGET_MEDIA_VERSION).toBe("0.18.71"); expect(localTranscriptVariantSegments(localIdentity)).toEqual([ "transcript", "local", diff --git a/src/messaging-automation-factory.ts b/src/messaging-automation-factory.ts index ca8db831..b906c9be 100644 --- a/src/messaging-automation-factory.ts +++ b/src/messaging-automation-factory.ts @@ -141,6 +141,7 @@ export async function createMessagingAutomationSession(options: MessagingAutomat }); const wrapped: MessagingAutomationProvider = { provider: selected.provider, inspect: status, + ...(concrete.groupConversations === undefined ? {} : { groupConversations: concrete.groupConversations }), conversations: (input, signal) => call(() => concrete.conversations(input, signal)), resolve: (input, signal) => call(() => concrete.resolve(input, signal)), history: (input, signal) => call(() => concrete.history(input, signal)), diff --git a/src/messaging-automation-groups.test.ts b/src/messaging-automation-groups.test.ts new file mode 100644 index 00000000..d85c7ee7 --- /dev/null +++ b/src/messaging-automation-groups.test.ts @@ -0,0 +1,268 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MessagingAutomationHost } from "./messaging-automation"; +import { AUTOMATION_ACTION_KINDS, AUTOMATION_BINDING_CHANGED_REASON, parseAutomationConversation, parseAutomationCoordinate } from "./messaging-automation-validation"; +import type { AutomationConversation, AutomationIdentity, AutomationMessage, AutomationProviderStatus, MessagingAutomationProvider } from "./messaging-automation-types"; +import { AutomationGroupBindingChangedError } from "./messaging-automation-types"; +import { assertAsyncProperty, assertProperty, fc } from "./test-support"; + +const at = "2026-10-01T00:00:00.000Z"; +const coordinate = { provider: "whatsapp", conversationJid: "120363012345678@g.us" } as const; +const identity: AutomationIdentity = { provider: "whatsapp", authId: "fixture", accountIdentity: "1".repeat(64), accountSubject: "whatsapp:pn:15550000000", implementationIdentity: "2".repeat(64), sourceGeneration: "fixture:1" }; +const marker = AUTOMATION_BINDING_CHANGED_REASON; +function message(id: string, kind: AutomationMessage["kind"] = "message", relatedMessageId: string | null = null): AutomationMessage { + return { id, coordinate, direction: "incoming", occurredAt: at, text: `Synthetic ${id}`, kind, relatedMessageId, attachments: [] }; +} +const cleanup: (() => Promise)[] = []; +afterEach(async () => { for (const close of cleanup.splice(0).reverse()) await close(); }); +function fixture() { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "ghostget-group-contract-"))); + const environment = { GHOSTGET_STATE_HOME: join(directory, "state") }; + let roster = ["15551111111@s.whatsapp.net", "15552222222@s.whatsapp.net"]; + let current = identity; let title = "Synthetic group"; let unavailable = false, emptyProof = false; let now = Date.parse(at); + const events: AutomationMessage[] = [], sends: Parameters[0][] = []; + const selected = (): AutomationConversation => ({ coordinate, title, kind: "group", participants: roster }); + const provider: MessagingAutomationProvider = { + provider: "whatsapp", groupConversations: { version: 1 }, + inspect: async () => ({ identity: current, connected: true, events: { available: true, reason: null }, actions: Object.fromEntries(AUTOMATION_ACTION_KINDS.map(kind => [kind, { available: true, reason: null }])) as AutomationProviderStatus["actions"] }), + conversations: async () => ({ identity: current, conversations: [selected()], complete: true }), + resolve: async () => { if (unavailable) throw new Error("Synthetic lookup unavailable"); if (emptyProof && roster.length === 0) throw new AutomationGroupBindingChangedError(current, coordinate); return { identity: current, conversation: selected() }; }, + history: async () => ({ identity: current, messages: [message("old-private")], nextCursor: String(events.length), caughtUp: true, gap: false }), + events: async input => ({ identity: current, messages: events.slice(Number(input.cursor)), nextCursor: String(events.length), caughtUp: true, gap: false }), + send: async input => { sends.push(input); return { state: "accepted", messageId: `sent:${sends.length}`, providerReceiptId: null, delivery: "unknown" }; }, + close: async () => undefined, + }; + let host = new MessagingAutomationHost([provider], environment, () => now); + const close = async () => { await host.close(); rmSync(directory, { recursive: true, force: true }); }; + cleanup.push(close); + return { + get host() { return host; }, provider, sends, selected, + roster: (value: string[]) => { roster = value; }, identity: (value: AutomationIdentity) => { current = value; }, title: (value: string) => { title = value; }, + unavailable: (value: boolean) => { unavailable = value; }, add: (value: AutomationMessage) => { events.push(value); }, + time: (value: number) => { now = value; }, + emptyProof: (value: boolean) => { emptyProof = value; }, + restart: async () => { await host.close(); host = new MessagingAutomationHost([provider], environment, () => now); }, + enroll: () => host.enroll({ provider: "whatsapp", coordinate }), + grant: (enrollment: Awaited>) => host.grant({ enrollmentId: enrollment.id, expectedBindingDigest: enrollment.bindingDigest, actions: ["text"], expiresAt: "2026-10-02T00:00:00.000Z", maximumActions: 20, minimumIntervalMs: 0 }), + }; +} + +test("group discovery and enrollment listings require explicit opt-in and a qualified provider", async () => { + const f = fixture(); + const requests: unknown[] = [], list = f.provider.conversations; + f.provider.conversations = async (...args) => { requests.push(args[0]); return list(...args); }; + expect(f.host.features()).toEqual({ groupConversations: { version: 1 } }); + expect((await f.host.conversations({ provider: "whatsapp", limit: 20 })).conversations).toEqual([]); + expect((await f.host.conversations({ provider: "whatsapp", limit: 20, includeGroups: false })).conversations).toEqual([]); + expect((await f.host.conversations({ provider: "whatsapp", limit: 20, includeGroups: true })).conversations).toHaveLength(1); + expect(requests).toEqual([{ limit: 20 }, { limit: 20 }, { limit: 20, includeGroups: true }]); + const enrollment = await f.enroll(); + expect(f.host.enrollments()).toEqual([]); + expect(f.host.enrollments({ includeGroups: true })).toEqual([enrollment]); + Object.defineProperty(f.provider, "groupConversations", { value: undefined }); + expect((await f.host.conversations({ provider: "whatsapp", limit: 20, includeGroups: true })).conversations).toEqual([]); + await expect(f.enroll()).rejects.toThrow("unavailable"); +}); + +test("group roster canonicalization preserves exact JIDs and rejects incomplete or ambiguous bindings", () => { + const selected = { coordinate, title: null, kind: "group", participants: ["b", "a"] }; + expect(parseAutomationConversation(selected).participants).toEqual(["a", "b"]); + for (const participants of [[], ["a", "a"], Array.from({ length: 501 }, (_, n) => String(n))]) expect(() => parseAutomationConversation({ ...selected, participants })).toThrow(); + expect(() => parseAutomationConversation({ ...selected, kind: "single" })).toThrow(); + expect(() => parseAutomationConversation({ ...selected, coordinate: { provider: "whatsapp", conversationJid: "15551111111@s.whatsapp.net" } })).toThrow(); + for (const conversationJid of ["120363012345678@g.us", "15551111111-1234567890@g.us"]) expect(parseAutomationCoordinate({ provider: "whatsapp", conversationJid })).toEqual({ provider: "whatsapp", conversationJid }); + for (const conversationJid of ["0120363012345678@g.us", "120363012345678@g.US", "120363012345678:1@g.us", "120363012345678@broadcast", "120363012345678@g.us "]) expect(() => parseAutomationCoordinate({ provider: "whatsapp", conversationJid })).toThrow(); + assertProperty(fc.property(fc.uniqueArray(fc.integer({ min: 1, max: 500 }), { minLength: 1, maxLength: 100 }), members => { + const roster = members.map(String); + expect(parseAutomationConversation({ ...selected, participants: roster }).participants).toEqual(parseAutomationConversation({ ...selected, participants: [...roster].reverse() }).participants); + })); +}); + +test("group live history excludes baseline bodies and mutations of unknown old messages", async () => { + const f = fixture(); const enrolled = await f.enroll(); + expect(f.host.history({ enrollmentId: enrolled.id, limit: 200 }).messages).toEqual([]); + f.add(message("old-private", "edit")); f.add(message("old-reaction", "reaction", "old-private")); + f.add(message("new-live")); f.add(message("new-reaction", "reaction", "new-live")); + await f.host.poll(enrolled.id); + const ids = ["new-live", "new-reaction"]; + expect(f.host.history({ enrollmentId: enrolled.id, limit: 200 }).messages.map(m => m.id)).toEqual(ids); + f.provider.history = async () => { throw new Error("Group history must never consult the provider archive"); }; + expect((await f.host.historyWindow({ enrollmentId: enrolled.id, limit: 200, before: null, after: null })).messages.map(m => m.id)).toEqual(ids); + expect(f.host.events({ enrollmentIds: [enrolled.id], cursor: null, limit: 200 }).events.map(e => e.message.id)).toEqual(ids); +}); + +test("group baseline catchup remains silent and excludes all initial bodies", async () => { + const f = fixture(); const history = f.provider.history; + f.provider.history = async (...args) => ({ ...await history(...args), caughtUp: false }); + const enrollment = await f.enroll(); f.add(message("late-old-private")); + expect((await f.host.poll(enrollment.id)).ready).toBe(true); + expect(f.host.history({ enrollmentId: enrollment.id, limit: 200 }).messages).toEqual([]); + f.add(message("post-boundary")); await f.host.poll(enrollment.id); + expect(f.host.history({ enrollmentId: enrollment.id, limit: 200 }).messages.map(m => m.id)).toEqual(["post-boundary"]); +}); +test("original creation time and durable exclusions reject late backfill and future-dated history", async () => { + const f = fixture(); const enrollment = await f.enroll(); + const past = { ...message("late-backfill"), occurredAt: "2026-09-30T23:59:59.000Z" }; + const future = { ...message("future-original"), occurredAt: "2026-10-01T00:00:10.000Z" }; + f.add(past); f.add(future); await f.host.poll(enrollment.id); + expect(f.host.history({ enrollmentId: enrollment.id, limit: 200 }).messages).toEqual([]); + await f.restart(); f.time(Date.parse("2026-10-01T00:00:20.000Z")); + f.add(future); f.add({ ...past, kind: "edit" }); f.add({ ...message("current-live"), occurredAt: "2026-10-01T00:00:15.000Z" }); + await f.host.poll(enrollment.id); + expect(f.host.history({ enrollmentId: enrollment.id, limit: 200 }).messages.map(m => m.id)).toEqual(["current-live"]); +}); + +test("observed roster drift permanently revokes old authority across restoration and restart", async () => { + const f = fixture(); const enrollment = await f.enroll(), grant = f.grant(enrollment); + const plan = f.host.prepare({ enrollmentId: enrollment.id, expectedRevision: 0, intentId: "before-drift", actions: [{ kind: "text", text: "Synthetic" }] }); + const original = [...f.selected().participants]; + f.roster([...original, "15553333333@s.whatsapp.net"]); + const invalidated = await f.host.poll(enrollment.id); + expect(invalidated).toMatchObject({ ready: false, reason: marker, bindingDigest: enrollment.bindingDigest }); + expect(f.host.grantStatus(grant.id).revoked).toBe(true); + f.roster(original); await f.restart(); + expect(await f.host.poll(enrollment.id)).toMatchObject({ ready: false, reason: marker }); + expect(() => f.grant(enrollment)).toThrow(); + await expect(f.host.submit({ planId: plan.id, grantId: grant.id })).rejects.toThrow(); + const fresh = await f.enroll(); + expect(fresh.id).not.toBe(enrollment.id); expect(fresh.bindingDigest).toBe(enrollment.bindingDigest); + expect(f.sends).toEqual([]); +}); + +test("new group roster and ID cannot inherit previous group history", async () => { + const f = fixture(); const first = await f.enroll(); + f.add(message("first-roster-private")); await f.host.poll(first.id); + f.roster(["15551111111@s.whatsapp.net", "15553333333@s.whatsapp.net"]); + const second = await f.enroll(); + expect(second.id).not.toBe(first.id); + expect(f.host.enrollments({ includeGroups: true }).find(e => e.id === first.id)).toMatchObject({ ready: false, reason: marker }); + f.add(message("first-roster-private", "edit")); await f.host.poll(second.id); + expect(f.host.history({ enrollmentId: second.id, limit: 200 }).messages).toEqual([]); + expect((await f.host.historyWindow({ enrollmentId: second.id, limit: 200, before: null, after: null })).messages).toEqual([]); +}); +test("complete-empty group proof permanently invalidates A to empty to A but unavailable evidence does not", async () => { + const f = fixture(); const enrolled = await f.enroll(), grant = f.grant(enrolled), roster = [...f.selected().participants]; + f.roster([]); await expect(f.host.poll(enrolled.id)).rejects.toThrow(); + expect(f.host.enrollments({ includeGroups: true })[0]?.reason).not.toBe(marker); + f.roster(roster); expect((await f.host.poll(enrolled.id)).ready).toBe(true); + f.emptyProof(true); f.roster([]); + expect(await f.host.poll(enrolled.id)).toMatchObject({ ready: false, reason: marker }); + expect(f.host.grantStatus(grant.id)?.revoked).toBe(true); + await expect(f.enroll()).rejects.toThrow(marker); + f.roster(roster); await f.restart(); + expect(await f.host.poll(enrolled.id)).toMatchObject({ ready: false, reason: marker }); + expect((await f.enroll()).id).not.toBe(enrolled.id); +}); +test("group invalidation proof cannot cross coordinate or account scope", async () => { + const f = fixture(); const enrolled = await f.enroll(), resolve = f.provider.resolve; + for (const [who, where] of [[identity, { ...coordinate, conversationJid: "120363099999999@g.us" }], [{ ...identity, authId: "another-account" }, coordinate]] as const) { + f.provider.resolve = async () => { throw new AutomationGroupBindingChangedError(who, where); }; + await expect(f.host.poll(enrolled.id)).rejects.toThrow(); + expect(f.host.enrollments({ includeGroups: true })[0]?.reason).not.toBe(marker); + f.provider.resolve = resolve; expect((await f.host.poll(enrolled.id)).ready).toBe(true); + } +}); +test("observed drift remains invalid even when replacement enrollment cannot finish", async () => { + const f = fixture(); const enrolled = await f.enroll(); const roster = [...f.selected().participants]; + f.roster(["replacement"]); + f.provider.history = async () => { throw new Error("Synthetic baseline failure"); }; + await expect(f.enroll()).rejects.toThrow("baseline failure"); + f.roster(roster); + expect(await f.host.poll(enrolled.id)).toMatchObject({ ready: false, reason: marker }); +}); +test("group drift observed after baseline history cannot revive when the original roster returns", async () => { + const f = fixture(); const enrolled = await f.enroll(), roster = [...f.selected().participants], history = f.provider.history; + f.provider.history = async (...args) => { const page = await history(...args); f.roster(["replacement"]); return page; }; + await expect(f.enroll()).rejects.toThrow("binding changed"); + f.roster(roster); await f.restart(); + expect(await f.host.poll(enrolled.id)).toMatchObject({ ready: false, reason: marker }); +}); +test("group identity drift observed at enrollment status, route or history cannot revive old authority", async () => { + for (const phase of ["status", "route", "history"] as const) { + const f = fixture(); const enrolled = await f.enroll(), changed = { ...identity, sourceGeneration: "replacement-generation" }; + const resolve = f.provider.resolve, history = f.provider.history; + if (phase === "status") { f.identity(changed); f.unavailable(true); } + if (phase === "route") f.provider.resolve = async () => ({ identity: changed, conversation: f.selected() }); + if (phase === "history") f.provider.history = async (...args) => ({ ...await history(...args), identity: changed }); + await expect(f.enroll()).rejects.toThrow(); + f.identity(identity); f.unavailable(false); f.provider.resolve = resolve; f.provider.history = history; + await f.restart(); expect(await f.host.poll(enrolled.id)).toMatchObject({ ready: false, reason: marker }); + } +}); +test("group identity drift in a validated event page cannot revive after restoration or restart", async () => { + const f = fixture(); const enrolled = await f.enroll(), events = f.provider.events; + f.provider.events = async (...args) => ({ ...await events(...args), identity: { ...identity, sourceGeneration: "page-generation" } }); + await expect(f.host.poll(enrolled.id)).rejects.toThrow("identity changed"); + f.provider.events = events; await f.restart(); + expect(await f.host.poll(enrolled.id)).toMatchObject({ ready: false, reason: marker }); +}); +test("account drift permanently invalidates a group and a page spanning roster drift is discarded", async () => { + const f = fixture(); const enrolled = await f.enroll(); f.identity({ ...identity, accountIdentity: "3".repeat(64) }); + await expect(f.host.poll(enrolled.id)).rejects.toThrow("identity changed"); + f.identity(identity); + expect(await f.host.poll(enrolled.id)).toMatchObject({ ready: false, reason: marker }); + const fresh = await f.enroll(); + const events = f.provider.events; + f.provider.events = async (...args) => { const page = await events(...args); f.roster(["replacement"]); return page; }; + f.add(message("crossed-roster-private")); + expect(await f.host.poll(fresh.id)).toMatchObject({ ready: false, reason: marker }); + expect(f.host.history({ enrollmentId: fresh.id, limit: 200 }).messages).toEqual([]); +}); + +test("roster drift between batch actions stops the next effect and preserves accepted evidence", async () => { + const f = fixture(); const enrolled = await f.enroll(), grant = f.grant(enrolled); + const plan = f.host.prepare({ enrollmentId: enrolled.id, expectedRevision: 0, intentId: "group-batch", actions: [{ kind: "text", text: "First" }, { kind: "text", text: "Second" }] }); + const send = f.provider.send; + f.provider.send = async (...args) => { const result = await send(...args); f.roster(["replacement"]); return result; }; + expect(await f.host.submit({ planId: plan.id, grantId: grant.id })).toMatchObject({ state: "partial", totalActions: 2, reason: marker }); + expect(f.sends).toHaveLength(1); expect(f.sends[0]?.conversation).toEqual(enrolled.conversation); + expect(f.host.enrollments({ includeGroups: true })[0]).toMatchObject({ ready: false, reason: marker }); +}); + +test("final provider-boundary drift invalidates group enrollment without pretending a send happened", async () => { + const f = fixture(); const enrolled = await f.enroll(), grant = f.grant(enrolled); + const plan = f.host.prepare({ enrollmentId: enrolled.id, expectedRevision: 0, intentId: "group-boundary", actions: [{ kind: "text", text: "Synthetic" }] }); + f.provider.send = async () => ({ state: "not-started", reason: marker }); + expect(await f.host.submit({ planId: plan.id, grantId: grant.id })).toMatchObject({ state: "failed", accepted: [], reason: marker }); + expect(f.host.enrollments({ includeGroups: true })[0]).toMatchObject({ ready: false, reason: marker }); +}); + +test("title changes and transient lookup failures do not invent a new roster", async () => { + const f = fixture(); const enrolled = await f.enroll(); f.title("Renamed group"); + expect((await f.host.poll(enrolled.id)).bindingDigest).toBe(enrolled.bindingDigest); + f.unavailable(true); await expect(f.host.poll(enrolled.id)).rejects.toThrow(); + f.unavailable(false); expect(await f.host.poll(enrolled.id)).toMatchObject({ ready: true, bindingDigest: enrolled.bindingDigest }); +}); + +test("bounded group lifecycle schedules never revive an observed old binding", async () => { + await assertAsyncProperty(fc.asyncProperty(fc.array(fc.constantFrom("poll", "drift", "empty", "restore", "restart", "rename", "send", "lookup-fault"), { minLength: 1, maxLength: 14 }), async schedule => { + const f = fixture(); const enrollment = await f.enroll(), grant = f.grant(enrollment); const original = [...f.selected().participants]; let changed = false, invalidated = false; + for (const [index, action] of schedule.entries()) { + const sentBefore = f.sends.length; + if (action === "drift") { f.roster(["changed-member"]); changed = true; } + if (action === "empty") { f.emptyProof(true); f.roster([]); changed = true; } + if (action === "restore") { f.roster(original); changed = false; } + if (action === "rename") f.title("Another title"); + if (action === "restart") await f.restart(); + if (action === "poll") { await f.host.poll(enrollment.id); if (changed) invalidated = true; } + if (action === "lookup-fault" && !invalidated) { f.unavailable(true); await expect(f.host.poll(enrollment.id)).rejects.toThrow(); f.unavailable(false); } + if (action === "send") { + const shouldSend = !changed && !invalidated && f.host.enrollments({ includeGroups: true })[0]?.ready === true; + try { + const plan = f.host.prepare({ enrollmentId: enrollment.id, expectedRevision: 0, intentId: `schedule:${index}`, actions: [{ kind: "text", text: "Synthetic" }] }); + await f.host.submit({ planId: plan.id, grantId: grant.id }); + } catch { /* An unavailable or invalidated binding must refuse safely. */ } + if (f.host.enrollments({ includeGroups: true })[0]?.reason === marker) invalidated = true; + if (changed || invalidated) expect(f.sends.length).toBe(sentBefore); + if (shouldSend) expect(f.sends.length).toBe(sentBefore + 1); + } + const current = f.host.enrollments({ includeGroups: true })[0]!; + expect(current.bindingDigest).toBe(enrollment.bindingDigest); + if (invalidated) expect(current).toMatchObject({ ready: false, reason: marker }); + } + f.roster(original); + expect((await f.host.poll(enrollment.id)).ready).toBe(!invalidated); + }), { numRuns: 30, interruptAfterTimeLimit: 90_000 }, "messaging-automation/groups-lifecycle"); +}); diff --git a/src/messaging-automation-server.test.ts b/src/messaging-automation-server.test.ts index 66892e02..a38bc379 100644 --- a/src/messaging-automation-server.test.ts +++ b/src/messaging-automation-server.test.ts @@ -107,6 +107,17 @@ test("initialize and inspection never imply sync; malformed fields fail without const result = await f.request(method as string, params); expect(result.ok).toBe(false); expect(JSON.stringify(result)).not.toContain("private"); } }); +test("group feature negotiation and opt-in fields are strict and preserve legacy direct lists", async () => { + const f = await fixture(); + expect(await f.request("features", {})).toMatchObject({ ok: true, result: { groupConversations: { version: 1 } } }); + for (const includeGroups of [undefined, false, true]) { + const flag = includeGroups === undefined ? {} : { includeGroups }; + expect(await f.request("enrollments", flag)).toMatchObject({ ok: true, result: [] }); + expect(await f.request("conversations", { provider: "whatsapp", limit: 20, ...flag })).toMatchObject({ ok: true, result: { conversations: [] } }); + } + for (const includeGroups of [null, 1, "true"]) expect((await f.request("enrollments", { includeGroups })).ok).toBe(false); + expect((await f.request("features", { includeGroups: true })).ok).toBe(false); +}); test("asset bytes are canonical, digest-bound, plan-scoped and removed after terminal submit", async () => { const f = await fixture(); const bytesBase64 = Buffer.from("synthetic").toString("base64"), sha256 = createHash("sha256").update("synthetic").digest("hex"); expect((await f.request("asset", { bytesBase64: bytesBase64 + "\n", sha256 })).ok).toBe(false); diff --git a/src/messaging-automation-server.ts b/src/messaging-automation-server.ts index 23362c6a..73df9ca6 100644 --- a/src/messaging-automation-server.ts +++ b/src/messaging-automation-server.ts @@ -5,7 +5,7 @@ import { canonicalJson } from "./canonical-json"; import { discoveryDiagnosticMessage } from "./messaging-automation-diagnostics"; import { MessagingAutomationHost } from "./messaging-automation"; import { MESSAGING_AUTOMATION_PROTOCOL as protocol, type AutomationGrantRequest, type AutomationProviderId, type AutomationProviderStatus } from "./messaging-automation-types"; -import { automationArray, automationDigest, automationId, automationInteger, automationRecord, automationText, parseAutomationAction, automationInstant } from "./messaging-automation-validation"; +import { automationGroupOptions, automationArray, automationDigest, automationId, automationInteger, automationRecord, automationText, parseAutomationAction, automationInstant } from "./messaging-automation-validation"; import { createMessagingAutomationSession } from "./messaging-automation-factory"; import { enablePersistentStateHelpers } from "./storage"; import type { ProviderPluginRegistry } from "./provider-plugin-registry"; @@ -72,13 +72,14 @@ export class MessagingAutomationRpcServer { } if (method === "close") { automationRecord(raw, []); await this.close(); return { closed: true }; } const host = this.host(); + if (method === "features") { automationRecord(raw, []); return host.features(); } if (method === "status" || method === "start") { const r = automationRecord(raw, ["provider"]); const selected = provider(r.provider); return method === "status" ? host.providerStatus(selected, this.abort.signal) : this.session!.start(selected, this.abort.signal); } - if (method === "conversations") { const r = automationRecord(raw, ["provider", "limit"]); return host.conversations({ provider: provider(r.provider), limit: automationInteger(r.limit, 1, 200) }, this.abort.signal); } + if (method === "conversations") { const r = automationGroupOptions(raw, ["provider", "limit"]); return host.conversations({ provider: provider(r.provider), limit: automationInteger(r.limit, 1, 200), includeGroups: r.includeGroups }, this.abort.signal); } if (method === "enroll") { const r = automationRecord(raw, ["provider", "coordinate"]); return host.enroll({ provider: provider(r.provider), coordinate: r.coordinate }, this.abort.signal); } - if (method === "enrollments") { automationRecord(raw, []); return host.enrollments(); } + if (method === "enrollments") { const r = automationGroupOptions(raw, []); return host.enrollments({ includeGroups: r.includeGroups }); } if (method === "grant") { const r = automationRecord(raw, ["intentId", "enrollmentId", "expectedBindingDigest", "actions", "expiresAt", "maximumActions", "minimumIntervalMs"]); const { intentId, ...request } = r; return host.grant(request as AutomationGrantRequest, automationId(intentId)); @@ -159,7 +160,7 @@ export class MessagingAutomationRpcServer { const results = free.length === 0 ? [] : [...await host.pollEnrollments(free, this.abort.signal)]; if (busy.length > 0) { let rows: ReturnType = []; - try { rows = host.enrollments(); } catch { /* a corrupt unrelated row degrades busy entries below */ } + try { rows = host.enrollments({ includeGroups: true }); } catch { /* a corrupt unrelated row degrades busy entries below */ } for (const id of busy) { const enrollment = rows.find(item => item.id === id) ?? null; results.push({ enrollmentId: id, enrollment, error: enrollment === null ? "Messaging enrollment is unavailable." : null }); @@ -189,7 +190,7 @@ export class MessagingAutomationRpcServer { const r = automationRecord(value, ["protocol", "id", "method", "params"]); id = automationText(r.id, 64); if (!/^[A-Za-z0-9._:-]+$/u.test(id) || r.protocol !== protocol) throw new Error("Invalid envelope"); method = automationText(r.method, 32); priority = ["cancel", "revoke", "close"].includes(method); - scoped = ["poll", "pollSet", "history", "history.window", "prepare", "grant", "submit", "events", "enrollments", "status", "run", "run.by-intent", "grant.get", "grant.by-intent"].includes(method); + scoped = ["features", "poll", "pollSet", "history", "history.window", "prepare", "grant", "submit", "events", "enrollments", "status", "run", "run.by-intent", "grant.get", "grant.by-intent"].includes(method); if (this.requests.has(id) || (priority ? this.priorityBusy >= 8 : scoped ? this.scopedBusy >= 16 : this.normalBusy)) return { protocol, id, ok: false, error: { code: "not-ready", message: "The owner host is busy or this request is already active." } }; this.requests.add(id); admitted = true; if (priority) this.priorityBusy++; else if (scoped) this.scopedBusy++; else this.normalBusy = true; diff --git a/src/messaging-automation-types.ts b/src/messaging-automation-types.ts index abe81590..afc5f8d9 100644 --- a/src/messaging-automation-types.ts +++ b/src/messaging-automation-types.ts @@ -1,5 +1,7 @@ /** Closed host-side messaging contract. Provider credentials and local paths never cross it. */ export const MESSAGING_AUTOMATION_PROTOCOL = "ghostget.messaging-automation/1" as const; +export const AUTOMATION_BINDING_CHANGED_REASON = "ghostget.binding-changed.v1" as const; +export type AutomationFeatures = Readonly<{ groupConversations: Readonly<{ version: 1 }> | null }>; export type AutomationProviderId = "imessage" | "whatsapp" | "beeper"; export type AutomationActionKind = AutomationAction["kind"]; @@ -19,6 +21,18 @@ export type AutomationIdentity = Readonly<{ sourceGeneration: string; }>; export type AutomationCapability = Readonly<{ available: boolean; reason: string | null }>; +/** Trusted adapter signal for a positively observed complete-empty group roster. + * Missing, partial and unavailable rosters must never produce this signal. */ +export class AutomationGroupBindingChangedError extends Error { + readonly identity: AutomationIdentity; + readonly coordinate: AutomationCoordinate; + constructor(identity: AutomationIdentity, coordinate: AutomationCoordinate) { + super(AUTOMATION_BINDING_CHANGED_REASON); + this.identity = identity; + this.coordinate = coordinate; + this.name = "AutomationGroupBindingChangedError"; + } +} export type AutomationProviderStatus = Readonly<{ identity: AutomationIdentity; connected: boolean; @@ -84,8 +98,12 @@ export type AutomationPollResult = Readonly<{ enrollmentId: string; enrollment: */ export interface MessagingAutomationProvider { readonly provider: AutomationProviderId; + /** Trusted implementation promise: complete authoritative rosters checked at + * each effect, stable cursor baselines, and original creation timestamps that + * are preserved through edits/backfill. The host enforces the history epoch. */ + readonly groupConversations?: Readonly<{ version: 1 }>; inspect(signal?: AbortSignal): Promise; - conversations(input: Readonly<{ limit: number }>, signal?: AbortSignal): Promise, signal?: AbortSignal): Promise>; resolve(coordinate: AutomationCoordinate, signal?: AbortSignal): Promise; - conversations(input: Readonly<{ provider: AutomationProviderId; limit: number }>, signal?: AbortSignal): Promise, signal?: AbortSignal): Promise>; enroll(input: Readonly<{ provider: AutomationProviderId; coordinate: AutomationCoordinate }>, signal?: AbortSignal): Promise; - enrollments(): readonly AutomationEnrollment[]; + enrollments(input?: Readonly<{ includeGroups?: boolean }>): readonly AutomationEnrollment[]; history(input: Readonly<{ enrollmentId: string; limit: number }>): Readonly<{ enrollment: AutomationEnrollment; messages: readonly AutomationMessage[] }>; historyWindow(input: Readonly<{ enrollmentId: string; limit: number; before: string | null; after: string | null }>, signal?: AbortSignal): Promise>; grant(request: AutomationGrantRequest, intentId?: string): AutomationGrant; diff --git a/src/messaging-automation-validation.ts b/src/messaging-automation-validation.ts index ec74bf8f..d0a72a66 100644 --- a/src/messaging-automation-validation.ts +++ b/src/messaging-automation-validation.ts @@ -1,7 +1,9 @@ import { types } from "node:util"; -import type { AutomationAction, AutomationActionKind, AutomationCoordinate, AutomationIdentity, AutomationMessage } from "./messaging-automation-types"; +import type { AutomationAction, AutomationActionKind, AutomationConversation, AutomationCoordinate, AutomationIdentity, AutomationMessage } from "./messaging-automation-types"; export const AUTOMATION_ACTION_KINDS = Object.freeze(["text", "attachment", "reaction", "sticker", "link", "poll", "app-clip", "experience"] as const); +export { AUTOMATION_BINDING_CHANGED_REASON } from "./messaging-automation-types"; +export const AUTOMATION_WHATSAPP_GROUP_JID = /^[1-9][0-9]{4,19}(?:-[1-9][0-9]{0,19})?@g\.us$/u; export function automationRecord(value: unknown, keys: readonly string[]): Record { if (types.isProxy(value) || typeof value !== "object" || value === null || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new Error("Messaging automation value must be a plain object."); @@ -65,9 +67,28 @@ export function parseAutomationCoordinate(value: unknown): AutomationCoordinate return Object.freeze({ provider, accountId: automationText(r.accountId, 512), conversationId: automationText(r.conversationId, 2048) }); } const r = automationRecord(value, ["provider", "conversationJid"]); - if (provider !== "whatsapp" || typeof r.conversationJid !== "string" || !/^(?:[1-9][0-9]{4,14}@s\.whatsapp\.net|[1-9][0-9]{4,19}@lid)$/u.test(r.conversationJid)) throw new Error("Only exact individual WhatsApp conversations are supported."); + if (provider !== "whatsapp" || typeof r.conversationJid !== "string" || (!/^(?:[1-9][0-9]{4,14}@s\.whatsapp\.net|[1-9][0-9]{4,19}@lid)$/u.test(r.conversationJid) && !AUTOMATION_WHATSAPP_GROUP_JID.test(r.conversationJid))) throw new Error("Only exact WhatsApp conversation JIDs are supported."); return Object.freeze({ provider, conversationJid: r.conversationJid }); } +/** Titles are presentation; callers bind the complete canonical roster and route. */ +export function parseAutomationConversation(value: unknown): AutomationConversation { + const r = automationRecord(value, ["coordinate", "title", "kind", "participants"]); + const coordinate = parseAutomationCoordinate(r.coordinate); + if (r.kind !== "single" && r.kind !== "group") throw new Error("Messaging automation requires a verified conversation kind."); + const participants = automationArray(r.participants, r.kind === "group" ? 500 : 2).map(value => automationText(value, 512)); + if (participants.length < 1 || new Set(participants).size !== participants.length) throw new Error("Messaging automation requires a complete distinct participant roster."); + if (coordinate.provider === "whatsapp" && AUTOMATION_WHATSAPP_GROUP_JID.test(coordinate.conversationJid) !== (r.kind === "group")) throw new Error("WhatsApp conversation kind and exact JID disagree."); + return Object.freeze({ coordinate, title: r.title === null ? null : automationText(r.title, 512), kind: r.kind, participants: Object.freeze([...participants].sort()) }); +} + +/** Optional flags remain strict: omission is false; explicit false is valid. */ +export function automationGroupOptions(value: unknown, keys: readonly string[]): Record & { includeGroups: boolean } { + if (types.isProxy(value)) throw new Error("Messaging automation value must be a plain object."); + const present = value !== null && typeof value === "object" && Object.hasOwn(value, "includeGroups"); + const r = automationRecord(value, present ? [...keys, "includeGroups"] : keys); + if (present && typeof r.includeGroups !== "boolean") throw new Error("Messaging group selection must be boolean."); + return { ...r, includeGroups: present ? r.includeGroups as boolean : false }; +} export function parseAutomationIdentity(value: unknown): AutomationIdentity { const r = automationRecord(value, ["provider", "authId", "accountIdentity", "accountSubject", "implementationIdentity", "sourceGeneration"]); if (r.provider !== "imessage" && r.provider !== "whatsapp" && r.provider !== "beeper") throw new Error("Messaging provider is invalid."); diff --git a/src/messaging-automation.test.ts b/src/messaging-automation.test.ts index b47c5a56..62a9b117 100644 --- a/src/messaging-automation.test.ts +++ b/src/messaging-automation.test.ts @@ -54,6 +54,22 @@ test("a grant permits one exact ordered dispatch and exact replay never sends ag const run = await f.host.submit({ planId: plan.id, grantId: grant.id }); expect(run.state).toBe("accepted"); expect(run.accepted).toEqual([{ messageId: "sent:1", providerReceiptId: null }]); expect(await f.host.submit({ planId: plan.id, grantId: grant.id })).toEqual(run); expect(f.calls).toHaveLength(1); }); +test("group-capable hosts preserve the protocol-v1 individual enrollment digest", async () => { + const f = fixture(); const { enrollment } = await prepared(f); + expect(enrollment.bindingDigest).toBe("97b06132f3eba278447cf05b3ea7809e023a9f940c3f64a1e685e39d7f5283f6"); +}); +test("upgrading a direct-only journal preserves existing enrollment, history and grants", async () => { + const f = fixture(); const { enrollment, grant } = await prepared(f); await f.host.close(); + const database = new Database(join(f.environment.GHOSTGET_STATE_HOME, "messaging", "automation", "host.sqlite")); + try { database.exec("ALTER TABLE enrollments DROP COLUMN history_floor; DROP TABLE group_history_exclusions; PRAGMA user_version=2;"); } + finally { database.close(); } + const reopened = new MessagingAutomationHost([f.provider], f.environment, () => Date.parse(at)); + try { + expect(reopened.enrollments()).toEqual([enrollment]); + expect(reopened.grantStatus(grant.id)).toEqual(grant); + expect(reopened.history({ enrollmentId: enrollment.id, limit: 200 }).messages.map(message => message.id)).toEqual(["history:1"]); + } finally { await reopened.close(); } +}); test("a dispatch inspects the provider once and still refuses a route whose identity moved after that inspection", async () => { const f = fixture(); const { plan, grant, enrollment } = await prepared(f); let inspections = 0; const inspect = f.provider.inspect; @@ -147,7 +163,7 @@ test("foreign coordinates and unsupported fields fail before state or provider m const f = fixture(); const { enrollment } = await prepared(f); f.add(message("wrong:1", { provider: "whatsapp", conversationJid: "15559876543@s.whatsapp.net" })); await expect(f.host.poll(enrollment.id)).rejects.toThrow("another conversation"); expect(f.host.enrollments()[0]?.revision).toBe(0); expect(() => parseAutomationAction({ kind: "text", text: "hello", shell: "no" })).toThrow(); - expect(() => parseAutomationCoordinate({ provider: "whatsapp", conversationJid: "12345@g.us" })).toThrow(); + expect(() => parseAutomationCoordinate({ provider: "whatsapp", conversationJid: "012345@g.us" })).toThrow(); }); test("closed action parsers reject arbitrary additional properties", () => { assertProperty(fc.property(fc.string().filter(key => key !== "kind" && key !== "text" && key !== "__proto__"), fc.jsonValue(), (key, value) => { diff --git a/src/messaging-automation.ts b/src/messaging-automation.ts index 77ecea5f..c8ac6516 100644 --- a/src/messaging-automation.ts +++ b/src/messaging-automation.ts @@ -9,12 +9,13 @@ import { sha256, } from "./canonical-json"; import { ensurePrivateStateDirectory, ghostgetStateHome, snapshotPrivateStateDirectory } from "./storage"; -import { AUTOMATION_ACTION_KINDS, automationArray, automationDate, automationDigest, automationId, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationActionKind, parseAutomationCoordinate, parseAutomationIdentity, parseAutomationMessage, automationInstant } from "./messaging-automation-validation"; +import { AUTOMATION_ACTION_KINDS, AUTOMATION_BINDING_CHANGED_REASON, automationGroupOptions, automationArray, automationDate, automationDigest, automationId, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationActionKind, parseAutomationCoordinate, parseAutomationConversation as conversation, parseAutomationIdentity, parseAutomationMessage, automationInstant } from "./messaging-automation-validation"; import type { AutomationConversation, AutomationEnrollment, AutomationEvent, AutomationGrant, AutomationGrantRequest, AutomationIdentity, AutomationPlan, AutomationPlanRequest, AutomationPollResult, AutomationProviderPage, AutomationProviderSendResult, AutomationProviderStatus, AutomationRun, MessagingAutomationProvider } from "./messaging-automation-types"; +import { AutomationGroupBindingChangedError } from "./messaging-automation-types"; export * from "./messaging-automation-types"; type Environment = Readonly>; -type StoredEnrollment = { id: string; data: string; cursor: string; revision: number; ready: number; baselining: number; gap: number; reason: string | null }; +type StoredEnrollment = { id: string; data: string; cursor: string; revision: number; ready: number; baselining: number; gap: number; reason: string | null; history_floor: string | null }; type StoredGrant = { id: string; data: string; revoked: number; consumed: number; last_dispatch: number }; type StoredRun = { id: string; plan_id: string; intent_id: string; enrollment_id: string; state: AutomationRun["state"]; accepted: string; total: number; reason: string | null }; const RUN_COLUMNS = "id,plan_id,intent_id,enrollment_id,state,accepted,total,reason"; @@ -22,7 +23,7 @@ const same = (a: unknown, b: unknown) => canonicalJson(a) === canonicalJson(b); const authority = (identity: AutomationIdentity, selected: AutomationConversation) => ({ identity, conversation: { ...selected, title: null } }); const sameConversation = (a: AutomationConversation, b: AutomationConversation) => same({ ...a, title: null }, { ...b, title: null }); const stopped = (signal?: AbortSignal) => { if (signal?.aborted) throw new Error("Messaging automation operation was cancelled."); }; -const CAPACITY = Object.freeze({ enrollments: 1000, grants: 10000, plans: 20000, runs: 20000, messages: 50000, events: 50000 }); +const CAPACITY = Object.freeze({ enrollments: 1000, grants: 10000, plans: 20000, runs: 20000, messages: 50000, events: 50000, group_history_exclusions: 50000 }); function grantData(value: unknown): AutomationGrantRequest { const r = automationRecord(value, ["enrollmentId", "expectedBindingDigest", "actions", "expiresAt", "maximumActions", "minimumIntervalMs"]); const actions = automationArray(r.actions, AUTOMATION_ACTION_KINDS.length).map(parseAutomationActionKind); @@ -39,13 +40,6 @@ function planData(value: unknown): AutomationPlan { return Object.freeze({ ...binding, id, digest }); } -function conversation(value: unknown): AutomationConversation { - const r = automationRecord(value, ["coordinate", "title", "kind", "participants"]); - const coordinate = parseAutomationCoordinate(r.coordinate); - const participants = automationArray(r.participants, 2).map(value => automationText(value, 512)); - if (r.kind !== "single" || participants.length < 1 || new Set(participants).size !== participants.length) throw new Error("Messaging automation requires one verified individual conversation."); - return Object.freeze({ coordinate, title: r.title === null ? null : automationText(r.title, 512), kind: "single", participants: Object.freeze([...participants].sort()) }); -} function checkedPage(value: unknown, expected: AutomationIdentity, coordinate: AutomationConversation["coordinate"]): AutomationProviderPage { const r = automationRecord(value, ["identity", "messages", "nextCursor", "caughtUp", "gap"]); const identity = parseAutomationIdentity(r.identity); @@ -102,14 +96,16 @@ export class MessagingAutomationHost { try { this.db.exec("PRAGMA busy_timeout=250; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA trusted_schema=OFF; PRAGMA secure_delete=ON; PRAGMA max_page_count=65536;"); const version = this.db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version; - if (version !== 0 && version !== 1 && version !== 2) throw new Error("Unsupported messaging journal schema."); + if (version !== 0 && version !== 1 && version !== 2 && version !== 3) throw new Error("Unsupported messaging journal schema."); this.db.exec("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS enrollments (id TEXT PRIMARY KEY,data TEXT NOT NULL,cursor TEXT NOT NULL,revision INTEGER NOT NULL,ready INTEGER NOT NULL,reason TEXT); CREATE TABLE IF NOT EXISTS grants (id TEXT PRIMARY KEY,data TEXT NOT NULL,revoked INTEGER NOT NULL,consumed INTEGER NOT NULL,last_dispatch INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS plans (id TEXT PRIMARY KEY,data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY,plan_id TEXT NOT NULL,intent_id TEXT NOT NULL UNIQUE,enrollment_id TEXT NOT NULL,state TEXT NOT NULL,accepted TEXT NOT NULL,total INTEGER NOT NULL,reason TEXT); CREATE INDEX IF NOT EXISTS runs_contact ON runs(enrollment_id,state); CREATE TABLE IF NOT EXISTS messages (enrollment_id TEXT NOT NULL,event_key TEXT NOT NULL,data TEXT NOT NULL,PRIMARY KEY(enrollment_id,event_key)); CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT,enrollment_id TEXT NOT NULL,revision INTEGER NOT NULL,data TEXT NOT NULL);"); // Upgrading an old cursor establishes a fresh live boundary. It must not // turn its unconsumed historical backlog into new automatic replies. - if (version !== 2) this.db.transaction(() => { + if (version !== 2 && version !== 3) this.db.transaction(() => { this.db.exec("ALTER TABLE enrollments ADD COLUMN baselining INTEGER NOT NULL DEFAULT 1; ALTER TABLE enrollments ADD COLUMN gap INTEGER NOT NULL DEFAULT 0; UPDATE enrollments SET gap=CASE WHEN reason LIKE '%unresolved gap%' THEN 1 ELSE 0 END,ready=0; PRAGMA user_version=2;"); }).immediate(); - else this.db.exec("PRAGMA user_version=2;"); + if (version !== 3) this.db.transaction(() => { + this.db.exec("ALTER TABLE enrollments ADD COLUMN history_floor TEXT; CREATE TABLE group_history_exclusions (enrollment_id TEXT NOT NULL,message_id TEXT NOT NULL,PRIMARY KEY(enrollment_id,message_id)); PRAGMA user_version=3;"); + }).immediate(); this.db.exec("CREATE TABLE IF NOT EXISTS grant_intents (id TEXT PRIMARY KEY,request_digest TEXT NOT NULL,grant_id TEXT NOT NULL UNIQUE REFERENCES grants(id));"); this.db.query("INSERT OR IGNORE INTO metadata(key,value) VALUES('cursor-key',?)").run(randomBytes(32).toString("hex")); this.cursorKey = Buffer.from(automationDigest(this.db.query<{ value: string }, []>("SELECT value FROM metadata WHERE key='cursor-key'").get()?.value), "hex"); @@ -140,10 +136,61 @@ export class MessagingAutomationHost { const identity = parseAutomationIdentity(r.identity); const selected = conversation(r.conversation); const bindingDigest = automationDigest(r.bindingDigest); if (!canonicalJsonSha256Matches(bindingDigest, authority(identity, selected))) throw new Error("Messaging enrollment binding is invalid."); const ready = automationInteger(row.ready, 0, 1); const baselining = automationInteger(row.baselining, 0, 1); const gap = automationInteger(row.gap, 0, 1); - if (ready && (baselining || gap)) throw new Error("Messaging enrollment readiness is inconsistent."); + if (selected.kind === "group") automationInstant(row.history_floor); + else if (row.history_floor !== null) throw new Error("Individual conversation has a group history boundary."); + if (ready && (baselining || gap || row.reason === AUTOMATION_BINDING_CHANGED_REASON)) throw new Error("Messaging enrollment readiness is inconsistent."); return Object.freeze({ id: automationId(row.id), identity, conversation: selected, bindingDigest, revision: automationInteger(row.revision, 0, Number.MAX_SAFE_INTEGER), ready: ready === 1, reason: row.reason === null ? null : automationText(row.reason, 1024) }); } - enrollments(): readonly AutomationEnrollment[] { this.ready(); return this.db.query("SELECT * FROM enrollments ORDER BY id LIMIT 1001").all().map(row => this.enrollment(row)); } + features() { this.ready(); return Object.freeze({ groupConversations: Object.freeze({ version: 1 as const }) }); } + enrollments(input: Readonly<{ includeGroups?: boolean }> = {}): readonly AutomationEnrollment[] { + this.ready(); const { includeGroups } = automationGroupOptions(input, []); + return this.db.query("SELECT * FROM enrollments ORDER BY id LIMIT 1001").all().map(row => this.enrollment(row)).filter(row => includeGroups || row.conversation.kind === "single"); + } + /** Mutation paths only. The immutable digest/history remain on the old ID. */ + private invalidateBinding(id: string): AutomationEnrollment { + this.db.transaction(() => { + this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=?").run(AUTOMATION_BINDING_CHANGED_REASON, id); + this.db.query("UPDATE grants SET revoked=1 WHERE json_extract(data,'$.enrollmentId')=?").run(id); + }).immediate(); + return this.enrollment(this.row(id)); + } + private async observeBinding(provider: MessagingAutomationProvider, enrollment: AutomationEnrollment, signal?: AbortSignal): Promise { + if (enrollment.reason === AUTOMATION_BINDING_CHANGED_REASON) return false; + let route; + try { route = await this.resolve(provider, enrollment.conversation.coordinate, signal); } + catch (error) { + if (error instanceof AutomationGroupBindingChangedError && this.row(enrollment.id).reason === AUTOMATION_BINDING_CHANGED_REASON) return false; + throw error; + } + stopped(signal); + if (route.identity.authId === enrollment.identity.authId) this.observeGroupIdentity(route.identity); + if (!same(parseAutomationIdentity(route.identity), enrollment.identity) || !sameConversation(conversation(route.conversation), enrollment.conversation)) { + this.invalidateBinding(enrollment.id); return false; + } + return true; + } + private observeGroupIdentity(identity: AutomationIdentity): void { + for (const prior of this.enrollments({ includeGroups: true })) { + if (prior.conversation.kind === "group" && prior.identity.provider === identity.provider && prior.identity.authId === identity.authId && !same(prior.identity, identity)) this.invalidateBinding(prior.id); + } + } + private async resolve(provider: MessagingAutomationProvider, coordinate: AutomationConversation["coordinate"], signal?: AbortSignal) { + try { + const resolved = await provider.resolve(coordinate, signal), identity = parseAutomationIdentity(resolved.identity), selected = conversation(resolved.conversation); + if (identity.provider !== provider.provider || !same(selected.coordinate, coordinate)) throw new Error("Messaging route resolution changed scope."); + return { identity, conversation: selected }; + } + catch (error) { + if (error instanceof AutomationGroupBindingChangedError) { + const identity = parseAutomationIdentity(error.identity), observed = parseAutomationCoordinate(error.coordinate); + if (identity.provider !== provider.provider || observed.provider !== provider.provider || !same(observed, coordinate)) throw new Error("Group binding invalidation escaped its exact scope."); + for (const prior of this.enrollments({ includeGroups: true })) { + if (prior.conversation.kind === "group" && prior.identity.provider === identity.provider && prior.identity.authId === identity.authId && same(prior.conversation.coordinate, coordinate)) this.invalidateBinding(prior.id); + } + } + throw error; + } + } private async status(provider: MessagingAutomationProvider, signal?: AbortSignal) { stopped(signal); const value = await provider.inspect(signal); stopped(signal); const r = automationRecord(value, ["identity", "connected", "events", "actions"]); const identity = parseAutomationIdentity(r.identity); @@ -155,15 +202,15 @@ export class MessagingAutomationHost { async providerStatus(id: string, signal?: AbortSignal) { this.ready(); return this.status(this.provider(automationId(id)), signal); } - async conversations(raw: Readonly<{ provider: string; limit: number }>, signal?: AbortSignal) { - this.ready(); const r = automationRecord(raw, ["provider", "limit"]); + async conversations(raw: Readonly<{ provider: string; limit: number; includeGroups?: boolean }>, signal?: AbortSignal) { + this.ready(); const r = automationGroupOptions(raw, ["provider", "limit"]); const provider = this.provider(automationId(r.provider)), limit = automationInteger(r.limit, 1, 200); let phase: DiscoveryDiagnosticPhase = "host-status"; let code: DiscoveryDiagnosticCode = "failed"; try { const status = await this.status(provider, signal); phase = "host-response"; - const rawResult = await provider.conversations({ limit }, signal); stopped(signal); + const rawResult = await provider.conversations({ limit, ...(r.includeGroups && provider.groupConversations?.version === 1 ? { includeGroups: true } : {}) }, signal); stopped(signal); code = "schema-invalid"; const result = automationRecord(rawResult, ["identity", "conversations", "complete"]); phase = "host-identity"; @@ -172,7 +219,10 @@ export class MessagingAutomationHost { if (!same(identity, status.identity) || typeof result.complete !== "boolean") throw new Error("Messaging discovery identity changed."); phase = "host-response"; code = "schema-invalid"; - const rows = automationArray(result.conversations, limit).filter(value => automationRecord(value, ["coordinate", "title", "kind", "participants"]).kind === "single").map(conversation); + const rows = automationArray(result.conversations, limit).filter(value => { + const kind = automationRecord(value, ["coordinate", "title", "kind", "participants"]).kind; + return kind === "single" || kind === "group" && r.includeGroups && provider.groupConversations?.version === 1; + }).map(conversation); if (rows.some(row => row.coordinate.provider !== identity.provider) || new Set(rows.map(row => canonicalJson(row.coordinate))).size !== rows.length) throw new Error("Messaging discovery contains conflicting conversations."); return Object.freeze({ identity, conversations: Object.freeze(rows), complete: result.complete }); } catch (error) { @@ -197,6 +247,12 @@ export class MessagingAutomationHost { if (before !== null && after !== null && Date.parse(after) >= Date.parse(before)) throw new Error("Messaging history window is empty."); const enrollment = this.enrollment(this.row(automationId(r.enrollmentId))); if (!enrollment.ready) throw new Error("Messaging enrollment is unavailable."); + if (enrollment.conversation.kind === "group") { + // A dated archive read cannot prove which roster could see old content. + // Group windows are projections of this enrollment's admitted live epoch. + const rows = this.db.query<{ data: string }, [string, string | null, string | null, string | null, string | null, number]>("SELECT data FROM messages WHERE rowid IN (SELECT MAX(rowid) FROM messages WHERE enrollment_id=? GROUP BY json_extract(data,'$.id')) AND (? IS NULL OR json_extract(data,'$.occurredAt')=?) ORDER BY json_extract(data,'$.occurredAt') DESC,rowid DESC LIMIT ?").all(enrollment.id, before, before, after, after, limit); + return Object.freeze({ enrollment, messages: Object.freeze(rows.map(row => parseAutomationMessage(JSON.parse(row.data) as unknown)).reverse()) }); + } const provider = this.provider(enrollment.identity.provider); const status = await this.status(provider, signal); if (!same(status.identity, enrollment.identity)) throw new Error("Messaging identity changed."); const coordinate = enrollment.conversation.coordinate; @@ -206,26 +262,57 @@ export class MessagingAutomationHost { } async enroll(raw: Readonly<{ provider: string; coordinate: unknown }>, signal?: AbortSignal): Promise { this.ready(); const request = automationRecord(raw, ["provider", "coordinate"]); const coordinate = parseAutomationCoordinate(request.coordinate); + const historyFloor = new Date(this.now()).toISOString(); if (request.provider !== coordinate.provider) throw new Error("Messaging provider and coordinate disagree."); const provider = this.provider(coordinate.provider); const status = await this.status(provider, signal); - const resolved = await provider.resolve(coordinate, signal); stopped(signal); - if (!same(parseAutomationIdentity(resolved.identity), status.identity)) throw new Error("Messaging identity changed during enrollment."); + this.observeGroupIdentity(status.identity); + const resolved = await this.resolve(provider, coordinate, signal); stopped(signal); const selected = conversation(resolved.conversation); if (!same(selected.coordinate, coordinate)) throw new Error("Messaging route resolution changed target."); - const history = checkedPage(await provider.history({ coordinate, limit: 200 }, signal), status.identity, coordinate); stopped(signal); + const resolvedIdentity = parseAutomationIdentity(resolved.identity); + if (resolvedIdentity.provider === status.identity.provider && resolvedIdentity.authId === status.identity.authId) this.observeGroupIdentity(resolvedIdentity); + if (!same(resolvedIdentity, status.identity)) throw new Error("Messaging identity changed during enrollment."); + if (selected.kind === "group" && provider.groupConversations?.version !== 1) throw new Error("Verified group conversations are unavailable for this provider."); + // Observed drift survives even when obtaining a replacement baseline fails. + for (const prior of this.enrollments({ includeGroups: true })) { + if (prior.identity.provider === status.identity.provider && prior.identity.authId === status.identity.authId && same(prior.conversation.coordinate, coordinate) + && (prior.conversation.kind === "group" || selected.kind === "group") && (!same(prior.identity, status.identity) || !sameConversation(prior.conversation, selected))) this.invalidateBinding(prior.id); + } + const rawHistory = await provider.history({ coordinate, limit: 200 }, signal); stopped(signal); + const historyIdentity = selected.kind === "group" ? parseAutomationIdentity(rawHistory.identity) : status.identity; + const history = checkedPage(rawHistory, historyIdentity, coordinate); + if (selected.kind === "group") { + if (historyIdentity.provider === status.identity.provider && historyIdentity.authId === status.identity.authId) this.observeGroupIdentity(historyIdentity); + if (!same(historyIdentity, status.identity)) throw new Error("Messaging identity changed during enrollment history."); + } + if (selected.kind === "group") { + const after = await this.resolve(provider, coordinate, signal); stopped(signal); + const afterIdentity = parseAutomationIdentity(after.identity), afterConversation = conversation(after.conversation); + if (!same(afterConversation.coordinate, coordinate)) throw new Error("Messaging route resolution changed target."); + if (!same(afterIdentity, status.identity) || !sameConversation(afterConversation, selected)) { + for (const prior of this.enrollments({ includeGroups: true })) { + if (prior.conversation.kind === "group" && prior.identity.provider === status.identity.provider && prior.identity.authId === status.identity.authId && same(prior.conversation.coordinate, coordinate) + && (!same(prior.identity, afterIdentity) || !sameConversation(prior.conversation, afterConversation))) this.invalidateBinding(prior.id); + } + throw new Error("Messaging binding changed during group enrollment."); + } + } const binding = { identity: status.identity, conversation: selected }; const bindingDigest = sha256(canonicalJson(authority(status.identity, selected))); const id = `enrollment:${randomUUID()}`; this.ready(); this.db.transaction(() => { - this.capacity("enrollments"); this.capacity("messages", history.messages.length); - if (this.enrollments().some(enrollment => same(enrollment.identity, status.identity) && same(enrollment.conversation.coordinate, coordinate))) throw new Error("Conversation is already enrolled."); - this.db.query("INSERT INTO enrollments(id,data,cursor,revision,ready,reason,baselining,gap) VALUES(?,?,?,0,?,?,?,?)").run(id, canonicalJson({ ...binding, bindingDigest }), history.nextCursor, !history.gap && history.caughtUp ? 1 : 0, history.gap ? "Provider history has an unresolved gap." : history.caughtUp ? null : "Initial history catchup is incomplete.", history.caughtUp ? 0 : 1, history.gap ? 1 : 0); - for (const message of history.messages) this.db.query("INSERT OR IGNORE INTO messages VALUES(?,?,?)").run(id, sha256(canonicalJson(message)), canonicalJson(message)); + this.capacity("enrollments"); this.capacity("messages", selected.kind === "group" ? 0 : history.messages.length); + const previous = this.enrollments({ includeGroups: true }).filter(enrollment => enrollment.identity.provider === status.identity.provider && enrollment.identity.authId === status.identity.authId && same(enrollment.conversation.coordinate, coordinate)); + if (previous.some(enrollment => enrollment.reason !== AUTOMATION_BINDING_CHANGED_REASON && same(enrollment.identity, status.identity) && sameConversation(enrollment.conversation, selected))) throw new Error("Conversation is already enrolled."); + for (const enrollment of previous) if (enrollment.conversation.kind === "group" || selected.kind === "group") this.invalidateBinding(enrollment.id); + this.db.query("INSERT INTO enrollments(id,data,cursor,revision,ready,reason,baselining,gap,history_floor) VALUES(?,?,?,0,?,?,?,?,?)").run(id, canonicalJson({ ...binding, bindingDigest }), history.nextCursor, !history.gap && history.caughtUp ? 1 : 0, history.gap ? "Provider history has an unresolved gap." : history.caughtUp ? null : "Initial history catchup is incomplete.", history.caughtUp ? 0 : 1, history.gap ? 1 : 0, selected.kind === "group" ? historyFloor : null); + if (selected.kind === "group") for (const message of history.messages) this.excludeHistory(id, message.id); + if (selected.kind !== "group") for (const message of history.messages) this.db.query("INSERT OR IGNORE INTO messages VALUES(?,?,?)").run(id, sha256(canonicalJson(message)), canonicalJson(message)); }).immediate(); this.checkFiles(); return this.enrollment(this.row(id)); } grant(raw: AutomationGrantRequest, intent?: string): AutomationGrant { this.ready(); const r = automationRecord(raw, ["enrollmentId", "expectedBindingDigest", "actions", "expiresAt", "maximumActions", "minimumIntervalMs"]); const enrollment = this.enrollment(this.row(automationId(r.enrollmentId))); const expectedBindingDigest = automationDigest(r.expectedBindingDigest); - if (enrollment.bindingDigest !== expectedBindingDigest) throw new Error("Messaging enrollment changed before grant issuance."); + if (enrollment.bindingDigest !== expectedBindingDigest || enrollment.reason === AUTOMATION_BINDING_CHANGED_REASON) throw new Error("Messaging enrollment changed before grant issuance."); const actions = automationArray(r.actions, AUTOMATION_ACTION_KINDS.length).map(parseAutomationActionKind); if (actions.length === 0 || new Set(actions).size !== actions.length) throw new Error("Messaging grant action list is invalid."); const expiresAt = automationDate(r.expiresAt); @@ -289,14 +376,28 @@ export class MessagingAutomationHost { return { page: { identity, messages: page.messages, nextCursor: page.nextCursor, caughtUp: page.caughtUp, gap: page.gap } }; }); } + private excludeHistory(enrollmentId: string, messageId: string): void { + if (this.db.query("SELECT 1 FROM group_history_exclusions WHERE enrollment_id=? AND message_id=?").get(enrollmentId, messageId) !== null) return; + this.capacity("group_history_exclusions"); + this.db.query("INSERT INTO group_history_exclusions VALUES(?,?)").run(enrollmentId, messageId); + } private ingest(enrollmentId: string, initial: StoredEnrollment, page: AutomationProviderPage): AutomationEnrollment { this.db.transaction(() => { - const current = this.row(enrollmentId); if (current.cursor !== initial.cursor || current.revision !== initial.revision || current.baselining !== initial.baselining || current.gap !== initial.gap || this.activeRun(enrollmentId)) throw new Error("Messaging poll lost its concurrent cursor claim."); + const current = this.row(enrollmentId); if (current.reason === AUTOMATION_BINDING_CHANGED_REASON || current.cursor !== initial.cursor || current.revision !== initial.revision || current.baselining !== initial.baselining || current.gap !== initial.gap || this.activeRun(enrollmentId)) throw new Error("Messaging poll lost its concurrent cursor claim."); + const group = this.enrollment(current).conversation.kind === "group"; this.capacity("messages", page.messages.length); if (!initial.baselining) this.capacity("events", page.messages.length); let revision = initial.revision; for (const [index, message] of page.messages.entries()) { + if (group) { + if (initial.baselining === 1 || Date.parse(message.occurredAt) < Date.parse(current.history_floor!) || Date.parse(message.occurredAt) > this.now()) { + this.excludeHistory(enrollmentId, message.id); continue; + } + if (this.db.query("SELECT 1 FROM group_history_exclusions WHERE enrollment_id=? AND message_id=?").get(enrollmentId, message.id) !== null) continue; + } const data = canonicalJson(message); const previous = this.db.query<{ data: string }, [string, string]>("SELECT data FROM messages WHERE enrollment_id=? AND json_extract(data,'$.id')=? ORDER BY rowid DESC LIMIT 1").get(enrollmentId, message.id); + // Mutations of a message predating this epoch cannot import its body. + if (group && message.kind !== "message" && previous === null && (message.kind !== "reaction" || message.relatedMessageId === null || this.db.query("SELECT 1 FROM messages WHERE enrollment_id=? AND json_extract(data,'$.id')=? LIMIT 1").get(enrollmentId, message.relatedMessageId) === null)) continue; if (previous?.data === data) continue; // A→B→A is another change, not a lifetime duplicate. The provider cursor // and page position identify this observation while latest state dedups @@ -331,6 +432,7 @@ export class MessagingAutomationHost { try { initial = this.row(id); } catch { results.set(id, { enrollmentId: id, enrollment: null, error: "Messaging enrollment does not exist." }); continue; } try { const enrollment = this.enrollment(initial); + if (enrollment.reason === AUTOMATION_BINDING_CHANGED_REASON) { results.set(id, { enrollmentId: id, enrollment, error: null }); continue; } if (this.activeRun(id)) { results.set(id, { enrollmentId: id, enrollment, error: null }); continue; } let group = groups.get(enrollment.identity.provider); if (group === undefined) groups.set(enrollment.identity.provider, group = { provider: this.provider(enrollment.identity.provider), items: [] }); @@ -341,7 +443,7 @@ export class MessagingAutomationHost { } for (const group of groups.values()) { let status: AutomationProviderStatus; - try { status = await this.status(group.provider, signal); observed?.set(group.provider, status); } + try { status = await this.status(group.provider, signal); this.observeGroupIdentity(status.identity); observed?.set(group.provider, status); } catch (error) { const message = error instanceof Error ? error.message : "Messaging provider status is unavailable."; for (const item of group.items) results.set(item.enrollment.id, { enrollmentId: item.enrollment.id, enrollment: null, error: message }); @@ -351,11 +453,21 @@ export class MessagingAutomationHost { for (const item of group.items) { const id = item.enrollment.id; if (!same(status.identity, item.enrollment.identity)) { - this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=?").run("Provider identity changed; enroll the conversation again.", id); + if (item.enrollment.conversation.kind === "group") this.invalidateBinding(id); + else this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=? AND (reason IS NULL OR reason<>?)").run("Provider identity changed; enroll the conversation again.", id, AUTOMATION_BINDING_CHANGED_REASON); results.set(id, { enrollmentId: id, enrollment: this.enrollment(this.row(id)), error: "Messaging provider identity changed." }); } else if (!status.connected || !status.events.available) { - this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=?").run(status.events.reason ?? "Provider events are unavailable.", id); + this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=? AND (reason IS NULL OR reason<>?)").run(status.events.reason ?? "Provider events are unavailable.", id, AUTOMATION_BINDING_CHANGED_REASON); results.set(id, { enrollmentId: id, enrollment: this.enrollment(this.row(id)), error: null }); + } else if (item.enrollment.conversation.kind === "group") { + try { + if (group.provider.groupConversations?.version !== 1) throw new Error("Verified group events are unavailable for this provider."); + if (await this.observeBinding(group.provider, item.enrollment, signal)) live.push(item); + else results.set(id, { enrollmentId: id, enrollment: this.enrollment(this.row(id)), error: null }); + } catch (error) { + this.db.query("UPDATE enrollments SET ready=0 WHERE id=?").run(id); + results.set(id, { enrollmentId: id, enrollment: this.tryEnrollment(id), error: error instanceof Error ? error.message : "Group binding could not be verified." }); + } } else live.push(item); } if (live.length === 0) continue; @@ -376,9 +488,19 @@ export class MessagingAutomationHost { const id = item.enrollment.id; const entry = pages[index]!; if ("error" in entry) { results.set(id, { enrollmentId: id, enrollment: this.tryEnrollment(id), error: entry.error }); continue; } try { - const page = checkedPage(entry.page, item.enrollment.identity, item.enrollment.conversation.coordinate); + const pageRecord = automationRecord(entry.page, ["identity", "messages", "nextCursor", "caughtUp", "gap"]); + const pageIdentity = item.enrollment.conversation.kind === "group" ? parseAutomationIdentity(pageRecord.identity) : item.enrollment.identity; + const page = checkedPage(entry.page, pageIdentity, item.enrollment.conversation.coordinate); + if (item.enrollment.conversation.kind === "group") { + if (pageIdentity.provider === item.enrollment.identity.provider && pageIdentity.authId === item.enrollment.identity.authId) this.observeGroupIdentity(pageIdentity); + if (!same(pageIdentity, item.enrollment.identity)) throw new Error("Messaging provider identity changed while polling."); + } + if (item.enrollment.conversation.kind === "group" && !await this.observeBinding(group.provider, item.enrollment, signal)) { + results.set(id, { enrollmentId: id, enrollment: this.enrollment(this.row(id)), error: null }); continue; + } results.set(id, { enrollmentId: id, enrollment: this.ingest(id, item.initial, page), error: null }); } catch (error) { + if (item.enrollment.conversation.kind === "group") this.db.query("UPDATE enrollments SET ready=0 WHERE id=?").run(id); results.set(id, { enrollmentId: id, enrollment: this.tryEnrollment(id), error: error instanceof Error ? error.message : "Messaging poll failed." }); } } @@ -462,8 +584,12 @@ export class MessagingAutomationHost { if (polled.enrollment === null) throw new Error("Messaging enrollment is unavailable."); const enrollment = polled.enrollment; const provider = this.provider(enrollment.identity.provider); const status = observed.get(provider) ?? await this.status(provider, signal); - const route = await provider.resolve(enrollment.conversation.coordinate, signal); stopped(signal); - if (!same(parseAutomationIdentity(route.identity), enrollment.identity) || !sameConversation(conversation(route.conversation), enrollment.conversation) || !same(status.identity, enrollment.identity) || !status.connected) throw new Error("Messaging route or identity changed before dispatch."); + const route = await this.resolve(provider, enrollment.conversation.coordinate, signal); stopped(signal); + if (enrollment.conversation.kind === "group" && route.identity.authId === enrollment.identity.authId) this.observeGroupIdentity(route.identity); + if (!same(parseAutomationIdentity(route.identity), enrollment.identity) || !sameConversation(conversation(route.conversation), enrollment.conversation) || !same(status.identity, enrollment.identity) || !status.connected) { + if (enrollment.conversation.kind === "group") this.invalidateBinding(enrollment.id); + throw new Error("Messaging route or identity changed before dispatch."); + } for (const action of plan.actions) if (!status.actions[parseAutomationAction(action).kind].available) throw new Error("Messaging action is unavailable for this provider."); const runId = `run:${randomUUID()}`; this.ready(); this.db.transaction(() => { @@ -493,10 +619,15 @@ export class MessagingAutomationHost { const grant = grantRow === null ? null : grantData(JSON.parse(grantRow.data) as unknown); const current = this.enrollment(this.row(plan.enrollmentId)); if (signal.aborted || grantRow?.revoked !== 0 || grant === null || Date.parse(grant.expiresAt) <= this.now() || Date.parse(plan.expiresAt) <= this.now() + || !current.ready || current.reason === AUTOMATION_BINDING_CHANGED_REASON || grant.enrollmentId !== current.id || grant.expectedBindingDigest !== current.bindingDigest || current.bindingDigest !== plan.bindingDigest || !grant.actions.includes(action.kind)) { state = accepted.length ? "partial" : "failed"; reason = "Dispatch permission expired or changed before the next action."; break; } - const result = checkedResult(await provider.send({ identity: enrollment.identity, coordinate: enrollment.conversation.coordinate, action, intentId: `${plan.intentId}:${index}` }, signal)); + if (enrollment.conversation.kind === "group" && !await this.observeBinding(provider, enrollment, signal)) { + state = accepted.length ? "partial" : "failed"; reason = AUTOMATION_BINDING_CHANGED_REASON; break; + } + const result = checkedResult(await provider.send({ identity: enrollment.identity, coordinate: enrollment.conversation.coordinate, ...(enrollment.conversation.kind === "group" ? { conversation: enrollment.conversation } : {}), action, intentId: `${plan.intentId}:${index}` }, signal)); + if (result.state === "not-started" && result.reason === AUTOMATION_BINDING_CHANGED_REASON) this.invalidateBinding(enrollment.id); if (result.state !== "accepted") { state = result.state === "indeterminate" ? "indeterminate" : accepted.length ? "partial" : "failed"; reason = result.reason; break; } accepted.push({ messageId: result.messageId, providerReceiptId: result.providerReceiptId }); this.checkFiles(); if (this.db.query("UPDATE runs SET accepted=? WHERE id=? AND state='started'").run(canonicalJson(accepted), runId).changes !== 1) throw new Error("Messaging result journal lost its claim."); diff --git a/src/plugins/whatsapp-linked-device/vendor/0002-ghostget-private-groups.patch b/src/plugins/whatsapp-linked-device/vendor/0002-ghostget-private-groups.patch new file mode 100644 index 00000000..3ee16d59 --- /dev/null +++ b/src/plugins/whatsapp-linked-device/vendor/0002-ghostget-private-groups.patch @@ -0,0 +1,537 @@ +From e190e1d464aff7ea1a16b87d80146e6466b7be49 Mon Sep 17 00:00:00 2001 +From: 0thernet <894119+0thernet@users.noreply.github.com> +Date: Thu, 1 Oct 2026 00:51:42 -0400 +Subject: [PATCH] feat: bind private group sends to authoritative participants + +--- + cmd/wacli/ghostget_private_test.go | 121 ++++++++++++++++++ + cmd/wacli/ghostget_private_unix.go | 167 ++++++++++++++++++++----- + internal/wa/client.go | 15 +++ + internal/wa/private_send_guard.go | 20 +++ + internal/wa/private_send_guard_test.go | 27 ++++ + 5 files changed, 321 insertions(+), 29 deletions(-) + create mode 100644 internal/wa/private_send_guard.go + create mode 100644 internal/wa/private_send_guard_test.go + +diff --git a/cmd/wacli/ghostget_private_test.go b/cmd/wacli/ghostget_private_test.go +index 49dff10..61b4b98 100644 +--- a/cmd/wacli/ghostget_private_test.go ++++ b/cmd/wacli/ghostget_private_test.go +@@ -14,7 +14,9 @@ import ( + "testing" + "time" + ++ "github.com/openclaw/wacli/internal/wa" + "go.mau.fi/whatsmeow" ++ "go.mau.fi/whatsmeow/types" + ) + + func privateTestDirectory(t *testing.T) string { +@@ -236,3 +238,122 @@ func TestGhostgetPrivateExternalSocketIsOwnedAndNeverReplaced(t *testing.T) { + t.Fatal("owned external socket remains") + } + } ++ ++func TestGhostgetPrivateGroupRosterProof(t *testing.T) { ++ target, _ := types.ParseJID("120363000000000000@g.us") ++ a, _ := types.ParseJID("15550000001@s.whatsapp.net") ++ b, _ := types.ParseJID("15550000002@s.whatsapp.net") ++ full := types.GroupInfo{JID: target, ParticipantCount: 2, Participants: []types.GroupParticipant{{JID: b}, {JID: a}}} ++ got, err := ghostgetGroupParticipants(&full, target.String()) ++ if err != nil || len(got) != 2 || got[0] != a.String() || got[1] != b.String() { ++ t.Fatal(got, err) ++ } ++ for _, change := range []func(*types.GroupInfo){ ++ func(g *types.GroupInfo) { g.ParticipantCount = 3 }, func(g *types.GroupInfo) { g.ParticipantCount = 0 }, ++ func(g *types.GroupInfo) { g.IsIncognito = true }, func(g *types.GroupInfo) { g.IsParent = true }, ++ func(g *types.GroupInfo) { g.Participants[1].JID = b }, func(g *types.GroupInfo) { g.Participants[0].Error = 403 }, ++ func(g *types.GroupInfo) { g.Participants[0].DisplayName = "anonymous" }, func(g *types.GroupInfo) { g.JID = a }, ++ } { ++ changed := full ++ changed.Participants = append([]types.GroupParticipant(nil), full.Participants...) ++ change(&changed) ++ if _, err := ghostgetGroupParticipants(&changed, target.String()); err == nil { ++ t.Fatal("partial or anonymous group admitted") ++ } ++ } ++} ++ ++func TestGhostgetPrivateGroupFinalEffectAndReceipt(t *testing.T) { ++ directory := privateTestDirectory(t) ++ request := privateTestRequest() ++ request.To = "120363000000000000@g.us" ++ request.ExpectedParticipants = []string{"15550000001@s.whatsapp.net", "15550000002@s.whatsapp.net"} ++ roster := append([]string(nil), request.ExpectedParticipants...) ++ effects := 0 ++ unavailable := false ++ changeDuringPreparation := false ++ runtime := ghostgetPrivateRuntime{account: func() string { return request.Account }, connected: func() bool { return true }, ++ groupInfo: func(context.Context, string) ([]string, error) { ++ if unavailable { ++ return nil, errors.New("offline") ++ } ++ return append([]string(nil), roster...), nil ++ }, ++ dispatch: func(ctx context.Context, r ghostgetPrivateRequest) (sendDelegateResponse, error) { ++ if changeDuringPreparation { ++ roster = append(roster, "15550000003@s.whatsapp.net") ++ } ++ target, _ := types.ParseJID(r.To) ++ if err := wa.CheckPrivateSendGuard(ctx, target); err != nil { ++ return sendDelegateResponse{}, err ++ } ++ effects++ ++ return sendDelegateResponse{OK: true, Sent: true, To: r.To, ID: "synthetic-group-send"}, nil ++ }, ++ } ++ response := executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request) ++ if response.State != "accepted" || effects != 1 { ++ t.Fatal(response, effects) ++ } ++ roster = append(roster, "15550000003@s.whatsapp.net") ++ // An actual earlier acceptance remains authoritative after membership changes. ++ response = executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request) ++ if response.State != "accepted" || effects != 1 { ++ t.Fatal("accepted replay changed", response, effects) ++ } ++ request.RequestID = strings.Repeat("c", 64) ++ response = executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request) ++ if response.State != "not-started" || !response.BindingChanged || effects != 1 { ++ t.Fatal("changed roster sent", response, effects) ++ } ++ roster = append([]string(nil), request.ExpectedParticipants...) ++ response = executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request) ++ if response.State != "not-started" || !response.BindingChanged || effects != 1 { ++ t.Fatal("failed old intent revived", response, effects) ++ } ++ request.RequestID = strings.Repeat("d", 64) ++ changeDuringPreparation = true ++ response = executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request) ++ if response.State != "not-started" || !response.BindingChanged || effects != 1 { ++ t.Fatal("late membership change sent", response, effects) ++ } ++ request.RequestID = strings.Repeat("e", 64) ++ unavailable = true ++ response = executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request) ++ if response.State != "not-started" || response.BindingChanged || effects != 1 { ++ t.Fatal("missing proof declared permanent change", response, effects) ++ } ++} ++ ++func TestGhostgetPrivateGroupInfoDoesNotClaimOrDispatch(t *testing.T) { ++ directory := privateTestDirectory(t) ++ request := privateTestRequest() ++ request.Kind = "group-info" ++ request.To = "120363000000000000@g.us" ++ request.Message = "" ++ roster := []string{"15550000001@s.whatsapp.net", "15550000002@s.whatsapp.net"} ++ runtime := ghostgetPrivateRuntime{account: func() string { return request.Account }, connected: func() bool { return true }, ++ groupInfo: func(context.Context, string) ([]string, error) { return roster, nil }, ++ dispatch: func(context.Context, ghostgetPrivateRequest) (sendDelegateResponse, error) { ++ t.Fatal("read dispatched") ++ return sendDelegateResponse{}, nil ++ }, ++ } ++ response := executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request) ++ if response.State != "ready" || len(response.Participants) != 2 { ++ t.Fatal(response) ++ } ++ entries, err := os.ReadDir(directory) ++ if err != nil || len(entries) != 0 { ++ t.Fatal("group read created dispatch state", entries, err) ++ } ++ raw, _ := json.Marshal(request) ++ if _, err := decodeGhostgetPrivateRequest(raw); err != nil { ++ t.Fatal(err) ++ } ++ request.ExpectedParticipants = roster ++ raw, _ = json.Marshal(request) ++ if _, err := decodeGhostgetPrivateRequest(raw); err == nil { ++ t.Fatal("read accepted send-only roster") ++ } ++} +diff --git a/cmd/wacli/ghostget_private_unix.go b/cmd/wacli/ghostget_private_unix.go +index 16c7eeb..65483be 100644 +--- a/cmd/wacli/ghostget_private_unix.go ++++ b/cmd/wacli/ghostget_private_unix.go +@@ -16,6 +16,7 @@ import ( + "os" + "path/filepath" + "regexp" ++ "slices" + "strconv" + "sync" + "syscall" +@@ -23,6 +24,7 @@ import ( + "unicode/utf8" + + "github.com/openclaw/wacli/internal/app" ++ "github.com/openclaw/wacli/internal/wa" + "go.mau.fi/whatsmeow/types" + ) + +@@ -33,33 +35,40 @@ const ghostgetPrivateMaximum = 1024 * 1024 + var ghostgetRequestID = regexp.MustCompile(`^[a-f0-9]{64}$`) + var ghostgetDirectJID = regexp.MustCompile(`^([1-9][0-9]{4,14}@s\.whatsapp\.net|[1-9][0-9]{4,19}@lid)$`) + ++var ghostgetGroupJID = regexp.MustCompile(`^[1-9][0-9]{4,19}(-[1-9][0-9]{0,19})?@g\.us$`) ++var errGhostgetGroupChanged = errors.New("the enrolled group membership changed") ++var errGhostgetGroupUnavailable = errors.New("the current group roster is unavailable") ++ + type ghostgetPrivateRequest struct { +- Protocol string `json:"protocol"` +- Kind string `json:"kind"` +- RequestID string `json:"requestId"` +- Generation string `json:"generation"` +- Account string `json:"account"` +- To string `json:"to"` +- Message string `json:"message"` +- File string `json:"file"` +- Filename string `json:"filename"` +- MIME string `json:"mime"` +- ID string `json:"id"` +- Reaction string `json:"reaction"` +- Question string `json:"question"` +- Options []string `json:"options"` +- Selectable int `json:"selectable"` ++ Protocol string `json:"protocol"` ++ Kind string `json:"kind"` ++ RequestID string `json:"requestId"` ++ Generation string `json:"generation"` ++ Account string `json:"account"` ++ To string `json:"to"` ++ Message string `json:"message"` ++ File string `json:"file"` ++ Filename string `json:"filename"` ++ MIME string `json:"mime"` ++ ID string `json:"id"` ++ Reaction string `json:"reaction"` ++ Question string `json:"question"` ++ Options []string `json:"options"` ++ Selectable int `json:"selectable"` ++ ExpectedParticipants []string `json:"expectedParticipants,omitempty"` + } + + type ghostgetPrivateResponse struct { +- Protocol string `json:"protocol"` +- RequestID string `json:"requestId"` +- Generation string `json:"generation"` +- Account string `json:"account"` +- State string `json:"state"` +- To string `json:"to"` +- MessageID string `json:"messageId"` +- Connected bool `json:"connected"` ++ Protocol string `json:"protocol"` ++ RequestID string `json:"requestId"` ++ Generation string `json:"generation"` ++ Account string `json:"account"` ++ State string `json:"state"` ++ To string `json:"to"` ++ MessageID string `json:"messageId"` ++ Connected bool `json:"connected"` ++ Participants []string `json:"participants,omitempty"` ++ BindingChanged bool `json:"bindingChanged,omitempty"` + } + + type ghostgetPrivateReceipt struct { +@@ -75,6 +84,7 @@ type ghostgetPrivateRuntime struct { + socketPath string + account func() string + connected func() bool ++ groupInfo func(context.Context, string) ([]string, error) + dispatch func(context.Context, ghostgetPrivateRequest) (sendDelegateResponse, error) + } + +@@ -155,7 +165,7 @@ func decodeGhostgetPrivateRequest(data []byte) (ghostgetPrivateRequest, error) { + if err = decoder.Decode(&request); err != nil { + return request, errors.New("unsupported request schema") + } +- if len(seen) != 15 || request.Protocol != ghostgetPrivateProtocol { ++ if len(seen) != 15 && !(len(seen) == 16 && seen["expectedParticipants"]) || request.Protocol != ghostgetPrivateProtocol || seen["expectedParticipants"] && (!ghostgetGroupJID.MatchString(request.To) || request.Kind == "group-info") { + return request, errors.New("unsupported request protocol") + } + return request, nil +@@ -204,6 +214,17 @@ func startGhostgetPrivateServer(parent context.Context, a *app.App, socketPath s + } + runtime := ghostgetPrivateRuntime{store: a.StoreDir(), account: func() string { return ghostgetPrivateAccount(a) }, connected: a.WA().IsConnected, + socketPath: socketPath, ++ groupInfo: func(ctx context.Context, target string) ([]string, error) { ++ jid, err := types.ParseJID(target) ++ if err != nil || !ghostgetGroupJID.MatchString(target) { ++ return nil, errors.New("invalid group coordinate") ++ } ++ info, err := a.WA().GetGroupInfo(ctx, jid) ++ if err != nil { ++ return nil, err ++ } ++ return ghostgetGroupParticipants(info, target) ++ }, + dispatch: func(ctx context.Context, request ghostgetPrivateRequest) (sendDelegateResponse, error) { + delegated := sendDelegateRequest{Version: 1, Kind: request.Kind, To: request.To, Message: request.Message, NoPreview: true, File: request.File, Filename: request.Filename, MIME: request.MIME, As: "document", ID: request.ID, Reaction: request.Reaction, Question: request.Question, Options: request.Options, Selectable: request.Selectable, PostSendWaitMS: 2000, TimeoutMS: 50000} + if request.Kind == "react" { +@@ -343,9 +364,16 @@ func ghostgetPrivateAccount(a *app.App) string { + } + + func validateGhostgetPrivateSend(request ghostgetPrivateRequest) error { +- if !ghostgetRequestID.MatchString(request.RequestID) || !ghostgetDirectJID.MatchString(request.To) || !ghostgetDirectJID.MatchString(request.Account) || len(request.Message) > 65536 || len(request.Filename) > 255 || len(request.MIME) > 128 || len(request.ID) > 256 || len(request.Reaction) > 64 { ++ if !ghostgetRequestID.MatchString(request.RequestID) || (!ghostgetDirectJID.MatchString(request.To) && !ghostgetGroupJID.MatchString(request.To)) || !ghostgetDirectJID.MatchString(request.Account) || len(request.Message) > 65536 || len(request.Filename) > 255 || len(request.MIME) > 128 || len(request.ID) > 256 || len(request.Reaction) > 64 { + return errors.New("invalid private send request") + } ++ if ghostgetGroupJID.MatchString(request.To) { ++ if !ghostgetValidParticipants(request.ExpectedParticipants) { ++ return errors.New("a complete canonical group roster is required") ++ } ++ } else if request.ExpectedParticipants != nil { ++ return errors.New("group roster is invalid for a direct recipient") ++ } + if request.To == request.Account { + return errors.New("self messaging requires a separate contract") + } +@@ -393,7 +421,7 @@ func validateGhostgetPrivateSend(request ghostgetPrivateRequest) error { + func executeGhostgetPrivate(ctx context.Context, runtime ghostgetPrivateRuntime, directory, generation string, request ghostgetPrivateRequest) ghostgetPrivateResponse { + response := ghostgetPrivateResponse{Protocol: ghostgetPrivateProtocol, RequestID: request.RequestID, Generation: generation, Account: runtime.account(), State: "not-started", To: request.To, Connected: runtime.connected()} + if request.Kind == "status" { +- if request.RequestID == "" && request.Generation == "" && request.Account == "" && request.To == "" && request.Message == "" && request.File == "" && request.Filename == "" && request.MIME == "" && request.ID == "" && request.Reaction == "" && request.Question == "" && len(request.Options) == 0 && request.Selectable == 0 { ++ if request.RequestID == "" && request.Generation == "" && request.Account == "" && request.To == "" && request.Message == "" && request.File == "" && request.Filename == "" && request.MIME == "" && request.ID == "" && request.Reaction == "" && request.Question == "" && len(request.Options) == 0 && request.Selectable == 0 && request.ExpectedParticipants == nil { + response.State = "ready" + } + return response +@@ -401,6 +429,18 @@ func executeGhostgetPrivate(ctx context.Context, runtime ghostgetPrivateRuntime, + if request.Generation != generation || request.Account != response.Account || ctx.Err() != nil || !response.Connected { + return response + } ++ if request.Kind == "group-info" { ++ if !ghostgetRequestID.MatchString(request.RequestID) || !ghostgetGroupJID.MatchString(request.To) || request.Message != "" || request.File != "" || request.Filename != "" || request.MIME != "" || request.ID != "" || request.Reaction != "" || request.Question != "" || len(request.Options) != 0 || request.Selectable != 0 || request.ExpectedParticipants != nil || runtime.groupInfo == nil { ++ return response ++ } ++ participants, err := runtime.groupInfo(ctx, request.To) ++ if err != nil || ctx.Err() != nil || runtime.account() != request.Account || !runtime.connected() || !ghostgetValidParticipants(participants) { ++ return response ++ } ++ response.State = "ready" ++ response.Participants = participants ++ return response ++ } + if validateGhostgetPrivateSend(request) != nil { + return response + } +@@ -422,7 +462,7 @@ func executeGhostgetPrivate(ctx context.Context, runtime ghostgetPrivateRuntime, + } + response = prior.Response + response.Generation = generation +- if response.State != "accepted" { ++ if response.State != "accepted" && !(response.State == "not-started" && ghostgetGroupJID.MatchString(response.To)) { + response.State = "indeterminate" + } + return response +@@ -453,15 +493,53 @@ func executeGhostgetPrivate(ctx context.Context, runtime ghostgetPrivateRuntime, + return response + } + privateCtx := context.WithValue(ctx, ghostgetPrivateContextKey{}, true) +- result, err := runtime.dispatch(privateCtx, request) ++ var guard func(context.Context, types.JID) error ++ if ghostgetGroupJID.MatchString(request.To) { ++ guard = func(ctx context.Context, target types.JID) error { ++ if target.String() != request.To || runtime.account() != request.Account { ++ return errGhostgetGroupChanged ++ } ++ if runtime.groupInfo == nil || !runtime.connected() { ++ return errGhostgetGroupUnavailable ++ } ++ participants, err := runtime.groupInfo(ctx, request.To) ++ if err != nil { ++ return errors.Join(errGhostgetGroupUnavailable, err) ++ } ++ if !ghostgetValidParticipants(participants) { ++ return errGhostgetGroupUnavailable ++ } ++ if runtime.account() != request.Account || !slices.Equal(participants, request.ExpectedParticipants) { ++ return errGhostgetGroupChanged ++ } ++ if err := ctx.Err(); err != nil { ++ return errors.Join(errGhostgetGroupUnavailable, err) ++ } ++ return nil ++ } ++ privateCtx = wa.WithPrivateSendGuard(privateCtx, guard) ++ } ++ var result sendDelegateResponse ++ if guard != nil { ++ target, _ := types.ParseJID(request.To) ++ err = guard(privateCtx, target) ++ } ++ if err == nil { ++ result, err = runtime.dispatch(privateCtx, request) ++ } + response.State = "indeterminate" + if err == nil && result.OK && result.Sent && result.To == request.To && result.ID != "" { + response.State = "accepted" + response.MessageID = result.ID + } ++ if errors.Is(err, errGhostgetGroupChanged) || errors.Is(err, errGhostgetGroupUnavailable) { ++ response.State = "not-started" ++ response.BindingChanged = errors.Is(err, errGhostgetGroupChanged) ++ } + receipt.Response = response + if err := writeGhostgetPrivateReceipt(path, receipt); err != nil { + response.State = "indeterminate" ++ response.BindingChanged = false + } + return response + } +@@ -483,7 +561,7 @@ func readGhostgetPrivateReceipt(path string) (ghostgetPrivateReceipt, error) { + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() +- if err = decoder.Decode(&receipt); err != nil || receipt.SchemaVersion != 1 || !ghostgetRequestID.MatchString(receipt.RequestDigest) || receipt.Response.Protocol != ghostgetPrivateProtocol || !ghostgetRequestID.MatchString(receipt.Response.RequestID) || !ghostgetRequestID.MatchString(receipt.Response.Generation) || !ghostgetDirectJID.MatchString(receipt.Response.Account) || !ghostgetDirectJID.MatchString(receipt.Response.To) || len(receipt.Response.MessageID) > 256 || (receipt.Response.State != "started" && receipt.Response.State != "accepted" && receipt.Response.State != "indeterminate") || (receipt.Response.State == "accepted" && receipt.Response.MessageID == "") { ++ if err = decoder.Decode(&receipt); err != nil || receipt.SchemaVersion != 1 || !ghostgetRequestID.MatchString(receipt.RequestDigest) || receipt.Response.Protocol != ghostgetPrivateProtocol || !ghostgetRequestID.MatchString(receipt.Response.RequestID) || !ghostgetRequestID.MatchString(receipt.Response.Generation) || !ghostgetDirectJID.MatchString(receipt.Response.Account) || (!ghostgetDirectJID.MatchString(receipt.Response.To) && !ghostgetGroupJID.MatchString(receipt.Response.To)) || len(receipt.Response.MessageID) > 256 || (receipt.Response.State != "started" && receipt.Response.State != "accepted" && receipt.Response.State != "indeterminate" && !(receipt.Response.State == "not-started" && ghostgetGroupJID.MatchString(receipt.Response.To))) || (receipt.Response.State == "accepted" && receipt.Response.MessageID == "") || receipt.Response.Participants != nil || receipt.Response.BindingChanged && receipt.Response.State != "not-started" { + return receipt, errors.New("invalid receipt") + } + var trailing any +@@ -525,3 +603,34 @@ func writeGhostgetPrivateReceipt(path string, receipt ghostgetPrivateReceipt) er + } + return ghostgetPrivateSyncDirectory(filepath.Dir(path)) + } ++ ++// Only GetGroupInfo supplies a roster. A local chat row or a cached participant ++// sample is never enrollment evidence. Recipient aliases are not resolved. ++func ghostgetGroupParticipants(info *types.GroupInfo, target string) ([]string, error) { ++ if info == nil || info.JID.String() != target || !ghostgetGroupJID.MatchString(target) || info.IsParent || info.IsIncognito || info.Suspended || info.ParticipantCount != len(info.Participants) || len(info.Participants) < 1 || len(info.Participants) > 500 { ++ return nil, errors.New("unverified group") ++ } ++ participants := make([]string, 0, len(info.Participants)) ++ for _, participant := range info.Participants { ++ if participant.Error != 0 || participant.AddRequest != nil || participant.DisplayName != "" { ++ return nil, errors.New("unverified group participant") ++ } ++ participants = append(participants, participant.JID.String()) ++ } ++ slices.Sort(participants) ++ if !ghostgetValidParticipants(participants) { ++ return nil, errors.New("incomplete or unsupported group roster") ++ } ++ return participants, nil ++} ++func ghostgetValidParticipants(participants []string) bool { ++ if len(participants) < 1 || len(participants) > 500 { ++ return false ++ } ++ for index, participant := range participants { ++ if !ghostgetDirectJID.MatchString(participant) || index > 0 && participants[index-1] >= participant { ++ return false ++ } ++ } ++ return true ++} +diff --git a/internal/wa/client.go b/internal/wa/client.go +index 715e457..1a6cc31 100644 +--- a/internal/wa/client.go ++++ b/internal/wa/client.go +@@ -285,6 +285,9 @@ func (c *Client) SendText(ctx context.Context, to types.JID, text string) (types + return "", fmt.Errorf("not connected") + } + msg := &waProto.Message{Conversation: &text} ++ if err := CheckPrivateSendGuard(ctx, to); err != nil { ++ return "", err ++ } + resp, err := cli.SendMessage(ctx, to, msg) + if err != nil { + return "", err +@@ -304,12 +307,18 @@ func (c *Client) SendProtoMessageWithExtra(ctx context.Context, to types.JID, ms + return "", fmt.Errorf("not connected") + } + if mediaHandle == "" { ++ if err := CheckPrivateSendGuard(ctx, to); err != nil { ++ return "", err ++ } + resp, err := cli.SendMessage(ctx, to, msg) + if err != nil { + return "", err + } + return resp.ID, nil + } ++ if err := CheckPrivateSendGuard(ctx, to); err != nil { ++ return "", err ++ } + resp, err := cli.SendMessage(ctx, to, msg, whatsmeow.SendRequestExtra{MediaHandle: mediaHandle}) + if err != nil { + return "", err +@@ -335,6 +344,9 @@ func (c *Client) SendPoll(ctx context.Context, to types.JID, name string, option + if ephemeral { + msg = wrapEphemeralPollMessage(msg) + } ++ if err := CheckPrivateSendGuard(ctx, to); err != nil { ++ return "", err ++ } + resp, err := cli.SendMessage(ctx, to, msg) + if err != nil { + return "", err +@@ -525,6 +537,9 @@ func (c *Client) SendReaction(ctx context.Context, chat, sender types.JID, targe + if cli == nil || !cli.IsConnected() { + return "", fmt.Errorf("not connected") + } ++ if err := CheckPrivateSendGuard(ctx, chat); err != nil { ++ return "", err ++ } + resp, err := cli.SendMessage(ctx, chat, cli.BuildReaction(chat, sender, targetID, reaction)) + if err != nil { + return "", err +diff --git a/internal/wa/private_send_guard.go b/internal/wa/private_send_guard.go +new file mode 100644 +index 0000000..0138c5d +--- /dev/null ++++ b/internal/wa/private_send_guard.go +@@ -0,0 +1,20 @@ ++package wa ++ ++import ( ++ "context" ++ "go.mau.fi/whatsmeow/types" ++) ++ ++type privateSendGuardKey struct{} ++ ++// WithPrivateSendGuard adds an exact final-effect check to the supervised path. ++// Ordinary commands carry no guard and retain their existing behavior. ++func WithPrivateSendGuard(ctx context.Context, check func(context.Context, types.JID) error) context.Context { ++ return context.WithValue(ctx, privateSendGuardKey{}, check) ++} ++func CheckPrivateSendGuard(ctx context.Context, target types.JID) error { ++ if check, ok := ctx.Value(privateSendGuardKey{}).(func(context.Context, types.JID) error); ok { ++ return check(ctx, target) ++ } ++ return nil ++} +diff --git a/internal/wa/private_send_guard_test.go b/internal/wa/private_send_guard_test.go +new file mode 100644 +index 0000000..2c49b32 +--- /dev/null ++++ b/internal/wa/private_send_guard_test.go +@@ -0,0 +1,27 @@ ++package wa ++ ++import ( ++ "context" ++ "errors" ++ "go.mau.fi/whatsmeow/types" ++ "testing" ++) ++ ++func TestPrivateSendGuardIsExactAndAbsentForOrdinarySends(t *testing.T) { ++ target, _ := types.ParseJID("120363000000000000@g.us") ++ if err := CheckPrivateSendGuard(context.Background(), target); err != nil { ++ t.Fatal(err) ++ } ++ calls := 0 ++ denied := errors.New("membership changed") ++ ctx := WithPrivateSendGuard(context.Background(), func(_ context.Context, received types.JID) error { ++ calls++ ++ if received != target { ++ t.Fatal("target rewritten") ++ } ++ return denied ++ }) ++ if !errors.Is(CheckPrivateSendGuard(ctx, target), denied) || calls != 1 { ++ t.Fatal("guard skipped") ++ } ++} diff --git a/src/plugins/whatsapp-linked-device/vendor/README.md b/src/plugins/whatsapp-linked-device/vendor/README.md index f31c9371..6942bf1a 100644 --- a/src/plugins/whatsapp-linked-device/vendor/README.md +++ b/src/plugins/whatsapp-linked-device/vendor/README.md @@ -1,13 +1,13 @@ # Reviewed WhatsApp automation transport -This patch applies to `openclaw/wacli` commit +These patches apply in order to `openclaw/wacli` commit `a020de724180d31eccfa5241d45443402d62fb06` (v0.15.0). It is opt-in through `sync --ghostget-private-transport`. Ordinary wacli commands retain their existing behavior. Ghostget does not admit stock auto-retrying sends for its automation contract. The private socket carries fixed-schema JSON, bound to an exact account, -connection generation, direct recipient JID, and durable request ID. A claimed +connection generation, exact direct or group JID, and durable request ID. A claimed request is never sent again; only an exact prior accepted receipt is returned. Connection loss or an unfinished claim remains indeterminate. Acceptance means the provider acknowledged the operation, not that the recipient received it. @@ -18,6 +18,15 @@ reactions and polls use existing pinned wacli operations. The upstream protocol library may retransmit protocol frames with its own message ID. No account, pairing, private-message or live-send qualification is implied by offline tests. +Group discovery is opt-in. The private `group-info` request reads the current +native roster without creating a dispatch claim. Group sends bind the complete, +canonical participant list and check it before preparation and immediately before +the final native send, including after attachment upload. Incomplete or unavailable +roster evidence refuses temporarily; a proven complete mismatch permanently +invalidates that binding. A refused group intent remains refused if the roster +later restores. An exact previously accepted receipt remains authoritative after +roster changes. Direct request fields, digests, and receipt behavior are unchanged. + The private runtime installs SQLite triggers that journal message insert, update and delete projections in the same transaction. A persistent generation and monotonic sequence support resumption; retention is 10,000 events and @@ -35,5 +44,12 @@ private cleanup directory so long managed account paths remain usable. Tests in the patch exercise a real private Unix socket using a synthetic runtime, joined cancellation, one-attempt failure, exact replay, identity and target mismatch, transactional events, cursor expiration and bounded retention. +Group tests cover authoritative rosters, changes during preparation, temporary +missing evidence, durable refusals, and accepted receipt replay after roster drift. Both complete upstream suites (plain and `sqlite_fts5`) and `go vet ./...` -passed. Two builds with identical flags produced identical bytes. +passed, along with documentation tests, Windows lock compilation, and the expected +CGO-disabled build rejection. Two builds with identical flags produced identical +bytes. `provenance.json` records the exact toolchain used for this artifact. +Replay verifies the complete Git source tree as well as each patch hash; the +recorded tip identifies the reviewed checkout without depending on reconstructed +committer metadata. diff --git a/src/plugins/whatsapp-linked-device/vendor/provenance.json b/src/plugins/whatsapp-linked-device/vendor/provenance.json index 6439a444..173dbc20 100644 --- a/src/plugins/whatsapp-linked-device/vendor/provenance.json +++ b/src/plugins/whatsapp-linked-device/vendor/provenance.json @@ -6,29 +6,49 @@ "baseCommit": "a020de724180d31eccfa5241d45443402d62fb06" }, "reviewedPatchStack": { - "tipCommit": "16cf7557a1683eb18e7a6e1c1fde8ca9f05e0850", - "patches": [{ - "file": "0001-ghostget-private-messaging.patch", - "sha256": "44235d0fcd070d895fbe2fa3fa4406b34b8f7c808216fd22a2417c4fc86f3255", - "purpose": "Bounded owner-only IPC, durable one-attempt request claims, exact direct JIDs, joined cancellation and atomic bounded message-change events." - }] + "tipCommit": "e190e1d464aff7ea1a16b87d80146e6466b7be49", + "sourceTree": "2cffa57593a273246d09b3c870d2ab006b76bd5c", + "patches": [ + { + "file": "0001-ghostget-private-messaging.patch", + "sha256": "44235d0fcd070d895fbe2fa3fa4406b34b8f7c808216fd22a2417c4fc86f3255", + "purpose": "Bounded owner-only IPC, durable one-attempt request claims, exact direct JIDs, joined cancellation and atomic bounded message-change events." + }, + { + "file": "0002-ghostget-private-groups.patch", + "sha256": "75e439488fbf4c62d3ae52edc98a5347eab691833c485d506327da18e7eea27b", + "purpose": "Optional complete native group rosters, exact bound group participants, and a final-effect participant check after attachment preparation. Existing direct requests remain unchanged." + } + ] }, "artifact": { "platform": "darwin", "arch": "arm64", - "version": "0.15.0+ghostget-private.1", - "executableSha256": "9b77ffb810d028fde725ca02b1451f1725b5ff5312a46a54468a9a38533d4cea", - "executableBytes": 21963810, + "version": "0.15.0+ghostget-private.2", + "executableSha256": "85a4c2b6f538103df08425f75984657559169a95161a256c6d096daf9de38f47", + "executableBytes": 21980338, "compression": "gzip", - "compressedSha256": "1c1650d6c79b74db7f8f335b4746398c802031260a90468312dcaac0374a5166", - "compressedBytes": 7682949, + "compressedSha256": "61c9aef8d1a2c38f4831e8546fea0ae9907c365a301b8cde7388e47adbb2d697", + "compressedBytes": 7694763, "build": { "go": "go1.25.12 darwin/arm64", "goArchiveSha256": "fa2c88bbcf64bd3b2aef355f026cfec6d3a4a01c132f999c8f8c964eb767164f", - "clang": "Apple clang version 21.0.0 (clang-2100.1.1.101)", - "macosSdk": "26.5", + "clang": "Apple clang version 17.0.0 (clang-1700.6.3.2)", + "macosSdk": "26.2", "moduleGraphChanged": false, - "command": ["go", "build", "-trimpath", "-buildvcs=false", "-tags", "sqlite_fts5", "-ldflags", "-s -w -buildid= -X main.version=0.15.0+ghostget-private.1", "-o", "wacli-private", "./cmd/wacli"], + "command": [ + "go", + "build", + "-trimpath", + "-buildvcs=false", + "-tags", + "sqlite_fts5", + "-ldflags", + "-s -w -buildid= -X main.version=0.15.0+ghostget-private.2", + "-o", + "wacli-private", + "./cmd/wacli" + ], "secondIdenticalBuildVerified": true } }, @@ -40,6 +60,13 @@ "realAccountRead": false, "pairingPerformed": false, "liveSendPerformed": false, - "deliveryQualified": false + "deliveryQualified": false, + "windowsLockCompile": "passed", + "cgoRequired": "passed", + "docsTests": "passed", + "groupRosterAndFinalEffectTests": "passed", + "completeParticipantMismatchInvalidatesBinding": true, + "unavailableParticipantProofIsTemporary": true, + "priorAcceptedReceiptRemainsAuthoritative": true } } diff --git a/src/providers/beeper-automation.test.ts b/src/providers/beeper-automation.test.ts index 0a2603fe..3fd3da97 100644 --- a/src/providers/beeper-automation.test.ts +++ b/src/providers/beeper-automation.test.ts @@ -6,6 +6,8 @@ import { join } from "node:path"; import type { GhostgetAuth } from "../auth"; import type { AutomationAction, AutomationCoordinate } from "../messaging-automation-types"; +import { AutomationGroupBindingChangedError } from "../messaging-automation-types"; +import { AUTOMATION_BINDING_CHANGED_REASON } from "../messaging-automation-validation"; import { createBeeperAutomationProvider, type BeeperAutomationAdmission, type BeeperAutomationOperation } from "./beeper-automation"; import { BEEPER_CLI_PIN } from "./beeper-local"; import { beeperSubjectFromAccountsAndTarget, parseBeeperExportAccounts, type BeeperCliInvocation } from "./beeper-local-runtime"; @@ -74,6 +76,8 @@ function jsonResponse(value: unknown): Response { } interface World { + chatReads: number; + emptyRosterAtRead: number | null; accounts: readonly Record[]; chats: readonly Record[]; /** Ascending sortKey order, like the provider's local history. */ @@ -85,6 +89,7 @@ interface World { function fixture(overrides: Partial = {}) { const world: World = { + chatReads: 0, emptyRosterAtRead: null, accounts: [selfAccount as Record], chats: [targetChat as Record], messages: [], @@ -112,6 +117,11 @@ function fixture(overrides: Partial = {}) { const cliCalls: string[] = []; const httpCalls: string[] = []; const sorted = () => [...world.messages].sort((a, b) => String(a.sortKey).localeCompare(String(b.sortKey))); + const readChat = (id: string | null | undefined) => { + world.chatReads++; + const chat = world.chats.find(value => value.id === id); + return chat?.type === "group" && world.emptyRosterAtRead !== null && world.chatReads >= world.emptyRosterAtRead ? { ...chat, participants: { items: [], total: 0, hasMore: false } } : chat; + }; const fetch = (input: string | URL | Request, init?: RequestInit): Promise => { const url = new URL(String(input)); const route = `${init?.method ?? "GET"} ${url.pathname}`; @@ -121,7 +131,7 @@ function fixture(overrides: Partial = {}) { if (route === "GET /v1/accounts") return Promise.resolve(jsonResponse(world.accounts)); const chatMatch = /^\/v1\/chats\/([^/]+)$/u.exec(url.pathname); if (route.startsWith("GET") && chatMatch !== null) { - const chat = world.chats.find(value => value.id === decodeURIComponent(chatMatch[1]!)); + const chat = readChat(decodeURIComponent(chatMatch[1]!)); return Promise.resolve(chat === undefined ? jsonResponse({ errcode: "M_NOT_FOUND", error: "not found" }) : jsonResponse(chat)); } const messagesMatch = /^\/v1\/chats\/([^/]+)\/messages$/u.exec(url.pathname); @@ -163,7 +173,7 @@ function fixture(overrides: Partial = {}) { if (command === "chats show") { const index = invocation.arguments.indexOf("--chat"); const id = index === -1 ? null : invocation.arguments[index + 1]; - return ok(world.chats.find(chat => chat.id === id)); + return ok(readChat(id)); } return ok(null); }; @@ -202,6 +212,26 @@ function seedMessages(world: World, count: number, start = 1): void { } describe("Beeper automation provider", () => { + test("groups require complete roster evidence and bind the final send to it", async () => { + const group = { ...targetChat, type: "group" }; + const f = fixture({ chats: [group] }); + const bound = await f.provider.resolve(target); + expect(bound.conversation.kind).toBe("group"); + expect(f.provider.groupConversations).toEqual({ version: 1 }); + const request = { identity: bound.identity, coordinate: target, conversation: bound.conversation, action: { kind: "text" as const, text: "Synthetic group text" }, intentId: "group-test" }; + expect((await f.provider.send(request)).state).toBe("accepted"); + f.world.chats = [{ ...group, participants: { hasMore: false, total: 2, items: [{ id: "@self:beeper.test", isSelf: true }, { id: "@replacement:beeper.test", isSelf: false }] } }]; + expect(await f.provider.send(request)).toEqual({ state: "not-started", reason: AUTOMATION_BINDING_CHANGED_REASON }); + expect(f.httpCalls.filter(call => call.startsWith("POST"))).toHaveLength(1); + f.world.chats = [{ ...group, participants: { ...group.participants, hasMore: true, total: 3 } }]; + expect((await f.provider.conversations({ limit: 20 })).conversations).toEqual([]); + await expect(f.provider.resolve(target)).rejects.toThrow("complete"); + const incomplete = await f.provider.send(request); + expect(incomplete.state).toBe("not-started"); + if (incomplete.state !== "accepted") expect(incomplete.reason).not.toBe(AUTOMATION_BINDING_CHANGED_REASON); + expect(f.httpCalls.filter(call => call.startsWith("POST"))).toHaveLength(1); + await f.provider.close(); + }); test("inspection reports the bound realm and text-only capability", async () => { const f = fixture(); const status = await f.provider.inspect(); @@ -222,6 +252,18 @@ describe("Beeper automation provider", () => { expect(status.events.reason).toContain("not reachable"); }); + test("complete-empty Beeper group evidence invalidates binding while incomplete evidence stays transient", async () => { + const group = { ...targetChat, type: "group" }; const f = fixture({ chats: [group] }); + const bound = await f.provider.resolve(target); + f.world.chats = [{ ...group, participants: { items: [], total: 0, hasMore: false } }]; + await expect(f.provider.resolve(target)).rejects.toBeInstanceOf(AutomationGroupBindingChangedError); + f.world.chats = [{ ...group, participants: { items: [], total: 1, hasMore: true } }]; + await expect(f.provider.resolve(target)).rejects.toThrow("unavailable"); + f.world.chats = [group]; f.world.chatReads = 0; f.world.emptyRosterAtRead = 2; + expect(await f.provider.send({ identity: bound.identity, coordinate: target, conversation: bound.conversation, action: { kind: "text", text: "Synthetic" }, intentId: "empty-at-effect" })).toMatchObject({ state: "not-started", reason: AUTOMATION_BINDING_CHANGED_REASON }); + expect(f.world.chatReads).toBeGreaterThanOrEqual(2); + expect(f.httpCalls.filter(call => call.startsWith("POST"))).toHaveLength(0); await f.provider.close(); + }); test("conversations projects exact single coordinates and demotes groups", async () => { const f = fixture({ chats: [ @@ -318,6 +360,7 @@ describe("Beeper automation provider", () => { f.world.messages.push(apiMessage("edited", "002", { edited: "2026-08-31T03:00:00.000Z" })); const history = await f.provider.history({ coordinate: target, limit: 200 }); expect(history.messages.map(value => [value.kind, value.text])).toEqual([["delete", null], ["edit", "body edited"]]); + expect(history.messages.find(value => value.id === "edited")?.occurredAt).toBe(new Date(1_788_000_002_000).toISOString()); }); test("a full fresh window drains older pages until the watermark is proven", async () => { diff --git a/src/providers/beeper-automation.ts b/src/providers/beeper-automation.ts index 0b39f043..f04f9dbf 100644 --- a/src/providers/beeper-automation.ts +++ b/src/providers/beeper-automation.ts @@ -7,7 +7,8 @@ import { import type { LocalCliExecutionOptions } from "../local-cli-execution"; import type { LocalCliRecipe, OperationInput } from "../model"; import type { AutomationAction, AutomationConversation, AutomationIdentity, AutomationMessage, AutomationProviderPage, AutomationProviderStatus, AutomationProviderSendResult, MessagingAutomationProvider } from "../messaging-automation-types"; -import { AUTOMATION_ACTION_KINDS, automationArray, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationIdentity } from "../messaging-automation-validation"; +import { AutomationGroupBindingChangedError } from "../messaging-automation-types"; +import { AUTOMATION_ACTION_KINDS, AUTOMATION_BINDING_CHANGED_REASON, automationArray, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationConversation, parseAutomationIdentity } from "../messaging-automation-validation"; import { OperationDeadline } from "../operation-deadline"; import { executeBeeperDirectMessagingPart, executeBeeperLocalOperation, type BeeperDirectDependencies, type BeeperDirectMessagingDependencies, type BeeperLocalRuntimeDependencies } from "./beeper-local-runtime"; import { materializeBeeperExactConversation, materializeBeeperMessagingList, materializeBeeperMessagingRead, rawBeeperConversationId, rawBeeperMessageId } from "./beeper-omni"; @@ -96,6 +97,16 @@ function conversation(entity: Readonly>): AutomationConv kind, participants: Object.freeze(unique), }); } +/** The materialized generic entity omits completeness metadata. Prove it from + * the strict native projection before it can become group authority. */ +function completeGroup(selected: AutomationConversation, raw: unknown, allowEmpty = false): boolean { + if (selected.kind !== "group") return true; + const row = raw as Readonly>; + const roster = automationRecord(row.participants, ["items", "total", "hasMore"]); + const items = automationArray(roster.items, 500); + return roster.hasMore === false && automationInteger(roster.total, 0, 100_000_000) === items.length + && items.length === selected.participants.length && (allowEmpty || items.length > 0); +} /** One message observation. `sortKey` orders the feed; the materialized entity * carries the reviewed projection fields. */ @@ -193,7 +204,11 @@ export function createBeeperAutomationProvider(options: BeeperAutomationOptions) const pending = previous.then(async () => { if (closed) throw new Error("Beeper automation provider is closed"); const activeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal; - activeSignal.throwIfAborted(); const before = await options.authorize(operation, activeSignal); if (typeof before.auth.subject !== "string" || before.auth.subject.length === 0) throw new Error("Beeper automation requires a bound local account realm"); const result = await work(before, activeSignal); const after = await options.authorize(operation, activeSignal); if (sha(before) !== sha(after)) throw new Error("Beeper account or permission changed during the operation"); return result; + activeSignal.throwIfAborted(); const before = await options.authorize(operation, activeSignal); if (typeof before.auth.subject !== "string" || before.auth.subject.length === 0) throw new Error("Beeper automation requires a bound local account realm"); + let result; + try { result = await work(before, activeSignal); } + catch (error) { if (error instanceof AutomationGroupBindingChangedError && sha(before) !== sha(await options.authorize(operation, activeSignal))) throw new Error("Beeper account or permission changed during the operation"); throw error; } + const after = await options.authorize(operation, activeSignal); if (sha(before) !== sha(after)) throw new Error("Beeper account or permission changed during the operation"); return result; }); inFlight = pending.then(() => undefined, () => undefined); return pending; @@ -289,6 +304,7 @@ export function createBeeperAutomationProvider(options: BeeperAutomationOptions) return { provider: "beeper", + groupConversations: { version: 1 }, inspect(signal) { return run("inspect", signal, async (admission, active) => { try { @@ -310,13 +326,18 @@ export function createBeeperAutomationProvider(options: BeeperAutomationOptions) : undefined; if (typeof completeness !== "object" || completeness === null || typeof (completeness as Readonly>).requestedLimitReached !== "boolean") throw new Error("Beeper conversation list completeness is invalid."); const requestedLimitReached = (completeness as Readonly>).requestedLimitReached === true; + const rawConversations = (output as Readonly>).conversations as readonly unknown[]; + const conversations = materialized.entities.map((entity, index) => { + const selected = conversation(entity as Readonly>); + return completeGroup(selected, rawConversations[index]) ? selected : null; + }).filter((item): item is AutomationConversation => item !== null); return Object.freeze({ identity: snapshotIdentity(admission, snapshot), - conversations: Object.freeze(materialized.entities.map(entity => conversation(entity as Readonly>))), + conversations: Object.freeze(conversations), // The pinned CLI list output has no continuation metadata; an exactly // full remote window cannot prove completeness, even when out-of-realm // rows were excluded from the projection. - complete: !requestedLimitReached, + complete: !requestedLimitReached && conversations.length === materialized.entities.length, }); }); }, @@ -324,10 +345,13 @@ export function createBeeperAutomationProvider(options: BeeperAutomationOptions) const selected = coordinate(value); return run("resolve", signal, async (admission, active) => { const snapshot = await accountsSnapshot(admission, active); - const operationInput = { account_id: selected.accountId, conversation_id: selected.conversationId }; - const entity = materializeBeeperExactConversation(operationInput, await execute(RECIPES.conversation, operationInput, admission.auth, active)); + const operationInput = { account_id: selected.accountId, conversation_id: selected.conversationId, max_participants: 500 }; + const output = await execute(RECIPES.conversation, operationInput, admission.auth, active); + const entity = materializeBeeperExactConversation(operationInput, output); const projected = conversation(entity as Readonly>); if (canonicalJson(projected.coordinate) !== canonicalJson(selected)) throw new Error("Beeper route resolution changed target."); + if (projected.kind === "group" && projected.participants.length === 0 && completeGroup(projected, (output as Readonly>).conversation, true)) throw new AutomationGroupBindingChangedError(snapshotIdentity(admission, snapshot), selected); + if (!completeGroup(projected, (output as Readonly>).conversation)) throw new Error("The complete Beeper group roster is unavailable."); return Object.freeze({ identity: snapshotIdentity(admission, snapshot), conversation: projected }); }); }, @@ -420,11 +444,24 @@ export function createBeeperAutomationProvider(options: BeeperAutomationOptions) async send(input, signal): Promise { const selected = coordinate(input.coordinate), expected = parseAutomationIdentity(input.identity), action = parseAutomationAction(input.action); automationText(input.intentId, 256); - let dispatched = false; + let dispatched = false, bindingChanged = false; try { return await run(action.kind, signal, async (admission, active) => { const observe = async (holder: BeeperAutomationAdmission): Promise => snapshotIdentity(holder, await accountsSnapshot(holder, active)); try { + const bound = input.conversation === undefined ? null : parseAutomationConversation(input.conversation); + const verifyGroup = async (): Promise => { + const operationInput = { account_id: selected.accountId, conversation_id: selected.conversationId, max_participants: 500 }; + const output = await execute(RECIPES.conversation, operationInput, admission.auth, active); + const live = conversation(materializeBeeperExactConversation(operationInput, output) as Readonly>); + if (bound?.kind === "group") { + if (live.kind === "group" && live.participants.length === 0 && completeGroup(live, (output as Readonly>).conversation, true)) { bindingChanged = true; throw new Error("The Beeper group roster is empty"); } + if (!completeGroup(live, (output as Readonly>).conversation)) throw new Error("The complete Beeper group roster is unavailable"); + if (sha({ ...parseAutomationConversation(live), title: null }) !== sha({ ...bound, title: null })) { + bindingChanged = true; throw new Error("The Beeper group binding changed"); + } + } else if (live.kind === "group") throw new Error("Group sends require an exact participant binding"); + }; const current = await observe(admission); if (sha(current) !== sha(expected) || action.kind !== "text" || !status(current, true).actions[action.kind].available) throw new Error("Beeper source identity or capability changed before dispatch"); const operationDeadline = new OperationDeadline(300_000, { signal: active }); @@ -440,6 +477,8 @@ export function createBeeperAutomationProvider(options: BeeperAutomationOptions) const again = await options.authorize("text", active); if (sha(again) !== sha(admission)) throw new Error("Beeper permission changed before writing the action"); if (sha(await observe(again)) !== sha(expected)) throw new Error("Beeper source identity changed before writing the action"); + await verifyGroup(); + if (sha(await options.authorize("text", active)) !== sha(admission)) throw new Error("Beeper permission changed during target revalidation"); dispatched = true; }, }, @@ -449,7 +488,7 @@ export function createBeeperAutomationProvider(options: BeeperAutomationOptions) } catch { return Object.freeze(dispatched ? { state: "indeterminate", reason: "The Beeper receipt or cleanup could not be verified; do not retry." } - : { state: "not-started", reason: "The selected Beeper account, permission, target, or action was unavailable." } satisfies AutomationProviderSendResult); + : { state: "not-started", reason: bindingChanged ? AUTOMATION_BINDING_CHANGED_REASON : "The selected Beeper account, permission, target, or action was unavailable." } satisfies AutomationProviderSendResult); } }); } catch { diff --git a/src/providers/imessage-automation.test.ts b/src/providers/imessage-automation.test.ts index e339b343..b8246497 100644 --- a/src/providers/imessage-automation.test.ts +++ b/src/providers/imessage-automation.test.ts @@ -6,7 +6,8 @@ import { tmpdir } from "node:os"; import { createImsgAutomationProvider, type ImsgAutomationOperation } from "./imessage-automation"; import type { AutomationAction, AutomationCoordinate } from "../messaging-automation-types"; import { assertProperty, fc } from "../test-support"; -import { parseAutomationCoordinate } from "../messaging-automation-validation"; +import { AUTOMATION_BINDING_CHANGED_REASON, parseAutomationCoordinate } from "../messaging-automation-validation"; +import { AutomationGroupBindingChangedError } from "../messaging-automation-types"; import { discoveryDiagnosticMessage, nativeDiagnostic, type DiscoveryDiagnosticCode } from "../messaging-automation-diagnostics"; const roots: string[] = []; @@ -19,13 +20,13 @@ function fixture(route = target) { const database = join(directory, "chat.db"); writeFileSync(database, "synthetic", { mode: 0o600 }); const calls: { method: string; params: Record }[] = []; const admissions: ImsgAutomationOperation[] = []; const barriers: Promise[] = []; - const state = { faultMethod: "chats.list", fault: "", databaseReady: true, revokeOnChats: false, changeIdentityOnChats: false, injectedCode: null as DiscoveryDiagnosticCode | null, bridge: true, malformed: false, linkQueued: false, linkConflict: false, accountIdentity: "a".repeat(64), foreign: false, replace: false, anchorChanged: false, authorizationError: false, revokeOnAsset: false, bytes: Buffer.from("test attachment"), hash: "", historyAttachments: [] as unknown[], sessions: 0, holdAuthorize: null as Promise | null, chats: [{ ...chat, id: route.observedChatRowId, guid: route.chatGuid }] as Record[], exactChat: { ...chat, id: route.observedChatRowId, guid: route.chatGuid } as Record }; + const state = { faultMethod: "chats.list", fault: "", databaseReady: true, revokeOnChats: false, changeIdentityOnChats: false, injectedCode: null as DiscoveryDiagnosticCode | null, bridge: true, malformed: false, linkQueued: false, linkConflict: false, accountIdentity: "a".repeat(64), foreign: false, replace: false, anchorChanged: false, authorizationError: false, revokeOnAsset: false, changeRosterOnAsset: false, emptyRosterOnAsset: false, bytes: Buffer.from("test attachment"), hash: "", historyAttachments: [] as unknown[], sessions: 0, holdAuthorize: null as Promise | null, chats: [{ ...chat, id: route.observedChatRowId, guid: route.chatGuid }] as Record[], exactChat: { ...chat, id: route.observedChatRowId, guid: route.chatGuid } as Record }; state.hash = createHash("sha256").update(state.bytes).digest("hex"); const readMethods = ["status", "chats.list", "chats.get", "messages.history", "messages.after", "send", "message.send_status"]; const provider = createImsgAutomationProvider({ async authorize(operation) { admissions.push(operation); if (state.holdAuthorize) { const gate = state.holdAuthorize; state.holdAuthorize = null; await gate; } if (state.authorizationError) throw new Error("revoked"); return { auth: { schemaVersion: 1, id: "imessage-fixture", kind: "linked-device-store", provider: "imessage", path: directory }, accountIdentity: state.accountIdentity, implementationIdentity: "b".repeat(64) }; }, execution: { registerCleanupBarrier(barrier) { barriers.push(barrier); return () => {}; } }, - async resolveAsset() { if (state.revokeOnAsset) state.authorizationError = true; return { bytes: state.bytes, sha256: state.hash }; }, + async resolveAsset() { if (state.revokeOnAsset) state.authorizationError = true; if (state.changeRosterOnAsset) state.exactChat = { ...state.exactChat, participants: state.emptyRosterOnAsset ? [] : ["replacement@example.test"] }; return { bytes: state.bytes, sha256: state.hash }; }, dependencies: { binaryPath: "/synthetic/imsg", expectedMessagesStorePath: directory, async run(invocation) { @@ -77,8 +78,55 @@ test("iMessage capabilities reflect current helper methods without starting brid test("iMessage bounded reads preserve exact coordinates and source generation", async () => { const f = fixture(); const list = await f.provider.conversations({ limit: 10 }); expect(list.conversations[0]!.coordinate).toEqual(target); const history = await f.provider.history({ coordinate: target, limit: 10 }); expect(history.identity).toEqual(list.identity); expect(history.messages[0]!.id).toBe("fixture-guid"); expect(history.caughtUp).toBe(false); + expect(history.messages[0]!.occurredAt).toBe("2026-09-11T12:00:00.000Z"); f.state.foreign = true; await expect(f.provider.resolve(target)).rejects.toThrow("exact"); await f.close(); }); +test("iMessage groups send only against the complete expected roster", async () => { + const route = { ...target, chatGuid: "iMessage;+;synthetic-group" }; + const f = fixture(route); + f.state.exactChat = { ...f.state.exactChat, is_group: true, participants: ["one@example.test", "two@example.test"] }; + f.state.chats = [f.state.exactChat]; + const bound = await f.provider.resolve(route); + expect(f.provider.groupConversations).toEqual({ version: 1 }); + expect((await f.provider.conversations({ limit: 10 })).conversations[0]?.kind).toBe("group"); + const request = { identity: bound.identity, coordinate: route, conversation: bound.conversation, action: { kind: "text" as const, text: "Synthetic group text" }, intentId: "group-test" }; + expect((await f.provider.send(request)).state).toBe("accepted"); + expect(f.calls.filter(call => call.method === "send")).toHaveLength(1); + f.state.exactChat = { ...f.state.exactChat, participants: ["one@example.test", "three@example.test"] }; + expect(await f.provider.send(request)).toEqual({ state: "not-started", reason: AUTOMATION_BINDING_CHANGED_REASON }); + expect(f.calls.filter(call => call.method === "send")).toHaveLength(1); + expect((await f.send(request.action)).state).toBe("not-started"); + await f.close(); +}); +test("iMessage group membership is checked again after asset preparation at the effect boundary", async () => { + const route = { ...target, chatGuid: "iMessage;+;synthetic-group" }; const f = fixture(route); + f.state.exactChat = { ...f.state.exactChat, is_group: true, participants: ["one@example.test", "two@example.test"] }; + const bound = await f.provider.resolve(route); f.state.changeRosterOnAsset = true; + expect(await f.provider.send({ identity: bound.identity, coordinate: route, conversation: bound.conversation, action: { kind: "attachment", assetId: "synthetic-asset", name: "fixture.txt", mimeType: "text/plain" }, intentId: "group-boundary" })).toEqual({ state: "not-started", reason: AUTOMATION_BINDING_CHANGED_REASON }); + expect(f.calls.filter(call => call.method === "send")).toHaveLength(0); await f.close(); +}); +test("iMessage complete-empty group roster is a scoped invalidation and an omitted roster is unavailable", async () => { + const route = { ...target, chatGuid: "iMessage;+;synthetic-group" }; const f = fixture(route); + f.state.exactChat = { ...f.state.exactChat, is_group: true, participants: ["one@example.test", "two@example.test"] }; + const bound = await f.provider.resolve(route); + f.state.exactChat = { ...f.state.exactChat, participants: [] }; + await expect(f.provider.resolve(route)).rejects.toBeInstanceOf(AutomationGroupBindingChangedError); + const request = { identity: bound.identity, coordinate: route, conversation: bound.conversation, action: { kind: "text" as const, text: "Synthetic" }, intentId: "empty-group" }; + expect(await f.provider.send(request)).toEqual({ state: "not-started", reason: AUTOMATION_BINDING_CHANGED_REASON }); + delete f.state.exactChat.participants; + expect((await f.provider.resolve(route)).conversation.participants).toEqual([]); + const missing = await f.provider.send(request); + expect(missing.state).toBe("not-started"); + if (missing.state !== "accepted") expect(missing.reason).not.toBe(AUTOMATION_BINDING_CHANGED_REASON); + expect(f.calls.filter(call => call.method === "send")).toHaveLength(0); await f.close(); +}); +test("iMessage complete-empty group proof at the final effect boundary prevents the send", async () => { + const route = { ...target, chatGuid: "iMessage;+;synthetic-group" }; const f = fixture(route); + f.state.exactChat = { ...f.state.exactChat, is_group: true, participants: ["one@example.test", "two@example.test"] }; + const bound = await f.provider.resolve(route); f.state.changeRosterOnAsset = true; f.state.emptyRosterOnAsset = true; + expect(await f.provider.send({ identity: bound.identity, coordinate: route, conversation: bound.conversation, action: { kind: "attachment", assetId: "synthetic-asset", name: "fixture.txt", mimeType: "text/plain" }, intentId: "empty-group-boundary" })).toEqual({ state: "not-started", reason: AUTOMATION_BINDING_CHANGED_REASON }); + expect(f.calls.filter(call => call.method === "send")).toHaveLength(0); await f.close(); +}); test("iMessage discovery preserves eligible rows when native single-chat metadata cannot be enrolled", async () => { const f = fixture(); const row = (id: number, fields: Record) => ({ ...chat, id, guid: `iMessage;-;fixture-${id}@example.test`, ...fields }); diff --git a/src/providers/imessage-automation.ts b/src/providers/imessage-automation.ts index d13ad2ba..82d78fc5 100644 --- a/src/providers/imessage-automation.ts +++ b/src/providers/imessage-automation.ts @@ -11,7 +11,8 @@ import type { LocalCliExecutionOptions } from "../local-cli-execution"; import { OperationDeadline } from "../operation-deadline"; import { discoveryDiagnostic, nativeDiagnostic, type DiscoveryDiagnosticPhase } from "../messaging-automation-diagnostics"; import type { AutomationAction, AutomationConversation, AutomationCoordinate, AutomationIdentity, AutomationMessage, AutomationProviderStatus, AutomationProviderSendResult, AutomationScopedPage, MessagingAutomationProvider } from "../messaging-automation-types"; -import { AUTOMATION_ACTION_KINDS, automationArray, automationDigest, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationCoordinate, parseAutomationIdentity, automationInstant } from "../messaging-automation-validation"; +import { AutomationGroupBindingChangedError } from "../messaging-automation-types"; +import { AUTOMATION_ACTION_KINDS, AUTOMATION_BINDING_CHANGED_REASON, automationArray, automationDigest, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationConversation, parseAutomationCoordinate, parseAutomationIdentity, automationInstant } from "../messaging-automation-validation"; import { IMSG_NO_FETCH_RICH_CARDS_AVAILABLE, type ImsgChatCoordinate, type ImsgRpcRequest } from "./imessage-direct"; import { createImsgAutomationSessions, imsgAutomationProjection as project, type ImsgAutomationSessionContext, type ImsgChatProjection, type ImsgDirectRuntimeDependencies } from "./imessage-direct-runtime"; @@ -51,19 +52,23 @@ function supportedDiscoveryCoordinate(chat: ImsgChatProjection): boolean { return (chat.guid.startsWith("iMessage;") || chat.guid.startsWith("any;")) && Buffer.byteLength(chat.guid) <= 1024; } function discoverableConversation(value: AutomationConversation): boolean { - if (value.kind !== "single") return true; // Native chat metadata can be valid without meeting the owner's bounded - // individual-conversation contract. Omit those rows from this incomplete + // conversation contract. Omit those rows from this incomplete // discovery page; exact resolution, enrollment and dispatch stay strict. - return value.participants.length >= 1 && value.participants.length <= 2 + return value.participants.length >= 1 && value.participants.length <= (value.kind === "group" ? 500 : 2) && new Set(value.participants).size === value.participants.length && value.participants.every(participant => Buffer.byteLength(participant) <= 512) && (value.title === null || Buffer.byteLength(value.title) <= 512); } -async function exactConversation(session: Session, value: AutomationCoordinate): Promise { +async function exactConversation(session: Session, value: AutomationCoordinate, identity?: AutomationIdentity): Promise { const selected = target(value); const output = await session.run([request("chats.get", { chat_id: selected.observedChatRowId })]); - return conversation(project.exactChat(output.get("operation"), selected)); + const raw = output.get("operation"), projected = conversation(project.exactChat(raw, selected)); + // The pinned helper's complete chat_handle_join query has no row limit. + // An absent field remains unavailable; only explicit [] proves this change. + const native = (raw as { chat: Record }).chat; + if (identity && projected.kind === "group" && Array.isArray(native.participants) && native.participants.length === 0) throw new AutomationGroupBindingChangedError(identity, projected.coordinate); + return projected; } function methods(session: Session): ReadonlySet { const status = session.status as Record; // already parsed by the pinned runtime @@ -184,7 +189,9 @@ export function createImsgAutomationProvider(options: ImsgAutomationOptions): Me return await sessions.withSession(admission.auth, { operationDeadline: deadline, ...(operation === "conversations" ? { discoveryPhase: phase } : {}) }, async session => { const identity = parseAutomationIdentity({ provider: "imessage", authId: admission.auth.id, accountIdentity: admission.accountIdentity, accountSubject: session.subject, implementationIdentity: admission.implementationIdentity, sourceGeneration: session.sourceGeneration }); const reauthorize = async () => { signal?.throwIfAborted(); const after = await options.authorize(operation, signal); if (sha(after) !== sha(admission)) throw nativeDiagnostic(new Error("iMessage account or permission changed during the operation"), "identity-changed"); }; - const result = await work(session, identity, reauthorize, phase); + let result; + try { result = await work(session, identity, reauthorize, phase); } + catch (error) { if (error instanceof AutomationGroupBindingChangedError) await reauthorize(); throw error; } phase("reauthorization"); await reauthorize(); return result; @@ -202,6 +209,7 @@ export function createImsgAutomationProvider(options: ImsgAutomationOptions): Me } return { provider: "imessage", + groupConversations: { version: 1 }, inspect(signal) { return run("inspect", signal, async (session, identity) => providerStatus(session, identity)); }, conversations(input, signal) { const limit = automationInteger(input.limit, 1, 200); @@ -220,7 +228,7 @@ export function createImsgAutomationProvider(options: ImsgAutomationOptions): Me return { identity, conversations, complete: false }; }); }, - resolve(value, signal) { const selected = coordinate(value); return run("resolve", signal, async (session, identity) => ({ identity, conversation: await exactConversation(session, selected) })); }, + resolve(value, signal) { const selected = coordinate(value); return run("resolve", signal, async (session, identity) => ({ identity, conversation: await exactConversation(session, selected, identity) })); }, history(input, signal) { const selected = coordinate(input.coordinate), limit = automationInteger(input.limit, 1, 200); const window = { ...(input.before === undefined ? {} : { end: automationInstant(input.before) }), ...(input.after === undefined ? {} : { start: automationInstant(input.after) }) }; @@ -273,12 +281,19 @@ export function createImsgAutomationProvider(options: ImsgAutomationOptions): Me }, async send(input, signal): Promise { const selected = coordinate(input.coordinate), action = parseAutomationAction(input.action), expected = parseAutomationIdentity(input.identity); - let dispatched = false; + let dispatched = false, bindingChanged = false; try { return await run(action.kind, signal, async (session, identity, reauthorize) => { if (sha(identity) !== sha(expected)) throw new Error("iMessage account or source changed before dispatch"); - const live = await exactConversation(session, selected); - if (live.kind !== "single" || live.participants.length !== 1) throw new Error("Only direct participant-bound automation is supported"); + const live = await exactConversation(session, selected, identity); + const bound = input.conversation === undefined ? null : parseAutomationConversation(input.conversation); + const fingerprint = (value: AutomationConversation) => sha({ ...parseAutomationConversation(value), title: null }); + if (live.kind === "group") { + if (bound === null || bound.kind !== "group") throw new Error("Group sends require an exact participant binding"); + if (fingerprint(live) !== fingerprint(bound)) { bindingChanged = true; throw new Error("iMessage group binding changed"); } + } else if (live.kind !== "single" || live.participants.length !== 1 || bound?.kind === "group") { + bindingChanged = bound?.kind === "group"; throw new Error("The iMessage participant binding changed"); + } if (!providerStatus(session, identity).actions[action.kind].available) throw new Error("Requested action is unavailable"); let method: string; const params: Record = { chat_guid: selected.chatGuid }; if (action.kind === "text") { method = "send"; Object.assign(params, { text: action.text, service: "imessage", transport: "applescript", allow_sms_fallback: false }); } @@ -309,7 +324,7 @@ export function createImsgAutomationProvider(options: ImsgAutomationOptions): Me signal?.throwIfAborted(); const response = await session.run([request(method, params)], async () => { await reauthorize(); - if (sha(await exactConversation(session, selected)) !== sha(live)) throw new Error("iMessage participants changed before dispatch"); + if (fingerprint(await exactConversation(session, selected, identity)) !== fingerprint(live)) { bindingChanged = live.kind === "group"; throw new Error("iMessage participants changed before dispatch"); } await reauthorize(); dispatched = true; }); @@ -335,7 +350,7 @@ export function createImsgAutomationProvider(options: ImsgAutomationOptions): Me if (action.kind === "poll" && (result.event !== "imessage.poll.created" || messageId === null)) throw new Error("Poll creation receipt is missing"); return { state: "accepted", messageId, providerReceiptId, delivery: "unknown" }; }); - } catch { return { state: dispatched ? "indeterminate" : "not-started", reason: dispatched ? "iMessage dispatch or process cleanup could not be reconciled. Do not retry." : "iMessage admission failed before any send." }; } + } catch (error) { return { state: dispatched ? "indeterminate" : "not-started", reason: dispatched ? "iMessage dispatch or process cleanup could not be reconciled. Do not retry." : bindingChanged || error instanceof AutomationGroupBindingChangedError && input.conversation?.kind === "group" ? AUTOMATION_BINDING_CHANGED_REASON : "iMessage admission failed before any send." }; } }, async close() { closed = true; await inFlight; await sessions.close(); }, }; diff --git a/src/providers/messaging-native-artifacts.ts b/src/providers/messaging-native-artifacts.ts index 4ccc6659..c1602f0e 100644 --- a/src/providers/messaging-native-artifacts.ts +++ b/src/providers/messaging-native-artifacts.ts @@ -9,10 +9,10 @@ export const MESSAGING_NATIVE_ARTIFACTS = Object.freeze({ }, "whatsapp": { "file": "wacli-darwin-arm64.gz", - "sha256": "9b77ffb810d028fde725ca02b1451f1725b5ff5312a46a54468a9a38533d4cea", - "bytes": 21963810, - "compressedSha256": "1c1650d6c79b74db7f8f335b4746398c802031260a90468312dcaac0374a5166", - "compressedBytes": 7682949 + "sha256": "85a4c2b6f538103df08425f75984657559169a95161a256c6d096daf9de38f47", + "bytes": 21980338, + "compressedSha256": "61c9aef8d1a2c38f4831e8546fea0ae9907c365a301b8cde7388e47adbb2d697", + "compressedBytes": 7694763 }, "phoneMetadata": { "file": "phone-number-metadata.json.gz", diff --git a/src/providers/whatsapp-automation-runtime.ts b/src/providers/whatsapp-automation-runtime.ts index e7f076be..8bda978b 100644 --- a/src/providers/whatsapp-automation-runtime.ts +++ b/src/providers/whatsapp-automation-runtime.ts @@ -1,3 +1,4 @@ +import { types } from "node:util"; import { Database } from "bun:sqlite"; import { createHash, randomBytes } from "node:crypto"; import { constants } from "node:fs"; @@ -8,23 +9,23 @@ import { dirname, isAbsolute, join } from "node:path"; import type { GhostgetAuth } from "../auth"; import { canonicalJson } from "../canonical-json"; import type { LocalCliExecutionOptions } from "../local-cli-execution"; -import { automationDigest, automationRecord, automationText } from "../messaging-automation-validation"; +import { AUTOMATION_WHATSAPP_GROUP_JID, automationArray, automationDigest, automationRecord, automationText } from "../messaging-automation-validation"; import { startProviderPluginCleanupTrackedOperation, type ProviderPluginCleanupProofController } from "../provider-plugin-cleanup-execution"; import { attachLocalCliCleanupProcessGroup, captureLocalCliCleanupResource, localCliCleanupProcessGroupStatus, type LocalCliCleanupResourceIdentityV1 } from "../provider-plugin-cleanup-resource"; import { assertSafeStatePath, ensurePrivateStateDirectory, ghostgetStateHome } from "../storage"; import { validateWhatsAppStoreDirectory } from "./whatsapp-web-runtime"; export const WHATSAPP_AUTOMATION_PROTOCOL = "ghostget.whatsapp-private/1" as const; -export const WHATSAPP_AUTOMATION_VERSION = "0.15.0+ghostget-private.1" as const; +export const WHATSAPP_AUTOMATION_VERSION = "0.15.0+ghostget-private.2" as const; // Pinned from the exact vendored patch, full plain/FTS suites, vet, and two // byte-identical builds recorded in the private transport provenance. -export const WHATSAPP_AUTOMATION_BINARY_SHA256 = "9b77ffb810d028fde725ca02b1451f1725b5ff5312a46a54468a9a38533d4cea"; +export const WHATSAPP_AUTOMATION_BINARY_SHA256 = "85a4c2b6f538103df08425f75984657559169a95161a256c6d096daf9de38f47"; const directJid = /^(?:[1-9][0-9]{4,14}@s\.whatsapp\.net|[1-9][0-9]{4,19}@lid)$/u; const sha = (value: unknown): string => createHash("sha256").update(canonicalJson(value)).digest("hex"); type LinkedAuth = Extract; export type WhatsAppAutomationSnapshot = Readonly<{ account: string; subject: string; sourceGeneration: string; ledgerReady: boolean; connected: boolean; generation: string | null }>; -export type WhatsAppPrivateRequest = Readonly<{ protocol: typeof WHATSAPP_AUTOMATION_PROTOCOL; kind: "status" | "text" | "file" | "react" | "sticker" | "poll"; requestId: string; generation: string; account: string; to: string; message: string; file: string; filename: string; mime: string; id: string; reaction: string; question: string; options: readonly string[]; selectable: number }>; -export type WhatsAppPrivateResponse = Readonly<{ protocol: typeof WHATSAPP_AUTOMATION_PROTOCOL; requestId: string; generation: string; account: string; state: "ready" | "accepted" | "not-started" | "indeterminate"; to: string; messageId: string; connected: boolean }>; +export type WhatsAppPrivateRequest = Readonly<{ protocol: typeof WHATSAPP_AUTOMATION_PROTOCOL; kind: "status" | "group-info" | "text" | "file" | "react" | "sticker" | "poll"; requestId: string; generation: string; account: string; to: string; message: string; file: string; filename: string; mime: string; id: string; reaction: string; question: string; options: readonly string[]; selectable: number; expectedParticipants?: readonly string[] }>; +export type WhatsAppPrivateResponse = Readonly<{ protocol: typeof WHATSAPP_AUTOMATION_PROTOCOL; requestId: string; generation: string; account: string; state: "ready" | "accepted" | "not-started" | "indeterminate"; to: string; messageId: string; connected: boolean; participants?: readonly string[]; bindingChanged?: true }>; export interface WhatsAppAutomationRuntime { read(auth: GhostgetAuth, work: (database: Database, snapshot: WhatsAppAutomationSnapshot) => T, signal?: AbortSignal): Promise; start(auth: GhostgetAuth, beforeSpawn: () => Promise, signal?: AbortSignal): Promise; @@ -95,12 +96,17 @@ export async function installReviewedWhatsAppAutomationBinary(source: string, en } export function parseWhatsAppPrivateResponse(value: unknown): WhatsAppPrivateResponse { - const row = automationRecord(value, ["protocol", "requestId", "generation", "account", "state", "to", "messageId", "connected"]); + if (!value || typeof value !== "object" || types.isProxy(value)) throw new Error("Invalid WhatsApp response"); + const roster = Object.hasOwn(value, "participants"), changed = Object.hasOwn(value, "bindingChanged"); + const row = automationRecord(value, ["protocol", "requestId", "generation", "account", "state", "to", "messageId", "connected", ...(roster ? ["participants"] : []), ...(changed ? ["bindingChanged"] : [])]); if (row.protocol !== WHATSAPP_AUTOMATION_PROTOCOL || !["ready", "accepted", "not-started", "indeterminate"].includes(String(row.state)) || typeof row.connected !== "boolean") throw new Error("Unsupported WhatsApp transport response"); for (const field of ["requestId", "to", "messageId"] as const) if (typeof row[field] !== "string" || Buffer.byteLength(row[field]) > 256 || /[\u0000-\u001f\u007f]/u.test(row[field])) throw new Error("Invalid WhatsApp receipt field"); const generation = automationDigest(row.generation), account = automationText(row.account, 128); - if (!directJid.test(account) || row.requestId !== "" && !/^[a-f0-9]{64}$/u.test(row.requestId as string) || row.to !== "" && !directJid.test(row.to as string) || row.state === "accepted" && (row.messageId === "" || row.requestId === "" || row.to === "")) throw new Error("Invalid WhatsApp receipt identity"); - return { protocol: WHATSAPP_AUTOMATION_PROTOCOL, requestId: row.requestId as string, generation, account, state: row.state as WhatsAppPrivateResponse["state"], to: row.to as string, messageId: row.messageId as string, connected: row.connected }; + if (!directJid.test(account) || row.requestId !== "" && !/^[a-f0-9]{64}$/u.test(row.requestId as string) || row.to !== "" && !directJid.test(row.to as string) && !AUTOMATION_WHATSAPP_GROUP_JID.test(row.to as string) || row.state === "accepted" && (row.messageId === "" || row.requestId === "" || row.to === "")) throw new Error("Invalid WhatsApp receipt identity"); + const participants = roster ? automationArray(row.participants, 500).map(value => automationText(value, 128)) : undefined; + if (participants && (row.state !== "ready" || row.requestId === "" || row.messageId !== "" || !AUTOMATION_WHATSAPP_GROUP_JID.test(row.to as string) || participants.length === 0 || participants.some((participant, index) => !directJid.test(participant) || index > 0 && participants[index - 1]! >= participant))) throw new Error("Incomplete or noncanonical WhatsApp group roster"); + if (changed && (row.bindingChanged !== true || row.state !== "not-started" || row.requestId === "" || row.messageId !== "" || !AUTOMATION_WHATSAPP_GROUP_JID.test(row.to as string) || roster)) throw new Error("Invalid WhatsApp membership-change proof"); + return { protocol: WHATSAPP_AUTOMATION_PROTOCOL, requestId: row.requestId as string, generation, account, state: row.state as WhatsAppPrivateResponse["state"], to: row.to as string, messageId: row.messageId as string, connected: row.connected, ...(participants ? { participants } : {}), ...(changed ? { bindingChanged: true as const } : {}) }; } const blankStatus = (): WhatsAppPrivateRequest => ({ protocol: WHATSAPP_AUTOMATION_PROTOCOL, kind: "status", requestId: "", generation: "", account: "", to: "", message: "", file: "", filename: "", mime: "", id: "", reaction: "", question: "", options: [], selectable: 0 }); async function socketRequest(path: string, identity: { dev: number; ino: number }, request: WhatsAppPrivateRequest, signal?: AbortSignal, beforeWrite?: () => Promise): Promise { @@ -124,13 +130,13 @@ async function socketRequest(path: string, identity: { dev: number; ino: number })().catch(() => finish(new Error("WhatsApp request admission changed before writing"))); }); socket.on("data", chunk => { if (!Buffer.isBuffer(chunk)) { finish(new Error("WhatsApp response was not bytes")); return; } - if (bytes.length + chunk.length > 4096) { finish(new Error("WhatsApp response exceeds its byte bound")); return; } + if (bytes.length + chunk.length > (request.kind === "group-info" ? 32_768 : 4096)) { finish(new Error("WhatsApp response exceeds its byte bound")); return; } bytes = Buffer.concat([bytes, chunk]); const newline = bytes.indexOf(10); if (newline < 0) return; try { if (newline !== bytes.length - 1) throw new Error("Trailing WhatsApp response data"); const response = parseWhatsAppPrivateResponse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, newline)))); - if (request.kind === "status" ? response.state !== "ready" || response.requestId !== "" || response.to !== "" || response.messageId !== "" : response.requestId !== request.requestId || response.to !== request.to || response.account !== request.account || response.generation !== request.generation || response.state === "ready") throw new Error("WhatsApp response did not bind its request"); + if (request.kind === "status" ? response.state !== "ready" || response.requestId !== "" || response.to !== "" || response.messageId !== "" || response.participants !== undefined || response.bindingChanged !== undefined : response.requestId !== request.requestId || response.to !== request.to || response.account !== request.account || response.generation !== request.generation || (request.kind === "group-info" ? response.state !== "not-started" && (response.state !== "ready" || response.participants === undefined) || response.bindingChanged !== undefined : response.state === "ready" || response.participants !== undefined)) throw new Error("WhatsApp response did not bind its request"); finish(undefined, response); } catch { finish(new Error("WhatsApp transport returned a malformed receipt")); } }); diff --git a/src/providers/whatsapp-automation.test.ts b/src/providers/whatsapp-automation.test.ts index 77e86253..e61a3a1b 100644 --- a/src/providers/whatsapp-automation.test.ts +++ b/src/providers/whatsapp-automation.test.ts @@ -4,19 +4,22 @@ import { createHash } from "node:crypto"; import { createWhatsAppAutomationProvider, type WhatsAppAutomationOperation } from "./whatsapp-automation"; import { parseWhatsAppPrivateResponse, WHATSAPP_AUTOMATION_PROTOCOL, type WhatsAppAutomationRuntime, type WhatsAppPrivateRequest } from "./whatsapp-automation-runtime"; import type { AutomationAction, AutomationCoordinate } from "../messaging-automation-types"; +import { AUTOMATION_BINDING_CHANGED_REASON } from "../messaging-automation-types"; import { assertProperty, fc } from "../test-support"; const databases: Database[] = []; afterEach(() => { for (const database of databases.splice(0)) database.close(); }); const target: Extract = { provider: "whatsapp", conversationJid: "15550000002@s.whatsapp.net" }; -function fixture() { +const groupTarget: typeof target = { provider: "whatsapp", conversationJid: "120363000000000000@g.us" }; +function fixture(group = false) { + const selected = group ? groupTarget : target; const database = new Database(":memory:"); databases.push(database); const fields = "chat_jid TEXT,msg_id TEXT,sender_jid TEXT,ts INTEGER,from_me INTEGER,text TEXT,display_text TEXT,quoted_msg_id TEXT,reaction_to_id TEXT,reaction_emoji TEXT,media_type TEXT,filename TEXT,mime_type TEXT,file_length INTEGER,revoked INTEGER,deleted_for_me INTEGER"; database.exec(`CREATE TABLE chats(jid TEXT PRIMARY KEY,kind TEXT,name TEXT,last_message_ts INTEGER); CREATE TABLE messages(rowid INTEGER PRIMARY KEY AUTOINCREMENT,${fields}); CREATE TABLE ghostget_automation_events(sequence INTEGER PRIMARY KEY AUTOINCREMENT,kind TEXT,${fields});`); - database.query("INSERT INTO chats VALUES(?,'dm','Synthetic',100)").run(target.conversationJid); + database.query("INSERT INTO chats VALUES(?,?,'Synthetic',100)").run(selected.conversationJid, group ? "group" : "dm"); const snapshot = { account: "15550000001@s.whatsapp.net", subject: "whatsapp:pn:15550000001", sourceGeneration: "c".repeat(64), ledgerReady: true, connected: false, generation: "d".repeat(64) }; const requests: WhatsAppPrivateRequest[] = [], operations: WhatsAppAutomationOperation[] = []; - const state = { accountIdentity: "a".repeat(64), revoke: false, revokeOnAsset: false, revokeBeforeWrite: false, started: 0, closed: false, staged: 0, removed: 0, failReceipt: false, pauseRequest: false, entered: (() => {}) as () => void }; + const state = { participants: ["15550000001@s.whatsapp.net", "15550000002@s.whatsapp.net"], missingRoster: false, changedBeforeEffect: false, accountIdentity: "a".repeat(64), revoke: false, revokeOnAsset: false, revokeBeforeWrite: false, started: 0, closed: false, staged: 0, removed: 0, failReceipt: false, pauseRequest: false, entered: (() => {}) as () => void }; const runtime: WhatsAppAutomationRuntime = { async read(_auth, work, signal) { signal?.throwIfAborted(); return work(database, snapshot); }, async start(_auth, beforeSpawn, signal) { signal?.throwIfAborted(); await beforeSpawn(); state.started++; snapshot.connected = true; }, @@ -25,7 +28,10 @@ function fixture() { await beforeWrite?.(); requests.push(request); if (state.failReceipt) throw new Error("lost receipt"); if (state.pauseRequest) { state.entered(); await new Promise((_resolve, reject) => { signal?.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }); if (signal?.aborted) reject(new Error("cancelled")); }); } - return { protocol: WHATSAPP_AUTOMATION_PROTOCOL, requestId: request.requestId, generation: request.generation, account: request.account, to: request.to, state: "accepted", messageId: "synthetic-sent", connected: true }; + const base = { protocol: WHATSAPP_AUTOMATION_PROTOCOL, requestId: request.requestId, generation: request.generation, account: request.account, to: request.to, connected: true }; + if (request.kind === "group-info") return state.missingRoster ? { ...base, state: "not-started", messageId: "" } : { ...base, state: "ready", messageId: "", participants: [...state.participants] }; + if (request.expectedParticipants && state.changedBeforeEffect) return { ...base, state: "not-started", messageId: "", bindingChanged: true }; + return { ...base, state: "accepted", messageId: "synthetic-sent" }; }, async stage(bytes, digest) { expect(createHash("sha256").update(bytes).digest("hex")).toBe(digest); state.staged++; return { path: "/synthetic/owned-asset", async close() { state.removed++; } }; }, async close() { state.closed = true; }, @@ -36,7 +42,7 @@ function fixture() { async resolveAsset() { if (state.revokeOnAsset) state.revoke = true; const bytes = Buffer.from("synthetic asset"); return { bytes, sha256: createHash("sha256").update(bytes).digest("hex") }; }, }); function add(id: string, kind = "message", outgoing = false) { - const fields = [target.conversationJid, id, target.conversationJid, 1_789_128_000, outgoing ? 1 : 0, "butler synthetic", null, null, null, null, null, null, null, null, 0, 0]; + const fields = [selected.conversationJid, id, target.conversationJid, 1_789_128_000, outgoing ? 1 : 0, "butler synthetic", null, null, null, null, null, null, null, null, 0, 0]; database.query("INSERT INTO messages(chat_jid,msg_id,sender_jid,ts,from_me,text,display_text,quoted_msg_id,reaction_to_id,reaction_emoji,media_type,filename,mime_type,file_length,revoked,deleted_for_me) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)").run(...fields); database.query("INSERT INTO ghostget_automation_events(kind,chat_jid,msg_id,sender_jid,ts,from_me,text,display_text,quoted_msg_id,reaction_to_id,reaction_emoji,media_type,filename,mime_type,file_length,revoked,deleted_for_me) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)").run(kind, ...fields); } @@ -128,7 +134,7 @@ test("WhatsApp receipt parser rejects arbitrary extra fields", () => { const valid = { protocol: WHATSAPP_AUTOMATION_PROTOCOL, requestId: "a".repeat(64), generation: "b".repeat(64), account: "15550000001@s.whatsapp.net", to: target.conversationJid, state: "accepted", messageId: "synthetic", connected: true }; expect(parseWhatsAppPrivateResponse(valid).state).toBe("accepted"); assertProperty(fc.property(fc.string().filter(key => !Object.hasOwn(valid, key)), fc.jsonValue(), (key, value) => { expect(() => parseWhatsAppPrivateResponse({ ...valid, [key]: value })).toThrow(); })); - for (const patch of [{ generation: "" }, { to: "15550000002@g.us" }, { messageId: "" }, { connected: 1 }, { state: "delivered" }]) expect(() => parseWhatsAppPrivateResponse({ ...valid, ...patch })).toThrow(); + for (const patch of [{ generation: "" }, { to: "15550000002@broadcast" }, { messageId: "" }, { connected: 1 }, { state: "delivered" }]) expect(() => parseWhatsAppPrivateResponse({ ...valid, ...patch })).toThrow(); }); test("WhatsApp refuses dated history windows instead of ignoring them", async () => { @@ -136,3 +142,66 @@ test("WhatsApp refuses dated history windows instead of ignoring them", async () await expect(f.provider.history({ coordinate: target, limit: 10, before: "2026-09-01T00:00:00.000Z" })).rejects.toThrow("unavailable for WhatsApp"); await expect(f.provider.history({ coordinate: target, limit: 10, after: "2026-06-01T00:00:00.000Z" })).rejects.toThrow("unavailable for WhatsApp"); }); + + +test("WhatsApp groups require an explicit full roster and bind rich sends to it", async () => { + const f = fixture(true); + expect(f.provider.groupConversations).toEqual({ version: 1 }); + await f.provider.start(); + const resolved = await f.provider.resolve(groupTarget); + expect(resolved.conversation).toMatchObject({ kind: "group", participants: f.state.participants }); + f.add("baseline"); + const history = await f.provider.history({ coordinate: groupTarget, limit: 20 }); + expect(history.messages.map(message => message.id)).toEqual(["baseline"]); + f.add("live"); + expect((await f.provider.events({ coordinates: [groupTarget], cursor: history.nextCursor, limit: 20 })).messages.map(message => message.id)).toEqual(["live"]); + const input = { identity: resolved.identity, coordinate: groupTarget, conversation: resolved.conversation, intentId: "group-synthetic", action: { kind: "text" as const, text: "Group reply" } }; + expect(await f.provider.send(input)).toMatchObject({ state: "accepted" }); + expect(f.requests.find(request => request.kind === "text")).toMatchObject({ to: groupTarget.conversationJid, expectedParticipants: f.state.participants }); + f.state.participants.push("15550000003@s.whatsapp.net"); + const sentCount = f.requests.filter(request => request.kind === "text").length; + expect(await f.provider.send({ ...input, intentId: "changed" })).toEqual({ state: "not-started", reason: AUTOMATION_BINDING_CHANGED_REASON }); + expect(f.requests.filter(request => request.kind === "text")).toHaveLength(sentCount); +}); + +test("WhatsApp missing group evidence refuses without declaring membership changed", async () => { + const f = fixture(true); await f.provider.start(); + const resolved = await f.provider.resolve(groupTarget); + f.state.missingRoster = true; + const input = { identity: resolved.identity, coordinate: groupTarget, conversation: resolved.conversation, intentId: "unavailable", action: { kind: "text" as const, text: "Never send" } }; + const result = await f.provider.send(input); + expect(result.state).toBe("not-started"); + expect("reason" in result && result.reason).not.toBe(AUTOMATION_BINDING_CHANGED_REASON); + expect(f.requests.filter(request => request.kind === "text")).toHaveLength(0); +}); + +test("WhatsApp forwards the native final-effect membership fence", async () => { + const f = fixture(true); await f.provider.start(); + const resolved = await f.provider.resolve(groupTarget); + f.state.changedBeforeEffect = true; + expect(await f.provider.send({ identity: resolved.identity, coordinate: groupTarget, conversation: resolved.conversation, intentId: "final-fence", action: { kind: "text", text: "Never send" } })).toEqual({ state: "not-started", reason: AUTOMATION_BINDING_CHANGED_REASON }); +}); + +test("WhatsApp group receipt extensions are closed and cannot decorate direct receipts", () => { + const base = { protocol: WHATSAPP_AUTOMATION_PROTOCOL, requestId: "a".repeat(64), generation: "b".repeat(64), account: "15550000001@s.whatsapp.net", to: groupTarget.conversationJid, state: "ready", messageId: "", connected: true }; + const participants = ["15550000001@s.whatsapp.net", "15550000002@s.whatsapp.net"]; + expect(parseWhatsAppPrivateResponse({ ...base, participants }).participants).toEqual(participants); + expect(parseWhatsAppPrivateResponse({ ...base, state: "not-started", bindingChanged: true }).bindingChanged).toBe(true); + for (const roster of [[], [...participants].reverse(), [participants[0], participants[0]], ["123@broadcast"], Array.from({ length: 501 }, (_, index) => `${15550000001 + index}@s.whatsapp.net`)]) expect(() => parseWhatsAppPrivateResponse({ ...base, participants: roster })).toThrow(); + for (const patch of [{ to: target.conversationJid, participants }, { state: "accepted", messageId: "sent", participants }, { bindingChanged: false }, { bindingChanged: true }, { state: "not-started", participants, bindingChanged: true }]) expect(() => parseWhatsAppPrivateResponse({ ...base, ...patch })).toThrow(); +}); + + +test("WhatsApp direct discovery never fetches a group roster or spends its result limit on groups", async () => { + const f = fixture(true); + f.database.query("INSERT INTO chats VALUES(?,'dm','Direct',50)").run(target.conversationJid); + expect((await f.provider.conversations({ limit: 1 })).conversations.map(item => item.coordinate)).toEqual([target]); + expect(f.requests).toHaveLength(0); + expect((await f.provider.conversations({ limit: 10, includeGroups: true })).complete).toBe(false); + expect(f.requests).toHaveLength(0); + await f.provider.start(); + const optedIn = await f.provider.conversations({ limit: 10, includeGroups: true }); + expect(optedIn.conversations.map(item => item.kind)).toEqual(["group", "single"]); + expect(optedIn.complete).toBe(true); + expect(f.requests.map(request => request.kind)).toEqual(["group-info"]); +}); diff --git a/src/providers/whatsapp-automation.ts b/src/providers/whatsapp-automation.ts index 14ae2411..b7cb8cb7 100644 --- a/src/providers/whatsapp-automation.ts +++ b/src/providers/whatsapp-automation.ts @@ -7,7 +7,7 @@ import { } from "../canonical-json"; import type { LocalCliExecutionOptions } from "../local-cli-execution"; import type { AutomationAction, AutomationConversation, AutomationCoordinate, AutomationIdentity, AutomationMessage, AutomationProviderStatus, AutomationProviderSendResult, MessagingAutomationProvider } from "../messaging-automation-types"; -import { AUTOMATION_ACTION_KINDS, automationArray, automationDigest, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationCoordinate, parseAutomationIdentity } from "../messaging-automation-validation"; +import { AUTOMATION_ACTION_KINDS, AUTOMATION_BINDING_CHANGED_REASON, AUTOMATION_WHATSAPP_GROUP_JID, automationArray, automationDigest, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationCoordinate, parseAutomationConversation, parseAutomationIdentity } from "../messaging-automation-validation"; import { createWhatsAppAutomationRuntime, WHATSAPP_AUTOMATION_PROTOCOL, type WhatsAppAutomationRuntime, type WhatsAppAutomationSnapshot, type WhatsAppPrivateRequest } from "./whatsapp-automation-runtime"; export type WhatsAppAutomationOperation = "inspect" | "start" | "conversations" | "resolve" | "history" | "events" | AutomationAction["kind"]; @@ -24,14 +24,14 @@ const sha = (value: unknown): string => createHash("sha256").update(canonicalJso type Coordinate = Extract; function coordinate(value: unknown): Coordinate { const result = parseAutomationCoordinate(value); if (result.provider !== "whatsapp") throw new Error("WhatsApp cannot operate another messaging network"); return result; } function identity(admission: WhatsAppAutomationAdmission, snapshot: WhatsAppAutomationSnapshot): AutomationIdentity { return parseAutomationIdentity({ provider: "whatsapp", authId: admission.auth.id, accountIdentity: admission.accountIdentity, accountSubject: snapshot.subject, implementationIdentity: admission.implementationIdentity, sourceGeneration: snapshot.sourceGeneration }); } -function conversation(value: unknown): AutomationConversation { +function conversation(value: unknown, participants?: readonly string[]): AutomationConversation { const row = automationRecord(value, ["jid", "kind", "name"]), selected = coordinate({ provider: "whatsapp", conversationJid: row.jid }); - if (row.kind !== "dm") throw new Error("Only direct WhatsApp conversations can be enrolled"); - return { coordinate: selected, title: row.name === null || row.name === "" ? null : automationText(row.name, 512), kind: "single", participants: [selected.conversationJid] }; + if (row.kind !== "dm" && row.kind !== "group" || row.kind === "group" && participants === undefined) throw new Error("A verified WhatsApp conversation and complete roster are required"); + return parseAutomationConversation({ coordinate: selected, title: row.name === null || row.name === "" ? null : automationText(row.name, 512), kind: row.kind === "group" ? "group" : "single", participants: row.kind === "group" ? participants : [selected.conversationJid] }); } -function exact(database: Database, selected: Coordinate, account: string): AutomationConversation { +function exact(database: Database, selected: Coordinate, account: string, participants?: readonly string[]): AutomationConversation { if (selected.conversationJid === account) throw new Error("Self messaging requires a separate contract"); - return conversation(database.query("SELECT jid,kind,name FROM chats WHERE jid=? AND kind='dm'").get(selected.conversationJid)); + return conversation(database.query("SELECT jid,kind,name FROM chats WHERE jid=? AND kind IN ('dm','group')").get(selected.conversationJid), participants); } const messageColumns = "chat_jid,msg_id,sender_jid,ts,from_me,text,display_text,quoted_msg_id,reaction_to_id,reaction_emoji,media_type,filename,mime_type,file_length,revoked,deleted_for_me"; const nullableText = (value: unknown, maximum: number): string | null => value === null || value === "" ? null : automationText(value, maximum, true); @@ -96,34 +96,72 @@ export function createWhatsAppAutomationProvider(options: WhatsAppAutomationOpti inFlight = pending.then(() => undefined, () => undefined); return pending; } + async function groupRoster(admission: WhatsAppAutomationAdmission, snapshot: WhatsAppAutomationSnapshot, selected: Coordinate, operation: WhatsAppAutomationOperation, signal: AbortSignal): Promise { + if (!snapshot.connected || !snapshot.generation) throw new Error("The owned WhatsApp connection is required for group membership"); + const response = await runtime.request({ protocol: WHATSAPP_AUTOMATION_PROTOCOL, kind: "group-info", requestId: sha({ operation: "group-info", account: snapshot.account, generation: snapshot.generation, to: selected.conversationJid }), generation: snapshot.generation, account: snapshot.account, to: selected.conversationJid, message: "", file: "", filename: "", mime: "", id: "", reaction: "", question: "", options: [], selectable: 0 }, signal, async () => { + if (sha(await options.authorize(operation, signal)) !== sha(admission)) throw new Error("WhatsApp group read permission changed"); + }); + if (response.state !== "ready" || response.participants === undefined || response.account !== snapshot.account || response.generation !== snapshot.generation || response.to !== selected.conversationJid) throw new Error("Complete current WhatsApp group membership is unavailable"); + return parseAutomationConversation({ coordinate: selected, title: null, kind: "group", participants: response.participants }).participants; + } + async function verifiedGroup(admission: WhatsAppAutomationAdmission, selected: Coordinate, operation: WhatsAppAutomationOperation, signal: AbortSignal): Promise { + if (!AUTOMATION_WHATSAPP_GROUP_JID.test(selected.conversationJid)) return undefined; + const snapshot = await runtime.read(admission.auth, (database, snapshot) => { + const row = automationRecord(database.query("SELECT jid,kind,name FROM chats WHERE jid=? AND kind='group'").get(selected.conversationJid), ["jid", "kind", "name"]); + if (row.jid !== selected.conversationJid) throw new Error("WhatsApp group coordinate changed"); + return snapshot; + }, signal); + return groupRoster(admission, snapshot, selected, operation, signal); + } return { provider: "whatsapp", + groupConversations: { version: 1 }, start(signal) { return run("start", signal, (admission, signal) => runtime.start(admission.auth, async () => { if (sha(await options.authorize("start", signal)) !== sha(admission)) throw new Error("WhatsApp synchronization permission changed before launch"); }, signal)); }, inspect(signal) { return run("inspect", signal, (admission, signal) => runtime.read(admission.auth, (_database, snapshot) => status(identity(admission, snapshot), snapshot), signal)); }, conversations(input, signal) { const limit = automationInteger(input.limit, 1, 200); - return run("conversations", signal, (admission, signal) => runtime.read(admission.auth, (database, snapshot) => { - const rows = database.query("SELECT jid,kind,name FROM chats WHERE kind='dm' AND jid<>? ORDER BY last_message_ts DESC,jid ASC LIMIT ?").all(snapshot.account, limit + 1); - return { identity: identity(admission, snapshot), conversations: rows.slice(0, limit).map(conversation), complete: rows.length <= limit }; - }, signal)); + return run("conversations", signal, async (admission, signal) => { + const query = input.includeGroups === true ? "SELECT jid,kind,name FROM chats WHERE kind IN ('dm','group') AND jid<>? ORDER BY last_message_ts DESC,jid ASC LIMIT ?" : "SELECT jid,kind,name FROM chats WHERE kind='dm' AND jid<>? ORDER BY last_message_ts DESC,jid ASC LIMIT ?"; + const page = await runtime.read(admission.auth, (database, snapshot) => ({ snapshot, rows: database.query(query).all(snapshot.account, limit + 1) }), signal); + const conversations: AutomationConversation[] = []; + let complete = page.rows.length <= limit; + for (const value of page.rows.slice(0, limit)) { + const row = automationRecord(value, ["jid", "kind", "name"]); + if (row.kind === "dm") { conversations.push(conversation(row)); continue; } + try { conversations.push(conversation(row, await groupRoster(admission, page.snapshot, coordinate({ provider: "whatsapp", conversationJid: row.jid }), "conversations", signal))); } + catch { signal.throwIfAborted(); complete = false; } + } + return { identity: identity(admission, page.snapshot), conversations, complete }; + }); + }, + resolve(value, signal) { + const selected = coordinate(value); + return run("resolve", signal, async (admission, signal) => { + const participants = await verifiedGroup(admission, selected, "resolve", signal); + return runtime.read(admission.auth, (database, snapshot) => ({ identity: identity(admission, snapshot), conversation: exact(database, selected, snapshot.account, participants) }), signal); + }); }, - resolve(value, signal) { const selected = coordinate(value); return run("resolve", signal, (admission, signal) => runtime.read(admission.auth, (database, snapshot) => ({ identity: identity(admission, snapshot), conversation: exact(database, selected, snapshot.account) }), signal)); }, history(input, signal) { if (input.before !== undefined || input.after !== undefined) return Promise.reject(new Error("Dated history windows are unavailable for WhatsApp.")); const selected = coordinate(input.coordinate), limit = automationInteger(input.limit, 1, 200); - return run("history", signal, (admission, signal) => runtime.read(admission.auth, (database, snapshot) => { + return run("history", signal, async (admission, signal) => { + const participants = await verifiedGroup(admission, selected, "history", signal); + return runtime.read(admission.auth, (database, snapshot) => { if (!snapshot.ledgerReady) throw new Error("Start the owned WhatsApp connection before enrollment"); - exact(database, selected, snapshot.account); const current = identity(admission, snapshot), bounds = ledgerBounds(database); + exact(database, selected, snapshot.account, participants); const current = identity(admission, snapshot), bounds = ledgerBounds(database); const rows = database.query(`SELECT CASE WHEN revoked=1 OR deleted_for_me=1 THEN 'delete' WHEN reaction_to_id IS NOT NULL AND reaction_to_id<>'' THEN 'reaction' ELSE 'message' END AS kind,${messageColumns} FROM messages WHERE chat_jid=? ORDER BY ts DESC,rowid DESC LIMIT ?`).all(selected.conversationJid, limit).reverse(); return { identity: current, messages: rows.map(message), nextCursor: encodeCursor({ version: 1, identity: sha(current), scope: sha([selected]), sequence: bounds.last, anchor: ledgerAnchor(database, bounds.last) }), caughtUp: true, gap: false }; - }, signal)); + }, signal); }); }, events(input, signal) { const coordinates = automationArray(input.coordinates, 50).map(coordinate).sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))), limit = automationInteger(input.limit, 1, 500); if (!coordinates.length || new Set(coordinates.map(sha)).size !== coordinates.length) throw new Error("Distinct WhatsApp event scopes are required"); - return run("events", signal, (admission, signal) => runtime.read(admission.auth, (database, snapshot) => { + return run("events", signal, async (admission, signal) => { + const rosters = new Map(); + for (const selected of coordinates) rosters.set(selected.conversationJid, await verifiedGroup(admission, selected, "events", signal)); + return runtime.read(admission.auth, (database, snapshot) => { if (!snapshot.ledgerReady || !snapshot.connected) throw new Error("The owned WhatsApp message feed is unavailable"); - for (const selected of coordinates) exact(database, selected, snapshot.account); + for (const selected of coordinates) exact(database, selected, snapshot.account, rosters.get(selected.conversationJid)); const current = identity(admission, snapshot), next = parseCursor(input.cursor, current, coordinates), bounds = ledgerBounds(database); if (next.sequence > bounds.last || next.sequence < bounds.first - 1 || next.sequence > 0 && ledgerAnchor(database, next.sequence) !== next.anchor) return { identity: current, messages: [], nextCursor: encodeCursor(next), caughtUp: false, gap: true }; const placeholders = coordinates.map(() => "?").join(","); @@ -131,7 +169,7 @@ export function createWhatsAppAutomationProvider(options: WhatsAppAutomationOpti const page = rows.slice(0, limit).map(value => { const row = automationRecord(value, ["sequence", "kind", ...messageColumns.split(",")]); const sequence = automationInteger(row.sequence, next.sequence + 1, bounds.last); next.sequence = sequence; const { sequence: _sequence, ...projection } = row; return message(projection); }); const caughtUp = rows.length <= limit; if (caughtUp) next.sequence = bounds.last; next.anchor = ledgerAnchor(database, next.sequence); return { identity: current, messages: page, nextCursor: encodeCursor(next), caughtUp, gap: false }; - }, signal)); + }, signal); }); }, async send(input, signal): Promise { const selected = coordinate(input.coordinate), expected = parseAutomationIdentity(input.identity), action = parseAutomationAction(input.action), intentId = automationText(input.intentId, 256); @@ -139,7 +177,10 @@ export function createWhatsAppAutomationProvider(options: WhatsAppAutomationOpti try { return await run(action.kind, signal, async (admission, signal) => { try { - let request: WhatsAppPrivateRequest = { protocol: WHATSAPP_AUTOMATION_PROTOCOL, kind: "text", requestId: sha({ intentId, expected, selected, action }), generation: "", account: "", to: selected.conversationJid, message: "", file: "", filename: "", mime: "", id: "", reaction: "", question: "", options: [], selectable: 0 }; + const group = AUTOMATION_WHATSAPP_GROUP_JID.test(selected.conversationJid); + const bound = group ? parseAutomationConversation(input.conversation) : undefined; + if (bound && (bound.kind !== "group" || sha(bound.coordinate) !== sha(selected))) throw new Error("The group action needs its exact enrollment binding"); + let request: WhatsAppPrivateRequest = { protocol: WHATSAPP_AUTOMATION_PROTOCOL, kind: "text", requestId: sha({ intentId, expected, selected, action, ...(bound ? { participants: bound.participants } : {}) }), generation: "", account: "", to: selected.conversationJid, message: "", file: "", filename: "", mime: "", id: "", reaction: "", question: "", options: [], selectable: 0, ...(bound ? { expectedParticipants: bound.participants } : {}) }; if (action.kind === "text") request = { ...request, message: action.text }; else if (action.kind === "link") request = { ...request, message: action.url }; else if (action.kind === "reaction") request = { ...request, kind: "react", id: action.messageId, reaction: action.remove ? "" : action.emoji }; @@ -150,16 +191,18 @@ export function createWhatsAppAutomationProvider(options: WhatsAppAutomationOpti request = { ...request, kind: action.kind === "sticker" ? "sticker" : "file", file: asset.path, filename: action.kind === "attachment" ? action.name : "", mime: action.kind === "attachment" ? action.mimeType : "" }; } else throw new Error("The pinned WhatsApp transport does not support this action"); if (sha(await options.authorize(action.kind, signal)) !== sha(admission)) throw new Error("WhatsApp permission changed before dispatch"); + const participants = await verifiedGroup(admission, selected, action.kind, signal); + if (bound && sha(participants) !== sha(bound.participants)) return { state: "not-started", reason: AUTOMATION_BINDING_CHANGED_REASON }; request = await runtime.read(admission.auth, (database, snapshot) => { const current = identity(admission, snapshot); if (sha(current) !== sha(expected) || !status(current, snapshot).actions[action.kind].available || !snapshot.generation) throw new Error("WhatsApp source identity or capability changed before dispatch"); - exact(database, selected, snapshot.account); + exact(database, selected, snapshot.account, participants); if (action.kind === "reaction" && !database.query("SELECT msg_id FROM messages WHERE chat_jid=? AND msg_id=? AND revoked=0 AND deleted_for_me=0").get(selected.conversationJid, action.messageId)) throw new Error("WhatsApp reaction target is unavailable"); return { ...request, account: snapshot.account, generation: snapshot.generation }; }, signal); signal?.throwIfAborted(); dispatched = true; const result = await runtime.request(request, signal, async () => { if (sha(await options.authorize(action.kind, signal)) !== sha(admission)) throw new Error("WhatsApp permission changed before writing the action"); }); if (result.state === "accepted") return { state: "accepted", messageId: result.messageId || null, providerReceiptId: result.requestId, delivery: "unknown" }; - return result.state === "not-started" ? { state: "not-started", reason: "The exact WhatsApp request was not admitted." } : { state: "indeterminate", reason: "The WhatsApp outcome is uncertain and cannot be retried." }; + return result.state === "not-started" ? { state: "not-started", reason: result.bindingChanged ? AUTOMATION_BINDING_CHANGED_REASON : "The exact WhatsApp request was not admitted." } : { state: "indeterminate", reason: "The WhatsApp outcome is uncertain and cannot be retried." }; } finally { await asset?.close(); } }); } catch { return { state: dispatched ? "indeterminate" : "not-started", reason: dispatched ? "The WhatsApp receipt or cleanup could not be verified; do not retry." : "The selected WhatsApp account, permission, target, or action was unavailable." }; } diff --git a/src/version.ts b/src/version.ts index 16a93e5a..9ac0aab2 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ /** Canonical immutable Ghostget package release identity. */ -export const GHOSTGET_VERSION = "0.18.70" as const; +export const GHOSTGET_VERSION = "0.18.71" as const; diff --git a/verification/claims.json b/verification/claims.json index 40dd69c7..cba7c980 100644 --- a/verification/claims.json +++ b/verification/claims.json @@ -3354,6 +3354,42 @@ "Only the enumerated example cases are checked." ] }, + { + "id": "messaging-automation-group-epochs", + "statement": "Observed group binding drift permanently disables the old enrollment and its grants; a replacement enrollment has a new ID and cannot import prior-roster baseline bodies, historical backfill or unknown message mutations.", + "area": "messaging", + "source": { + "path": "docs/messaging-automation.md", + "quote": "Observed group binding drift permanently invalidates that enrollment." + }, + "layer": "property", + "status": "evidenced", + "evidence": [ + "src/messaging-automation-groups.test.ts", + "verification/mutants.json", + "src/messaging-automation.test.ts", + "src/messaging-automation-server.test.ts", + "src/providers/imessage-automation.test.ts", + "src/providers/beeper-automation.test.ts", + "src/providers/whatsapp-automation.test.ts", + "src/providers/whatsapp-automation-runtime.test.ts" + ], + "assumptions": [ + "filesystem-durability", + "provider-behaviour" + ], + "notVerified": [ + "The model samples 30 schedules of up to 14 actions plus retained workload seeds over the production SQLite host with injected providers. It covers polling, sending, roster changes (including an authoritative empty roster) and restoration, restart, title changes and lookup faults; it does not exhaust arbitrary concurrent schedules or prove provider liveness. Two seeded defects, transient binding invalidation and group reads from the unrestricted provider archive, are killed by their named regression tests.", + "History isolation trusts the reviewed provider's original creation timestamp and cursor semantics. It does not cover a compromised provider or host, or a roster change restored between every authoritative observation.", + "The provider effect-boundary checks use synthetic adapters; no real account, pairing, message send or delivery qualification is implied." + ], + "properties": [ + { + "path": "src/messaging-automation-groups.test.ts", + "test": "bounded group lifecycle schedules never revive an observed old binding" + } + ] + }, { "id": "messaging-automation-grant-scoped", "statement": "The owner messaging host requires explicit allow grants for messaging.automation.* operations; old messaging.send allow, ask, deny, or unmanaged policy provide no unattended authority, and changed manifests or closures invalidate grants.", diff --git a/verification/mutants.json b/verification/mutants.json index fbdccc4b..4ae7431c 100644 --- a/verification/mutants.json +++ b/verification/mutants.json @@ -1,6 +1,24 @@ { "schema": "ghostget-source-mutants-v1", "mutants": [ + { + "id": "group-binding-drift-transient", + "file": "src/messaging-automation.ts", + "defect": "Observed group drift is only a transient readiness failure, so the old enrollment can revive when its roster returns.", + "search": " this.db.query(\"UPDATE enrollments SET ready=0,reason=? WHERE id=?\").run(AUTOMATION_BINDING_CHANGED_REASON, id);", + "replace": " this.db.query(\"UPDATE enrollments SET ready=0,reason=? WHERE id=?\").run(\"Temporary group change\", id);", + "test": "src/messaging-automation-groups.test.ts", + "killedBy": ["observed roster drift permanently revokes old authority across restoration and restart"] + }, + { + "id": "group-history-provider-archive", + "file": "src/messaging-automation.ts", + "defect": "A group history window reads the unrestricted provider archive and can expose prior-roster messages.", + "search": " if (enrollment.conversation.kind === \"group\") {\n // A dated archive read cannot prove which roster could see old content.", + "replace": " if (false) {\n // A dated archive read cannot prove which roster could see old content.", + "test": "src/messaging-automation-groups.test.ts", + "killedBy": ["group live history excludes baseline bodies and mutations of unknown old messages"] + }, { "id": "run-journal-release-verified-ledger", "file": "src/run-journal.ts", diff --git a/verification/seeds/corpus.json b/verification/seeds/corpus.json index f8c0b228..77579110 100644 --- a/verification/seeds/corpus.json +++ b/verification/seeds/corpus.json @@ -48,6 +48,16 @@ } ] }, + "messaging-automation/groups-lifecycle": { + "file": "src/messaging-automation-groups.test.ts", + "entries": [ + { + "kind": "workload", + "seed": -1090781564, + "origin": "The initial group lifecycle check exceeded the generic 10-second property budget after eight schedules over durable SQLite hosts; no counterexample or shrink was reported. Replay retains this workload with the explicit 90-second I/O property budget and repository 180-second Bun runner policy." + } + ] + }, "verification-claims/guideline-additions": { "file": "scripts/verification-claims.test.ts", "entries": [ @@ -67,7 +77,7 @@ "kind": "counterexample", "seed": 42458414, "path": "148:42:40:40:40:40:40:40:40:40:40:40:40", - "origin": "Required verification on PR #381 shrank a drive-letter input reached through a backslash, https:user:pass@a-.co\\m:/../a?q=ä, that comparedParse missed.", + "origin": "Required verification on PR #381 shrank a drive-letter input reached through a backslash, https:user:pass@a-.co\\m:/../a?q=\u00e4, that comparedParse missed.", "regression": "the known drive-letter difference through a backslash leaves both sides refusing the input" } ] diff --git a/website/build.ts b/website/build.ts index e726ffcf..95d3939f 100644 --- a/website/build.ts +++ b/website/build.ts @@ -136,7 +136,7 @@ export const HRANESS_LOGO_URL = "https://hraness.com/icon.png" as const; export const HRANESS_LINKEDIN_URL = "https://www.linkedin.com/company/hraness" as const; export const NPM_PACKAGE_URL = "https://www.npmjs.com/package/@hraness/ghostget" as const; export const SKILL_REPOSITORY = "hraness/ghostget" as const; -export const CONTENT_REVIEWED_RELEASE = "v0.18.70" as const; +export const CONTENT_REVIEWED_RELEASE = "v0.18.71" as const; export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" as const; export const DEMO_PUBLIC_FILES = [ "wrench-first-capture.gif", @@ -870,11 +870,12 @@ const CONTENT_FOOTER_LINKS = [ { href: "/about/", label: "About" }, { href: "/contact/", label: "Contact" }, { href: "/privacy/", label: "Privacy" }, + { href: "/llms.txt", label: "llms.txt" }, { href: REPOSITORY_URL, label: 'GitHub ' }, ] as const; // The in-flow product footer is GhostGet's own composition around the shared -// Hraness network footer: same row contract, GhostGet brand, eight links. +// Hraness network footer: same row contract and GhostGet brand. function renderGhostgetContentFooter(): string { const links = CONTENT_FOOTER_LINKS .map(({ href, label }) => `${label}`) @@ -891,7 +892,8 @@ function renderGhostgetContentFooter(): string { // Every page carries the product's content footer immediately before the // shared Hraness network footer so the two read as one band; indexable pages -// keep the Ask AI row above the pair. +// keep the Ask AI row above the pair. Templates must not add another project +// information row or duplicate this navigation. function renderInFlowFooters(options: RenderOptions, page?: PublicPage): string { const above = page === undefined ? "" diff --git a/website/launch/facts.ts b/website/launch/facts.ts index de3cc922..a5e5c46f 100644 --- a/website/launch/facts.ts +++ b/website/launch/facts.ts @@ -29,10 +29,10 @@ export const LAUNCH_SERVICE_COUNT = 21 as const; export const LAUNCH_CLAIMS_NOT_VERIFIED = 19 as const; /** Every claim in verification/claims.json. */ -export const LAUNCH_CLAIMS_TOTAL = 247 as const; +export const LAUNCH_CLAIMS_TOTAL = 248 as const; /** Claims in verification/claims.json with status evidenced. */ -export const LAUNCH_CLAIMS_EVIDENCED = 228 as const; +export const LAUNCH_CLAIMS_EVIDENCED = 229 as const; /** Claims in verification/claims.json whose layer is configuration-readback: repository and hosting settings an administrator reads back. */ export const LAUNCH_CLAIMS_CONFIG_READBACK = 15 as const; diff --git a/website/site.test.ts b/website/site.test.ts index ae57cdf9..194f8b4e 100644 --- a/website/site.test.ts +++ b/website/site.test.ts @@ -702,7 +702,8 @@ describe("ghostget.com static site", () => { expect(html).not.toMatch(/hero-field|hero-orbit|hero-glyph/u); expect(html).not.toMatch(/observed provider operations|capture-required|unavailable reservations/iu); expect(html).not.toContain("🔧"); - expect(html).toContain(`href="${PUBLISHER_URL}">Hraness GitHub organization`); + expect(html).toContain(`href="${PUBLISHER_URL}"`); + expect(html).toContain('aria-label="Hraness on GitHub"'); expect(preview).toContain("GhostGet preview"); expect(preview).toContain(''); expect(preview).toContain(''); @@ -937,6 +938,8 @@ describe("ghostget.com static site", () => { .map((match) => match[0]); expect(footers).toHaveLength(2); const [contentFooter, footer] = footers; + expect(document).not.toContain('aria-label="GhostGet project information"'); + expect(document).not.toMatch(/

GhostGet(?: [\d.]+)? · MIT ·/u); expect(contentFooter).toContain('aria-label="GhostGet" class="hraness-marketing-footer" data-hraness-marketing="footer"'); expect(contentFooter).toContain('GhostGet'); expect(contentFooter).not.toContain('src="/icon.png"'); @@ -947,6 +950,7 @@ describe("ghostget.com static site", () => { expect(contentFooter).toContain('class="hraness-marketing-footer__link" href="/blog/"'); expect(contentFooter).toContain('class="hraness-marketing-footer__link" href="/contact/"'); expect(contentFooter).toContain('class="hraness-marketing-footer__link" href="/privacy/"'); + expect(contentFooter?.match(/href="\/llms\.txt"/gu)).toHaveLength(1); expect(contentFooter).toContain('class="hraness-marketing-footer__link" href="https://github.com/hraness/ghostget"'); expect(document.indexOf('data-hraness-marketing="footer"')) .toBeLessThan(document.indexOf('data-slot="hraness-site-footer"')); diff --git a/website/source/404.html b/website/source/404.html index a86cdac6..3665a498 100644 --- a/website/source/404.html +++ b/website/source/404.html @@ -34,9 +34,6 @@

{{STATUS_PAGE}}
- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/about.html b/website/source/about.html index 55763ad9..e11dce66 100644 --- a/website/source/about.html +++ b/website/source/about.html @@ -80,9 +80,6 @@

Start with the current release

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/agentic-web-spoofing.html b/website/source/agentic-web-spoofing.html index 8a8b8a46..a05dfbdc 100644 --- a/website/source/agentic-web-spoofing.html +++ b/website/source/agentic-web-spoofing.html @@ -70,9 +70,6 @@

How to verify a web agent’s identity

GhostGet applies that distinction through named account actions and previews for consequential writes. Its security guide describes those controls. Website owners still need to enforce their own authentication and access rules on incoming traffic.

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/blog.html b/website/source/blog.html index d78c5798..4bab7eeb 100644 --- a/website/source/blog.html +++ b/website/source/blog.html @@ -53,9 +53,6 @@
{{BLOG_MAIN}}
- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/claims.html b/website/source/claims.html index d7a42540..579c41d2 100644 --- a/website/source/claims.html +++ b/website/source/claims.html @@ -102,9 +102,6 @@

Where the register lives

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/compare-agent-browser.html b/website/source/compare-agent-browser.html index 983fbd81..3ed9da9d 100644 --- a/website/source/compare-agent-browser.html +++ b/website/source/compare-agent-browser.html @@ -117,9 +117,6 @@

Verify the pin yourself

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/compare-browser-use.html b/website/source/compare-browser-use.html index 546737c4..ac1f2f3d 100644 --- a/website/source/compare-browser-use.html +++ b/website/source/compare-browser-use.html @@ -129,9 +129,6 @@

See the boundary before you install

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/compare-browserbase.html b/website/source/compare-browserbase.html index 854e535a..ed849f82 100644 --- a/website/source/compare-browserbase.html +++ b/website/source/compare-browserbase.html @@ -128,9 +128,6 @@

Check the operation set first

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/compare-firecrawl.html b/website/source/compare-firecrawl.html index 260c3ed0..3c977574 100644 --- a/website/source/compare-firecrawl.html +++ b/website/source/compare-firecrawl.html @@ -129,9 +129,6 @@

Read one page locally first

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/compare-index.html b/website/source/compare-index.html index 3fb2fb4f..f1ee127f 100644 --- a/website/source/compare-index.html +++ b/website/source/compare-index.html @@ -171,9 +171,6 @@

Read the detailed comparisons

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/compare-jina-reader.html b/website/source/compare-jina-reader.html index c4801f0e..7b02943c 100644 --- a/website/source/compare-jina-reader.html +++ b/website/source/compare-jina-reader.html @@ -134,9 +134,6 @@

Read the same page both ways

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/compare-personal-agents-browser-use.html b/website/source/compare-personal-agents-browser-use.html index 7cc8305d..9d530b2f 100644 --- a/website/source/compare-personal-agents-browser-use.html +++ b/website/source/compare-personal-agents-browser-use.html @@ -77,9 +77,6 @@

Browser control or named actions: choosing an agent tool

GhostGet does not silently replace an unavailable action with browser control. Check the supported actions before connecting an account, then use the security guide to understand confirmation and recovery.

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/compare-playwright-mcp.html b/website/source/compare-playwright-mcp.html index 7c2ec023..b94038e8 100644 --- a/website/source/compare-playwright-mcp.html +++ b/website/source/compare-playwright-mcp.html @@ -123,9 +123,6 @@

Measure before you standardize

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/contact.html b/website/source/contact.html index dd128035..bc2e2169 100644 --- a/website/source/contact.html +++ b/website/source/contact.html @@ -80,9 +80,6 @@

Read before you file

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/docs-explanation-security-model.html b/website/source/docs-explanation-security-model.html index 3fb299ec..79f4faae 100644 --- a/website/source/docs-explanation-security-model.html +++ b/website/source/docs-explanation-security-model.html @@ -118,9 +118,6 @@

Use only authorized material and accounts

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/docs-how-to-author-provider-plugin.html b/website/source/docs-how-to-author-provider-plugin.html index 6c47a1af..e01ecdbf 100644 --- a/website/source/docs-how-to-author-provider-plugin.html +++ b/website/source/docs-how-to-author-provider-plugin.html @@ -134,9 +134,6 @@

Inspect before you extend

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/docs-how-to-capture-and-archive.html b/website/source/docs-how-to-capture-and-archive.html index 224bf74a..4ef90573 100644 --- a/website/source/docs-how-to-capture-and-archive.html +++ b/website/source/docs-how-to-capture-and-archive.html @@ -112,9 +112,6 @@

Before you connect an account

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/docs-how-to-connect-beeper.html b/website/source/docs-how-to-connect-beeper.html index 047fc52d..bcfe3d4c 100644 --- a/website/source/docs-how-to-connect-beeper.html +++ b/website/source/docs-how-to-connect-beeper.html @@ -174,9 +174,6 @@

Inspect the local state before binding an account

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/docs-how-to-export-whatsapp.html b/website/source/docs-how-to-export-whatsapp.html index e0d98d60..09b3ddbf 100644 --- a/website/source/docs-how-to-export-whatsapp.html +++ b/website/source/docs-how-to-export-whatsapp.html @@ -98,7 +98,8 @@

Exact macOS arm64 export runtime

Owner-controlled messaging is a separate setup

-

The trusted ghostget messaging automation serve --stdio host supports enrolled individual conversations through a separate, pinned wacli build with reviewed patches. It requires an existing account, explicit managed operation permissions, and the exact installed automation runtime. Stock Wacli and the export runtime above do not establish automation readiness.

+

The trusted ghostget messaging automation serve --stdio host supports enrolled direct and group conversations through a separate, pinned wacli build with reviewed patches. It requires an existing account, explicit managed operation permissions, and the exact installed automation runtime. Stock Wacli and the export runtime above do not establish automation readiness.

+

Groups require explicit enrollment with a complete participant list. Their history begins at enrollment, and an observed account or participant change stops the enrollment and revokes its sends. The host checks the participant list again before each send.

Initialization does not pair or start synchronization. The owner must explicitly start sync and grant a conversation its allowed action kinds, expiry, interval, and capacity. Available text, attachment, reaction, sticker, link, and poll actions depend on current account and runtime capabilities. The messaging.automation.* catalog entries are permission ceilings, not operations callable through generic invoke or confirm.

The host retains private plaintext conversation history and an action journal. Historical bootstrap is not a new-message event, gaps block automatic sends, and uncertain actions are not resubmitted. Provider acceptance does not prove delivery. This contract description does not claim a paired account or a successful live send on your machine.

Follow the release's owner messaging setup and recovery guide and review local data retention before enabling it.

@@ -110,9 +111,6 @@

Inspect the installed boundary

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/docs-how-to-use-webmcp-sites.html b/website/source/docs-how-to-use-webmcp-sites.html index c92a0707..24ce01d5 100644 --- a/website/source/docs-how-to-use-webmcp-sites.html +++ b/website/source/docs-how-to-use-webmcp-sites.html @@ -98,9 +98,6 @@

Keep exploring

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/docs-index.html b/website/source/docs-index.html index 0e181baf..96ca8e85 100644 --- a/website/source/docs-index.html +++ b/website/source/docs-index.html @@ -105,9 +105,6 @@

Start with the tutorial

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/docs-reference-provider-capabilities.html b/website/source/docs-reference-provider-capabilities.html index 72409265..19528d94 100644 --- a/website/source/docs-reference-provider-capabilities.html +++ b/website/source/docs-reference-provider-capabilities.html @@ -86,9 +86,6 @@

Set up only the service you need

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/docs-tutorials-getting-started.html b/website/source/docs-tutorials-getting-started.html index 8c32a4a2..4f24d049 100644 --- a/website/source/docs-tutorials-getting-started.html +++ b/website/source/docs-tutorials-getting-started.html @@ -196,9 +196,6 @@

Choose your next task

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/index.html b/website/source/index.html index 29dfff36..2b403f7a 100644 --- a/website/source/index.html +++ b/website/source/index.html @@ -392,9 +392,6 @@

{{PORTFOLIO_HEADING: - {{HRANESS_SITE_FOOTER}} diff --git a/website/source/llms.txt b/website/source/llms.txt index c40ccc06..0eb1a32f 100644 --- a/website/source/llms.txt +++ b/website/source/llms.txt @@ -20,7 +20,7 @@ Do not use GhostGet as an AI agent, hosted HTTP API, OpenAPI surface, OAuth deve - [First-capture demo](https://ghostget.com/docs/tutorials/getting-started/#demo): a 12-second successful public-page read with reusable video, GIF, PNG, and a text transcript - [Provider support](https://ghostget.com/docs/reference/provider-capabilities/): generic actions and separate owner-only messaging permissions, grouped by service and access method; catalog presence is not current account or runtime readiness - [Beeper support](https://ghostget.com/docs/how-to/connect-beeper/): {{BEEPER_OBSERVED_OPERATION_COUNT}} supported actions. {{BEEPER_CLI_BACKED_OPERATION_COUNT}} run through the pinned `@beeper/cli` {{BEEPER_CLI_VERSION}} executable and {{BEEPER_DESKTOP_LOOPBACK_OPERATION_COUNT}} use fixed Desktop loopback reads. Message mutations require preview and confirmation. Tagged `{{BEEPER_CLI_SOURCE_PACKAGE_PATH}}` declares {{BEEPER_CLI_SOURCE_DECLARED_VERSION}} and is provenance-only; exact executable runtime identity remains authoritative -- [WhatsApp support](https://ghostget.com/docs/how-to/export-whatsapp/): four read-only local actions and a private seven-file export for TextButler (formerly Message Like Me), in the Message Like Me schema-2 format, from an existing Wacli 0.15.0 store; the export does not pair, sync, or send. The separate owner automation host uses a pinned reviewed wacli build, explicit sync permission and start, enrolled individual conversations, and bounded revocable send grants +- [WhatsApp support](https://ghostget.com/docs/how-to/export-whatsapp/): four read-only local actions and a private seven-file export for TextButler (formerly Message Like Me), in the Message Like Me schema-2 format, from an existing Wacli 0.15.0 store; the export does not pair, sync, or send. The separate owner automation host uses a pinned reviewed wacli build, explicit sync permission and start, explicitly enrolled direct or group conversations, complete participant checks for groups, and bounded revocable send grants - [Provider directory](https://ghostget.com/providers/): every service GhostGet supports, plus per-domain pages for sites listed in the public WebMCP Registry. GhostGet's `webmcp` adapter reads each site's tool schema from the registry and calls only read-only tools through it - [WebMCP explainer](https://ghostget.com/webmcp/): how WebMCP sites publish tools through `navigator.modelContext`, what the registry adds, and the three `webmcp` operations - [Use WebMCP sites](https://ghostget.com/docs/how-to/use-webmcp-sites/): `ghostget webmcp sites.search`, `sites.get`, and `tools.call` in sequence, with their inputs and limits; no account needed diff --git a/website/source/omarchy-root-escalation.html b/website/source/omarchy-root-escalation.html index 446de015..aa74e2c0 100644 --- a/website/source/omarchy-root-escalation.html +++ b/website/source/omarchy-root-escalation.html @@ -67,9 +67,6 @@

Review the administrator access a tool needs

Permission removal also matters. After an experiment or an uninstalled tool, review persistent services and access rules through the operating system’s settings. Removing the application’s folder may not remove permissions or background components it installed elsewhere.

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/paypal-grapheneos-attestation.html b/website/source/paypal-grapheneos-attestation.html index 3fd2fe17..fc61eb20 100644 --- a/website/source/paypal-grapheneos-attestation.html +++ b/website/source/paypal-grapheneos-attestation.html @@ -67,9 +67,6 @@

What a device integrity check tells an app

A tool that connects to the account should use a provider-supported connection. Adding an agent to the workflow does not change the service’s device requirements.

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/privacy.html b/website/source/privacy.html index d789006d..46b7554e 100644 --- a/website/source/privacy.html +++ b/website/source/privacy.html @@ -129,9 +129,6 @@

Related public documents

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/provider-webmcp-site.html b/website/source/provider-webmcp-site.html index f67861fb..e2fd0531 100644 --- a/website/source/provider-webmcp-site.html +++ b/website/source/provider-webmcp-site.html @@ -86,7 +86,6 @@

What to expect

-

GhostGet {{GHOSTGET_VERSION}} · MIT · Source on GitHub · About · Contact · Privacy · llms.txt

{{HRANESS_SITE_FOOTER}} diff --git a/website/source/providers.html b/website/source/providers.html index a080dc55..780cc233 100644 --- a/website/source/providers.html +++ b/website/source/providers.html @@ -70,7 +70,6 @@

WebMCP Registry sites

-

GhostGet {{GHOSTGET_VERSION}} · MIT · Source on GitHub · About · Contact · Privacy · llms.txt

{{HRANESS_SITE_FOOTER}} diff --git a/website/source/rumour-is-the-exploit.html b/website/source/rumour-is-the-exploit.html index 01516310..618f78fd 100644 --- a/website/source/rumour-is-the-exploit.html +++ b/website/source/rumour-is-the-exploit.html @@ -67,9 +67,6 @@

Turn a bug report into a reproducible test

Send the reduced input, expected result, actual result, and reproduction steps through the project’s bug-reporting channel. For a security-sensitive report, use its private vulnerability-reporting process.

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/styles.css b/website/source/styles.css index 4e264a58..6c4e1fd9 100644 --- a/website/source/styles.css +++ b/website/source/styles.css @@ -1018,31 +1018,11 @@ code:not(pre code):not(.hraness-marketing-flow__code) { justify-content: space-between; } -.site-disclosure { - align-items: center; - background: var(--dark-background); - color: var(--dark-muted); - display: flex; - font-size: 0.78rem; - gap: 1.5rem; - justify-content: space-between; - min-height: 6rem; - padding: 1.5rem max(var(--page-inline), env(safe-area-inset-right)) max(1.5rem, env(safe-area-inset-bottom)) max(var(--page-inline), env(safe-area-inset-left)); -} - -.site-disclosure p { - margin: 0; -} - .privacy-note { line-height: 1.5; max-width: 48rem; } -.site-disclosure a { - color: var(--dark-foreground); -} - .guide-main { margin-inline: auto; max-width: 100rem; @@ -1753,15 +1733,10 @@ code:not(pre code):not(.hraness-marketing-flow__code) { grid-template-columns: 1fr; } - .final-section, - .site-disclosure { + .final-section { align-items: stretch; flex-direction: column; } - - .site-disclosure { - justify-content: center; - } } @media (max-width: 32.5rem) { @@ -1829,7 +1804,6 @@ code:not(pre code):not(.hraness-marketing-flow__code) { .hero, .preview-card, .section-dark, - .site-disclosure, .topbar { background: Canvas; color: CanvasText; @@ -2510,18 +2484,6 @@ body { text-align: right; } -.site-disclosure { - background: var(--background); - border-top: 1px solid var(--line); - color: var(--muted); - font-size: 0.85rem; - padding-inline: var(--page-inline); -} - -.site-disclosure a { - color: var(--foreground); -} - /* Inert social preview keeps its dark card; only the type changes. */ .preview-body, .preview-card { @@ -2918,8 +2880,7 @@ body { .command-panel, .prompt-card, .guide-callout, - .topbar, - .site-disclosure { + .topbar { background: Canvas; border-color: CanvasText; color: CanvasText; @@ -3171,7 +3132,6 @@ body { padding-inline: max(var(--ghostget-column-inset), env(safe-area-inset-left)) max(var(--ghostget-column-inset), env(safe-area-inset-right)); } -.site-disclosure, .hraness-marketing-footer__inner { padding-inline: max(var(--ghostget-column-inset), env(safe-area-inset-left)) max(var(--ghostget-column-inset), env(safe-area-inset-right)); } @@ -3181,12 +3141,6 @@ body { max-inline-size: none; } -.site-disclosure { - align-items: flex-start; - flex-wrap: wrap; - min-height: 0; -} - .guide-article, :root[data-palette] .guide-article { margin-inline: 0; @@ -3792,18 +3746,21 @@ a[href] { a[href]:where(:hover, :focus-visible) { text-decoration-color: currentColor; } /* Keep settled analytics choices in the footer, clear of reading content. + * A fixed control moved into a grid needs intrinsic width and start alignment; + * otherwise its shared full-width root stretches across the available track. * The shared required-consent prompt retains its original placement. */ [data-slot="hraness-site-footer"] [data-slot="hraness-cookie-consent"]:is([data-consent-state="clear"], [data-consent-state="declined"]) { + inline-size: fit-content; inset: auto; + justify-self: start; position: static; z-index: auto; } -@media (max-width: 40rem) { - [data-slot="hraness-site-footer"] [data-slot="hraness-cookie-consent"]:is([data-consent-state="clear"], [data-consent-state="declined"]) details > div { - inset-inline: 0 auto; - max-inline-size: calc(100vw - var(--page-inline) - var(--page-inline)); - } +/* A settled control's disclosure follows its start edge at every breakpoint. */ +[data-slot="hraness-site-footer"] [data-slot="hraness-cookie-consent"]:is([data-consent-state="clear"], [data-consent-state="declined"]) details > div { + inset-inline: 0 auto; + max-inline-size: calc(100vw - var(--page-inline) - var(--page-inline)); } /* Static counterpart of design-kit MarketingAccount and MarketingAccountActions. */ diff --git a/website/source/vms-cannot-contain-agents.html b/website/source/vms-cannot-contain-agents.html index 4ac9ac2c..a5d472e2 100644 --- a/website/source/vms-cannot-contain-agents.html +++ b/website/source/vms-cannot-contain-agents.html @@ -71,9 +71,6 @@

What a virtual machine isolates, and what an agent can still do

For GhostGet’s account behavior, read the security guide. For choosing how the agent reaches websites in the first place, compare browser control and named actions.

- {{HRANESS_SITE_FOOTER}} diff --git a/website/source/webmcp.html b/website/source/webmcp.html index 338ea37a..5dfcf2f4 100644 --- a/website/source/webmcp.html +++ b/website/source/webmcp.html @@ -96,9 +96,6 @@

Try it against the registry

- {{HRANESS_SITE_FOOTER}}