Skip to content

feat(runtime): ScriptContext.user 由 unknown 收窄为 ScriptUser 联合 (#5521) - #6295

Merged
qq9340100 merged 1 commit into
mainfrom
claude/issue-5521-scriptcontext-user-type
Aug 7, 2026
Merged

feat(runtime): ScriptContext.user 由 unknown 收窄为 ScriptUser 联合 (#5521)#6295
qq9340100 merged 1 commit into
mainfrom
claude/issue-5521-scriptcontext-user-type

Conversation

@qq9340100

Copy link
Copy Markdown
Collaborator

Fixes #5521

沙箱接缝 ScriptContextuser 字段此前是 unknown,类型系统对它一无所知。issue 说得很准:第四个 dispatch 面明天再手搓一个 user 字面量,编译器不会说一句话 —— 而"三个 dispatcher 手搓出三种形状"正是 #5372 的成因,它能存在几个版本,部分原因就是没有任何声明可以违背

本 PR 按 #5991 的模子,把它收窄成命名联合 ScriptUser = ActorUser | HookContext['user']行为零变化,纯类型面。


一、测量(#5991 同一测法,针对 user 面 —— 先测后改)

基线 origin/main @ f6609e6ae

写入方(2 处,均在 packages/runtime/src/sandbox/body-runner.ts)

# 位置 表达式 第一支的真实生产者 第二支
W1 body-runner.ts:315 buildSandboxContext engineCtx?.user ?? engineCtx?.session?.user HookContext['user'] — ObjectQL buildUser()(packages/objectql/src/engine.ts:1745) 死支,见第二节
W2 body-runner.ts:340 buildActionSandboxContext actionCtx?.user ?? actionCtx?.session?.user ActorUser(packages/runtime/src/security/actor-user.ts:76),由 REST /actions(domains/actions.ts:325)与 MCP run_action(action-execution.ts:1013)经 actorUserFromExecutionContext() 构造 死支

两个形参类型都是 any ⇒ 收窄后表达式类型仍是 any,两处赋值一律编译通过,无需改写入方。

读取方(全仓,非测试)—— 逐点:文件:行号 / 收的类型 / 收窄后是否仍编译

# 位置 收的类型 收窄后仍编译
R1 packages/runtime/src/sandbox/quickjs-runner.ts:489 installCtxsetObjectJson(vm, ctxObj, 'user', ctx.user) setObjectJson(…, v: unknown)(同文件 :1031)

全仓非测试读取方就这一处。 派发令转述的分诊预判"user 的读取方比 session 多"在 TS 类型面上不成立,如实记录:多出来的读取方全部在 VM 内部(body 作者写的 ctx.user.id 之类),那是交给 QuickJS 的 JS 源码字符串,不经 tsc,收窄影响不到它们。所以 user 面的读取点拓扑与 session相同,不是更复杂。

测试面构造 ScriptContext 字面量而带 user 的只有 quickjs-runner.test.ts:275(user: { id: 'u1' }),经 HookContext['user'] 支合法。

跨仓

分诊 08-07 06:55Z 已核 ScriptContext 在 objectui / cloud 零命中(含阳性对照),本轮按派发令免重跑。收窄面止于 packages/runtime —— 并由下面的全仓 turbo run typecheck 125/125 独立复核。


二、必答项:两写入方的 ?? …session?.user 兜底链是否逼出联合第三支?

否 —— 而且这条兜底链的第二支在两个写入方上都是死支(#4984 死肢家族)。 逐条证据:

  • hook 侧(W1):HookContext['session'] 的键集是 userId / actor / organizationId / accessToken / isSystem / skipTriggers / skipAutomations / positions / preserveAudit(外加 roles 墓碑),没有 user(packages/spec/src/data/hook.zod.ts:386-536);唯一生产者 ObjectQLEngine.buildSession()(packages/objectql/src/engine.ts:1647-1690)逐字段构造,同样不写 user
  • action 侧(W2):ActionSession 的键集是 userId / organizationId / positions / roles(弃用别名),没有 user(packages/spec/src/ui/action-params.zod.ts:237-336);唯一生产者 buildActionSession()(packages/runtime/src/action-execution.ts:811-823)只写这四个键。

⇒ 联合恰好两支,未越出照抄 ScriptSession 模子的范围

本 PR 不动这条死肢:它是运行时表达式,本单的授权是"给接缝钉类型",顺手删表达式属于扩范围;已按 Prime Directive #10 另行立单(见文末)。

一处真实的第三种取值(是取值,不是第三个形状)

ScopedRepo.execute()(packages/objectql/src/engine.ts:7400-7407)是 executeAction第二个调用点,传的 ctx 是 { ...params, userId, tenantId, roles } —— 既无 user 也无 session,该路径上 ctx.user 恒为 undefinedundefined 已被 user?: 的可选性(以及 HookContext['user'] 自带的 optional)覆盖,不需要额外联合支。这条已写进 ScriptUser 的 docblock,免得下个读者以为 undefined 只是拼写上的宽松。


三、与 #5991(ScriptSession)模子的对齐 / 偏离

对齐(照抄):导出命名联合而非单型;成员是真实生产者形状而非发明的契约;undefined 经 spec 侧 optional 自然成为成员;类型放在同一个 script-runner.ts、紧邻姊妹字段;docblock 写清"为什么不是单型"。

偏离,三处,均有测量支撑:

  1. 成员不是两个 spec 契约,而是"一个 runtime interface + 一个 spec 契约"ScriptSession 两支都来自 spec(ActionSession / HookContext['session']);user 的 action 侧生产者 ActorUser 今天只有 runtime 的 TS interface、无 spec 契约 —— 这一点已记在 [runtime] ctx.userroles 别名(值是 positions)没有关闭日期 —— #5613 给 ctx.session 装了迁移窗口,同名同值的 ctx.user 面仍是无限期别名(observation) #6011,按派发令不归本单,本 PR ⛔ 不发明 spec 契约。
  2. ⛔ 不用 EvalUser(issue 选项 1),这是被实测否掉的,不是口味问题EvalUser(ADR-0068 D1)要求 id: string positions: string[],而 hook 侧的 buildUser() 根本不产 positions 键。所以 EvalUser 是 hook 侧交付形状的超集,拿它当"最小公分母"会在 hook 面断言一个从不存在的键 —— 与直接收成 ActorUser 是同一种过度声明,只是套了层 spec 外衣。ActorUser extends EvalUser,所以真正有这套契约的 action 路径一点没丢。选项 3(改用闸门)未采纳:类型是这里更便宜、更精确的通道,且 ScriptSession 已立先例。
  3. 额外导出 ActorUser(仅类型)ScriptSession 两支都能被消费者命名(都是公开 spec 类型),ScriptUser 要对等就得让 ActorUser 也可命名,否则导出了联合却没法指名它的一支。只导出 type,构造函数保持内部 —— Action context's ctx.user.name is hardcoded to the raw user id on the REST dispatch path — three dispatchers hand the sandbox three different user shapes #5372 的要义就是只有一个生产者

四、类型钉子放在哪(#6220 的教训)

⚠️ packages/runtime/tsconfig.json 排除所有 .test.ts / .spec.ts,而它的 typecheck 就是一句 tsc --noEmit钉在 runtime 的测试文件里 = phantom check —— 没有任何 tsc program 编译它,删掉指令每个闸门照样绿,这正是 check:type-check-coveragePINS_CHECKED 不变式所说的情形,且 PHANTOM_PIN_DEBT 已对新条目关闭。

所以钉子落在 packages/runtime/src/sandbox/script-user-type-assertions.ts —— include 覆盖 src 树、不被测试排除规则命中的普通 src 模块,与既有先例 packages/spec/src/ui/app.nav-type-assertions.ts 同形。它不被任何 tsup entry 或 barrel 引用(entry: ['src/index.ts']),不进产物。

已有的运行时钉 packages/runtime/src/action-ctx-user-shape.test.ts(#5372,逐键逐值断言三条 dispatch 路径相等)按派发令引用而不重复:它钉的是 VALUE,本文件钉的是 TYPE,而 VALUE 钉恰好覆盖不到本单要防的那种事 —— 一个新的第四 dispatch 面,因为 pin 只能检查它点名的生产者。

钉子分两族:正向(两个真实生产者形状仍可赋 —— 这是防过度收窄的一半:一旦有人把联合塌成 ActorUser,hook 支的断言立刻红)与反向 @ts-expect-error(垃圾形状、错类型 id、裸字符串、缺键的 action envelope、裸 EvalUser,以及读取侧 ctx.user?.positions 必须先判别 body 种类)。

一条钉子的预设被证伪,如实改掉而不是硬凑

原本想钉"{ id, positions } 这种半截 action envelope 应当编译红"。实测它是绿的,tsc 报 TS2578: Unused '@ts-expect-error' directive。原因是两条普通 TS 规则叠加:hook 支是 weak type(键全可选),命中一个键即满足;而联合上的 excess-property 检查只拒绝在所有成员里都不存在的键,positionsActorUser 支上是真键,所以不算 excess。

于是把它改成两条诚实的钉子:一条反向钉真正成立的情形({ userId, positions } —— 与 hook 支无公共键,被 weak-type 检查拒绝),一条把上面那个能编译的情形作为正向断言显式写下来,并在注释里说明这就是"两个无判别式的生产者形状取联合"的能力上限,姊妹字段 ScriptSession 因为 ActionSession 同样全可选而有完全一样的上限。声明买到的不是形状良构性证明,而是"与两个生产者都毫无交集的形状(如 inventedShape)从此被拒",这是 unknown 下静悄悄放行的那一类。


五、反向验证(先预测,后执行)

做法:把 ScriptUser 与字段一起临时放宽回 unknown。两处一起放宽才是真正的"改动前"状态 —— 只改字段而留着别名的话,直接标注 : ScriptUser 的钉子根本不受影响,那是假反向验证。

预测(执行前写死,含一条反模板的):方向是红,但不是均匀的 6 条 TS2578。5 条赋值型反向钉会因"任何形状都能赋给 unknown"变成未使用(TS2578);正向读取钉 sharedIdIsReadable 没有抑制指令,会真错;而 positionsNeedDiscrimination 不会红 —— 它压的错误只是从"联合上无此键"换成"读不了无类型值",抑制指令照样用得上。

实测:6 条错误,与预测的条数与归属完全一致:

script-user-type-assertions.ts(132,1): error TS2339: Property 'id' does not exist on type '{}'.   ← sharedIdIsReadable(正向,无抑制)
script-user-type-assertions.ts(139,1): error TS2578: Unused '@ts-expect-error' directive.          ← inventedShape
script-user-type-assertions.ts(143,1): error TS2578: Unused '@ts-expect-error' directive.          ← wrongIdType
script-user-type-assertions.ts(147,1): error TS2578: Unused '@ts-expect-error' directive.          ← primitiveUser
script-user-type-assertions.ts(157,1): error TS2578: Unused '@ts-expect-error' directive.          ← partialActionShape
script-user-type-assertions.ts(190,1): error TS2578: Unused '@ts-expect-error' directive.          ← bareEvalUser

positionsNeedDiscrimination 如预测未报错

与预测的唯一出入(如实记录):132 行的错误码是 TS2339('{}' 上没有 id)而非我预测的 TS18046(值为 unknown)—— 因为 ?. 先把 unknown 里的 null/undefined 排掉,剩下 {},再去读属性。同一类事实(无类型的值读不出属性),错误码不同。

已还原,并与放宽前的备份逐字节 diff 确认一致。


六、验证

命令 结果
pnpm --filter @objectstack/runtime typecheck ✅ 绿(tsc --noEmit 无输出)
pnpm --filter @objectstack/runtime test 106 个测试文件 / 1514 个用例全通过
turbo run typecheck --concurrency=2(全仓) 125 / 125 successful —— 与 #5991 同一口径,证明收窄面止于 runtime 包内
pnpm lint ✅ 无输出
pnpm check:type-check-coverage ✅ OK(含 PINS_CHECKED:新钉子确实在 tsc program 内)
pnpm check:nul-bytes ✅ OK(5958 个 tracked 文本文件,无裸控制字节)
pnpm check:empty-changeset / check:adr-anchors / check:org-identifier ✅ OK

七、文件面

  • packages/runtime/src/sandbox/script-runner.ts —— 新增 ScriptUser 联合 + user?: ScriptUser(唯一的行为无关类型改动)
  • packages/runtime/src/sandbox/script-user-type-assertions.ts —— 新增,类型钉子
  • packages/runtime/src/sandbox/index.ts / src/index.ts —— 导出 ScriptUser
  • packages/runtime/src/security/index.ts / src/index.ts —— 导出 type ActorUser
  • .changeset/light-berries-tickle.md —— @objectstack/runtime patch

body-runner.ts / quickjs-runner.ts 按派发令只读,零改动 —— 收窄没有暴露任何写入方类型错误(两个写入方从 any 赋值)。⛔ 未触碰 standalone-stack.ts(#5820 在飞)与 action-execution.ts / domains/data.ts / endpoint-policy.ts(#6244 落地中);未碰 content/docs/releases/、未碰 pnpm-lock.yaml


Generated by Claude Code

沙箱接缝的 user 字段此前是 `unknown`,类型系统对它一无所知 —— 第四个 dispatch
面手搓一个 user 字面量,编译器不会说一句话,而这正是 #5372 三种形状能存在几个
版本的部分原因:没有任何声明可以违背。

现在 `user?: ScriptUser`,`ScriptUser = ActorUser | HookContext['user']`,是两个
实测真实生产者形状的联合,与 33 行外的姊妹字段 ScriptSession(#5613 / #5991)同构。

刻意不收成单一类型:hook 侧的 buildUser() 快捷方式不带 positions / permissions /
systemPermissions,收成 ActorUser 会断言一套 hook 面从未生产过的授权词汇;也不收成
spec 的 EvalUser(issue 选项 1)—— 实测 buildUser() 根本不产 positions,而 EvalUser
要求它。

两个写入方的 `?? …session?.user` 兜底链未逼出联合第三支:两种 session 形状均未声明
也未生产 user 键(#4984 死肢家族),该死肢另行立单,本 PR 不动运行时表达式。

类型钉子放在 src 下的非测试文件 script-user-type-assertions.ts —— runtime 的
tsconfig 排除测试文件,钉在测试里就是 PINS_CHECKED 所说的 phantom check。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 1:22pm

Request Review

@github-actions github-actions Bot added the size/m label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)
  • content/docs/releases/v17.mdx (via @objectstack/runtime)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Aug 7, 2026
@qq9340100
qq9340100 marked this pull request as ready for review August 7, 2026 13:40
@qq9340100
qq9340100 added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit c51ffa5 Aug 7, 2026
24 checks passed
@qq9340100
qq9340100 deleted the claude/issue-5521-scriptcontext-user-type branch August 7, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ScriptContext.userunknown —— 沙箱接缝上没有任何类型把 dispatcher 的 user 形状钉住(observation)

2 participants