diff --git a/docs-web/architecture/chat-connectors/imessage.md b/docs-web/architecture/chat-connectors/imessage.md index dfaef4143b..099d9cd2a4 100644 --- a/docs-web/architecture/chat-connectors/imessage.md +++ b/docs-web/architecture/chat-connectors/imessage.md @@ -1,5 +1,41 @@ -# iMessage Connector Profile +# iMessage Connector Architecture -iMessage is registered with `managed_bridge` and `native_bridge` transports. Its module owns the unchanged setup schemas, iMessage bridge normalizer, bearer authentication metadata, managed HTTP mapping, native command mapping, configuration verification, and session requirement metadata. +The iMessage profile is a transparent third-party bridge contract. It advertises only `managed_bridge` and `native_bridge`; it does not expose `official_api`, imply Apple endorsement, or verify an Apple provider endpoint. -The baseline profile has no live test and does not implement `official_api`. Command execution remains in the shared outbound adapter. +Apple documents the [Messages framework](https://developer.apple.com/documentation/messages) for app extensions, [iMessage apps and Messages for Business experiences](https://developer.apple.com/imessage/), and [Message UI](https://developer.apple.com/documentation/messageui) for composing messages inside apps. Those public surfaces do not define a general-purpose personal-iMessage server bot API or public personal-account sandbox. Consequently, Code UX models bridge-owned identifiers without treating third-party payloads as undocumented Apple objects. + +## Profile boundary + +`src/domain/chat-connectors/providers/imessage.ts` owns: + +- the unchanged persisted setup schemas for existing managed and native records; +- shared bearer authentication metadata for both inbound modes; +- opaque message/chat GUID normalization and reply/thread identity mapping; +- the version `1.0` send and health envelope; +- managed HTTP and native command request mapping; and +- the explicit `liveTest.available = false` provider-native verification declaration. + +The protocol envelope always contains `protocolVersion`, `operation`, `correlation`, `message`, `chat`, `sender`, `reply`, `result`, and `error`. Send requests fill message/chat/reply data and leave result/error null. Health requests leave identity fields null. Send parsing requires `operation: send`, `result.status: sent`, every stable field, and the request's correlation id, so a health response or malformed envelope cannot complete outbound delivery. Failures return stable `error.code`, `error.message`, and `error.retryable` values. Existing native records receive transitional top-level send aliases, and recognized legacy message-id send responses remain readable; health negotiation is strict. + +Inbound HTTP routes still pass through `ChatProviderIngressSecurity`. Both modes require a fresh timestamp and use the shared constant-time bearer check plus nonce replay cache. The native setup schema keeps `bridgeToken` optional for outbound-only commands, but a native bridge posting inbound events must configure it because ingress fails closed without a secret. + +Outbound credential lookup preserves stored-record compatibility. Managed delivery checks `bridgeApiKey`, `bridgeToken`, `botToken`, then `webhookSecret`; native delivery checks `bridgeToken`, `botToken`, then `webhookSecret`. Inbound verification remains restricted to the declared credential for its mode. + +## Native execution boundary + +`src/services/chat-providers/imessage-native-bridge.ts` is the process and bridge-health boundary. The default outbound adapter routes iMessage native sends through it. + +The service: + +- parses legacy command records into an executable plus argv without shell evaluation; +- preserves quoted arguments, spaces, macOS paths, and Windows drive-path separators; +- spawns with `shell: false` and writes exactly one JSON request to stdin; +- inherits only a minimal OS environment and exposes the bridge secret solely as `CODEUX_CHAT_BRIDGE_TOKEN`; +- bounds stdout, stderr, managed response bodies, and execution time; +- redacts the configured credential from errors; +- negotiates protocol/correlation/result fields before accepting a health result; and +- tracks active children so cancellation, timeout, output overflow, disposal, or runtime shutdown terminates the process group and escalates to a forced kill. + +Native health diagnostics distinguish `unsupported_platform`, `missing_executable`, `permission_denied`, `protocol_version_mismatch`, `correlation_mismatch`, `timeout`, `cancelled`, `shutdown`, `output_limit_exceeded`, `malformed_response`, `nonzero_exit`, `spawn_failed`, and bridge-declared errors. Managed checks add deterministic network and HTTP diagnostics. + +Managed health accepts only third-party HTTP(S) URLs and rejects Apple-owned hosts as `provider_native_verification_unavailable`. Tests use Node subprocess fixtures and mocked Fetch responses on every platform. No test calls an Apple network endpoint, uses AppleScript, reads the local Messages database, or signs into a real Messages account. diff --git a/docs/settings/chat-connectors/imessage.md b/docs/settings/chat-connectors/imessage.md index c367485e50..7ccf601612 100644 --- a/docs/settings/chat-connectors/imessage.md +++ b/docs/settings/chat-connectors/imessage.md @@ -1,9 +1,73 @@ # iMessage Chat Connector -The baseline iMessage profile supports `managed_bridge` and `native_bridge`. Native delivery keeps the local command contract, writing the outbound JSON payload to stdin and optionally exposing a bridge token to the child process environment. +Code UX supports iMessage only through operator-selected third-party bridge contracts. It does not connect directly to an Apple messaging endpoint, claim Apple endorsement, or read the local Messages database. -Setup remains compatible with stored connections: managed setup uses optional `workspaceId` and `deviceLabel` with `bridgeApiKey`; native setup uses `command`, optional `workingDirectory`, and optional `bridgeToken`. +## Supported modes -The profile requires a reachable bridge session. Live provider testing and direct `official_api` transport are not implemented. +| Mode | Contract | Configuration | +| --- | --- | --- | +| `managed_bridge` | A third-party managed HTTP bridge operated or selected by the user | Existing managed records retain `workspaceId`, `deviceLabel`, and the required `bridgeApiKey`; the bridge URL continues to resolve from the stored bridge URL fields. | +| `native_bridge` | A local third-party command controlled by the operator | Existing native records retain `command`, optional `workingDirectory`, and optional outbound `bridgeToken`. A `bridgeToken` is required if the command also submits inbound requests to Code UX. | -Official reference: [Apple Messages](https://developer.apple.com/documentation/messages). +These are the only supported modes. `official_api` is not an iMessage mode, and provider-native endpoint verification is explicitly unavailable. + +Apple's public material describes [Messages framework app extensions](https://developer.apple.com/documentation/messages), [iMessage apps, stickers, and Messages for Business](https://developer.apple.com/imessage/), and [in-app message composition UI](https://developer.apple.com/documentation/messageui). Those references do not publish a general-purpose server bot API for personal iMessage accounts or a public personal-iMessage sandbox. The bridge contract is therefore a Code UX contract for third-party/local software, not an Apple API contract. + +## Bridge protocol v1 + +Native commands receive one UTF-8 JSON object on stdin and return one UTF-8 JSON object on stdout. Managed health and send operations use the same envelope as the HTTP request and response body. Protocol version `1.0` defines these stable top-level fields: + +```json +{ + "protocolVersion": "1.0", + "operation": "send", + "correlation": { "id": "request-correlation-id" }, + "message": { + "guid": "local-message-id", + "text": "Reply text", + "timestamp": null + }, + "chat": { "guid": "opaque-chat-guid", "name": "Display name" }, + "sender": { "id": null, "name": null }, + "reply": { + "messageGuid": "opaque-message-guid-being-replied-to", + "threadId": "code-ux-thread-id" + }, + "result": null, + "error": null +} +``` + +Health checks use `"operation": "health_check"`; `message`, `chat`, `sender`, and `reply` are `null`. A successful response echoes the protocol, operation, and correlation fields and supplies: + +```json +{ + "result": { + "status": "healthy", + "messageGuid": null, + "chatGuid": null, + "metadata": {} + }, + "error": null +} +``` + +A send result uses `"operation": "send"`, `"status": "sent"`, the request correlation id, and the bridge's opaque `messageGuid` and `chatGuid`. Managed and native send delivery reject health-check responses, mismatched correlations, missing protocol fields, and malformed result/error values. An error response sets `result` to `null` and returns stable `code`, `message`, and `retryable` fields under `error`. GUIDs are treated as opaque Unicode identifiers: Code UX trims them, normalizes Unicode composition, rejects control characters and unreasonable lengths, and never infers undocumented Apple payload structure. + +For existing native command records, v1 send requests also contain the former top-level send aliases during migration. A legacy response containing `externalMessageId` remains accepted for sends. Health checks always require protocol `1.0` and matching correlation identity. + +## Process and secret boundary + +The native command string is parsed into an executable and argument array, then spawned with shell interpretation disabled. Quoted paths, spaces, macOS application paths, and Windows drive paths remain arguments rather than executable shell text. The configured executable and working directory remain under explicit operator control. + +The child receives a minimal operating-system environment. The bridge credential is exposed only as `CODEUX_CHAT_BRIDGE_TOKEN`; it is never added to argv or stdin. Stdout and stderr have byte limits, error diagnostics redact the configured credential, and every execution has a timeout. Cancellation, runtime shutdown, timeout, or output overflow terminates the complete spawned process group, with a forced-kill fallback. + +New records should use `bridgeApiKey` for managed delivery and `bridgeToken` for native delivery. Existing stored outbound records remain compatible with the prior managed fallback order (`bridgeToken`, `botToken`, then `webhookSecret`) and native fallback order (`botToken`, then `webhookSecret`). These outbound fallbacks do not weaken inbound authentication, which still resolves only the mode's declared bearer credential. + +Inbound callbacks in both modes use the shared timestamped bearer verifier and replay-nonce cache. Senders should provide `Authorization: Bearer ` (or `X-Code-UX-Bridge-Token`), `X-Code-UX-Timestamp`, and a unique `X-Code-UX-Nonce` or `X-Request-Id`. + +## Health verification + +Bridge verification is deterministic and reports machine-readable diagnostics for unsupported platforms, missing executables, permission failures, invalid configuration, protocol-version or correlation mismatches, timeouts, cancellation, shutdown, oversized output, malformed JSON, bridge errors, nonzero exits, network errors, and HTTP errors. + +Managed verification accepts only HTTP(S) third-party bridge URLs. Apple-owned hostnames are rejected as `provider_native_verification_unavailable`. Native verification runs only the configured command. The automated test suite uses Node-powered local fixtures and mocked HTTP responses; it does not contact Apple, invoke AppleScript, inspect the Messages database, or use a Messages account. diff --git a/src/domain/chat-connectors/providers/imessage.ts b/src/domain/chat-connectors/providers/imessage.ts index 90ab7c40ec..16fc0e4857 100644 --- a/src/domain/chat-connectors/providers/imessage.ts +++ b/src/domain/chat-connectors/providers/imessage.ts @@ -1,15 +1,43 @@ -import type { ChatConnectorProfile } from "../types.js"; +import type { ChatConnectorOutboundContext, ChatConnectorOutboundResult, ChatConnectorProfile } from "../types.js"; +import { redactMetadata } from "../../../shared/security/redaction.js"; import { buildLegacyCommandOutboundRequest, buildLegacyHttpOutboundRequest, isLegacyRetryableHttpStatus, - parseLegacyOutboundResponse, readRecord, readString, - resolveLegacyIdentity, verifyConnectorConfiguration, } from "../types.js"; +export const IMESSAGE_BRIDGE_PROTOCOL_VERSION = "1.0" as const; + +export interface ImessageBridgeErrorPayload { + code: string; + message: string; + retryable: boolean; +} + +export interface ImessageBridgeEnvelope { + protocolVersion: typeof IMESSAGE_BRIDGE_PROTOCOL_VERSION; + operation: "send" | "health_check"; + correlation: { id: string }; + message: { + guid: string | null; + text: string | null; + timestamp: string | null; + } | null; + chat: { guid: string | null; name: string | null } | null; + sender: { id: string | null; name: string | null } | null; + reply: { messageGuid: string | null; threadId: string | null } | null; + result: { + status: "sent" | "healthy"; + messageGuid: string | null; + chatGuid: string | null; + metadata: Record; + } | null; + error: ImessageBridgeErrorPayload | null; +} + const setupSchema = { kind: "imessage", label: "iMessage", @@ -17,7 +45,7 @@ const setupSchema = { bridgeModes: [ { mode: "managed_bridge", - label: "Managed iMessage bridge", + label: "Third-party managed iMessage bridge contract", integration: "managed_core", setupFields: [ { key: "workspaceId", label: "Connector workspace", type: "string", required: false }, @@ -27,7 +55,7 @@ const setupSchema = { }, { mode: "native_bridge", - label: "macOS native bridge command", + label: "Local third-party iMessage bridge command", integration: "native_bridge", setupFields: [ { key: "command", label: "Bridge command", type: "command", required: true }, @@ -38,6 +66,129 @@ const setupSchema = { ], } as const; +export function normalizeImessageBridgeGuid(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.normalize("NFC").trim(); + return normalized && normalized.length <= 512 && !/[\u0000-\u001f\u007f]/.test(normalized) + ? normalized + : undefined; +} + +export function buildImessageBridgeRequest( + operation: "send" | "health_check", + correlationId: string, + context?: ChatConnectorOutboundContext, +): ImessageBridgeEnvelope { + const payload = context?.payload; + const messageGuid = normalizeImessageBridgeGuid(payload?.conversationMessageId) ?? null; + const chatGuid = normalizeImessageBridgeGuid(payload?.channelId) ?? null; + const replyGuid = normalizeImessageBridgeGuid(payload?.replyToExternalMessageId) ?? null; + const request: ImessageBridgeEnvelope = { + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + operation, + correlation: { id: correlationId }, + message: operation === "send" + ? { guid: messageGuid, text: payload?.replyText ?? "", timestamp: null } + : null, + chat: operation === "send" ? { guid: chatGuid, name: context?.binding.externalChannelName ?? null } : null, + sender: operation === "send" ? { id: null, name: null } : null, + reply: operation === "send" ? { messageGuid: replyGuid, threadId: payload?.threadId ?? null } : null, + result: null, + error: null, + }; + + if (operation === "send" && payload) { + // Existing command records receive compatibility aliases while bridge authors migrate to v1. + return Object.assign(request, { + channelId: payload.channelId, + threadId: payload.threadId, + conversationMessageId: payload.conversationMessageId, + replyText: payload.replyText, + replyToExternalMessageId: payload.replyToExternalMessageId, + }); + } + return request; +} + +export function parseImessageBridgeResponse( + text: string, + expectedCorrelationId?: string, +): ChatConnectorOutboundResult { + const parsed = parseBridgeResponseRecord(text); + const isProtocolEnvelope = "protocolVersion" in parsed || "result" in parsed || "error" in parsed; + if (!isProtocolEnvelope) return parseLegacyImessageSendResponse(parsed); + + if (parsed.protocolVersion !== IMESSAGE_BRIDGE_PROTOCOL_VERSION) { + throw new Error(`Unsupported iMessage bridge protocol version: ${readString(parsed.protocolVersion) ?? "missing"}.`); + } + if (parsed.operation !== "send") { + throw new Error("Malformed iMessage bridge send response: operation must be send."); + } + const requiredFields = ["correlation", "message", "chat", "sender", "reply", "result", "error"]; + if (requiredFields.some((field) => !(field in parsed))) { + throw new Error("Malformed iMessage bridge send response: required protocol fields are missing."); + } + const correlation = readRecord(parsed.correlation); + if (typeof correlation?.id !== "string" || !correlation.id.trim()) { + throw new Error("Malformed iMessage bridge send response: correlation.id is required."); + } + if (expectedCorrelationId && correlation.id !== expectedCorrelationId) { + throw new Error("Malformed iMessage bridge send response: correlation.id does not match the request."); + } + const error = readRecord(parsed.error); + if (error) { + throw new Error(`iMessage bridge error (${readString(error.code) ?? "unknown"}): ${readString(error.message) ?? "Unknown bridge error."}`); + } + if (parsed.error !== null) { + throw new Error("Malformed iMessage bridge send response: error must be null or an error object."); + } + const result = readRecord(parsed.result); + if (!result || result.status !== "sent") { + throw new Error("Malformed iMessage bridge send response: result.status must be sent."); + } + if (!["messageGuid", "chatGuid", "metadata"].every((field) => field in result) || !readRecord(result.metadata)) { + throw new Error("Malformed iMessage bridge send response: result fields are missing or invalid."); + } + return { + externalMessageId: normalizeImessageBridgeGuid(result.messageGuid) ?? null, + responseMetadata: { + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + status: result.status, + chatGuid: normalizeImessageBridgeGuid(result.chatGuid) ?? null, + metadata: redactMetadata(readRecord(result.metadata) ?? {}) as Record, + }, + }; +} + +function parseBridgeResponseRecord(text: string): Record { + try { + const parsed = JSON.parse(text.trim()) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Malformed iMessage bridge response: expected a JSON object."); + } + return parsed as Record; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error("Malformed iMessage bridge response: invalid JSON."); + } + throw error; + } +} + +function parseLegacyImessageSendResponse(parsed: Record): ChatConnectorOutboundResult { + const messageGuid = readString(parsed.externalMessageId, parsed.messageId, parsed.id); + const normalizedMessageGuid = normalizeImessageBridgeGuid(messageGuid); + if (!normalizedMessageGuid) { + throw new Error("Malformed legacy iMessage bridge send response: a message id is required."); + } + return { + externalMessageId: normalizedMessageGuid, + responseMetadata: redactMetadata(parsed) as Record, + }; +} + export const imessageChatConnectorProfile: ChatConnectorProfile = { kind: "imessage", setupSchema, @@ -60,35 +211,57 @@ export const imessageChatConnectorProfile: ChatConnectorProfile = { handshake: { type: "none" }, acknowledgement: { statusCode: 200, headers: { "content-type": "application/json" }, body: null }, normalize: (body) => { + const message = readRecord(body.message); + const chat = readRecord(body.chat); const sender = readRecord(body.sender) ?? readRecord(body.from); return { - externalChannelId: readString(body.chatGuid, body.chatId, body.channelId, body.groupId), - externalChannelName: readString(body.chatName, body.channelName, body.groupName), - externalSenderId: readString(body.senderId, body.handle, sender?.id, sender?.handle), - externalSenderName: readString(body.senderName, sender?.name, sender?.handle), - textBody: readString(body.text, body.body, body.content), - externalMessageId: readString(body.guid, body.messageGuid, body.messageId, body.id), - timestamp: body.timestamp ?? body.date, + externalChannelId: normalizeImessageBridgeGuid(chat?.guid) + ?? normalizeImessageBridgeGuid(body.chatGuid) + ?? readString(body.chatId, body.channelId, body.groupId), + externalChannelName: readString(chat?.name, body.chatName, body.channelName, body.groupName), + externalSenderId: readString(sender?.id, sender?.handle, body.senderId, body.handle), + externalSenderName: readString(sender?.name, sender?.handle, body.senderName), + textBody: readString(message?.text, body.text, body.body, body.content), + externalMessageId: normalizeImessageBridgeGuid(message?.guid) + ?? normalizeImessageBridgeGuid(body.guid) + ?? normalizeImessageBridgeGuid(body.messageGuid) + ?? readString(body.messageId, body.id), + timestamp: message?.timestamp ?? body.timestamp ?? body.date, + }; + }, + }, + identity: { + resolve: (normalized, payload) => { + const reply = readRecord(payload.reply); + return { + conversationId: normalizeImessageBridgeGuid(normalized.externalChannelId) ?? null, + threadId: readString(reply?.threadId, payload.threadId, payload.conversationThreadId) ?? null, }; }, }, - identity: { resolve: resolveLegacyIdentity }, outbound: { buildRequest: (context) => { + const body = buildImessageBridgeRequest("send", context.correlationId, context); if (context.connection.bridgeMode === "managed_bridge") { - return buildLegacyHttpOutboundRequest(context, { - mode: "managed_bridge", - urlKeys: ["bridgeUrl", "outboundUrl", "endpointUrl", "url"], - bearerSecretKeys: ["bridgeApiKey", "bridgeToken", "botToken", "webhookSecret"], - label: "managed_bridge bridge URL", - }); + return { + ...buildLegacyHttpOutboundRequest(context, { + mode: "managed_bridge", + urlKeys: ["bridgeUrl", "outboundUrl", "endpointUrl", "url"], + bearerSecretKeys: ["bridgeApiKey", "bridgeToken", "botToken", "webhookSecret"], + label: "third-party managed iMessage bridge URL", + }), + body, + }; } if (context.connection.bridgeMode === "native_bridge") { - return buildLegacyCommandOutboundRequest(context, ["bridgeToken", "botToken", "webhookSecret"]); + return { + ...buildLegacyCommandOutboundRequest(context, ["bridgeToken", "botToken", "webhookSecret"]), + body, + }; } throw new Error(`Unsupported bridge mode for imessage: ${context.connection.bridgeMode}`); }, - parseResponse: parseLegacyOutboundResponse, + parseResponse: (responseBody) => parseImessageBridgeResponse(responseBody), isRetryableStatus: isLegacyRetryableHttpStatus, }, verification: { @@ -96,8 +269,20 @@ export const imessageChatConnectorProfile: ChatConnectorProfile = { capabilities: ["setup", "authentication", "outbound"], verifyConfiguration: (mode, setup, secrets) => verifyConnectorConfiguration(setupSchema, mode, setup, secrets), }, - session: { required: true, scope: "connection", requirements: ["A reachable bridge session is required for delivery."] }, - officialDocumentation: [{ label: "Apple Messages", url: "https://developer.apple.com/documentation/messages" }], - liveTest: { available: false, modes: [], reason: "Baseline bridge profiles do not invoke provider endpoints." }, - lifecycle: { status: "baseline", profileVersion: 1, introducedIn: "typed-registry" }, + session: { + required: true, + scope: "connection", + requirements: ["An operator-configured third-party bridge session is required for delivery."], + }, + officialDocumentation: [ + { label: "Apple Messages framework", url: "https://developer.apple.com/documentation/messages" }, + { label: "iMessage apps and stickers", url: "https://developer.apple.com/imessage/" }, + { label: "Apple Message UI", url: "https://developer.apple.com/documentation/messageui" }, + ], + liveTest: { + available: false, + modes: [], + reason: "Apple provider-native endpoint verification is unavailable; Code UX verifies only configured third-party bridge contracts.", + }, + lifecycle: { status: "preview", profileVersion: 2, introducedIn: "imessage-bridge-protocol-v1" }, }; diff --git a/src/services/chat-provider-adapters.ts b/src/services/chat-provider-adapters.ts index 5775354547..de3f219682 100644 --- a/src/services/chat-provider-adapters.ts +++ b/src/services/chat-provider-adapters.ts @@ -10,8 +10,16 @@ import type { ChatConnectorHttpOutboundRequest, ChatConnectorProfile, } from "../domain/chat-connectors/types.js"; +import { + parseImessageBridgeResponse, + type ImessageBridgeEnvelope, +} from "../domain/chat-connectors/providers/imessage.js"; import { ChatConnectorOutboundResponseError } from "../domain/chat-connectors/types.js"; import { redactText } from "../shared/security/redaction.js"; +import { + ImessageNativeBridge, + ImessageNativeBridgeError, +} from "./chat-providers/imessage-native-bridge.js"; export interface ChatProviderOutboundBridgePayload { providerKind: string; @@ -68,6 +76,7 @@ export function createDefaultChatProviderOutboundAdapter(): ChatProviderOutbound } export class ConfiguredChatProviderOutboundAdapter implements ChatProviderOutboundAdapter { + private readonly imessageNativeBridge = new ImessageNativeBridge(); private readonly rateLimitReadyAt = new Map(); async send(context: ChatProviderOutboundAdapterContext): Promise { @@ -76,8 +85,8 @@ export class ConfiguredChatProviderOutboundAdapter implements ChatProviderOutbou profile = getChatConnectorProfileForMode(context.connection.providerKind, context.connection.bridgeMode); const request = profile.outbound.buildRequest(context); return request.transport === "http" - ? this.sendHttp(context, profile, request) - : this.sendNative(context, profile, request); + ? await this.sendHttp(context, profile, request) + : await this.sendNative(context, profile, request); } catch (error) { if (error instanceof ChatProviderOutboundAdapterError) { throw error; @@ -145,18 +154,22 @@ export class ConfiguredChatProviderOutboundAdapter implements ChatProviderOutbou } let parsed: ChatProviderOutboundAdapterResult; - try { - parsed = profile.outbound.parseResponse(responseText, responseContext); - } catch (error) { - if (error instanceof ChatConnectorOutboundResponseError) { - throw new ChatProviderOutboundAdapterError( - error.message, - error.retryable, - error.statusCode ?? response.status, - error.retryAfterMs ?? retryAfterMs, - ); + if (context.connection.providerKind === "imessage") { + parsed = parseImessageBridgeResponse(responseText, context.correlationId); + } else { + try { + parsed = profile.outbound.parseResponse(responseText, responseContext); + } catch (error) { + if (error instanceof ChatConnectorOutboundResponseError) { + throw new ChatProviderOutboundAdapterError( + error.message, + error.retryable, + error.statusCode ?? response.status, + error.retryAfterMs ?? retryAfterMs, + ); + } + throw error; } - throw error; } if (parsed.failure) { throw new ChatProviderOutboundAdapterError( @@ -207,8 +220,25 @@ export class ConfiguredChatProviderOutboundAdapter implements ChatProviderOutbou throw new ChatProviderOutboundAdapterError("Native bridge command is not configured.", false); } - const env: NodeJS.ProcessEnv = { ...process.env }; const bridgeToken = getFirstSecret(context.connection.secrets, request.tokenSecretKeys); + if (context.connection.providerKind === "imessage") { + try { + return await this.imessageNativeBridge.send({ + command: request.command, + workingDirectory: request.workingDirectory, + bridgeToken, + correlationId: context.correlationId, + request: request.body as ImessageBridgeEnvelope, + timeoutMs: request.timeoutMs, + }); + } catch (error) { + if (error instanceof ImessageNativeBridgeError) { + throw new ChatProviderOutboundAdapterError(error.message, error.retryable); + } + throw error; + } + } + const env: NodeJS.ProcessEnv = { ...process.env }; if (bridgeToken) { env.CODEUX_CHAT_BRIDGE_TOKEN = bridgeToken; } diff --git a/src/services/chat-providers/imessage-native-bridge.ts b/src/services/chat-providers/imessage-native-bridge.ts new file mode 100644 index 0000000000..95f6afd60f --- /dev/null +++ b/src/services/chat-providers/imessage-native-bridge.ts @@ -0,0 +1,653 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; +import { access } from "node:fs/promises"; +import { isAbsolute } from "node:path"; +import type { ChatConnectorOutboundResult } from "../../domain/chat-connectors/types.js"; +import { + buildImessageBridgeRequest, + IMESSAGE_BRIDGE_PROTOCOL_VERSION, + parseImessageBridgeResponse, + type ImessageBridgeEnvelope, +} from "../../domain/chat-connectors/providers/imessage.js"; +import { redactText } from "../../shared/security/redaction.js"; +import { isRuntimeShutdownInProgress } from "../shutdown-state.js"; + +const DEFAULT_TIMEOUT_MS = 15_000; +const DEFAULT_MAX_STDOUT_BYTES = 256 * 1024; +const DEFAULT_MAX_STDERR_BYTES = 64 * 1024; +const FORCE_KILL_DELAY_MS = 250; +const SHUTDOWN_POLL_MS = 50; + +export type ImessageBridgeDiagnosticCode = + | "healthy" + | "unsupported_platform" + | "missing_executable" + | "permission_denied" + | "invalid_configuration" + | "protocol_version_mismatch" + | "correlation_mismatch" + | "timeout" + | "cancelled" + | "shutdown" + | "output_limit_exceeded" + | "malformed_response" + | "nonzero_exit" + | "spawn_failed" + | "bridge_error" + | "network_error" + | "http_error" + | "provider_native_verification_unavailable"; + +export interface ImessageBridgeHealthResult { + ok: boolean; + code: ImessageBridgeDiagnosticCode; + message: string; + protocolVersion: string | null; + durationMs: number; +} + +export interface ImessageNativeCommandInput { + command: string; + workingDirectory?: string; + bridgeToken?: string; + correlationId: string; + timeoutMs?: number; + maxStdoutBytes?: number; + maxStderrBytes?: number; + signal?: AbortSignal; +} + +export interface ImessageNativeSendInput extends ImessageNativeCommandInput { + request: ImessageBridgeEnvelope; +} + +export interface ImessageNativeHealthInput extends ImessageNativeCommandInput { + supportedPlatforms?: readonly NodeJS.Platform[]; +} + +export interface ImessageManagedHealthInput { + url: string; + bridgeApiKey: string; + correlationId: string; + timeoutMs?: number; + maxResponseBytes?: number; + signal?: AbortSignal; +} + +interface ImessageNativeBridgeDependencies { + platform?: NodeJS.Platform; + fetch?: typeof fetch; + now?: () => number; + isShuttingDown?: () => boolean; +} + +interface ActiveProcess { + closed: Promise; + terminate(error: ImessageNativeBridgeError): void; +} + +interface NativeExecutionResult { + stdout: string; + stderr: string; + code: number | null; +} + +export class ImessageNativeBridgeError extends Error { + constructor( + readonly code: Exclude, + message: string, + readonly retryable: boolean, + ) { + super(redactText(message)); + this.name = "ImessageNativeBridgeError"; + } +} + +export class ImessageNativeBridge { + private readonly platform: NodeJS.Platform; + private readonly fetchFn: typeof fetch; + private readonly now: () => number; + private readonly isShuttingDown: () => boolean; + private readonly activeProcesses = new Map(); + private disposed = false; + + constructor(deps: ImessageNativeBridgeDependencies = {}) { + this.platform = deps.platform ?? process.platform; + this.fetchFn = deps.fetch ?? fetch; + this.now = deps.now ?? Date.now; + this.isShuttingDown = deps.isShuttingDown ?? isRuntimeShutdownInProgress; + } + + async send(input: ImessageNativeSendInput): Promise { + const execution = await this.executeNative(input); + if (execution.code !== 0) { + throw this.nonzeroExitError(execution, input.bridgeToken); + } + this.validateResponseEnvelope(execution.stdout, input.correlationId, "sent", true, input.bridgeToken); + try { + return redactResultSecret(parseImessageBridgeResponse(execution.stdout), input.bridgeToken); + } catch (error) { + throw this.responseError(error, input.bridgeToken); + } + } + + async verifyNative(input: ImessageNativeHealthInput): Promise { + const startedAt = this.now(); + try { + if (input.supportedPlatforms && !input.supportedPlatforms.includes(this.platform)) { + throw new ImessageNativeBridgeError( + "unsupported_platform", + `The configured local bridge does not support ${this.platform}.`, + false, + ); + } + const request = buildImessageBridgeRequest("health_check", input.correlationId); + const execution = await this.executeNative({ ...input, request }); + if (execution.code !== 0) { + throw this.nonzeroExitError(execution, input.bridgeToken); + } + this.validateResponseEnvelope(execution.stdout, input.correlationId, "healthy", false, input.bridgeToken); + return this.healthResult(true, "healthy", "The local bridge protocol health check succeeded.", startedAt); + } catch (error) { + return this.failedHealthResult(error, input.bridgeToken, startedAt); + } + } + + async verifyManaged(input: ImessageManagedHealthInput): Promise { + const startedAt = this.now(); + let url: URL; + try { + url = new URL(input.url); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new ImessageNativeBridgeError("invalid_configuration", "Managed bridge URL must use HTTP or HTTPS.", false); + } + if (isAppleHostname(url.hostname)) { + throw new ImessageNativeBridgeError( + "provider_native_verification_unavailable", + "Apple provider-native endpoint verification is unavailable; configure a third-party bridge URL.", + false, + ); + } + if (!input.bridgeApiKey.trim()) { + throw new ImessageNativeBridgeError("invalid_configuration", "Managed bridge API key is not configured.", false); + } + this.assertRunnable(input.signal); + + const timeoutMs = requirePositiveLimit(input.timeoutMs, DEFAULT_TIMEOUT_MS, "timeoutMs"); + const controller = new AbortController(); + let abortCode: "timeout" | "cancelled" | "shutdown" | null = null; + const abort = (code: typeof abortCode): void => { + if (!controller.signal.aborted) { + abortCode = code; + controller.abort(code); + } + }; + const timeout = setTimeout(() => abort("timeout"), timeoutMs); + const shutdownPoll = setInterval(() => { + if (this.disposed || this.isShuttingDown()) abort("shutdown"); + }, SHUTDOWN_POLL_MS); + const onAbort = (): void => abort("cancelled"); + input.signal?.addEventListener("abort", onAbort, { once: true }); + + try { + const request = buildImessageBridgeRequest("health_check", input.correlationId); + const response = await this.fetchFn(url, { + method: "POST", + headers: { + authorization: `Bearer ${input.bridgeApiKey}`, + "content-type": "application/json", + "x-correlation-id": input.correlationId, + }, + body: JSON.stringify(request), + signal: controller.signal, + }); + const text = await readBoundedResponse(response, input.maxResponseBytes ?? DEFAULT_MAX_STDOUT_BYTES); + if (!response.ok) { + throw new ImessageNativeBridgeError( + "http_error", + `Managed bridge health check returned HTTP ${response.status}${text ? `: ${sanitize(text, input.bridgeApiKey).slice(0, 500)}` : ""}.`, + response.status === 408 || response.status === 429 || response.status >= 500, + ); + } + this.validateResponseEnvelope(text, input.correlationId, "healthy", false, input.bridgeApiKey); + return this.healthResult(true, "healthy", "The managed bridge protocol health check succeeded.", startedAt); + } catch (error) { + if (abortCode) { + throw abortError(abortCode, timeoutMs); + } + if (error instanceof ImessageNativeBridgeError) throw error; + throw new ImessageNativeBridgeError( + "network_error", + `Managed bridge health check failed: ${sanitize(errorMessage(error), input.bridgeApiKey)}.`, + true, + ); + } finally { + clearTimeout(timeout); + clearInterval(shutdownPoll); + input.signal?.removeEventListener("abort", onAbort); + } + } catch (error) { + return this.failedHealthResult(error, input.bridgeApiKey, startedAt); + } + } + + async dispose(): Promise { + this.disposed = true; + const shutdownError = new ImessageNativeBridgeError("shutdown", "iMessage bridge execution stopped during runtime shutdown.", true); + const active = [...this.activeProcesses.values()]; + for (const entry of active) entry.terminate(shutdownError); + await Promise.allSettled(active.map((entry) => entry.closed)); + } + + private async executeNative(input: ImessageNativeSendInput): Promise { + this.assertRunnable(input.signal); + const [executable, ...args] = parseImessageNativeBridgeCommand(input.command, this.platform); + await assertExecutable(executable, this.platform); + const timeoutMs = requirePositiveLimit(input.timeoutMs, DEFAULT_TIMEOUT_MS, "timeoutMs"); + const maxStdoutBytes = requirePositiveLimit(input.maxStdoutBytes, DEFAULT_MAX_STDOUT_BYTES, "maxStdoutBytes"); + const maxStderrBytes = requirePositiveLimit(input.maxStderrBytes, DEFAULT_MAX_STDERR_BYTES, "maxStderrBytes"); + const secret = input.bridgeToken?.trim() ?? ""; + + return new Promise((resolve, reject) => { + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(executable, args, { + cwd: input.workingDirectory || process.cwd(), + env: bridgeEnvironment(secret), + shell: false, + detached: this.platform !== "win32", + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + reject(classifyImessageNativeBridgeSpawnError(error)); + return; + } + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let terminalError: ImessageNativeBridgeError | null = null; + let settled = false; + let forceKillTimer: NodeJS.Timeout | null = null; + + let closeActive!: () => void; + const closed = new Promise((close) => { closeActive = close; }); + const terminate = (error: ImessageNativeBridgeError): void => { + if (terminalError || settled) return; + terminalError = error; + terminateProcessTree(child, this.platform, false); + forceKillTimer = setTimeout(() => terminateProcessTree(child, this.platform, true), FORCE_KILL_DELAY_MS); + }; + this.activeProcesses.set(child, { closed, terminate }); + + const timeout = setTimeout( + () => terminate(new ImessageNativeBridgeError("timeout", `Native bridge command timed out after ${timeoutMs}ms.`, true)), + timeoutMs, + ); + const shutdownPoll = setInterval(() => { + if (this.disposed || this.isShuttingDown()) { + terminate(new ImessageNativeBridgeError("shutdown", "Native bridge command stopped during runtime shutdown.", true)); + } + }, SHUTDOWN_POLL_MS); + const onAbort = (): void => terminate(new ImessageNativeBridgeError("cancelled", "Native bridge command was cancelled.", true)); + input.signal?.addEventListener("abort", onAbort, { once: true }); + if (input.signal?.aborted) onAbort(); + + const cleanup = (): void => { + clearTimeout(timeout); + clearInterval(shutdownPoll); + if (forceKillTimer) clearTimeout(forceKillTimer); + input.signal?.removeEventListener("abort", onAbort); + this.activeProcesses.delete(child); + closeActive(); + }; + const finishError = (error: ImessageNativeBridgeError): void => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + + child.stdout.on("data", (chunk: Buffer) => { + stdoutBytes += chunk.length; + if (stdoutBytes > maxStdoutBytes) { + terminate(new ImessageNativeBridgeError("output_limit_exceeded", "Native bridge stdout exceeded its configured limit.", false)); + return; + } + stdout.push(chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderrBytes += chunk.length; + if (stderrBytes > maxStderrBytes) { + terminate(new ImessageNativeBridgeError("output_limit_exceeded", "Native bridge stderr exceeded its configured limit.", false)); + return; + } + stderr.push(chunk); + }); + child.once("error", (error) => { + terminalError = terminalError ?? classifyImessageNativeBridgeSpawnError(error); + }); + child.once("close", (code) => { + if (terminalError) { + finishError(terminalError); + return; + } + if (settled) return; + settled = true; + cleanup(); + resolve({ + code, + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + }); + }); + child.stdin.once("error", () => { + // A bridge may exit without consuming stdin; close/error handling reports the terminal result. + }); + child.stdin.end(JSON.stringify(input.request)); + }); + } + + private validateResponseEnvelope( + text: string, + correlationId: string, + expectedStatus: "sent" | "healthy", + allowLegacySend: boolean, + secret?: string, + ): void { + let value: unknown; + try { + value = JSON.parse(text.trim()); + } catch { + throw new ImessageNativeBridgeError("malformed_response", "Bridge response was not valid JSON.", false); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ImessageNativeBridgeError("malformed_response", "Bridge response must be a JSON object.", false); + } + const record = value as Record; + if (allowLegacySend && !("protocolVersion" in record) && typeof record.externalMessageId === "string") { + return; + } + const stableFields = ["correlation", "message", "chat", "sender", "reply", "result", "error"]; + if (stableFields.some((field) => !(field in record))) { + throw new ImessageNativeBridgeError("malformed_response", "Bridge response omitted required protocol fields.", false); + } + if (record.protocolVersion !== IMESSAGE_BRIDGE_PROTOCOL_VERSION) { + throw new ImessageNativeBridgeError( + "protocol_version_mismatch", + `Bridge protocol version ${String(record.protocolVersion ?? "missing")} is unsupported; expected ${IMESSAGE_BRIDGE_PROTOCOL_VERSION}.`, + false, + ); + } + const expectedOperation = expectedStatus === "healthy" ? "health_check" : "send"; + if (record.operation !== expectedOperation) { + throw new ImessageNativeBridgeError("malformed_response", `Bridge response operation must be ${expectedOperation}.`, false); + } + const correlation = asRecord(record.correlation); + if (correlation?.id !== correlationId) { + throw new ImessageNativeBridgeError("correlation_mismatch", "Bridge response correlation id did not match the request.", false); + } + const bridgeError = asRecord(record.error); + if (bridgeError) { + throw new ImessageNativeBridgeError( + "bridge_error", + `Bridge error (${String(bridgeError.code ?? "unknown")}): ${sanitize(String(bridgeError.message ?? "Unknown bridge error."), secret)}.`, + bridgeError.retryable === true, + ); + } + const result = asRecord(record.result); + if (result?.status !== expectedStatus) { + throw new ImessageNativeBridgeError("malformed_response", `Bridge response did not contain result.status=${expectedStatus}.`, false); + } + } + + private responseError(error: unknown, secret?: string): ImessageNativeBridgeError { + if (error instanceof ImessageNativeBridgeError) return error; + const message = errorMessage(error); + const code = /protocol version/i.test(message) ? "protocol_version_mismatch" : "malformed_response"; + return new ImessageNativeBridgeError(code, sanitize(message, secret), false); + } + + private nonzeroExitError(result: NativeExecutionResult, secret?: string): ImessageNativeBridgeError { + const detail = sanitize(result.stderr.trim() || result.stdout.trim() || `exit code ${result.code ?? "unknown"}`, secret).slice(0, 500); + return new ImessageNativeBridgeError( + "nonzero_exit", + `Native bridge command exited with code ${result.code ?? "unknown"}: ${detail}.`, + result.code !== 126 && result.code !== 127, + ); + } + + private assertRunnable(signal?: AbortSignal): void { + if (this.disposed || this.isShuttingDown()) { + throw new ImessageNativeBridgeError("shutdown", "iMessage bridge execution is unavailable during runtime shutdown.", true); + } + if (signal?.aborted) { + throw new ImessageNativeBridgeError("cancelled", "iMessage bridge operation was cancelled.", true); + } + } + + private healthResult( + ok: boolean, + code: ImessageBridgeDiagnosticCode, + message: string, + startedAt: number, + ): ImessageBridgeHealthResult { + return { + ok, + code, + message, + protocolVersion: ok ? IMESSAGE_BRIDGE_PROTOCOL_VERSION : null, + durationMs: Math.max(0, this.now() - startedAt), + }; + } + + private failedHealthResult(error: unknown, secret: string | undefined, startedAt: number): ImessageBridgeHealthResult { + const normalized = error instanceof ImessageNativeBridgeError + ? error + : new ImessageNativeBridgeError("spawn_failed", sanitize(errorMessage(error), secret), true); + return this.healthResult(false, normalized.code, sanitize(normalized.message, secret), startedAt); + } +} + +export function parseImessageNativeBridgeCommand(command: string, platform: NodeJS.Platform = process.platform): string[] { + if (!command.trim()) { + throw new ImessageNativeBridgeError("invalid_configuration", "Native bridge command is not configured.", false); + } + if (command.includes("\0")) { + throw new ImessageNativeBridgeError("invalid_configuration", "Native bridge command cannot contain null bytes.", false); + } + + const argv: string[] = []; + let current = ""; + let quote: "'" | "\"" | null = null; + const pushCurrent = (): void => { + if (!current) return; + argv.push(normalizeWindowsEscapedToken(current, platform)); + current = ""; + }; + for (let index = 0; index < command.length; index += 1) { + const char = command[index]; + if (quote) { + if (char === quote) { + quote = null; + } else if (char === "\\" && quote === "\"" && shouldEscape(command[index + 1], platform)) { + current += command[index + 1] ?? ""; + index += 1; + } else { + current += char; + } + continue; + } + if (char === "'" || char === "\"") { + quote = char; + } else if (char === "\\" && shouldEscape(command[index + 1], platform)) { + current += command[index + 1] ?? ""; + index += 1; + } else if (/\s/.test(char)) { + pushCurrent(); + } else { + current += char; + } + } + if (quote) { + throw new ImessageNativeBridgeError("invalid_configuration", "Native bridge command has an unterminated quote.", false); + } + pushCurrent(); + if (!argv.length) { + throw new ImessageNativeBridgeError("invalid_configuration", "Native bridge command is not configured.", false); + } + return argv; +} + +function normalizeWindowsEscapedToken(value: string, platform: NodeJS.Platform): string { + return platform === "win32" && (/^[A-Za-z]:\\\\/.test(value) || /^\\\\\\\\/.test(value)) + ? value.replace(/\\\\/g, "\\") + : value; +} + +function shouldEscape(next: string | undefined, platform: NodeJS.Platform): boolean { + if (!next) return false; + if (platform === "win32") return next === "\""; + return /[\s'"\\]/.test(next); +} + +async function assertExecutable(executable: string, platform: NodeJS.Platform): Promise { + if (!isAbsolute(executable) && !executable.includes("/") && !executable.includes("\\")) return; + try { + await access(executable, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); + } catch (error) { + const code = asErrorCode(error); + if (code === "EACCES" || code === "EPERM") { + throw new ImessageNativeBridgeError("permission_denied", `Native bridge executable is not permitted: ${executable}.`, false); + } + throw new ImessageNativeBridgeError("missing_executable", `Native bridge executable was not found: ${executable}.`, false); + } +} + +export function classifyImessageNativeBridgeSpawnError(error: unknown): ImessageNativeBridgeError { + const code = asErrorCode(error); + if (code === "ENOENT") return new ImessageNativeBridgeError("missing_executable", "Native bridge executable was not found.", false); + if (code === "EACCES" || code === "EPERM") return new ImessageNativeBridgeError("permission_denied", "Native bridge executable is not permitted.", false); + return new ImessageNativeBridgeError("spawn_failed", `Failed to start native bridge command: ${errorMessage(error)}.`, true); +} + +function terminateProcessTree(child: ChildProcessWithoutNullStreams, platform: NodeJS.Platform, force: boolean): void { + if (!child.pid) return; + if (platform === "win32") { + const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", ...(force ? ["/F"] : [])], { + shell: false, + stdio: "ignore", + windowsHide: true, + }); + killer.once("error", () => child.kill(force ? "SIGKILL" : "SIGTERM")); + killer.unref(); + return; + } + try { + process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM"); + } catch { + child.kill(force ? "SIGKILL" : "SIGTERM"); + } +} + +function bridgeEnvironment(token: string): NodeJS.ProcessEnv { + const allowed = [ + "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "TMP", "TEMP", "SystemRoot", "WINDIR", "PATHEXT", + "LOCALAPPDATA", "APPDATA", "LANG", "LC_ALL", "TZ", + ]; + const env: NodeJS.ProcessEnv = {}; + for (const key of allowed) { + if (process.env[key] !== undefined) env[key] = process.env[key]; + } + if (token) env.CODEUX_CHAT_BRIDGE_TOKEN = token; + return env; +} + +async function readBoundedResponse(response: Response, maxBytes: number): Promise { + const limit = requirePositiveLimit(maxBytes, DEFAULT_MAX_STDOUT_BYTES, "maxResponseBytes"); + if (!response.body) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel(); + throw new ImessageNativeBridgeError("output_limit_exceeded", "Managed bridge response exceeded its configured limit.", false); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return new TextDecoder().decode(concatBytes(chunks, size)); +} + +function concatBytes(chunks: Uint8Array[], size: number): Uint8Array { + const output = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function abortError(code: "timeout" | "cancelled" | "shutdown", timeoutMs: number): ImessageNativeBridgeError { + if (code === "timeout") return new ImessageNativeBridgeError("timeout", `Bridge health check timed out after ${timeoutMs}ms.`, true); + if (code === "shutdown") return new ImessageNativeBridgeError("shutdown", "Bridge health check stopped during runtime shutdown.", true); + return new ImessageNativeBridgeError("cancelled", "Bridge health check was cancelled.", true); +} + +function requirePositiveLimit(value: number | undefined, fallback: number, name: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved <= 0) { + throw new ImessageNativeBridgeError("invalid_configuration", `${name} must be a positive integer.`, false); + } + return resolved; +} + +function isAppleHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/\.$/, ""); + return normalized === "apple.com" || normalized.endsWith(".apple.com"); +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function asErrorCode(error: unknown): string | null { + return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : null; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function sanitize(value: string, secret?: string): string { + let sanitized = redactText(value); + if (secret) sanitized = sanitized.split(secret).join("[REDACTED]"); + return sanitized; +} + +function redactResultSecret(result: ChatConnectorOutboundResult, secret?: string): ChatConnectorOutboundResult { + if (!secret || !result.responseMetadata) return result; + return { + ...result, + responseMetadata: replaceSecret(result.responseMetadata, secret) as Record, + }; +} + +function replaceSecret(value: unknown, secret: string): unknown { + if (typeof value === "string") return value.split(secret).join("[REDACTED]"); + if (Array.isArray(value)) return value.map((entry) => replaceSecret(entry, secret)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, replaceSecret(entry, secret)])); + } + return value; +} diff --git a/tests/backend/domain/chat-connectors/imessage.test.ts b/tests/backend/domain/chat-connectors/imessage.test.ts new file mode 100644 index 0000000000..734b5edc3d --- /dev/null +++ b/tests/backend/domain/chat-connectors/imessage.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; +import type { ChatProviderConnectionInternalRecord } from "../../../../src/contracts/chat-provider-types.js"; +import { + buildImessageBridgeRequest, + IMESSAGE_BRIDGE_PROTOCOL_VERSION, + imessageChatConnectorProfile, + normalizeImessageBridgeGuid, + parseImessageBridgeResponse, +} from "../../../../src/domain/chat-connectors/providers/imessage.js"; +import type { ChatConnectorOutboundContext } from "../../../../src/domain/chat-connectors/types.js"; +import { + ChatProviderIngressSecurity, + ChatProviderIngressSecurityError, +} from "../../../../src/services/chat-provider-security.js"; + +describe("iMessage connector profile", () => { + it("advertises only transparent third-party bridge modes", () => { + expect(imessageChatConnectorProfile.supportedTransportModes).toEqual(["managed_bridge", "native_bridge"]); + expect(imessageChatConnectorProfile.setupSchema.bridgeModes.map(({ mode, label }) => ({ mode, label }))).toEqual([ + { mode: "managed_bridge", label: "Third-party managed iMessage bridge contract" }, + { mode: "native_bridge", label: "Local third-party iMessage bridge command" }, + ]); + expect(imessageChatConnectorProfile.liveTest).toMatchObject({ + available: false, + modes: [], + reason: expect.stringContaining("provider-native endpoint verification is unavailable"), + }); + expect(imessageChatConnectorProfile.officialDocumentation.map((entry) => entry.url)).toEqual([ + "https://developer.apple.com/documentation/messages", + "https://developer.apple.com/imessage/", + "https://developer.apple.com/documentation/messageui", + ]); + }); + + it("builds the stable versioned send envelope while preserving legacy command records", () => { + const context = buildOutboundContext(); + const request = imessageChatConnectorProfile.outbound.buildRequest(context); + + expect(request).toMatchObject({ + transport: "command", + command: '"/Applications/Bridge App/bridge" --profile "Personal Relay"', + workingDirectory: "/Users/operator/Bridge Workspace", + tokenSecretKeys: ["bridgeToken", "botToken", "webhookSecret"], + body: { + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + operation: "send", + correlation: { id: "corr-1" }, + message: { guid: "conversation-message-1", text: "Bridge reply", timestamp: null }, + chat: { guid: "chat-guid-1", name: "Operations" }, + sender: { id: null, name: null }, + reply: { messageGuid: "message-guid-1", threadId: "thread-1" }, + result: null, + error: null, + }, + }); + expect((request.body as Record).channelId).toBe("chat-guid-1"); + expect((request.body as Record).replyToExternalMessageId).toBe("message-guid-1"); + + const managedContext = buildOutboundContext(); + managedContext.connection.bridgeMode = "managed_bridge"; + managedContext.connection.setup = { bridgeUrl: "https://third-party-bridge.example.test/send" }; + expect(imessageChatConnectorProfile.outbound.buildRequest(managedContext)).toMatchObject({ + transport: "http", + bearerSecretKeys: ["bridgeApiKey", "bridgeToken", "botToken", "webhookSecret"], + }); + + expect(buildImessageBridgeRequest("health_check", "health-1")).toEqual({ + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + operation: "health_check", + correlation: { id: "health-1" }, + message: null, + chat: null, + sender: null, + reply: null, + result: null, + error: null, + }); + }); + + it("normalizes contract GUIDs and preserves opaque reply/thread identity", () => { + const body = { + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + operation: "send", + correlation: { id: "corr-inbound" }, + message: { guid: " message-e\u0301 ", text: "Inbound bridge text", timestamp: "2026-07-13T00:00:00.000Z" }, + chat: { guid: " iMessage;-;+15550001111 ", name: "Bridge chat" }, + sender: { id: "+15550002222", name: "Sender" }, + reply: { messageGuid: "prior-message-guid", threadId: "conversation-thread-1" }, + result: null, + error: null, + }; + + expect(imessageChatConnectorProfile.ingress.normalize(body)).toMatchObject({ + externalChannelId: "iMessage;-;+15550001111", + externalSenderId: "+15550002222", + externalMessageId: "message-é", + textBody: "Inbound bridge text", + }); + expect(imessageChatConnectorProfile.identity.resolve( + imessageChatConnectorProfile.ingress.normalize(body), + body, + )).toEqual({ conversationId: "iMessage;-;+15550001111", threadId: "conversation-thread-1" }); + expect(normalizeImessageBridgeGuid("bad\0guid")).toBeUndefined(); + expect(normalizeImessageBridgeGuid("x".repeat(513))).toBeUndefined(); + }); + + it("parses negotiated responses and rejects unsupported versions", () => { + const response = JSON.stringify({ + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + operation: "send", + correlation: { id: "corr-1" }, + message: null, + chat: null, + sender: null, + reply: null, + result: { status: "sent", messageGuid: "out-guid-1", chatGuid: "chat-guid-1", metadata: { transport: "fixture" } }, + error: null, + }); + + expect(parseImessageBridgeResponse(response)).toEqual({ + externalMessageId: "out-guid-1", + responseMetadata: { + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + status: "sent", + chatGuid: "chat-guid-1", + metadata: { transport: "fixture" }, + }, + }); + expect(() => parseImessageBridgeResponse(JSON.stringify({ protocolVersion: "2.0", result: null, error: null }))) + .toThrow("Unsupported iMessage bridge protocol version: 2.0"); + }); + + it("rejects health-check and malformed envelopes as send responses", () => { + const sendResponse = { + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + operation: "send", + correlation: { id: "corr-1" }, + message: null, + chat: null, + sender: null, + reply: null, + result: { status: "sent", messageGuid: "out-guid-1", chatGuid: "chat-guid-1", metadata: {} }, + error: null, + }; + + expect(() => parseImessageBridgeResponse(JSON.stringify({ + ...sendResponse, + operation: "health_check", + result: { ...sendResponse.result, status: "healthy" }, + }))).toThrow("operation must be send"); + expect(() => parseImessageBridgeResponse(JSON.stringify(sendResponse), "different-correlation")) + .toThrow("correlation.id does not match the request"); + expect(() => parseImessageBridgeResponse(JSON.stringify({ + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + operation: "send", + result: sendResponse.result, + error: null, + }))).toThrow("required protocol fields are missing"); + expect(() => parseImessageBridgeResponse("{}")) + .toThrow("Malformed legacy iMessage bridge send response"); + expect(() => parseImessageBridgeResponse("{not-json")) + .toThrow("Malformed iMessage bridge response: invalid JSON"); + }); + + it.each([ + ["managed_bridge", "bridgeApiKey"], + ["native_bridge", "bridgeToken"], + ] as const)("uses shared bearer and nonce replay protection for %s ingress", (bridgeMode, secretKey) => { + const secret = "bridge-credential-value"; + const security = new ChatProviderIngressSecurity(); + const connection = buildConnection(bridgeMode, { [secretKey]: secret }); + const now = new Date("2026-07-13T12:00:00.000Z"); + const request = { + headers: { + authorization: `Bearer ${secret}`, + "x-code-ux-timestamp": String(now.getTime()), + "x-code-ux-nonce": "nonce-1", + }, + rawBody: "{}", + now, + }; + + expect(security.verify(connection, request)).toEqual({ authenticated: true, method: "bearer" }); + expect(() => security.verify(connection, request)).toThrowError(expect.objectContaining({ code: "replay_detected" })); + expect(() => security.verify(connection, { + ...request, + headers: { ...request.headers, authorization: "Bearer wrong", "x-code-ux-nonce": "nonce-2" }, + })).toThrowError(expect.objectContaining({ code: "invalid_bearer_token" })); + }); + + it("fails closed when a native inbound credential is absent", () => { + const security = new ChatProviderIngressSecurity(); + try { + security.verify(buildConnection("native_bridge", {}), { + headers: { "x-code-ux-timestamp": String(Date.now()) }, + rawBody: "{}", + }); + throw new Error("Expected security verification to fail."); + } catch (error) { + expect(error).toBeInstanceOf(ChatProviderIngressSecurityError); + expect((error as ChatProviderIngressSecurityError).code).toBe("missing_bridge_secret"); + } + }); +}); + +function buildConnection( + bridgeMode: "managed_bridge" | "native_bridge", + secrets: Record, +): ChatProviderConnectionInternalRecord { + return { + id: `imessage-${bridgeMode}`, + providerKind: "imessage", + displayName: "Fixture bridge", + bridgeMode, + status: "active", + enabled: true, + setup: {}, + secrets, + createdAt: "2026-07-13T00:00:00.000Z", + updatedAt: "2026-07-13T00:00:00.000Z", + }; +} + +function buildOutboundContext(): ChatConnectorOutboundContext { + const connection = buildConnection("native_bridge", { bridgeToken: "fixture-secret" }); + connection.setup = { + command: '"/Applications/Bridge App/bridge" --profile "Personal Relay"', + workingDirectory: "/Users/operator/Bridge Workspace", + }; + return { + connection, + binding: { + id: "binding-1", + providerConnectionId: connection.id, + providerKind: "imessage", + externalChannelId: "chat-guid-1", + externalChannelName: "Operations", + externalChannelMetadata: null, + projectId: "project-1", + agentPresetId: null, + routingHints: null, + enabled: true, + inboundEnabled: true, + outboundEnabled: true, + suppressRichWidgets: true, + createdAt: "2026-07-13T00:00:00.000Z", + updatedAt: "2026-07-13T00:00:00.000Z", + }, + delivery: { + id: "delivery-1", + providerConnectionId: connection.id, + providerKind: "imessage", + channelBindingId: "binding-1", + externalChannelId: "chat-guid-1", + externalMessageId: null, + direction: "outbound", + status: "sending", + attemptCount: 1, + lastError: null, + conversationThreadId: "thread-1", + conversationMessageId: "conversation-message-1", + payload: null, + createdAt: "2026-07-13T00:00:00.000Z", + updatedAt: "2026-07-13T00:00:00.000Z", + }, + payload: { + providerKind: "imessage", + providerConnectionId: connection.id, + channelId: "chat-guid-1", + threadId: "thread-1", + conversationMessageId: "conversation-message-1", + replyText: "Bridge reply", + replyToExternalMessageId: "message-guid-1", + metadata: {}, + }, + correlationId: "corr-1", + }; +} diff --git a/tests/backend/services/imessage-native-bridge.test.ts b/tests/backend/services/imessage-native-bridge.test.ts new file mode 100644 index 0000000000..ff742c56a8 --- /dev/null +++ b/tests/backend/services/imessage-native-bridge.test.ts @@ -0,0 +1,450 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + IMESSAGE_BRIDGE_PROTOCOL_VERSION, + type ImessageBridgeEnvelope, +} from "../../../src/domain/chat-connectors/providers/imessage.js"; +import { + classifyImessageNativeBridgeSpawnError, + ImessageNativeBridge, + ImessageNativeBridgeError, + parseImessageNativeBridgeCommand, +} from "../../../src/services/chat-providers/imessage-native-bridge.js"; +import { + ConfiguredChatProviderOutboundAdapter, + type ChatProviderOutboundAdapterContext, +} from "../../../src/services/chat-provider-adapters.js"; + +const tempDirs: string[] = []; +const bridges: ImessageNativeBridge[] = []; + +beforeEach(() => { + vi.useRealTimers(); +}); + +afterEach(async () => { + await Promise.allSettled(bridges.splice(0).map((bridge) => bridge.dispose())); + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("ImessageNativeBridge", () => { + it("executes a quoted Node fixture without a shell and sends the v1 contract", async () => { + vi.stubEnv("JULES_API_KEY", "must-not-inherit"); + const fixture = await createFixture(` + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => input += chunk); + process.stdin.on('end', () => { + const request = JSON.parse(input); + process.stdout.write(JSON.stringify({ + protocolVersion: request.protocolVersion, + operation: request.operation, + correlation: request.correlation, + message: request.message, + chat: request.chat, + sender: request.sender, + reply: request.reply, + result: { + status: 'sent', + messageGuid: 'bridge-message-guid', + chatGuid: request.chat.guid, + metadata: { + argv: process.argv.slice(2), + tokenBoundary: process.env.CODEUX_CHAT_BRIDGE_TOKEN === 'dedicated-secret', + echoedCredential: process.env.CODEUX_CHAT_BRIDGE_TOKEN, + unrelatedSecretInherited: Boolean(process.env.JULES_API_KEY), + }, + }, + error: null, + })); + }); + `); + const bridge = track(new ImessageNativeBridge()); + const correlationId = "send-correlation"; + + const result = await bridge.send({ + command: `${quote(process.execPath)} ${quote(fixture)} "argument with spaces" "semicolon;is-literal"`, + workingDirectory: path.dirname(fixture), + bridgeToken: "dedicated-secret", + correlationId, + request: sendRequest(correlationId), + }); + + expect(result).toEqual({ + externalMessageId: "bridge-message-guid", + responseMetadata: { + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + status: "sent", + chatGuid: "chat-guid", + metadata: { + argv: ["argument with spaces", "semicolon;is-literal"], + tokenBoundary: true, + echoedCredential: "[REDACTED]", + unrelatedSecretInherited: false, + }, + }, + }); + }); + + it("parses macOS, Windows, and bare executable command records without losing path separators", () => { + expect(parseImessageNativeBridgeCommand( + '"/Applications/Bridge App/bridge" --profile "Local Relay"', + "darwin", + )).toEqual(["/Applications/Bridge App/bridge", "--profile", "Local Relay"]); + expect(parseImessageNativeBridgeCommand( + String.raw`"C:\Program Files\Bridge App\bridge.exe" --profile "Local Relay"`, + "win32", + )).toEqual([String.raw`C:\Program Files\Bridge App\bridge.exe`, "--profile", "Local Relay"]); + expect(parseImessageNativeBridgeCommand( + '"C:\\\\Program Files\\\\Bridge App\\\\bridge.exe" --health', + "win32", + )).toEqual([String.raw`C:\Program Files\Bridge App\bridge.exe`, "--health"]); + expect(parseImessageNativeBridgeCommand( + '"\\\\\\\\server\\\\Bridge Share\\\\bridge.exe" --health', + "win32", + )).toEqual([String.raw`\\server\Bridge Share\bridge.exe`, "--health"]); + expect(parseImessageNativeBridgeCommand("bridge --literal semicolon;value", "linux")) + .toEqual(["bridge", "--literal", "semicolon;value"]); + expect(() => parseImessageNativeBridgeCommand('bridge "unfinished', "linux")) + .toThrowError(expect.objectContaining({ code: "invalid_configuration" })); + }); + + it("returns deterministic unsupported-platform, missing-executable, and permission diagnostics", async () => { + const bridge = track(new ImessageNativeBridge({ platform: process.platform })); + const unsupported = await bridge.verifyNative({ + command: quote(process.execPath), + correlationId: "platform-check", + supportedPlatforms: process.platform === "darwin" ? ["linux"] : ["darwin"], + }); + const missing = await bridge.verifyNative({ + command: path.join(os.tmpdir(), "code-ux-definitely-missing-imessage-bridge"), + correlationId: "missing-check", + }); + const permission = classifyImessageNativeBridgeSpawnError(Object.assign(new Error("access denied"), { code: "EACCES" })); + + expect(unsupported).toMatchObject({ ok: false, code: "unsupported_platform", protocolVersion: null }); + expect(missing).toMatchObject({ ok: false, code: "missing_executable", protocolVersion: null }); + expect(permission).toMatchObject({ code: "permission_denied", retryable: false }); + }); + + it("terminates timed-out and cancelled fixtures with stable diagnostics", async () => { + const fixture = await createFixture("process.stdin.resume(); setInterval(() => {}, 1000);"); + const bridge = track(new ImessageNativeBridge()); + + const timeout = await bridge.verifyNative({ + command: `${quote(process.execPath)} ${quote(fixture)}`, + correlationId: "timeout-check", + timeoutMs: 50, + }); + const controller = new AbortController(); + const cancellationPromise = bridge.verifyNative({ + command: `${quote(process.execPath)} ${quote(fixture)}`, + correlationId: "cancel-check", + timeoutMs: 2_000, + signal: controller.signal, + }); + setTimeout(() => controller.abort("fixture cancellation"), 25); + + expect(timeout).toMatchObject({ ok: false, code: "timeout" }); + await expect(cancellationPromise).resolves.toMatchObject({ ok: false, code: "cancelled" }); + }); + + it("terminates all active child processes when disposed", async () => { + const fixture = await createFixture("process.stdin.resume(); setInterval(() => {}, 1000);"); + const bridge = track(new ImessageNativeBridge()); + const healthPromise = bridge.verifyNative({ + command: `${quote(process.execPath)} ${quote(fixture)}`, + correlationId: "shutdown-check", + timeoutMs: 2_000, + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + await bridge.dispose(); + + await expect(healthPromise).resolves.toMatchObject({ ok: false, code: "shutdown" }); + }); + + it("rejects oversized output and malformed JSON", async () => { + const oversizedFixture = await createFixture("process.stdin.resume(); process.stdout.write('x'.repeat(4096));"); + const malformedFixture = await createFixture("process.stdin.resume(); process.stdout.write('{not-json');"); + const bridge = track(new ImessageNativeBridge()); + + await expect(bridge.verifyNative({ + command: `${quote(process.execPath)} ${quote(oversizedFixture)}`, + correlationId: "oversized-check", + maxStdoutBytes: 128, + })).resolves.toMatchObject({ ok: false, code: "output_limit_exceeded" }); + await expect(bridge.verifyNative({ + command: `${quote(process.execPath)} ${quote(malformedFixture)}`, + correlationId: "malformed-check", + })).resolves.toMatchObject({ ok: false, code: "malformed_response" }); + }); + + it("negotiates protocol and correlation fields strictly for health checks", async () => { + const wrongVersion = await createFixture(responseFixture({ protocolVersion: "2.0" })); + const wrongCorrelation = await createFixture(responseFixture({ correlationId: "different-correlation" })); + const bridge = track(new ImessageNativeBridge()); + + await expect(bridge.verifyNative({ + command: `${quote(process.execPath)} ${quote(wrongVersion)}`, + correlationId: "version-check", + })).resolves.toMatchObject({ ok: false, code: "protocol_version_mismatch" }); + await expect(bridge.verifyNative({ + command: `${quote(process.execPath)} ${quote(wrongCorrelation)}`, + correlationId: "correlation-check", + })).resolves.toMatchObject({ ok: false, code: "correlation_mismatch" }); + }); + + it("redacts bridge credentials from nonzero-exit diagnostics", async () => { + const fixture = await createFixture(` + process.stdin.resume(); + process.stderr.write('credential=' + process.env.CODEUX_CHAT_BRIDGE_TOKEN); + process.exitCode = 17; + `); + const bridge = track(new ImessageNativeBridge()); + const result = await bridge.verifyNative({ + command: `${quote(process.execPath)} ${quote(fixture)}`, + correlationId: "redaction-check", + bridgeToken: "plain-fixture-credential", + }); + + expect(result).toMatchObject({ ok: false, code: "nonzero_exit" }); + expect(result.message).toContain("[REDACTED]"); + expect(result.message).not.toContain("plain-fixture-credential"); + }); + + it("verifies a managed bridge through the same protocol without contacting Apple", async () => { + const fetchMock = vi.fn(async (_url, init) => { + const request = JSON.parse(String(init?.body)) as ImessageBridgeEnvelope; + expect(init?.headers).toMatchObject({ authorization: "Bearer managed-fixture-secret" }); + return Response.json({ + ...request, + result: { status: "healthy", messageGuid: null, chatGuid: null, metadata: { fixture: true } }, + }); + }); + const bridge = track(new ImessageNativeBridge({ fetch: fetchMock })); + + const result = await bridge.verifyManaged({ + url: "https://third-party-bridge.example.test/health", + bridgeApiKey: "managed-fixture-secret", + correlationId: "managed-health", + }); + const apple = await bridge.verifyManaged({ + url: "https://api.apple.com/messages", + bridgeApiKey: "managed-fixture-secret", + correlationId: "apple-health", + }); + + expect(result).toMatchObject({ ok: true, code: "healthy", protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION }); + expect(apple).toMatchObject({ ok: false, code: "provider_native_verification_unavailable" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("delivers managed sends with a legacy stored secret fallback", async () => { + const fetchMock = vi.fn(async (_url, init) => { + const request = JSON.parse(String(init?.body)) as ImessageBridgeEnvelope; + expect(init?.headers).toMatchObject({ authorization: "Bearer legacy-managed-secret" }); + return Response.json({ + ...request, + result: { status: "sent", messageGuid: "managed-message-guid", chatGuid: "chat-guid", metadata: {} }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + const adapter = new ConfiguredChatProviderOutboundAdapter(); + + await expect(adapter.send(adapterContext( + "managed_bridge", + { bridgeUrl: "https://third-party-bridge.example.test/send" }, + { webhookSecret: "legacy-managed-secret" }, + ))).resolves.toMatchObject({ externalMessageId: "managed-message-guid" }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("delivers native sends with a legacy stored secret fallback", async () => { + const fixture = await createFixture(` + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => input += chunk); + process.stdin.on('end', () => { + const request = JSON.parse(input); + if (process.env.CODEUX_CHAT_BRIDGE_TOKEN !== 'legacy-native-secret') process.exit(19); + process.stdout.write(JSON.stringify({ + ...request, + result: { status: 'sent', messageGuid: 'native-message-guid', chatGuid: request.chat.guid, metadata: {} }, + })); + }); + `); + const adapter = new ConfiguredChatProviderOutboundAdapter(); + + await expect(adapter.send(adapterContext( + "native_bridge", + { command: `${quote(process.execPath)} ${quote(fixture)}`, workingDirectory: path.dirname(fixture) }, + { botToken: "legacy-native-secret" }, + ))).resolves.toMatchObject({ externalMessageId: "native-message-guid" }); + }); + + it.each([ + ["health-check", (request: ImessageBridgeEnvelope) => ({ + ...request, + operation: "health_check", + message: null, + chat: null, + sender: null, + reply: null, + result: { status: "healthy", messageGuid: null, chatGuid: null, metadata: {} }, + })], + ["malformed", (_request: ImessageBridgeEnvelope) => ({ + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + operation: "send", + result: { status: "sent", messageGuid: "invalid-send", chatGuid: null, metadata: {} }, + error: null, + })], + ["mismatched-correlation", (request: ImessageBridgeEnvelope) => ({ + ...request, + correlation: { id: "stale-correlation" }, + result: { status: "sent", messageGuid: "stale-send", chatGuid: "chat-guid", metadata: {} }, + })], + ] as const)("rejects %s managed responses as completed sends", async (_label, buildResponse) => { + const fetchMock = vi.fn(async (_url, init) => { + const request = JSON.parse(String(init?.body)) as ImessageBridgeEnvelope; + return Response.json(buildResponse(request)); + }); + vi.stubGlobal("fetch", fetchMock); + const adapter = new ConfiguredChatProviderOutboundAdapter(); + + await expect(adapter.send(adapterContext( + "managed_bridge", + { bridgeUrl: "https://third-party-bridge.example.test/send" }, + { bridgeApiKey: "managed-secret" }, + ))).rejects.toMatchObject({ name: "ChatProviderOutboundAdapterError", retryable: false }); + }); +}); + +function sendRequest(correlationId: string): ImessageBridgeEnvelope { + return { + protocolVersion: IMESSAGE_BRIDGE_PROTOCOL_VERSION, + operation: "send", + correlation: { id: correlationId }, + message: { guid: "local-message-guid", text: "Fixture message", timestamp: null }, + chat: { guid: "chat-guid", name: "Fixture chat" }, + sender: { id: null, name: null }, + reply: { messageGuid: "reply-guid", threadId: "thread-id" }, + result: null, + error: null, + }; +} + +function adapterContext( + bridgeMode: "managed_bridge" | "native_bridge", + setup: Record, + secrets: Record, +): ChatProviderOutboundAdapterContext { + return { + connection: { + id: "imessage-connection", + providerKind: "imessage", + displayName: "Fixture iMessage bridge", + bridgeMode, + status: "active", + enabled: true, + setup, + secrets, + createdAt: "2026-07-13T00:00:00.000Z", + updatedAt: "2026-07-13T00:00:00.000Z", + }, + binding: { + id: "binding-1", + providerConnectionId: "imessage-connection", + providerKind: "imessage", + externalChannelId: "chat-guid", + externalChannelName: "Fixture chat", + externalChannelMetadata: null, + projectId: "project-1", + agentPresetId: null, + routingHints: null, + enabled: true, + inboundEnabled: true, + outboundEnabled: true, + suppressRichWidgets: true, + createdAt: "2026-07-13T00:00:00.000Z", + updatedAt: "2026-07-13T00:00:00.000Z", + }, + delivery: { + id: "delivery-1", + providerConnectionId: "imessage-connection", + providerKind: "imessage", + channelBindingId: "binding-1", + externalChannelId: "chat-guid", + externalMessageId: null, + direction: "outbound", + status: "sending", + attemptCount: 1, + lastError: null, + conversationThreadId: "thread-1", + conversationMessageId: "message-1", + payload: null, + createdAt: "2026-07-13T00:00:00.000Z", + updatedAt: "2026-07-13T00:00:00.000Z", + }, + payload: { + providerKind: "imessage", + providerConnectionId: "imessage-connection", + channelId: "chat-guid", + threadId: "thread-1", + conversationMessageId: "message-1", + replyText: "Fixture reply", + replyToExternalMessageId: "inbound-guid", + metadata: {}, + }, + correlationId: "delivery-correlation", + }; +} + +function responseFixture(overrides: { protocolVersion?: string; correlationId?: string }): string { + return ` + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => input += chunk); + process.stdin.on('end', () => { + const request = JSON.parse(input); + process.stdout.write(JSON.stringify({ + ...request, + protocolVersion: ${JSON.stringify(overrides.protocolVersion ?? IMESSAGE_BRIDGE_PROTOCOL_VERSION)}, + correlation: { id: ${JSON.stringify(overrides.correlationId)} || request.correlation.id }, + result: { status: 'healthy', messageGuid: null, chatGuid: null, metadata: {} }, + })); + }); + `; +} + +async function createFixture(source: string): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "code ux imessage fixture ")); + tempDirs.push(directory); + const fixturePath = path.join(directory, "bridge fixture.cjs"); + await fs.writeFile(fixturePath, source, "utf8"); + return fixturePath; +} + +function quote(value: string): string { + return JSON.stringify(value); +} + +function track(bridge: ImessageNativeBridge): ImessageNativeBridge { + bridges.push(bridge); + return bridge; +} + +describe("ImessageNativeBridgeError", () => { + it("retains stable machine-readable codes", () => { + expect(new ImessageNativeBridgeError("cancelled", "cancelled", true)).toMatchObject({ + name: "ImessageNativeBridgeError", + code: "cancelled", + retryable: true, + }); + }); +});