From ff5a28be3d0876d7cd3c3d3123eded06ee15d3db Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sun, 1 Nov 2026 10:07:22 +0800 Subject: [PATCH] feat(runtime): support Start Plan through shared OAuth sessions Reuse the existing ZCode account session for standalone Start Plan providers without aliasing Coding Plan identities, exchanging project tokens, or falling back to paid credentials. Guard account changes, cancellation and rejected sessions, and support JWTs without optional expiry claims. Follow up on #248 and #249; thanks to Erencv for investigating the standalone gates and identifying the ZCode JWT credential. --- docs/CONFIGURATION.md | 15 +++ docs/CONFIGURATION.zh-CN.md | 11 ++ scripts/check-runtime.ts | 2 + scripts/runtime-start-plan-patches.ts | 85 +++++++++++++ scripts/sync-runtime.ts | 6 + src/runtime-config-bridge.ts | 1 + src/shared-oauth.ts | 2 +- src/start-plan.ts | 80 ++++++++++++ test/runtime/shared-oauth.test.ts | 18 ++- test/runtime/start-plan.test.ts | 173 ++++++++++++++++++++++++++ test/start-plan.test.ts | 128 +++++++++++++++++++ 11 files changed, 515 insertions(+), 6 deletions(-) create mode 100644 scripts/runtime-start-plan-patches.ts create mode 100644 src/start-plan.ts create mode 100644 test/runtime/start-plan.test.ts create mode 100644 test/start-plan.test.ts diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index daf5a08..85b9c3d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -334,6 +334,21 @@ Z.AI credentials use Desktop's business-token format. Older CLI OAuth credential are converted after successful validation on their first request; failed validation or a concurrent login never overwrites the newer shared session. +Start Plan uses the same ZCode OAuth login as Coding Plan; there is no second +Start Plan sign-in. An existing Desktop session works without another login. +Choose `account:zai-start-plan/` or `account:bigmodel-start-plan/` +in `/model`, or pass it with `--model`. Start Plan requests use the shared ZCode +JWT directly, without exchanging a Coding Plan project token. The runtime still +builds and signs the requests. Switching models does not change the saved login. + +Start Plan providers are available locally when the active account has a valid +ZCode JWT and profile; the server determines the account's actual grants, model +availability and remaining quota. A manual Coding Plan API key alone is not a +ZCode session. Missing, expired or rejected session tokens require the shared +ZCode login to be renewed, not an additional plan-specific login. Start Plan +requests never fall back to paid Coding Plan credentials. New requests follow +the current Desktop account; retries cannot switch accounts or tokens mid-turn. + For macOS OAuth, the CLI temporarily registers a callback receiver, checks `state`, restores the previous `zcode://` handler and sends the callback through stdin. The runtime resolves business and project access before publishing diff --git a/docs/CONFIGURATION.zh-CN.md b/docs/CONFIGURATION.zh-CN.md index 92a655c..9e65e71 100644 --- a/docs/CONFIGURATION.zh-CN.md +++ b/docs/CONFIGURATION.zh-CN.md @@ -284,6 +284,17 @@ Provider 文件中的 `config.defaultModelSelection` 决定新会话使用的模 显式配置的手动 API Key 仍使用其独立的账号选择。Z.AI 凭证统一采用桌面端的业务令牌格式; 旧版 CLI 保存的 OAuth 令牌会在首次请求验证成功后转换,验证失败或并发登录不会覆盖更新的共享会话。 +Start Plan 与 Coding Plan 共用同一次 ZCode OAuth 登录,不需要第二次套餐授权;已有桌面端 +会话也无需再次登录。在 `/model` 中选择 `account:zai-start-plan/` 或 +`account:bigmodel-start-plan/`,也可以通过 `--model` 指定。Start Plan 请求直接使用 +共享的 ZCode JWT,不兑换 Coding Plan 项目令牌;请求仍由官方 runtime 构建和签名。 +切换模型不会改变已保存的登录状态。 + +本地仅在当前账号的 ZCode JWT 和资料有效时提供 Start Plan provider;具体活动授权、可用模型 +和剩余额度由服务端判定。仅有手动 Coding Plan API Key 不等于已经登录 ZCode。会话缺失、 +过期或被拒绝时,需要更新共享的 ZCode 登录,而不是新增套餐专属登录。Start Plan 请求不会 +回退使用付费 Coding Plan 凭证。新请求跟随桌面端当前账号,同一请求重试时禁止切换账号或令牌。 + macOS OAuth 会临时注册回调接收器、校验 `state`,恢复原来的 `zcode://` 处理程序,并通过 stdin 把回调交给 runtime。Runtime 先取得业务和项目访问令牌,再保存加密登录凭证与 原生默认模型;TUI 随后重新读取 provider 配置。BigModel 使用 runtime 的 localhost diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index 1ebc0b1..50057f7 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -26,6 +26,7 @@ import { hasRuntimeResolvedContextWindow, hasRuntimeRecursiveWorkspacePaths, hasRuntimeSharedOAuth, + hasRuntimeStartPlan, hasRuntimeSqliteBusyTimeout, hasRuntimeSqliteWriteRecovery, hasRuntimeStreamEofFinishGuard, @@ -112,6 +113,7 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource || !hasRuntimeRecursiveWorkspacePaths(runtimeSource) || !hasRuntimeResolvedContextWindow(runtimeSource) || !hasRuntimeSharedOAuth(runtimeSource) + || (patchEnabled("start-plan-standalone") && !hasRuntimeStartPlan(runtimeSource)) || !hasRuntimeCliSettingsFile(runtimeSource) || !runtimeSource.includes(".readRuntimeProjection=async()=>{let $zRuntimeProjectionBridge=await ") || !runtimeSource.includes('"plugin://"') diff --git a/scripts/runtime-start-plan-patches.ts b/scripts/runtime-start-plan-patches.ts new file mode 100644 index 0000000..41b9d43 --- /dev/null +++ b/scripts/runtime-start-plan-patches.ts @@ -0,0 +1,85 @@ +import { parseExpressionAt, type FunctionExpression } from "acorn"; + +const identifier = "[A-Za-z_$][\\w$]*"; +const helper = 'require(require("node:path").join(__dirname,"cli-config.cjs"))'; + +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; +} + +export function hasRuntimeStartPlan(runtime: string): boolean { + try { + const body = (label: string) => { const fn = namedFunction(runtime, label); return runtime.slice(fn.start, fn.end); }; + return body("readStandaloneCodingPlanCatalog").includes('mode:c.mode') + && body("readStandaloneCodingPlanCatalog").includes('c.mode==="start-plan"') + && body("resolveStandaloneCodingPlanProvider").includes('o.mode==="individual-coding-plan"') + && body("readStandaloneAccountProviderConfigSnapshot").includes(`${helper}.readStartPlanSession(`) + && body("readStandaloneAccountProviderConfigSnapshot").includes('$zStartSession?.family===') + && body("createStandaloneProviderRuntimeHeadersPort").includes(`${helper}.refreshStandaloneStartPlanRequest(`); + } catch { + return false; + } +} + +export function patchRuntimeStartPlan(runtime: string): string { + if (hasRuntimeStartPlan(runtime)) return runtime; + const edits: { start: number; end: number; value: string }[] = []; + const replace = (fn: FunctionExpression, pattern: RegExp, change: (match: RegExpMatchArray) => string, name: string) => { + const match = unique([...runtime.slice(fn.start, fn.end).matchAll(pattern)], name); + edits.push({ start: fn.start + match.index!, end: fn.start + match.index! + match[0].length, value: change(match) }); + }; + + const catalog = namedFunction(runtime, "readStandaloneCodingPlanCatalog"); + replace(catalog, /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 predicate"); + replace(catalog, /\{family:c\.accountType,modelId:u,providerId:i\}/gu, + () => '{family:c.accountType,modelId:u,providerId:i,mode:c.mode}', "catalog access mode"); + + replace(namedFunction(runtime, "resolveStandaloneCodingPlanProvider"), /\.filter\(o=>o\.family===e\)/gu, + () => '.filter(o=>o.family===e&&o.mode==="individual-coding-plan")', "login provider selection"); + + const snapshot = namedFunction(runtime, "readStandaloneAccountProviderConfigSnapshot"); + const [store] = snapshot.params; + if (store?.type !== "Identifier") incompatible("snapshot credential store changed"); + const source = runtime.slice(snapshot.start, snapshot.end); + const providers = unique([...source.matchAll(new RegExp(`(${identifier})=(${identifier})\\.providers,`, "gu"))], "snapshot providers"); + const providerMap = unique([...source.matchAll(new RegExp(`(${identifier})=new (${identifier})\\(${escapePattern(providers[1]!)}\\.map\\(`, "gu"))], "provider map"); + const config = unique([...source.matchAll(new RegExp(`new (${identifier})\\(\\{access:new (${identifier})\\(\\{entitled:!0\\}\\)\\}\\)`, "gu"))], "entitlement config"); + replace(snapshot, new RegExp(escapePattern(providers[0]), "gu"), () => + `$zStartSession=${providers[2]}.providers.some($zProvider=>$zProvider.mode==="start-plan")?await ${helper}.readStartPlanSession(${store.name}):void 0,` + + `${providers[1]}=${providers[2]}.providers.filter($zProvider=>$zProvider.mode!=="start-plan"),`, "independent Start Plan snapshot"); + replace(snapshot, new RegExp(`return (${identifier})\\(${escapePattern(providers[2]!)}\\.zcodeBuiltinRevision,${escapePattern(providerMap[1]!)}\\)`, "gu"), match => + `return ${match[1]}(${providers[2]}.zcodeBuiltinRevision,new ${providerMap[2]}([...${providerMap[1]}.entries(),` + + `...${providers[2]}.providers.filter($zProvider=>$zProvider.mode==="start-plan").map(({family:$zFamily,providerId:$zProviderId})=>` + + `[$zProviderId,new ${config[1]}({access:new ${config[2]}({entitled:$zStartSession?.family===$zFamily})})])]))`, "Start Plan entitlements"); + + const headers = namedFunction(runtime, "createStandaloneProviderRuntimeHeadersPort"); + const [headerStore] = headers.params; + if (headerStore?.type !== "Identifier") incompatible("request credential store changed"); + replace(headers, new RegExp(`let (${identifier})=(${identifier})\\.providerId\\.trim\\(\\),(${identifier})=\\2\\.accountAccess;`, "gu"), match => + `${match[0]}if(${match[3]}?.mode==="start-plan")return ${helper}.refreshStandaloneStartPlanRequest(${headerStore.name},${match[2]});`, "Start Plan request branch"); + + let output = runtime; + for (const edit of edits.sort((first, second) => second.start - first.start)) { + output = output.slice(0, edit.start) + edit.value + output.slice(edit.end); + } + if (!hasRuntimeStartPlan(output)) incompatible("postcondition verification failed"); + return output; +} diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 3de7d95..48be6a5 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -21,6 +21,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"; @@ -1975,6 +1977,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/src/runtime-config-bridge.ts b/src/runtime-config-bridge.ts index 6110709..2462c13 100644 --- a/src/runtime-config-bridge.ts +++ b/src/runtime-config-bridge.ts @@ -14,6 +14,7 @@ export { appServerRegistryOptions } from "./app-server-auth.ts"; export { exchangeBigmodelOAuthCode } from "./bigmodel-oauth.ts"; export { browserOpenCommand } from "./browser-open.ts"; export { sharedOAuthCredentialReader, zaiBusinessAccessToken } from "./shared-oauth.ts"; +export { readStartPlanSession, refreshStandaloneStartPlanRequest } from "./start-plan.ts"; export { inspectRuntimeConfiguration, writeConfigurationDoctor } from "./config-diagnostics.ts"; export { withHeadlessPromptLifecycle } from "./runtime-headless-lifecycle.ts"; diff --git a/src/shared-oauth.ts b/src/shared-oauth.ts index d55ad13..020bcc3 100644 --- a/src/shared-oauth.ts +++ b/src/shared-oauth.ts @@ -11,7 +11,7 @@ const activeProviderKey = "oauth:active_provider"; const identityKey = (provider: string) => `account-provider:${provider}:identity`; const sourceKey = (provider: string) => `account-provider:${provider}:auth-source`; -function profileIdentity(value: string | null | undefined): string | null { +export function profileIdentity(value: string | null | undefined): string | null { if (!value) return null; try { const profile = JSON.parse(value); diff --git a/src/start-plan.ts b/src/start-plan.ts new file mode 100644 index 0000000..5b4f1c6 --- /dev/null +++ b/src/start-plan.ts @@ -0,0 +1,80 @@ +import { createHash } from "node:crypto"; +import { profileIdentity } from "./shared-oauth.ts"; + +interface CredentialReader { + loadMany(keys: string[]): Promise>; +} + +interface StartPlanSession { + family: "zai" | "bigmodel"; + identity: string; + token: string; +} + +interface StartPlanRequest { + providerId: string; + accountAccess?: { mode?: string; accountType?: string }; + expectedAccountScope?: string; + rejectedProjectTokenFingerprint?: string; + abortSignal?: AbortSignal; +} + +function usableToken(value: string | null | undefined): string | undefined { + const token = value?.trim().replace(/^Bearer\s+/iu, "").trim(); + const parts = token?.split("."); + if (!parts || parts.length !== 3 || parts.some(part => !part)) return undefined; + try { + const claims = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")); + if (!claims || typeof claims !== "object" || Array.isArray(claims)) return undefined; + const now = Date.now() / 1000; + if (claims.exp !== undefined && (typeof claims.exp !== "number" || !Number.isFinite(claims.exp) || claims.exp <= now)) return undefined; + if (claims.nbf !== undefined && (typeof claims.nbf !== "number" || !Number.isFinite(claims.nbf) || claims.nbf > now)) return undefined; + return token; + } catch { + return undefined; + } +} + +export async function readStartPlanSession(store: CredentialReader, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const activeKey = "oauth:active_provider", tokenKey = "zcodejwttoken"; + const active = await store.loadMany([activeKey]); + signal?.throwIfAborted(); + const family = active[activeKey]; + if (family !== "zai" && family !== "bigmodel") return undefined; + const profileKey = `oauth:${family}:user_info`; + const keys = [activeKey, tokenKey, profileKey]; + const values = await store.loadMany(keys); + signal?.throwIfAborted(); + const identity = profileIdentity(values[profileKey]), token = usableToken(values[tokenKey]); + if (values[activeKey] !== family || !identity || !token) return undefined; + const current = await store.loadMany(keys); + signal?.throwIfAborted(); + if (keys.some(key => current[key] !== values[key])) return undefined; + return { family, identity, token }; +} + +export async function refreshStandaloneStartPlanRequest(store: CredentialReader, input: StartPlanRequest) { + input.abortSignal?.throwIfAborted(); + const family = input.accountAccess?.accountType; + if (input.accountAccess?.mode !== "start-plan" || (family !== "zai" && family !== "bigmodel") + || input.providerId.trim() !== `account:${family}-start-plan`) { + throw new Error("start_plan_account_required"); + } + const session = await readStartPlanSession(store, input.abortSignal); + if (!session || session.family !== family) { + if (input.expectedAccountScope || input.rejectedProjectTokenFingerprint) throw new Error("project_token_scope_invalidated"); + throw new Error("start_plan_login_required: Sign in to ZCode once to use both Coding Plan and Start Plan; a Coding Plan API key alone is not a ZCode session."); + } + const accountScope = createHash("sha256").update(JSON.stringify([ + input.providerId.trim(), session.family, session.identity, session.token, + ])).digest("hex"); + if ((input.expectedAccountScope && input.expectedAccountScope !== accountScope) + || (input.rejectedProjectTokenFingerprint && !input.expectedAccountScope)) { + throw new Error("project_token_scope_invalidated"); + } + if (input.rejectedProjectTokenFingerprint === createHash("sha256").update(session.token).digest("hex")) { + throw new Error("start_plan_session_rejected: ZCode rejected the shared session; sign in to ZCode again."); + } + return { headersApplied: true, requestAuth: { apiKey: session.token, accountScope } }; +} diff --git a/test/runtime/shared-oauth.test.ts b/test/runtime/shared-oauth.test.ts index 094ed36..b2c3a12 100644 --- a/test/runtime/shared-oauth.test.ts +++ b/test/runtime/shared-oauth.test.ts @@ -48,6 +48,7 @@ async function probe(body: string, options: { callback?: boolean } = {}) { const httpFixture = ` const assert=require("node:assert/strict"),fs=require("node:fs/promises"); const env={...process.env},providerId="account:zai-individual-coding-plan"; + const zcodeJwt="e30."+Buffer.from(JSON.stringify({sub:"fixture-user"})).toString("base64url")+".fixture-signature"; const store=createSharedZCodeCredentialStore({env}); const calls=[]; let businessToken="fixture-business-token",projectToken="fixture-project-token",rejectExchange=false,rejectedStatus=401,beforeIssue; @@ -55,8 +56,8 @@ const httpFixture = ` const httpClient={request:async(request)=>{try{ const url=new URL(request.url);calls.push(url.pathname); if(url.pathname==="/api/v1/oauth/cli/init")return response({authorize_url:"https://example.test/login",flow_id:"flow",expires_at:Math.ceil(Date.now()/1000)+60,poll_interval_sec:1}); - if(url.pathname==="/api/v1/oauth/cli/poll/flow")return response({status:"ready",token:"fixture-zcode-jwt",user:{user_id:"fixture-user"},zai:{access_token:"fixture-oauth-token"}}); - if(url.pathname==="/api/v1/oauth/token")return response({token:"fixture-zcode-jwt",user:{user_id:"fixture-user"},zai:{access_token:"fixture-oauth-token"}}); + if(url.pathname==="/api/v1/oauth/cli/poll/flow")return response({status:"ready",token:zcodeJwt,user:{user_id:"fixture-user"},zai:{access_token:"fixture-oauth-token"}}); + if(url.pathname==="/api/v1/oauth/token")return response({token:zcodeJwt,user:{user_id:"fixture-user"},zai:{access_token:"fixture-oauth-token"}}); if(url.pathname==="/api/auth/z/login"){ const input=JSON.parse(new TextDecoder().decode(request.body)); if(rejectExchange||input.token!=="fixture-oauth-token")return response({},401); @@ -73,15 +74,15 @@ const httpFixture = ` throw new Error("Unexpected fixture request: "+url.pathname); }catch(error){console.error("Fixture HTTP error:",error);throw error}}}; const resolver=()=>createCodingPlanApiKeyResolver({httpClient}); - const seed=async(user="desktop-user")=>store.saveMany({"oauth:active_provider":"zai","oauth:zai:access_token":businessToken,"oauth:zai:user_info":JSON.stringify({user_id:user}),zcodejwttoken:"desktop-jwt"}); + const seed=async(user="desktop-user")=>store.saveMany({"oauth:active_provider":"zai","oauth:zai:access_token":businessToken,"oauth:zai:user_info":JSON.stringify({user_id:user}),zcodejwttoken:zcodeJwt}); const input={providerId,accountAccess:{mode:"individual-coding-plan",accountType:"zai"}}; `; -for (const callback of [false, true]) test(`Z.AI ${callback ? "macOS callback" : "polling"} login publishes a token Desktop and CLI can both use`, async () => { +for (const callback of [false, true]) test(`one Z.AI ${callback ? "macOS callback" : "polling"} login authorizes Coding Plan and Start Plan`, async () => { await probe(` await loginZCodeCli({env,noBrowser:true,httpClient}); assert.equal(await store.load("oauth:zai:access_token"),businessToken); - assert.equal(await store.load("zcodejwttoken"),"fixture-zcode-jwt"); + assert.equal(await store.load("zcodejwttoken"),zcodeJwt); assert.deepEqual(JSON.parse(await store.load("oauth:zai:user_info")),{user_id:"fixture-user"}); const saved=await fs.readFile(store.filePath,"utf8"); assert.ok(saved.includes("enc:v1:")); @@ -91,6 +92,13 @@ for (const callback of [false, true]) test(`Z.AI ${callback ? "macOS callback" : calls.length=0; const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver()); assert.equal((await headers.refreshBeforeModelRequest(input)).requestAuth.apiKey,projectToken); + const callsBeforeStart=calls.length; + assert.equal((await headers.refreshBeforeModelRequest({providerId:"account:zai-start-plan",accountAccess:{mode:"start-plan",accountType:"zai"}})).requestAuth.apiKey,zcodeJwt); + assert.equal(calls.length,callsBeforeStart); + const snapshot=await readStandaloneAccountProviderConfigSnapshot(store,env); + for(const id of [providerId,"account:zai-start-plan"]){ + assert.equal(snapshot.providers.entries().find(([key])=>key===id)[1].access.entitled,true); + } assert.ok(!calls.includes("/api/auth/z/login")); `, { callback }); }, 15_000); diff --git a/test/runtime/start-plan.test.ts b/test/runtime/start-plan.test.ts new file mode 100644 index 0000000..5c59b70 --- /dev/null +++ b/test/runtime/start-plan.test.ts @@ -0,0 +1,173 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { parse } from "acorn"; +import { hasRuntimeStartPlan, patchRuntimeStartPlan } from "../../scripts/runtime-start-plan-patches.ts"; +import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; + +const root = join(import.meta.dir, "../.."); +const fixture = ` +async function catalog(e,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}]:[]})}} +label(catalog,"readStandaloneCodingPlanCatalog"); +async function resolve(e,t){let n=(await catalog(t)).filter(o=>o.family===e);if(n.length!==1)throw new Error("ambiguous provider");return n[0]} +label(resolve,"resolveStandaloneCodingPlanProvider"); +async function snapshot(e,t,n){let o=await catalog(t,n),i=o.providers,a=i.map(({providerId:S})=>S),v=new Providers(i.map(({providerId:k})=>[k,new Config({access:new Access({entitled:!0})})]));return assemble(o.zcodeBuiltinRevision,v)} +label(snapshot,"readStandaloneAccountProviderConfigSnapshot"); +function headers(e){return{async refreshBeforeModelRequest(c){let u=c.providerId.trim(),f=c.accountAccess;if(!f||f.mode!=="individual-coding-plan")throw new Error("invalid mode");return exchange(e,u)}}} +label(headers,"createStandaloneProviderRuntimeHeadersPort"); +`; + +test("Start Plan patch separates catalog, login, account projection and auth and is idempotent", () => { + expect(hasRuntimeStartPlan(fixture)).toBe(false); + const patched = patchRuntimeStartPlan(fixture); + expect(hasRuntimeStartPlan(patched)).toBe(true); + expect(patchRuntimeStartPlan(patched)).toBe(patched); + expect(() => parse(patched, { ecmaVersion: "latest" })).not.toThrow(); + expect(patched).toContain('o.mode==="individual-coding-plan"'); + expect(patched).toContain('.filter($zProvider=>$zProvider.mode!=="start-plan")'); + expect(patched).toContain('.refreshStandaloneStartPlanRequest(e,c)'); + expect(patched).not.toContain("-start-plan$"); +}); + +test.each([ + fixture.replace('c.mode==="individual-coding-plan"', 'c.mode==="changed"'), + fixture.replace('.filter(o=>o.family===e)', '.filter(o=>o.family!==e)'), + fixture.replace('i=o.providers,', 'i=o.changed,'), + fixture.replace('f=c.accountAccess;', 'f=c.changed;'), + fixture + fixture, +])("shape drift or ambiguous anchors fail without a partial patch", runtime => { + expect(() => patchRuntimeStartPlan(runtime)).toThrow("incompatible"); + expect(hasRuntimeStartPlan(runtime)).toBe(false); +}); + +test("a partial marker does not falsely advertise full support", () => { + const partial = fixture.replace('c.mode==="individual-coding-plan"', '(c.mode==="individual-coding-plan"||c.mode==="start-plan")'); + expect(hasRuntimeStartPlan(partial)).toBe(false); + expect(() => patchRuntimeStartPlan(partial)).toThrow("incompatible"); +}); + +async function probe(body: string) { + const directory = await mkdtemp(join(tmpdir(), "zcode-start-plan-")); + try { + const file = join(root, "vendor/zcode.cjs"); + const source = await readFile(file, "utf8"); + const names = ["createSharedZCodeCredentialStore", "readStandaloneAccountProviderConfigSnapshot", + "createStandaloneProviderRuntimeHeadersPort", "resolveStandaloneCodingPlanProvider"]; + const symbols = names.map(name => { + const match = new RegExp(`[A-Za-z_$][\\w$]*\\(([A-Za-z_$][\\w$]*),"${name}"\\)`, "u").exec(source); + if (!match) throw new Error(`Missing native function: ${name}`); + const init = [...source.slice(0, match.index).matchAll(/([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\(\(\)=>\{/gu)].at(-1)?.[1]; + if (!init) throw new Error(`Missing native initializer: ${name}`); + return { symbol: match[1], init }; + }); + const main = /async function [A-Za-z_$][\w$]*\(\)\{let [A-Za-z_$][\w$]*=process\.argv\.slice\(2\);/u.exec(source); + if (!main) throw new Error("Missing native entry point"); + const injected = symbols.map(value => `${value.init}();`).join("") + + `await(async(${names.join(",")})=>{${setup}${body};console.log("START_PLAN_OK")})(${symbols.map(value => value.symbol).join(",")}).catch(error=>{console.error(error.stack);process.exitCode=1});return;`; + const script = `const fs=require("node:fs"),Module=require("node:module"),path=require("node:path"); + const file=${JSON.stringify(file)},runtime=new Module(file,module); + runtime.filename=file;runtime.paths=Module._nodeModulePaths(path.dirname(file)); + runtime._compile(fs.readFileSync(file,"utf8").replace(${JSON.stringify(main[0])},${JSON.stringify(main[0] + injected)}),file);`; + const child = Bun.spawn([Bun.which("node")!, "--eval", script], { + cwd: directory, + env: { ...runtimeTestEnv(directory), ZCODE_CREDENTIAL_SECRET: "start-plan-test-secret" }, + stdin: "ignore", stdout: "pipe", stderr: "pipe", + }); + const timer = setTimeout(() => child.kill(), 12_000); + try { + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(code, stderr || stdout).toBe(0); + expect(stdout).toContain("START_PLAN_OK"); + } finally { clearTimeout(timer); } + } finally { await rm(directory, { recursive: true, force: true }); } +} + +const setup = ` + const assert=require("node:assert/strict"),crypto=require("node:crypto"); + globalThis.fetch=()=>{throw new Error("Unexpected HTTP request")}; + const env={...process.env},store=createSharedZCodeCredentialStore({env}); + const expiresAt=Math.floor(Date.now()/1000)+3600; + const jwt=(user="desktop-user",exp=expiresAt)=>"e30."+Buffer.from(JSON.stringify({sub:user,exp})).toString("base64url")+".fixture-signature"; + const seed=async(family="zai",user="desktop-user")=>store.saveMany({"oauth:active_provider":family,["oauth:"+family+":user_info"]:JSON.stringify({user_id:user}),zcodejwttoken:jwt(user)}); + const input=(family="zai")=>({providerId:"account:"+family+"-start-plan",accountAccess:{mode:"start-plan",accountType:family}}); + const resolver={clear(){},resolveMaterial(){throw new Error("Start Plan must not exchange a Coding Plan token")}}; + const port=()=>createStandaloneProviderRuntimeHeadersPort(store,env,resolver); + const entitled=async(id)=>{const snapshot=await readStandaloneAccountProviderConfigSnapshot(store,env);return snapshot.providers.entries().find(([key])=>key===id)?.[1].access.entitled}; +`; + +for (const family of ["zai", "bigmodel"]) test(`native ${family} Start Plan works with only the shared Desktop session`, async () => { + await probe(` + await seed(${JSON.stringify(family)}); + const request=input(${JSON.stringify(family)}); + assert.equal(await store.load("account-provider:"+request.providerId+":identity"),null); + assert.equal(await entitled(request.providerId),true); + const result=await port().refreshBeforeModelRequest(request); + assert.equal(result.requestAuth.apiKey,jwt()); + assert.match(result.requestAuth.accountScope,/^[a-f0-9]{64}$/); + assert.equal(result.requestAuth.apiKeyId,undefined); + const coding=await resolveStandaloneCodingPlanProvider(${JSON.stringify(family)},env); + assert.equal(coding.providerId,"account:"+${JSON.stringify(family)}+"-individual-coding-plan"); + `); +}, 15_000); + +test("native Start Plan follows Desktop switches without stale identities or manual keys", async () => { + await probe(` + await seed(); + await store.saveMany({"account-provider:account:zai-individual-coding-plan:identity":"old-cli-user","account-provider:account:zai-individual-coding-plan:auth-source":"manual"}); + const headers=port(),first=await headers.refreshBeforeModelRequest(input()); + await seed("zai","second-user"); + const second=await headers.refreshBeforeModelRequest(input()); + assert.equal(second.requestAuth.apiKey,jwt("second-user")); + assert.notEqual(first.requestAuth.accountScope,second.requestAuth.accountScope); + await assert.rejects(headers.refreshBeforeModelRequest({...input(),expectedAccountScope:first.requestAuth.accountScope}),/project_token_scope_invalidated/); + await seed("bigmodel","bigmodel-user"); + assert.equal(await entitled(input().providerId),false); + assert.equal(await entitled(input("bigmodel").providerId),true); + await assert.rejects(headers.refreshBeforeModelRequest(input()),/start_plan_login_required/); + assert.equal((await headers.refreshBeforeModelRequest(input("bigmodel"))).requestAuth.apiKey,jwt("bigmodel-user")); + `); +}, 15_000); + +test("native Start Plan never substitutes a Coding Plan token for a missing or expired JWT", async () => { + await probe(` + await seed(); + await store.save("oauth:zai:access_token","coding-business-token"); + const headers=port(); + for(const value of ["", "project-token", jwt("desktop-user",1)]){ + if(value)await store.save("zcodejwttoken",value);else await store.delete("zcodejwttoken"); + assert.equal(await entitled(input().providerId),false); + await assert.rejects(headers.refreshBeforeModelRequest(input()),/start_plan_login_required/); + } + await seed(); + const first=await headers.refreshBeforeModelRequest(input()); + await store.delete("oauth:active_provider"); + assert.equal(await entitled(input().providerId),false); + await assert.rejects(headers.refreshBeforeModelRequest({...input(),expectedAccountScope:first.requestAuth.accountScope}),/project_token_scope_invalidated/); + `); +}, 15_000); + +test("native Start Plan rejects an unchanged JWT after a server rejection", async () => { + await probe(` + await seed(); + const headers=port(),first=await headers.refreshBeforeModelRequest(input()); + const fingerprint=crypto.createHash("sha256").update(first.requestAuth.apiKey).digest("hex"); + await assert.rejects(headers.refreshBeforeModelRequest({...input(),expectedAccountScope:first.requestAuth.accountScope,rejectedProjectTokenFingerprint:fingerprint}),/start_plan_session_rejected/); + `); +}, 15_000); + +test("native model projection and request auth accept shared ZCode JWTs without exp", async () => { + await probe(` + await seed(); + const token="e30."+Buffer.from(JSON.stringify({sub:"desktop-user"})).toString("base64url")+".fixture-signature"; + await store.save("zcodejwttoken",token); + assert.equal(await entitled(input().providerId),true); + assert.equal((await port().refreshBeforeModelRequest(input())).requestAuth.apiKey,token); + `); +}, 15_000); + +test("synced runtime ships complete, idempotent Start Plan support", async () => { + const runtime = await readFile(join(root, "vendor/zcode.cjs"), "utf8"); + expect(hasRuntimeStartPlan(runtime)).toBe(true); + expect(patchRuntimeStartPlan(runtime)).toBe(runtime); +}); diff --git a/test/start-plan.test.ts b/test/start-plan.test.ts new file mode 100644 index 0000000..dea0309 --- /dev/null +++ b/test/start-plan.test.ts @@ -0,0 +1,128 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { readStartPlanSession, refreshStandaloneStartPlanRequest } from "../src/start-plan.ts"; + +const token = (claims: unknown = { exp: Math.floor(Date.now() / 1000) + 3600 }) => + `e30.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.fixture-signature`; +const input = { providerId: "account:zai-start-plan", accountAccess: { mode: "start-plan", accountType: "zai" } }; + +function fixture(family = "zai") { + const values: Record = { + "oauth:active_provider": family, + [`oauth:${family}:user_info`]: JSON.stringify({ user_id: "desktop-user" }), + zcodejwttoken: token(), + }; + const reads: string[][] = []; + let onRead: (() => void) | undefined; + const store = { async loadMany(keys: string[]) { + reads.push(keys); + onRead?.(); + return Object.fromEntries(keys.map(key => [key, values[key] ?? null])); + } }; + return { values, reads, store, setReadHook(hook: typeof onRead) { onRead = hook; } }; +} + +test.each(["zai", "bigmodel"])("%s Start Plan uses the shared JWT without Coding Plan credentials", async family => { + const f = fixture(family); + const result = await refreshStandaloneStartPlanRequest(f.store, { + providerId: `account:${family}-start-plan`, accountAccess: { mode: "start-plan", accountType: family }, + }); + expect(result).toEqual({ headersApplied: true, requestAuth: { apiKey: f.values.zcodejwttoken!, accountScope: expect.stringMatching(/^[a-f0-9]{64}$/u) } }); + expect(f.reads.flat()).not.toContain(`oauth:${family}:access_token`); + expect(f.reads.flat().some(key => key.startsWith("account-provider:"))).toBe(false); +}); + +test("a Bearer-prefixed stored JWT is normalized", async () => { + const f = fixture(), jwt = f.values.zcodejwttoken; + f.values.zcodejwttoken = ` Bearer ${jwt} `; + expect((await refreshStandaloneStartPlanRequest(f.store, input)).requestAuth.apiKey).toBe(jwt!); +}); + +test("a ZCode session JWT without optional time claims remains usable", async () => { + const f = fixture(); + f.values.zcodejwttoken = token({ sub: "desktop-user" }); + expect(await readStartPlanSession(f.store)).toEqual({ family: "zai", identity: "desktop-user", token: f.values.zcodejwttoken }); + expect((await refreshStandaloneStartPlanRequest(f.store, input)).requestAuth.apiKey).toBe(f.values.zcodejwttoken!); +}); + +test.each([null, "", "project-token", "a.invalid.c", token({ exp: 1 }), token(null), token([]), token("claims"), + token({ exp: null }), token({ exp: "9999999999" }), + token({ exp: 9999999999, nbf: 9999999998 })])("missing, malformed or expired JWT fails closed (%s)", async jwt => { + const f = fixture(); + f.values.zcodejwttoken = jwt; + f.values["oauth:zai:access_token"] = "coding-plan-business-token"; + expect(await readStartPlanSession(f.store)).toBeUndefined(); + await expect(refreshStandaloneStartPlanRequest(f.store, input)).rejects.toThrow("start_plan_login_required"); +}); + +test.each([null, "{", "{}", '{"user_id":""}'])("an invalid profile cannot reuse a stale CLI identity (%s)", async profile => { + const f = fixture(); + f.values["oauth:zai:user_info"] = profile; + f.values["account-provider:account:zai-individual-coding-plan:identity"] = "old-cli-user"; + await expect(refreshStandaloneStartPlanRequest(f.store, input)).rejects.toThrow("start_plan_login_required"); +}); + +test("only the active OAuth family's session can authorize Start Plan", async () => { + const f = fixture("bigmodel"); + f.values["oauth:zai:user_info"] = JSON.stringify({ user_id: "old-zai-user" }); + await expect(refreshStandaloneStartPlanRequest(f.store, input)).rejects.toThrow("start_plan_login_required"); + expect(f.reads.flat()).not.toContain("oauth:zai:user_info"); +}); + +test("no active OAuth family means no Start Plan even if stale credentials remain", async () => { + const f = fixture(); + delete f.values["oauth:active_provider"]; + await expect(refreshStandaloneStartPlanRequest(f.store, input)).rejects.toThrow("start_plan_login_required"); + expect(f.reads).toEqual([["oauth:active_provider"]]); +}); + +test.each(["oauth:active_provider", "oauth:zai:user_info", "zcodejwttoken"])("a concurrent session change invalidates the snapshot (%s)", async key => { + const f = fixture(); + f.setReadHook(() => { if (f.reads.length === 3) f.values[key] = null; }); + await expect(refreshStandaloneStartPlanRequest(f.store, input)).rejects.toThrow("start_plan_login_required"); +}); + +test("each request rereads the session, and an in-flight retry cannot change account or JWT", async () => { + const f = fixture(); + const first = await refreshStandaloneStartPlanRequest(f.store, input); + f.values["oauth:zai:user_info"] = JSON.stringify({ user_id: "new-desktop-user" }); + f.values.zcodejwttoken = token({ exp: Math.floor(Date.now() / 1000) + 7200 }); + const second = await refreshStandaloneStartPlanRequest(f.store, input); + expect(second.requestAuth.accountScope).not.toBe(first.requestAuth.accountScope); + expect(second.requestAuth.apiKey).toBe(f.values.zcodejwttoken!); + await expect(refreshStandaloneStartPlanRequest(f.store, { ...input, expectedAccountScope: first.requestAuth.accountScope })).rejects.toThrow("project_token_scope_invalidated"); + delete f.values["oauth:active_provider"]; + await expect(refreshStandaloneStartPlanRequest(f.store, { ...input, expectedAccountScope: second.requestAuth.accountScope })).rejects.toThrow("project_token_scope_invalidated"); +}); + +test("a rejected JWT is not silently reused or replaced by a paid-plan key", async () => { + const f = fixture(); + const first = await refreshStandaloneStartPlanRequest(f.store, input); + const rejectedProjectTokenFingerprint = createHash("sha256").update(first.requestAuth.apiKey).digest("hex"); + await expect(refreshStandaloneStartPlanRequest(f.store, { ...input, rejectedProjectTokenFingerprint })).rejects.toThrow("project_token_scope_invalidated"); + await expect(refreshStandaloneStartPlanRequest(f.store, { + ...input, expectedAccountScope: first.requestAuth.accountScope, rejectedProjectTokenFingerprint, + })).rejects.toThrow("start_plan_session_rejected"); +}); + +test.each([0, 1, 2, 3])("cancellation stops credential reads at step %s", async step => { + const f = fixture(), controller = new AbortController(); + if (step === 0) controller.abort(); + else f.setReadHook(() => { if (f.reads.length === step) controller.abort(); }); + await expect(refreshStandaloneStartPlanRequest(f.store, { ...input, abortSignal: controller.signal })).rejects.toThrow(); + expect(f.reads).toHaveLength(step); +}); + +test("credential read errors do not become an authorization fallback", async () => { + await expect(refreshStandaloneStartPlanRequest({ async loadMany() { throw new Error("decrypt failed"); } }, input)).rejects.toThrow("decrypt failed"); +}); + +test("the helper cannot authorize an unrelated access mode or family", async () => { + const f = fixture(); + for (const accountAccess of [{ mode: "individual-coding-plan", accountType: "zai" }, { mode: "start-plan", accountType: "unknown" }]) { + await expect(refreshStandaloneStartPlanRequest(f.store, { ...input, accountAccess })).rejects.toThrow("start_plan_account_required"); + } + await expect(refreshStandaloneStartPlanRequest(f.store, { ...input, providerId: "custom-provider" })).rejects.toThrow("start_plan_account_required"); + await expect(refreshStandaloneStartPlanRequest(f.store, { ...input, providerId: "account:bigmodel-start-plan" })).rejects.toThrow("start_plan_account_required"); + expect(f.reads).toHaveLength(0); +});