@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.
- package/CHANGELOG.md +301 -0
- package/README.md +16 -9
- package/dist/apple-photos-client.js +2 -1
- package/dist/beeper-client.js +2 -1
- package/dist/client.js +1 -0
- package/dist/imessage-direct-install-2gm3kge7.js +324 -0
- package/dist/index-01eeae9e.js +5 -0
- package/dist/index-2ymnp8xv.js +145 -0
- package/dist/index-en5hycxp.js +175 -0
- package/dist/{index-cytf8d9p.js → index-hfbygww8.js} +1 -1
- package/dist/index-n4szk3nw.js +50 -0
- package/dist/index-yq6maz71.js +1105 -0
- package/dist/index-z1w83f81.js +4 -0
- package/dist/index.js +4 -47
- package/dist/messaging-automation-api.js +21 -0
- package/dist/messaging-automation-j4274hvc.js +609 -0
- package/dist/messaging-native-install-8w1j36ah.js +16 -0
- package/dist/messaging.js +1 -0
- package/dist/omni-client.js +1 -0
- package/dist/whatsapp-automation-runtime-hgh1e9rm.js +845 -0
- package/dist/whatsapp-client.js +1 -0
- package/docs/control-panel.md +147 -0
- package/docs/imessage-direct-provider.md +31 -7
- package/docs/messaging-automation.md +103 -0
- package/package.json +66 -7
- package/skills/ghostget/SKILL.md +3 -1
- package/skills/ghostget/references/control-panel.md +43 -0
- package/skills/ghostget/references/install.md +5 -5
- package/skills/ghostget/references/linkedin-adapter.md +222 -12
- package/skills/ghostget/references/platform-patterns.md +1 -1
- package/src/args.ts +18 -5
- package/src/assets/adapters/imessage/wrench-web-adapter.json +126 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +144 -0
- package/src/assets/messaging-runtime/NOTICE.txt +2580 -0
- package/src/assets/messaging-runtime/imsg-darwin-arm64.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-info.plist.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-metadata.json.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-privacy.plist.gz +0 -0
- package/src/assets/messaging-runtime/wacli-darwin-arm64.gz +0 -0
- package/src/auth.ts +35 -1
- package/src/beeper-client-types.ts +1 -1
- package/src/cli.ts +18 -0
- package/src/confirmed-write-platform.ts +16 -1
- package/src/control/account-revision.ts +16 -0
- package/src/control/activity.ts +104 -0
- package/src/control/approval-broker.ts +59 -0
- package/src/control/approval-client.ts +49 -0
- package/src/control/bundled-interfaces.ts +20 -0
- package/src/control/cli.ts +20 -0
- package/src/control/connections.ts +87 -0
- package/src/control/credential-helper.ts +152 -0
- package/src/control/helper.ts +79 -0
- package/src/control/interface-cli.ts +22 -0
- package/src/control/interface-json.ts +94 -0
- package/src/control/interface-schema.ts +120 -0
- package/src/control/interfaces.ts +438 -0
- package/src/control/protocol.ts +182 -0
- package/src/control/service.ts +104 -0
- package/src/control/validation.ts +103 -0
- package/src/control/vault.ts +105 -0
- package/src/control/web-gateway.ts +62 -0
- package/src/control/web-policy.ts +56 -0
- package/src/ghostget.ts +18 -4
- package/src/messaging-automation-api.ts +12 -0
- package/src/messaging-automation-descriptors.ts +29 -0
- package/src/messaging-automation-factory.ts +148 -0
- package/src/messaging-automation-server.ts +183 -0
- package/src/messaging-automation-types.ts +155 -0
- package/src/messaging-automation-validation.ts +110 -0
- package/src/messaging-automation.ts +356 -0
- package/src/messaging-runtime.ts +3 -0
- package/src/oauth-google.ts +11 -5
- package/src/omni-runtime.ts +18 -3
- package/src/operation-permission-store.ts +92 -0
- package/src/operation-permission.ts +308 -0
- package/src/pinned-https.ts +5 -0
- package/src/plugins/imessage-direct/plugin.ts +12 -1
- package/src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch +276 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +84 -10
- package/src/plugins/linkedin-web/plugin.ts +1 -1
- package/src/plugins/whatsapp-linked-device/plugin.ts +7 -2
- package/src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch +1093 -0
- package/src/plugins/whatsapp-linked-device/vendor/README.md +39 -0
- package/src/plugins/whatsapp-linked-device/vendor/provenance.json +45 -0
- package/src/provider-http.ts +11 -3
- package/src/provider-plugin-contract-identity.ts +2 -2
- package/src/provider-plugin-import-analysis.ts +52 -0
- package/src/provider-plugin-module-analysis.ts +21 -1
- package/src/provider-plugin-registry.ts +4 -8
- package/src/provider-plugin.ts +18 -8
- package/src/providers/imessage-automation.ts +250 -0
- package/src/providers/imessage-direct-install.ts +7 -0
- package/src/providers/imessage-direct-runtime.ts +32 -2
- package/src/providers/imessage-direct.ts +13 -3
- package/src/providers/linkedin-contact-failure.ts +21 -0
- package/src/providers/linkedin-contact-platform.ts +8 -1
- package/src/providers/linkedin-contact-program.ts +34 -5
- package/src/providers/linkedin-web-contact.ts +1223 -35
- package/src/providers/linkedin-web-profile-browser.ts +659 -45
- package/src/providers/linkedin-web-runtime.ts +1 -0
- package/src/providers/linkedin-web.ts +46 -2
- package/src/providers/messaging-native-artifacts.ts +38 -0
- package/src/providers/messaging-native-install.ts +75 -0
- package/src/providers/whatsapp-automation-runtime.ts +240 -0
- package/src/providers/whatsapp-automation.ts +153 -0
- package/src/read-client.ts +12 -2
- package/src/runtime.ts +81 -9
- package/src/state-helper.ts +2 -0
- package/src/storage.ts +71 -1
- package/src/usage.ts +9 -1
- package/src/version.ts +1 -1
- package/src/web-session-contract-definitions.ts +1 -1
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { canonicalJson, sha256 } from "./canonical-json";
|
|
3
|
+
import { loadAuth, parseAuth } from "./auth";
|
|
4
|
+
import { isLocalCliOperation, isProviderOperation, isWebSessionOperation, manifestHash, parseRuntimeManifest, type GhostgetManifest, type OperationInput } from "./model";
|
|
5
|
+
import { providerContractHash, getProviderContract } from "./provider-contracts";
|
|
6
|
+
import { getWebSessionContract, webSessionContractHash } from "./web-session-contracts";
|
|
7
|
+
import { getLocalCliContract, localCliContractHash } from "./local-cli-contracts";
|
|
8
|
+
import { requireProviderPluginAuth } from "./provider-plugin-auth";
|
|
9
|
+
import type { ProviderPluginRegistry, ProviderPluginOperationResolutionV1 } from "./provider-plugin-registry";
|
|
10
|
+
import { ghostgetStateHome, loadInstalledManifest } from "./storage";
|
|
11
|
+
import { projectionAuthIdentityHash, withSettledReadProjectionAuthAdmission } from "./read-projections";
|
|
12
|
+
import { publicWebSessionAuthorityIdentityHash, webSessionAuthenticationPolicy, type InvocationAuthority } from "./web-session-authentication-policy";
|
|
13
|
+
import type { PreparedInvocation, StoredPlan } from "./runtime";
|
|
14
|
+
import { summarizePlanFile } from "./plan-assets";
|
|
15
|
+
import type { ApprovalTarget, CheckedApproval, JsonValue, PermissionDecision } from "./control/protocol";
|
|
16
|
+
import { OperationPermissionError, readOperationPolicy, setOperationPolicyEntry, type PermissionEnvironment, type OperationPolicySnapshot } from "./operation-permission-store";
|
|
17
|
+
export { OperationPermissionError, readOperationPolicy, enableOperationPermissions } from "./operation-permission-store";
|
|
18
|
+
|
|
19
|
+
type Options = { readonly environment: PermissionEnvironment; readonly registry: ProviderPluginRegistry; readonly signal?: AbortSignal };
|
|
20
|
+
type ProviderTarget = Extract<ApprovalTarget, { readonly kind: "provider" }>;
|
|
21
|
+
type ApprovalLease = Readonly<{ id: string; digest: string }>;
|
|
22
|
+
export type OperationPermissionIdentity = Readonly<{
|
|
23
|
+
schemaVersion: 1; pluginId: string; transport: string; surfaceId: string; operation: string; contractVersion: number;
|
|
24
|
+
adapterId: string; manifestHash: string; authId: string; authIncarnation: string; contractHash: string; closureHash: string; portableHash: string | null;
|
|
25
|
+
}>;
|
|
26
|
+
export type OperationPermissionDescription = Readonly<{
|
|
27
|
+
digest: string; decision: PermissionDecision | "unmanaged"; revision: number; coordinate: OperationPermissionIdentity;
|
|
28
|
+
manifest: GhostgetManifest; resolution: ProviderPluginOperationResolutionV1; auth: InvocationAuthority;
|
|
29
|
+
}>;
|
|
30
|
+
type Admission = Readonly<{ capability: string; requestDigest: string; revision: number; inputs: ReadonlySet<string>; planDigest: string | null; lease: ApprovalLease | null; stateHome: string }>;
|
|
31
|
+
const admissions = new AsyncLocalStorage<readonly Admission[]>();
|
|
32
|
+
const checkedCapabilities = new WeakMap<CheckedApproval, Readonly<{ capability: string; target: string }>>();
|
|
33
|
+
const MAX_APPROVAL_BYTES = 240 * 1024;
|
|
34
|
+
|
|
35
|
+
function changed(): never { throw new OperationPermissionError("OPERATION_PERMISSION_CHANGED", "Operation, account, input, or permissions changed; request fresh authorization."); }
|
|
36
|
+
function denied(): never { throw new OperationPermissionError("OPERATION_PERMISSION_DENIED", "This operation is denied in Ghostget. Review its account and operation permissions in the app."); }
|
|
37
|
+
function approvalRequired(): never { throw new OperationPermissionError("OPERATION_APPROVAL_REQUIRED", "This operation requires human approval in Ghostget. Run a live invocation with the app open; cached reads never wait for approval."); }
|
|
38
|
+
|
|
39
|
+
function resolutionFor(manifest: GhostgetManifest, operationId: string, registry: ProviderPluginRegistry): ProviderPluginOperationResolutionV1 {
|
|
40
|
+
const operation = manifest.operations[operationId];
|
|
41
|
+
if (operation === undefined) throw new Error("Operation is not installed.");
|
|
42
|
+
if (isProviderOperation(operation)) return registry.requireOperationDefinition("provider-api", operation.provider.provider, operation.provider.action, operation.provider.contractVersion);
|
|
43
|
+
if (isLocalCliOperation(operation)) return registry.requireOperationDefinition("local-cli", operation.localCli.surface, operation.localCli.action, operation.localCli.contractVersion);
|
|
44
|
+
if (isWebSessionOperation(operation)) return registry.requireOperationDefinition(registry.requireSessionRoute(operation.webSession.site).transport, operation.webSession.site, operation.webSession.action, operation.webSession.contractVersion);
|
|
45
|
+
throw new OperationPermissionError("OPERATION_PERMISSION_DENIED", "This legacy transport does not support managed operation permissions.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function currentManifest(adapterId: string, options: Options): GhostgetManifest {
|
|
49
|
+
const owned = options.registry.resolveOwnedManifest(adapterId);
|
|
50
|
+
const selected = owned === undefined ? loadInstalledManifest(adapterId, options.environment, options.registry) : parseRuntimeManifest(owned, options.registry);
|
|
51
|
+
if (!selected.ok) throw new Error("The selected adapter is unavailable or invalid.");
|
|
52
|
+
return selected.value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function publicAuthority(manifest: GhostgetManifest, operationId: string, resolution: ProviderPluginOperationResolutionV1): InvocationAuthority | null {
|
|
56
|
+
const operation = manifest.operations[operationId]!;
|
|
57
|
+
if (!isWebSessionOperation(operation)) return null;
|
|
58
|
+
const policy = webSessionAuthenticationPolicy({
|
|
59
|
+
adapterId: manifest.id, operationId, recipe: operation.webSession, pluginSourceKind: resolution.plugin.sourceKind,
|
|
60
|
+
portable: resolution.portableIdentity !== null, risk: resolution.operation.risk, state: resolution.operation.state, dispatch: resolution.operation.dispatch,
|
|
61
|
+
...(resolution.contractVersion === resolution.operation.contractVersion && resolution.operation.access !== undefined ? { access: resolution.operation.access } : {}),
|
|
62
|
+
});
|
|
63
|
+
return policy.kind === "public" ? policy.authority : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type AccountIdentity = Readonly<{ auth: ReturnType<typeof loadAuth>; incarnation: string }>;
|
|
67
|
+
type Inspection = {
|
|
68
|
+
policy: OperationPolicySnapshot;
|
|
69
|
+
manifests: Map<string, GhostgetManifest>;
|
|
70
|
+
accounts: Map<string, AccountIdentity | null>;
|
|
71
|
+
closures: Map<ProviderPluginOperationResolutionV1["binding"], string>;
|
|
72
|
+
contracts: Map<string, string>;
|
|
73
|
+
};
|
|
74
|
+
function accountIdentity(id: string, options: Options): AccountIdentity {
|
|
75
|
+
return withSettledReadProjectionAuthAdmission(id, options.environment, () => {
|
|
76
|
+
const auth = loadAuth(id, options.environment);
|
|
77
|
+
return { auth, incarnation: projectionAuthIdentityHash(auth.id, sha256(canonicalJson(auth)), options.environment) };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function inspectedAccount(id: string, options: Options, inspection: Inspection): AccountIdentity {
|
|
81
|
+
if (!inspection.accounts.has(id)) {
|
|
82
|
+
try { inspection.accounts.set(id, accountIdentity(id, options)); } catch { inspection.accounts.set(id, null); }
|
|
83
|
+
}
|
|
84
|
+
const account = inspection.accounts.get(id);
|
|
85
|
+
if (account === null || account === undefined) throw new Error("The selected account is unavailable.");
|
|
86
|
+
return account;
|
|
87
|
+
}
|
|
88
|
+
function describe(adapterId: string, operationId: string, authId: string | null, options: Options, inspection: Inspection): OperationPermissionDescription {
|
|
89
|
+
const { policy } = inspection;
|
|
90
|
+
const manifest = inspection.manifests.get(adapterId) ?? currentManifest(adapterId, options);
|
|
91
|
+
inspection.manifests.set(adapterId, manifest);
|
|
92
|
+
const resolution = resolutionFor(manifest, operationId, options.registry);
|
|
93
|
+
const operation = manifest.operations[operationId]!;
|
|
94
|
+
const publicAuth = publicAuthority(manifest, operationId, resolution);
|
|
95
|
+
if (publicAuth !== null && authId !== null) throw new Error("Public operations do not accept an account.");
|
|
96
|
+
if (publicAuth === null && authId === null) throw new Error("Select an explicit account to inspect private operation permissions.");
|
|
97
|
+
const selectedId = authId ?? adapterId;
|
|
98
|
+
const authority = publicAuth !== null
|
|
99
|
+
? { auth: publicAuth, incarnation: publicWebSessionAuthorityIdentityHash(publicAuth as Parameters<typeof publicWebSessionAuthorityIdentityHash>[0]) }
|
|
100
|
+
: inspectedAccount(selectedId, options, inspection);
|
|
101
|
+
if (publicAuth === null) {
|
|
102
|
+
requireProviderPluginAuth(resolution.binding, authority.auth as ReturnType<typeof loadAuth>);
|
|
103
|
+
}
|
|
104
|
+
const contractKey = canonicalJson([resolution.binding.transport, resolution.binding.surfaceId, resolution.operation.name, resolution.contractVersion]);
|
|
105
|
+
const contractHash = inspection.contracts.get(contractKey) ?? (isProviderOperation(operation) ? providerContractHash(getProviderContract(operation.provider, options.registry), options.registry)
|
|
106
|
+
: isWebSessionOperation(operation) ? webSessionContractHash(getWebSessionContract(operation.webSession, options.registry), options.registry)
|
|
107
|
+
: isLocalCliOperation(operation) ? localCliContractHash(getLocalCliContract(operation.localCli, options.registry), options.registry) : denied());
|
|
108
|
+
inspection.contracts.set(contractKey, contractHash);
|
|
109
|
+
const closureHash = inspection.closures.get(resolution.binding) ?? options.registry.implementationClosureHash(resolution.binding);
|
|
110
|
+
inspection.closures.set(resolution.binding, closureHash);
|
|
111
|
+
const coordinate: OperationPermissionIdentity = Object.freeze({
|
|
112
|
+
schemaVersion: 1, pluginId: resolution.plugin.id, transport: resolution.binding.transport, surfaceId: resolution.binding.surfaceId,
|
|
113
|
+
operation: resolution.operation.name, contractVersion: resolution.contractVersion, adapterId, manifestHash: manifestHash(manifest),
|
|
114
|
+
authId: authority.auth.id, authIncarnation: authority.incarnation, contractHash,
|
|
115
|
+
closureHash, portableHash: resolution.portableIdentity === null ? null : sha256(canonicalJson(resolution.portableIdentity)),
|
|
116
|
+
});
|
|
117
|
+
const digest = sha256(canonicalJson(coordinate));
|
|
118
|
+
return Object.freeze({ digest, coordinate, revision: policy.revision, decision: policy.managed ? policy.entries.find(entry => entry.digest === digest)?.decision ?? "deny" : "unmanaged", manifest, resolution, auth: authority.auth });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function inspection(options: Options): Inspection {
|
|
122
|
+
return { policy: readOperationPolicy(options.environment), manifests: new Map(), accounts: new Map(), closures: new Map(), contracts: new Map() };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function describeOperationPermission(adapterId: string, operationId: string, authId: string | null, options: Options): OperationPermissionDescription {
|
|
126
|
+
return describe(adapterId, operationId, authId, options, inspection(options));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Snapshot-local reuse keeps control-panel inspection proportional to unique accounts and adapters. Never reuse this snapshot to authorize a later request. */
|
|
130
|
+
export function describeOperationPermissions(requests: readonly Readonly<{ adapterId: string; operationId: string; authId: string | null }>[], options: Options): readonly (OperationPermissionDescription | null)[] {
|
|
131
|
+
const unavailable = () => Object.freeze(requests.map(() => null));
|
|
132
|
+
let snapshot: Inspection;
|
|
133
|
+
try { snapshot = inspection(options); } catch { return unavailable(); }
|
|
134
|
+
const results = requests.map(request => {
|
|
135
|
+
try { return describe(request.adapterId, request.operationId, request.authId, options, snapshot); } catch { return null; }
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
for (const [id, manifest] of snapshot.manifests) if (manifestHash(currentManifest(id, options)) !== manifestHash(manifest)) return unavailable();
|
|
139
|
+
for (const [id, identity] of snapshot.accounts) if (identity !== null && canonicalJson(accountIdentity(id, options)) !== canonicalJson(identity)) return unavailable();
|
|
140
|
+
for (const [binding, closure] of snapshot.closures) if (options.registry.implementationClosureHash(binding) !== closure) return unavailable();
|
|
141
|
+
if (canonicalJson(readOperationPolicy(options.environment)) !== canonicalJson(snapshot.policy)) return unavailable();
|
|
142
|
+
} catch { return unavailable(); }
|
|
143
|
+
return Object.freeze(results);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function setOperationPermission(request: {
|
|
147
|
+
readonly adapterId: string; readonly operationId: string; readonly authId: string | null; readonly decision: PermissionDecision;
|
|
148
|
+
readonly expectedRevision: number; readonly expectedCapabilityDigest: string;
|
|
149
|
+
}, options: Options) {
|
|
150
|
+
const description = describeOperationPermission(request.adapterId, request.operationId, request.authId, options);
|
|
151
|
+
if (description.digest !== request.expectedCapabilityDigest || description.revision !== request.expectedRevision) return changed();
|
|
152
|
+
return setOperationPolicyEntry(description.digest, request.decision, request.expectedRevision, options.environment);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function describeInvocation(invocation: PreparedInvocation, options: Options): OperationPermissionDescription {
|
|
156
|
+
const description = describeOperationPermission(invocation.manifest.id, invocation.operationId, invocation.auth.kind === "public-web-session" ? null : invocation.auth.id, options);
|
|
157
|
+
if (manifestHash(invocation.manifest) !== description.coordinate.manifestHash || canonicalJson(invocation.auth) !== canonicalJson(description.auth)
|
|
158
|
+
|| invocation.readProjectionAuthIdentityHash !== description.coordinate.authIncarnation) return changed();
|
|
159
|
+
if (invocation.auth.kind !== "public-web-session") parseAuth(invocation.auth);
|
|
160
|
+
return description;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function activeAdmission(description: OperationPermissionDescription, input: OperationInput, options: Options): Admission | undefined {
|
|
164
|
+
const hash = sha256(canonicalJson(input));
|
|
165
|
+
const stateHome = ghostgetStateHome(options.environment);
|
|
166
|
+
return admissions.getStore()?.findLast(admission => admission.stateHome === stateHome && admission.capability === description.digest && admission.revision === description.revision && admission.inputs.has(hash));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Cheap deny check for construction and identity queries. Inspection by the control plane uses describeOperationPermission instead. */
|
|
170
|
+
export function assertOperationPreparationPermission(invocation: PreparedInvocation, options: Options): void {
|
|
171
|
+
if (!readOperationPolicy(options.environment).managed) return;
|
|
172
|
+
if (describeInvocation(invocation, options).decision === "deny") denied();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Synchronous disclosure never opens IPC or creates an approval request. */
|
|
176
|
+
export function assertOperationPermission(invocation: PreparedInvocation, options: Options): void {
|
|
177
|
+
const policy = readOperationPolicy(options.environment);
|
|
178
|
+
if (!policy.managed) {
|
|
179
|
+
if (admissions.getStore()?.some(admission => admission.revision !== 0)) changed();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const description = describeInvocation(invocation, options);
|
|
183
|
+
if (admissions.getStore()?.some(admission => admission.capability === description.digest && admission.revision !== description.revision)) changed();
|
|
184
|
+
if (description.decision === "deny") denied();
|
|
185
|
+
if (description.decision === "ask" && activeAdmission(description, invocation.input, options) === undefined) approvalRequired();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function checkOperationPermission(invocation: PreparedInvocation, options: Options): Promise<void> {
|
|
189
|
+
assertOperationPermission(invocation, options);
|
|
190
|
+
if (!readOperationPolicy(options.environment).managed) return;
|
|
191
|
+
const description = describeInvocation(invocation, options);
|
|
192
|
+
const admission = activeAdmission(description, invocation.input, options);
|
|
193
|
+
if (admission?.lease !== null && admission?.lease !== undefined) {
|
|
194
|
+
const { checkApproval } = await import("./control/approval-client");
|
|
195
|
+
await checkApproval(admission.lease, { environment: options.environment, ...(options.signal === undefined ? {} : { signal: options.signal }) });
|
|
196
|
+
assertOperationPermission(invocation, options);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function inputForTarget(input: OperationInput): JsonValue {
|
|
201
|
+
return JSON.parse(canonicalJson(Object.fromEntries(Object.entries(input).map(([key, value]) => [key,
|
|
202
|
+
Array.isArray(value) ? value.map(item => typeof item === "object" ? item.reference : item)
|
|
203
|
+
: typeof value === "object" && "reference" in value ? value.reference : value,
|
|
204
|
+
])))) as JsonValue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function checkedApproval(invocation: PreparedInvocation, description: OperationPermissionDescription, stored: StoredPlan | null): CheckedApproval {
|
|
208
|
+
const previewOf = (input: OperationInput): unknown => Object.fromEntries(Object.entries(input).map(([key, value]) => [key,
|
|
209
|
+
Array.isArray(value) ? value.map(item => typeof item === "object" ? summarizePlanFile(item) : item)
|
|
210
|
+
: typeof value === "object" ? summarizePlanFile(value as Parameters<typeof summarizePlanFile>[0]) : value,
|
|
211
|
+
]));
|
|
212
|
+
const composite = stored?.plan.messagingComposite;
|
|
213
|
+
const previewInput = composite === undefined ? previewOf(invocation.input) : {
|
|
214
|
+
recipient: composite.recipient,
|
|
215
|
+
routeRef: composite.routeRef,
|
|
216
|
+
contextRef: composite.contextRef,
|
|
217
|
+
parts: composite.parts.map(part => ({ part: part.partId, text: part.text, input: previewOf(part.input) })),
|
|
218
|
+
};
|
|
219
|
+
const preview = canonicalJson(previewInput);
|
|
220
|
+
if (Buffer.byteLength(preview) > MAX_APPROVAL_BYTES) throw new OperationPermissionError("OPERATION_APPROVAL_TOO_LARGE", "This operation exceeds the human approval preview size limit; no request was executed.");
|
|
221
|
+
const digest = sha256(canonicalJson({ protocol: "ghostget.operation-approval/1", capability: description.digest, revision: description.revision,
|
|
222
|
+
inputHash: sha256(canonicalJson(invocation.input)), planDigest: stored?.digest ?? null, previewHash: sha256(preview) }));
|
|
223
|
+
return Object.freeze({ digest, revision: description.revision, decision: description.decision === "unmanaged" ? "allow" : description.decision,
|
|
224
|
+
kind: "provider", title: `${description.manifest.displayName}: ${invocation.operationId}`, account: invocation.auth.kind === "public-web-session" ? null : invocation.auth.id,
|
|
225
|
+
effect: description.manifest.operations[invocation.operationId]!.sideEffect, preview });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Recompute agent requests using current installed manifests/auth and encrypted plans. Caller summaries never grant authority. */
|
|
229
|
+
export async function checkProviderApproval(target: ProviderTarget, options: Options): Promise<CheckedApproval> {
|
|
230
|
+
const { prepareOperationApprovalInvocation } = await import("./runtime");
|
|
231
|
+
const { invocation, stored } = prepareOperationApprovalInvocation(target, options);
|
|
232
|
+
if (stored === null && invocation.manifest.operations[invocation.operationId]!.risk !== "R1") {
|
|
233
|
+
throw new OperationPermissionError("OPERATION_APPROVAL_REQUIRED", "This operation requires an exact saved confirmation plan before human approval.");
|
|
234
|
+
}
|
|
235
|
+
const description = describeInvocation(invocation, options);
|
|
236
|
+
const checked = checkedApproval(invocation, description, stored);
|
|
237
|
+
checkedCapabilities.set(checked, { capability: description.digest, target: canonicalJson(target) });
|
|
238
|
+
return checked;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A consumed plan is intentionally gone. Recheck retained admission authority, never revive or reload it. */
|
|
242
|
+
export async function recheckProviderApproval(target: ProviderTarget, checked: CheckedApproval, options: Options): Promise<CheckedApproval> {
|
|
243
|
+
const retained = checkedCapabilities.get(checked);
|
|
244
|
+
if (retained === undefined || retained.target !== canonicalJson(target)) return changed();
|
|
245
|
+
const current = describeOperationPermission(target.adapterId, target.operationId, target.authId, options);
|
|
246
|
+
if (current.digest !== retained.capability || current.revision !== checked.revision || current.decision !== "ask") return changed();
|
|
247
|
+
return checked;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Invocation-local permits cannot authorize another input, account, manifest or policy revision. */
|
|
251
|
+
export async function withOperationPermission<T>(invocation: PreparedInvocation, optionsValue: Options & { readonly plan?: StoredPlan }, work: () => Promise<T>): Promise<T> {
|
|
252
|
+
const options = { ...optionsValue, environment: Object.freeze({ ...optionsValue.environment }) };
|
|
253
|
+
const policy = readOperationPolicy(options.environment);
|
|
254
|
+
if (!policy.managed) return withUnmanagedOperationPermission(options.environment, work);
|
|
255
|
+
const description = describeInvocation(invocation, options);
|
|
256
|
+
if (description.decision === "deny") denied();
|
|
257
|
+
const existing = activeAdmission(description, invocation.input, options);
|
|
258
|
+
if (existing !== undefined) {
|
|
259
|
+
if (options.plan !== undefined && existing.planDigest !== options.plan.digest) return changed();
|
|
260
|
+
await checkOperationPermission(invocation, options);
|
|
261
|
+
const result = await work();
|
|
262
|
+
await checkOperationPermission(invocation, options);
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
const checked = description.decision === "ask" ? checkedApproval(invocation, description, options.plan ?? null) : null;
|
|
266
|
+
// Capture the authorized values before the first await: readonly TypeScript input is not an immutable runtime object.
|
|
267
|
+
const inputHashes = [sha256(canonicalJson(invocation.input)), ...(options.plan?.plan.messagingComposite?.parts.map(part => sha256(canonicalJson(part.input))) ?? [])];
|
|
268
|
+
const planDigest = options.plan?.digest ?? null;
|
|
269
|
+
const stateHome = ghostgetStateHome(options.environment);
|
|
270
|
+
let lease: ApprovalLease | null = null;
|
|
271
|
+
if (description.decision === "ask") {
|
|
272
|
+
const target: ProviderTarget = { kind: "provider", adapterId: invocation.manifest.id, operationId: invocation.operationId,
|
|
273
|
+
authId: invocation.auth.kind === "public-web-session" ? null : invocation.auth.id,
|
|
274
|
+
input: options.plan === undefined ? inputForTarget(invocation.input) : null, planDigest: options.plan?.digest ?? null };
|
|
275
|
+
const { requestApproval } = await import("./control/approval-client");
|
|
276
|
+
if (checked === null) return changed();
|
|
277
|
+
lease = await requestApproval(target, checked.digest, { environment: options.environment, ...(options.signal === undefined ? {} : { signal: options.signal }) });
|
|
278
|
+
}
|
|
279
|
+
const admission: Admission = Object.freeze({ capability: description.digest, requestDigest: checked?.digest ?? description.digest, revision: description.revision,
|
|
280
|
+
inputs: new Set(inputHashes), planDigest, lease, stateHome });
|
|
281
|
+
try {
|
|
282
|
+
return await admissions.run([...(admissions.getStore() ?? []), admission], async () => {
|
|
283
|
+
const current = describeInvocation(invocation, options);
|
|
284
|
+
if (current.digest !== description.digest || current.revision !== description.revision) changed();
|
|
285
|
+
await checkOperationPermission(invocation, options);
|
|
286
|
+
const result = await work();
|
|
287
|
+
await checkOperationPermission(invocation, options);
|
|
288
|
+
return result;
|
|
289
|
+
});
|
|
290
|
+
} finally {
|
|
291
|
+
if (lease !== null) {
|
|
292
|
+
const { releaseApproval } = await import("./control/approval-client");
|
|
293
|
+
await releaseApproval(lease, { environment: options.environment });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Preserve synchronous native admission while preventing opt-in during an in-flight legacy request from disclosing its result. */
|
|
299
|
+
export async function withUnmanagedOperationPermission<T>(environment: PermissionEnvironment, work: () => Promise<T>): Promise<T> {
|
|
300
|
+
const result = await work();
|
|
301
|
+
if (readOperationPolicy(environment).managed) changed();
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function withOperationPermissions<T>(invocations: readonly PreparedInvocation[], options: Options, work: () => Promise<T>): Promise<T> {
|
|
306
|
+
if (invocations.length === 0) return work();
|
|
307
|
+
return withOperationPermission(invocations[0]!, options, () => withOperationPermissions(invocations.slice(1), options, work));
|
|
308
|
+
}
|
package/src/pinned-https.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type PinnedHttpsResponse = PinnedNetworkResponse;
|
|
|
15
15
|
export type PinnedHttpsDependencies = {
|
|
16
16
|
readonly resolveTarget: typeof resolveSafeNetworkTarget;
|
|
17
17
|
readonly request: NetworkTransport;
|
|
18
|
+
readonly beforeRequest?: () => void;
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export type PinnedHttpsFetch = (
|
|
@@ -103,14 +104,18 @@ export async function pinnedHttpsFetch(
|
|
|
103
104
|
throw new Error("authenticated request timeout is invalid");
|
|
104
105
|
}
|
|
105
106
|
const resolved = { ...defaultDependencies, ...dependencies };
|
|
107
|
+
init.signal.throwIfAborted();
|
|
106
108
|
const addresses = await resolved.resolveTarget(url, {
|
|
107
109
|
allowPrivateNetwork: false,
|
|
108
110
|
timeoutMs,
|
|
109
111
|
});
|
|
112
|
+
init.signal.throwIfAborted();
|
|
110
113
|
const address = addresses[0];
|
|
111
114
|
if (address === undefined) throw new Error("authenticated HTTPS origin did not resolve to a safe address");
|
|
112
115
|
const headers = new Headers(init.headers);
|
|
113
116
|
const body = requestBody(init.body);
|
|
117
|
+
resolved.beforeRequest?.();
|
|
118
|
+
init.signal.throwIfAborted();
|
|
114
119
|
const response = await resolved.request({
|
|
115
120
|
url,
|
|
116
121
|
address,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { automationOperationDefinitions } from "../../messaging-automation-descriptors";
|
|
1
2
|
import {
|
|
2
3
|
defineProviderPlugin,
|
|
3
4
|
lazyLocalCliRuntime,
|
|
@@ -108,6 +109,12 @@ export const imessageDirectPlugin = defineProviderPlugin({
|
|
|
108
109
|
sourceKind: "built-in",
|
|
109
110
|
implementationSources: Object.freeze([
|
|
110
111
|
...providerImplementationEntry(import.meta.url),
|
|
112
|
+
Object.freeze({ label: "native/imsg-darwin-arm64.gz", url: new URL("../../assets/messaging-runtime/imsg-darwin-arm64.gz", import.meta.url) }),
|
|
113
|
+
Object.freeze({ label: "native/phone-number-metadata.json.gz", url: new URL("../../assets/messaging-runtime/phone-number-metadata.json.gz", import.meta.url) }),
|
|
114
|
+
Object.freeze({ label: "native/phone-number-privacy.plist.gz", url: new URL("../../assets/messaging-runtime/phone-number-privacy.plist.gz", import.meta.url) }),
|
|
115
|
+
Object.freeze({ label: "native/phone-number-info.plist.gz", url: new URL("../../assets/messaging-runtime/phone-number-info.plist.gz", import.meta.url) }),
|
|
116
|
+
Object.freeze({ label: "native/NOTICE.txt", url: new URL("../../assets/messaging-runtime/NOTICE.txt", import.meta.url) }),
|
|
117
|
+
|
|
111
118
|
Object.freeze({
|
|
112
119
|
label: "vendor/private-transport.patch",
|
|
113
120
|
url: new URL(
|
|
@@ -122,6 +129,10 @@ export const imessageDirectPlugin = defineProviderPlugin({
|
|
|
122
129
|
import.meta.url,
|
|
123
130
|
),
|
|
124
131
|
}),
|
|
132
|
+
Object.freeze({
|
|
133
|
+
label: "vendor/no-fetch-rich-cards.patch",
|
|
134
|
+
url: new URL("./vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch", import.meta.url),
|
|
135
|
+
}),
|
|
125
136
|
Object.freeze({
|
|
126
137
|
label: "vendor/provenance.json",
|
|
127
138
|
url: new URL("./vendor/provenance.json", import.meta.url),
|
|
@@ -143,7 +154,7 @@ export const imessageDirectPlugin = defineProviderPlugin({
|
|
|
143
154
|
sourceUrl: IMSG_TOOL_PIN.sourceUrl,
|
|
144
155
|
artifacts: IMSG_TOOL_PIN.artifacts,
|
|
145
156
|
},
|
|
146
|
-
operations,
|
|
157
|
+
operations: [...operations, ...automationOperationDefinitions("imessage")],
|
|
147
158
|
subject: {
|
|
148
159
|
format: "imessage:device-default:<sha256-messages-store-coordinate>",
|
|
149
160
|
matches: (value) => /^imessage:device-default:[a-f0-9]{64}$/u.test(value),
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
From 520b82ab025c1d4d57333b552aa65c9ae3fdb5fd Mon Sep 17 00:00:00 2001
|
|
2
|
+
From: 0thernet <ben@substrate.run>
|
|
3
|
+
Date: Fri, 11 Sep 2026 18:46:37 -0400
|
|
4
|
+
Subject: [PATCH] feat(rpc): add native rich cards without metadata fetching
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
.../RPCServer+BridgeMessageHandlers.swift | 12 +++++-
|
|
8
|
+
Sources/imsg/RichLinkPreparer.swift | 13 +++++++
|
|
9
|
+
.../RPCBridgeMessageHandlersTests.swift | 37 +++++++++++++++++++
|
|
10
|
+
.../RPCSendRichLinkValidationTests.swift | 4 ++
|
|
11
|
+
Tests/imsgTests/RPCServerTests.swift | 21 +++++++----
|
|
12
|
+
Tests/imsgTests/RichLinkPreparerTests.swift | 28 ++++++++++++++
|
|
13
|
+
docs/rpc.md | 2 +-
|
|
14
|
+
scripts/patch-deps.sh | 17 +++++++++
|
|
15
|
+
8 files changed, 123 insertions(+), 11 deletions(-)
|
|
16
|
+
|
|
17
|
+
diff --git a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift
|
|
18
|
+
index b097b9d..9ea023c 100644
|
|
19
|
+
--- a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift
|
|
20
|
+
+++ b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift
|
|
21
|
+
@@ -9,7 +9,7 @@ extension RPCServer {
|
|
22
|
+
RPCParameterKeys.partIndex,
|
|
23
|
+
[
|
|
24
|
+
"text", "message", "file", "path", "url", "dd_scan", "ddScan", "effect_id",
|
|
25
|
+
- "effectId", "effect", "subject", "text_formatting", "textFormatting",
|
|
26
|
+
+ "effectId", "effect", "subject", "text_formatting", "textFormatting", "fetch_metadata",
|
|
27
|
+
]
|
|
28
|
+
)
|
|
29
|
+
let params = try RPCParameters(params, method: "send.rich", supportedKeys: supportedKeys)
|
|
30
|
+
@@ -17,6 +17,9 @@ extension RPCServer {
|
|
31
|
+
try await handleSendRichLink(params: params, id: id)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
+ if params.contains("fetch_metadata") {
|
|
35
|
+
+ throw RPCError.invalidParams("fetch_metadata requires url")
|
|
36
|
+
+ }
|
|
37
|
+
let text = try params.string("text", aliases: ["message"]) ?? ""
|
|
38
|
+
let file = try params.string("file", aliases: ["path"]) ?? ""
|
|
39
|
+
if params.contains("file") || params.contains("path") {
|
|
40
|
+
@@ -112,6 +115,7 @@ extension RPCServer {
|
|
41
|
+
guard let rawURL = try params.string("url") else {
|
|
42
|
+
throw RPCError.invalidParams("url is required")
|
|
43
|
+
}
|
|
44
|
+
+ let fetchMetadata = try params.boolean("fetch_metadata") ?? true
|
|
45
|
+
|
|
46
|
+
let database = try await databaseResources.require()
|
|
47
|
+
let chatInfo = try await strictRichLinkChatInfo(params, database: database)
|
|
48
|
+
@@ -125,7 +129,11 @@ extension RPCServer {
|
|
49
|
+
|
|
50
|
+
let prepared: PreparedRichLinkPreview
|
|
51
|
+
do {
|
|
52
|
+
- prepared = try await prepareRichLink(rawURL)
|
|
53
|
+
+ if fetchMetadata {
|
|
54
|
+
+ prepared = try await prepareRichLink(rawURL)
|
|
55
|
+
+ } else {
|
|
56
|
+
+ prepared = try await RichLinkPreparer.prepare(rawURL, fetchMetadata: false)
|
|
57
|
+
+ }
|
|
58
|
+
} catch let error as RichLinkPreparationError {
|
|
59
|
+
throw RPCError.invalidParams(error.localizedDescription)
|
|
60
|
+
}
|
|
61
|
+
diff --git a/Sources/imsg/RichLinkPreparer.swift b/Sources/imsg/RichLinkPreparer.swift
|
|
62
|
+
index a861229..06e95db 100644
|
|
63
|
+
--- a/Sources/imsg/RichLinkPreparer.swift
|
|
64
|
+
+++ b/Sources/imsg/RichLinkPreparer.swift
|
|
65
|
+
@@ -110,6 +110,7 @@ enum RichLinkPreparer {
|
|
66
|
+
|
|
67
|
+
static func prepare(
|
|
68
|
+
_ rawURL: String,
|
|
69
|
+
+ fetchMetadata: Bool = true,
|
|
70
|
+
timeout: Duration = defaultTimeout,
|
|
71
|
+
loadMetadata: @escaping MetadataLoader = { url in
|
|
72
|
+
try await RichLinkPreparer.loadMetadata(url)
|
|
73
|
+
@@ -119,6 +120,18 @@ enum RichLinkPreparer {
|
|
74
|
+
}
|
|
75
|
+
) async throws -> PreparedRichLinkPreview {
|
|
76
|
+
let originalURL = try validatedURL(rawURL)
|
|
77
|
+
+ try Task.checkCancellation()
|
|
78
|
+
+ if !fetchMetadata {
|
|
79
|
+
+ // The owner may need a native card without giving a URL authority to
|
|
80
|
+
+ // perform network requests. Do not construct a metadata provider, load
|
|
81
|
+
+ // subresources, or stage an image in this mode.
|
|
82
|
+
+ return PreparedRichLinkPreview(
|
|
83
|
+
+ originalURL: originalURL.absoluteString,
|
|
84
|
+
+ resolvedURL: originalURL.absoluteString,
|
|
85
|
+
+ title: sanitizedTitle(nil, fallback: originalURL.host ?? originalURL.absoluteString),
|
|
86
|
+
+ image: nil
|
|
87
|
+
+ )
|
|
88
|
+
+ }
|
|
89
|
+
let fetched: RichLinkFetchedMetadata?
|
|
90
|
+
do {
|
|
91
|
+
fetched = try await fetchWithDeadline(
|
|
92
|
+
diff --git a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift
|
|
93
|
+
index 8340399..bed9184 100644
|
|
94
|
+
--- a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift
|
|
95
|
+
+++ b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift
|
|
96
|
+
@@ -489,6 +489,43 @@ func rpcSendRichURLRejectsBridgeWithoutPreviewSupportBeforePreparation() async t
|
|
97
|
+
#expect((error?["data"] as? String)?.contains("does not support rich links") == true)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
+@Test
|
|
101
|
+
+func rpcSendRichNoFetchBuildsNativeCardWithoutMetadataPreparation() async throws {
|
|
102
|
+
+ let store = try CommandTestDatabase.makeStoreForRPC()
|
|
103
|
+
+ let output = TestRPCOutput()
|
|
104
|
+
+ var capturedAction: BridgeAction?
|
|
105
|
+
+ var capturedParams: [String: Any] = [:]
|
|
106
|
+
+ var metadataPrepared = false
|
|
107
|
+
+ let server = RPCServer(
|
|
108
|
+
+ store: store,
|
|
109
|
+
+ verbose: false,
|
|
110
|
+
+ output: output,
|
|
111
|
+
+ invokeBridge: { action, params in
|
|
112
|
+
+ if action == .status { return rpcRichLinkCapableStatus() }
|
|
113
|
+
+ capturedAction = action
|
|
114
|
+
+ capturedParams = params
|
|
115
|
+
+ return ["messageGuid": "synthetic-rich-link-guid"]
|
|
116
|
+
+ },
|
|
117
|
+
+ prepareRichLink: { _ in
|
|
118
|
+
+ metadataPrepared = true
|
|
119
|
+
+ return rpcPreparedRichLinkFixture
|
|
120
|
+
+ }
|
|
121
|
+
+ )
|
|
122
|
+
+ await server.handleLineForTesting(
|
|
123
|
+
+ #"{"jsonrpc":"2.0","id":"no-fetch","method":"send.rich","params":{"chat_id":1,"url":"https://example.com/path","fetch_metadata":false}}"#
|
|
124
|
+
+ )
|
|
125
|
+
+ #expect(metadataPrepared == false)
|
|
126
|
+
+ #expect(capturedAction == .sendRichLink)
|
|
127
|
+
+ #expect(capturedParams["message"] as? String == "https://example.com/path")
|
|
128
|
+
+ let preview = capturedParams["richLinkPreview"] as? [String: Any]
|
|
129
|
+
+ #expect(preview?["originalURL"] as? String == "https://example.com/path")
|
|
130
|
+
+ #expect(preview?["resolvedURL"] as? String == "https://example.com/path")
|
|
131
|
+
+ #expect(preview?["title"] as? String == "example.com")
|
|
132
|
+
+ #expect(preview?["image"] == nil)
|
|
133
|
+
+ let result = output.responses.first?["result"] as? [String: Any]
|
|
134
|
+
+ #expect(result?["message_id"] as? String == "synthetic-rich-link-guid")
|
|
135
|
+
+}
|
|
136
|
+
+
|
|
137
|
+
@Test
|
|
138
|
+
func rpcSendRichURLPrefersIMessageChatForSharedIdentifier() async throws {
|
|
139
|
+
let store = try CommandTestDatabase.makeStoreForRPCWithSharedIdentifier()
|
|
140
|
+
diff --git a/Tests/imsgTests/RPCSendRichLinkValidationTests.swift b/Tests/imsgTests/RPCSendRichLinkValidationTests.swift
|
|
141
|
+
index 7e2f329..21fdf77 100644
|
|
142
|
+
--- a/Tests/imsgTests/RPCSendRichLinkValidationTests.swift
|
|
143
|
+
+++ b/Tests/imsgTests/RPCSendRichLinkValidationTests.swift
|
|
144
|
+
@@ -14,6 +14,10 @@ private let rpcValidationRichLinkFixture = PreparedRichLinkPreview(
|
|
145
|
+
func rpcSendRichURLRejectsInvalidTypesAndModifiersBeforePreparation() async throws {
|
|
146
|
+
let store = try CommandTestDatabase.makeStoreForRPC()
|
|
147
|
+
let requests = [
|
|
148
|
+
+ #"{"chat_id":1,"url":"https://imsg.sh","fetch_metadata":null}"#,
|
|
149
|
+
+ #"{"chat_id":1,"url":"https://imsg.sh","fetch_metadata":"false"}"#,
|
|
150
|
+
+ #"{"chat_id":1,"url":"https://imsg.sh","fetch_metadata":0}"#,
|
|
151
|
+
+ #"{"chat_id":1,"text":"synthetic","fetch_metadata":false}"#,
|
|
152
|
+
#"{"chat_id":1,"url":42}"#,
|
|
153
|
+
#"{"chat_id":null,"url":"https://imsg.sh"}"#,
|
|
154
|
+
#"{"chat_id":true,"url":"https://imsg.sh"}"#,
|
|
155
|
+
diff --git a/Tests/imsgTests/RPCServerTests.swift b/Tests/imsgTests/RPCServerTests.swift
|
|
156
|
+
index de1ba0b..68d7859 100644
|
|
157
|
+
--- a/Tests/imsgTests/RPCServerTests.swift
|
|
158
|
+
+++ b/Tests/imsgTests/RPCServerTests.swift
|
|
159
|
+
@@ -53,12 +53,15 @@ func rpcChatsGetReturnsOneExactChatPayload() async throws {
|
|
160
|
+
await server.handleLineForTesting(line)
|
|
161
|
+
|
|
162
|
+
#expect(output.responses.count == 1)
|
|
163
|
+
- let result = output.responses[0]["result"] as? [String: Any]
|
|
164
|
+
- let chat = result?["chat"] as? [String: Any]
|
|
165
|
+
- #expect(int64Value(chat?["id"]) == 1)
|
|
166
|
+
- #expect(chat?["guid"] as? String == "iMessage;+;chat123")
|
|
167
|
+
- #expect(chat?["service"] as? String == "iMessage")
|
|
168
|
+
- #expect(chat?["contact_name"] as? String == "Family")
|
|
169
|
+
+ let response = try #require(output.responses.first)
|
|
170
|
+
+ let result = try #require(response["result"] as? [String: Any])
|
|
171
|
+
+ let chat = try #require(result["chat"] as? [String: Any])
|
|
172
|
+
+ #expect(int64Value(chat["id"]) == 1)
|
|
173
|
+
+ #expect(chat["guid"] as? String == "iMessage;+;chat123")
|
|
174
|
+
+ #expect(chat["service"] as? String == "iMessage")
|
|
175
|
+
+ #expect(chat["display_name"] as? String == "Group Chat")
|
|
176
|
+
+ #expect(chat["is_group"] as? Bool == true)
|
|
177
|
+
+ #expect(chat["contact_name"] == nil)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@Test
|
|
181
|
+
@@ -70,8 +73,10 @@ func rpcChatsGetRejectsAnUnknownChatRow() async throws {
|
|
182
|
+
let line = #"{"jsonrpc":"2.0","id":"missing","method":"chats.get","params":{"chat_id":999}}"#
|
|
183
|
+
await server.handleLineForTesting(line)
|
|
184
|
+
|
|
185
|
+
- #expect(output.responses.count == 1)
|
|
186
|
+
- let error = output.responses[0]["error"] as? [String: Any]
|
|
187
|
+
+ #expect(output.responses.isEmpty)
|
|
188
|
+
+ #expect(output.errors.count == 1)
|
|
189
|
+
+ let response = try #require(output.errors.first)
|
|
190
|
+
+ let error = response["error"] as? [String: Any]
|
|
191
|
+
#expect(int64Value(error?["code"]) == -32602)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
diff --git a/Tests/imsgTests/RichLinkPreparerTests.swift b/Tests/imsgTests/RichLinkPreparerTests.swift
|
|
195
|
+
index e20bed3..6bde945 100644
|
|
196
|
+
--- a/Tests/imsgTests/RichLinkPreparerTests.swift
|
|
197
|
+
+++ b/Tests/imsgTests/RichLinkPreparerTests.swift
|
|
198
|
+
@@ -21,6 +21,34 @@ private final class RichLinkTestBox<Value>: @unchecked Sendable {
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
+@Test
|
|
203
|
+
+func richLinkPreparerNoFetchNeverLoadsOrStagesResources() async throws {
|
|
204
|
+
+ for rawURL in [
|
|
205
|
+
+ "https://example.com/path", "https://127.0.0.1/private", "https://[::1]/", "https://localhost/",
|
|
206
|
+
+ ] {
|
|
207
|
+
+ let loaded = RichLinkTestBox(false)
|
|
208
|
+
+ let staged = RichLinkTestBox(false)
|
|
209
|
+
+ let prepared = try await RichLinkPreparer.prepare(
|
|
210
|
+
+ rawURL,
|
|
211
|
+
+ fetchMetadata: false,
|
|
212
|
+
+ loadMetadata: { _ in
|
|
213
|
+
+ loaded.withValue { $0 = true }
|
|
214
|
+
+ throw CocoaError(.fileReadUnknown)
|
|
215
|
+
+ },
|
|
216
|
+
+ stageImage: { _ in
|
|
217
|
+
+ staged.withValue { $0 = true }
|
|
218
|
+
+ throw CocoaError(.fileWriteUnknown)
|
|
219
|
+
+ }
|
|
220
|
+
+ )
|
|
221
|
+
+ #expect(prepared.originalURL == rawURL)
|
|
222
|
+
+ #expect(prepared.resolvedURL == rawURL)
|
|
223
|
+
+ #expect(!prepared.title.isEmpty)
|
|
224
|
+
+ #expect(prepared.image == nil)
|
|
225
|
+
+ #expect(loaded.withValue { $0 } == false)
|
|
226
|
+
+ #expect(staged.withValue { $0 } == false)
|
|
227
|
+
+ }
|
|
228
|
+
+}
|
|
229
|
+
+
|
|
230
|
+
@Test
|
|
231
|
+
func richLinkPreparerRejectsUnsafeURLs() async {
|
|
232
|
+
let tooLong = "https://example.com/" + String(repeating: "a", count: 8 * 1024)
|
|
233
|
+
diff --git a/docs/rpc.md b/docs/rpc.md
|
|
234
|
+
index c788989..a46bd3e 100644
|
|
235
|
+
--- a/docs/rpc.md
|
|
236
|
+
+++ b/docs/rpc.md
|
|
237
|
+
@@ -584,7 +584,7 @@ database even with an explicit GUID. Strictly verified `send.rich` file/path
|
|
238
|
+
mode and `send.multipart` also require it. Rich-link mode requires a stored
|
|
239
|
+
existing iMessage chat; ordinary `send.rich` text does not.
|
|
240
|
+
|
|
241
|
+
-- `send.rich` sends text with optional `effect`, `subject`, `reply_to`, `part_index`, `dd_scan`, and `text_formatting`. It also accepts `file` or `path` and securely stages the file before sending it through the attachment bridge while preserving those same caption/effect/subject/reply/part/formatting semantics. Attachment capability is checked before staging or publishing the send. Alternatively, pass only one chat target plus an HTTP(S) `url` to send an Apple URL-preview balloon. URL mode is iMessage-only and rejects text, file, and other send modifiers; metadata or image lookup failure falls back to a metadata-only card, never a plain-message send.
|
|
242
|
+
+- `send.rich` sends text with optional `effect`, `subject`, `reply_to`, `part_index`, `dd_scan`, and `text_formatting`. It also accepts `file` or `path` and securely stages the file before sending it through the attachment bridge while preserving those same caption/effect/subject/reply/part/formatting semantics. Attachment capability is checked before staging or publishing the send. Alternatively, pass only one chat target plus an HTTP(S) `url` to send an Apple URL-preview balloon. URL mode is iMessage-only and rejects text, file, and other send modifiers; metadata or image lookup failure falls back to a metadata-only card, never a plain-message send. Optional `fetch_metadata: false` builds that native card directly from the URL and host title, without invoking LinkPresentation metadata/image fetching or staging an image. The default remains `true`. This flag requires URL mode and must be a JSON boolean.
|
|
243
|
+
- `send.attachment` sends `file` or `path`, with optional `audio` / `is_audio` / `as_voice`. Pass `reply_to` (or `replyTo`, `reply_to_guid`, or `message_guid`) to reply to an existing message. An optional non-negative integer `part_index` / `partIndex` selects that message's part and is invalid without a reply target.
|
|
244
|
+
- `send.multipart` sends 1–20 text parts. `parts` is a required array of objects containing a non-empty `text` string and optional `text_formatting` array. Top-level `effect` / `effect_id` and `subject` match `imsg send-multipart`. File, attachment, and mention parts are rejected before bridge dispatch.
|
|
245
|
+
- `tapback` sends or removes a reaction. Params: `message_id` or `message_guid`, plus `reaction` / `kind` / `emoji`, optional `remove`.
|
|
246
|
+
diff --git a/scripts/patch-deps.sh b/scripts/patch-deps.sh
|
|
247
|
+
index 556e58f..670cade 100755
|
|
248
|
+
--- a/scripts/patch-deps.sh
|
|
249
|
+
+++ b/scripts/patch-deps.sh
|
|
250
|
+
@@ -63,6 +63,23 @@ elif "resolvingSymlinksInPath()" not in text:
|
|
251
|
+
print(f"Error: PhoneNumberKit bundle URL list no longer contains the expected insertion point: {path}", file=sys.stderr)
|
|
252
|
+
raise SystemExit(1)
|
|
253
|
+
|
|
254
|
+
+# SwiftPM's generated Bundle.module accessor contains an absolute build path.
|
|
255
|
+
+# Release executables must use the shipped bundle, never a developer checkout.
|
|
256
|
+
+fallback = "#if SWIFT_PACKAGE\n return Bundle.module\n #else"
|
|
257
|
+
+release_fallback = """#if SWIFT_PACKAGE
|
|
258
|
+
+ #if DEBUG
|
|
259
|
+
+ return Bundle.module
|
|
260
|
+
+ #else
|
|
261
|
+
+ fatalError("PhoneNumberKit resource bundle is missing")
|
|
262
|
+
+ #endif
|
|
263
|
+
+ #else"""
|
|
264
|
+
+if fallback in text:
|
|
265
|
+
+ text = text.replace(fallback, release_fallback)
|
|
266
|
+
+ updated = True
|
|
267
|
+
+elif release_fallback not in text:
|
|
268
|
+
+ print(f"Error: PhoneNumberKit bundle fallback no longer matches the reviewed source: {path}", file=sys.stderr)
|
|
269
|
+
+ raise SystemExit(1)
|
|
270
|
+
+
|
|
271
|
+
if updated:
|
|
272
|
+
path.write_text(text)
|
|
273
|
+
PY
|
|
274
|
+
--
|
|
275
|
+
2.50.1 (Apple Git-155)
|
|
276
|
+
|