Skip to content

Commit 665059b

Browse files
fix(service-print): a server print works under the console's default CSP (the ready wait passes a function, not a string) (#26783)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X5qLZ9SK4dTsHt1W8ph2VBe)_ Fixes #26664 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:服务端打印的 Chromium 驱动等待打印页就绪时,改为传一个函数谓词,不再传字符串。 - **为什么改**:默认部署下控制台 CSP 不含 `'unsafe-eval'`,字符串谓词每次轮询都要在页面里 eval,第二次轮询即抛 EvalError,所有服务端打印都失败。 - **风险与回滚**:只动 `chromium-driver.ts` 一处等待;就绪含义、超时、轮询间隔不变,控制台 CSP 不变。回滚即还原该文件。 - **先看**:`packages/services/service-print/src/chromium-driver.ts`(`printReady` 与 `waitForFunction` 那一行);`chromium-driver.unit.test.ts` 末尾新增的两条。 - **两分钟验证**:`OS_TEST_PRINT_CHROMIUM_PATH=/opt/pw-browsers/chromium pnpm --filter @objectstack/service-print exec vitest run src/chromium-driver.test.ts`,应见 CSP 那条在两种放置下都通过(main 上是 EvalError)。 - **需要你做的**:无,席位落地。 ## What changes `packages/services/service-print/src/chromium-driver.ts` waited for the print page with `page.waitForFunction(PRINT_READY_PREDICATE, …)`, where the predicate was a **string**. It is now a function, `printReady(stablePolls)`, passed as `page.waitForFunction(printReady, STABLE_POLLS, { timeout: remaining(), polling: POLL_INTERVAL_MS })`. **Which evaluation path, and why `script-src` does not block it** (read in playwright-core 1.63.0, `lib/coreBundle.js` `waitForFunctionExpression`, and measured below). The wait installs a poller through the injected script's `evaluateHandle`, a DevTools `Runtime.callFunctionOn`. The poller's `predicate()` runs `globalThis.eval(expression)`: - with a **string** (`isFunction: false`), on **every** poll. The first poll runs synchronously inside the DevTools call, where the inspector permits code generation; every later poll runs from a page `setTimeout`, where the page's CSP governs, and without `'unsafe-eval'` it throws EvalError. The predicate needs three stable polls, so the second poll always fails. - with a **function** (`isFunction: true`), **once**: the first, synchronous poll caches the evaluated function (`evaledExpression`), and every later poll calls that cached function. No string is evaluated from a page timer, so the CSP has nothing to refuse. The readiness meaning is unchanged: the same three conditions (a `[data-print-page]` root, `window.__objectui` present, `idle === true`), the same in-page run counter (`__objectstackPrintStable`), the same `STABLE_POLLS` (3), the same 100 ms polling and the same share of the one render budget. The page receives the function's text only, so the poll count it used to read from a template literal now arrives as its argument. The built output was read: tsup emits `printReady` as plain ES2020 in both `dist/index.js` and `dist/index.cjs`, with no injected helper names. The console CSP (`packages/cli/src/utils/console.ts`) is not touched. `page.evaluate(FONTS_READY)` stays a string: it is evaluated synchronously inside its DevTools call, and the CSP leg below shows it passing. A `patch` changeset on `@objectstack/service-print`. ## Tests - `chromium-driver.unit.test.ts` (runs in CI, no browser): the staged page's `waitForFunction` must receive a **function**, `STABLE_POLLS` as its argument, `polling: 100` and a timeout inside the budget. A second case runs the received function against a staged `document` / `__objectui` and pins the meaning: false with no console, false when idle with no root, false with the root while busy, then exactly `STABLE_POLLS` idle polls to true, and a busy poll resets the run. - `chromium-driver.test.ts` (real Chromium, both placements): the fixture now also serves the print page under the console's default policy (`script-src 'self' 'nonce-…' 'wasm-unsafe-eval'`, the script carrying the nonce), and a new case per placement renders it. - The render dogfood (`packages/qa/dogfood/test/print-service-render.dogfood.test.ts`, which carries the named-page leg from #26662) is unchanged; it is the end-to-end reading below. ## Acceptance notes - CI skips both real-Chromium files (`describe.skipIf(!CHROMIUM)` / `!CHROMIUM || !CONSOLE_BUILT`): Test Core has no Chromium and no console bundle. The unit test is the guard CI runs. Turning the render tests on in CI is a CI-provisioning question and is not done here. - `page.evaluate(FONTS_READY)` and the predicate's first poll rely on DevTools-initiated evaluation being exempt from the page's `'unsafe-eval'` check, which is Chromium's behaviour today and is what playwright-core's own function-predicate path relies on. ## Verification All readings at head `dfd3fc087`, with `OS_PAGE_REACT_ENABLED` (and legacy `OS_PAGE_REACT`) unset, Chromium `/opt/pw-browsers/chromium`, console bundle from `pnpm console:build`. <details><summary>Red on main's driver (base 1685a06): EvalError</summary> `chromium-driver.ts` byte-equal to `origin/main`, the new tests committed: - `vitest run src/chromium-driver.unit.test.ts src/chromium-driver.test.ts`: Tests 4 failed, 16 passed (20). Both unit cases fail; the CSP case fails on both placements with `The print engine failed at the local executable /opt/pw-browsers/chromium: page.waitForFunction: EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive because 'unsafe-eval' is not an allowed source of script: script-src 'self' 'nonce-print-fixture-nonce' 'wasm-unsafe-eval'` (and the same at the remote endpoint). - Dogfood `print-service-render.dogfood.test.ts` + `print-service-named-page.dogfood.test.ts`: Tests 6 failed, 6 passed (12). All 6 render legs (invoice, named print page, revoke-after-failure; local and remote) fail with the EvalError under the console's real nonce policy. The named-page file uses a stand-in driver and passes either way. </details> <details><summary>Green with the fix</summary> - Same two driver files: Test Files 2 passed, Tests 20 passed (20). - Same two dogfood files: Test Files 2 passed, Tests 12 passed (12). - `pnpm --filter @objectstack/service-print build`, `typecheck`: exit 0. `test` (with Chromium): Test Files 8 passed, Tests 118 passed (118). - `pnpm exec eslint --no-inline-config packages/services/service-print`: exit 0. </details> <details><summary>Gates</summary> Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `dfd3fc087` (4 paths, +129 / -13): 59 commands, plus the 4 roster gates it marks as keeping a roster under one of these paths (`check:authz-resolver`, `check:changeset-fixed`, `check:error-code-casing`, `check:filter-alias-parity`). Each run alone, exit code captured before any pipe: - 61 of 63 exit 0 on the first run. - `node scripts/check-plugin-teardown-shape.mjs --self-test`: exit 3 on the shallow clone (its pinned fixture commit `621a4876` was not in the object store). After `git fetch origin 621a487`: exit 0, 48 cases pass. - `pnpm check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET (7 untouched packages had no `dist/`). After building those 7: exit 0, 112 require entry points across 69 packages load. `dispatch-gates --ran` with every exit code recorded: 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). `origin/main` moved 11 commits since the base, none touching `packages/services/service-print`, the print dogfood or `packages/cli/src/utils/console.ts`; `git merge-tree` of head with it is clean. Every family CI owns (Lint & Repo Gates, Test Core, the type-check lanes) is read from this PR's checks. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [ ] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kpFFUuQY5FA7FCdwY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 607d3bb commit 665059b

4 files changed

Lines changed: 129 additions & 13 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-print': patch
3+
---
4+
5+
fix(service-print): a server print works under the console's default Content Security Policy
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Every server print (`POST /api/v1/print/records`, `printRecordToFile`) failed on a deployment that keeps the console's default CSP, answering "The print engine failed … page.waitForFunction: EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive". The Chromium driver waited for the print page with a string predicate, which playwright-core evaluates with `eval` in the page on every poll; the console's `script-src` carries no `'unsafe-eval'` unless `OS_PAGE_REACT_ENABLED` is on, so the second poll was refused.
11+
12+
The driver now hands the wait a function predicate. playwright-core evaluates a function's text once, inside the DevTools call that starts the wait, which the page's CSP does not govern, and each poll calls the function it produced. The wait means exactly what it meant: a root carrying `data-print-page` and an idle console (`window.__objectui.idle`), held for three polls in a row, polled every 100 ms within the render's one budget. The console CSP is unchanged.

‎packages/services/service-print/src/chromium-driver.test.ts‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ import type { PrintSessionCookie } from './principal-session.js';
3939
* host's CJK font, every code point mapped; with Chromium's fonts narrowed to a
4040
* Latin-only fontconfig, none did.
4141
*
42+
* ## Under the console's CSP
43+
*
44+
* The console serves every page with a Content Security Policy whose
45+
* `script-src` carries no `'unsafe-eval'` by default (it is added only for the
46+
* `kind:'react'` page tier, `consoleContentSecurityPolicy` in
47+
* `packages/cli/src/utils/console.ts`). The fixture serves the same print page
48+
* under that directive too, so a ready wait that evaluates a string in the page
49+
* fails here with the EvalError a default deployment answers.
50+
*
4251
* ## Which Chromium
4352
*
4453
* `OS_TEST_PRINT_CHROMIUM_PATH`, else Playwright's provisioned browser
@@ -82,12 +91,17 @@ const COOKIE: PrintSessionCookie = {
8291
};
8392
const CJK_TEXT = '发票客户名称上海示例有限公司合计金额';
8493
const RECORD_PATH = '/_console/apps/showcase/invoice/record/inv_1';
94+
/** The same record page, served under the console's default CSP. */
95+
const CSP_RECORD_PATH = '/_console/apps/showcase/invoice/record/inv_csp';
96+
const CSP_NONCE = 'print-fixture-nonce';
97+
/** The console's default `script-src` (no `'unsafe-eval'`), with the rest of its policy's script-relevant directives. */
98+
const CONSOLE_DEFAULT_CSP = `default-src 'self'; script-src 'self' 'nonce-${CSP_NONCE}' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'`;
8599

86-
function printPageHtml(): string {
100+
function printPageHtml(nonce?: string): string {
87101
return `<!doctype html><html><head><meta charset="utf-8">
88102
<style>@media print { @page { size: A4 portrait; margin: 15mm; } nav { display: none; } }</style>
89103
</head><body><nav>console chrome</nav><div id="root"></div>
90-
<script>
104+
<script${nonce ? ` nonce="${nonce}"` : ''}>
91105
window.__objectui = { idle: false };
92106
setTimeout(() => {
93107
document.getElementById('root').innerHTML =
@@ -113,6 +127,11 @@ async function startFixture(): Promise<Fixture> {
113127
cookieSeen.push(cookie);
114128
res.setHeader('content-type', 'text/html; charset=utf-8');
115129
const signedIn = cookie.split(/;\s*/).includes(`${COOKIE.name}=${COOKIE.value}`);
130+
if (req.url === CSP_RECORD_PATH && signedIn) {
131+
res.setHeader('content-security-policy', CONSOLE_DEFAULT_CSP);
132+
res.end(printPageHtml(CSP_NONCE));
133+
return;
134+
}
116135
res.end(req.url === RECORD_PATH && signedIn ? printPageHtml() : SIGNED_OUT_HTML);
117136
});
118137
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
@@ -293,6 +312,17 @@ describe.skipIf(!CHROMIUM)('the Chromium engine on both placements', () => {
293312
expect(missing, 'Chinese characters printed as empty boxes (no embedded glyph maps to them)').toEqual([]);
294313
}, 60_000);
295314

315+
it('renders a page served under the console’s default CSP (no unsafe-eval): the ready wait evaluates no string in the page', async () => {
316+
const pdf = await pick().driver.renderPdf({
317+
url: `${fixture.origin}${CSP_RECORD_PATH}`,
318+
cookie: COOKIE,
319+
timeoutMs: 30_000,
320+
});
321+
expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-');
322+
const targets = toUnicodeTargets(pdf);
323+
expect([...CJK_TEXT].filter((ch) => !targets.has(ch.codePointAt(0)!))).toEqual([]);
324+
}, 60_000);
325+
296326
it('a page that never shows a print root is refused TIMEOUT / 504, not printed', async () => {
297327
const r = await refusalOf(
298328
pick().driver.renderPdf({

‎packages/services/service-print/src/chromium-driver.ts‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ interface ContextHandle {
9898
}
9999
interface PageHandle {
100100
goto(url: string, options: { waitUntil: 'load'; timeout: number }): Promise<unknown>;
101-
waitForFunction(fn: string, arg: undefined, options: { timeout: number; polling: number }): Promise<unknown>;
101+
waitForFunction(fn: (stablePolls: number) => boolean, arg: number, options: { timeout: number; polling: number }): Promise<unknown>;
102102
evaluate(fn: string): Promise<unknown>;
103103
pdf(options: { preferCSSPageSize: boolean; printBackground: boolean }): Promise<Buffer>;
104104
}
@@ -141,15 +141,29 @@ interface OpenBrowser {
141141
}
142142

143143
/**
144-
* The ready predicate, evaluated in the page. A string, not a function, so the
145-
* page sees exactly this text and nothing from this module's scope.
144+
* The ready predicate, run in the page on every poll. A function, never a
145+
* string, because of how playwright-core polls: it evaluates the predicate's
146+
* text with `eval` in the page. A string is evaluated again on every poll, from
147+
* a page timer, where the page's own CSP applies, so under the console's
148+
* default `script-src` (no `'unsafe-eval'`) the second poll throws EvalError
149+
* and every print fails. A function's text is evaluated once, inside the
150+
* DevTools call that starts the wait, which a page's CSP does not govern; each
151+
* poll then calls the function that evaluation produced.
152+
*
153+
* The page receives the function's text only, so it reads nothing from this
154+
* module's scope: the poll count arrives as its argument.
146155
*/
147-
const PRINT_READY_PREDICATE = `(() => {
148-
const ready = document.querySelector('[data-print-page]') !== null
149-
&& window.__objectui !== undefined && window.__objectui.idle === true;
150-
window.__objectstackPrintStable = ready ? (window.__objectstackPrintStable || 0) + 1 : 0;
151-
return window.__objectstackPrintStable >= ${STABLE_POLLS};
152-
})()`;
156+
function printReady(stablePolls: number): boolean {
157+
const page = globalThis as unknown as {
158+
document: { querySelector(selector: string): unknown };
159+
__objectui?: { idle?: unknown };
160+
__objectstackPrintStable?: number;
161+
};
162+
const ready = page.document.querySelector('[data-print-page]') !== null
163+
&& page.__objectui !== undefined && page.__objectui.idle === true;
164+
page.__objectstackPrintStable = ready ? (page.__objectstackPrintStable || 0) + 1 : 0;
165+
return page.__objectstackPrintStable >= stablePolls;
166+
}
153167

154168
const FONTS_READY = `document.fonts.ready.then(() => true)`;
155169

@@ -300,7 +314,7 @@ class PlaywrightChromiumDriver implements ChromiumDriver {
300314
await held.context.addCookies([cookieFor(input.url, input.cookie)]);
301315
const page = await held.context.newPage();
302316
await page.goto(input.url, { waitUntil: 'load', timeout: remaining() });
303-
await page.waitForFunction(PRINT_READY_PREDICATE, undefined, { timeout: remaining(), polling: POLL_INTERVAL_MS });
317+
await page.waitForFunction(printReady, STABLE_POLLS, { timeout: remaining(), polling: POLL_INTERVAL_MS });
304318
await page.evaluate(FONTS_READY);
305319
return page.pdf({ preferCSSPageSize: true, printBackground: true });
306320
})();

‎packages/services/service-print/src/chromium-driver.unit.test.ts‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, expect, it, vi } from 'vitest';
4-
import { createChromiumDriver, type ChromiumLauncher } from './chromium-driver.js';
4+
import { createChromiumDriver, STABLE_POLLS, type ChromiumLauncher } from './chromium-driver.js';
55
import type { PrintSessionCookie } from './principal-session.js';
66

77
/**
@@ -202,3 +202,63 @@ describe('the cleanup after the deadline is bounded, and nothing opened late is
202202
expect(browser.newContext).not.toHaveBeenCalled();
203203
});
204204
});
205+
206+
describe('the ready wait hands the page a function, never a string', () => {
207+
/**
208+
* playwright-core evaluates a string predicate with `eval` in the page on
209+
* EVERY poll, from a timer the page's CSP governs: under the console's
210+
* default `script-src` (no `'unsafe-eval'`) the second poll throws EvalError
211+
* and every print fails. CI has no Chromium for the render tests, so this is
212+
* the guard it runs.
213+
*/
214+
async function readyWait() {
215+
const { launcher, page } = stagedLauncher({});
216+
const driver = createChromiumDriver({ placement: 'remote', target: 'ws://127.0.0.1:9222/x', consoleUrl: 'x' }, launcher);
217+
await driver.renderPdf({ url: URL_, cookie: COOKIE, timeoutMs: 5_000 });
218+
expect(page.waitForFunction).toHaveBeenCalledTimes(1);
219+
const [predicate, arg, options] = page.waitForFunction.mock.calls[0] as unknown as [unknown, unknown, { timeout: number; polling: number }];
220+
return { predicate, arg, options };
221+
}
222+
223+
it('waitForFunction receives a function and the stable-poll count as its argument, polling every 100 ms within the budget', async () => {
224+
const { predicate, arg, options } = await readyWait();
225+
expect(typeof predicate).toBe('function');
226+
expect(arg).toBe(STABLE_POLLS);
227+
expect(options.polling).toBe(100);
228+
expect(options.timeout).toBeGreaterThan(0);
229+
expect(options.timeout).toBeLessThanOrEqual(5_000);
230+
});
231+
232+
it('the function means what the wait meant: a data-print-page root AND an idle console, held for STABLE_POLLS polls in a row', async () => {
233+
const { predicate, arg } = await readyWait();
234+
const poll = predicate as (stablePolls: unknown) => boolean;
235+
const g = globalThis as unknown as Record<string, unknown>;
236+
const saved = { document: g.document, __objectui: g.__objectui, stable: g.__objectstackPrintStable };
237+
let root: unknown = null;
238+
g.document = { querySelector: (selector: string) => (selector === '[data-print-page]' ? root : null) };
239+
try {
240+
// No console yet, then a console that is idle before any print root exists.
241+
expect(poll(arg)).toBe(false);
242+
g.__objectui = { idle: true };
243+
expect(poll(arg)).toBe(false);
244+
// The root appears, but a request is in flight.
245+
root = {};
246+
g.__objectui = { idle: false };
247+
expect(poll(arg)).toBe(false);
248+
g.__objectui = { idle: true };
249+
const run = Array.from({ length: STABLE_POLLS }, () => poll(arg));
250+
expect(run).toEqual([...Array(STABLE_POLLS - 1).fill(false), true]);
251+
// A not-ready poll resets the run.
252+
g.__objectui = { idle: false };
253+
expect(poll(arg)).toBe(false);
254+
g.__objectui = { idle: true };
255+
expect(Array.from({ length: STABLE_POLLS }, () => poll(arg))[STABLE_POLLS - 1]).toBe(true);
256+
expect(poll(arg)).toBe(true);
257+
} finally {
258+
for (const [key, value] of [['document', saved.document], ['__objectui', saved.__objectui], ['__objectstackPrintStable', saved.stable]] as const) {
259+
if (value === undefined) delete g[key];
260+
else g[key] = value;
261+
}
262+
}
263+
});
264+
});

0 commit comments

Comments
 (0)