diff --git a/scripts/runtime-start-plan-patches.ts b/scripts/runtime-start-plan-patches.ts new file mode 100644 index 0000000..9d0871e --- /dev/null +++ b/scripts/runtime-start-plan-patches.ts @@ -0,0 +1,109 @@ +import { parseExpressionAt, type FunctionExpression } from "acorn"; + +const identifier = "[A-Za-z_$][\\w$]*"; + +function incompatible(detail: string): never { + throw new Error(`ZCode runtime is incompatible with standalone Start Plan support (${detail}).`); +} + +function unique(items: Item[], name: string): Item { + if (items.length !== 1) incompatible(`${name} missing or ambiguous`); + return items[0]!; +} + +function escapePattern(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function namedFunction(runtime: string, label: string): FunctionExpression { + const name = unique([...runtime.matchAll(new RegExp(`${identifier}\\((${identifier}),"${label}"\\)`, "gu"))], label)[1]!; + const start = unique([...runtime.matchAll(new RegExp(`(?:async )?function ${escapePattern(name)}\\(`, "gu"))], label).index!; + const fn = parseExpressionAt(runtime, start, { ecmaVersion: "latest" }); + if (fn.type !== "FunctionExpression") incompatible(`${label} declaration changed`); + return fn; +} + +/** + * Admit z.ai/bigmodel Start Plan providers (for example the ZCODE WEEKEND BUILD + * token promos) in standalone mode. The bundled provider catalog already ships + * `account:zai-start-plan` / `account:bigmodel-start-plan`, and Desktop's host + * registers them whenever the account is entitled, but the standalone account + * paths only enumerate Individual Coding Plan providers. Five minimal edits: + * + * 1. the standalone catalog reader enumerates Start Plan providers as well, so + * they reach the provider registry and get an entitlement computed; + * 2. the login provider resolution keeps its "exactly one per family" contract + * by excluding Start Plan providers (login must keep targeting the Coding + * Plan api-key flow); + * 3. the standalone identity credential key maps Start Plan providers to the + * family's Coding Plan identity — the identity is account-scoped, and the + * server reports Start Plan grants for the same account; + * 4. the request credential refresher accepts the Start Plan access mode; + * 5. Start Plan requests authenticate with the zcode-scoped JWT credential + * (`zcodejwttoken`): Start Plan traffic is served from + * `zcode.z.ai/api/v1/zcode-plan/...`, which rejects the Coding Plan api-key + * and the exchanged api.z.ai project JWT with 401. + * + * No protocol changes: requests are still built and signed by the genuine + * runtime; only provider enumeration and credential selection are widened to + * cover an entitlement the account already has. + */ + +/** True when the runtime already admits Start Plan providers standalone (native or patched). */ +export function hasRuntimeStartPlan(runtime: string): boolean { + try { + const catalog = namedFunction(runtime, "readStandaloneCodingPlanCatalog"); + const headers = namedFunction(runtime, "createStandaloneProviderRuntimeHeadersPort"); + return runtime.slice(catalog.start, catalog.end).includes('"start-plan"') + && runtime.slice(headers.start, headers.end).includes('mode==="start-plan"'); + } catch { + return false; + } +} + +export function patchRuntimeStartPlan(runtime: string): string { + if (hasRuntimeStartPlan(runtime)) return runtime; + const edits: { start: number; end: number; value: string }[] = []; + const plan = (label: string, pattern: RegExp, value: string, name: string) => { + const fn = namedFunction(runtime, label); + const source = runtime.slice(fn.start, fn.end); + const match = unique([...source.matchAll(pattern)], name); + edits.push({ start: fn.start + match.index!, end: fn.start + match.index! + match[0].length, value }); + }; + + // 1) standalone catalog: enumerate Start Plan providers beside Coding Plan providers + plan("readStandaloneCodingPlanCatalog", + new RegExp('c\\?\\.type==="zhipu-account"&&c\\.mode==="individual-coding-plan"&&c\\.accountType&&u', "gu"), + 'c?.type==="zhipu-account"&&(c.mode==="individual-coding-plan"||c.mode==="start-plan")&&c.accountType&&u', + "catalog provider predicate"); + + // 2) login: keep resolving the Coding Plan provider uniquely (Start Plan must not break it) + plan("resolveStandaloneCodingPlanProvider", + new RegExp('\\.filter\\(o=>o\\.family===e\\);if\\(n\\.length!==1\\)', "gu"), + '.filter(o=>o.family===e&&!/-start-plan$/.test(o.providerId));if(n.length!==1)', + "login provider uniqueness filter"); + + // 3) identity: Start Plan providers share the family's account-scoped identity credential + plan("standaloneAccountIdentityCredentialKey", + new RegExp('return`account-provider:\\$\\{t\\}:identity`', "gu"), + 'return`account-provider:${/-start-plan$/.test(t)?t.replace(/-start-plan$/u,"-individual-coding-plan"):t}:identity`', + "identity credential key mapping"); + + // 4) request refresher: accept the Start Plan access mode + plan("createStandaloneProviderRuntimeHeadersPort", + new RegExp('if\\(!f\\|\\|f\\.mode!=="individual-coding-plan"\\)throw', "gu"), + 'if(!f||f.mode!=="individual-coding-plan"&&f.mode!=="start-plan")throw', + "request access mode check"); + + // 5) request auth: Start Plan requests use the zcode-scoped JWT credential + const credentialCheck = 'if(c.abortSignal?.throwIfAborted(),!R)throw new Error(`Standalone Account Provider \\u7F3A\\u5C11\\u8BF7\\u6C42\\u51ED\\u636E: ${u}`)'; + plan("createStandaloneProviderRuntimeHeadersPort", + new RegExp(escapePattern(credentialCheck), "gu"), + `if(f?.mode==="start-plan"){let $zSpToken=(await e.load("zcodejwttoken"))?.trim();if($zSpToken)R=$zSpToken}${credentialCheck}`, + "Start Plan request auth override"); + + edits.sort((first, second) => second.start - first.start); + let output = runtime; + for (const edit of edits) output = output.slice(0, edit.start) + edit.value + output.slice(edit.end); + return output; +} diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 23bbdb0..ca89dc9 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -19,6 +19,8 @@ export { hasRuntimeSessionLifecycle, patchRuntimeSessionLifecycle } from "./runt import { hasRuntimeBigmodelOAuth, patchRuntimeBigmodelOAuth } from "./runtime-bigmodel-oauth-patches.ts"; import { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "./runtime-shared-oauth-patches.ts"; export { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "./runtime-shared-oauth-patches.ts"; +import { hasRuntimeStartPlan, patchRuntimeStartPlan } from "./runtime-start-plan-patches.ts"; +export { hasRuntimeStartPlan, patchRuntimeStartPlan } from "./runtime-start-plan-patches.ts"; export { hasRuntimeBigmodelOAuth, patchRuntimeBigmodelOAuth } from "./runtime-bigmodel-oauth-patches.ts"; import { hasRuntimeBrowserUrlOpening, patchRuntimeBrowserUrlOpening } from "./runtime-browser-patches.ts"; export { hasRuntimeBrowserUrlOpening, patchRuntimeBrowserUrlOpening } from "./runtime-browser-patches.ts"; @@ -1967,6 +1969,10 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ id: "shared-oauth", requirement: "required", apply: patchRuntimeSharedOAuth, verify: hasRuntimeSharedOAuth }, + { + id: "start-plan-standalone", requirement: "optional", apply: patchRuntimeStartPlan, + verify: hasRuntimeStartPlan + }, { id: "login-model-defaults", requirement: "required", diff --git a/test/runtime/start-plan.test.ts b/test/runtime/start-plan.test.ts new file mode 100644 index 0000000..3fd9c21 --- /dev/null +++ b/test/runtime/start-plan.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { hasRuntimeStartPlan, patchRuntimeStartPlan } from "../../scripts/runtime-start-plan-patches.ts"; + +const root = join(import.meta.dir, "../.."); + +// Minimal stand-in for the vendored runtime: the four account functions the +// Start Plan patches touch, in their real (pristine, minified) statement +// shapes. Escapes are doubled where the outer template would consume them, so +// the fixture string carries the same literal \uXXXX / ${...} text as bundle. +const fixture = ` +var K3n=require("node:crypto"); +async function Y3n(e,t){if(t)return{zcodeBuiltinRevision:t.revision,providers:t.providers.entries().flatMap(([i,a])=>{let c=a.access,u=a.builtinModelIds?.find(f=>f.trim())?.trim();return c?.type==="zhipu-account"&&c.mode==="individual-coding-plan"&&c.accountType&&u?[{family:c.accountType,modelId:u,providerId:i}]:[]})};throw new Error(\`\${VG} is required for login\`)} +r(Y3n,"readStandaloneCodingPlanCatalog"); +async function Kgi(e,t){let n=(await Z3n(t)).filter(o=>o.family===e);if(n.length!==1)throw new Error(\`ZCode Built-in Config \\u5FC5\\u987B\\u4E3A \${e} \\u58F0\\u660E\\u552F\\u4E00 Individual Coding Plan Provider\`);return n[0]} +r(Kgi,"resolveStandaloneCodingPlanProvider"); +function fW(e){let t=e.trim();if(!t)throw new Error("Standalone Account Provider ID \\u4E0D\\u80FD\\u4E3A\\u7A7A");return\`account-provider:\${t}:identity\`} +r(fW,"standaloneAccountIdentityCredentialKey"); +function Ygi(e){return{async refreshBeforeModelRequest(c){let u=c.providerId.trim(),f=c.accountAccess,_=f.accountType;if(!f||f.mode!=="individual-coding-plan")throw new Error(\`Standalone Account Provider \\u8BF7\\u6C42\\u8EAB\\u4EFD\\u65E0\\u6548: \${u}\`);let h=(await e.load(fW(u)))?.trim();if(!h)throw new Error("Standalone Account Provider \\u51ED\\u8BC1\\u5DF2\\u7ECF\\u5931\\u6548: "+u);let v=bX(u),S=await e.load(v),k=J3n(_),I=S==="manual"?void 0:(await e.load(k))?.trim(),C=S==="oauth"||S!=="manual"&&!!I,R,D,B;if(C){R=await exchange()}else if(S==="manual"){R=(await e.load($5e({providerId:u,accountIdentity:h})))?.trim()}if(!C&&S!=="manual")throw new Error("project_token_login_required");if(c.abortSignal?.throwIfAborted(),!R)throw new Error(\`Standalone Account Provider \\u7F3A\\u5C11\\u8BF7\\u6C42\\u51ED\\u636E: \${u}\`);return{headersApplied:!0,requestAuth:{apiKey:R,...D?{apiKeyId:D}:{},...B?{accountScope:B}:{}}}}}} +r(Ygi,"createStandaloneProviderRuntimeHeadersPort"); +`; + +test("patched fixture admits Start Plan providers and is idempotent", () => { + expect(hasRuntimeStartPlan(fixture)).toBe(false); + const patched = patchRuntimeStartPlan(fixture); + expect(hasRuntimeStartPlan(patched)).toBe(true); + expect(patchRuntimeStartPlan(patched)).toBe(patched); + expect(patched).toContain('c.mode==="individual-coding-plan"||c.mode==="start-plan"'); + expect(patched).toContain('!/-start-plan$/.test(o.providerId)'); + expect(patched).toContain('t.replace(/-start-plan$/u,"-individual-coding-plan")'); + expect(patched).toContain('f.mode!=="individual-coding-plan"&&f.mode!=="start-plan"'); + expect(patched).toContain('(await e.load("zcodejwttoken"))?.trim()'); +}); + +test("Start Plan identity resolves to the family Coding Plan identity credential", () => { + const patched = patchRuntimeStartPlan(fixture); + // Label/env stubs only; the function under test touches none of the others. + const fW = new Function("require", "r", "VG", "jM", "Z3n", "bX", "J3n", "$5e", `${patched}\nreturn fW;`)( + (id: string) => (id === "node:crypto" ? {} : undefined), + () => {}, + "VG", class {}, async () => [], + () => "auth-source", () => "oauth", () => "api-key", + ) as (id: string) => string; + expect(fW("account:zai-individual-coding-plan")).toBe("account-provider:account:zai-individual-coding-plan:identity"); + expect(fW("account:zai-start-plan")).toBe("account-provider:account:zai-individual-coding-plan:identity"); + expect(fW("account:bigmodel-start-plan")).toBe("account-provider:account:bigmodel-individual-coding-plan:identity"); + expect(fW("account:zai-team-coding-plan")).toBe("account-provider:account:zai-team-coding-plan:identity"); +}); + +test("synced runtime ships standalone Start Plan support", async () => { + const runtime = await readFile(join(root, "vendor/zcode.cjs"), "utf8"); + expect(hasRuntimeStartPlan(runtime)).toBe(true); + expect(patchRuntimeStartPlan(runtime)).toBe(runtime); +});