Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/host-to-device-message-encoding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@rozenite/app': patch
'@rozenite/middleware': patch
'@rozenite/runtime': patch
---

Fix host-to-device messages carrying emoji or any other character outside the
Basic Multilingual Plane being silently dropped — editing a stored value that
contains one from the Storage, MMKV, or SQLite panels now reaches the app
instead of looking like it did. A message the device refuses to accept is
reported now, rather than lost without a trace.
64 changes: 64 additions & 0 deletions packages/app/src/connection/device-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,23 @@ const getExpressions = (socket: FakeWebSocket): string[] =>
.filter((command) => command.method === 'Runtime.evaluate')
.map((command) => String(command.params?.expression ?? ''));

/**
* Replays what the device does with the injected source text: compile it,
* call the dispatcher, and `JSON.parse` the payload it is handed.
*/
const evaluateOnDevice = (expression: string): Array<[string, unknown]> => {
const delivered: Array<[string, unknown]> = [];
const dispatcher = {
sendMessage: (domain: string, payload: string): void => {
delivered.push([domain, JSON.parse(payload)]);
},
};

new Function(RUNTIME_GLOBAL, expression)(dispatcher);

return delivered;
};

/**
* The device-local page id a real target would report: the `page` query
* parameter of its own `webSocketDebuggerUrl`, exactly like the
Expand Down Expand Up @@ -411,6 +428,53 @@ describe('createDeviceConnection', () => {
});
});

describe('message encoding', () => {
it('keeps the injected expression ASCII-only so Hermes can compile it', async () => {
const { connection, socket } = await connectAndBootstrap();
const message = { key: 'note', value: 'ship \u{1F389} it \u{2014} \u{65E5}\u{672C}\u{8A9E}' };

const before = socket.sent.length;
connection.send(message);
await waitUntil(() => socket.sent.length > before);

const expressions = getExpressions(socket).filter((expression) =>
expression.includes(`${RUNTIME_GLOBAL}.sendMessage("rozenite"`),
);
expect(expressions).toHaveLength(1);

// The bug this guards: Hermes compiles the expression as UTF-8 source
// and rejects a raw astral-plane code unit with `Invalid UTF-8 code
// point`, losing the message. The browser accepts that raw form, so this
// ASCII-only assertion is the part that actually reproduces the failure;
// the round trip below proves the escaping does not alter the payload.
expect(expressions[0]).toMatch(/^[\x20-\x7E]+$/);
expect(evaluateOnDevice(expressions[0] ?? '')).toEqual([['rozenite', message]]);
});

it('reports a message the device refused to evaluate', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const { connection, socket } = await connectAndBootstrap();

socket.responder = (method, params) => {
const expression = String(params?.expression ?? '');
if (method === 'Runtime.evaluate' && expression.includes('.sendMessage(')) {
return { exceptionDetails: { text: 'SyntaxError: Invalid UTF-8 code point' } };
}
return defaultResponder(method, params);
};

const before = socket.sent.length;
connection.send({ key: 'note', value: 'hello' });
await waitUntil(() => socket.sent.length > before);
await vi.advanceTimersByTimeAsync(50);

expect(consoleError).toHaveBeenCalledTimes(1);
expect(consoleError.mock.calls[0][0]).toContain('Invalid UTF-8 code point');

consoleError.mockRestore();
});
});

describe('message routing', () => {
it('forwards rozenite-domain binding payloads and drops other domains', async () => {
const { connection, socket } = await connectAndBootstrap();
Expand Down
44 changes: 42 additions & 2 deletions packages/app/src/connection/device-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ type CDPEvaluateResult = {

const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));

/**
* Escapes every non-ASCII code unit as a `\uXXXX` escape sequence, so that the
* JS source text handed to `Runtime.evaluate` is pure ASCII.
*
* Hermes compiles that source text from UTF-8 and fails on a raw astral-plane
* code unit with `Invalid UTF-8 code point`, which loses the message without
* any visible error. The escape sequence it produces is plain ASCII, and the
* device's own parser turns it back into the original code unit, so the payload
* the device reconstructs is byte-identical to the one the host serialized.
*
* This has to run on the output of the second `JSON.stringify`, never before
* it: escaped earlier, `JSON.stringify` escapes the backslash instead and the
* device receives `\uD83C` as six characters of text.
*
* Identical in all three hosts that speak this protocol (here, the embedded
* bindings model in `@rozenite/runtime`, and the agent session in
* `@rozenite/middleware`), like the dispatcher-wait poll below.
*/
const toAsciiJsSource = (source: string): string =>
source.replace(
/[^\0-\x7F]/g,
(codeUnit) => `\\u${codeUnit.charCodeAt(0).toString(16).padStart(4, '0')}`,
);

/** A detached `void sendCommand(...)` (queue flushing, fire-and-forget
* sends) has nothing else attached to observe its rejection. Ported from
* `session.ts`'s `markPromiseAsHandled` so a socket dying mid-send surfaces
Expand Down Expand Up @@ -292,11 +316,27 @@ export const createDeviceConnection = (target: ParsedTarget): DeviceConnection =

const sendDomainMessage = (socket: WebSocket, message: unknown): Promise<void> => {
const serializedMessage = JSON.stringify(message);
const escapedMessage = JSON.stringify(serializedMessage);
const escapedMessage = toAsciiJsSource(JSON.stringify(serializedMessage));

return markPromiseAsHandled(
sendCommand(socket, 'Runtime.evaluate', {
expression: `${RUNTIME_GLOBAL}.sendMessage(${JSON.stringify('rozenite')}, ${escapedMessage})`,
}).then(() => undefined),
})
.then((response) => {
// The device rejecting the source text is reported on the response,
// so this is the only place a lost message can be noticed. Reported
// rather than thrown because every caller here is a detached
// fire-and-forget send (`send`, `flushQueue`) with nothing left to
// hand an error to.
const { exceptionDetails } = response as CDPEvaluateResult;
if (exceptionDetails) {
console.error(
'[rozenite] Failed to send a message to the rozenite domain: ' +
(exceptionDetails.text ?? 'unknown error'),
);
}
})
.then(() => undefined),
);
};

Expand Down
88 changes: 88 additions & 0 deletions packages/middleware/src/__tests__/agent-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => {
const resolveDebuggerOrigin = vi.fn();
let bindingName = 'rozenite-binding';
let stalledRuntimeExpression: string | null = null;
let refusedRuntimeExpression: string | null = null;

class MockWebSocket {
static readonly CONNECTING = 0;
Expand Down Expand Up @@ -151,6 +152,17 @@ const mocks = vi.hoisted(() => {
};
}

// Models the device refusing to compile the injected source text (the
// `Invalid UTF-8 code point` failure of issue #407): the command itself
// succeeds, and the rejection is reported on the response.
if (refusedRuntimeExpression && expression.includes(refusedRuntimeExpression)) {
return {
exceptionDetails: {
text: 'Compiling JS failed: Invalid UTF-8 code point',
},
};
}

return {};
};

Expand All @@ -176,6 +188,9 @@ const mocks = vi.hoisted(() => {
stallRuntimeEvaluationContaining: (expression: string) => {
stalledRuntimeExpression = expression;
},
refuseRuntimeEvaluationContaining: (expression: string) => {
refusedRuntimeExpression = expression;
},
reset: () => {
commandLog.length = 0;
loggerInfo.mockReset();
Expand All @@ -197,6 +212,7 @@ const mocks = vi.hoisted(() => {
});
bindingName = 'rozenite-binding';
stalledRuntimeExpression = null;
refusedRuntimeExpression = null;
wsInstances.length = 0;
resolveDebuggerOrigin.mockReset();
resolveDebuggerOrigin.mockImplementation(
Expand Down Expand Up @@ -327,6 +343,26 @@ const getExpressions = (): string[] => {
.map((command) => String(command.params?.expression ?? ''));
};

/**
* Replays what the device does with the injected source text: compile it,
* call the dispatcher, and `JSON.parse` the payload it is handed.
*/
const evaluateOnDevice = (expression: string): Array<[string, unknown]> => {
const delivered: Array<[string, unknown]> = [];
const dispatcher = {
sendMessage: (domain: string, payload: string): void => {
delivered.push([domain, JSON.parse(payload)]);
},
};

new Function(RUNTIME_GLOBAL, expression)(dispatcher);

return delivered;
};

const sendMessageExpressions = (expressions: string[]): string[] =>
expressions.filter((expression) => expression.includes('sendMessage("rozenite"'));

const emitRozeniteBindingPayload = async (
socket: InstanceType<typeof mocks.MockWebSocket>,
message: Record<string, unknown>,
Expand Down Expand Up @@ -535,6 +571,58 @@ describe('agent session', () => {
expect(readyExpressions).toHaveLength(2);
});

it('keeps the injected expression ASCII-only so Hermes can compile it', async () => {
await startSession();

const sender = mocks.handler.connectDevice.mock.calls[0]?.[2] as
| { sendMessage: (message: unknown) => void }
| undefined;
expect(sender).toBeDefined();

const message = {
pluginId: '@rozenite/storage-plugin',
type: 'set-entry',
payload: { value: 'ship \u{1F389} it' },
};
sender?.sendMessage(message);
await flushMicrotasks();

const expressions = sendMessageExpressions(getExpressions()).filter((expression) =>
expression.includes('set-entry'),
);
expect(expressions).toHaveLength(1);

// The bug this guards: Hermes compiles the expression as UTF-8 source and
// rejects a raw astral-plane code unit with `Invalid UTF-8 code point`,
// losing the message. Node accepts that raw form, so this ASCII-only
// assertion is the part that actually reproduces the failure; the round
// trip below proves the escaping the fix adds does not alter the payload.
expect(expressions[0]).toMatch(/^[\x20-\x7E]+$/);
expect(evaluateOnDevice(expressions[0])).toEqual([['rozenite', message]]);
});

it('retries the handshake when the device refuses the message', async () => {
// The device refuses the evaluation while the command itself succeeds:
// precisely the shape a lost message used to have.
mocks.refuseRuntimeEvaluationContaining('agent-session-ready');

const { socket, startPromise } = await createStartedSession();
const onResolved = vi.fn();
startPromise.then(onResolved);

socket.open();
await vi.advanceTimersByTimeAsync(500);
await flushMicrotasks();

expect(sendMessageExpressions(getExpressions())).toHaveLength(1);
expect(onResolved).not.toHaveBeenCalled();

await vi.advanceTimersByTimeAsync(500);
await flushMicrotasks();

expect(sendMessageExpressions(getExpressions())).toHaveLength(2);
});

it('heals a relaunched app with the same device id and a new page id', async () => {
const replacementTarget = createTarget({
pageId: 'page-2',
Expand Down
44 changes: 42 additions & 2 deletions packages/middleware/src/agent/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,30 @@ const DEVTOOLS_TOOK_CONNECTION_REASON = '[NEW_DEBUGGER_OPENED]';
const getCloseReason = (reason: unknown): string =>
Buffer.isBuffer(reason) ? reason.toString() : String(reason ?? '');

/**
* Escapes every non-ASCII code unit as a `\uXXXX` escape sequence, so that the
* JS source text handed to `Runtime.evaluate` is pure ASCII.
*
* Hermes compiles that source text from UTF-8 and fails on a raw astral-plane
* code unit with `Invalid UTF-8 code point`, which loses the message without
* any visible error. The escape sequence it produces is plain ASCII, and the
* device's own parser turns it back into the original code unit, so the payload
* the device reconstructs is byte-identical to the one the host serialized.
*
* This has to run on the output of the second `JSON.stringify`, never before
* it: escaped earlier, `JSON.stringify` escapes the backslash instead and the
* device receives `\uD83C` as six characters of text.
*
* Identical in all three hosts that speak this protocol (here, the embedded
* bindings model in `@rozenite/runtime`, and the device connection in
* `@rozenite/app`), like the dispatcher-wait poll further down.
*/
const toAsciiJsSource = (source: string): string =>
source.replace(
/[^\0-\x7F]/g,
(codeUnit) => `\\u${codeUnit.charCodeAt(0).toString(16).padStart(4, '0')}`,
);

type PendingCommand = {
/** Retained so a device error can name the method it refused. */
method: string;
Expand Down Expand Up @@ -376,11 +400,27 @@ export const createAgentSession = (options: {

const sendDomainMessage = (domain: string, message: unknown): Promise<void> => {
const serializedMessage = JSON.stringify(message);
const escapedMessage = JSON.stringify(serializedMessage);
const escapedMessage = toAsciiJsSource(JSON.stringify(serializedMessage));

return markPromiseAsHandled(
sendCommand('Runtime.evaluate', {
expression: `${RUNTIME_GLOBAL}.sendMessage(${JSON.stringify(domain)}, ${escapedMessage})`,
}).then(() => undefined),
})
.then((response) => {
// The device rejecting the source text is reported on the response,
// so this is the only place a lost message can be noticed. Thrown
// rather than logged because the callers that matter await it:
// `bootstrap` retries the handshake, and an agent tool call turns it
// into an error for the client instead of an empty result.
const { exceptionDetails } = response as CDPEvaluateResponse;
if (exceptionDetails) {
throw new Error(
`Failed to send a message to the ${domain} domain: ` +
(exceptionDetails.text ?? 'unknown error'),
);
}
})
.then(() => undefined),
);
};

Expand Down
Loading