diff --git a/.project-docs/30-worklog/tasks/20260824-pi-runtime-unavailable-hotfix-4e9c7a31.md b/.project-docs/30-worklog/tasks/20260824-pi-runtime-unavailable-hotfix-4e9c7a31.md new file mode 100644 index 0000000..66bad91 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260824-pi-runtime-unavailable-hotfix-4e9c7a31.md @@ -0,0 +1,141 @@ +# Task: Fix packaged Pi proxy token initialization and recovery + +## Identity + +- Task ID: 20260824-pi-runtime-unavailable-hotfix-4e9c7a31 +- Mode: Feature +- Branch: codex/20260824-pi-runtime-unavailable-hotfix-4e9c7a31-pi-runtime-unavailable-hotfix +- Worktree: D:\Datas\OthersProjects\makelore-pi-runtime-unavailable-hotfix-4e9c7a31 +- Base commit: fa510c49761485cbd323801923dbe2472d6981bb +- Owner: codex-root +- Status: In Progress + +## Scope + +- Fix the Windows packaged Pi startup failure for the + `works_square_ai_gateway_proxy` account without changing the OpenCode-to-Pi + architecture or starting PI-160. +- Resolve the Main Host API credential lazily for persistent parent open, + rebuild/recover, and ephemeral child open; preserve Main ownership and prove + token rotation does not reuse a captured value. +- Map Pi's typed `PROVIDER_AUTH_REQUIRED` failure through the existing single + refresh/reopen boundary, and make Renderer Conversation recovery failure + leave `recovering` for a retryable per-Conversation error without losing + user state. +- Produce and verify a Windows x64 NSIS installer from the final clean + candidate, including a real packaged Main-composition proxy first- + Conversation proof and token non-disclosure checks. + +## Intent And Constraints + +- Base exactly on `fa510c49761485cbd323801923dbe2472d6981bb`, which is the + common HEAD of the PI-150 release-proof and diagnostic worktrees and contains + code candidate `09841c8`; do not rewrite or drop cumulative PI-150 history. +- Do not modify the old PI-150 worktree, diagnostic worktree, occupied/dirty + `main`, or the user's installed application under + `D:\Tools\泥土\niancode\Makelore`. +- Keep the Host token Main-owned and memory-only. Never write it to argv, + `models.json`, logs, diagnostics, task evidence, or Renderer state. +- Do not upgrade Pi, add a fallback/compatibility layer, refactor Host auth, or + change direct API-key, OAuth, or local Provider behavior. +- Real Provider verification remains `Explicitly Waived / Accepted Risk` with + `realTurnVerified=false`; macOS and native non-WSL Linux gate status is + unchanged. +- Use the exact package-manager version pinned by `packageManager`, frozen + install semantics, focused regressions, full repository verification, and + the formal Windows packaging path. + +## Project Context Loaded + +- Concurrent Task Gate: Passed in the isolated worktree above; ownership is + `codex-root`, feature mode, and the task record matches the registry. +- Planning Gate: Passed after reading the diagnostic record, PI-150 source + record, memory index, positioning/current-state/decision/architecture/domain/ + evidence/reflection/commitment/stale records, and all active peer scopes. +- The shared canonical snapshot is older than the Pi feature chain. The + diagnostic record, cumulative PI-150 source record, current source, and the + delegated repair boundary are authoritative for this task. +- Other active tasks use separate worktrees. The main-worktree integration task + concerns the older OpenCode model-switch path and explicitly does not package; + no semantic conflict requires changing this hotfix plan. +- No subagents are used by explicit user direction. + +## Plan + +1. Inspect the exact composition, opener, auth-classifier, recovery-store, test, + Windows Electron E2E, and packaged proof seams; identify the smallest + production and test surface. +2. Add red-capable focused regressions for lazy/current token reads across + parent/rebuild/recover/child, typed auth mapping and bounded refresh, recovery + failure/retry, and unaffected direct/OAuth/local Provider paths. +3. Implement the lazy Main-owned credential getter, typed error classification, + and retryable recovery-state transition with surgical changes only. +4. Run focused tests, typecheck, lint, the full unit suite, production build, + and Windows Electron E2E; fix only failures caused by this hotfix. +5. Commit the clean implementation candidate, run formal `package:win`, execute + Windows artifact/runtime closure checks, and run a final packaged Main- + composition proxy first-Conversation proof including token-secrecy and clean + process-exit assertions. +6. Record exact commits, changed files, verification results, NSIS size/hash, + structured packaged evidence and unchanged release waivers; pass the Task + Documentation Gate and mark the task ready for integration. + +## Outcome + +- Implemented a lazy `getLocalProxyCredential` seam from Electron Main through + the single coding composition. Composition construction no longer reads or + captures the pre-server token; persistent parent first-open, recover/rebuild, + and ephemeral child open all resolve the getter at the worker-open boundary. +- Extended the existing typed Provider-auth classifier to recognize only + `PiProviderConfigError.code === PROVIDER_AUTH_REQUIRED`. Initial prepare and + prompt authentication failures now use the existing maximum-one refresh and + reopen coordinator and project as `CODING_PROVIDER_AUTH_REQUIRED`. +- Made Renderer recovery failures leave `recovering` for the target + Conversation's retryable `error` state while preserving the selected + Conversation, last good Snapshot, draft, and attachments; a later retry can + recover normally. +- Added focused rotation/non-disclosure regressions for parent first-open, + recover, rebuild, and child open while retaining the pre-existing direct + API-key/OAuth/local credential coverage. +- Extended the final packaged proof entry so the actual global Main composition + can exercise `works_square_ai_gateway_proxy` through the current authenticated + Host proxy, create the first Conversation from the real UI, establish a Pi + binding, accept input, dispatch a real packaged parent and child, and verify + argv/models/log/diagnostic token non-disclosure. Its upstream is controlled + loopback and the report remains `realTurnVerified=false`. +- Implementation and repository verification are complete. Formal Windows NSIS + packaging and final-artifact proof remain pending from the clean candidate. + +## Verification + +- `corepack pnpm install --frozen-lockfile` with pinned pnpm `10.33.4`: passed, + 997 packages linked from the existing store and the lockfile remained + unchanged. +- Focused Vitest run for composition, auth, Renderer recovery, parent opener, + child opener, and auth recovery: 6 files / 55 tests passed. The preceding red + run failed exactly on typed auth classification, auth-required projection, + and permanent `recovering` state. +- `corepack pnpm run typecheck`: passed after the final proof changes. +- `corepack pnpm run lint:check`: passed with 0 errors and 5 pre-existing React + warnings in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `corepack pnpm test`: passed; primary run 178 files / 1512 passed / 2 skipped, + serialized pressure run 1 file / 1 passed. +- `corepack pnpm run build:vite`: passed; Renderer, Main, Preload, and release + utility bundles built. Existing dynamic-import and large-chunk warnings remain. +- `corepack pnpm run test:electron:windows`: passed; 2 files / 4 tests. +- `corepack pnpm run package:win`, final artifact/closure verifier, and final + packaged global-Main proxy proof: pending the clean implementation commit. + +## Follow-ups + +- Real external Provider turn verification remains explicitly waived and its + concurrency, credential-isolation, and protocol-compatibility risk remains + accepted; the loopback Host-proxy proof must not be reported as a real + Provider pass. +- macOS and native non-WSL Linux release gates remain unchanged by this Windows + hotfix. + +## Promotion Candidates + +- None. This feature task records the hotfix and evidence locally; it does not + promote stale shared OpenCode-era canonical documents. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index e66ecc5..ae1b093 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -46,7 +46,7 @@ export interface CreateCodingCompositionOptions { projectStore?: CodingProjectStore; browser: AgentBrowserModule; paths: CodingCompositionPaths; - localProxyCredential?: string; + getLocalProxyCredential?(): string | undefined; } export function resolveCodingPiRuntimePaths(input: { @@ -73,6 +73,7 @@ export function resolveCodingPiRuntimePaths(input: { export function createCodingComposition( options: CreateCodingCompositionOptions, ): CodingProductComposition { + const getLocalProxyCredential = options.getLocalProxyCredential; const projectStore = options.projectStore ?? createCodingProjectStore(options.storage); const attachments = new CodingAttachmentStore( path.join(options.paths.userDataDir, 'coding-runtime', 'attachments'), @@ -113,8 +114,8 @@ export function createCodingComposition( bundledSkillsDir: options.paths.bundledSkillsDir, loadProviderInput, resolveCredential: resolvePiProviderCredentialFromSecretStore, - ...(options.localProxyCredential - ? { getLocalProxyCredential: async () => options.localProxyCredential } + ...(getLocalProxyCredential + ? { getLocalProxyCredential: async () => getLocalProxyCredential() } : {}), extensionHost, }), @@ -129,8 +130,8 @@ export function createCodingComposition( loadProviderInput, resolveCredential: resolvePiProviderCredentialFromSecretStore, getRevision: () => revisions.current, - ...(options.localProxyCredential - ? { getLocalProxyCredential: async () => options.localProxyCredential } + ...(getLocalProxyCredential + ? { getLocalProxyCredential: async () => getLocalProxyCredential() } : {}), }); const subagents = new PiSubagentScheduler({ diff --git a/electron/api/coding-provider-auth.ts b/electron/api/coding-provider-auth.ts index 0ab08b6..08d8cac 100644 --- a/electron/api/coding-provider-auth.ts +++ b/electron/api/coding-provider-auth.ts @@ -1,4 +1,7 @@ -import { resolvePiProviderCredentialFromSecretStore } from '../coding-runtime/pi/provider-config'; +import { + PiProviderConfigError, + resolvePiProviderCredentialFromSecretStore, +} from '../coding-runtime/pi/provider-config'; import { getProviderService } from '../services/providers/provider-service'; import { getFreshWorksSquareAIGatewayCredential, @@ -17,6 +20,7 @@ export class CodingProviderCredentialRefreshError extends Error { export function isCodingProviderAuthenticationError(error: unknown): boolean { return error instanceof CodingProviderCredentialRefreshError + || (error instanceof PiProviderConfigError && error.code === 'PROVIDER_AUTH_REQUIRED') || (error instanceof Error && AUTHENTICATION_ERROR_PATTERN.test(error.message)); } diff --git a/electron/coding-runtime/pi/release-proof.ts b/electron/coding-runtime/pi/release-proof.ts index 7489a94..db0992e 100644 --- a/electron/coding-runtime/pi/release-proof.ts +++ b/electron/coding-runtime/pi/release-proof.ts @@ -1,8 +1,11 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { promisify } from 'node:util'; +import type { CodingProductComposition } from '../../api/coding-product-services'; import { createCodingConversationStore } from '../../coding-projects/conversation-store'; import { createCodingProjectAgent } from '../../coding-projects/project-config'; import { @@ -12,6 +15,11 @@ import { type CodingProjectStore, } from '../../coding-projects/project-store'; import type { ProviderAccount } from '../../shared/providers/types'; +import { getProviderService } from '../../services/providers/provider-service'; +import { + clearWorksSquareAIGatewayCredential, + seedWorksSquareAIGatewayCredential, +} from '../../services/works-square-ai-gateway'; import type { PrepareConversationInput } from '../contracts'; import { PiManagedExtensionHost } from './extension-host'; import { PiManagedInputRevisionCoordinator } from './managed-input-revision'; @@ -85,6 +93,21 @@ type PressureRun = { finish(options?: { injectFailureAt?: 'parents.settle' }): Promise; }; +type ProxyCompositionRun = { + composition: CodingProductComposition; + provider: LocalProofProvider; + projectId: string; + projectPath: string; + hostToken: string; + activeStatus?: PiReleaseProxyCompositionStatus; +}; + +type InspectedPiProcess = { + processId: number; + role: ProofWorkerRole; + commandLineContainsHostToken: boolean; +}; + export interface PiReleaseManagedTurnProof { runRef: string; cold: boolean; @@ -130,6 +153,40 @@ export interface PiReleaseExtensionProof { }; } +export interface PiReleaseProxyCompositionStatus { + conversationCount: number; + providerRequests: { parent: number; child: number }; + activeProviderRequests: { parent: number; child: number }; + workers: ReturnType['workers']; + processes: { + supported: boolean; + parent: number[]; + child: number[]; + }; + currentHostTokenAvailable: boolean; + tokenSafety: { + argv: boolean; + modelsJson: boolean; + logs: boolean; + diagnostics: boolean; + }; + realTurnVerified: false; +} + +export interface PiReleaseProxyCompositionProof extends PiReleaseProxyCompositionStatus { + providerMode: 'loopback-through-authenticated-host-proxy'; + firstConversation: { + count: number; + bindingEstablished: boolean; + workerStatus: string; + runStatus: string; + userInputProjected: boolean; + parentResponseProjected: boolean; + }; + currentHostTokenUsedBy: ProofWorkerRole[]; + released: { workers: number }; +} + const PROOF_ACCOUNT_ID = 'release-proof-account'; const PROOF_AGENT_ID = 'release-proof-agent'; const PROOF_MODEL_ID = 'release-proof-model'; @@ -147,6 +204,8 @@ const EXPECTED_TURN_MILESTONES: readonly ProofMilestone[] = [ ]; let pressureRun: PressureRun | null = null; +let proxyCompositionRun: ProxyCompositionRun | null = null; +const execFileAsync = promisify(execFile); async function waitFor(predicate: () => boolean, message: string): Promise { const deadline = Date.now() + 30_000; @@ -532,6 +591,122 @@ function providerRequestCounts(provider: LocalProofProvider): { parent: number; }; } +async function fileContainsValue(filePath: string, value: string): Promise { + try { + return (await readFile(filePath)).includes(Buffer.from(value)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + +async function directoryContainsValue(directory: string, value: string): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (await directoryContainsValue(entryPath, value)) return true; + } else if (entry.isFile() && await fileContainsValue(entryPath, value)) { + return true; + } + } + return false; +} + +async function inspectWindowsPiProcesses(hostToken: string): Promise<{ + supported: boolean; + processes: InspectedPiProcess[]; +}> { + if (process.platform !== 'win32') return { supported: false, processes: [] }; + const script = `$parentPid = ${process.pid}; ` + + '$items = @(Get-CimInstance Win32_Process -Filter "ParentProcessId = $parentPid" ' + + '| Where-Object { $_.CommandLine -match "pi-runtime[\\\\/]dist[\\\\/]cli\\.js" } ' + + '| Select-Object ProcessId, CommandLine); ' + + '$items | ConvertTo-Json -Compress'; + const { stdout } = await execFileAsync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { windowsHide: true, maxBuffer: 1024 * 1024 }, + ); + const text = stdout.trim(); + const parsed = text ? JSON.parse(text) as unknown : []; + const items = Array.isArray(parsed) ? parsed : [parsed]; + const processes = items.flatMap((item): InspectedPiProcess[] => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const record = item as { ProcessId?: unknown; CommandLine?: unknown }; + const processId = Number(record.ProcessId); + const commandLine = typeof record.CommandLine === 'string' ? record.CommandLine : ''; + if (!Number.isSafeInteger(processId) || !commandLine) return []; + const role = commandLine.includes('--no-session') ? 'child' : 'parent'; + return [{ + processId, + role, + commandLineContainsHostToken: commandLine.includes(hostToken), + }]; + }); + return { supported: true, processes }; +} + +function proxyProviderAccount(baseUrl: string): ProviderAccount { + return { + ...providerAccount(baseUrl), + label: 'Authenticated Host proxy release proof', + metadata: { + worksSquareCredentialMode: 'works_square_ai_gateway_proxy', + customModels: [PROOF_MODEL_ID], + }, + }; +} + +async function proxyCompositionStatus( + run: ProxyCompositionRun, +): Promise { + const diagnostics = run.composition.runtime.getDiagnostics(); + const processInspection = await inspectWindowsPiProcesses(run.hostToken); + const modelsFile = path.join( + path.dirname(run.projectPath), + 'coding-runtime', + 'pi', + 'config', + 'models.json', + ); + const logsDirectory = path.join(path.dirname(run.projectPath), 'logs'); + const [modelsContainToken, logsContainToken] = await Promise.all([ + fileContainsValue(modelsFile, run.hostToken), + directoryContainsValue(logsDirectory, run.hostToken), + ]); + const providerRequests = providerRequestCounts(run.provider); + const parent = processInspection.processes + .filter(({ role }) => role === 'parent') + .map(({ processId }) => processId) + .sort((left, right) => left - right); + const child = processInspection.processes + .filter(({ role }) => role === 'child') + .map(({ processId }) => processId) + .sort((left, right) => left - right); + return { + conversationCount: (await run.composition.conversations.listConversations(run.projectId)).length, + providerRequests, + activeProviderRequests: run.provider.activeCounts(), + workers: diagnostics.workers, + processes: { supported: processInspection.supported, parent, child }, + currentHostTokenAvailable: run.hostToken.length > 0, + tokenSafety: { + argv: processInspection.processes.every(({ commandLineContainsHostToken }) => !commandLineContainsHostToken), + modelsJson: !modelsContainToken, + logs: !logsContainToken, + diagnostics: !JSON.stringify(diagnostics).includes(run.hostToken), + }, + realTurnVerified: false, + }; +} + function timelineForTurn( composition: RealProofComposition, conversationId: string, @@ -929,6 +1104,178 @@ export async function runFinalAsarExtensionProof(): Promise { + run.provider.releaseAll(); + await run.composition.projects.removeProject(run.projectId).catch(() => undefined); + await run.provider.close().catch(() => undefined); + clearWorksSquareAIGatewayCredential(); + await getProviderService().deleteAccount(PROOF_ACCOUNT_ID).catch(() => undefined); +} + +export async function startFinalAsarProxyCompositionProof(input: { + composition: CodingProductComposition; + projectPath: string; + hostProxyBaseUrl: string; + hostToken: string; +}): Promise<{ + projectId: string; + agentId: string; + providerMode: 'works_square_ai_gateway_proxy'; + currentHostTokenAvailable: true; + realTurnVerified: false; +}> { + if (proxyCompositionRun) throw new Error('PI proxy composition proof is already running'); + const hostToken = input.hostToken.trim(); + if (!hostToken) throw new Error('Current Main Host token is unavailable'); + const provider = await startLocalProofProvider('subagent'); + let projectId: string | null = null; + try { + seedWorksSquareAIGatewayCredential({ + accessToken: 'release-proof-upstream-only', + oneApiBaseUrl: provider.baseUrl, + }); + const providerService = getProviderService(); + await providerService.createAccount(proxyProviderAccount(input.hostProxyBaseUrl)); + await providerService.setDefaultAccount(PROOF_ACCOUNT_ID); + const project = await input.composition.projects.createProject({ projectPath: input.projectPath }); + projectId = project.project.id; + await createCodingProjectAgent(input.projectPath, { + id: PROOF_AGENT_ID, + avatarId: 'avatar-01', + roleName: 'Packaged proxy proof', + name: 'Packaged proxy proof agent', + model: { + accountId: PROOF_ACCOUNT_ID, + modelId: PROOF_MODEL_ID, + thinkingLevel: 'medium', + }, + modelResolution: 'resolved', + responsibility: { + mission: 'Exercise the real packaged Main proxy composition', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + prompt: 'Follow the controlled loopback Provider and complete its subagent request.', + skillIds: [], + }); + proxyCompositionRun = { + composition: input.composition, + provider, + projectId, + projectPath: input.projectPath, + hostToken, + }; + return { + projectId, + agentId: PROOF_AGENT_ID, + providerMode: 'works_square_ai_gateway_proxy', + currentHostTokenAvailable: true, + realTurnVerified: false, + }; + } catch (error) { + provider.releaseAll(); + if (projectId) await input.composition.projects.removeProject(projectId).catch(() => undefined); + await provider.close().catch(() => undefined); + clearWorksSquareAIGatewayCredential(); + await getProviderService().deleteAccount(PROOF_ACCOUNT_ID).catch(() => undefined); + throw error; + } +} + +export async function getFinalAsarProxyCompositionStatus(): Promise { + const run = proxyCompositionRun; + if (!run) throw new Error('PI proxy composition proof is not running'); + const status = await proxyCompositionStatus(run); + if (status.activeProviderRequests.child > 0 + && status.processes.child.length > 0 + && status.processes.parent.length > 0) { + run.activeStatus = status; + } + return status; +} + +export function releaseFinalAsarProxyCompositionChild(): void { + const run = proxyCompositionRun; + if (!run) throw new Error('PI proxy composition proof is not running'); + run.provider.releaseChildren(); +} + +export async function finishFinalAsarProxyCompositionProof(): Promise { + const run = proxyCompositionRun; + if (!run) throw new Error('PI proxy composition proof is not running'); + try { + const conversations = await run.composition.conversations.listConversations(run.projectId); + if (conversations.length !== 1) { + throw new Error(`Expected one first proxy Conversation, received ${conversations.length}`); + } + const conversation = conversations[0]!; + let snapshot = await run.composition.conversations.getSnapshot(conversation.id); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline && ( + snapshot.run.status !== 'idle' + || !JSON.stringify(snapshot.nodes).includes('REAL_PARENT_COMPLETE') + )) { + await delay(20); + snapshot = await run.composition.conversations.getSnapshot(conversation.id); + } + const serializedNodes = JSON.stringify(snapshot.nodes); + if (snapshot.run.status !== 'idle' || !serializedNodes.includes('REAL_PARENT_COMPLETE')) { + throw new Error('First proxy Conversation did not settle with the controlled parent response'); + } + const binding = await run.composition.projects.conversationStore(run.projectPath).get(conversation.id); + const finalStatus = await proxyCompositionStatus(run); + const activeStatus = run.activeStatus ?? finalStatus; + const currentHostTokenUsedBy: ProofWorkerRole[] = [ + ...(activeStatus.providerRequests.parent > 0 ? ['parent' as const] : []), + ...(activeStatus.providerRequests.child > 0 ? ['child' as const] : []), + ]; + if (currentHostTokenUsedBy.join(',') !== 'parent,child') { + throw new Error('Authenticated Host proxy did not receive both parent and child Provider requests'); + } + if (!binding?.piSessionId || !binding.sessionKey) { + throw new Error('First proxy Conversation did not persist its Pi session binding'); + } + const tokenSafety = { + argv: activeStatus.tokenSafety.argv && finalStatus.tokenSafety.argv, + modelsJson: activeStatus.tokenSafety.modelsJson && finalStatus.tokenSafety.modelsJson, + logs: activeStatus.tokenSafety.logs && finalStatus.tokenSafety.logs, + diagnostics: activeStatus.tokenSafety.diagnostics && finalStatus.tokenSafety.diagnostics, + }; + if (Object.values(tokenSafety).some((safe) => !safe)) { + throw new Error(`Current Main Host token escaped its credential boundary: ${JSON.stringify(tokenSafety)}`); + } + const proof: Omit = { + ...finalStatus, + providerRequests: activeStatus.providerRequests, + activeProviderRequests: activeStatus.activeProviderRequests, + processes: activeStatus.processes, + tokenSafety, + providerMode: 'loopback-through-authenticated-host-proxy', + firstConversation: { + count: conversations.length, + bindingEstablished: true, + workerStatus: snapshot.worker.status, + runStatus: snapshot.run.status, + userInputProjected: serializedNodes.includes('Exercise the packaged Main proxy Conversation'), + parentResponseProjected: serializedNodes.includes('REAL_PARENT_COMPLETE'), + }, + currentHostTokenUsedBy, + }; + await cleanupProxyCompositionRun(run); + proxyCompositionRun = null; + return { + ...proof, + released: { workers: run.composition.runtime.getDiagnostics().workers.length }, + }; + } catch (error) { + await cleanupProxyCompositionRun(run); + proxyCompositionRun = null; + throw error; + } +} + export async function startFinalAsarPressureProof(): Promise { if (pressureRun) throw new Error('PI release pressure proof is already running'); pressureRun = await startPressureRun(); diff --git a/electron/main/index.ts b/electron/main/index.ts index b577247..6993faf 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -89,8 +89,12 @@ import { resolveCodingPiRuntimePaths, } from '../api/coding-composition'; import { + finishFinalAsarProxyCompositionProof, finishFinalAsarPressureProof, + getFinalAsarProxyCompositionStatus, + releaseFinalAsarProxyCompositionChild, runFinalAsarExtensionProof, + startFinalAsarProxyCompositionProof, startFinalAsarPressureProof, } from '../coding-runtime/pi/release-proof'; @@ -546,7 +550,7 @@ async function initialize(): Promise { storage: codingProjectStorage, projectStore: codingProjectStore, browser: agentBrowser, - localProxyCredential: getHostApiToken() || undefined, + getLocalProxyCredential: () => getHostApiToken() || undefined, paths: { ...resolveCodingPiRuntimePaths({ isPackaged: app.isPackaged, @@ -882,6 +886,10 @@ export async function runLocalPreviewPreflightE2E( type PiReleaseProofAction = | 'extension' + | 'proxy.start' + | 'proxy.status' + | 'proxy.release-child' + | 'proxy.finish' | 'pressure.start' | 'pressure.finish' | 'pressure.finish.inject-failure'; @@ -897,6 +905,31 @@ export async function runPiReleaseProofE2E(action: PiReleaseProofAction) { if (action === 'extension') { return { action, packagedMain, extension: await runFinalAsarExtensionProof() }; } + if (action === 'proxy.start') { + if (!codingProducts) throw new Error('Packaged Main coding composition is unavailable'); + const address = hostApiServer?.address(); + if (!address || typeof address === 'string') throw new Error('Packaged Main Host API is unavailable'); + return { + action, + packagedMain, + proxy: await startFinalAsarProxyCompositionProof({ + composition: codingProducts, + projectPath: join(app.getPath('userData'), 'pi-proxy-proof-project'), + hostProxyBaseUrl: `http://127.0.0.1:${address.port}/api/ai-proxy/v1`, + hostToken: getHostApiToken(), + }), + }; + } + if (action === 'proxy.status') { + return { action, packagedMain, proxy: await getFinalAsarProxyCompositionStatus() }; + } + if (action === 'proxy.release-child') { + releaseFinalAsarProxyCompositionChild(); + return { action, packagedMain, proxy: { released: 'child' } }; + } + if (action === 'proxy.finish') { + return { action, packagedMain, proxy: await finishFinalAsarProxyCompositionProof() }; + } if (action === 'pressure.start') { return { action, packagedMain, pressure: await startFinalAsarPressureProof() }; } diff --git a/scripts/run-pi-subagent-packaged-smoke.mjs b/scripts/run-pi-subagent-packaged-smoke.mjs index b1c13b3..7aae072 100644 --- a/scripts/run-pi-subagent-packaged-smoke.mjs +++ b/scripts/run-pi-subagent-packaged-smoke.mjs @@ -129,6 +129,51 @@ function assertManagedTurns(extension) { } } +function assertProxyStatus(status) { + if (status?.currentHostTokenAvailable !== true + || status?.realTurnVerified !== false + || status?.activeProviderRequests?.child !== 1 + || status?.providerRequests?.parent < 1 + || status?.providerRequests?.child !== 1 + || status?.processes?.supported !== true + || status?.processes?.parent?.length !== 1 + || status?.processes?.child?.length !== 1 + || Object.values(status?.tokenSafety ?? {}).some((safe) => safe !== true)) { + throw new Error(`Packaged Main proxy status is incomplete: ${JSON.stringify(status)}`); + } +} + +function assertProxyProof(proof) { + if (proof?.providerMode !== 'loopback-through-authenticated-host-proxy' + || proof?.realTurnVerified !== false + || proof?.firstConversation?.count !== 1 + || proof?.firstConversation?.bindingEstablished !== true + || proof?.firstConversation?.workerStatus !== 'ready' + || proof?.firstConversation?.runStatus !== 'idle' + || proof?.firstConversation?.userInputProjected !== true + || proof?.firstConversation?.parentResponseProjected !== true + || proof?.currentHostTokenUsedBy?.join(',') !== 'parent,child' + || proof?.released?.workers !== 0 + || Object.values(proof?.tokenSafety ?? {}).some((safe) => safe !== true)) { + throw new Error(`Packaged Main proxy proof failed: ${JSON.stringify(proof)}`); + } +} + +async function waitForActiveProxyProof(electronApplication) { + const deadline = Date.now() + 30_000; + let latest; + while (Date.now() < deadline) { + latest = await evaluateProof(electronApplication, 'proxy.status'); + if (latest?.proxy?.activeProviderRequests?.child === 1 + && latest?.proxy?.processes?.parent?.length === 1 + && latest?.proxy?.processes?.child?.length === 1) { + return latest; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 200)); + } + throw new Error(`Packaged Main proxy child did not become active: ${JSON.stringify(latest?.proxy)}`); +} + async function evaluateProof(electronApplication, action) { return await electronApplication.evaluate(async (_electron, requestedAction) => { const proof = globalThis.__niancodeRunPiReleaseProofE2E; @@ -157,6 +202,7 @@ export async function runPackagedProductProof(options) { const hostApiPort = await allocatePort(); let electronApplication; let pressureActive = false; + let proxyActive = false; try { electronApplication = await electron.launch({ executablePath: options.electronExecutable, @@ -234,6 +280,53 @@ export async function runPackagedProductProof(options) { }; } + const proxyStart = await evaluateProof(electronApplication, 'proxy.start'); + proxyActive = true; + assertPackagedMain(proxyStart); + if (proxyStart?.proxy?.providerMode !== 'works_square_ai_gateway_proxy' + || proxyStart?.proxy?.currentHostTokenAvailable !== true + || proxyStart?.proxy?.realTurnVerified !== false) { + throw new Error(`Packaged Main proxy setup failed: ${JSON.stringify(proxyStart?.proxy)}`); + } + + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + const moduleSelection = page.getByTestId('ai-module-selection-page'); + if (await moduleSelection.count()) { + await page.getByTestId('ai-module-option-programming').click(); + await page.getByTestId('main-layout').waitFor({ state: 'visible', timeout: 10_000 }); + } + await page.evaluate(() => { window.location.hash = '/chat'; }); + const composer = page.getByRole('textbox'); + await composer.waitFor({ state: 'visible', timeout: 30_000 }); + await composer.fill('Exercise the packaged Main proxy Conversation'); + if (!await composer.isEnabled()) throw new Error('Packaged proxy first Conversation composer is disabled'); + const beforeSubmitText = await page.locator('body').innerText(); + if (beforeSubmitText.includes('本地编程运行时暂时不可用') + || beforeSubmitText.includes('正在重新连接本地 Agent')) { + throw new Error(`Packaged proxy first Conversation exposed a runtime banner: ${beforeSubmitText}`); + } + await page.getByTestId('coding-message-composer').evaluate( + (form) => form.requestSubmit(), + ); + + const proxyStatus = await waitForActiveProxyProof(electronApplication); + assertPackagedMain(proxyStatus); + assertProxyStatus(proxyStatus.proxy); + await evaluateProof(electronApplication, 'proxy.release-child'); + await page.getByText('REAL_PARENT_COMPLETE').waitFor({ state: 'visible', timeout: 30_000 }); + const afterTurnText = await page.locator('body').innerText(); + if (afterTurnText.includes('本地编程运行时暂时不可用') + || afterTurnText.includes('正在重新连接本地 Agent')) { + throw new Error(`Packaged proxy Conversation did not leave recovery cleanly: ${afterTurnText}`); + } + if (!await composer.isEnabled()) throw new Error('Packaged proxy composer is not editable after the turn'); + + const proxyFinish = await evaluateProof(electronApplication, 'proxy.finish'); + proxyActive = false; + assertPackagedMain(proxyFinish); + assertProxyProof(proxyFinish.proxy); + const report = { schemaVersion: 1, generatedAt: new Date().toISOString(), @@ -252,6 +345,18 @@ export async function runPackagedProductProof(options) { released: pressureFinish.pressure, ...(failureCleanup ? { failureCleanup } : {}), }, + proxy: { + setup: proxyStart.proxy, + active: proxyStatus.proxy, + completed: proxyFinish.proxy, + ui: { + firstConversationEditable: true, + inputSubmitted: true, + runtimeUnavailableBanner: false, + permanentRecovering: false, + }, + realTurnVerified: false, + }, result: 'pass', }; if (options.reportPath) { @@ -263,6 +368,10 @@ export async function runPackagedProductProof(options) { if (electronApplication && pressureActive) { await evaluateProof(electronApplication, 'pressure.finish').catch(() => undefined); } + if (electronApplication && proxyActive) { + await evaluateProof(electronApplication, 'proxy.release-child').catch(() => undefined); + await evaluateProof(electronApplication, 'proxy.finish').catch(() => undefined); + } if (electronApplication) await closeApplication(electronApplication); await rm(scratchRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } diff --git a/src/stores/coding-conversations.ts b/src/stores/coding-conversations.ts index 7ff9649..03f6114 100644 --- a/src/stores/coding-conversations.ts +++ b/src/stores/coding-conversations.ts @@ -573,8 +573,22 @@ export function createCodingConversationStore( }, }; }); - await deps.recover(conversationId); - await get().loadSnapshot(conversationId, true); + try { + await deps.recover(conversationId); + await get().loadSnapshot(conversationId, true); + } catch (error) { + const failure = errorDetails(error); + set((state) => { + const entry = state.entriesByConversationId[conversationId] ?? emptyEntry(); + return { + entriesByConversationId: { + ...state.entriesByConversationId, + [conversationId]: { ...entry, loadState: 'error', error: failure.message }, + }, + }; + }); + throw error; + } }, async connectEvents() { diff --git a/tests/unit/coding-conversations-store.test.tsx b/tests/unit/coding-conversations-store.test.tsx index ba440a7..3748107 100644 --- a/tests/unit/coding-conversations-store.test.tsx +++ b/tests/unit/coding-conversations-store.test.tsx @@ -676,6 +676,60 @@ describe('coding Conversation store', () => { expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Keep this draft'); }); + it('leaves a failed recover retryable without losing the target Snapshot, selection, or draft', async () => { + const initialSnapshot = snapshot('conversation-a', 1, 4); + const recoveredSnapshot = snapshot('conversation-a', 2, 5); + const recover = vi.fn() + .mockRejectedValueOnce(new Error('Provider authentication is required')) + .mockResolvedValueOnce(undefined); + const getSnapshot = vi.fn() + .mockResolvedValueOnce(initialSnapshot) + .mockResolvedValueOnce(recoveredSnapshot); + const store = createCodingConversationStore({ + getSnapshot, + openEvents: vi.fn(async () => new FakeEventSource() as unknown as EventSource), + submitPrompt: vi.fn(), + recover, + createId: ids(), + }); + + await store.getState().selectConversation('conversation-a'); + store.getState().setDraft('conversation-a', 'Keep retry context', [{ + attachmentId: 'attachment-retry', + mime: 'image/png', + previewUrl: 'blob:retry-preview', + }]); + const lastGoodSnapshot = selectCodingConversationSnapshot('conversation-a')(store.getState()); + + await expect(store.getState().recoverConversation('conversation-a')) + .rejects.toThrow('Provider authentication is required'); + expect(store.getState().selectedConversationId).toBe('conversation-a'); + expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ + loadState: 'error', + error: 'Provider authentication is required', + }); + expect(selectCodingConversationSnapshot('conversation-a')(store.getState())).toBe(lastGoodSnapshot); + expect(store.getState().draftsByConversationId['conversation-a']).toMatchObject({ + text: 'Keep retry context', + attachments: [{ + attachmentId: 'attachment-retry', + mime: 'image/png', + previewUrl: 'blob:retry-preview', + }], + }); + expect(getSnapshot).toHaveBeenCalledTimes(1); + + await store.getState().recoverConversation('conversation-a'); + expect(recover).toHaveBeenCalledTimes(2); + expect(getSnapshot).toHaveBeenCalledTimes(2); + expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ + loadState: 'live', + error: null, + }); + expect(selectCodingConversationSnapshot('conversation-a')(store.getState())).toEqual(recoveredSnapshot); + expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Keep retry context'); + }); + it('folds a 100 KB thinking batch in one notification without duplicate deltas', () => { const store = createCodingConversationStore({ getSnapshot: vi.fn(), diff --git a/tests/unit/coding-core-routes.test.ts b/tests/unit/coding-core-routes.test.ts index 76de9f4..a99e95c 100644 --- a/tests/unit/coding-core-routes.test.ts +++ b/tests/unit/coding-core-routes.test.ts @@ -102,9 +102,11 @@ describe('PI-100 coding core Host contract', () => { const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-composition-project-')); const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-pi-composition-user-')); roots.push(projectPath, userDataDir); + const getLocalProxyCredential = vi.fn(() => 'host-token-after-server-start'); const composition = createCodingComposition({ storage: createMemoryCodingProjectStorage(), browser: { close: vi.fn(async () => undefined) } as unknown as AgentBrowserModule, + getLocalProxyCredential, paths: { executablePath: process.execPath, cliPath: path.join(projectPath, 'unused-cli.js'), @@ -131,10 +133,12 @@ describe('PI-100 coding core Host contract', () => { title: 'Local only', }); expect(composition.runtime.getDiagnostics().workers).toEqual([]); + expect(getLocalProxyCredential).not.toHaveBeenCalled(); expect(await composition.host.listCommands(conversation.id)).toEqual( expect.arrayContaining([expect.objectContaining({ name: 'compact', source: 'makelore' })]), ); expect(composition.runtime.getDiagnostics().workers).toEqual([]); + expect(getLocalProxyCredential).not.toHaveBeenCalled(); } finally { await composition.shutdown(); } diff --git a/tests/unit/coding-provider-auth.test.ts b/tests/unit/coding-provider-auth.test.ts index b538491..7c4248e 100644 --- a/tests/unit/coding-provider-auth.test.ts +++ b/tests/unit/coding-provider-auth.test.ts @@ -17,7 +17,8 @@ vi.mock('../../electron/services/providers/provider-service', () => ({ }), })); -vi.mock('../../electron/coding-runtime/pi/provider-config', () => ({ +vi.mock('../../electron/coding-runtime/pi/provider-config', async (importOriginal) => ({ + ...await importOriginal(), resolvePiProviderCredentialFromSecretStore: mocks.resolveCredential, })); @@ -31,6 +32,7 @@ import { isCodingProviderAuthenticationError, refreshCodingProviderCredential, } from '../../electron/api/coding-provider-auth'; +import { PiProviderConfigError } from '../../electron/coding-runtime/pi/provider-config'; describe('coding Provider credential refresh boundary', () => { beforeEach(() => { @@ -60,4 +62,13 @@ describe('coding Provider credential refresh boundary', () => { expect(isCodingProviderAuthenticationError(failure)).toBe(true); expect(mocks.updateAccount).not.toHaveBeenCalled(); }); + + it('classifies only the typed missing Provider credential as authentication-required', () => { + expect(isCodingProviderAuthenticationError( + new PiProviderConfigError('PROVIDER_AUTH_REQUIRED', 'Provider credential is unavailable'), + )).toBe(true); + expect(isCodingProviderAuthenticationError( + new PiProviderConfigError('PROVIDER_INVALID', 'Provider configuration is invalid'), + )).toBe(false); + }); }); diff --git a/tests/unit/pi-managed-worker-opener.test.ts b/tests/unit/pi-managed-worker-opener.test.ts index 7c7ea44..145ee37 100644 --- a/tests/unit/pi-managed-worker-opener.test.ts +++ b/tests/unit/pi-managed-worker-opener.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store'; import { createCodingProjectAgent } from '../../electron/coding-projects/project-config'; import { @@ -27,6 +27,7 @@ import type { import type { PiWorkerProcessOptions } from '../../electron/coding-runtime/pi/worker-process'; import type { PiRuntimeTelemetryEvent } from '../../electron/coding-runtime/pi/telemetry'; import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host'; +import { PiWorkerPool } from '../../electron/coding-runtime/pi/worker-pool'; const roots: string[] = []; const NOW = '2026-08-22T16:00:00.000Z'; @@ -185,4 +186,91 @@ describe('managed Pi worker opener', () => { await reopened.worker.stop(); await extensionHost.close(); }); + + it('reads the current local-proxy credential for first open, recover, and rebuild', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-proxy-opener-')); + roots.push(root); + const projectPath = path.join(root, 'project'); + const userDataDir = path.join(root, 'user-data'); + const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), { + createId: () => 'project-proxy', + now: () => NOW, + }); + await createLocalCodingProject({ projectPath, now: NOW }, projectStore); + await createCodingProjectAgent(projectPath, { + id: 'agent-proxy', + avatarId: 'avatar-01', + roleName: 'Implementer', + name: 'Proxy Agent', + model: { accountId: 'account-proxy', modelId: 'model-proxy', thinkingLevel: 'medium' }, + modelResolution: 'resolved', + responsibility: { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [] }, + }, { now: NOW }); + const conversationStore = createCodingConversationStore(projectPath, { + createId: () => 'f47ac10b-58cc-4372-a567-0e02b2c3d480', + now: () => NOW, + }); + const created = await conversationStore.create({ + agentId: 'agent-proxy', + title: 'Proxy Conversation', + model: { accountId: 'account-proxy', modelId: 'model-proxy', thinkingLevel: 'medium' }, + modelResolution: 'resolved', + }); + const input = { + conversationId: created.id, + projectId: 'project-proxy', + agentId: 'agent-proxy', + title: created.title, + model: { model: created.model, modelResolution: created.modelResolution }, + } as const; + const account: ProviderAccount = { + id: 'account-proxy', vendorId: 'custom', label: 'Proxy account', authMode: 'api_key', + apiProtocol: 'openai-completions', baseUrl: 'http://127.0.0.1:43123/api/ai-proxy/v1', + model: 'model-proxy', enabled: true, isDefault: true, createdAt: NOW, updatedAt: NOW, + metadata: { worksSquareCredentialMode: 'works_square_ai_gateway_proxy' }, + }; + const processOptions: PiWorkerProcessOptions[] = []; + const extensionHost = new PiManagedExtensionHost(); + let currentToken = 'host-token-first-open'; + const getLocalProxyCredential = vi.fn(async () => currentToken); + const opener = createPiManagedWorkerOpener({ + registry: new PiSessionRegistry({ projectStore }), + executablePath: 'electron.exe', + cliPath: 'pi-cli.js', + userDataDir, + bundledSkillsDir: path.resolve('resources/coding-skills'), + extensionHost, + loadProviderInput: async () => ({ accounts: [account], modelSummaries: [] }), + resolveCredential: vi.fn(async () => 'stale-secret-store-token'), + getLocalProxyCredential, + createSessionKey: () => 'session-key-proxy', + createProcess: (options) => { + processOptions.push(options); + const sessionIndex = options.additionalArgs?.indexOf('--session-id') ?? -1; + return new OpenerFakeProcess(options.additionalArgs?.[sessionIndex + 1] ?? '', options); + }, + }); + const pool = new PiWorkerPool({ openWorker: opener, maxIdle: 2 }); + + await pool.prepare(input); + currentToken = 'host-token-recover-open'; + await pool.recover(created.id); + currentToken = 'host-token-rebuild-open'; + await pool.reconfigureConversationModel(created.id, input.model); + + expect(getLocalProxyCredential).toHaveBeenCalledTimes(3); + expect(processOptions).toHaveLength(3); + expect(processOptions.map(({ env }) => Object.values(env ?? {}).find((value) => value.startsWith('host-token-')))) + .toEqual(['host-token-first-open', 'host-token-recover-open', 'host-token-rebuild-open']); + for (const token of ['host-token-first-open', 'host-token-recover-open', 'host-token-rebuild-open']) { + expect(JSON.stringify(processOptions.map(({ additionalArgs }) => additionalArgs))).not.toContain(token); + expect(JSON.stringify(pool.getDiagnostics())).not.toContain(token); + } + const modelsFile = path.join(userDataDir, 'coding-runtime', 'pi', 'config', 'models.json'); + const modelsJson = await readFile(modelsFile, 'utf8'); + expect(modelsJson).not.toContain('host-token-'); + + await pool.shutdown(); + await extensionHost.close(); + }); }); diff --git a/tests/unit/pi-runtime-auth-recovery.test.ts b/tests/unit/pi-runtime-auth-recovery.test.ts index 8800a3d..5640134 100644 --- a/tests/unit/pi-runtime-auth-recovery.test.ts +++ b/tests/unit/pi-runtime-auth-recovery.test.ts @@ -16,9 +16,14 @@ import { createMemoryCodingProjectStorage, } from '../../electron/coding-projects/project-store'; import { PiProcessError } from '../../electron/coding-runtime/pi/process-errors'; +import { PiProviderConfigError } from '../../electron/coding-runtime/pi/provider-config'; import { PiConversationRuntime } from '../../electron/coding-runtime/pi/runtime'; import { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry'; -import { PiWorkerPool, type PiConversationWorker } from '../../electron/coding-runtime/pi/worker-pool'; +import { + PiWorkerPool, + type PiConversationWorker, + type PiWorkerPoolOptions, +} from '../../electron/coding-runtime/pi/worker-pool'; import type { PiRpcCommand, PiRpcEvent, @@ -68,7 +73,13 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); -async function setupAuthRuntime(refreshCredential: (accountId: string) => Promise) { +async function setupAuthRuntime( + refreshCredential: (accountId: string) => Promise, + options: { + openWorker?: PiWorkerPoolOptions['openWorker']; + prepare?: boolean; + } = {}, +) { const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-auth-')); roots.push(projectPath); const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), { @@ -96,19 +107,23 @@ async function setupAuthRuntime(refreshCredential: (accountId: string) => Promis modelResolution: 'resolved', }); const workers: AuthFailureWorker[] = []; + const defaultOpenWorker: PiWorkerPoolOptions['openWorker'] = async ({ + conversation: input, + existingSession, + }) => { + const worker = new AuthFailureWorker(`worker-${workers.length + 1}`); + workers.push(worker); + return { + worker, + session: existingSession ?? { + piSessionId: `session-${input.conversationId}`, + sessionKey: `key-${input.conversationId}`, + }, + }; + }; const pool = new PiWorkerPool({ maxIdle: 2, - openWorker: async ({ conversation: input, existingSession }) => { - const worker = new AuthFailureWorker(`worker-${workers.length + 1}`); - workers.push(worker); - return { - worker, - session: existingSession ?? { - piSessionId: `session-${input.conversationId}`, - sessionKey: `key-${input.conversationId}`, - }, - }; - }, + openWorker: options.openWorker ?? defaultOpenWorker, }); const runtime = new PiConversationRuntime({ pool, @@ -118,17 +133,38 @@ async function setupAuthRuntime(refreshCredential: (accountId: string) => Promis isAuthenticationError: isCodingProviderAuthenticationError, createId: () => 'run-auth', }); - await runtime.prepare({ + const input = { conversationId: conversation.id, projectId: 'project-auth', agentId: 'agent-auth', title: conversation.title, model: { model: conversation.model, modelResolution: conversation.modelResolution }, - }); - return { conversation, pool, runtime, workers }; + }; + if (options.prepare !== false) await runtime.prepare(input); + return { conversation, input, pool, runtime, workers }; } describe('Pi runtime Provider authentication recovery', () => { + it('maps a missing proxy token to Provider auth-required after one refresh retry', async () => { + const refreshCredential = vi.fn(async () => undefined); + const openWorker = vi.fn(async () => { + throw new PiProviderConfigError('PROVIDER_AUTH_REQUIRED', 'Provider credential is unavailable'); + }); + const { input, runtime } = await setupAuthRuntime(refreshCredential, { + openWorker, + prepare: false, + }); + + await expect(runtime.prepare(input)).rejects.toMatchObject({ + publicError: { + code: 'CODING_PROVIDER_AUTH_REQUIRED', + recoverable: true, + }, + }); + expect(refreshCredential).toHaveBeenCalledTimes(1); + expect(openWorker).toHaveBeenCalledTimes(2); + }); + it('refreshes and reopens once, then exposes the second authentication failure without looping', async () => { const refreshCredential = vi.fn(async () => undefined); const { conversation, pool, runtime, workers } = await setupAuthRuntime(refreshCredential); diff --git a/tests/unit/pi-subagent-child.test.ts b/tests/unit/pi-subagent-child.test.ts index b919d25..067a62a 100644 --- a/tests/unit/pi-subagent-child.test.ts +++ b/tests/unit/pi-subagent-child.test.ts @@ -1,9 +1,9 @@ // @vitest-environment node -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createCodingProjectAgent } from '../../electron/coding-projects/project-config'; import { createCodingProjectStore, @@ -152,4 +152,66 @@ describe('managed Pi subagent child opener', () => { expect(processes.every(({ stopped }) => stopped)).toBe(true); await host.close(); }); + + it('reads the current local-proxy credential for every ephemeral child open', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-proxy-child-')); + roots.push(root); + const projectPath = path.join(root, 'project'); + const userDataDir = path.join(root, 'user-data'); + const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), { + createId: () => 'project-proxy', now: () => NOW, + }); + await createLocalCodingProject({ projectPath, now: NOW }, projectStore); + await createCodingProjectAgent(projectPath, { + id: 'agent-proxy', avatarId: 'avatar-01', roleName: 'Reviewer', name: 'Proxy Agent', + model: { accountId: 'account-proxy', modelId: 'model-proxy', thinkingLevel: 'medium' }, + modelResolution: 'resolved', + responsibility: { mission: 'Review', owns: [], boundaries: [], collaborators: [], principles: [] }, + }, { now: NOW }); + const account: ProviderAccount = { + id: 'account-proxy', vendorId: 'custom', label: 'Proxy account', authMode: 'api_key', + apiProtocol: 'openai-completions', baseUrl: 'http://127.0.0.1:43123/api/ai-proxy/v1', + model: 'model-proxy', enabled: true, isDefault: true, createdAt: NOW, updatedAt: NOW, + metadata: { worksSquareCredentialMode: 'works_square_ai_gateway_proxy' }, + }; + const host = new PiManagedExtensionHost(); + const processOptions: PiWorkerProcessOptions[] = []; + let currentToken = 'host-token-child-first'; + const getLocalProxyCredential = vi.fn(async () => currentToken); + const opener = createPiManagedSubagentChildOpener({ + projectStore, + executablePath: 'electron.exe', + cliPath: 'pi-cli.js', + userDataDir, + bundledSkillsDir: path.resolve('resources/coding-skills'), + extensionHost: host, + loadProviderInput: async () => ({ accounts: [account], modelSummaries: [] }), + resolveCredential: vi.fn(async () => 'stale-secret-store-token'), + getRevision: () => ({ provider: 1, resources: 1 }), + getLocalProxyCredential, + createProcess: (options) => { + processOptions.push(options); + return new FakeChildProcess(); + }, + }); + const identity = { + conversationId: 'conversation-proxy', workerGeneration: 1, runId: 'run-proxy', + projectId: 'project-proxy', dispatchId: 'dispatch-proxy', agentId: 'agent-proxy', + }; + + const first = await opener({ ...identity, taskId: 'task-child-first', toolProfile: 'read-only' }); + currentToken = 'host-token-child-second'; + const second = await opener({ ...identity, taskId: 'task-child-second', toolProfile: 'coding' }); + + expect(getLocalProxyCredential).toHaveBeenCalledTimes(2); + expect(processOptions.map(({ env }) => Object.values(env ?? {}).find((value) => value.startsWith('host-token-')))) + .toEqual(['host-token-child-first', 'host-token-child-second']); + expect(JSON.stringify(processOptions.map(({ additionalArgs }) => additionalArgs))).not.toContain('host-token-'); + expect(await readFile(path.join(userDataDir, 'coding-runtime', 'pi', 'config', 'models.json'), 'utf8')) + .not.toContain('host-token-'); + + await first.stop(); + await second.stop(); + await host.close(); + }); });