DSH / Atlas
2026-08-26implementedbug-fixarchived 2026-09-04

pi-ai grant payloads store their JSON image

pi-ai grant payload 落盘其 JSON 像

A GitHub Copilot sign-in against github.com failed at its commit step: `credentials-local: record "llm-pi-ai/github-copilot" payload holds a value JSON cannot represent`. pi-ai's Copilot credential carries its optional members as explicit `undefined` (`enterpriseUrl: undefined` when no Enterprise domain was given — idiomatic JavaScript that `JSON.stringify` would simply drop), and `llm-pi-ai`'s store bridge committed

English

Problem

A GitHub Copilot sign-in against github.com failed at its commit step: credentials-local: record "llm-pi-ai/github-copilot" payload holds a value JSON cannot represent. pi-ai's Copilot credential carries its optional members as explicit undefined (enterpriseUrl: undefined when no Enterprise domain was given — idiomatic JavaScript that JSON.stringify would simply drop), and llm-pi-ai's store bridge committed the credential object verbatim as the grant payload. The credential store's validator rightly refuses undefined as unrepresentable, so every grant whose flow left an optional member unset failed to store, and the sign-in reported failure after the provider had already authorized it.

Decision

toRecord in packages/llm/llm-pi-ai/src/auth.ts stores the JSON image of a grant credential: jsonImage drops explicitly-undefined members of plain objects and renders undefined array entries as null, exactly as JSON.stringify would. Everything else — non-finite numbers, foreign-prototype objects — passes through untouched, so a genuinely unstorable value still fails loud at the store's validator rather than being silently reshaped. Reading back is unchanged: an absent member and an explicitly-undefined one are indistinguishable to pi-ai's consumers, which access optional members by property read.

Testing

tests/auth.spec.ts writes the Copilot-shaped grant (explicit undefined member, nested drop, array hole) through the real LocalCredentialProvider and asserts the stored payload is the JSON image; a second case proves the fail-loud path survives by committing a Date-valued member and asserting the store's refusal reaches the caller.

Alternatives considered

JSON.parse(JSON.stringify(credential)). Rejected: it also renders NaN/Infinity as null and runs toJSON methods, silently reshaping exactly the values the strict validator exists to refuse loudly.

Relaxing the store validator to skip undefined members. Rejected: the seam stores payloads it never reads or reshapes, and every producer relies on byte-faithful round-trips; normalization belongs to the producer that knows its library's idiom, not to the store every plugin shares.

Fixing pi-ai upstream to omit unset members. Out of this repository's hands and version-fragile: any future flow reintroducing the idiom would break sign-in again. The bridge owning the translation makes the harness robust against the whole class.

Consequences

Grants from every pi-ai flow store regardless of which optional members the flow left unset. The bridge now owns a one-way normalization: a payload read back lacks members that were explicitly undefined at write time, which is indistinguishable from their absence for property access, and remains the documented JSON semantics.

中文

Problem

一次面向 github.com 的 GitHub Copilot 登录在提交环节失败:credentials-local: record "llm-pi-ai/github-copilot" payload holds a value JSON cannot represent。pi-ai 的 Copilot 凭据以显式 undefined 携带可选成员(未填 Enterprise 域名时为 enterpriseUrl: undefined——这是 JSON.stringify 会直接丢弃的 JavaScript 惯用写法),而 llm-pi-ai 的存储桥接把凭据对象原样作为 grant payload 提交。凭据存储的校验器正当地拒绝 undefined 为不可表示,于是所有流程留有未填可选成员的 grant 都无法落盘,提供方已经完成授权之后登录却报失败。

Decision

packages/llm/llm-pi-ai/src/auth.ts 的 toRecord 改为落盘 grant 凭据的 JSON 像:jsonImage 丢弃普通对象里显式为 undefined 的成员,把数组中的 undefined 条目渲染为 null,与 JSON.stringify 完全一致。其余一切——非有限数、异种原型对象——原样透传,因此真正不可存储的值仍会在存储校验器处大声失败,而不是被静默改写。读回不变:成员缺失与显式 undefined 对以属性读取访问可选成员的 pi-ai 消费方不可区分。

Testing

tests/auth.spec.ts 经真实 LocalCredentialProvider 写入 Copilot 形状的 grant(显式 undefined 成员、嵌套丢弃、数组空洞),断言落盘 payload 为 JSON 像;第二个用例提交带 Date 成员的 grant 并断言存储的拒绝到达调用方,证明 fail-loud 路径仍在。

Alternatives considered

**JSON.parse(JSON.stringify(credential))。**否决:它还会把 NaN/Infinity 渲染为 null 并执行 toJSON 方法,把严格校验器本要大声拒绝的值静默改写掉。

**放宽存储校验器、跳过 undefined 成员。**否决:seam 存储的 payload 它从不读取或改写,所有生产方都依赖逐字节往返;归一化属于了解自家库惯用法的生产方,而不是所有插件共享的存储。

**在 pi-ai 上游修掉未填成员。**不在本仓库掌控内且随版本脆弱:将来任何流程重新引入该惯用法都会再次弄坏登录。由桥接层拥有这次翻译,让 harness 对整类问题免疫。

Consequences

所有 pi-ai 流程的 grant 无论留空哪些可选成员都能落盘。桥接层现在拥有一次单向归一化:写入时显式为 undefined 的成员在读回时缺失,这对属性访问不可区分,且正是文档化的 JSON 语义。