From 161f3f471bfdb66fc8d9bb941d9e5e78abe13ea7 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sat, 22 Aug 2026 21:48:00 +0800 Subject: [PATCH] feat: add Pi provider managed resources --- ...20260822-pi-provider-resources-7c3e91a4.md | 99 ++++ README.md | 4 +- electron-builder.yml | 6 - .../pi/managed-input-revision.ts | 102 ++++ electron/coding-runtime/pi/provider-config.ts | 529 ++++++++++++++++++ .../coding-runtime/pi/provider-refresh.ts | 44 ++ electron/coding-runtime/pi/resource-loader.ts | 193 +++++++ electron/coding-runtime/pi/worker-process.ts | 75 ++- electron/opencode/course-skills.ts | 19 +- .../opencode}/niancode-agent-browser.js | 0 .../coding-skills}/agent-browser/SKILL.md | 0 .../coding-skills}/frontend-slides/LICENSE | 0 .../coding-skills}/frontend-slides/SKILL.md | 0 .../frontend-slides/agents/openai.yaml | 0 .../references/animation-patterns.md | 0 .../references/html-template.md | 0 .../references/style-presets.md | 0 .../references/viewport-base.css | 0 .../coding-skills}/grilling/SKILL.md | 0 .../planning-with-files/SKILL.md | 0 scripts/probe-pi-packaged-runtime.mjs | 11 + scripts/probe-pi-provider-contracts.mjs | 9 +- .../{opencode-skills.ts => coding-skills.ts} | 6 +- .../opencode/AgentCreationDialog.tsx | 2 +- tests/unit/agent-browser-plugin.test.ts | 2 +- tests/unit/opencode-manager.test.ts | 7 +- tests/unit/pi-provider-config.test.ts | 225 ++++++++ tests/unit/pi-provider-state.test.ts | 95 ++++ tests/unit/pi-resource-loader.test.ts | 157 ++++++ tests/unit/pi-rpc-foundation.test.ts | 34 ++ tests/unit/planning-with-files-skill.test.ts | 2 +- 31 files changed, 1581 insertions(+), 40 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260822-pi-provider-resources-7c3e91a4.md create mode 100644 electron/coding-runtime/pi/managed-input-revision.ts create mode 100644 electron/coding-runtime/pi/provider-config.ts create mode 100644 electron/coding-runtime/pi/provider-refresh.ts create mode 100644 electron/coding-runtime/pi/resource-loader.ts rename {.opencode/skills/agent-browser/.opencode/plugins => resources/coding-extensions/opencode}/niancode-agent-browser.js (100%) rename {.opencode/skills => resources/coding-skills}/agent-browser/SKILL.md (100%) rename {.opencode/skills => resources/coding-skills}/frontend-slides/LICENSE (100%) rename {.opencode/skills => resources/coding-skills}/frontend-slides/SKILL.md (100%) rename {.opencode/skills => resources/coding-skills}/frontend-slides/agents/openai.yaml (100%) rename {.opencode/skills => resources/coding-skills}/frontend-slides/references/animation-patterns.md (100%) rename {.opencode/skills => resources/coding-skills}/frontend-slides/references/html-template.md (100%) rename {.opencode/skills => resources/coding-skills}/frontend-slides/references/style-presets.md (100%) rename {.opencode/skills => resources/coding-skills}/frontend-slides/references/viewport-base.css (100%) rename {.opencode/skills => resources/coding-skills}/grilling/SKILL.md (100%) rename {.opencode/skills => resources/coding-skills}/planning-with-files/SKILL.md (100%) rename shared/{opencode-skills.ts => coding-skills.ts} (53%) create mode 100644 tests/unit/pi-provider-config.test.ts create mode 100644 tests/unit/pi-provider-state.test.ts create mode 100644 tests/unit/pi-resource-loader.test.ts diff --git a/.project-docs/30-worklog/tasks/20260822-pi-provider-resources-7c3e91a4.md b/.project-docs/30-worklog/tasks/20260822-pi-provider-resources-7c3e91a4.md new file mode 100644 index 0000000..eb05c50 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260822-pi-provider-resources-7c3e91a4.md @@ -0,0 +1,99 @@ +# Task: Implement PI-040 provider and managed resources + +## Identity + +- Task ID: 20260822-pi-provider-resources-7c3e91a4 +- Mode: Feature +- Branch: codex/20260822-pi-provider-resources-7c3e91a4-pi-provider-resources +- Worktree: D:\Datas\OthersProjects\makelore-pi-provider-resources-7c3e91a4 +- Base commit: 81d8ad1b6bc0b18859feadfeb9e27cc3f15ffce5 +- Owner: codex +- Status: Completed + +## Scope + +- Implement `PI-040 — Provider and managed resources` from the planner's Pi hard-cutover ticket graph. +- Add a Main-owned Provider Account to Pi managed-catalog projection, worker-only credential/header environment projection, Works gateway normalization, and refresh/auth-recovery primitives under `electron/coding-runtime/pi`. +- Add managed prompt, Agent manifest, explicit skill-path, directory-layout, and revision/staleness contracts without wiring them into the PI-050 worker pool or Renderer routes yet. +- Move bundled coding skills to the vendor-neutral `resources/coding-skills` source and keep the current OpenCode runtime reading that same source until final PI-140 removal. +- Add focused provider catalog, redaction, refresh, revision, resource-discovery, packaging, and provider-shaped loopback tests. Real Provider calls and macOS qualification remain explicitly outside this ticket. + +## Intent And Constraints + +- Keep Provider Account identity, credentials, proxy trust, resource selection, and revision state Main-owned. Renderer/API summaries may expose only safe identifiers, model capabilities, header names, revision numbers, and availability state. +- Give every account a stable account-derived Pi runtime Provider ID so two accounts for one vendor cannot collide. Map Makelore `openrouter` onto Pi `openai-completions` plus the locked OpenRouter compatibility flags; map the other current protocols directly. +- Persist only environment references in Pi `models.json`; API key, OAuth/proxy token, and custom header values enter only the individual worker environment and the worker diagnostic redaction set. They must not enter argv, snapshots, API DTOs, logs, or test snapshots. +- Normalize Works gateway roots to exactly one `/v1`, preserve direct custom headers, and treat the current local Host API token as a worker credential rather than as a Provider-account credential revision. +- Coalesce concurrent refreshes by account and permit at most one auth refresh plus worker reopen for a failed operation. The contract is generic; PI-050 owns connecting it to worker lifecycle. +- A running turn keeps its Provider/resource snapshot. Stale idle workers rebuild before the next prompt; stale running workers rebuild only after settlement. Refresh never interrupts an active run. +- Disable all Pi resource auto-discovery and provide only explicit managed prompt/selected skill paths. No project/user `.pi`, `.agents`, `.codex`, or home-directory skill discovery is allowed. +- Preserve current OpenCode product behavior during staged implementation by repointing its bundled-skill source to `resources/coding-skills`; do not add a compatibility copy or parallel skill registry. +- The user explicitly waived real Provider validation and accepted protocol/account compatibility, real concurrency, credential-isolation, and image-behavior risk. These remain `Accepted Risk`, `realTurnVerified=false`, never `Pass`. macOS validation remains deferred to PI-150. + +## Project Context Loaded + +- `maintain-project-docs` created and verified isolated feature ownership at base `81d8ad1b6bc0b18859feadfeb9e27cc3f15ffce5`; Concurrent Task Gate and Planning Gate passed. +- The planner audited `{PI-000, PI-010, PI-020, PI-030}` as Done and identified PI-040 as the unique Ready Frontier ticket. This task follows the locked PI Provider/resource contracts and does not widen into PI-050 worker-pool orchestration or PI-140 cutover removal. +- Repository guidance, shared project memory, PI Spec Provider/resource sections, PI-040 ticket, current Provider registry/service/secret store, Works gateway, OpenCode Provider projection, Pi worker boundary, qualification probes, Pi 0.84.2 model/skill documentation, packaging configuration, and all planning peer task records were inspected before planning. +- Pi 0.84.2 supports the four locked API shapes (`openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`), environment interpolation in `models.json` API keys and headers, and repeatable explicit `--skill` paths even with `--no-skills`. +- Current Makelore accounts expose three direct Pi API shapes plus `openrouter`; OpenRouter is an application protocol alias mapped to `openai-completions` with `thinkingFormat` and `sessionAffinityFormat` set to `openrouter`. Google accounts currently have no explicit Makelore protocol field and therefore use the registry-derived `google-generative-ai` descriptor. +- Built-in coding skills currently live under `.opencode/skills` and are packaged as `course-skills`; PI-040 will make `resources/coding-skills` the single vendor-neutral source while retaining current OpenCode installation behavior through its source resolver. +- Other planning peers do not declare writes to the new PI-040 modules or vendor-neutral resource path. `20260820-partner-create-model-parity-c83d` still has undefined scope and is retained as an unknown coordination warning; this task avoids its existing files. +- `codebase-design` keeps credential projection, refresh/recovery, revision state, and resource materialization behind small explicit interfaces. The repository prohibition on subagents is honored; no parallel agent was created. + +## Plan + +1. Implement pure Provider Account normalization and Pi catalog projection with stable per-account runtime IDs, protocol/Works semantics, model capability validation, worker-only environment references, and safe summaries. +2. Implement per-account refresh single-flight, one-retry auth recovery, and revision/staleness state that defers rebuild until an active run settles. +3. Implement the managed Pi directory/resource loader, atomic prompt and Agent-manifest materialization, explicit skill registry/path selection, safe spawn arguments, and negative auto-discovery boundaries. +4. Move bundled skills to `resources/coding-skills`, update current packaging/OpenCode source resolution to that single source, and preserve selected-skill behavior. +5. Add focused catalog/redaction/refresh/revision/resource/packaging tests plus the packaged provider-shaped loopback; then run typecheck, lint, full tests, build, documentation gates, commit, and planner handoff. + +## Outcome + +- Added the Main-owned Pi Provider boundary. Every enabled current runtime account maps to a stable account-derived `runtimeProviderId`; accounts for the same vendor remain distinct. Makelore `openrouter` maps to Pi `openai-completions` with both locked OpenRouter compatibility flags, Google maps to `google-generative-ai`, and other current protocols map directly. +- Added managed `models.json` generation with account/default/imported model selection, image/reasoning/context/output capability projection, Works gateway `/v1` normalization, unavailable-model refusal before atomic replacement, and safe summaries containing only Provider/model/header names and credential-reference state. +- API keys, OAuth access tokens, local credentials, Works proxy tokens, and every custom/default Header value now enter only selected-worker environment variables. The catalog stores environment references, worker argv rejects known sensitive values, diagnostics redact all supplied values, and workers inherit an explicit OS/runtime allowlist instead of ambient Provider credentials from Main. +- Added secure-store credential resolution, local-proxy preference over stale stored Host tokens, per-account refresh single-flight, at-most-one auth refresh/reopen operation, and separate Provider/resource revision tracking. Idle stale workers rebuild before a prompt; active runs keep their original snapshot and request rebuild only after settlement. +- Added the managed `/coding-runtime/pi/{config,sessions,prompts,extensions,logs,trash}` layout, atomic Prompt plus Agent-manifest materialization, path-segment validation, and explicit bundled Skill ID→`SKILL.md` resolution. Spawn input contains Prompt/Skill paths, never Prompt content, and no project/user `.pi` or other skill roots are inspected. +- Moved the four bundled coding Skills from `.opencode/skills` to `resources/coding-skills`, renamed the shared registry to vendor-neutral terminology, and moved the current OpenCode-only Agent Browser plugin to `resources/coding-extensions/opencode`. Existing OpenCode installation now consumes the same vendor-neutral Skill source; generic `resources/` packaging carries both paths. +- Extended the controlled packaged Pi probe to execute the four protocol-shaped local Provider contracts. Each protocol completed overlapping two-worker image turns and abort isolation against the packaged runtime. Reports explicitly state `realTurnVerified: false` and `realProviderDecision: explicitly-waived-accepted-risk`. +- No PI-050 worker-pool, Host API, Renderer, or current product runtime cutover wiring was added. The real Provider and macOS decisions remain unchanged. + +## Verification + +- `pnpm install --frozen-lockfile`: passed with repository-pinned pnpm 10.33.4. +- Core focused suite after final changes: 4 files, 36 tests passed (`pi-provider-config`, Provider/revision state, managed resource loader, Pi RPC foundation). Earlier compatibility-focused run covered 7 files / 79 tests including OpenCode skill install/plugin paths. +- `pnpm run typecheck`: passed. +- Scoped ESLint over all changed implementation and test files: passed with no findings. +- `pnpm run lint:check`: passed with 0 errors and 6 pre-existing warnings outside this task's files. +- `pnpm test`: 187 files, 2152 tests passed. The first full run found one stale `.opencode/skills` path in `planning-with-files-skill.test.ts`; after updating it to `resources/coding-skills`, the focused test and full suite passed. +- `pnpm run test:electron:windows`: 1 file, 3 Electron-runtime tests passed, including Unicode Skill copy behavior. +- `pnpm run build:vite`: passed for Renderer, Main, Preload, and release utility output; existing dynamic-import and chunk-size warnings remain unchanged. +- `node scripts/probe-pi-provider-contracts.mjs --timeout-ms 30000`: passed all four local protocol contracts with environment-resolved custom Headers, four image requests per protocol, distinct sessions, overlapping turns, abort isolation, and `realTurnVerified=false`. +- `node scripts/probe-pi-packaged-runtime.mjs --timeout-ms 30000`: packaged closure complete (130 expected packages, no missing/relocated packages or assets); all four packaged Provider-shaped loopbacks passed. Runtime result remains intentionally `partial-pass` / `incomplete` because real Provider validation is explicitly waived and macOS remains deferred. +- Initial `pnpm run probe:pi-runtime:packaged -- --timeout-ms 30000` invocation stopped before build because pnpm forwarded the separator as an unknown argument; the direct equivalent command above passed twice. Cleanup of generated ignored `release/pi-runtime-probe` was attempted but blocked by the local command policy, so no deletion occurred and the ignored probe output remains outside the commit. +- `git diff --check`: passed. + +## Follow-ups + +- `PI-050` owns worker-pool integration. It must write the validated catalog before worker start, pass only `PiWorkerCredentialProjection.env` plus its `sensitiveValues`, use `buildPiManagedInputArgs`, and honor `PiManagedInputRevisionCoordinator` actions around prompt/run settlement. +- `PI-050` must connect authentication classification and worker reopening to `PiProviderRefreshCoordinator`; a second authentication failure from the retried operation must surface instead of refreshing again. +- `PI-100` may expose only the safe Provider/credential/resource summaries through Host API DTOs. Raw descriptors containing account IDs and raw worker environment projections remain Main-private. +- `PI-140` may remove the temporary current-OpenCode consumer and `resources/coding-extensions/opencode` artifact only when cutover is complete. It must keep `resources/coding-skills` as the product-owned Skill source and account for PI-020's temporary legacy migration dependency. +- Real Provider authentication, account/protocol variation, rate limiting, true concurrency, credential isolation, abort/session/model/image behavior remain explicitly accepted unverified risk. `realTurnVerified` must stay false and must not be promoted to Pass by local loopbacks. macOS remains deferred to PI-150. + +## Promotion Candidates + +- Target: Pi worker-pool implementation guidance in the architecture/planner task. + Proposal: treat catalog/resource revisions as desired Main-owned state; rebuild idle stale workers before prompt and running stale workers only after `agent_settled`, while preserving the run's original revision snapshot. + Evidence: focused revision tests prove provider/resource changes do not mutate active-run snapshots and produce distinct pre-prompt versus post-settlement rebuild actions. + Future impact: PI-050 and PI-060 can share one deterministic staleness contract without interrupting active turns or adding worker-specific flags. + Semantic conflicts: none found. + Human confirmation required: no. +- Target: Pi credential-isolation guidance in the architecture/planner task. + Proposal: persist only `$ENV_VAR` references in managed `models.json`, build a selected-account worker environment from secure-store/Works credentials, pass the same values as the diagnostic redaction set, and inherit only explicit non-Provider OS/runtime variables. + Evidence: catalog/redaction/argv/environment tests and the environment-resolved packaged Provider-shaped loopback all pass; ambient OpenAI/Anthropic/custom secret variables are excluded. + Future impact: PI-050 must preserve this exact spawn boundary and PI-100 must expose only safe summaries. + Semantic conflicts: none found. + Human confirmation required: no. diff --git a/README.md b/README.md index 263fa84..b1a7319 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,9 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - 一键提交时,Makelore 会从待上传构建归档的同一组 Main-owned 内存字节启动临时回环站点,并在两个独立的临时 Chromium profile 中检查桌面和移动视口的主页面加载、运行错误、失败资源与白屏。临时页面不挂载到界面,不读取或写入用户浏览器的 Cookie、历史和登录态;检查结束后始终销毁并清理,也不要求用户预先打开开发预览。 - 客户端复用 Electron 内置 Chromium,不安装 Playwright 或额外浏览器。预检只改善提交前反馈,可被非官方客户端绕过,也不会上传“已通过”凭据;平台仍把源码、构建归档和清单视为不可信输入,逐字节重算并在人工审核后发布。安装包携带固定 npm 运行时,项目依赖和 Vite 版本由 `package-lock.json` 锁定;依赖准备需要本地网络。 -### 项目内置 OpenCode Skills +### 项目内置编码 Skills -- 项目随产品提供 `agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)和 `planning-with-files`(项目规划)四个 OpenCode Skill。它们从 `.opencode/skills/` 打包,并由 Electron Main 安装到受管的 OpenCode 配置目录。 +- 项目随产品提供 `agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)和 `planning-with-files`(项目规划)四个编码 Skill。它们从 vendor-neutral 的 `resources/coding-skills/` 打包;当前由 Electron Main 安装到受管的 OpenCode 配置目录,Pi 运行时只会按 Agent 选择显式加载对应路径。旧运行时专用的 Agent Browser 插件独立保留在 `resources/coding-extensions/opencode/`,不混入共享 Skill 内容。 - 创建项目伙伴时,`agent-browser`、`grilling` 与 `planning-with-files` 默认勾选;`frontend-slides` 作为专项能力可手动选择。用户可以在创建或维护伙伴时调整选择。最终选择写入项目 Agent 的 `skillIds`,未选择的 Skill 保持拒绝权限。 - `grilling` 会在复杂实现前逐项确认高影响决策,用户确认前不执行变更。`planning-with-files` 只在复杂、可分阶段或需要跨会话恢复的任务中使用,并把 `task_plan.md`、`findings.md` 和 `progress.md` 直接保存到当前项目根目录,不写入 Skill 安装目录、用户目录或 `.niancode/agent-planning/`。 - `frontend-slides` 只在用户准备项目展示、汇报或结题时自动调用,生成项目目录中的固定 16:9 HTML 演示和相对路径素材;它不生成 `.pptx`,不访问云部署服务。 diff --git a/electron-builder.yml b/electron-builder.yml index 2baf850..fcb7102 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -28,12 +28,6 @@ extraResources: # opencode runtime bundled by scripts/bundle-opencode.mjs. - from: build/opencode-ai/ to: opencode-ai/ - # Built-in course skills installed into the managed opencode config directory. - - from: .opencode/skills/ - to: course-skills/ - filter: - - "**/*" - afterPack: ./scripts/after-pack.cjs asar: true diff --git a/electron/coding-runtime/pi/managed-input-revision.ts b/electron/coding-runtime/pi/managed-input-revision.ts new file mode 100644 index 0000000..bd317f6 --- /dev/null +++ b/electron/coding-runtime/pi/managed-input-revision.ts @@ -0,0 +1,102 @@ +export interface PiManagedInputRevision { + provider: number; + resources: number; +} + +export type PiManagedInputAction = + | { action: 'reuse'; revision: PiManagedInputRevision } + | { action: 'rebuild-before-prompt'; revision: PiManagedInputRevision } + | { action: 'rebuild-after-settled'; revision: PiManagedInputRevision } + | { action: 'defer-until-settled'; revision: PiManagedInputRevision }; + +interface WorkerRevisionState { + applied: PiManagedInputRevision; + activeRun: PiManagedInputRevision | null; +} + +function copyRevision(revision: PiManagedInputRevision): PiManagedInputRevision { + return { provider: revision.provider, resources: revision.resources }; +} + +function revisionsEqual(left: PiManagedInputRevision, right: PiManagedInputRevision): boolean { + return left.provider === right.provider && left.resources === right.resources; +} + +export class PiManagedInputRevisionCoordinator { + private currentRevision: PiManagedInputRevision = { provider: 1, resources: 1 }; + private readonly workers = new Map(); + + get current(): PiManagedInputRevision { + return copyRevision(this.currentRevision); + } + + markProviderStale(): PiManagedInputRevision { + this.currentRevision = { + provider: this.currentRevision.provider + 1, + resources: this.currentRevision.resources, + }; + return this.current; + } + + markResourcesStale(): PiManagedInputRevision { + this.currentRevision = { + provider: this.currentRevision.provider, + resources: this.currentRevision.resources + 1, + }; + return this.current; + } + + registerWorker(workerId: string, applied: PiManagedInputRevision = this.currentRevision): void { + if (!workerId.trim()) throw new Error('Worker id is required'); + if (this.workers.has(workerId)) throw new Error(`Worker is already registered: ${workerId}`); + this.workers.set(workerId, { applied: copyRevision(applied), activeRun: null }); + } + + removeWorker(workerId: string): void { + this.workers.delete(workerId); + } + + beforePrompt(workerId: string): PiManagedInputAction { + const worker = this.requireWorker(workerId); + if (worker.activeRun) { + return revisionsEqual(worker.applied, this.currentRevision) + ? { action: 'reuse', revision: copyRevision(worker.activeRun) } + : { action: 'defer-until-settled', revision: copyRevision(worker.activeRun) }; + } + return revisionsEqual(worker.applied, this.currentRevision) + ? { action: 'reuse', revision: copyRevision(worker.applied) } + : { action: 'rebuild-before-prompt', revision: this.current }; + } + + applyCurrentRevision(workerId: string): PiManagedInputRevision { + const worker = this.requireWorker(workerId); + if (worker.activeRun) throw new Error('Cannot rebuild managed input while a run is active'); + worker.applied = this.current; + return copyRevision(worker.applied); + } + + beginRun(workerId: string): PiManagedInputRevision { + const worker = this.requireWorker(workerId); + if (worker.activeRun) throw new Error('Worker already has an active run'); + if (!revisionsEqual(worker.applied, this.currentRevision)) { + throw new Error('Worker managed input is stale'); + } + worker.activeRun = copyRevision(worker.applied); + return copyRevision(worker.activeRun); + } + + settleRun(workerId: string): PiManagedInputAction { + const worker = this.requireWorker(workerId); + if (!worker.activeRun) throw new Error('Worker has no active run'); + worker.activeRun = null; + return revisionsEqual(worker.applied, this.currentRevision) + ? { action: 'reuse', revision: copyRevision(worker.applied) } + : { action: 'rebuild-after-settled', revision: this.current }; + } + + private requireWorker(workerId: string): WorkerRevisionState { + const worker = this.workers.get(workerId); + if (!worker) throw new Error(`Worker is not registered: ${workerId}`); + return worker; + } +} diff --git a/electron/coding-runtime/pi/provider-config.ts b/electron/coding-runtime/pi/provider-config.ts new file mode 100644 index 0000000..d207cc2 --- /dev/null +++ b/electron/coding-runtime/pi/provider-config.ts @@ -0,0 +1,529 @@ +import type { + ModelSummary, + ProviderAccount, + ProviderModelEntry, + ProviderProtocol, + ProviderSecret, +} from '../../shared/providers/types'; +import { + getProviderBackendConfig, + getProviderDefaultModel, + getProviderDefinition, +} from '../../shared/providers/registry'; +import type { ProductModelRef } from '../contracts'; +import { atomicWriteJson } from '../../coding-projects/atomic-json'; +import { + NIANCODE_USER_MODEL_ACCOUNT_ID, + normalizeImportedUserModelId, + selectUserModelRuntimeAccounts, +} from '../../../shared/user-model-config'; +import { getImportedModelProfile } from '../../../shared/imported-model-profile'; + +const PI_ENV_PREFIX = 'MAKELORE_PI'; +const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway'; +const WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE = 'works_square_ai_gateway_proxy'; + +export const PI_PROVIDER_APIS = [ + 'openai-completions', + 'openai-responses', + 'anthropic-messages', + 'google-generative-ai', +] as const; + +export type PiProviderApi = (typeof PI_PROVIDER_APIS)[number]; + +export interface PiProviderModelDescriptor { + id: string; + name: string; + input: Array<'text' | 'image'>; + reasoning: boolean; + contextWindow?: number; + maxOutputTokens?: number; + compat?: { + thinkingFormat: 'openrouter'; + sessionAffinityFormat: 'openrouter'; + }; +} + +export interface PiProviderDescriptor { + accountId: string; + runtimeProviderId: string; + api: PiProviderApi; + baseUrl?: string; + headers: Record; + apiKeyEnv?: string; + models: PiProviderModelDescriptor[]; +} + +export interface PiModelsFile { + providers: Record; + models: Array<{ + id: string; + name: string; + reasoning: boolean; + input: Array<'text' | 'image'>; + contextWindow?: number; + maxTokens?: number; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + compat?: PiProviderModelDescriptor['compat']; + }>; + }>; +} + +export interface PiProviderCatalogSummary { + providerCount: number; + providers: Array<{ + runtimeProviderId: string; + api: PiProviderApi; + baseUrl?: string; + modelIds: string[]; + imageInputModelIds: string[]; + headerNames: string[]; + hasCredentialReference: boolean; + }>; +} + +export interface PiProviderCatalogResult { + descriptors: PiProviderDescriptor[]; + modelsFile: PiModelsFile; + summary: PiProviderCatalogSummary; +} + +export interface BuildPiProviderCatalogOptions { + accounts: readonly ProviderAccount[]; + modelSummaries?: readonly ModelSummary[]; +} + +export async function buildPiProviderCatalogFromProviderService( + modelSummaries: readonly ModelSummary[] = [], +): Promise { + const { getProviderService } = await import('../../services/providers/provider-service'); + return buildPiProviderCatalog({ + accounts: await getProviderService().listAccounts(), + modelSummaries, + }); +} + +export interface PiProviderSelection { + accountId: string; + runtimeProviderId: string; + modelId: string; + thinkingLevel: ProductModelRef['thinkingLevel']; + input: Array<'text' | 'image'>; + contextWindow?: number; + maxOutputTokens?: number; +} + +export interface BuildPiWorkerCredentialProjectionOptions { + account: ProviderAccount; + descriptor: PiProviderDescriptor; + resolveCredential: (account: ProviderAccount) => Promise; + localProxyCredential?: string; +} + +export interface PiWorkerCredentialProjection { + env: Record; + sensitiveValues: string[]; +} + +export interface PiWorkerCredentialProjectionSummary { + envKeys: string[]; + sensitiveValueCount: number; +} + +export class PiProviderConfigError extends Error { + constructor( + public readonly code: 'PROVIDER_INVALID' | 'PROVIDER_AUTH_REQUIRED' | 'MODEL_UNAVAILABLE', + message: string, + ) { + super(message); + } +} + +export function credentialValueForProviderSecret(secret: ProviderSecret | null): string | null { + if (!secret) return null; + if (secret.type === 'api_key') return secret.apiKey.trim() || null; + if (secret.type === 'oauth') return secret.accessToken.trim() || null; + return secret.apiKey?.trim() || null; +} + +export async function resolvePiProviderCredentialFromSecretStore( + account: ProviderAccount, +): Promise { + const { getProviderSecret } = await import('../../services/secrets/secret-store'); + return credentialValueForProviderSecret(await getProviderSecret(account.id)); +} + +function accountHex(accountId: string): string { + const normalized = accountId.trim(); + if (!normalized) throw new PiProviderConfigError('PROVIDER_INVALID', 'Provider account id is required'); + return Buffer.from(normalized, 'utf8').toString('hex'); +} + +export function resolvePiRuntimeProviderId(accountId: string): string { + return `makelore-account-${accountHex(accountId)}`; +} + +function apiKeyEnvForAccount(accountId: string): string { + return `${PI_ENV_PREFIX}_ACCOUNT_${accountHex(accountId).toUpperCase()}_API_KEY`; +} + +function headerEnvForAccount(accountId: string, headerName: string): string { + const nameHex = Buffer.from(headerName.toLowerCase(), 'utf8').toString('hex').toUpperCase(); + return `${PI_ENV_PREFIX}_ACCOUNT_${accountHex(accountId).toUpperCase()}_HEADER_${nameHex}`; +} + +function normalizeBaseUrl(value: string | undefined): string | undefined { + const normalized = value?.trim().replace(/\/+$/, ''); + return normalized || undefined; +} + +function normalizeWorksGatewayBaseUrl(value: string | undefined): string | undefined { + const normalized = normalizeBaseUrl(value); + if (!normalized) return undefined; + try { + const parsed = new URL(normalized); + const pathname = parsed.pathname.replace(/\/+$/, ''); + if (!pathname || pathname === '/') parsed.pathname = '/v1'; + return parsed.toString().replace(/\/+$/, ''); + } catch { + return normalized; + } +} + +function defaultBaseUrlForVendor(vendorId: string): string | undefined { + if (vendorId === 'anthropic') return 'https://api.anthropic.com'; + if (vendorId === 'google') return 'https://generativelanguage.googleapis.com/v1beta'; + return getProviderBackendConfig(vendorId)?.baseUrl + ?? getProviderDefinition(vendorId)?.defaultBaseUrl; +} + +function baseUrlForAccount(account: ProviderAccount): string | undefined { + const baseUrl = account.baseUrl + ?? account.metadata?.worksSquareOneApiBaseUrl + ?? defaultBaseUrlForVendor(account.vendorId); + return account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE + ? normalizeWorksGatewayBaseUrl(baseUrl) + : normalizeBaseUrl(baseUrl); +} + +function protocolForAccount(account: ProviderAccount): ProviderProtocol | 'google-generative-ai' | undefined { + if (account.apiProtocol) return account.apiProtocol; + const configured = getProviderBackendConfig(account.vendorId)?.api; + if (configured) return configured; + if (account.vendorId === 'anthropic') return 'anthropic-messages'; + if (account.vendorId === 'google') return 'google-generative-ai'; + if (account.vendorId === 'openai') return 'openai-responses'; + if (account.vendorId === 'openrouter') return 'openrouter'; + return undefined; +} + +function mapProtocol(protocol: ReturnType): { + api: PiProviderApi; + compat?: PiProviderModelDescriptor['compat']; +} { + if (protocol === 'openrouter') { + return { + api: 'openai-completions', + compat: { + thinkingFormat: 'openrouter', + sessionAffinityFormat: 'openrouter', + }, + }; + } + if (protocol && PI_PROVIDER_APIS.includes(protocol as PiProviderApi)) { + return { api: protocol as PiProviderApi }; + } + throw new PiProviderConfigError('PROVIDER_INVALID', 'Provider protocol is not supported by Pi'); +} + +function normalizeModelId(rawModelId: string | undefined, account: ProviderAccount): string | undefined { + const modelId = rawModelId?.trim(); + if (!modelId) return undefined; + const runtimePrefix = `${resolvePiRuntimeProviderId(account.id)}/`; + const unqualified = modelId.startsWith(runtimePrefix) + ? modelId.slice(runtimePrefix.length) + : modelId; + return account.id === NIANCODE_USER_MODEL_ACCOUNT_ID + ? normalizeImportedUserModelId(unqualified) + : unqualified; +} + +function backendModelEntries(account: ProviderAccount): Map { + return new Map((getProviderBackendConfig(account.vendorId)?.models ?? []).map((model) => [model.id, model])); +} + +function modelIdsForAccount(account: ProviderAccount): string[] { + const seen = new Set(); + const result: string[] = []; + const configuredModels = getProviderBackendConfig(account.vendorId)?.models ?? []; + for (const rawModelId of [ + account.model ?? getProviderDefaultModel(account.vendorId), + ...(account.fallbackModels ?? []), + ...(account.metadata?.customModels ?? []), + ...configuredModels.map((model) => model.id), + ]) { + const modelId = normalizeModelId(rawModelId, account); + if (!modelId || seen.has(modelId)) continue; + seen.add(modelId); + result.push(modelId); + } + return result; +} + +function finitePositiveInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 + ? value + : undefined; +} + +function modelDescriptor( + account: ProviderAccount, + modelId: string, + summaries: readonly ModelSummary[], + compat: PiProviderModelDescriptor['compat'], + backendModels: Map, +): PiProviderModelDescriptor { + const summary = summaries.find((candidate) => ( + candidate.id === modelId + && (candidate.accountId === account.id || (!candidate.accountId && candidate.vendorId === account.vendorId)) + )); + const backend = backendModels.get(modelId); + const profile = getImportedModelProfile(modelId); + const backendInput = Array.isArray(backend?.input) + ? backend.input.filter((input): input is 'text' | 'image' => input === 'text' || input === 'image') + : []; + const supportsImage = Boolean( + summary?.supportsVision + || profile?.modalities.input.includes('image') + || backendInput.includes('image'), + ); + const contextWindow = finitePositiveInteger(summary?.contextWindow) + ?? finitePositiveInteger(profile?.limit?.context) + ?? finitePositiveInteger(backend?.contextWindow); + const maxOutputTokens = finitePositiveInteger(profile?.limit?.output) + ?? finitePositiveInteger(backend?.maxTokens); + return { + id: modelId, + name: summary?.name || (typeof backend?.name === 'string' && backend.name.trim()) || modelId, + input: supportsImage ? ['text', 'image'] : ['text'], + reasoning: summary?.supportsReasoning === true || backend?.reasoning === true, + ...(contextWindow ? { contextWindow } : {}), + ...(maxOutputTokens ? { maxOutputTokens } : {}), + ...(compat ? { compat } : {}), + }; +} + +function rawHeadersForAccount(account: ProviderAccount): Record { + const headers: Record = {}; + for (const [name, value] of Object.entries({ + ...(getProviderBackendConfig(account.vendorId)?.headers ?? {}), + ...(account.headers ?? {}), + })) { + const duplicate = Object.keys(headers).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); + if (duplicate) delete headers[duplicate]; + headers[name] = value; + } + return headers; +} + +function headerNamesForAccount(account: ProviderAccount): string[] { + const names = Object.keys(rawHeadersForAccount(account)); + if ( + account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE + && !names.some((name) => name.toLowerCase() === 'authorization') + ) { + names.push('Authorization'); + } + return names.sort((left, right) => left.toLowerCase().localeCompare(right.toLowerCase())); +} + +function descriptorForAccount( + account: ProviderAccount, + summaries: readonly ModelSummary[], +): PiProviderDescriptor { + const runtimeProviderId = resolvePiRuntimeProviderId(account.id); + const mapping = mapProtocol(protocolForAccount(account)); + const baseUrl = baseUrlForAccount(account); + if (!baseUrl) { + throw new PiProviderConfigError( + 'PROVIDER_INVALID', + `Provider account ${account.id} has no runtime base URL`, + ); + } + const apiKeyEnv = apiKeyEnvForAccount(account.id); + const models = modelIdsForAccount(account).map((modelId) => modelDescriptor( + account, + modelId, + summaries, + mapping.compat, + backendModelEntries(account), + )); + if (models.length === 0) { + throw new PiProviderConfigError( + 'PROVIDER_INVALID', + `Provider account ${account.id} has no configured model`, + ); + } + const headers = Object.fromEntries(headerNamesForAccount(account).map((headerName) => [ + headerName, + `$${headerEnvForAccount(account.id, headerName)}`, + ])); + return { + accountId: account.id, + runtimeProviderId, + api: mapping.api, + baseUrl, + headers, + apiKeyEnv, + models, + }; +} + +function modelsFileForDescriptors(descriptors: readonly PiProviderDescriptor[]): PiModelsFile { + return { + providers: Object.fromEntries(descriptors.map((descriptor) => [ + descriptor.runtimeProviderId, + { + ...(descriptor.baseUrl ? { baseUrl: descriptor.baseUrl } : {}), + api: descriptor.api, + ...(descriptor.apiKeyEnv ? { apiKey: `$${descriptor.apiKeyEnv}` } : {}), + ...(Object.keys(descriptor.headers).length > 0 ? { headers: { ...descriptor.headers } } : {}), + models: descriptor.models.map((model) => ({ + id: model.id, + name: model.name, + reasoning: model.reasoning, + input: [...model.input], + ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), + ...(model.maxOutputTokens ? { maxTokens: model.maxOutputTokens } : {}), + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + ...(model.compat ? { compat: { ...model.compat } } : {}), + })), + }, + ])), + }; +} + +export function buildPiProviderCatalog( + options: BuildPiProviderCatalogOptions, +): PiProviderCatalogResult { + const accounts = selectUserModelRuntimeAccounts([...options.accounts]).filter((account) => account.enabled); + const accountIds = new Set(); + const descriptors = accounts.map((account) => { + if (accountIds.has(account.id)) { + throw new PiProviderConfigError('PROVIDER_INVALID', `Duplicate Provider account id: ${account.id}`); + } + accountIds.add(account.id); + return descriptorForAccount(account, options.modelSummaries ?? []); + }); + return { + descriptors, + modelsFile: modelsFileForDescriptors(descriptors), + summary: { + providerCount: descriptors.length, + providers: descriptors.map((descriptor) => ({ + runtimeProviderId: descriptor.runtimeProviderId, + api: descriptor.api, + ...(descriptor.baseUrl ? { baseUrl: descriptor.baseUrl } : {}), + modelIds: descriptor.models.map((model) => model.id), + imageInputModelIds: descriptor.models + .filter((model) => model.input.includes('image')) + .map((model) => model.id), + headerNames: Object.keys(descriptor.headers).sort(), + hasCredentialReference: Boolean(descriptor.apiKeyEnv), + })), + }, + }; +} + +export function selectPiProviderModel( + catalog: PiProviderCatalogResult, + modelRef: ProductModelRef, +): PiProviderSelection { + const descriptor = catalog.descriptors.find((candidate) => candidate.accountId === modelRef.accountId); + const model = descriptor?.models.find((candidate) => candidate.id === modelRef.modelId); + if (!descriptor || !model) { + throw new PiProviderConfigError( + 'MODEL_UNAVAILABLE', + 'The selected Provider account or model is unavailable', + ); + } + return { + accountId: descriptor.accountId, + runtimeProviderId: descriptor.runtimeProviderId, + modelId: model.id, + thinkingLevel: modelRef.thinkingLevel, + input: [...model.input], + ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), + ...(model.maxOutputTokens ? { maxOutputTokens: model.maxOutputTokens } : {}), + }; +} + +export async function writePiProviderCatalog( + filePath: string, + catalog: PiProviderCatalogResult, + modelRef?: ProductModelRef, +): Promise { + if (modelRef) selectPiProviderModel(catalog, modelRef); + await atomicWriteJson(filePath, catalog.modelsFile); +} + +function replaceOpenCodeEnvReferences(value: string, credential: string | null): string { + if (!value.includes('{env:')) return value; + if (!credential) { + throw new PiProviderConfigError('PROVIDER_AUTH_REQUIRED', 'Provider credential is unavailable'); + } + return value.replace(/\{env:[^}]+\}/g, credential); +} + +export async function buildPiWorkerCredentialProjection( + options: BuildPiWorkerCredentialProjectionOptions, +): Promise { + if (options.account.id !== options.descriptor.accountId) { + throw new PiProviderConfigError('PROVIDER_INVALID', 'Provider account does not match descriptor'); + } + const useLocalProxy = options.account.metadata?.worksSquareCredentialMode + === WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE; + const resolved = useLocalProxy + ? options.localProxyCredential?.trim() || null + : await options.resolveCredential(options.account); + const credential = resolved?.trim() + || (options.account.authMode === 'local' ? 'local-provider' : null); + if (!credential) { + throw new PiProviderConfigError('PROVIDER_AUTH_REQUIRED', 'Provider credential is unavailable'); + } + const env: Record = {}; + if (options.descriptor.apiKeyEnv) env[options.descriptor.apiKeyEnv] = credential; + const accountHeaders = rawHeadersForAccount(options.account); + for (const headerName of Object.keys(options.descriptor.headers)) { + const sourceEntry = Object.entries(accountHeaders).find(([name]) => ( + name.toLowerCase() === headerName.toLowerCase() + )); + let value = sourceEntry?.[1]; + if (!value && headerName.toLowerCase() === 'authorization' + && options.account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) { + value = `Bearer ${credential}`; + } + if (value === undefined) { + throw new PiProviderConfigError('PROVIDER_INVALID', `Provider header is unavailable: ${headerName}`); + } + env[headerEnvForAccount(options.account.id, headerName)] = replaceOpenCodeEnvReferences(value, credential); + } + return { + env, + sensitiveValues: [...new Set(Object.values(env).filter(Boolean))], + }; +} + +export function summarizePiWorkerCredentialProjection( + projection: PiWorkerCredentialProjection, +): PiWorkerCredentialProjectionSummary { + return { + envKeys: Object.keys(projection.env).sort(), + sensitiveValueCount: projection.sensitiveValues.length, + }; +} diff --git a/electron/coding-runtime/pi/provider-refresh.ts b/electron/coding-runtime/pi/provider-refresh.ts new file mode 100644 index 0000000..ff6e406 --- /dev/null +++ b/electron/coding-runtime/pi/provider-refresh.ts @@ -0,0 +1,44 @@ +export interface PiProviderAuthRecoveryOptions { + accountId: string; + operation: (attempt: 0 | 1) => Promise; + isAuthenticationError: (error: unknown) => boolean; + refreshCredential: () => Promise; + reopenWorker: () => Promise; +} + +export class PiProviderRefreshCoordinator { + private readonly refreshes = new Map>(); + + get pendingAccountCount(): number { + return this.refreshes.size; + } + + async refreshAccount(accountId: string, refresh: () => Promise): Promise { + const normalizedAccountId = accountId.trim(); + if (!normalizedAccountId) throw new Error('Provider account id is required'); + let pending = this.refreshes.get(normalizedAccountId); + if (!pending) { + pending = Promise.resolve() + .then(refresh) + .finally(() => { + if (this.refreshes.get(normalizedAccountId) === pending) { + this.refreshes.delete(normalizedAccountId); + } + }); + this.refreshes.set(normalizedAccountId, pending); + } + await pending; + } + + async withSingleAuthRecovery(options: PiProviderAuthRecoveryOptions): Promise { + try { + return await options.operation(0); + } catch (error) { + if (!options.isAuthenticationError(error)) throw error; + } + + await this.refreshAccount(options.accountId, options.refreshCredential); + await options.reopenWorker(); + return await options.operation(1); + } +} diff --git a/electron/coding-runtime/pi/resource-loader.ts b/electron/coding-runtime/pi/resource-loader.ts new file mode 100644 index 0000000..0cea4f7 --- /dev/null +++ b/electron/coding-runtime/pi/resource-loader.ts @@ -0,0 +1,193 @@ +import { mkdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { + BUNDLED_CODING_SKILL_IDS, + type BundledCodingSkillId, +} from '../../../shared/coding-skills'; +import { atomicWriteJson, atomicWriteText } from '../../coding-projects/atomic-json'; +import type { PiProviderSelection } from './provider-config'; +import type { PiManagedInputRevision } from './managed-input-revision'; + +const MANAGED_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; + +export interface BundledCodingSkillsPathInput { + isPackaged: boolean; + resourcesPath: string; + appPath: string; +} + +export interface PiManagedPaths { + rootDir: string; + configDir: string; + modelsFile: string; + sessionsDir: string; + promptsDir: string; + extensionsDir: string; + logsDir: string; + trashDir: string; +} + +export interface MaterializePiAgentResourcesOptions { + userDataDir: string; + projectId: string; + agentId: string; + prompt: string; + skillIds: readonly string[]; + bundledSkillsDir: string; + revision: PiManagedInputRevision; +} + +export interface PiAgentResourceManifest { + schemaVersion: 1; + projectId: string; + agentId: string; + promptFile: string; + skillIds: BundledCodingSkillId[]; + revision: PiManagedInputRevision; +} + +export interface PiAgentResourceSnapshot { + paths: PiManagedPaths; + projectSessionsDir: string; + promptPath: string; + manifestPath: string; + skillIds: BundledCodingSkillId[]; + skillPaths: string[]; + revision: PiManagedInputRevision; + summary: { + projectId: string; + agentId: string; + skillIds: BundledCodingSkillId[]; + revision: PiManagedInputRevision; + }; +} + +function managedSegment(value: string, name: string): string { + const normalized = value.trim(); + if (!MANAGED_SEGMENT_PATTERN.test(normalized)) { + throw new Error(`${name} is not a valid managed resource segment`); + } + return normalized; +} + +export function resolveBundledCodingSkillsDir(input: BundledCodingSkillsPathInput): string { + return input.isPackaged + ? path.join(input.resourcesPath, 'resources', 'coding-skills') + : path.join(input.appPath, 'resources', 'coding-skills'); +} + +export function getPiManagedPaths(userDataDir: string): PiManagedPaths { + const rootDir = path.join(path.resolve(userDataDir), 'coding-runtime', 'pi'); + const configDir = path.join(rootDir, 'config'); + return { + rootDir, + configDir, + modelsFile: path.join(configDir, 'models.json'), + sessionsDir: path.join(rootDir, 'sessions'), + promptsDir: path.join(rootDir, 'prompts'), + extensionsDir: path.join(rootDir, 'extensions'), + logsDir: path.join(rootDir, 'logs'), + trashDir: path.join(rootDir, 'trash'), + }; +} + +export async function ensurePiManagedPaths(userDataDir: string): Promise { + const paths = getPiManagedPaths(userDataDir); + await Promise.all([ + mkdir(paths.configDir, { recursive: true }), + mkdir(paths.sessionsDir, { recursive: true }), + mkdir(paths.promptsDir, { recursive: true }), + mkdir(paths.extensionsDir, { recursive: true }), + mkdir(paths.logsDir, { recursive: true }), + mkdir(paths.trashDir, { recursive: true }), + ]); + return paths; +} + +function normalizeSkillIds(skillIds: readonly string[]): BundledCodingSkillId[] { + const result: BundledCodingSkillId[] = []; + const seen = new Set(); + for (const rawSkillId of skillIds) { + const skillId = rawSkillId.trim(); + if (!BUNDLED_CODING_SKILL_IDS.includes(skillId as BundledCodingSkillId)) { + throw new Error(`Unknown bundled coding skill: ${skillId || '(empty)'}`); + } + if (seen.has(skillId)) continue; + seen.add(skillId); + result.push(skillId as BundledCodingSkillId); + } + return result; +} + +export async function resolveExplicitCodingSkillPaths( + bundledSkillsDir: string, + skillIds: readonly string[], +): Promise<{ skillIds: BundledCodingSkillId[]; skillPaths: string[] }> { + const normalizedSkillIds = normalizeSkillIds(skillIds); + const root = path.resolve(bundledSkillsDir); + const skillPaths = normalizedSkillIds.map((skillId) => path.join(root, skillId, 'SKILL.md')); + await Promise.all(skillPaths.map(async (skillPath) => { + const metadata = await stat(skillPath); + if (!metadata.isFile()) throw new Error(`Bundled coding skill entry is not a file: ${skillPath}`); + })); + return { skillIds: normalizedSkillIds, skillPaths }; +} + +export async function materializePiAgentResources( + options: MaterializePiAgentResourcesOptions, +): Promise { + const projectId = managedSegment(options.projectId, 'Project id'); + const agentId = managedSegment(options.agentId, 'Agent id'); + const paths = await ensurePiManagedPaths(options.userDataDir); + const projectSessionsDir = path.join(paths.sessionsDir, projectId); + const projectPromptsDir = path.join(paths.promptsDir, projectId); + await Promise.all([ + mkdir(projectSessionsDir, { recursive: true }), + mkdir(projectPromptsDir, { recursive: true }), + ]); + const { skillIds, skillPaths } = await resolveExplicitCodingSkillPaths( + options.bundledSkillsDir, + options.skillIds, + ); + const promptPath = path.join(projectPromptsDir, `${agentId}.md`); + const manifestPath = path.join(projectPromptsDir, `${agentId}.manifest.json`); + const manifest: PiAgentResourceManifest = { + schemaVersion: 1, + projectId, + agentId, + promptFile: path.basename(promptPath), + skillIds: [...skillIds], + revision: { ...options.revision }, + }; + await atomicWriteText(promptPath, options.prompt); + await atomicWriteJson(manifestPath, manifest); + return { + paths, + projectSessionsDir, + promptPath, + manifestPath, + skillIds: [...skillIds], + skillPaths, + revision: { ...options.revision }, + summary: { + projectId, + agentId, + skillIds: [...skillIds], + revision: { ...options.revision }, + }, + }; +} + +export function buildPiManagedInputArgs( + selection: PiProviderSelection, + resources: PiAgentResourceSnapshot, +): string[] { + const args = [ + '--provider', selection.runtimeProviderId, + '--model', selection.modelId, + '--thinking', selection.thinkingLevel, + '--system-prompt', resources.promptPath, + ]; + for (const skillPath of resources.skillPaths) args.push('--skill', skillPath); + return args; +} diff --git a/electron/coding-runtime/pi/worker-process.ts b/electron/coding-runtime/pi/worker-process.ts index 03ac609..36e1d5b 100644 --- a/electron/coding-runtime/pi/worker-process.ts +++ b/electron/coding-runtime/pi/worker-process.ts @@ -18,6 +18,27 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 10_000; const DEFAULT_SHUTDOWN_GRACE_MS = 3_000; const DEFAULT_DIAGNOSTIC_BYTES = 16_000; const ANSI_COLOR_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); +const PI_INHERITED_ENV_KEYS = [ + 'APPDATA', + 'COMSPEC', + 'HOME', + 'LANG', + 'LC_ALL', + 'LD_LIBRARY_PATH', + 'LOCALAPPDATA', + 'NODE_EXTRA_CA_CERTS', + 'PATH', + 'PATHEXT', + 'SSL_CERT_DIR', + 'SSL_CERT_FILE', + 'SYSTEMROOT', + 'TEMP', + 'TMP', + 'TMPDIR', + 'TZ', + 'USERPROFILE', + 'WINDIR', +] as const; export type PiWorkerStopResult = { mode: 'not-started' | 'stdin-close' | 'forced-tree-kill'; @@ -67,13 +88,50 @@ export function sanitizePiDiagnostic( .replace(ANSI_COLOR_PATTERN, '') .replace(/(authorization\s*[:=]\s*(?:bearer\s+)?)[^\s,;]+/gi, '$1[REDACTED]') .replace(/((?:x-api-key|api[_-]?key|token|secret)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]'); - for (const value of sensitiveValues) { - if (value.length < 4) continue; + const uniqueSensitiveValues = [...new Set(sensitiveValues.filter(Boolean))] + .sort((left, right) => right.length - left.length); + for (const value of uniqueSensitiveValues) { sanitized = sanitized.split(value).join('[REDACTED]'); } return sanitized; } +export function buildPiWorkerEnvironment( + configDir: string, + overlay: NodeJS.ProcessEnv = {}, + inherited: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const key of PI_INHERITED_ENV_KEYS) { + const exact = inherited[key]; + if (exact !== undefined) { + env[key] = exact; + continue; + } + const matchingKey = Object.keys(inherited).find((candidate) => candidate.toUpperCase() === key); + if (matchingKey && inherited[matchingKey] !== undefined) env[matchingKey] = inherited[matchingKey]; + } + return { + ...env, + ...overlay, + ELECTRON_RUN_AS_NODE: '1', + PI_CODING_AGENT_DIR: configDir, + PI_OFFLINE: '1', + PI_TELEMETRY: '0', + }; +} + +function assertSensitiveValuesAbsentFromArgs( + args: readonly string[], + sensitiveValues: readonly string[] = [], +): void { + for (const value of sensitiveValues) { + if (value && args.some((argument) => argument.includes(value))) { + throw new Error('Pi worker arguments contain a sensitive value'); + } + } +} + function boundedUtf8Tail(source: string, maxBytes: number): string { const bytes = Buffer.from(source, 'utf8'); if (bytes.length <= maxBytes) return source; @@ -158,19 +216,14 @@ export class PiWorkerProcess { async start(): Promise { if (this.child) throw new Error('Pi worker process already started'); const generation = this.generationValue; + const args = buildPiRpcArgs(this.options.sessionDir, this.options.additionalArgs); + assertSensitiveValuesAbsentFromArgs(args, this.options.sensitiveValues); const child = spawn( this.options.executablePath, - [this.options.cliPath, ...buildPiRpcArgs(this.options.sessionDir, this.options.additionalArgs)], + [this.options.cliPath, ...args], { cwd: this.options.cwd, - env: { - ...process.env, - ...this.options.env, - ELECTRON_RUN_AS_NODE: '1', - PI_CODING_AGENT_DIR: this.options.configDir, - PI_OFFLINE: '1', - PI_TELEMETRY: '0', - }, + env: buildPiWorkerEnvironment(this.options.configDir, this.options.env), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, detached: platform() !== 'win32', diff --git a/electron/opencode/course-skills.ts b/electron/opencode/course-skills.ts index eb3aec1..074935f 100644 --- a/electron/opencode/course-skills.ts +++ b/electron/opencode/course-skills.ts @@ -7,7 +7,7 @@ import { unlinkSync, } from 'node:fs'; import { join } from 'node:path'; -import { BUNDLED_OPENCODE_SKILL_IDS } from '../../shared/opencode-skills'; +import { BUNDLED_CODING_SKILL_IDS } from '../../shared/coding-skills'; export interface BundledSkillsPathInput { isPackaged: boolean; @@ -20,7 +20,7 @@ export interface EnsureBundledCourseSkillsOptions { sourceDir?: string; } -export const BUNDLED_COURSE_SKILL_IDS = BUNDLED_OPENCODE_SKILL_IDS; +export const BUNDLED_COURSE_SKILL_IDS = BUNDLED_CODING_SKILL_IDS; const RETIRED_COURSE_SKILL_IDS = [ 'course-stage-review', 'student-growth-logger', @@ -48,18 +48,15 @@ const LEGACY_SUPERPOWERS_ARTIFACTS = [ export function resolveBundledCourseSkillsDir(input: BundledSkillsPathInput): string { return input.isPackaged - ? join(input.resourcesPath, 'course-skills') - : join(input.appPath, '.opencode', 'skills'); + ? join(input.resourcesPath, 'resources', 'coding-skills') + : join(input.appPath, 'resources', 'coding-skills'); } export function resolveBundledAgentBrowserPluginPath(input: BundledSkillsPathInput): string { - return join( - resolveBundledCourseSkillsDir(input), - 'agent-browser', - '.opencode', - 'plugins', - 'niancode-agent-browser.js', - ); + const resourcesRoot = input.isPackaged + ? join(input.resourcesPath, 'resources') + : join(input.appPath, 'resources'); + return join(resourcesRoot, 'coding-extensions', 'opencode', 'niancode-agent-browser.js'); } export function getManagedOpencodeConfigDir(userDataDir: string): string { diff --git a/.opencode/skills/agent-browser/.opencode/plugins/niancode-agent-browser.js b/resources/coding-extensions/opencode/niancode-agent-browser.js similarity index 100% rename from .opencode/skills/agent-browser/.opencode/plugins/niancode-agent-browser.js rename to resources/coding-extensions/opencode/niancode-agent-browser.js diff --git a/.opencode/skills/agent-browser/SKILL.md b/resources/coding-skills/agent-browser/SKILL.md similarity index 100% rename from .opencode/skills/agent-browser/SKILL.md rename to resources/coding-skills/agent-browser/SKILL.md diff --git a/.opencode/skills/frontend-slides/LICENSE b/resources/coding-skills/frontend-slides/LICENSE similarity index 100% rename from .opencode/skills/frontend-slides/LICENSE rename to resources/coding-skills/frontend-slides/LICENSE diff --git a/.opencode/skills/frontend-slides/SKILL.md b/resources/coding-skills/frontend-slides/SKILL.md similarity index 100% rename from .opencode/skills/frontend-slides/SKILL.md rename to resources/coding-skills/frontend-slides/SKILL.md diff --git a/.opencode/skills/frontend-slides/agents/openai.yaml b/resources/coding-skills/frontend-slides/agents/openai.yaml similarity index 100% rename from .opencode/skills/frontend-slides/agents/openai.yaml rename to resources/coding-skills/frontend-slides/agents/openai.yaml diff --git a/.opencode/skills/frontend-slides/references/animation-patterns.md b/resources/coding-skills/frontend-slides/references/animation-patterns.md similarity index 100% rename from .opencode/skills/frontend-slides/references/animation-patterns.md rename to resources/coding-skills/frontend-slides/references/animation-patterns.md diff --git a/.opencode/skills/frontend-slides/references/html-template.md b/resources/coding-skills/frontend-slides/references/html-template.md similarity index 100% rename from .opencode/skills/frontend-slides/references/html-template.md rename to resources/coding-skills/frontend-slides/references/html-template.md diff --git a/.opencode/skills/frontend-slides/references/style-presets.md b/resources/coding-skills/frontend-slides/references/style-presets.md similarity index 100% rename from .opencode/skills/frontend-slides/references/style-presets.md rename to resources/coding-skills/frontend-slides/references/style-presets.md diff --git a/.opencode/skills/frontend-slides/references/viewport-base.css b/resources/coding-skills/frontend-slides/references/viewport-base.css similarity index 100% rename from .opencode/skills/frontend-slides/references/viewport-base.css rename to resources/coding-skills/frontend-slides/references/viewport-base.css diff --git a/.opencode/skills/grilling/SKILL.md b/resources/coding-skills/grilling/SKILL.md similarity index 100% rename from .opencode/skills/grilling/SKILL.md rename to resources/coding-skills/grilling/SKILL.md diff --git a/.opencode/skills/planning-with-files/SKILL.md b/resources/coding-skills/planning-with-files/SKILL.md similarity index 100% rename from .opencode/skills/planning-with-files/SKILL.md rename to resources/coding-skills/planning-with-files/SKILL.md diff --git a/scripts/probe-pi-packaged-runtime.mjs b/scripts/probe-pi-packaged-runtime.mjs index 118081e..4af6e93 100644 --- a/scripts/probe-pi-packaged-runtime.mjs +++ b/scripts/probe-pi-packaged-runtime.mjs @@ -10,6 +10,7 @@ import { runProbe, validatePiIdentity, } from './probe-pi-runtime.mjs'; +import { runLocalProviderContracts } from './probe-pi-provider-contracts.mjs'; import { PI_RUNTIME_MANIFEST, defaultPiBundleTarget, @@ -305,6 +306,15 @@ export async function runPackagedProbe(options, projectRoot = process.cwd()) { cliPath, artifactLabel: ARTIFACT_LABEL, }, resolvedProjectRoot); + const providerContracts = await runLocalProviderContracts({ + ...options, + reportPath: undefined, + stage: false, + keepStage: false, + electronExecutablePath: executable, + cliPath, + artifactLabel: ARTIFACT_LABEL, + }, resolvedProjectRoot); const report = { schemaVersion: 1, generatedAt: new Date().toISOString(), @@ -320,6 +330,7 @@ export async function runPackagedProbe(options, projectRoot = process.cwd()) { stagedClosure, packagedClosure, runtime, + providerContracts, result: runtime.result, decision: 'incomplete', }; diff --git a/scripts/probe-pi-provider-contracts.mjs b/scripts/probe-pi-provider-contracts.mjs index 8c67826..a9db142 100644 --- a/scripts/probe-pi-provider-contracts.mjs +++ b/scripts/probe-pi-provider-contracts.mjs @@ -13,6 +13,7 @@ import { const LOCAL_API_KEY_ENV = 'PI_PROBE_LOCAL_CONTRACT_KEY'; const LOCAL_API_KEY = 'pi-local-contract-only'; +const LOCAL_HEADER_ENV = 'PI_PROBE_LOCAL_CONTRACT_HEADER'; const CUSTOM_HEADER = 'x-makelore-pi-probe'; const IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z4N8AAAAASUVORK5CYII='; @@ -227,6 +228,7 @@ export async function runLocalProviderContracts(options, projectRoot = process.c const scratchRoot = await mkdtemp(join(tmpdir(), 'makelore-pi-provider-contracts-')); const requests = []; const previousApiKey = process.env[LOCAL_API_KEY_ENV]; + const previousHeader = process.env[LOCAL_HEADER_ENV]; const server = await startContractServer(requests); process.env[LOCAL_API_KEY_ENV] = LOCAL_API_KEY; try { @@ -243,6 +245,7 @@ export async function runLocalProviderContracts(options, projectRoot = process.c await writeFile(imagePath, Buffer.from(IMAGE_BASE64, 'base64')); const protocols = []; for (const protocol of MAKELore_PROVIDER_PROTOCOLS) { + process.env[LOCAL_HEADER_ENV] = protocol; const protocolRoot = join(scratchRoot, protocol); await mkdir(protocolRoot); const fixturePath = join(protocolRoot, 'fixture.json'); @@ -255,7 +258,7 @@ export async function runLocalProviderContracts(options, projectRoot = process.c apiProtocol: protocol, baseUrl, apiKeyEnv: LOCAL_API_KEY_ENV, - headers: { [CUSTOM_HEADER]: protocol }, + headers: { [CUSTOM_HEADER]: `$${LOCAL_HEADER_ENV}` }, model: { id: modelId, input: ['text', 'image'] }, }, null, 2)}\n`); const qualification = await runProviderQualification( @@ -283,6 +286,8 @@ export async function runLocalProviderContracts(options, projectRoot = process.c scope: { network: '127.0.0.1 only', realProvider: false, + realTurnVerified: false, + realProviderDecision: 'explicitly-waived-accepted-risk', limitation: 'Validates Pi HTTP/SSE request contracts and worker behavior; does not qualify a real provider account.', }, protocols, @@ -296,6 +301,8 @@ export async function runLocalProviderContracts(options, projectRoot = process.c } finally { if (previousApiKey === undefined) delete process.env[LOCAL_API_KEY_ENV]; else process.env[LOCAL_API_KEY_ENV] = previousApiKey; + if (previousHeader === undefined) delete process.env[LOCAL_HEADER_ENV]; + else process.env[LOCAL_HEADER_ENV] = previousHeader; await server.close(); await rm(scratchRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } diff --git a/shared/opencode-skills.ts b/shared/coding-skills.ts similarity index 53% rename from shared/opencode-skills.ts rename to shared/coding-skills.ts index e76a77f..d934316 100644 --- a/shared/opencode-skills.ts +++ b/shared/coding-skills.ts @@ -1,12 +1,14 @@ -export const BUNDLED_OPENCODE_SKILL_IDS = [ +export const BUNDLED_CODING_SKILL_IDS = [ 'agent-browser', 'frontend-slides', 'grilling', 'planning-with-files', ] as const; +export type BundledCodingSkillId = (typeof BUNDLED_CODING_SKILL_IDS)[number]; + export const DEFAULT_PROJECT_AGENT_SKILL_IDS = [ 'agent-browser', 'grilling', 'planning-with-files', -] as const; +] as const satisfies readonly BundledCodingSkillId[]; diff --git a/src/components/opencode/AgentCreationDialog.tsx b/src/components/opencode/AgentCreationDialog.tsx index dc382f9..8c077e2 100644 --- a/src/components/opencode/AgentCreationDialog.tsx +++ b/src/components/opencode/AgentCreationDialog.tsx @@ -11,7 +11,7 @@ import { agentAvatarOptions } from '@/lib/agent-avatars'; import { formatModelRefLabel, type ConfiguredModelOption } from '@/lib/model-options'; import { cn } from '@/lib/utils'; import type { ProjectAgentConfig } from '../../../shared/project-config'; -import { DEFAULT_PROJECT_AGENT_SKILL_IDS } from '../../../shared/opencode-skills'; +import { DEFAULT_PROJECT_AGENT_SKILL_IDS } from '../../../shared/coding-skills'; export type AgentCreationSkillOption = { id: string; diff --git a/tests/unit/agent-browser-plugin.test.ts b/tests/unit/agent-browser-plugin.test.ts index 32b56bd..e73d0d6 100644 --- a/tests/unit/agent-browser-plugin.test.ts +++ b/tests/unit/agent-browser-plugin.test.ts @@ -37,7 +37,7 @@ afterEach(() => { describe('Agent Browser OpenCode plugin', () => { it('loads the bundled runtime plugin artifact', async () => { const bundled = await import( - '../../.opencode/skills/agent-browser/.opencode/plugins/niancode-agent-browser.js' + '../../resources/coding-extensions/opencode/niancode-agent-browser.js' ); expect(bundled.NianCodeAgentBrowserPlugin).toBeTypeOf('function'); diff --git a/tests/unit/opencode-manager.test.ts b/tests/unit/opencode-manager.test.ts index db12a19..feddf70 100644 --- a/tests/unit/opencode-manager.test.ts +++ b/tests/unit/opencode-manager.test.ts @@ -63,10 +63,9 @@ describe('OpencodeManager', () => { appPath: 'C:\\Program Files\\Makelore\\resources\\app.asar', })).toBe(join( resourcesPath, - 'course-skills', - 'agent-browser', - '.opencode', - 'plugins', + 'resources', + 'coding-extensions', + 'opencode', 'niancode-agent-browser.js', )); }); diff --git a/tests/unit/pi-provider-config.test.ts b/tests/unit/pi-provider-config.test.ts new file mode 100644 index 0000000..acd3fe9 --- /dev/null +++ b/tests/unit/pi-provider-config.test.ts @@ -0,0 +1,225 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ProviderAccount } from '@electron/shared/providers/types'; +import { + buildPiProviderCatalog, + buildPiWorkerCredentialProjection, + credentialValueForProviderSecret, + PiProviderConfigError, + resolvePiRuntimeProviderId, + selectPiProviderModel, + summarizePiWorkerCredentialProjection, + writePiProviderCatalog, +} from '@electron/coding-runtime/pi/provider-config'; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +function account(overrides: Partial = {}): ProviderAccount { + return { + id: 'account-one', + vendorId: 'openai', + label: 'Primary account', + authMode: 'api_key', + model: 'gpt-5.4', + enabled: true, + isDefault: true, + createdAt: '2026-08-22T00:00:00.000Z', + updatedAt: '2026-08-22T00:00:00.000Z', + ...overrides, + }; +} + +describe('Pi Provider catalog', () => { + it('maps every current Provider protocol shape to a Pi API', () => { + const catalog = buildPiProviderCatalog({ + accounts: [ + account({ id: 'completions', vendorId: 'custom', apiProtocol: 'openai-completions', baseUrl: 'https://one.test/v1', model: 'chat' }), + account({ id: 'responses', vendorId: 'custom', apiProtocol: 'openai-responses', baseUrl: 'https://two.test/v1', model: 'response' }), + account({ id: 'anthropic', vendorId: 'anthropic', model: 'claude-opus-4-6' }), + account({ id: 'google', vendorId: 'google', model: 'gemini-3-pro-preview' }), + account({ id: 'openrouter', vendorId: 'openrouter', model: 'openai/gpt-5.4' }), + ], + }); + + expect(catalog.descriptors.map(({ api }) => api)).toEqual([ + 'openai-completions', + 'openai-responses', + 'anthropic-messages', + 'google-generative-ai', + 'openai-completions', + ]); + expect(catalog.descriptors.at(-1)?.models[0]?.compat).toEqual({ + thinkingFormat: 'openrouter', + sessionAffinityFormat: 'openrouter', + }); + expect(Object.keys(catalog.descriptors.at(-1)?.headers ?? {}).sort()).toEqual([ + 'HTTP-Referer', + 'X-OpenRouter-Title', + ]); + }); + + it('uses stable collision-free account-derived runtime IDs for the same vendor', () => { + const first = account({ id: 'openai-account-a' }); + const second = account({ id: 'openai-account-b', isDefault: false }); + const catalog = buildPiProviderCatalog({ accounts: [first, second] }); + + expect(catalog.descriptors[0]?.runtimeProviderId).toBe(resolvePiRuntimeProviderId(first.id)); + expect(catalog.descriptors[1]?.runtimeProviderId).toBe(resolvePiRuntimeProviderId(second.id)); + expect(catalog.descriptors[0]?.runtimeProviderId).not.toBe(catalog.descriptors[1]?.runtimeProviderId); + }); + + it('normalizes Works gateway /v1 and keeps all credential and header values out of catalog output', async () => { + const provider = account({ + id: 'niancode-user-models', + vendorId: 'custom', + apiProtocol: 'openai-completions', + baseUrl: 'https://gateway.test/', + model: 'qwen3.6-plus', + headers: { + 'X-Works-Square-AI-Token': '{env:OLD_GATEWAY_TOKEN}', + 'X-Tenant-Secret': 'private-tenant-header', + }, + metadata: { + worksSquareCredentialMode: 'works_square_ai_gateway', + customModels: ['qwen3.6-plus'], + }, + }); + const catalog = buildPiProviderCatalog({ accounts: [provider] }); + const descriptor = catalog.descriptors[0]!; + const serialized = JSON.stringify(catalog); + + expect(descriptor.baseUrl).toBe('https://gateway.test/v1'); + expect(Object.keys(descriptor.headers).sort()).toEqual([ + 'Authorization', + 'X-Tenant-Secret', + 'X-Works-Square-AI-Token', + ]); + expect(descriptor.models[0]).toMatchObject({ + id: 'qwen3.6-plus', + input: ['text', 'image'], + contextWindow: 1_000_000, + maxOutputTokens: 65_536, + }); + expect(serialized).not.toContain('private-tenant-header'); + expect(serialized).not.toContain('OLD_GATEWAY_TOKEN'); + + const projection = await buildPiWorkerCredentialProjection({ + account: provider, + descriptor, + resolveCredential: vi.fn().mockResolvedValue('gateway-proxy-token'), + }); + expect(Object.values(projection.env)).toEqual(expect.arrayContaining([ + 'gateway-proxy-token', + 'Bearer gateway-proxy-token', + 'private-tenant-header', + ])); + expect(projection.sensitiveValues).toContain('gateway-proxy-token'); + const safeProjection = summarizePiWorkerCredentialProjection(projection); + expect(JSON.stringify(safeProjection)).not.toContain('gateway-proxy-token'); + expect(JSON.stringify(safeProjection)).not.toContain('private-tenant-header'); + }); + + it('uses only the current worker local-proxy credential for proxy mode', async () => { + const provider = account({ + id: 'niancode-user-models', + vendorId: 'custom', + apiProtocol: 'openai-completions', + baseUrl: 'http://127.0.0.1:54321/api/ai-proxy/v1', + model: 'qwen-vl-max', + metadata: { worksSquareCredentialMode: 'works_square_ai_gateway_proxy' }, + }); + const descriptor = buildPiProviderCatalog({ accounts: [provider] }).descriptors[0]!; + const resolveCredential = vi.fn().mockResolvedValue('stale-stored-host-token'); + const projection = await buildPiWorkerCredentialProjection({ + account: provider, + descriptor, + resolveCredential, + localProxyCredential: 'current-worker-host-token', + }); + + expect(resolveCredential).not.toHaveBeenCalled(); + expect(Object.values(projection.env)).toContain('current-worker-host-token'); + expect(Object.values(projection.env)).not.toContain('stale-stored-host-token'); + }); + + it('uses account-scoped model capability metadata and rejects unavailable models', () => { + const provider = account({ id: 'account-with-vision', model: 'vision-model' }); + const catalog = buildPiProviderCatalog({ + accounts: [provider], + modelSummaries: [{ + id: 'vision-model', + name: 'Vision model', + vendorId: 'openai', + accountId: provider.id, + supportsVision: true, + supportsReasoning: true, + contextWindow: 200_000, + source: 'remote', + }], + }); + expect(selectPiProviderModel(catalog, { + accountId: provider.id, + modelId: 'vision-model', + thinkingLevel: 'high', + })).toMatchObject({ + input: ['text', 'image'], + contextWindow: 200_000, + thinkingLevel: 'high', + }); + expect(() => selectPiProviderModel(catalog, { + accountId: provider.id, + modelId: 'missing-model', + thinkingLevel: 'off', + })).toThrowError(PiProviderConfigError); + }); + + it('does not replace an existing catalog when model selection is unavailable', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-provider-')); + temporaryRoots.push(root); + const filePath = path.join(root, 'models.json'); + await writeFile(filePath, 'existing-catalog\n', 'utf8'); + const catalog = buildPiProviderCatalog({ accounts: [account()] }); + + await expect(writePiProviderCatalog(filePath, catalog, { + accountId: 'account-one', + modelId: 'not-configured', + thinkingLevel: 'off', + })).rejects.toMatchObject({ code: 'MODEL_UNAVAILABLE' }); + expect(await readFile(filePath, 'utf8')).toBe('existing-catalog\n'); + }); + + it('fails closed when a non-local credential is unavailable', async () => { + const provider = account(); + const descriptor = buildPiProviderCatalog({ accounts: [provider] }).descriptors[0]!; + await expect(buildPiWorkerCredentialProjection({ + account: provider, + descriptor, + resolveCredential: vi.fn().mockResolvedValue(null), + })).rejects.toMatchObject({ code: 'PROVIDER_AUTH_REQUIRED' }); + }); + + it('projects API key, OAuth, and local secrets to one worker credential value', () => { + expect(credentialValueForProviderSecret({ + type: 'api_key', + accountId: 'account-one', + apiKey: 'api-key-value', + })).toBe('api-key-value'); + expect(credentialValueForProviderSecret({ + type: 'oauth', + accountId: 'account-one', + accessToken: 'oauth-access-token', + refreshToken: 'refresh-token-never-projected', + expiresAt: Date.now() + 60_000, + })).toBe('oauth-access-token'); + expect(credentialValueForProviderSecret({ + type: 'local', + accountId: 'account-one', + })).toBeNull(); + }); +}); diff --git a/tests/unit/pi-provider-state.test.ts b/tests/unit/pi-provider-state.test.ts new file mode 100644 index 0000000..ccb2409 --- /dev/null +++ b/tests/unit/pi-provider-state.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PiManagedInputRevisionCoordinator } from '@electron/coding-runtime/pi/managed-input-revision'; +import { PiProviderRefreshCoordinator } from '@electron/coding-runtime/pi/provider-refresh'; + +describe('Pi Provider refresh coordination', () => { + it('coalesces concurrent refreshes for one account but not different accounts', async () => { + const coordinator = new PiProviderRefreshCoordinator(); + let releaseFirst!: () => void; + const firstWait = new Promise((resolve) => { releaseFirst = resolve; }); + const sameAccountRefresh = vi.fn(async () => { await firstWait; }); + const otherAccountRefresh = vi.fn(async () => undefined); + + const first = coordinator.refreshAccount('account-a', sameAccountRefresh); + const second = coordinator.refreshAccount('account-a', sameAccountRefresh); + const other = coordinator.refreshAccount('account-b', otherAccountRefresh); + await other; + expect(coordinator.pendingAccountCount).toBe(1); + releaseFirst(); + await Promise.all([first, second]); + + expect(sameAccountRefresh).toHaveBeenCalledTimes(1); + expect(otherAccountRefresh).toHaveBeenCalledTimes(1); + expect(coordinator.pendingAccountCount).toBe(0); + }); + + it('refreshes and reopens at most once for one failed operation', async () => { + const coordinator = new PiProviderRefreshCoordinator(); + const authError = new Error('401'); + const operation = vi.fn(async () => { throw authError; }); + const refreshCredential = vi.fn(async () => undefined); + const reopenWorker = vi.fn(async () => undefined); + + await expect(coordinator.withSingleAuthRecovery({ + accountId: 'account-a', + operation, + isAuthenticationError: (error) => error === authError, + refreshCredential, + reopenWorker, + })).rejects.toBe(authError); + + expect(operation).toHaveBeenCalledTimes(2); + expect(operation.mock.calls.map(([attempt]) => attempt)).toEqual([0, 1]); + expect(refreshCredential).toHaveBeenCalledTimes(1); + expect(reopenWorker).toHaveBeenCalledTimes(1); + }); + + it('does not refresh for a non-authentication failure', async () => { + const coordinator = new PiProviderRefreshCoordinator(); + const failure = new Error('timeout'); + const refreshCredential = vi.fn(async () => undefined); + const reopenWorker = vi.fn(async () => undefined); + await expect(coordinator.withSingleAuthRecovery({ + accountId: 'account-a', + operation: async () => { throw failure; }, + isAuthenticationError: () => false, + refreshCredential, + reopenWorker, + })).rejects.toBe(failure); + expect(refreshCredential).not.toHaveBeenCalled(); + expect(reopenWorker).not.toHaveBeenCalled(); + }); +}); + +describe('Pi managed input revision coordination', () => { + it('rebuilds an idle stale worker before its next prompt', () => { + const coordinator = new PiManagedInputRevisionCoordinator(); + coordinator.registerWorker('worker'); + coordinator.markProviderStale(); + + expect(coordinator.beforePrompt('worker')).toEqual({ + action: 'rebuild-before-prompt', + revision: { provider: 2, resources: 1 }, + }); + coordinator.applyCurrentRevision('worker'); + expect(coordinator.beforePrompt('worker').action).toBe('reuse'); + }); + + it('keeps a running snapshot and schedules rebuild only after settlement', () => { + const coordinator = new PiManagedInputRevisionCoordinator(); + coordinator.registerWorker('worker'); + const runRevision = coordinator.beginRun('worker'); + coordinator.markResourcesStale(); + + expect(runRevision).toEqual({ provider: 1, resources: 1 }); + expect(coordinator.beforePrompt('worker')).toEqual({ + action: 'defer-until-settled', + revision: { provider: 1, resources: 1 }, + }); + expect(() => coordinator.applyCurrentRevision('worker')).toThrow('run is active'); + expect(coordinator.settleRun('worker')).toEqual({ + action: 'rebuild-after-settled', + revision: { provider: 1, resources: 2 }, + }); + }); +}); diff --git a/tests/unit/pi-resource-loader.test.ts b/tests/unit/pi-resource-loader.test.ts new file mode 100644 index 0000000..02f0653 --- /dev/null +++ b/tests/unit/pi-resource-loader.test.ts @@ -0,0 +1,157 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import YAML from 'yaml'; +import type { PiProviderSelection } from '@electron/coding-runtime/pi/provider-config'; +import { + buildPiManagedInputArgs, + getPiManagedPaths, + materializePiAgentResources, + resolveBundledCodingSkillsDir, + resolveExplicitCodingSkillPaths, +} from '@electron/coding-runtime/pi/resource-loader'; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function fixtureRoot(): Promise<{ + root: string; + userDataDir: string; + projectDir: string; + skillsDir: string; +}> { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-resources-')); + temporaryRoots.push(root); + const userDataDir = path.join(root, 'user-data'); + const projectDir = path.join(root, 'project'); + const skillsDir = path.join(root, 'bundled-skills'); + await Promise.all([ + mkdir(path.join(projectDir, '.pi', 'skills', 'untrusted-project-skill'), { recursive: true }), + mkdir(path.join(projectDir, '.agents', 'skills', 'untrusted-agent-skill'), { recursive: true }), + mkdir(path.join(skillsDir, 'grilling'), { recursive: true }), + mkdir(path.join(skillsDir, 'agent-browser'), { recursive: true }), + ]); + await Promise.all([ + writeFile(path.join(projectDir, '.pi', 'skills', 'untrusted-project-skill', 'SKILL.md'), 'untrusted', 'utf8'), + writeFile(path.join(projectDir, '.agents', 'skills', 'untrusted-agent-skill', 'SKILL.md'), 'untrusted', 'utf8'), + writeFile(path.join(skillsDir, 'grilling', 'SKILL.md'), '---\nname: grilling\n---\n', 'utf8'), + writeFile(path.join(skillsDir, 'agent-browser', 'SKILL.md'), '---\nname: agent-browser\n---\n', 'utf8'), + ]); + return { root, userDataDir, projectDir, skillsDir }; +} + +describe('Pi managed resource loader', () => { + it('materializes only managed prompt and explicitly selected bundled skills', async () => { + const fixture = await fixtureRoot(); + const prompt = 'PRIVATE PARTNER PROMPT CONTENT'; + const resources = await materializePiAgentResources({ + userDataDir: fixture.userDataDir, + projectId: 'project-1', + agentId: 'agent-1', + prompt, + skillIds: ['grilling', 'grilling'], + bundledSkillsDir: fixture.skillsDir, + revision: { provider: 3, resources: 7 }, + }); + + expect(await readFile(resources.promptPath, 'utf8')).toBe(prompt); + expect(resources.skillIds).toEqual(['grilling']); + expect(resources.skillPaths).toEqual([path.join(fixture.skillsDir, 'grilling', 'SKILL.md')]); + expect(JSON.stringify(resources.summary)).not.toContain(prompt); + const manifest = JSON.parse(await readFile(resources.manifestPath, 'utf8')) as Record; + expect(manifest).toMatchObject({ + schemaVersion: 1, + projectId: 'project-1', + agentId: 'agent-1', + promptFile: 'agent-1.md', + skillIds: ['grilling'], + revision: { provider: 3, resources: 7 }, + }); + expect(JSON.stringify(manifest)).not.toContain(prompt); + await expect(readFile(path.join(fixture.userDataDir, '.pi', 'agents', 'agent-1.md'), 'utf8')) + .rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('builds argv from managed paths without prompt content, credentials, or auto-discovery roots', async () => { + const fixture = await fixtureRoot(); + const prompt = 'PROMPT-MUST-NOT-BE-IN-ARGV'; + const resources = await materializePiAgentResources({ + userDataDir: fixture.userDataDir, + projectId: 'project-1', + agentId: 'agent-1', + prompt, + skillIds: ['agent-browser'], + bundledSkillsDir: fixture.skillsDir, + revision: { provider: 1, resources: 1 }, + }); + const selection: PiProviderSelection = { + accountId: 'private-account-id', + runtimeProviderId: 'makelore-account-opaque', + modelId: 'model-a', + thinkingLevel: 'medium', + input: ['text'], + }; + const args = buildPiManagedInputArgs(selection, resources); + const serialized = JSON.stringify(args); + + expect(args).toEqual([ + '--provider', 'makelore-account-opaque', + '--model', 'model-a', + '--thinking', 'medium', + '--system-prompt', resources.promptPath, + '--skill', path.join(fixture.skillsDir, 'agent-browser', 'SKILL.md'), + ]); + expect(serialized).not.toContain(prompt); + expect(serialized).not.toContain('private-account-id'); + expect(serialized).not.toContain(path.join(fixture.projectDir, '.pi')); + expect(serialized).not.toContain(path.join(fixture.projectDir, '.agents')); + }); + + it('rejects unknown skills and unsafe managed path segments', async () => { + const fixture = await fixtureRoot(); + await expect(resolveExplicitCodingSkillPaths(fixture.skillsDir, ['not-bundled'])) + .rejects.toThrow('Unknown bundled coding skill'); + await expect(materializePiAgentResources({ + userDataDir: fixture.userDataDir, + projectId: '../outside', + agentId: 'agent-1', + prompt: '', + skillIds: [], + bundledSkillsDir: fixture.skillsDir, + revision: { provider: 1, resources: 1 }, + })).rejects.toThrow('Project id'); + }); + + it('uses the same vendor-neutral resource source in development and packaged apps', () => { + expect(resolveBundledCodingSkillsDir({ + isPackaged: false, + resourcesPath: 'C:\\Program Files\\Makelore\\resources', + appPath: 'D:\\source\\makelore', + })).toBe(path.join('D:\\source\\makelore', 'resources', 'coding-skills')); + expect(resolveBundledCodingSkillsDir({ + isPackaged: true, + resourcesPath: 'C:\\Program Files\\Makelore\\resources', + appPath: 'unused', + })).toBe(path.join('C:\\Program Files\\Makelore\\resources', 'resources', 'coding-skills')); + expect(getPiManagedPaths('D:\\user-data').rootDir) + .toBe(path.join(path.resolve('D:\\user-data'), 'coding-runtime', 'pi')); + }); + + it('packages coding skills through the vendor-neutral resources bundle only', async () => { + const builder = YAML.parse(await readFile('electron-builder.yml', 'utf8')) as { + extraResources: Array<{ from: string; to: string }>; + }; + expect(builder.extraResources).toContainEqual(expect.objectContaining({ + from: 'resources/', + to: 'resources/', + })); + expect(builder.extraResources).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ from: '.opencode/skills/' }), + expect.objectContaining({ to: 'course-skills/' }), + ])); + }); +}); diff --git a/tests/unit/pi-rpc-foundation.test.ts b/tests/unit/pi-rpc-foundation.test.ts index a82d98b..4396cf4 100644 --- a/tests/unit/pi-rpc-foundation.test.ts +++ b/tests/unit/pi-rpc-foundation.test.ts @@ -12,6 +12,7 @@ import { PiWorkerProcess, buildPiRpcArgs, sanitizePiDiagnostic, + buildPiWorkerEnvironment, } from '../../electron/coding-runtime/pi/worker-process'; const fakeChildPath = resolve('tests/fixtures/fake-pi-rpc-child.mjs'); @@ -254,6 +255,39 @@ describe('Pi worker process', () => { expect(worker.stderrDiagnostic).toContain('[REDACTED]'); expect(Buffer.byteLength(worker.stderrDiagnostic)).toBeLessThanOrEqual(160); expect(sanitizePiDiagnostic(`token=${secret}`, [secret])).toBe('token=[REDACTED]'); + expect(sanitizePiDiagnostic('custom-header=q', ['q'])).toBe('custom-header=[REDACTED]'); + }); + + it('inherits only the worker-safe environment allowlist', () => { + const env = buildPiWorkerEnvironment( + 'D:\\managed-pi', + { MAKELore_PI_SELECTED_API_KEY: 'selected-secret' }, + { + PATH: 'D:\\tools', + OPENAI_API_KEY: 'unrelated-openai-secret', + ANTHROPIC_API_KEY: 'unrelated-anthropic-secret', + CUSTOM_APPLICATION_SECRET: 'unrelated-custom-secret', + }, + ); + expect(env).toMatchObject({ + PATH: 'D:\\tools', + MAKELore_PI_SELECTED_API_KEY: 'selected-secret', + PI_CODING_AGENT_DIR: 'D:\\managed-pi', + PI_OFFLINE: '1', + PI_TELEMETRY: '0', + ELECTRON_RUN_AS_NODE: '1', + }); + expect(env).not.toHaveProperty('OPENAI_API_KEY'); + expect(env).not.toHaveProperty('ANTHROPIC_API_KEY'); + expect(env).not.toHaveProperty('CUSTOM_APPLICATION_SECRET'); + }); + + it('refuses to put a selected worker credential in argv', async () => { + const secret = 'argv-secret-value'; + await expect(makeWorker({ + additionalArgs: ['--api-key', secret], + sensitiveValues: [secret], + })).rejects.toThrow('arguments contain a sensitive value'); }); it('forces the complete child tree down after the graceful deadline', async () => { diff --git a/tests/unit/planning-with-files-skill.test.ts b/tests/unit/planning-with-files-skill.test.ts index a7da241..e2473c6 100644 --- a/tests/unit/planning-with-files-skill.test.ts +++ b/tests/unit/planning-with-files-skill.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; describe('planning-with-files Skill', () => { it('requires planning files directly in the current project root', async () => { const skill = await readFile( - path.join(process.cwd(), '.opencode', 'skills', 'planning-with-files', 'SKILL.md'), + path.join(process.cwd(), 'resources', 'coding-skills', 'planning-with-files', 'SKILL.md'), 'utf8', );