@hraness/ghostget 0.17.6 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/README.md +16 -9
  3. package/dist/apple-photos-client.js +2 -1
  4. package/dist/beeper-client.js +2 -1
  5. package/dist/client.js +1 -0
  6. package/dist/imessage-direct-install-2gm3kge7.js +324 -0
  7. package/dist/index-01eeae9e.js +5 -0
  8. package/dist/index-2ymnp8xv.js +145 -0
  9. package/dist/index-en5hycxp.js +175 -0
  10. package/dist/{index-cytf8d9p.js → index-hfbygww8.js} +1 -1
  11. package/dist/index-n4szk3nw.js +50 -0
  12. package/dist/index-yq6maz71.js +1105 -0
  13. package/dist/index-z1w83f81.js +4 -0
  14. package/dist/index.js +4 -47
  15. package/dist/messaging-automation-api.js +21 -0
  16. package/dist/messaging-automation-j4274hvc.js +609 -0
  17. package/dist/messaging-native-install-8w1j36ah.js +16 -0
  18. package/dist/messaging.js +1 -0
  19. package/dist/omni-client.js +1 -0
  20. package/dist/whatsapp-automation-runtime-hgh1e9rm.js +845 -0
  21. package/dist/whatsapp-client.js +1 -0
  22. package/docs/control-panel.md +147 -0
  23. package/docs/imessage-direct-provider.md +31 -7
  24. package/docs/messaging-automation.md +103 -0
  25. package/package.json +66 -7
  26. package/skills/ghostget/SKILL.md +3 -1
  27. package/skills/ghostget/references/control-panel.md +43 -0
  28. package/skills/ghostget/references/install.md +5 -5
  29. package/skills/ghostget/references/linkedin-adapter.md +222 -12
  30. package/skills/ghostget/references/platform-patterns.md +1 -1
  31. package/src/args.ts +18 -5
  32. package/src/assets/adapters/imessage/wrench-web-adapter.json +126 -0
  33. package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
  34. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +144 -0
  35. package/src/assets/messaging-runtime/NOTICE.txt +2580 -0
  36. package/src/assets/messaging-runtime/imsg-darwin-arm64.gz +0 -0
  37. package/src/assets/messaging-runtime/phone-number-info.plist.gz +0 -0
  38. package/src/assets/messaging-runtime/phone-number-metadata.json.gz +0 -0
  39. package/src/assets/messaging-runtime/phone-number-privacy.plist.gz +0 -0
  40. package/src/assets/messaging-runtime/wacli-darwin-arm64.gz +0 -0
  41. package/src/auth.ts +35 -1
  42. package/src/beeper-client-types.ts +1 -1
  43. package/src/cli.ts +18 -0
  44. package/src/confirmed-write-platform.ts +16 -1
  45. package/src/control/account-revision.ts +16 -0
  46. package/src/control/activity.ts +104 -0
  47. package/src/control/approval-broker.ts +59 -0
  48. package/src/control/approval-client.ts +49 -0
  49. package/src/control/bundled-interfaces.ts +20 -0
  50. package/src/control/cli.ts +20 -0
  51. package/src/control/connections.ts +87 -0
  52. package/src/control/credential-helper.ts +152 -0
  53. package/src/control/helper.ts +79 -0
  54. package/src/control/interface-cli.ts +22 -0
  55. package/src/control/interface-json.ts +94 -0
  56. package/src/control/interface-schema.ts +120 -0
  57. package/src/control/interfaces.ts +438 -0
  58. package/src/control/protocol.ts +182 -0
  59. package/src/control/service.ts +104 -0
  60. package/src/control/validation.ts +103 -0
  61. package/src/control/vault.ts +105 -0
  62. package/src/control/web-gateway.ts +62 -0
  63. package/src/control/web-policy.ts +56 -0
  64. package/src/ghostget.ts +18 -4
  65. package/src/messaging-automation-api.ts +12 -0
  66. package/src/messaging-automation-descriptors.ts +29 -0
  67. package/src/messaging-automation-factory.ts +148 -0
  68. package/src/messaging-automation-server.ts +183 -0
  69. package/src/messaging-automation-types.ts +155 -0
  70. package/src/messaging-automation-validation.ts +110 -0
  71. package/src/messaging-automation.ts +356 -0
  72. package/src/messaging-runtime.ts +3 -0
  73. package/src/oauth-google.ts +11 -5
  74. package/src/omni-runtime.ts +18 -3
  75. package/src/operation-permission-store.ts +92 -0
  76. package/src/operation-permission.ts +308 -0
  77. package/src/pinned-https.ts +5 -0
  78. package/src/plugins/imessage-direct/plugin.ts +12 -1
  79. package/src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch +276 -0
  80. package/src/plugins/imessage-direct/vendor/provenance.json +84 -10
  81. package/src/plugins/linkedin-web/plugin.ts +1 -1
  82. package/src/plugins/whatsapp-linked-device/plugin.ts +7 -2
  83. package/src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch +1093 -0
  84. package/src/plugins/whatsapp-linked-device/vendor/README.md +39 -0
  85. package/src/plugins/whatsapp-linked-device/vendor/provenance.json +45 -0
  86. package/src/provider-http.ts +11 -3
  87. package/src/provider-plugin-contract-identity.ts +2 -2
  88. package/src/provider-plugin-import-analysis.ts +52 -0
  89. package/src/provider-plugin-module-analysis.ts +21 -1
  90. package/src/provider-plugin-registry.ts +4 -8
  91. package/src/provider-plugin.ts +18 -8
  92. package/src/providers/imessage-automation.ts +250 -0
  93. package/src/providers/imessage-direct-install.ts +7 -0
  94. package/src/providers/imessage-direct-runtime.ts +32 -2
  95. package/src/providers/imessage-direct.ts +13 -3
  96. package/src/providers/linkedin-contact-failure.ts +21 -0
  97. package/src/providers/linkedin-contact-platform.ts +8 -1
  98. package/src/providers/linkedin-contact-program.ts +34 -5
  99. package/src/providers/linkedin-web-contact.ts +1223 -35
  100. package/src/providers/linkedin-web-profile-browser.ts +659 -45
  101. package/src/providers/linkedin-web-runtime.ts +1 -0
  102. package/src/providers/linkedin-web.ts +46 -2
  103. package/src/providers/messaging-native-artifacts.ts +38 -0
  104. package/src/providers/messaging-native-install.ts +75 -0
  105. package/src/providers/whatsapp-automation-runtime.ts +240 -0
  106. package/src/providers/whatsapp-automation.ts +153 -0
  107. package/src/read-client.ts +12 -2
  108. package/src/runtime.ts +81 -9
  109. package/src/state-helper.ts +2 -0
  110. package/src/storage.ts +71 -1
  111. package/src/usage.ts +9 -1
  112. package/src/version.ts +1 -1
  113. package/src/web-session-contract-definitions.ts +1 -1
  114. package/tsconfig.json +3 -0
@@ -0,0 +1,356 @@
1
+ import { Database, constants as sqlite } from "bun:sqlite";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync } from "node:fs";
3
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import { canonicalJson, sha256 } from "./canonical-json";
6
+ import { ensurePrivateStateDirectory, ghostgetStateHome, snapshotPrivateStateDirectory } from "./storage";
7
+ import { AUTOMATION_ACTION_KINDS, automationArray, automationDate, automationDigest, automationId, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationActionKind, parseAutomationCoordinate, parseAutomationIdentity, parseAutomationMessage } from "./messaging-automation-validation";
8
+ import type { AutomationConversation, AutomationEnrollment, AutomationEvent, AutomationGrant, AutomationGrantRequest, AutomationIdentity, AutomationPlan, AutomationPlanRequest, AutomationProviderPage, AutomationProviderSendResult, AutomationRun, MessagingAutomationProvider } from "./messaging-automation-types";
9
+ export * from "./messaging-automation-types";
10
+
11
+ type Environment = Readonly<Record<string, string | undefined>>;
12
+ type StoredEnrollment = { id: string; data: string; cursor: string; revision: number; ready: number; baselining: number; gap: number; reason: string | null };
13
+ type StoredGrant = { id: string; data: string; revoked: number; consumed: number; last_dispatch: number };
14
+ type StoredRun = { id: string; plan_id: string; intent_id: string; enrollment_id: string; state: AutomationRun["state"]; accepted: string; total: number; reason: string | null };
15
+ const RUN_COLUMNS = "id,plan_id,intent_id,enrollment_id,state,accepted,total,reason";
16
+ const same = (a: unknown, b: unknown) => canonicalJson(a) === canonicalJson(b);
17
+ const authority = (identity: AutomationIdentity, selected: AutomationConversation) => ({ identity, conversation: { ...selected, title: null } });
18
+ const sameConversation = (a: AutomationConversation, b: AutomationConversation) => same({ ...a, title: null }, { ...b, title: null });
19
+ const stopped = (signal?: AbortSignal) => { if (signal?.aborted) throw new Error("Messaging automation operation was cancelled."); };
20
+ const CAPACITY = Object.freeze({ enrollments: 1000, grants: 10000, plans: 20000, runs: 20000, messages: 50000, events: 50000 });
21
+ function grantData(value: unknown): AutomationGrantRequest {
22
+ const r = automationRecord(value, ["enrollmentId", "expectedBindingDigest", "actions", "expiresAt", "maximumActions", "minimumIntervalMs"]);
23
+ const actions = automationArray(r.actions, AUTOMATION_ACTION_KINDS.length).map(parseAutomationActionKind);
24
+ if (actions.length === 0 || new Set(actions).size !== actions.length) throw new Error("Messaging grant action list is invalid.");
25
+ return Object.freeze({ enrollmentId: automationId(r.enrollmentId), expectedBindingDigest: automationDigest(r.expectedBindingDigest), actions: Object.freeze(actions), expiresAt: automationDate(r.expiresAt), maximumActions: automationInteger(r.maximumActions, 1, 100000), minimumIntervalMs: automationInteger(r.minimumIntervalMs, 0, 86400000) });
26
+ }
27
+ function planData(value: unknown): AutomationPlan {
28
+ const r = automationRecord(value, ["enrollmentId", "expectedRevision", "intentId", "actions", "bindingDigest", "expiresAt", "id", "digest"]);
29
+ const actions = automationArray(r.actions, 8).map(parseAutomationAction);
30
+ if (actions.length === 0 || Buffer.byteLength(canonicalJson(actions)) > 256 * 1024) throw new Error("Messaging plan exceeds its bound.");
31
+ const binding = { enrollmentId: automationId(r.enrollmentId), expectedRevision: automationInteger(r.expectedRevision, 0, Number.MAX_SAFE_INTEGER), intentId: automationId(r.intentId), actions: Object.freeze(actions), bindingDigest: automationDigest(r.bindingDigest), expiresAt: automationDate(r.expiresAt) };
32
+ const digest = automationDigest(r.digest); const id = automationId(r.id);
33
+ if (sha256(canonicalJson(binding)) !== digest || id !== `plan:${digest}`) throw new Error("Messaging plan binding is invalid.");
34
+ return Object.freeze({ ...binding, id, digest });
35
+ }
36
+
37
+ function conversation(value: unknown): AutomationConversation {
38
+ const r = automationRecord(value, ["coordinate", "title", "kind", "participants"]);
39
+ const coordinate = parseAutomationCoordinate(r.coordinate);
40
+ const participants = automationArray(r.participants, 2).map(value => automationText(value, 512));
41
+ if (r.kind !== "single" || participants.length < 1 || new Set(participants).size !== participants.length) throw new Error("Messaging automation requires one verified individual conversation.");
42
+ return Object.freeze({ coordinate, title: r.title === null ? null : automationText(r.title, 512), kind: "single", participants: Object.freeze([...participants].sort()) });
43
+ }
44
+ function checkedPage(value: unknown, expected: AutomationIdentity, coordinate: AutomationConversation["coordinate"]): AutomationProviderPage {
45
+ const r = automationRecord(value, ["identity", "messages", "nextCursor", "caughtUp", "gap"]);
46
+ const identity = parseAutomationIdentity(r.identity);
47
+ if (!same(identity, expected) || typeof r.caughtUp !== "boolean" || typeof r.gap !== "boolean") throw new Error("Messaging provider identity or cursor state changed.");
48
+ const messages = automationArray(r.messages, 500).map(parseAutomationMessage);
49
+ if (messages.some(message => !same(message.coordinate, coordinate))) throw new Error("Messaging provider returned another conversation.");
50
+ return Object.freeze({ identity, messages: Object.freeze(messages), nextCursor: automationText(r.nextCursor, 4096), caughtUp: r.caughtUp, gap: r.gap });
51
+ }
52
+ function checkedResult(value: unknown): AutomationProviderSendResult {
53
+ if (typeof value !== "object" || value === null) throw new Error("Messaging provider result is invalid.");
54
+ const state = Object.getOwnPropertyDescriptor(value, "state")?.value;
55
+ if (state === "accepted") {
56
+ const r = automationRecord(value, ["state", "messageId", "providerReceiptId", "delivery"]);
57
+ if (r.delivery !== "unknown") throw new Error("Messaging provider delivery claim is unsupported.");
58
+ return { state, messageId: r.messageId === null ? null : automationId(r.messageId), providerReceiptId: r.providerReceiptId === null ? null : automationId(r.providerReceiptId), delivery: "unknown" };
59
+ }
60
+ const r = automationRecord(value, ["state", "reason"]);
61
+ if (state !== "not-started" && state !== "indeterminate") throw new Error("Messaging provider result is invalid.");
62
+ return { state, reason: automationText(r.reason, 1024) };
63
+ }
64
+
65
+ /**
66
+ * Durable host coordination, not a model runtime or a replacement permission policy.
67
+ * The owner invokes grant/revoke through Ghostget's private control boundary. A grant
68
+ * narrows an already permitted provider operation to one immutable conversation.
69
+ * Multiple processes are serialized at each durable claim; an unresolved run fences
70
+ * its enrollment across restarts. No startup path clears uncertain dispatch state.
71
+ */
72
+ export class MessagingAutomationHost {
73
+ private readonly db: Database;
74
+ private readonly directory: string;
75
+ private readonly directoryIdentity: ReturnType<typeof ensurePrivateStateDirectory>;
76
+ private readonly fileIdentity: { dev: number; ino: number };
77
+ private readonly providers = new Map<string, MessagingAutomationProvider>();
78
+ private readonly cursorKey: Buffer;
79
+ private readonly controllers = new Set<AbortController>();
80
+ private readonly planControllers = new Map<string, Set<AbortController>>();
81
+ private readonly pending = new Set<Promise<unknown>>();
82
+ private closing = false;
83
+ private closed = false;
84
+ constructor(providers: readonly MessagingAutomationProvider[], private readonly environment: Environment = process.env, private readonly now: () => number = Date.now) {
85
+ if (providers.length > 16) throw new Error("Too many messaging providers.");
86
+ for (const provider of providers) {
87
+ if (this.providers.has(provider.provider)) throw new Error("Duplicate messaging provider.");
88
+ this.providers.set(provider.provider, provider);
89
+ }
90
+ this.directory = join(ghostgetStateHome(environment), "messaging", "automation");
91
+ this.directoryIdentity = ensurePrivateStateDirectory(this.directory, environment);
92
+ const path = join(this.directory, "host.sqlite");
93
+ const fd = openSync(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
94
+ try { const stat = fstatSync(fd); if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1) throw new Error("Unsafe messaging journal."); this.fileIdentity = { dev: stat.dev, ino: stat.ino }; } finally { closeSync(fd); }
95
+ this.checkFiles();
96
+ this.db = new Database(path, sqlite.SQLITE_OPEN_READWRITE | sqlite.SQLITE_OPEN_NOFOLLOW);
97
+ try {
98
+ 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;");
99
+ const version = this.db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version;
100
+ if (version !== 0 && version !== 1 && version !== 2) throw new Error("Unsupported messaging journal schema.");
101
+ 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);");
102
+ // Upgrading an old cursor establishes a fresh live boundary. It must not
103
+ // turn its unconsumed historical backlog into new automatic replies.
104
+ if (version !== 2) this.db.transaction(() => {
105
+ 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;");
106
+ }).immediate();
107
+ else this.db.exec("PRAGMA user_version=2;");
108
+ 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));");
109
+ this.db.query("INSERT OR IGNORE INTO metadata(key,value) VALUES('cursor-key',?)").run(randomBytes(32).toString("hex"));
110
+ this.cursorKey = Buffer.from(automationDigest(this.db.query<{ value: string }, []>("SELECT value FROM metadata WHERE key='cursor-key'").get()?.value), "hex");
111
+ this.checkFiles();
112
+ } catch (error) { this.db.close(); throw error; }
113
+ }
114
+ private checkFiles(): void {
115
+ snapshotPrivateStateDirectory(this.directory, this.environment, this.directoryIdentity);
116
+ for (const name of ["host.sqlite", "host.sqlite-journal", "host.sqlite-wal", "host.sqlite-shm"]) {
117
+ let stat: ReturnType<typeof lstatSync>; try { stat = lstatSync(join(this.directory, name)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" && name !== "host.sqlite") continue; throw error; }
118
+ if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1 || name.endsWith("-wal") || name.endsWith("-shm")
119
+ || (name === "host.sqlite" && (stat.dev !== this.fileIdentity.dev || stat.ino !== this.fileIdentity.ino))) throw new Error("Messaging journal filesystem identity changed.");
120
+ }
121
+ }
122
+ private ready(): void { if (this.closing || this.closed) throw new Error("Messaging automation host is closed."); this.checkFiles(); }
123
+ private capacity(table: keyof typeof CAPACITY, additional = 1): void {
124
+ const count = this.db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get()!.n;
125
+ const pages = this.db.query<{ page_count: number }, []>("PRAGMA page_count").get()!.page_count;
126
+ const size = this.db.query<{ page_size: number }, []>("PRAGMA page_size").get()!.page_size;
127
+ // Keep substantial physical headroom for dispatch/result journals. Nothing
128
+ // evicts receipts or advances consumer cursors silently when the bound fills.
129
+ if (count + additional > CAPACITY[table] || pages * size >= 192 * 1024 * 1024) throw new Error("Messaging journal capacity reached; owner maintenance is required before further dispatch.");
130
+ }
131
+ private provider(id: string): MessagingAutomationProvider { const provider = this.providers.get(id); if (provider === undefined) throw new Error("Messaging provider is unavailable."); return provider; }
132
+ private row(id: string): StoredEnrollment { this.ready(); const row = this.db.query<StoredEnrollment, [string]>("SELECT * FROM enrollments WHERE id=?").get(automationId(id)); if (row === null) throw new Error("Messaging enrollment does not exist."); return row; }
133
+ private enrollment(row: StoredEnrollment): AutomationEnrollment {
134
+ const r = automationRecord(JSON.parse(row.data) as unknown, ["identity", "conversation", "bindingDigest"]);
135
+ const identity = parseAutomationIdentity(r.identity); const selected = conversation(r.conversation); const bindingDigest = automationDigest(r.bindingDigest);
136
+ if (sha256(canonicalJson(authority(identity, selected))) !== bindingDigest) throw new Error("Messaging enrollment binding is invalid.");
137
+ const ready = automationInteger(row.ready, 0, 1); const baselining = automationInteger(row.baselining, 0, 1); const gap = automationInteger(row.gap, 0, 1);
138
+ if (ready && (baselining || gap)) throw new Error("Messaging enrollment readiness is inconsistent.");
139
+ 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) });
140
+ }
141
+ enrollments(): readonly AutomationEnrollment[] { this.ready(); return this.db.query<StoredEnrollment, []>("SELECT * FROM enrollments ORDER BY id LIMIT 1001").all().map(row => this.enrollment(row)); }
142
+ private async status(provider: MessagingAutomationProvider, signal?: AbortSignal) {
143
+ stopped(signal); const value = await provider.inspect(signal); stopped(signal);
144
+ const r = automationRecord(value, ["identity", "connected", "events", "actions"]); const identity = parseAutomationIdentity(r.identity);
145
+ if (identity.provider !== provider.provider || typeof r.connected !== "boolean") throw new Error("Messaging provider identity is invalid.");
146
+ const actions = automationRecord(r.actions, AUTOMATION_ACTION_KINDS);
147
+ for (const capability of [r.events, ...Object.values(actions)]) { const c = automationRecord(capability, ["available", "reason"]); if (typeof c.available !== "boolean") throw new Error("Messaging capability is invalid."); if (c.reason !== null) automationText(c.reason, 1024); }
148
+ return value;
149
+ }
150
+ async providerStatus(id: string, signal?: AbortSignal) {
151
+ this.ready(); return this.status(this.provider(automationId(id)), signal);
152
+ }
153
+ async conversations(raw: Readonly<{ provider: string; limit: number }>, signal?: AbortSignal) {
154
+ this.ready(); const r = automationRecord(raw, ["provider", "limit"]);
155
+ const provider = this.provider(automationId(r.provider)), limit = automationInteger(r.limit, 1, 200);
156
+ const status = await this.status(provider, signal);
157
+ const result = automationRecord(await provider.conversations({ limit }, signal), ["identity", "conversations", "complete"]); stopped(signal);
158
+ const identity = parseAutomationIdentity(result.identity);
159
+ if (!same(identity, status.identity) || typeof result.complete !== "boolean") throw new Error("Messaging discovery identity changed.");
160
+ const rows = automationArray(result.conversations, limit).filter(value => automationRecord(value, ["coordinate", "title", "kind", "participants"]).kind === "single").map(conversation);
161
+ 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.");
162
+ return Object.freeze({ identity, conversations: Object.freeze(rows), complete: result.complete });
163
+ }
164
+ history(raw: Readonly<{ enrollmentId: string; limit: number }>) {
165
+ this.ready(); const r = automationRecord(raw, ["enrollmentId", "limit"]), limit = automationInteger(r.limit, 1, 200);
166
+ const enrollment = this.enrollment(this.row(automationId(r.enrollmentId)));
167
+ const rows = this.db.query<{ data: string }, [string, number]>("SELECT data FROM messages WHERE rowid IN (SELECT MAX(rowid) FROM messages WHERE enrollment_id=? GROUP BY json_extract(data,'$.id')) ORDER BY json_extract(data,'$.occurredAt') DESC,rowid DESC LIMIT ?").all(enrollment.id, limit);
168
+ const messages = rows.map(row => parseAutomationMessage(JSON.parse(row.data) as unknown)).reverse();
169
+ if (messages.some(message => !same(message.coordinate, enrollment.conversation.coordinate))) throw new Error("Messaging stored history has another conversation.");
170
+ return Object.freeze({ enrollment, messages: Object.freeze(messages) });
171
+ }
172
+ async enroll(raw: Readonly<{ provider: string; coordinate: unknown }>, signal?: AbortSignal): Promise<AutomationEnrollment> {
173
+ this.ready(); const request = automationRecord(raw, ["provider", "coordinate"]); const coordinate = parseAutomationCoordinate(request.coordinate);
174
+ if (request.provider !== coordinate.provider) throw new Error("Messaging provider and coordinate disagree.");
175
+ const provider = this.provider(coordinate.provider); const status = await this.status(provider, signal);
176
+ const resolved = await provider.resolve(coordinate, signal); stopped(signal);
177
+ if (!same(parseAutomationIdentity(resolved.identity), status.identity)) throw new Error("Messaging identity changed during enrollment.");
178
+ const selected = conversation(resolved.conversation); if (!same(selected.coordinate, coordinate)) throw new Error("Messaging route resolution changed target.");
179
+ const history = checkedPage(await provider.history({ coordinate, limit: 200 }, signal), status.identity, coordinate); stopped(signal);
180
+ const binding = { identity: status.identity, conversation: selected }; const bindingDigest = sha256(canonicalJson(authority(status.identity, selected)));
181
+ const id = `enrollment:${randomUUID()}`;
182
+ this.ready(); this.db.transaction(() => {
183
+ this.capacity("enrollments"); this.capacity("messages", history.messages.length);
184
+ if (this.enrollments().some(enrollment => same(enrollment.identity, status.identity) && same(enrollment.conversation.coordinate, coordinate))) throw new Error("Conversation is already enrolled.");
185
+ 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);
186
+ for (const message of history.messages) this.db.query("INSERT OR IGNORE INTO messages VALUES(?,?,?)").run(id, sha256(canonicalJson(message)), canonicalJson(message));
187
+ }).immediate(); this.checkFiles();
188
+ return this.enrollment(this.row(id));
189
+ }
190
+ grant(raw: AutomationGrantRequest, intent?: string): AutomationGrant {
191
+ this.ready(); const r = automationRecord(raw, ["enrollmentId", "expectedBindingDigest", "actions", "expiresAt", "maximumActions", "minimumIntervalMs"]);
192
+ const enrollment = this.enrollment(this.row(automationId(r.enrollmentId))); const expectedBindingDigest = automationDigest(r.expectedBindingDigest);
193
+ if (enrollment.bindingDigest !== expectedBindingDigest) throw new Error("Messaging enrollment changed before grant issuance.");
194
+ const actions = automationArray(r.actions, AUTOMATION_ACTION_KINDS.length).map(parseAutomationActionKind);
195
+ if (actions.length === 0 || new Set(actions).size !== actions.length) throw new Error("Messaging grant action list is invalid.");
196
+ const expiresAt = automationDate(r.expiresAt);
197
+ const grant = { enrollmentId: enrollment.id, expectedBindingDigest, actions: [...actions].sort(), expiresAt, maximumActions: automationInteger(r.maximumActions, 1, 100000), minimumIntervalMs: automationInteger(r.minimumIntervalMs, 0, 86400000) };
198
+ const intentId = intent === undefined ? null : automationId(intent), digest = sha256(canonicalJson(grant));
199
+ const result = this.db.transaction(() => {
200
+ if (intentId !== null) {
201
+ const previous = this.db.query<{ request_digest: string; grant_id: string }, [string]>("SELECT request_digest,grant_id FROM grant_intents WHERE id=?").get(intentId);
202
+ if (previous) {
203
+ if (automationDigest(previous.request_digest) !== digest) throw new Error("Messaging grant intent changed its request.");
204
+ return this.grantStatus(previous.grant_id);
205
+ }
206
+ }
207
+ if (Date.parse(expiresAt) <= this.now() || Date.parse(expiresAt) > this.now() + 30 * 86400000) throw new Error("Messaging grant must expire within 30 days.");
208
+ const id = `grant:${randomUUID()}`;
209
+ this.capacity("grants"); this.db.query("INSERT INTO grants VALUES(?,?,0,0,0)").run(id, canonicalJson(grant));
210
+ if (intentId !== null) this.db.query("INSERT INTO grant_intents VALUES(?,?,?)").run(intentId, digest, id);
211
+ return Object.freeze({ ...grant, id, revoked: false, consumedActions: 0 });
212
+ }).immediate(); this.checkFiles(); return result;
213
+ }
214
+ /** Resolves uncertain grant creation without issuing or renewing authority. */
215
+ grantByIntent(intentId: string): AutomationGrant | null {
216
+ this.ready(); const row = this.db.query<{ grant_id: string }, [string]>("SELECT grant_id FROM grant_intents WHERE id=?").get(automationId(intentId));
217
+ return row === null ? null : this.grantStatus(row.grant_id);
218
+ }
219
+ revoke(id: string): void { this.ready(); if (this.db.query("UPDATE grants SET revoked=1 WHERE id=?").run(automationId(id)).changes !== 1) throw new Error("Messaging grant does not exist."); this.checkFiles(); }
220
+ grantStatus(id: string): AutomationGrant {
221
+ this.ready(); const row = this.db.query<StoredGrant, [string]>("SELECT * FROM grants WHERE id=?").get(automationId(id));
222
+ if (row === null) throw new Error("Messaging grant does not exist.");
223
+ const grant = grantData(JSON.parse(row.data) as unknown);
224
+ return Object.freeze({ ...grant, id: automationId(row.id), revoked: automationInteger(row.revoked, 0, 1) === 1, consumedActions: automationInteger(row.consumed, 0, grant.maximumActions) });
225
+ }
226
+ private activeRun(enrollmentId: string): boolean { return this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM runs WHERE enrollment_id=? AND state IN ('started','partial','indeterminate')").get(enrollmentId)!.n > 0; }
227
+ async poll(enrollmentId: string, signal?: AbortSignal): Promise<AutomationEnrollment> {
228
+ const initial = this.row(enrollmentId); const enrollment = this.enrollment(initial);
229
+ if (this.activeRun(enrollmentId)) return enrollment;
230
+ const provider = this.provider(enrollment.identity.provider); const status = await this.status(provider, signal);
231
+ if (!same(status.identity, enrollment.identity)) { this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=?").run("Provider identity changed; enroll the conversation again.", enrollmentId); throw new Error("Messaging provider identity changed."); }
232
+ 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.", enrollmentId); return this.enrollment(this.row(enrollmentId)); }
233
+ const page = checkedPage(await provider.events({ coordinates: [enrollment.conversation.coordinate], cursor: initial.cursor, limit: 200 }, signal), enrollment.identity, enrollment.conversation.coordinate); stopped(signal);
234
+ this.ready(); this.db.transaction(() => {
235
+ 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.");
236
+ this.capacity("messages", page.messages.length); if (!initial.baselining) this.capacity("events", page.messages.length);
237
+ let revision = initial.revision;
238
+ for (const [index, message] of page.messages.entries()) {
239
+ const data = canonicalJson(message);
240
+ 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);
241
+ if (previous?.data === data) continue;
242
+ // A→B→A is another change, not a lifetime duplicate. The provider cursor
243
+ // and page position identify this observation while latest state dedups
244
+ // overlap at page boundaries.
245
+ this.db.query("INSERT INTO messages VALUES(?,?,?)").run(enrollmentId, sha256(canonicalJson({ cursor: page.nextCursor, index, message })), data);
246
+ if (initial.baselining === 1) continue;
247
+ revision = automationInteger(revision + 1, 1, Number.MAX_SAFE_INTEGER);
248
+ this.db.query("INSERT INTO events(enrollment_id,revision,data) VALUES(?,?,?)").run(enrollmentId, revision, data);
249
+ }
250
+ const gap = initial.gap === 1 || page.gap;
251
+ this.db.query("UPDATE enrollments SET cursor=?,revision=?,ready=?,reason=?,baselining=?,gap=? WHERE id=?").run(page.nextCursor, revision, !gap && page.caughtUp ? 1 : 0, gap ? "Provider event stream has an unresolved gap." : page.caughtUp ? null : "Provider catchup is incomplete.", initial.baselining && !page.caughtUp ? 1 : 0, gap ? 1 : 0, enrollmentId);
252
+ }).immediate(); this.checkFiles(); return this.enrollment(this.row(enrollmentId));
253
+ }
254
+ events(raw: Readonly<{ enrollmentIds: readonly string[]; cursor: string | null; limit: number }>): Readonly<{ events: readonly AutomationEvent[]; nextCursor: string; caughtUp: boolean }> {
255
+ this.ready(); const r = automationRecord(raw, ["enrollmentIds", "cursor", "limit"]); const ids = automationArray(r.enrollmentIds, 100).map(automationId).sort();
256
+ if (ids.length === 0 || new Set(ids).size !== ids.length) throw new Error("Messaging event selection is invalid.");
257
+ for (const id of ids) this.row(id);
258
+ const limit = automationInteger(r.limit, 1, 500); const fingerprint = sha256(canonicalJson(ids)); let after = 0;
259
+ if (r.cursor !== null) {
260
+ const cursor = automationText(r.cursor, 4096); const [payload, signature, extra] = cursor.split(".");
261
+ if (payload === undefined || signature === undefined || extra !== undefined || !/^[a-f0-9]{64}$/u.test(signature)) throw new Error("Messaging cursor is invalid.");
262
+ const mac = createHmac("sha256", this.cursorKey).update(payload).digest(); if (!timingSafeEqual(Buffer.from(signature, "hex"), mac)) throw new Error("Messaging cursor is invalid.");
263
+ const decoded = automationRecord(JSON.parse(Buffer.from(payload, "base64url").toString()) as unknown, ["fingerprint", "after"]);
264
+ if (decoded.fingerprint !== fingerprint) throw new Error("Messaging cursor belongs to a different contact selection."); after = automationInteger(decoded.after, 0, Number.MAX_SAFE_INTEGER);
265
+ }
266
+ const rows = this.db.query<{ seq: number; enrollment_id: string; revision: number; data: string }, (string | number)[]>(`SELECT seq,enrollment_id,revision,data FROM events WHERE seq>? AND enrollment_id IN (${ids.map(() => "?").join(",")}) ORDER BY seq LIMIT ?`).all(after, ...ids, limit + 1);
267
+ const selected = rows.slice(0, limit); const payload = Buffer.from(canonicalJson({ fingerprint, after: selected.at(-1)?.seq ?? after })).toString("base64url");
268
+ return Object.freeze({ events: Object.freeze(selected.map(row => Object.freeze({ sequence: row.seq, enrollmentId: row.enrollment_id, revision: row.revision, message: parseAutomationMessage(JSON.parse(row.data) as unknown) }))), nextCursor: `${payload}.${createHmac("sha256", this.cursorKey).update(payload).digest("hex")}`, caughtUp: rows.length <= limit });
269
+ }
270
+ prepare(raw: AutomationPlanRequest): AutomationPlan {
271
+ this.ready(); const r = automationRecord(raw, ["enrollmentId", "expectedRevision", "intentId", "actions"]);
272
+ const enrollment = this.enrollment(this.row(automationId(r.enrollmentId))); const expectedRevision = automationInteger(r.expectedRevision, 0, Number.MAX_SAFE_INTEGER);
273
+ if (!enrollment.ready || enrollment.revision !== expectedRevision) throw new Error("Messaging context is stale or unavailable.");
274
+ const actions = automationArray(r.actions, 8).map(parseAutomationAction); if (actions.length === 0) throw new Error("Messaging plan is empty.");
275
+ if (Buffer.byteLength(canonicalJson(actions)) > 256 * 1024) throw new Error("Messaging plan exceeds its byte bound.");
276
+ const intentId = automationId(r.intentId); const binding = { enrollmentId: enrollment.id, expectedRevision, intentId, actions, bindingDigest: enrollment.bindingDigest, expiresAt: new Date(this.now() + 120000).toISOString() };
277
+ const digest = sha256(canonicalJson(binding)); const plan = Object.freeze({ ...binding, id: `plan:${digest}`, digest });
278
+ this.db.transaction(() => { this.capacity("plans"); this.db.query("INSERT OR IGNORE INTO plans VALUES(?,?)").run(plan.id, canonicalJson(plan)); }).immediate(); this.checkFiles(); return plan;
279
+ }
280
+ private runProjection(row: StoredRun): AutomationRun {
281
+ const accepted = automationArray(JSON.parse(row.accepted) as unknown, 8).map(value => { const r = automationRecord(value, ["messageId", "providerReceiptId"]); return Object.freeze({ messageId: r.messageId === null ? null : automationId(r.messageId), providerReceiptId: r.providerReceiptId === null ? null : automationId(r.providerReceiptId) }); });
282
+ const totalActions = automationInteger(row.total, 1, 8);
283
+ if (!["started", "accepted", "failed", "partial", "indeterminate"].includes(row.state) || accepted.length > totalActions || (row.state === "accepted" && accepted.length !== totalActions) || (row.state === "failed" && accepted.length !== 0)) throw new Error("Messaging run state is inconsistent.");
284
+ return Object.freeze({ id: automationId(row.id), planId: automationId(row.plan_id), intentId: automationId(row.intent_id), enrollmentId: automationId(row.enrollment_id), state: row.state, accepted: Object.freeze(accepted), totalActions, reason: row.reason === null ? null : automationText(row.reason, 1024), retryable: false });
285
+ }
286
+ run(id: string): AutomationRun { this.ready(); const row = this.db.query<StoredRun, [string]>(`SELECT ${RUN_COLUMNS} FROM runs WHERE id=?`).get(automationId(id)); if (row === null) throw new Error("Messaging run does not exist."); return this.runProjection(row); }
287
+ cancel(planId: string): boolean {
288
+ this.ready(); const controllers = this.planControllers.get(automationId(planId));
289
+ if (controllers === undefined) return false;
290
+ for (const controller of controllers) controller.abort(); return true;
291
+ }
292
+ submit(raw: Readonly<{ planId: string; grantId: string }>, signal?: AbortSignal): Promise<AutomationRun> {
293
+ this.ready(); const r = automationRecord(raw, ["planId", "grantId"]); const controller = new AbortController(); const abort = () => controller.abort(); signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) controller.abort(); this.controllers.add(controller);
294
+ const planId = automationId(r.planId), grantId = automationId(r.grantId);
295
+ const owned = this.planControllers.get(planId) ?? new Set<AbortController>(); owned.add(controller); this.planControllers.set(planId, owned);
296
+ const operation = this.dispatch(planId, grantId, controller.signal);
297
+ this.pending.add(operation); void operation.finally(() => { signal?.removeEventListener("abort", abort); this.controllers.delete(controller); owned.delete(controller); if (!owned.size) this.planControllers.delete(planId); this.pending.delete(operation); }).catch(() => undefined); return operation;
298
+ }
299
+ private async dispatch(planId: string, grantId: string, signal: AbortSignal): Promise<AutomationRun> {
300
+ stopped(signal); const record = this.db.query<{ data: string }, [string]>("SELECT data FROM plans WHERE id=?").get(planId); if (record === null) throw new Error("Messaging plan does not exist.");
301
+ const plan = planData(JSON.parse(record.data) as unknown);
302
+ if (plan.id !== planId) throw new Error("Messaging plan binding is invalid.");
303
+ const existing = this.db.query<StoredRun, [string]>(`SELECT ${RUN_COLUMNS} FROM runs WHERE intent_id=?`).get(plan.intentId);
304
+ if (existing !== null) { if (existing.plan_id !== plan.id) throw new Error("Messaging intent already belongs to another plan."); return this.runProjection(existing); }
305
+ const enrollment = await this.poll(plan.enrollmentId, signal); const provider = this.provider(enrollment.identity.provider); const status = await this.status(provider, signal);
306
+ const route = await provider.resolve(enrollment.conversation.coordinate, signal); stopped(signal);
307
+ 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.");
308
+ for (const action of plan.actions) if (!status.actions[parseAutomationAction(action).kind].available) throw new Error("Messaging action is unavailable for this provider.");
309
+ const runId = `run:${randomUUID()}`;
310
+ this.ready(); this.db.transaction(() => {
311
+ stopped(signal); const current = this.enrollment(this.row(plan.enrollmentId)); const now = this.now();
312
+ this.capacity("runs"); this.capacity("messages", 0); this.capacity("events", 0);
313
+ if (!current.ready || current.revision !== plan.expectedRevision || current.bindingDigest !== plan.bindingDigest || Date.parse(plan.expiresAt) <= now || this.activeRun(current.id)) throw new Error("Messaging context expired, changed, or has an unresolved run.");
314
+ const grantRow = this.db.query<StoredGrant, [string]>("SELECT * FROM grants WHERE id=?").get(grantId); if (grantRow === null) throw new Error("Messaging grant does not exist.");
315
+ const grant = grantData(JSON.parse(grantRow.data) as unknown);
316
+ automationInteger(grantRow.revoked, 0, 1); automationInteger(grantRow.consumed, 0, grant.maximumActions); automationInteger(grantRow.last_dispatch, 0, Number.MAX_SAFE_INTEGER);
317
+ if (grantRow.revoked !== 0 || grant.enrollmentId !== current.id || grant.expectedBindingDigest !== current.bindingDigest || Date.parse(grant.expiresAt) <= now
318
+ || grantRow.consumed + plan.actions.length > grant.maximumActions || (grantRow.last_dispatch !== 0 && now - grantRow.last_dispatch < grant.minimumIntervalMs)
319
+ || plan.actions.some(action => !grant.actions.includes(action.kind))) throw new Error("Messaging grant does not authorize this dispatch.");
320
+ this.db.query("INSERT INTO runs VALUES(?,?,?,?, 'started','[]',?,NULL)").run(runId, plan.id, plan.intentId, current.id, plan.actions.length);
321
+ this.db.query("UPDATE grants SET consumed=consumed+?,last_dispatch=? WHERE id=?").run(plan.actions.length, now, grantId);
322
+ }).immediate(); this.checkFiles();
323
+ const accepted: { messageId: string | null; providerReceiptId: string | null }[] = [];
324
+ let state: AutomationRun["state"] = "accepted"; let reason: string | null = null;
325
+ for (const [index, action] of plan.actions.entries()) {
326
+ try {
327
+ this.checkFiles();
328
+ const observed = checkedPage(await provider.events({ coordinates: [enrollment.conversation.coordinate], cursor: this.row(enrollment.id).cursor, limit: 200 }, signal), enrollment.identity, enrollment.conversation.coordinate);
329
+ const ownMessages = new Set(accepted.flatMap(receipt => receipt.messageId === null ? [] : [receipt.messageId]));
330
+ if (observed.gap || !observed.caughtUp || observed.messages.some(message => message.direction !== "outgoing" || message.kind !== "message" || !ownMessages.has(message.id))) {
331
+ state = accepted.length ? "partial" : "failed"; reason = "Conversation changed before the next action."; break;
332
+ }
333
+ const grantRow = this.db.query<StoredGrant, [string]>("SELECT * FROM grants WHERE id=?").get(grantId);
334
+ const grant = grantRow === null ? null : grantData(JSON.parse(grantRow.data) as unknown);
335
+ const current = this.enrollment(this.row(plan.enrollmentId));
336
+ if (signal.aborted || grantRow?.revoked !== 0 || grant === null || Date.parse(grant.expiresAt) <= this.now() || Date.parse(plan.expiresAt) <= this.now()
337
+ || grant.enrollmentId !== current.id || grant.expectedBindingDigest !== current.bindingDigest || current.bindingDigest !== plan.bindingDigest || !grant.actions.includes(action.kind)) {
338
+ state = accepted.length ? "partial" : "failed"; reason = "Dispatch permission expired or changed before the next action."; break;
339
+ }
340
+ const result = checkedResult(await provider.send({ identity: enrollment.identity, coordinate: enrollment.conversation.coordinate, action, intentId: `${plan.intentId}:${index}` }, signal));
341
+ if (result.state !== "accepted") { state = result.state === "indeterminate" ? "indeterminate" : accepted.length ? "partial" : "failed"; reason = result.reason; break; }
342
+ accepted.push({ messageId: result.messageId, providerReceiptId: result.providerReceiptId });
343
+ 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.");
344
+ } catch { state = "indeterminate"; reason = "Provider dispatch or result recording did not settle conclusively."; break; }
345
+ }
346
+ this.checkFiles(); if (this.db.query("UPDATE runs SET state=?,accepted=?,reason=? WHERE id=? AND state='started'").run(state, canonicalJson(accepted), reason, runId).changes !== 1) throw new Error("Messaging terminal result could not be recorded.");
347
+ const row = this.db.query<StoredRun, [string]>(`SELECT ${RUN_COLUMNS} FROM runs WHERE id=?`).get(runId)!; return this.runProjection(row);
348
+ }
349
+ async close(): Promise<void> {
350
+ if (this.closed) return; if (this.closing) throw new Error("Messaging automation shutdown is already in progress."); this.closing = true;
351
+ for (const controller of this.controllers) controller.abort();
352
+ const settled = await Promise.allSettled([...this.pending]); const providers = await Promise.allSettled([...this.providers.values()].map(provider => provider.close()));
353
+ this.db.close(); this.closed = true;
354
+ if (settled.some(result => result.status === "rejected") || providers.some(result => result.status === "rejected")) throw new Error("Messaging automation shutdown did not establish complete cleanup.");
355
+ }
356
+ }
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { checkOperationPermission } from "./operation-permission";
2
3
  import { isAbsolute, resolve } from "node:path";
3
4
 
4
5
  import type { GhostgetAuth } from "./auth";
@@ -1717,6 +1718,7 @@ export async function executeMessagingCompositeInternal(
1717
1718
  const environment = options.environment ?? process.env;
1718
1719
  const registry = options.registry ?? providerPluginRegistry;
1719
1720
  const actionResolution = messagingResolution(invocation, registry, "action");
1721
+ await checkOperationPermission(invocation, { environment, registry, ...(options.signal === undefined ? {} : { signal: options.signal }) });
1720
1722
  const action = actionResolution.messaging.action;
1721
1723
  if (action.state !== "supported") {
1722
1724
  throw new Error("messaging action support disappeared after confirmation");
@@ -1889,6 +1891,7 @@ export async function executeMessagingCompositeInternal(
1889
1891
  );
1890
1892
  };
1891
1893
  const beforeExternalBegin = async (): Promise<void> => {
1894
+ await checkOperationPermission({ ...invocation, input: composite.parts[index]!.input }, { environment, registry, ...(options.signal === undefined ? {} : { signal: options.signal }) });
1892
1895
  operationDeadline.throwIfUnavailable("messaging provider action");
1893
1896
  if (crossedExternalBoundary) {
1894
1897
  throw new Error(
@@ -756,6 +756,7 @@ export function installManagedGoogleOAuth(
756
756
  managed: true,
757
757
  });
758
758
  if (auth.kind !== "oauth-token-file") throw new Error("managed OAuth login created the wrong auth kind");
759
+ const { contentSha256 } = loadOAuthCredential(auth, { expectedContent: content });
759
760
  let path: string;
760
761
  try {
761
762
  path = saveAuth(
@@ -764,11 +765,16 @@ export function installManagedGoogleOAuth(
764
765
  options.force === undefined ? {} : { force: options.force },
765
766
  );
766
767
  } catch (error) {
767
- removePrivateStateFileIfUnchanged(
768
- tokenPath,
769
- { expectedCurrentContentSha256: createHash("sha256").update(content, "utf8").digest("hex") },
770
- environment,
771
- );
768
+ // Auth publication can succeed before prior-credential cleanup fails. Never
769
+ // delete the new live credential when reconciling that uncertain outcome.
770
+ const observed = loadAuthSnapshotIfPresent(id, environment)?.auth;
771
+ if (observed?.kind !== "oauth-token-file" || observed.path !== tokenPath) {
772
+ removePrivateStateFileIfUnchanged(
773
+ tokenPath,
774
+ { expectedCurrentContentSha256: contentSha256 },
775
+ environment,
776
+ );
777
+ }
772
778
  throw error;
773
779
  }
774
780
  if (
@@ -2,6 +2,7 @@ import { types as nodeTypes } from "node:util";
2
2
 
3
3
  import { canonicalJson, sha256 } from "./canonical-json";
4
4
  import { loadAuthSnapshotIfPresent } from "./auth";
5
+ import { assertOperationPermission, withOperationPermissions, withUnmanagedOperationPermission, readOperationPolicy } from "./operation-permission";
5
6
  import {
6
7
  isProviderOperation,
7
8
  isLocalCliOperation,
@@ -1573,7 +1574,10 @@ export function readCachedOmniViewInternal(
1573
1574
  options: OmniRuntimeOptions = {},
1574
1575
  ): OmniReadResultV1 {
1575
1576
  const prepared = prepareSources(requestValue, options);
1576
- return result("omni-cache", prepared, view(prepared, options));
1577
+ for (const source of prepared.sources) assertOperationPermission(source.invocation, { environment: options.environment ?? process.env, registry: prepared.registry });
1578
+ const output = result("omni-cache", prepared, view(prepared, options));
1579
+ for (const source of prepared.sources) assertOperationPermission(source.invocation, { environment: options.environment ?? process.env, registry: prepared.registry });
1580
+ return output;
1577
1581
  }
1578
1582
 
1579
1583
  export function rebuildOmniViewFromExactCache(
@@ -1581,6 +1585,7 @@ export function rebuildOmniViewFromExactCache(
1581
1585
  options: OmniRuntimeOptions = {},
1582
1586
  ): OmniReadResultV1 {
1583
1587
  const prepared = prepareSources(requestValue, options);
1588
+ for (const source of prepared.sources) assertOperationPermission(source.invocation, { environment: options.environment ?? process.env, registry: prepared.registry });
1584
1589
  const errors = new Map<string, OmniSourceUpdateError>();
1585
1590
  for (const source of prepared.sources) {
1586
1591
  if (source.omni?.state !== "supported") continue;
@@ -1596,10 +1601,12 @@ export function rebuildOmniViewFromExactCache(
1596
1601
  );
1597
1602
  }
1598
1603
  }
1599
- return result("omni-exact-cache", prepared, view(prepared, options, errors));
1604
+ const output = result("omni-exact-cache", prepared, view(prepared, options, errors));
1605
+ for (const source of prepared.sources) assertOperationPermission(source.invocation, { environment: options.environment ?? process.env, registry: prepared.registry });
1606
+ return output;
1600
1607
  }
1601
1608
 
1602
- export async function revalidateOmniViewInternal(
1609
+ async function revalidateOmniViewCore(
1603
1610
  requestValue: unknown,
1604
1611
  options: OmniLiveRuntimeOptions = {},
1605
1612
  ): Promise<OmniReadResultV1> {
@@ -1630,3 +1637,11 @@ export async function revalidateOmniViewInternal(
1630
1637
  throwIfAborted(options.signal);
1631
1638
  return result("omni-live", prepared, selectedView);
1632
1639
  }
1640
+
1641
+ export async function revalidateOmniViewInternal(requestValue: unknown, options: OmniLiveRuntimeOptions = {}): Promise<OmniReadResultV1> {
1642
+ const environment = options.environment ?? process.env;
1643
+ if (!readOperationPolicy(environment).managed) return withUnmanagedOperationPermission(environment, () => revalidateOmniViewCore(requestValue, options));
1644
+ const prepared = prepareSources(requestValue, options);
1645
+ return withOperationPermissions(prepared.sources.map(source => source.invocation), { environment, registry: prepared.registry,
1646
+ ...(options.signal === undefined ? {} : { signal: options.signal }) }, () => revalidateOmniViewCore(requestValue, options));
1647
+ }
@@ -0,0 +1,92 @@
1
+ import { join } from "node:path";
2
+ import { canonicalJson, sha256 } from "./canonical-json";
3
+ import { createPrivateJsonIfAbsent, ensurePrivateStateDirectory, ghostgetStateHome, privateStateFilesMayExist, readPrivateStateFileIfPresent, writePrivateJsonIfUnchanged } from "./storage";
4
+ import type { PermissionDecision } from "./control/protocol";
5
+
6
+ export type PermissionEnvironment = Readonly<Record<string, string | undefined>>;
7
+ export type OperationPolicyEntry = Readonly<{ digest: string; decision: PermissionDecision }>;
8
+ export type OperationPolicy = Readonly<{ schemaVersion: 1; revision: number; entries: readonly OperationPolicyEntry[] }>;
9
+ export type OperationPolicySnapshot = Readonly<{ managed: boolean; revision: number; entries: readonly OperationPolicyEntry[]; contentSha256: string | null }>;
10
+ const MAX_POLICY_BYTES = 512 * 1024;
11
+ const MAX_ENTRIES = 4096;
12
+ const DIGEST = /^[a-f0-9]{64}$/u;
13
+ const marker = Object.freeze({ schemaVersion: 1, managed: true });
14
+
15
+ export class OperationPermissionError extends Error {
16
+ constructor(readonly code: "OPERATION_PERMISSION_DENIED" | "OPERATION_APPROVAL_REQUIRED" | "OPERATION_PERMISSION_CHANGED" | "OPERATION_POLICY_INVALID" | "OPERATION_APPROVAL_TOO_LARGE", message: string) {
17
+ super(message);
18
+ this.name = "OperationPermissionError";
19
+ }
20
+ }
21
+
22
+ export function parseOperationPolicy(value: unknown): OperationPolicy {
23
+ const fail = (): never => { throw new OperationPermissionError("OPERATION_POLICY_INVALID", "Operation permission policy is invalid; repair it in Ghostget before execution."); };
24
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return fail();
25
+ const record = value as Record<string, unknown>;
26
+ if (Object.keys(record).sort().join(",") !== "entries,revision,schemaVersion" || record.schemaVersion !== 1
27
+ || !Number.isSafeInteger(record.revision) || (record.revision as number) < 1
28
+ || !Array.isArray(record.entries) || record.entries.length > MAX_ENTRIES) return fail();
29
+ let previous = "";
30
+ const entries = record.entries.map((candidate: unknown): OperationPolicyEntry => {
31
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) return fail();
32
+ const entry = candidate as Record<string, unknown>;
33
+ if (Object.keys(entry).sort().join(",") !== "decision,digest" || typeof entry.digest !== "string" || !DIGEST.test(entry.digest)
34
+ || entry.digest <= previous || (entry.decision !== "allow" && entry.decision !== "deny" && entry.decision !== "ask")) return fail();
35
+ previous = entry.digest;
36
+ return Object.freeze({ digest: entry.digest, decision: entry.decision });
37
+ });
38
+ return Object.freeze({ schemaVersion: 1, revision: record.revision as number, entries: Object.freeze(entries) });
39
+ }
40
+
41
+ function paths(environment: PermissionEnvironment) {
42
+ const directory = join(ghostgetStateHome(environment), "operation-permissions");
43
+ return { directory, marker: join(directory, "managed.json"), policy: join(directory, "policy.json") };
44
+ }
45
+
46
+ export function readOperationPolicy(environment: PermissionEnvironment = process.env): OperationPolicySnapshot {
47
+ try {
48
+ if (!privateStateFilesMayExist("operation-permissions", ["managed.json", "policy.json"], environment)) {
49
+ return Object.freeze({ managed: false, revision: 0, entries: Object.freeze([]), contentSha256: null });
50
+ }
51
+ const selected = paths(environment);
52
+ const markerText = readPrivateStateFileIfPresent(selected.marker, 256, "operation policy marker", environment);
53
+ if (markerText !== null && markerText !== `${canonicalJson(marker)}\n`) throw new Error("marker");
54
+ const text = readPrivateStateFileIfPresent(selected.policy, MAX_POLICY_BYTES, "operation permission policy", environment);
55
+ if (text === null) {
56
+ if (markerText !== null) throw new Error("missing policy");
57
+ return Object.freeze({ managed: false, revision: 0, entries: Object.freeze([]), contentSha256: null });
58
+ }
59
+ const policy = parseOperationPolicy(JSON.parse(text) as unknown);
60
+ if (text !== `${canonicalJson(policy)}\n`) throw new Error("noncanonical policy");
61
+ return Object.freeze({ managed: true, revision: policy.revision, entries: policy.entries, contentSha256: sha256(text) });
62
+ } catch {
63
+ throw new OperationPermissionError("OPERATION_POLICY_INVALID", "Operation permission state is missing or invalid; execution is blocked until it is repaired.");
64
+ }
65
+ }
66
+
67
+ export function enableOperationPermissions(expectedRevision: number, environment: PermissionEnvironment = process.env): OperationPolicySnapshot {
68
+ const snapshot = readOperationPolicy(environment);
69
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== snapshot.revision) throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Operation policy changed; refresh Ghostget before editing it.");
70
+ if (snapshot.managed) return snapshot;
71
+ const selected = paths(environment);
72
+ ensurePrivateStateDirectory(selected.directory, environment);
73
+ // Publishing the marker first deliberately makes interrupted opt-in fail closed.
74
+ const created = createPrivateJsonIfAbsent(selected.marker, marker, { environment });
75
+ if (!created.created) throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Another control session enabled operation permissions.");
76
+ if (!createPrivateJsonIfAbsent(selected.policy, { schemaVersion: 1, revision: 1, entries: [] }, { environment }).created) {
77
+ throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Operation policy appeared concurrently; refresh Ghostget.");
78
+ }
79
+ return readOperationPolicy(environment);
80
+ }
81
+
82
+ export function setOperationPolicyEntry(digest: string, decision: PermissionDecision, expectedRevision: number, environment: PermissionEnvironment = process.env): OperationPolicySnapshot {
83
+ if (!DIGEST.test(digest) || !["allow", "deny", "ask"].includes(decision)) throw new OperationPermissionError("OPERATION_POLICY_INVALID", "Operation permission update is invalid.");
84
+ const snapshot = readOperationPolicy(environment);
85
+ if (!snapshot.managed || snapshot.contentSha256 === null || !Number.isSafeInteger(expectedRevision) || snapshot.revision !== expectedRevision
86
+ || snapshot.revision >= Number.MAX_SAFE_INTEGER) throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Enable operation permissions or refresh the policy before editing it.");
87
+ const entries = [...snapshot.entries.filter(entry => entry.digest !== digest), { digest, decision }].sort((a, b) => a.digest.localeCompare(b.digest));
88
+ const policy = parseOperationPolicy({ schemaVersion: 1, revision: snapshot.revision + 1, entries });
89
+ if (Buffer.byteLength(canonicalJson(policy)) > MAX_POLICY_BYTES - 1) throw new OperationPermissionError("OPERATION_POLICY_INVALID", "Operation policy exceeds its size limit.");
90
+ if (!writePrivateJsonIfUnchanged(paths(environment).policy, policy, { expectedCurrentContentSha256: snapshot.contentSha256 })) throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Operation policy changed; refresh Ghostget before editing it.");
91
+ return readOperationPolicy(environment);
92
+ }