Skip to content

Commit 97ca257

Browse files
feat(runtime): a sandboxed body can do time-zone date maths, through Intl.DateTimeFormat (#29031)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X5pePkMPaas25B8dVLe6RiN)_ Fixes #28898 Clause-②: no No published schema, type, export or error code moves. The one `packages/spec` change is the job `handler` key's `.describe()` text (and the reference page regenerated from it); the accept set is identical. The sandbox's globals are not declared by any spec schema: `ScriptBodySchema.source` is a string, and what it may call is the runtime's answer, which is what changes here. Metadata-protocol: none What a metadata author can write is unchanged: no key, value, type or default moves. A body `source` that calls `Intl.DateTimeFormat` was already accepted at every door; it now runs instead of throwing `ReferenceError`. ## 维护者速读 - 改了什么:沙箱(QuickJS)里有了 `Intl.DateTimeFormat`,完整的一整个构造器(`format`/`formatToParts`/`formatRange`/`formatRangeToParts`/`resolvedOptions`/`supportedLocalesOf`),结果由服务器自己的 `Intl` 算出,所以 IANA 时区和夏令时边界与服务器一致。`Intl` 的其它成员都不存在。钩子、动作、作业三种 body 共用同一个 runner,所以三者同时获得。`os build` 的降级判断随之按成员判:只用 `Intl.DateTimeFormat` 的 handler 现在能降级成 body,用 `Intl.NumberFormat` 等仍打包。作业 `handler` 的弃用说明补了一句"时区日期计算可用 `Intl.DateTimeFormat` 迁到 `body`"。 - 为什么改:duly#147 的派单作业要按每个职责自己的时区算当前周期,沙箱没有 `Intl`,作业就离不开已弃用的 `handler`。席位裁定走纯运行时路线,不给 `ctx` 加日期助手,作业上下文契约不动。 - 风险与回滚:跨边界只有纯值(一个 JSON 字符串 + 最多两个毫秒数进,一个 JSON 字符串出,在沙箱内解析),桥是闭包参数而非全局,"现在"取沙箱自己的 `Date.now()`,无新 I/O。回滚即 revert,无数据迁移。 - 先看:`packages/runtime/src/sandbox/sandbox-intl.ts`(垫片与宿主侧的值清洗、错误转译);`packages/cli/src/utils/detect-free-identifiers.ts` 的 `MEMBER_JUDGED_GLOBALS` 与 `collectReferences` 的成员分支;`packages/cli/src/utils/sandbox-globals-probe.test.ts` 的点号探针。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/sandbox/sandbox-intl.test.ts`,预期 `Tests 12 passed (12)`。 - 需要你做的:无,席位落地。 ## What changes - **`packages/runtime/src/sandbox/sandbox-intl.ts`** (new): `installSandboxIntl(vm)` installs `globalThis.Intl` (non-enumerable, like the built-in) with one member, `DateTimeFormat`: callable with or without `new`, `supportedLocalesOf`, and the whole prototype (`format` as a bound getter, `formatToParts`, `formatRange`, `formatRangeToParts`, `resolvedOptions`, `Symbol.toStringTag`). The VM-side constructor keeps only a plain-value snapshot of `locales` and of the option keys `DateTimeFormat` reads; every call asks the host to answer with a host `Intl.DateTimeFormat` built from that snapshot (a per-invocation cache of at most 64 formatters, dropped with the VM). - In: an op name, ONE JSON string, at most two numbers. The host re-reads the JSON and keeps only the known option keys with primitive values. - Out: one JSON string, parsed inside the VM, so `resolvedOptions()` and the parts arrays are VM objects. A host error becomes a VM `RangeError` / `TypeError` with the host's message: the body's own throw, never a host refusal relayed. - The bridge is a closure argument of the shim (no global), and the shim captures `JSON`, `Date.now` and friends at install, so a body replacing them cannot steer the host. `format()` with no argument takes "now" from the VM's own clock. - **`packages/runtime/src/sandbox/quickjs-runner.ts`**: `execute()` calls `installSandboxIntl(vm)` right after `installCtx`. Every body kind (hook, action, job, and the L1 expression path) goes through `execute()`, so this is one seam and one global set. - **`packages/cli/src/utils/detect-free-identifiers.ts`**: the measured allowlist learns member granularity. New `MEMBER_JUDGED_GLOBALS = {'Intl'}`; `collectReferences` records a non-computed `Intl.NAME` read as `Intl.NAME`; `SANDBOX_GLOBALS` gains `Intl.DateTimeFormat`; `NODE_ONLY_GLOBALS` swaps `Intl` for the ten other `Intl` members Node 22 has. A bare `Intl` use (`const I = Intl`, `Intl[k]`) or an unlisted member is reported host-only, so that handler stays bundled (the safe direction). A local or parameter named `Intl` still binds it. - **`packages/cli/src/utils/extract-hook-body.ts`**: the host-only refusal sentence says "of `Intl` it provides `Intl.DateTimeFormat` only" when an `Intl` name is refused, and "would throw" where it said "would throw ReferenceError" (a missing member throws `TypeError`). - **`packages/spec/src/system/job.zod.ts`**: the `handler` docblock and `.describe()` (the deprecation message) say time-zone date maths moves to `body` through `Intl.DateTimeFormat`. `content/docs/references/system/job.mdx` regenerated (`gen:schema && gen:docs`). - **`content/docs/automation/jobs.mdx`**: one bullet under "What running in the sandbox means": `Intl.DateTimeFormat` is there whole, nothing else of `Intl` is, the feature test, and that hook and action bodies share it. - **`.changeset/28898-sandbox-intl-date-time-format.md`**: `@objectstack/runtime`, `@objectstack/cli`, `@objectstack/spec` patch; both declaration lines. ## Tests - New `packages/runtime/src/sandbox/sandbox-intl.test.ts` (12, real QuickJS). Every expected value is computed with the host's `Intl` in the test, never a literal (CI's ICU differs from a container's). - The card reproduction: `formatToParts` of two instants either side of New York's 2026-03-08 spring-forward, in `America/New_York` and `Asia/Shanghai`, equals the host's (anti-vacuity: the zone name changes in New York and not in Shanghai). - `format`, `resolvedOptions`, `formatRange`, `formatRangeToParts` equal the host's; the period-start shape (calendar date of one instant in two zones: `2026-03-08` vs `2026-03-09`); callable without `new`, bound `format` getter, `instanceof`, both `toStringTag`s, `supportedLocalesOf`; options read at construction. - Errors: an invalid `timeZone` throws the host's `RangeError` text as a VM `RangeError` (and uncaught, fails the run with that text); `format(NaN)`, `formatRange(0)`, `formatToParts('not a date')` throw what the host throws. - Absent, not half-working: `Object.getOwnPropertyNames(Intl)` is `['DateTimeFormat']`, `typeof Intl.NumberFormat` is `'undefined'`, `Intl` is not enumerable. - Isolation: answers are VM-made (`Object.prototype` / `Array.prototype`), fresh per call; no global named like the bridge; a foreign receiver throws `TypeError`; replacing `JSON.stringify` / `Date.now` steers nothing. - One seam: a hook body and an action body answer the same. Through the job door: a `jobBodyRunnerFactory` job reports `period 2026-03-09`. - New `packages/qa/dogfood/test/job-body-time-zone-date.dogfood.test.ts` (1): a booted stack declares a job whose `body` inserts each zone's calendar date of one instant; the job service triggers it, and `GET /data/tzj_period` reads both zones' own dates (anti-vacuity: the two dates differ; the job is listed as scheduled first). - cli: `detect-free-identifiers.test.ts` (+3: `Intl.DateTimeFormat` has no free name; the bare namespace is reported; an unlisted member is reported), `extract-hook-body.test.ts` (+1: a handler using `Intl.DateTimeFormat` lowers), `hook-body-lowering.test.ts` (+1: no finding for it), `sandbox-globals-probe.test.ts` (dotted names probed as namespace-then-member; +1: every member-judged namespace measures present and owns every dotted member). The #14301 refusal pins move from `Intl.DateTimeFormat` to `Intl.NumberFormat`, a member the sandbox still lacks, so the class stays pinned. <details><summary>Red first, green, ablations</summary> - Red on base `a3640933` (new runtime test, unchanged source): `Tests 12 failed (12)`, each `ReferenceError: 'Intl' is not defined` (the hook case: `hook 't' threw: ReferenceError: 'Intl' is not defined`). - Green: `src/sandbox/` `Test Files 35 passed (35)`, `Tests 341 passed (341)`. - Ablation, runtime (`scripts/ablation-replace.mjs`, anchor `installSandboxIntl(vm);` hit once, blob `f779c2c2` to `8af3c789`; the test imports `src`): `Test Files 1 failed | 34 passed (35)`, `Tests 12 failed | 329 passed (341)`: exactly the 12 Intl cases. Restored: blob `f779c2c2` == HEAD, `git diff HEAD` empty. - Ablation, cli (anchor `'Intl.DateTimeFormat',` deleted from `SANDBOX_GLOBALS`, the four files, all `src`): `Tests 4 failed | 136 passed (140)`: the three new lower-it pins and the named-cases pin. The partition pin stays green, as it must: a name removed from a set is no longer probed, which is why the named-cases pin exists. Restored: blob `7df744bf` == HEAD, `git diff HEAD` empty. - Dogfood, red leg on a rebuilt dist (call replaced by `void installSandboxIntl;`, blob `f779c2c2` to `a8815ee9`; `pnpm --filter @objectstack/runtime build`; `ablation-dist-preflight` "marker absent from all 12 built files"): `Tests 1 failed (1)`, the job logged `job 'tzj_period_stamp' threw: ReferenceError: 'Intl' is not defined` and the read was `{}`. Restored: blob == HEAD, `git diff HEAD` empty; restore leg rebuilt, preflight "marker present in 2 built files", "working tree clean against HEAD", `Tests 1 passed (1)`. </details> ## Acceptance notes - **Hook and action bodies share the runner** (measured: every production call site of `hookBodyRunnerFactory`, `actionBodyRunnerFactory` and `jobBodyRunnerFactory`, in `app-plugin.ts` and `app-artifact-handlers.ts`, hands the body to a `QuickJSScriptRunner`, one class in separate instances, and `QuickJSScriptRunner.execute()` is the only place a VM is made; `git grep "new QuickJSScriptRunner"` outside tests: 5 hits, all those factories), so they get the same `Intl.DateTimeFormat`, as the order rules. The L1 expression path goes through `execute()` too. - **Why the cli moved in this PR.** `sandbox-globals-probe.test.ts` measures the sandbox's globals against the lowering allowlist, so the runtime change alone reds it (`Intl` measures present). Moving `Intl` wholesale to the sandbox set would have lowered a handler using `Intl.NumberFormat` into a body that throws, the #14301 failure class; leaving it host-only would keep printing "Intl is not provided by the sandbox", which this change makes false. Judging per member is the smallest honest change, and it is why `DateTimeFormat` is whole: a lowered handler calling `formatRange` must not throw where the in-process one works. - **Published value change (cli `hook-body` subpath).** `HookBodyExtractionError.freeIdentifiers` / `nodeOnlyIdentifiers` now name the member (`Intl.NumberFormat`) where they named `Intl`; the error's shape and members are unchanged (`published-subpath-hook-body.pin.test.ts` green). The refusal sentence's host-only branch changed as listed above; the module-scope sentence the docs quote is byte-identical (its pin is green). - **What a body can learn.** `new Intl.DateTimeFormat().resolvedOptions()` reports the server's default locale and time zone, which a body could not read before. Values, not objects; noted for the seat, not treated as a leak. - **Not done, out of scope.** QuickJS's own `Date.prototype.toLocaleString(locale, { timeZone })` exists and ignores its arguments; a handler using it lowers today and formats in the server zone. Not changed here (not class a/b/c with a measured reach). Dedupe words: toLocaleString timeZone sandbox ignored. - **Overlap.** Before opening: none of the 31 open PRs touches `packages/runtime/src/sandbox/**`, the four cli files, `system/job.zod.ts` or `automation/jobs.mdx` (file lists read per PR); search for `quickjs`, `sandbox`, `detect-free-identifiers`, `Intl`, `job body`, `hook body` among open PRs found no related one. ## Verification Run on `aed5e295` (base `a3640933`). Each test command under `os-verify-lock` (`VERDICT command-exit 0` unless stated). <details><summary>Package suites, type checks, lint</summary> - Builds: `pnpm --filter '@objectstack/cli^...' build` exit 0; `pnpm --filter '@objectstack/dogfood^...' build` exit 0; `pnpm --filter @objectstack/spec build` exit 0. - `pnpm --filter @objectstack/runtime test`: `Test Files 514 passed (514)`, `Tests 8279 passed | 19 skipped (8298)`. - `@objectstack/cli` unit tier (`vitest run --project unit`): `Test Files 366 passed`, 2 failed for `packages/cli is not built` (the two `published-subpath-*.pin` files); after `pnpm --filter @objectstack/cli build` those two: `Test Files 2 passed (2)`, `Tests 29 passed (29)`. So 368 of 368 files green. The integration tier is declared to CI (no integration-tier file or spawn entry touched). - `pnpm --filter @objectstack/runtime typecheck` and `pnpm --filter @objectstack/cli typecheck`: exit 0, both `check:test-typecheck: OK`. - Dogfood: the new file `Tests 1 passed (1)` on the built dist. The full battery is not owed (no `!` in the title, no narrowing arm). - `eslint --no-inline-config` over the eleven changed source and test files: exit 0. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` (derived on `aed5e295` against merge base `a3640933`): 108 families, each run with `timeout 900 bash -c CMD` from `/dev/null`, exit captured before any pipe; record `pm-orders/server/28898-gates-ran.txt` (`CMD :: exit N`). Reconciled: `✓ dispatch-gates --ran: 108 derived famil(ies) accounted for — 108 run, 0 NOT-MEASURED (a DERIVED zero — all 108 recorded an exit code and none of them is 3).` All 108 exit 0, among them `check:docs` (`235 generated files in sync`), `check:generated` (`All 14 generated artifacts are up to date`), `check:authorable-surface`, `check:api-surface`, `check:type-check-debt`, `check:nul-bytes`, `check:test-source-alias`, `check:i18n-coverage`, `check:dual-build-cjs-loads` and `check:skill-examples`. A first pass on `b4099da2` (before the dogfood file) read 104 at 0 and 4 at 3 (prerequisite not met: the plugin-teardown self-test needed its pinned commit fetched; the other three needed builds of packages outside this diff); the final pass above ran after `git fetch --depth=1` of that commit and the dogfood closure build. Families CI owns (`Test Core`, the type-check lanes, `pnpm lint`) are read on this PR's check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5paXMpX75J4HCKYU6Z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5d3667e commit 97ca257

14 files changed

Lines changed: 860 additions & 33 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/cli': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
feat(runtime): a sandboxed body can do time-zone date maths, through `Intl.DateTimeFormat`
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
The body sandbox (QuickJS) had no `Intl`, so a job `body` could not tell which calendar day or period an instant falls in for a named IANA zone. A job that needs that kept the deprecated `handler`. The sandbox now has `globalThis.Intl` with one member, `DateTimeFormat`, and it is the whole constructor: `new Intl.DateTimeFormat(locales, { timeZone, ... })` (or without `new`), `supportedLocalesOf`, and `format` (a bound getter), `formatToParts`, `formatRange`, `formatRangeToParts` and `resolvedOptions`. Every answer comes from the server's own `Intl`, so the output matches the server's for the same input, IANA zones and DST edges included. An invalid `timeZone` throws the server's `RangeError` text as a `RangeError` inside the body. Nothing else of `Intl` exists (`Intl.NumberFormat` is `undefined`), so test for it with `typeof Intl.DateTimeFormat === 'function'`.
13+
14+
**Hooks and actions too.** Hook, action and job bodies run through one sandbox runner, so all three get the same `Intl.DateTimeFormat`.
15+
16+
**Isolation is unchanged.** Only plain values cross the boundary: a snapshot of the locales and the options `DateTimeFormat` reads, and epoch-millisecond numbers in, one JSON string out, parsed inside the sandbox. No host object reaches a body, the bridge is not a global, and "now" for `format()` comes from the sandbox's own `Date.now()`. There is no new I/O.
17+
18+
**`os build` lowering (cli).** A hook or action `handler` function that uses `Intl.DateTimeFormat` now lowers to a metadata `body` instead of being bundled, and `os lint` stops reporting it under `hook-body/not-lowerable`. The rest of `Intl` is judged per member: a handler using `Intl.NumberFormat`, or the bare `Intl` name in a way the build cannot follow (`const I = Intl`, `Intl[k]`), is still refused and bundled, and the refusal names the member, for example `Intl.NumberFormat`, where it used to name `Intl`. The refusal sentence now says the sandbox provides `Intl.DateTimeFormat` only of `Intl`, and says the body "would throw" where it said "would throw ReferenceError".
19+
20+
**Migration text (spec).** The job `handler` description, which marks `handler` deprecated, now says time-zone date maths moves to `body` too, through `Intl.DateTimeFormat`. The accepted metadata is unchanged.

‎content/docs/automation/jobs.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,14 @@ What running in the sandbox means for the code in `source`:
191191
`ctx.api.object(…)`, and only with the tokens it declares: `api.read`,
192192
`api.write`, `api.transaction`. Logging is `ctx.log`, under `log`. An undeclared
193193
call throws at run time. Outbound HTTP is not available; use a Connector.
194+
- **Time-zone date maths works.** `Intl.DateTimeFormat` is in the sandbox, whole
195+
(`format`, `formatToParts`, `formatRange`, `formatRangeToParts`,
196+
`resolvedOptions`), and answers exactly as the server's own does, so a body can
197+
find the calendar date or period of an instant in a named IANA zone:
198+
`new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(Date.now())`.
199+
Nothing else of `Intl` is there (`Intl.NumberFormat` is `undefined`); test for
200+
it with `typeof Intl.DateTimeFormat === 'function'`. Hook and action bodies run
201+
in the same sandbox and get the same `Intl.DateTimeFormat`.
194202
- **Not the handler's context.** The `JobHandlerContext` a `handler` receives —
195203
`ql`, `logger`, `bundle` — does not exist inside the sandbox. A handler is not
196204
turned into a body by copying its source: rewrite its `ql.find(…)` reads as

‎content/docs/references/system/job.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const result = CronScheduleSchema.parse(data);
5757
| **label** | `string` | optional | Human-readable label |
5858
| **description** | `string` | optional | Job description / purpose |
5959
| **schedule** | `{ type: 'cron'; expression: string \| object; timezone?: string } \| { type: 'interval'; intervalMs: integer } \| { type: 'once'; at: string }` | ✅ | Job schedule configuration |
60-
| **handler** | `string` | optional | Handler function name (must match a key in `defineStack({ functions })`) — DEPRECATED, prefer `body`. When both are present `body` wins; refused beside `pull`. A job must declare one of `body`, `handler` or `pull`. |
60+
| **handler** | `string` | optional | Handler function name (must match a key in `defineStack({ functions })`) — DEPRECATED, prefer `body`. When both are present `body` wins; refused beside `pull`. A job must declare one of `body`, `handler` or `pull`. Time-zone date maths moves to `body` too: `Intl.DateTimeFormat` with a `timeZone` option is available in the sandbox (nothing else of `Intl` is). |
6161
| **body** | `{ language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Job body — a sandboxed JS (L2) body, the same shape hooks and actions use; an expression (L1) body is refused, because a job runs for its effects and an expression has none. Preferred over `handler`: when both are present `body` wins. It runs in the QuickJS sandbox with no module scope (no imports, no helpers or constants from the surrounding file): it reaches data only through `ctx.api` under its declared `capabilities` (`api.read` / `api.write` / `api.transaction`) and logs through `ctx.log` (`log`); the in-process handler context (`ql`, `logger`, `bundle`) does not exist there. Its time limit is the job's `timeoutMs` (see there): long-running work declares a `timeoutMs` that covers it, or splits into bounded runs that each finish within it. Every door that brings an artifact in schedules a job's `body` — the boot, and `os package install` on install and on every restart — while a `handler` is code that travels only in the artifact's runtime module and runs only on a boot that loads it (a config, or `os start --artifact`); `os package install` therefore refuses an enabled job with no `body`. A `pull` is data too, so an enabled `pull` job is judged by its `pull` instead: it installs when the `pull` binds (it names a mapping the package declares, with a `connectorSource`) and is refused when it does not, as is a job whose `body` the declaration refuses. Refused beside `pull`. |
6262
| **pull** | `{ mapping: string }` | optional | Pull run form: on each run the platform pulls the named mapping's `connectorSource` (one action call, one response) and writes the rows through the import runner — no code. A refused pull records the run `failed` (retried per `retryPolicy`); a pull whose rows the import runner refused records it `degraded`. Data like `body`, so every door schedules it. Refused beside `body` or `handler`. |
6363
| **organization** | `string` | optional | Organization id (sys_organization.id) this job runs as — its `body`'s `ctx.api`, the execution context its `handler` is handed, and its `pull`'s reads and writes alike, as a system run carrying that organization. A scheduled run has no session to inherit one from. Judged at bind by the posture rule scheduled flows use: required under the isolated tenancy posture (a job that declares none is not scheduled); optional under group (undeclared, the run carries no organization and a tenant-scoped write it makes is refused); not required under single (the install's one organization is resolved beneath each write). Where declared, it is the organization the run acts as on every posture. |

‎packages/cli/src/lint/hook-body-lowering.test.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,26 @@ const forbiddenTokenHook = {
4747
// [#14301] A global the NODE HOST provides and the sandbox does not. Reads as
4848
// self-contained — `Intl` is standard in every browser and in Node — which is
4949
// precisely why it is the ACCIDENTAL class and not the structural one.
50+
// [#28898] Pinned on `Intl.NumberFormat`: the sandbox provides
51+
// `Intl.DateTimeFormat` now, and nothing else of `Intl`.
5052
const nodeOnlyGlobalHook = {
5153
name: 'stamp_due_label',
5254
object: 'task',
5355
events: ['beforeInsert'],
5456
handler: (ctx: any) => {
55-
const f = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' });
56-
ctx.input.due_label = f.format(new Date(ctx.input.due_at));
57+
const f = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
58+
ctx.input.due_label = f.format(ctx.input.amount);
59+
},
60+
};
61+
62+
// [#28898] The same handler shape on the member the sandbox DOES provide.
63+
const sandboxDateFormatHook = {
64+
name: 'stamp_due_day',
65+
object: 'task',
66+
events: ['beforeInsert'],
67+
handler: (ctx: any) => {
68+
const f = new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai' });
69+
ctx.input.due_day = f.format(new Date(ctx.input.due_at));
5770
},
5871
};
5972

@@ -119,6 +132,10 @@ describe('checkHookBodyLowering', () => {
119132
expect(issues[0].message).toContain('deployment shape');
120133
});
121134

135+
it('reports nothing for Intl.DateTimeFormat, which the sandbox provides (#28898)', () => {
136+
expect(checkHookBodyLowering({ hooks: [sandboxDateFormatHook] })).toEqual([]);
137+
});
138+
122139
it('prints the remedy that is POSSIBLE, not the inline one', () => {
123140
// The wrong-remedy failure is worse than none: there is nothing to inline
124141
// `Intl` from, so an author (or a code-writing model) told to inline it

‎packages/cli/src/utils/detect-free-identifiers.test.ts‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -198,25 +198,54 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
198198
// membership of the two sets is not asserted here from knowledge — it is
199199
// measured inside the real sandbox by `sandbox-globals-probe.test.ts`.
200200
describe('reports host globals the sandbox does not provide', () => {
201-
it('the card reproduction — Intl.DateTimeFormat', () => {
201+
// The #14301 reproduction called `Intl.DateTimeFormat`, which the sandbox
202+
// has provided since #28898 (below). The class it pinned is unchanged and
203+
// is pinned on a member the sandbox still lacks.
204+
it('the card reproduction — an Intl member the sandbox lacks', () => {
202205
const r = detectFreeIdentifiers(
203-
"(ctx) => { const f = new Intl.DateTimeFormat('en-US'); ctx.input.label = f.format(new Date(ctx.input.at)); }",
206+
"(ctx) => { const f = new Intl.NumberFormat('en-US'); ctx.input.label = f.format(ctx.input.amount); }",
204207
);
205208
expect(r.unparsed).toBe(false);
206-
expect(r.free).toEqual(['Intl']);
207-
expect(r.nodeOnly).toEqual(['Intl']);
209+
expect(r.free).toEqual(['Intl.NumberFormat']);
210+
expect(r.nodeOnly).toEqual(['Intl.NumberFormat']);
208211
});
209212

210213
it('`nodeOnly` is a labelled SUBSET of `free`, not a second list', () => {
211214
// Both halves at once. The caller needs them apart because the remedies
212215
// are opposite — inline the helper, but a host global cannot be inlined
213216
// — and needs them together because the refusal names every name.
214217
const r = detectFreeIdentifiers('(ctx) => { ctx.x = slugify(ctx.name) + Intl.NumberFormat; }');
215-
expect(r.free).toEqual(['Intl', 'slugify']);
216-
expect(r.nodeOnly).toEqual(['Intl']);
218+
expect(r.free).toEqual(['Intl.NumberFormat', 'slugify']);
219+
expect(r.nodeOnly).toEqual(['Intl.NumberFormat']);
217220
expect(r.nodeOnly.every((n) => r.free.includes(n))).toBe(true);
218221
});
219222

223+
// ── #28898 — `Intl` is judged per member ────────────────────────────────
224+
it('Intl.DateTimeFormat is sandbox-provided, so a handler using only it has no free name', () => {
225+
const r = detectFreeIdentifiers(
226+
"(ctx) => { const f = new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai' }); ctx.input.label = f.formatToParts(new Date(ctx.input.at)).map((p) => p.value).join(''); }",
227+
);
228+
expect(r.free).toEqual([]);
229+
expect(r.nodeOnly).toEqual([]);
230+
});
231+
232+
it('a use of the bare namespace cannot be judged, so it is reported — the safe direction', () => {
233+
for (const source of [
234+
'(ctx) => { const I = Intl; ctx.x = new I.DateTimeFormat().format(0); }',
235+
"(ctx) => { ctx.x = new Intl['DateTimeFormat']().format(0); }",
236+
]) {
237+
const r = detectFreeIdentifiers(source);
238+
expect(r.free, source).toEqual(['Intl']);
239+
expect(r.nodeOnly, source).toEqual(['Intl']);
240+
}
241+
});
242+
243+
it('a member neither set lists is reported as host-only, not waived', () => {
244+
const r = detectFreeIdentifiers('(ctx) => { ctx.x = Intl.SomeFutureFormat; }');
245+
expect(r.free).toEqual(['Intl.SomeFutureFormat']);
246+
expect(r.nodeOnly).toEqual(['Intl.SomeFutureFormat']);
247+
});
248+
220249
it('a sandbox-provided global is still waived — the positive control', () => {
221250
const r = detectFreeIdentifiers('(ctx) => { ctx.x = JSON.stringify(Math.round(ctx.n)); }');
222251
expect(r.free).toEqual([]);

‎packages/cli/src/utils/detect-free-identifiers.ts‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ export const SANDBOX_GLOBALS: ReadonlySet<string> = new Set([
7272
'encodeURIComponent', 'decodeURIComponent', 'encodeURI', 'decodeURI',
7373
// Literal-ish globals
7474
'undefined', 'NaN', 'Infinity', 'globalThis',
75+
// A MEMBER of a {@link MEMBER_JUDGED_GLOBALS} namespace. The sandbox installs
76+
// `Intl` with this one constructor, whole (every prototype method), and
77+
// backs it with the host's (#28898) — so a handler formatting a date in an
78+
// IANA zone lowers and runs the same in both places.
79+
'Intl.DateTimeFormat',
7580
]);
7681

7782
/**
@@ -92,9 +97,12 @@ export const SANDBOX_GLOBALS: ReadonlySet<string> = new Set([
9297
* measured by the probe, never an edit here.
9398
*/
9499
export const NODE_ONLY_GLOBALS: ReadonlySet<string> = new Set([
95-
// ECMA-402. Standard in every browser and in Node; absent from this QuickJS
96-
// build. The name the defect was reported under.
97-
'Intl',
100+
// ECMA-402. Standard in every browser and in Node; the sandbox's `Intl`
101+
// carries `DateTimeFormat` only (#28898), so every other member is absent.
102+
// `Intl` was the name the #14301 defect was reported under.
103+
'Intl.Collator', 'Intl.DisplayNames', 'Intl.ListFormat', 'Intl.Locale',
104+
'Intl.NumberFormat', 'Intl.PluralRules', 'Intl.RelativeTimeFormat', 'Intl.Segmenter',
105+
'Intl.getCanonicalLocales', 'Intl.supportedValuesOf',
98106
// Host/Web platform additions, not ECMAScript. QuickJS is the language, not
99107
// the platform — nothing installs these into the VM.
100108
'structuredClone', 'queueMicrotask', 'atob', 'btoa',
@@ -113,6 +121,22 @@ export const NODE_ONLY_GLOBALS: ReadonlySet<string> = new Set([
113121
'arguments',
114122
]);
115123

124+
/**
125+
* Namespaces the sandbox provides IN PART, so a reference to one is judged per
126+
* member rather than per name (#28898). `Intl.DateTimeFormat` resolves in the
127+
* sandbox and `Intl.NumberFormat` does not, so "is `Intl` in scope?" has no
128+
* single answer: {@link collectReferences} records a non-computed member read
129+
* as `Intl.NAME` and judges that against {@link SANDBOX_GLOBALS} /
130+
* {@link NODE_ONLY_GLOBALS}. Any other use of the bare name — `const I = Intl`,
131+
* `Intl[k]`, passing it along — cannot be judged statically, so it is reported
132+
* as `Intl` and the handler stays bundled: the safe direction.
133+
*
134+
* Measured like the other two sets: `sandbox-globals-probe.test.ts` requires
135+
* each member here to be PRESENT in the sandbox, and a member it reaches
136+
* through to be present or absent as the two sets say.
137+
*/
138+
export const MEMBER_JUDGED_GLOBALS: ReadonlySet<string> = new Set(['Intl']);
139+
116140
export interface FreeIdentifierResult {
117141
/**
118142
* Sorted, de-duplicated names the handler references and NOTHING in the
@@ -268,6 +292,16 @@ function collectReferences(fn: ts.FunctionLikeDeclarationBase): Set<string> {
268292
if (ts.isTypeNode(node)) return;
269293

270294
if (ts.isPropertyAccessExpression(node)) {
295+
// [#28898] `Intl.DateTimeFormat` — a member of a namespace the sandbox
296+
// provides in part is the reference, judged as `Intl.NAME`.
297+
if (
298+
ts.isIdentifier(node.expression) &&
299+
MEMBER_JUDGED_GLOBALS.has(node.expression.text) &&
300+
ts.isIdentifier(node.name)
301+
) {
302+
refs.add(`${node.expression.text}.${node.name.text}`);
303+
return;
304+
}
271305
// `a.b` — visit `a` (could be a ref) but NOT `b` (member name).
272306
walk(node.expression);
273307
return;
@@ -346,13 +380,18 @@ export function detectFreeIdentifiers(rawFunctionSource: string): FreeIdentifier
346380
const free: string[] = [];
347381
const nodeOnly: string[] = [];
348382
for (const name of refs) {
349-
if (bound.has(name)) continue;
383+
// A member reference (`Intl.NAME`) is bound when its namespace name is: a
384+
// local or parameter called `Intl` is the author's, not the host's.
385+
const root = name.split('.', 1)[0];
386+
if (bound.has(root)) continue;
350387
// Only the SANDBOX set waives a name. A `NODE_ONLY_GLOBALS` member falls
351388
// through to `free` on purpose — that is the whole fix — and is ALSO
352-
// recorded in `nodeOnly` so the refusal can name the right remedy.
389+
// recorded in `nodeOnly` so the refusal can name the right remedy. So is
390+
// any other use of a member-judged namespace (the bare name, or a member
391+
// neither set lists): the host has it, the sandbox is not known to.
353392
if (SANDBOX_GLOBALS.has(name)) continue;
354393
free.push(name);
355-
if (NODE_ONLY_GLOBALS.has(name)) nodeOnly.push(name);
394+
if (NODE_ONLY_GLOBALS.has(name) || MEMBER_JUDGED_GLOBALS.has(root)) nodeOnly.push(name);
356395
}
357396
free.sort();
358397
nodeOnly.sort();

‎packages/cli/src/utils/extract-hook-body.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,11 +711,16 @@ function freeIdentifierRefusal(
711711
`inline them into the handler or move the logic behind \`ctx\` (e.g. \`ctx.api\`).`
712712
: '';
713713
const hint = nodeOnly.map((n) => SANDBOX_REPLACEMENTS[n]).filter(Boolean).join(' ');
714+
// [#28898] The sandbox's `Intl` is partial, so a refused `Intl` name says
715+
// which part exists: "does not provide Intl" alone is false of the namespace.
716+
const intlNote = nodeOnly.some((n) => n === 'Intl' || n.startsWith('Intl.'))
717+
? ' (of `Intl` it provides `Intl.DateTimeFormat` only)'
718+
: '';
714719

715720
return (
716721
head +
717722
`${names} ${isAre} not available in the hook sandbox — the QuickJS build the runtime evaluates a ` +
718-
`lowered body in does not provide ${itThem}, so the body would throw ReferenceError in production ` +
723+
`lowered body in does not provide ${itThem}${intlNote}, so the body would throw in production ` +
719724
`while validate, typecheck, test and build all stay green (they run the raw function in Node, ` +
720725
`where ${itThem} exist${nodeOnly.length === 1 ? 's' : ''}). This handler will be BUNDLED instead ` +
721726
`(no behavior change). To keep it as metadata, keep the check in a string handler ref — put the ` +

0 commit comments

Comments
 (0)