From 48aedb8bce4c66399c659b0c09f34bc71a2b474b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 24 Oct 2026 16:16:22 +0200 Subject: [PATCH 1/2] test: cover own __proto__ keys and failing polls in eval JSON --- tests/dev/eval-json-async.test.ts | 31 +++++++++++++++++++++++++++++++ tests/dev/eval-json.test.ts | 12 ++++++++++++ 2 files changed, 43 insertions(+) diff --git a/tests/dev/eval-json-async.test.ts b/tests/dev/eval-json-async.test.ts index 20afb8a..06ada48 100644 --- a/tests/dev/eval-json-async.test.ts +++ b/tests/dev/eval-json-async.test.ts @@ -163,6 +163,37 @@ describe("runEvalJsonAsync", () => { await expect(runEvalJsonAsync(dev, "await Promise.resolve('polled')")).resolves.toBe("polled"); }); + it("resolves an own __proto__ key as data without touching any prototype", async () => { + const { dev } = createInAppEmulator(); + + const value = await runEvalJsonAsync<{ savedFeeds: Record }>( + dev, + 'JSON.parse(\'{"savedFeeds":{"__proto__":{"title":"__proto__"}}}\')', + { timeoutMs: 2_000 }, + ); + + expect(Object.getOwnPropertyDescriptor(value.savedFeeds, "__proto__")?.value).toEqual({ + title: "__proto__", + }); + expect(Object.getPrototypeOf(value.savedFeeds)).toBe(Object.prototype); + expect(({} as Record).title).toBeUndefined(); + }); + + it("rethrows a poll that fails in-app instead of reporting the operation as pending", async () => { + const { dev } = createInAppEmulator(() => { + // A completed entry the poll read cannot serialize. + for (const entry of Object.values(registryEntries())) { + if (entry.state === "done") + Object.assign(entry, { envelope: { ok: true, value: new Map() } }); + } + return undefined; + }); + + await expect( + runEvalJsonAsync(dev, "await Promise.resolve('done')", { timeoutMs: 2_000 }), + ).rejects.toThrowError(DevEvalError); + }); + it("fails with reason 'still-pending' when the promise never settles", async () => { const { dev } = createInAppEmulator(); diff --git a/tests/dev/eval-json.test.ts b/tests/dev/eval-json.test.ts index 7ccb522..13c5997 100644 --- a/tests/dev/eval-json.test.ts +++ b/tests/dev/eval-json.test.ts @@ -44,6 +44,18 @@ describe("buildEvalJsonCode executed", () => { expect(thrown).toBeInstanceOf(DevEvalError); expect((thrown as DevEvalError).message).toBe("Failed to evaluate Obsidian code: nope"); }); + + it("keeps an own __proto__ key as data without touching any prototype", () => { + const value = evalGenerated<{ savedFeeds: Record }>( + 'JSON.parse(\'{"savedFeeds":{"__proto__":{"title":"__proto__"}}}\')', + ); + + expect(Object.getOwnPropertyDescriptor(value.savedFeeds, "__proto__")?.value).toEqual({ + title: "__proto__", + }); + expect(Object.getPrototypeOf(value.savedFeeds)).toBe(Object.prototype); + expect(({} as Record).title).toBeUndefined(); + }); }); // Regression for https://github.com/chhoumann/obsidian-e2e/issues/18 on the From 9179b889bcee3ab5f2d2d8fd52a45293fc7b9bfd Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 24 Oct 2026 16:17:28 +0200 Subject: [PATCH 2/2] fix: keep own __proto__ keys in eval JSON results (#44) The in-app serializer copied entries into a {} literal, so an own __proto__ key swapped that object's prototype instead of becoming a key. evalJson dropped the key. evalJsonAsync stored the swapped object, every poll read then failed to reserialize it, and the loop retried those failures until it reported a finished operation as still-pending. Build serializer records with a null prototype. The client decodes them with Object.fromEntries, which defines the key as own data on a plain object, so nothing can pollute a prototype. A poll that throws in-app is deterministic, so it now surfaces at once instead of being retried. Fixes #44 --- .changeset/eval-json-proto-keys.md | 5 +++++ src/dev/eval-json.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .changeset/eval-json-proto-keys.md diff --git a/.changeset/eval-json-proto-keys.md b/.changeset/eval-json-proto-keys.md new file mode 100644 index 0000000..443902c --- /dev/null +++ b/.changeset/eval-json-proto-keys.md @@ -0,0 +1,5 @@ +--- +"obsidian-e2e": patch +--- + +`evalJson` and `evalJsonAsync` now keep an own `__proto__` key in a result as a plain data key. `evalJson` used to drop it, and `evalJsonAsync` timed out with reason `still-pending` although the operation had finished. The decoded result still has `Object.prototype` as its prototype, so the key cannot pollute any prototype. A poll read that fails inside Obsidian now throws its `DevEvalError` at once. diff --git a/src/dev/eval-json.ts b/src/dev/eval-json.ts index d4dc778..c9083ba 100644 --- a/src/dev/eval-json.ts +++ b/src/dev/eval-json.ts @@ -57,7 +57,8 @@ const EVAL_JSON_SERIALIZER = [ "if(Array.isArray(value)){return value.map((item,index)=>__obsidianE2ESerialize(item,`${path}[${index}]`));}", "const prototype=Object.getPrototypeOf(value);", "if(prototype!==Object.prototype&&prototype!==null){throw new Error(`Cannot serialize non-plain object at ${path}.`);}", - "const next={};", + // A null prototype keeps an own `__proto__` key as data instead of a prototype swap. + "const next=Object.create(null);", "for(const [key,entry] of Object.entries(value)){next[key]=__obsidianE2ESerialize(entry,`${path}.${key}`);}", "return next;", "};", @@ -240,6 +241,8 @@ export async function runEvalJsonAsync( directCommandOptions(ASYNC_EVAL_COMMAND_TIMEOUT_MS), ); } catch (error) { + // The read ran in-app and threw, so repeating it cannot succeed. + if (error instanceof DevEvalError) throw error; lastError = error; await pause(); continue;