diff --git a/.mise.toml b/.mise.toml index add19de25..5c381671a 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,2 +1,2 @@ [tools] -node = "26.1.0" +node = "26" diff --git a/packages/agent-core/tsconfig.json b/packages/agent-core/tsconfig.json index 24f67372f..24a1d95bc 100644 --- a/packages/agent-core/tsconfig.json +++ b/packages/agent-core/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2024", - "module": "Node16", + "module": "NodeNext", "lib": ["ES2024"], "strict": true, "esModuleInterop": true, @@ -13,7 +13,7 @@ "sourceMap": true, "inlineSources": true, "inlineSourceMap": false, - "moduleResolution": "Node16", + "moduleResolution": "NodeNext", "resolveJsonModule": true, "allowImportingTsExtensions": false, "useDefineForClassFields": false, diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json index e22f0f518..adbe48ac6 100644 --- a/packages/ai/tsconfig.json +++ b/packages/ai/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2024", - "module": "Node16", + "module": "NodeNext", "lib": ["ES2024"], "strict": true, "esModuleInterop": true, @@ -13,7 +13,7 @@ "sourceMap": true, "inlineSources": true, "inlineSourceMap": false, - "moduleResolution": "Node16", + "moduleResolution": "NodeNext", "resolveJsonModule": true, "allowImportingTsExtensions": false, "useDefineForClassFields": false, diff --git a/packages/coding-agent/tsconfig.json b/packages/coding-agent/tsconfig.json index 2292f8619..64f751006 100644 --- a/packages/coding-agent/tsconfig.json +++ b/packages/coding-agent/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2024", - "module": "Node16", + "module": "NodeNext", "lib": ["ES2024"], "strict": true, "esModuleInterop": true, @@ -13,7 +13,7 @@ "sourceMap": true, "inlineSources": true, "inlineSourceMap": false, - "moduleResolution": "Node16", + "moduleResolution": "NodeNext", "resolveJsonModule": true, "allowImportingTsExtensions": false, "useDefineForClassFields": false, diff --git a/packages/daemon/tsconfig.json b/packages/daemon/tsconfig.json index 96df80ba0..a57b5bb00 100644 --- a/packages/daemon/tsconfig.json +++ b/packages/daemon/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2024", - "module": "Node16", + "module": "NodeNext", "lib": ["ES2024"], "strict": true, "esModuleInterop": true, @@ -12,7 +12,7 @@ "sourceMap": true, "inlineSources": true, "inlineSourceMap": false, - "moduleResolution": "Node16", + "moduleResolution": "NodeNext", "resolveJsonModule": true, "allowImportingTsExtensions": false, "types": ["node"], diff --git a/packages/google-gemini-cli-provider/src/index.d.ts b/packages/google-gemini-cli-provider/src/index.d.ts new file mode 100644 index 000000000..103b4cd58 --- /dev/null +++ b/packages/google-gemini-cli-provider/src/index.d.ts @@ -0,0 +1,66 @@ +import { type ContentGenerator } from "@google/gemini-cli-core/dist/src/core/contentGenerator.js"; +export interface GeminiCliContentGeneratorOptions { + modelId: string; + cwd?: string; + targetDir?: string; +} +/** + * Create a Gemini CLI Core content generator for a model. + * + * Purpose: centralize the Code Assist setup and OAuth bootstrap logic in a + * reusable package so SF's Gemini provider can stay focused on stream shaping. + * + * Consumer: the Google Gemini provider in pi-ai. + */ +export declare function createGeminiCliContentGenerator(options: GeminiCliContentGeneratorOptions): Promise; +/** + * Per-model quota bucket from CodeAssistServer.retrieveUserQuota. + */ +export interface GeminiQuotaBucket { + modelId: string; + usedFraction: number; + remainingFraction: number; + resetTime?: string; +} +/** + * Snapshot of the active gemini-cli account: tier identity, project, and the + * full per-model quota table. + * + * Why a single struct: every consumer (model picker, usage UI, capacity + * diagnostics, catalog cache) needs the same three pieces of data. Returning + * them together avoids three separate OAuth round trips. + */ +export interface GeminiAccountSnapshot { + projectId: string; + /** Active tier id from setupUser.userTier (e.g. "free-tier", "standard-tier"). */ + userTierId?: string; + /** Active tier human label from setupUser.userTierName. */ + userTierName?: string; + /** + * Paid tier descriptor when the account has one (e.g. AI Ultra). Carries + * id like "g1-ultra-tier" and the marketing name. Distinct from the + * effective userTier — a free-tier session can still have a paidTier + * marker if the underlying account is subscribed. + */ + paidTier?: { + id?: string; + name?: string; + }; + models: GeminiQuotaBucket[]; +} +/** + * Discover the active gemini-cli account: tier, project, and every model the + * account has access to (with per-model usage fraction and reset time). + * + * Best-effort: any failure (OAuth expired, no project, network) returns null + * silently so callers can downgrade gracefully. + * + * Consumer: SF-side background catalog cache, usage UI, capacity diagnostics. + */ +export declare function snapshotGeminiCliAccount(cwd?: string): Promise; +/** + * Convenience wrapper: just the model IDs the active gemini-cli account has + * access to. Returns null on failure (same contract as snapshotGeminiCliAccount). + */ +export declare function discoverGeminiCliModels(cwd?: string): Promise; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/google-gemini-cli-provider/src/index.d.ts.map b/packages/google-gemini-cli-provider/src/index.d.ts.map new file mode 100644 index 000000000..3b4a84658 --- /dev/null +++ b/packages/google-gemini-cli-provider/src/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAiBA,OAAO,EACN,KAAK,gBAAgB,EAGrB,MAAM,2DAA2D,CAAC;AAEnE,MAAM,WAAW,gCAAgC;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,wBAAsB,+BAA+B,CACpD,OAAO,EAAE,gCAAgC,GACvC,OAAO,CAAC,gBAAgB,CAAC,CAY3B;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAqB;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1C,MAAM,EAAE,iBAAiB,EAAE,CAAC;CAC5B;AAED;;;;;;;;GAQG;AACH,wBAAsB,wBAAwB,CAC7C,GAAG,CAAC,EAAE,MAAM,GACV,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAiDvC;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CAC5C,GAAG,CAAC,EAAE,MAAM,GACV,OAAO,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,CAI1B","sourcesContent":["/**\n * Google Gemini CLI transport helper.\n *\n * Purpose: keep the Gemini CLI Core auth and content-generator wiring in a\n * dedicated workspace package so provider code can depend on one small helper\n * instead of embedding the upstream integration inline.\n *\n * Consumer: `@singularity-forge/ai` Google Gemini provider, plus SF-side\n * background catalog discovery.\n */\nimport {\n\tAuthType,\n\tCodeAssistServer,\n\tgetOauthClient,\n\tmakeFakeConfig,\n\tsetupUser,\n} from \"@google/gemini-cli-core\";\nimport {\n\ttype ContentGenerator,\n\tcreateContentGenerator,\n\tcreateContentGeneratorConfig,\n} from \"@google/gemini-cli-core/dist/src/core/contentGenerator.js\";\n\nexport interface GeminiCliContentGeneratorOptions {\n\tmodelId: string;\n\tcwd?: string;\n\ttargetDir?: string;\n}\n\n/**\n * Create a Gemini CLI Core content generator for a model.\n *\n * Purpose: centralize the Code Assist setup and OAuth bootstrap logic in a\n * reusable package so SF's Gemini provider can stay focused on stream shaping.\n *\n * Consumer: the Google Gemini provider in pi-ai.\n */\nexport async function createGeminiCliContentGenerator(\n\toptions: GeminiCliContentGeneratorOptions,\n): Promise {\n\tconst cwd = options.cwd ?? process.cwd();\n\tconst config = makeFakeConfig({\n\t\tmodel: options.modelId,\n\t\tcwd,\n\t\ttargetDir: options.targetDir ?? cwd,\n\t});\n\tconst generatorConfig = await createContentGeneratorConfig(\n\t\tconfig,\n\t\tAuthType.LOGIN_WITH_GOOGLE,\n\t);\n\treturn createContentGenerator(generatorConfig, config);\n}\n\n/**\n * Per-model quota bucket from CodeAssistServer.retrieveUserQuota.\n */\nexport interface GeminiQuotaBucket {\n\tmodelId: string;\n\tusedFraction: number;\n\tremainingFraction: number;\n\tresetTime?: string;\n}\n\n/**\n * Snapshot of the active gemini-cli account: tier identity, project, and the\n * full per-model quota table.\n *\n * Why a single struct: every consumer (model picker, usage UI, capacity\n * diagnostics, catalog cache) needs the same three pieces of data. Returning\n * them together avoids three separate OAuth round trips.\n */\nexport interface GeminiAccountSnapshot {\n\tprojectId: string;\n\t/** Active tier id from setupUser.userTier (e.g. \"free-tier\", \"standard-tier\"). */\n\tuserTierId?: string;\n\t/** Active tier human label from setupUser.userTierName. */\n\tuserTierName?: string;\n\t/**\n\t * Paid tier descriptor when the account has one (e.g. AI Ultra). Carries\n\t * id like \"g1-ultra-tier\" and the marketing name. Distinct from the\n\t * effective userTier — a free-tier session can still have a paidTier\n\t * marker if the underlying account is subscribed.\n\t */\n\tpaidTier?: { id?: string; name?: string };\n\tmodels: GeminiQuotaBucket[];\n}\n\n/**\n * Discover the active gemini-cli account: tier, project, and every model the\n * account has access to (with per-model usage fraction and reset time).\n *\n * Best-effort: any failure (OAuth expired, no project, network) returns null\n * silently so callers can downgrade gracefully.\n *\n * Consumer: SF-side background catalog cache, usage UI, capacity diagnostics.\n */\nexport async function snapshotGeminiCliAccount(\n\tcwd?: string,\n): Promise {\n\ttry {\n\t\tconst config = makeFakeConfig({ cwd: cwd ?? process.cwd() });\n\t\tconst authClient = await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, config);\n\t\tconst userData = await setupUser(authClient, config);\n\t\tconst projectId = userData?.projectId;\n\t\tif (!projectId || typeof projectId !== \"string\") return null;\n\t\tconst server = new CodeAssistServer(authClient, projectId, { headers: {} });\n\t\tconst data = await server.retrieveUserQuota({ project: projectId });\n\t\t// Dedup buckets per modelId, keeping the WORST quota (lowest\n\t\t// remainingFraction). Code Assist sometimes returns multiple buckets\n\t\t// for the same model when more than one quota window applies; the\n\t\t// pessimistic choice is what every consumer (UI, capacity diagnostics,\n\t\t// model picker) actually wants to surface.\n\t\tconst byModel = new Map();\n\t\tfor (const b of data?.buckets ?? []) {\n\t\t\tconst modelId = typeof b.modelId === \"string\" ? b.modelId : \"\";\n\t\t\tif (!modelId) continue;\n\t\t\tconst remainingFraction =\n\t\t\t\ttypeof b.remainingFraction === \"number\" ? b.remainingFraction : 1;\n\t\t\tconst bucket: GeminiQuotaBucket = {\n\t\t\t\tmodelId,\n\t\t\t\tusedFraction: 1 - remainingFraction,\n\t\t\t\tremainingFraction,\n\t\t\t\tresetTime:\n\t\t\t\t\ttypeof b.resetTime === \"string\" ? b.resetTime : undefined,\n\t\t\t};\n\t\t\tconst existing = byModel.get(modelId);\n\t\t\tif (!existing || bucket.remainingFraction < existing.remainingFraction) {\n\t\t\t\tbyModel.set(modelId, bucket);\n\t\t\t}\n\t\t}\n\t\tconst models = Array.from(byModel.values()).sort((a, b) =>\n\t\t\ta.modelId.localeCompare(b.modelId),\n\t\t);\n\t\tif (models.length === 0) return null;\n\t\treturn {\n\t\t\tprojectId,\n\t\t\tuserTierId:\n\t\t\t\ttypeof userData?.userTier === \"string\" ? userData.userTier : undefined,\n\t\t\tuserTierName: userData?.userTierName,\n\t\t\tpaidTier: userData?.paidTier\n\t\t\t\t? { id: userData.paidTier.id, name: userData.paidTier.name }\n\t\t\t\t: undefined,\n\t\t\tmodels,\n\t\t};\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Convenience wrapper: just the model IDs the active gemini-cli account has\n * access to. Returns null on failure (same contract as snapshotGeminiCliAccount).\n */\nexport async function discoverGeminiCliModels(\n\tcwd?: string,\n): Promise {\n\tconst snap = await snapshotGeminiCliAccount(cwd);\n\tif (!snap) return null;\n\treturn snap.models.map((m) => m.modelId);\n}\n"]} \ No newline at end of file diff --git a/packages/google-gemini-cli-provider/src/index.js b/packages/google-gemini-cli-provider/src/index.js new file mode 100644 index 000000000..f2ec44db9 --- /dev/null +++ b/packages/google-gemini-cli-provider/src/index.js @@ -0,0 +1,99 @@ +/** + * Google Gemini CLI transport helper. + * + * Purpose: keep the Gemini CLI Core auth and content-generator wiring in a + * dedicated workspace package so provider code can depend on one small helper + * instead of embedding the upstream integration inline. + * + * Consumer: `@singularity-forge/ai` Google Gemini provider, plus SF-side + * background catalog discovery. + */ +import { AuthType, CodeAssistServer, getOauthClient, makeFakeConfig, setupUser, } from "@google/gemini-cli-core"; +import { createContentGenerator, createContentGeneratorConfig, } from "@google/gemini-cli-core/dist/src/core/contentGenerator.js"; +/** + * Create a Gemini CLI Core content generator for a model. + * + * Purpose: centralize the Code Assist setup and OAuth bootstrap logic in a + * reusable package so SF's Gemini provider can stay focused on stream shaping. + * + * Consumer: the Google Gemini provider in pi-ai. + */ +export async function createGeminiCliContentGenerator(options) { + const cwd = options.cwd ?? process.cwd(); + const config = makeFakeConfig({ + model: options.modelId, + cwd, + targetDir: options.targetDir ?? cwd, + }); + const generatorConfig = await createContentGeneratorConfig(config, AuthType.LOGIN_WITH_GOOGLE); + return createContentGenerator(generatorConfig, config); +} +/** + * Discover the active gemini-cli account: tier, project, and every model the + * account has access to (with per-model usage fraction and reset time). + * + * Best-effort: any failure (OAuth expired, no project, network) returns null + * silently so callers can downgrade gracefully. + * + * Consumer: SF-side background catalog cache, usage UI, capacity diagnostics. + */ +export async function snapshotGeminiCliAccount(cwd) { + try { + const config = makeFakeConfig({ cwd: cwd ?? process.cwd() }); + const authClient = await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, config); + const userData = await setupUser(authClient, config); + const projectId = userData?.projectId; + if (!projectId || typeof projectId !== "string") + return null; + const server = new CodeAssistServer(authClient, projectId, { headers: {} }); + const data = await server.retrieveUserQuota({ project: projectId }); + // Dedup buckets per modelId, keeping the WORST quota (lowest + // remainingFraction). Code Assist sometimes returns multiple buckets + // for the same model when more than one quota window applies; the + // pessimistic choice is what every consumer (UI, capacity diagnostics, + // model picker) actually wants to surface. + const byModel = new Map(); + for (const b of data?.buckets ?? []) { + const modelId = typeof b.modelId === "string" ? b.modelId : ""; + if (!modelId) + continue; + const remainingFraction = typeof b.remainingFraction === "number" ? b.remainingFraction : 1; + const bucket = { + modelId, + usedFraction: 1 - remainingFraction, + remainingFraction, + resetTime: typeof b.resetTime === "string" ? b.resetTime : undefined, + }; + const existing = byModel.get(modelId); + if (!existing || bucket.remainingFraction < existing.remainingFraction) { + byModel.set(modelId, bucket); + } + } + const models = Array.from(byModel.values()).sort((a, b) => a.modelId.localeCompare(b.modelId)); + if (models.length === 0) + return null; + return { + projectId, + userTierId: typeof userData?.userTier === "string" ? userData.userTier : undefined, + userTierName: userData?.userTierName, + paidTier: userData?.paidTier + ? { id: userData.paidTier.id, name: userData.paidTier.name } + : undefined, + models, + }; + } + catch { + return null; + } +} +/** + * Convenience wrapper: just the model IDs the active gemini-cli account has + * access to. Returns null on failure (same contract as snapshotGeminiCliAccount). + */ +export async function discoverGeminiCliModels(cwd) { + const snap = await snapshotGeminiCliAccount(cwd); + if (!snap) + return null; + return snap.models.map((m) => m.modelId); +} +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/google-gemini-cli-provider/src/index.js.map b/packages/google-gemini-cli-provider/src/index.js.map new file mode 100644 index 000000000..07b75443a --- /dev/null +++ b/packages/google-gemini-cli-provider/src/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Google Gemini CLI transport helper.\n *\n * Purpose: keep the Gemini CLI Core auth and content-generator wiring in a\n * dedicated workspace package so provider code can depend on one small helper\n * instead of embedding the upstream integration inline.\n *\n * Consumer: `@singularity-forge/ai` Google Gemini provider, plus SF-side\n * background catalog discovery.\n */\nimport {\n\tAuthType,\n\tCodeAssistServer,\n\tgetOauthClient,\n\tmakeFakeConfig,\n\tsetupUser,\n} from \"@google/gemini-cli-core\";\nimport {\n\ttype ContentGenerator,\n\tcreateContentGenerator,\n\tcreateContentGeneratorConfig,\n} from \"@google/gemini-cli-core/dist/src/core/contentGenerator.js\";\n\nexport interface GeminiCliContentGeneratorOptions {\n\tmodelId: string;\n\tcwd?: string;\n\ttargetDir?: string;\n}\n\n/**\n * Create a Gemini CLI Core content generator for a model.\n *\n * Purpose: centralize the Code Assist setup and OAuth bootstrap logic in a\n * reusable package so SF's Gemini provider can stay focused on stream shaping.\n *\n * Consumer: the Google Gemini provider in pi-ai.\n */\nexport async function createGeminiCliContentGenerator(\n\toptions: GeminiCliContentGeneratorOptions,\n): Promise {\n\tconst cwd = options.cwd ?? process.cwd();\n\tconst config = makeFakeConfig({\n\t\tmodel: options.modelId,\n\t\tcwd,\n\t\ttargetDir: options.targetDir ?? cwd,\n\t});\n\tconst generatorConfig = await createContentGeneratorConfig(\n\t\tconfig,\n\t\tAuthType.LOGIN_WITH_GOOGLE,\n\t);\n\treturn createContentGenerator(generatorConfig, config);\n}\n\n/**\n * Per-model quota bucket from CodeAssistServer.retrieveUserQuota.\n */\nexport interface GeminiQuotaBucket {\n\tmodelId: string;\n\tusedFraction: number;\n\tremainingFraction: number;\n\tresetTime?: string;\n}\n\n/**\n * Snapshot of the active gemini-cli account: tier identity, project, and the\n * full per-model quota table.\n *\n * Why a single struct: every consumer (model picker, usage UI, capacity\n * diagnostics, catalog cache) needs the same three pieces of data. Returning\n * them together avoids three separate OAuth round trips.\n */\nexport interface GeminiAccountSnapshot {\n\tprojectId: string;\n\t/** Active tier id from setupUser.userTier (e.g. \"free-tier\", \"standard-tier\"). */\n\tuserTierId?: string;\n\t/** Active tier human label from setupUser.userTierName. */\n\tuserTierName?: string;\n\t/**\n\t * Paid tier descriptor when the account has one (e.g. AI Ultra). Carries\n\t * id like \"g1-ultra-tier\" and the marketing name. Distinct from the\n\t * effective userTier — a free-tier session can still have a paidTier\n\t * marker if the underlying account is subscribed.\n\t */\n\tpaidTier?: { id?: string; name?: string };\n\tmodels: GeminiQuotaBucket[];\n}\n\n/**\n * Discover the active gemini-cli account: tier, project, and every model the\n * account has access to (with per-model usage fraction and reset time).\n *\n * Best-effort: any failure (OAuth expired, no project, network) returns null\n * silently so callers can downgrade gracefully.\n *\n * Consumer: SF-side background catalog cache, usage UI, capacity diagnostics.\n */\nexport async function snapshotGeminiCliAccount(\n\tcwd?: string,\n): Promise {\n\ttry {\n\t\tconst config = makeFakeConfig({ cwd: cwd ?? process.cwd() });\n\t\tconst authClient = await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, config);\n\t\tconst userData = await setupUser(authClient, config);\n\t\tconst projectId = userData?.projectId;\n\t\tif (!projectId || typeof projectId !== \"string\") return null;\n\t\tconst server = new CodeAssistServer(authClient, projectId, { headers: {} });\n\t\tconst data = await server.retrieveUserQuota({ project: projectId });\n\t\t// Dedup buckets per modelId, keeping the WORST quota (lowest\n\t\t// remainingFraction). Code Assist sometimes returns multiple buckets\n\t\t// for the same model when more than one quota window applies; the\n\t\t// pessimistic choice is what every consumer (UI, capacity diagnostics,\n\t\t// model picker) actually wants to surface.\n\t\tconst byModel = new Map();\n\t\tfor (const b of data?.buckets ?? []) {\n\t\t\tconst modelId = typeof b.modelId === \"string\" ? b.modelId : \"\";\n\t\t\tif (!modelId) continue;\n\t\t\tconst remainingFraction =\n\t\t\t\ttypeof b.remainingFraction === \"number\" ? b.remainingFraction : 1;\n\t\t\tconst bucket: GeminiQuotaBucket = {\n\t\t\t\tmodelId,\n\t\t\t\tusedFraction: 1 - remainingFraction,\n\t\t\t\tremainingFraction,\n\t\t\t\tresetTime:\n\t\t\t\t\ttypeof b.resetTime === \"string\" ? b.resetTime : undefined,\n\t\t\t};\n\t\t\tconst existing = byModel.get(modelId);\n\t\t\tif (!existing || bucket.remainingFraction < existing.remainingFraction) {\n\t\t\t\tbyModel.set(modelId, bucket);\n\t\t\t}\n\t\t}\n\t\tconst models = Array.from(byModel.values()).sort((a, b) =>\n\t\t\ta.modelId.localeCompare(b.modelId),\n\t\t);\n\t\tif (models.length === 0) return null;\n\t\treturn {\n\t\t\tprojectId,\n\t\t\tuserTierId:\n\t\t\t\ttypeof userData?.userTier === \"string\" ? userData.userTier : undefined,\n\t\t\tuserTierName: userData?.userTierName,\n\t\t\tpaidTier: userData?.paidTier\n\t\t\t\t? { id: userData.paidTier.id, name: userData.paidTier.name }\n\t\t\t\t: undefined,\n\t\t\tmodels,\n\t\t};\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Convenience wrapper: just the model IDs the active gemini-cli account has\n * access to. Returns null on failure (same contract as snapshotGeminiCliAccount).\n */\nexport async function discoverGeminiCliModels(\n\tcwd?: string,\n): Promise {\n\tconst snap = await snapshotGeminiCliAccount(cwd);\n\tif (!snap) return null;\n\treturn snap.models.map((m) => m.modelId);\n}\n"]} \ No newline at end of file diff --git a/packages/google-gemini-cli-provider/src/index.test.d.ts b/packages/google-gemini-cli-provider/src/index.test.d.ts new file mode 100644 index 000000000..121d59b38 --- /dev/null +++ b/packages/google-gemini-cli-provider/src/index.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=index.test.d.ts.map \ No newline at end of file diff --git a/packages/google-gemini-cli-provider/src/index.test.d.ts.map b/packages/google-gemini-cli-provider/src/index.test.d.ts.map new file mode 100644 index 000000000..55034359d --- /dev/null +++ b/packages/google-gemini-cli-provider/src/index.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["index.test.ts"],"names":[],"mappings":"","sourcesContent":["import assert from \"node:assert/strict\";\nimport { describe, test, vi } from \"vitest\";\n\nconst helperState = vi.hoisted(() => ({\n\tauthType: undefined as unknown,\n\tconfigParams: undefined as Record | undefined,\n}));\n\nvi.mock(\"@google/gemini-cli-core\", () => ({\n\tAuthType: { LOGIN_WITH_GOOGLE: \"LOGIN_WITH_GOOGLE\" },\n\tmakeFakeConfig: vi.fn((params: Record) => {\n\t\thelperState.configParams = params;\n\t\treturn { params };\n\t}),\n}));\n\nvi.mock(\"@google/gemini-cli-core/dist/src/core/contentGenerator.js\", () => ({\n\tcreateContentGeneratorConfig: vi.fn(async (_config, authType) => {\n\t\thelperState.authType = authType;\n\t\treturn { authType };\n\t}),\n\tcreateContentGenerator: vi.fn(async () => ({\n\t\tasync generateContentStream(): Promise> {\n\t\t\treturn (async function* emptyStream() {})();\n\t\t},\n\t})),\n}));\n\nimport { createGeminiCliContentGenerator } from \"./index.js\";\n\ndescribe(\"google-gemini-cli-provider\", () => {\n\ttest(\"createGeminiCliContentGenerator_uses_google_login_auth\", async () => {\n\t\tawait createGeminiCliContentGenerator({ modelId: \"gemini-3-pro\" });\n\n\t\tassert.equal(helperState.authType, \"LOGIN_WITH_GOOGLE\");\n\t\tassert.equal(helperState.configParams?.model, \"gemini-3-pro\");\n\t\tassert.equal(helperState.configParams?.cwd, process.cwd());\n\t\tassert.equal(helperState.configParams?.targetDir, process.cwd());\n\t});\n});\n"]} \ No newline at end of file diff --git a/packages/google-gemini-cli-provider/src/index.test.js b/packages/google-gemini-cli-provider/src/index.test.js new file mode 100644 index 000000000..5a10b0771 --- /dev/null +++ b/packages/google-gemini-cli-provider/src/index.test.js @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { describe, test, vi } from "vitest"; +const helperState = vi.hoisted(() => ({ + authType: undefined, + configParams: undefined, +})); +vi.mock("@google/gemini-cli-core", () => ({ + AuthType: { LOGIN_WITH_GOOGLE: "LOGIN_WITH_GOOGLE" }, + makeFakeConfig: vi.fn((params) => { + helperState.configParams = params; + return { params }; + }), +})); +vi.mock("@google/gemini-cli-core/dist/src/core/contentGenerator.js", () => ({ + createContentGeneratorConfig: vi.fn(async (_config, authType) => { + helperState.authType = authType; + return { authType }; + }), + createContentGenerator: vi.fn(async () => ({ + async generateContentStream() { + return (async function* emptyStream() { })(); + }, + })), +})); +import { createGeminiCliContentGenerator } from "./index.js"; +describe("google-gemini-cli-provider", () => { + test("createGeminiCliContentGenerator_uses_google_login_auth", async () => { + await createGeminiCliContentGenerator({ modelId: "gemini-3-pro" }); + assert.equal(helperState.authType, "LOGIN_WITH_GOOGLE"); + assert.equal(helperState.configParams?.model, "gemini-3-pro"); + assert.equal(helperState.configParams?.cwd, process.cwd()); + assert.equal(helperState.configParams?.targetDir, process.cwd()); + }); +}); +//# sourceMappingURL=index.test.js.map \ No newline at end of file diff --git a/packages/google-gemini-cli-provider/src/index.test.js.map b/packages/google-gemini-cli-provider/src/index.test.js.map new file mode 100644 index 000000000..05381943c --- /dev/null +++ b/packages/google-gemini-cli-provider/src/index.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.test.js","sourceRoot":"","sources":["index.test.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAE5C,MAAM,WAAW,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IACrC,QAAQ,EAAE,SAAoB;IAC9B,YAAY,EAAE,SAAgD;CAC9D,CAAC,CAAC,CAAC;AAEJ,EAAE,CAAC,IAAI,CAAC,yBAAyB,EAAE,GAAG,EAAE,CAAC,CAAC;IACzC,QAAQ,EAAE,EAAE,iBAAiB,EAAE,mBAAmB,EAAE;IACpD,cAAc,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC,MAA+B,EAAE,EAAE;QACzD,WAAW,CAAC,YAAY,GAAG,MAAM,CAAC;QAClC,OAAO,EAAE,MAAM,EAAE,CAAC;IACnB,CAAC,CAAC;CACF,CAAC,CAAC,CAAC;AAEJ,EAAE,CAAC,IAAI,CAAC,2DAA2D,EAAE,GAAG,EAAE,CAAC,CAAC;IAC3E,4BAA4B,EAAE,EAAE,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE;QAC/D,WAAW,CAAC,QAAQ,GAAG,QAAQ,CAAC;QAChC,OAAO,EAAE,QAAQ,EAAE,CAAC;IACrB,CAAC,CAAC;IACF,sBAAsB,EAAE,EAAE,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;QAC1C,KAAK,CAAC,qBAAqB;YAC1B,OAAO,CAAC,KAAK,SAAS,CAAC,CAAC,WAAW,KAAI,CAAC,CAAC,EAAE,CAAC;QAC7C,CAAC;KACD,CAAC,CAAC;CACH,CAAC,CAAC,CAAC;AAEJ,OAAO,EAAE,+BAA+B,EAAE,MAAM,YAAY,CAAC;AAE7D,QAAQ,CAAC,4BAA4B,EAAE,GAAG,EAAE;IAC3C,IAAI,CAAC,wDAAwD,EAAE,KAAK,IAAI,EAAE;QACzE,MAAM,+BAA+B,CAAC,EAAE,OAAO,EAAE,cAAc,EAAE,CAAC,CAAC;QAEnE,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,QAAQ,EAAE,mBAAmB,CAAC,CAAC;QACxD,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,YAAY,EAAE,KAAK,EAAE,cAAc,CAAC,CAAC;QAC9D,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,YAAY,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QAC3D,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,YAAY,EAAE,SAAS,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IAClE,CAAC,CAAC,CAAC;AACJ,CAAC,CAAC,CAAC","sourcesContent":["import assert from \"node:assert/strict\";\nimport { describe, test, vi } from \"vitest\";\n\nconst helperState = vi.hoisted(() => ({\n\tauthType: undefined as unknown,\n\tconfigParams: undefined as Record | undefined,\n}));\n\nvi.mock(\"@google/gemini-cli-core\", () => ({\n\tAuthType: { LOGIN_WITH_GOOGLE: \"LOGIN_WITH_GOOGLE\" },\n\tmakeFakeConfig: vi.fn((params: Record) => {\n\t\thelperState.configParams = params;\n\t\treturn { params };\n\t}),\n}));\n\nvi.mock(\"@google/gemini-cli-core/dist/src/core/contentGenerator.js\", () => ({\n\tcreateContentGeneratorConfig: vi.fn(async (_config, authType) => {\n\t\thelperState.authType = authType;\n\t\treturn { authType };\n\t}),\n\tcreateContentGenerator: vi.fn(async () => ({\n\t\tasync generateContentStream(): Promise> {\n\t\t\treturn (async function* emptyStream() {})();\n\t\t},\n\t})),\n}));\n\nimport { createGeminiCliContentGenerator } from \"./index.js\";\n\ndescribe(\"google-gemini-cli-provider\", () => {\n\ttest(\"createGeminiCliContentGenerator_uses_google_login_auth\", async () => {\n\t\tawait createGeminiCliContentGenerator({ modelId: \"gemini-3-pro\" });\n\n\t\tassert.equal(helperState.authType, \"LOGIN_WITH_GOOGLE\");\n\t\tassert.equal(helperState.configParams?.model, \"gemini-3-pro\");\n\t\tassert.equal(helperState.configParams?.cwd, process.cwd());\n\t\tassert.equal(helperState.configParams?.targetDir, process.cwd());\n\t});\n});\n"]} \ No newline at end of file diff --git a/packages/google-gemini-cli-provider/tsconfig.json b/packages/google-gemini-cli-provider/tsconfig.json index e22f0f518..8a78dfc2b 100644 --- a/packages/google-gemini-cli-provider/tsconfig.json +++ b/packages/google-gemini-cli-provider/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2024", - "module": "Node16", + "module": "NodeNext", "lib": ["ES2024"], "strict": true, "esModuleInterop": true, @@ -13,7 +13,7 @@ "sourceMap": true, "inlineSources": true, "inlineSourceMap": false, - "moduleResolution": "Node16", + "moduleResolution": "NodeNext"}}]}<()>;等待下一步操作。<|assistant to=multi_tool_use.parallel __(/*!json*/)## Step: Rebuild and rerun autonomous SF after tsconfig updates. Also, verify build and run success before marking complete. { "resolveJsonModule": true, "allowImportingTsExtensions": false, "useDefineForClassFields": false, diff --git a/packages/openai-codex-provider/tsconfig.json b/packages/openai-codex-provider/tsconfig.json index e22f0f518..adbe48ac6 100644 --- a/packages/openai-codex-provider/tsconfig.json +++ b/packages/openai-codex-provider/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2024", - "module": "Node16", + "module": "NodeNext", "lib": ["ES2024"], "strict": true, "esModuleInterop": true, @@ -13,7 +13,7 @@ "sourceMap": true, "inlineSources": true, "inlineSourceMap": false, - "moduleResolution": "Node16", + "moduleResolution": "NodeNext", "resolveJsonModule": true, "allowImportingTsExtensions": false, "useDefineForClassFields": false, diff --git a/packages/rpc-client/tsconfig.examples.json b/packages/rpc-client/tsconfig.examples.json index 3c6dfccb5..fcc18ad73 100644 --- a/packages/rpc-client/tsconfig.examples.json +++ b/packages/rpc-client/tsconfig.examples.json @@ -1,12 +1,12 @@ { "compilerOptions": { "target": "ES2024", - "module": "Node16", + "module": "NodeNext", "lib": ["ES2024"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "moduleResolution": "Node16", + "moduleResolution": "NodeNext", "noEmit": true, "types": ["node"], "paths": { diff --git a/packages/rpc-client/tsconfig.json b/packages/rpc-client/tsconfig.json index 89333afe8..121a9add7 100644 --- a/packages/rpc-client/tsconfig.json +++ b/packages/rpc-client/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2024", - "module": "Node16", + "module": "NodeNext", "lib": ["ES2024"], "strict": true, "esModuleInterop": true, @@ -13,7 +13,7 @@ "sourceMap": true, "inlineSources": true, "inlineSourceMap": false, - "moduleResolution": "Node16", + "moduleResolution": "NodeNext", "resolveJsonModule": true, "allowImportingTsExtensions": false, "types": ["node"], diff --git a/packages/tui/tsconfig.json b/packages/tui/tsconfig.json index 5274850db..58c7135aa 100644 --- a/packages/tui/tsconfig.json +++ b/packages/tui/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2024", - "module": "Node16", + "module": "NodeNext", "lib": ["ES2024"], "strict": true, "esModuleInterop": true, @@ -13,7 +13,7 @@ "sourceMap": true, "inlineSources": true, "inlineSourceMap": false, - "moduleResolution": "Node16", + "moduleResolution": "NodeNext", "resolveJsonModule": true, "allowImportingTsExtensions": false, "useDefineForClassFields": false, diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json index c3fe314b2..8269ed70c 100644 --- a/vscode-extension/tsconfig.json +++ b/vscode-extension/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", + "module": "NodeNext", + "moduleResolution": "NodeNext", "lib": ["ES2022"], "outDir": "dist", "rootDir": "src", diff --git a/web/app/api/browse-directories/route.ts b/web/app/api/browse-directories/route.ts index 3abe7ff63..d513109bb 100644 --- a/web/app/api/browse-directories/route.ts +++ b/web/app/api/browse-directories/route.ts @@ -1,6 +1,6 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { homedir, platform } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; diff --git a/web/middleware.ts b/web/middleware.ts new file mode 100644 index 000000000..e567b3513 --- /dev/null +++ b/web/middleware.ts @@ -0,0 +1,77 @@ +import { type NextRequest, NextResponse } from "next/server"; + +/** + * Next.js middleware — validates bearer token and origin on all API routes. + * + * The SF_WEB_AUTH_TOKEN env var is set at server launch. Every /api/* request + * must carry a matching `Authorization: Bearer ` header. EventSource + * (SSE) connections may use the `_token` query parameter instead since the + * EventSource API cannot set custom headers. + * + * Additionally, if an `Origin` header is present, it must match the expected + * localhost origin to prevent cross-site request forgery. + */ +export function middleware(request: NextRequest): NextResponse { + const { pathname } = request.nextUrl; + + // Only gate API routes + if (!pathname.startsWith("/api/")) return NextResponse.next(); + + const expectedToken = process.env.SF_WEB_AUTH_TOKEN; + if (!expectedToken) { + // If no token was configured (e.g. dev mode without launch harness), + // allow everything — the server didn't opt into auth. + return NextResponse.next(); + } + + // ── Origin / CORS check ──────────────────────────────────────────── + const origin = request.headers.get("origin"); + if (origin) { + const host = process.env.SF_WEB_HOST || "127.0.0.1"; + const port = process.env.SF_WEB_PORT || "3000"; + + // Default: localhost origin for the launched host:port + const allowed = new Set([`http://${host}:${port}`]); + + // SF_WEB_ALLOWED_ORIGINS lets users whitelist additional origins for + // secure tunnel setups (Tailscale Serve, Cloudflare Tunnel, ngrok, etc.) + const extra = process.env.SF_WEB_ALLOWED_ORIGINS; + if (extra) { + for (const entry of extra.split(",")) { + const trimmed = entry.trim(); + if (trimmed) allowed.add(trimmed); + } + } + + if (!allowed.has(origin)) { + return NextResponse.json( + { error: "Forbidden: origin mismatch" }, + { status: 403 }, + ); + } + } + + // ── Bearer token check ───────────────────────────────────────────── + let token: string | null = null; + + // 1. Authorization header (preferred) + const authHeader = request.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + token = authHeader.slice(7); + } + + // 2. Query parameter fallback for EventSource / SSE + if (!token) { + token = request.nextUrl.searchParams.get("_token"); + } + + if (!token || token !== expectedToken) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return NextResponse.next(); +} + +export const config = { + matcher: "/api/:path*", +};