fix(pi): resolve packaged proxy token lazily
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<typeof import('../../electron/coding-runtime/pi/provider-config')>(),
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>) {
|
||||
async function setupAuthRuntime(
|
||||
refreshCredential: (accountId: string) => Promise<void>,
|
||||
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<PiWorkerPoolOptions['openWorker']>(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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user