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); +});