Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions packages/pi-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@ pi install npm:@parallel-web/pi-extension

- Registers `web_search`
- Registers `web_fetch`
- Adds a `parallel-login` command for browser auth
- Stores the resulting Parallel API key in Pi's auth store
- Registers a `parallel` auth provider, so Pi's own `/login parallel` runs the
Parallel browser OAuth flow and stores the API key in Pi's auth store
(`auth.json`) alongside every other provider credential
- Adds a `parallel-login` command that reports current auth status

Auth resolution order:
Auth resolution order (owned by Pi, not the extension):

1. Stored Pi auth for provider key `parallel`
1. The credential Pi stored for provider `parallel`
2. `PARALLEL_API_KEY`

`/logout parallel` removes the stored credential, and
`pi auth check --provider parallel` reports whether it is configured.

Requires `@earendil-works/pi-coding-agent` 0.83.0 or newer.

## Dogfooding Locally

Build the extension first:
Expand All @@ -41,7 +48,8 @@ If the extension loads successfully, Pi will have:

- the `web_search` tool
- the `web_fetch` tool
- the `parallel-login` command
- `parallel` listed under `/login`
- the `parallel-login` status command
- per-session Parallel `session_id` reuse inside that Pi session

### Option 2: Symlink It Into Pi Extensions
Expand Down Expand Up @@ -82,10 +90,12 @@ pnpm --filter @parallel-web/pi-extension build
Inside Pi, run:

```text
/parallel-login
/login parallel
```

That opens the browser for Parallel OAuth. On success, the API key is stored in Pi auth under `parallel`.
That opens the browser for Parallel OAuth. On success, Pi stores the API key in
its auth store under `parallel`. Run `/parallel-login` to see the current status,
and `/logout parallel` to remove the credential.

### Use Environment Variable Instead

Expand All @@ -105,7 +115,7 @@ export PARALLEL_PLATFORM_URL=https://your-platform-host
pi --no-extensions --no-skills -e ./packages/pi-extension/dist/index.js
```

This changes the browser login endpoints used by `parallel-login` and on-demand auth.
This changes the browser login endpoints used by `/login parallel`.

## Quick Smoke Test

Expand Down Expand Up @@ -136,5 +146,7 @@ pnpm --filter @parallel-web/pi-extension typecheck
- Search requests include `client_model` when Pi has an active model selected.
- Search and extract requests reuse a generated `session_id` for the life of the current Pi session.
- The login flow tries to open your browser automatically.
- If automatic callback capture does not complete, the extension falls back to asking you to paste the callback URL.
- If automatic callback capture does not complete, the login dialog asks you to paste the callback URL.
- Credential storage is entirely Pi's; the extension only reads the resolved key
through `ctx.modelRegistry.getApiKeyForProvider("parallel")`.
- Skill suppression inside the extension is prompt-level only. If you want a clean dogfooding session without your usual skills list, start Pi with `--no-skills`.
3 changes: 2 additions & 1 deletion packages/pi-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@
"typebox": "^1.1.37"
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "*"
"@earendil-works/pi-coding-agent": ">=0.83.0"
},
"devDependencies": {
"@earendil-works/pi-ai": ">=0.83.0",
"@parallel-web/oauth": "workspace:*",
"@types/node": "^20.0.0"
},
Expand Down
163 changes: 93 additions & 70 deletions packages/pi-extension/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
ExtensionAPI,
ExtensionContext,
} from '@mariozechner/pi-coding-agent';
} from '@earendil-works/pi-coding-agent';

const mocks = vi.hoisted(() => ({
getParallelApiKey: vi.fn(),
loginWithParallel: vi.fn(),
clearStoredParallelApiKey: vi.fn(),
getParallelAuthStatus: vi.fn(),
registerParallelAuthProvider: vi.fn(),
runParallelSearch: vi.fn(),
runParallelExtract: vi.fn(),
isParallelAuthenticationError: vi.fn(),
}));

vi.mock('../parallel-auth.js', () => ({
getParallelApiKey: mocks.getParallelApiKey,
loginWithParallel: mocks.loginWithParallel,
clearStoredParallelApiKey: mocks.clearStoredParallelApiKey,
getParallelAuthStatus: mocks.getParallelAuthStatus,
registerParallelAuthProvider: mocks.registerParallelAuthProvider,
}));

vi.mock('../parallel-client.js', () => ({
Expand All @@ -29,13 +29,15 @@ type MockPi = {
on: ReturnType<typeof vi.fn>;
registerCommand: ReturnType<typeof vi.fn>;
registerTool: ReturnType<typeof vi.fn>;
registerProvider: ReturnType<typeof vi.fn>;
};

function createMockPi(): MockPi {
return {
on: vi.fn(),
registerCommand: vi.fn(),
registerTool: vi.fn(),
registerProvider: vi.fn(),
};
}

Expand Down Expand Up @@ -90,8 +92,7 @@ describe('@parallel-web/pi-extension', () => {
expect(pi.registerCommand).toHaveBeenCalledWith(
'parallel-login',
expect.objectContaining({
description:
'Run Parallel browser login and store the API key in Pi auth',
description: 'Show Parallel authentication status and how to sign in',
handler: expect.any(Function),
})
);
Expand Down Expand Up @@ -186,8 +187,65 @@ describe('@parallel-web/pi-extension', () => {
expect(result.systemPrompt).toContain('<name>result</name>');
});

it('parallel-login should run browser login and notify on success', async () => {
mocks.loginWithParallel.mockResolvedValue('stored-api-key');
it('should register the Parallel auth provider with Pi', async () => {
const extension = (await import('../index.js')).default;
const pi = createMockPi();

extension(pi as unknown as ExtensionAPI);

expect(mocks.registerParallelAuthProvider).toHaveBeenCalledWith(pi);
});

it('parallel-login should point at /login parallel when unauthenticated', async () => {
mocks.getParallelApiKey.mockResolvedValue(undefined);
mocks.getParallelAuthStatus.mockReturnValue({ configured: false });

const extension = (await import('../index.js')).default;
const pi = createMockPi();
extension(pi as unknown as ExtensionAPI);

const command = getRegisteredCommand(pi, 'parallel-login');
const ctx = createToolContext();

await command.handler([], ctx);

expect(ctx.ui.notify).toHaveBeenCalledWith(
expect.stringContaining('/login parallel'),
'info'
);
expect(ctx.ui.notify).toHaveBeenCalledWith(
expect.stringContaining('not authenticated'),
'info'
);
});

it('parallel-login should report the credential source when authenticated', async () => {
mocks.getParallelApiKey.mockResolvedValue('stored-api-key');
mocks.getParallelAuthStatus.mockReturnValue({
configured: true,
source: 'stored',
});

const extension = (await import('../index.js')).default;
const pi = createMockPi();
extension(pi as unknown as ExtensionAPI);

const command = getRegisteredCommand(pi, 'parallel-login');
const ctx = createToolContext();

await command.handler([], ctx);

expect(ctx.ui.notify).toHaveBeenCalledWith(
expect.stringContaining('authenticated (stored)'),
'info'
);
});

it('parallel-login should recognize a key that only PARALLEL_API_KEY provides', async () => {
// getProviderAuthStatus only sees stored credentials, so an env-var-only
// setup reports unconfigured even though the key resolves fine.
mocks.getParallelApiKey.mockResolvedValue('env-api-key');
mocks.getParallelAuthStatus.mockReturnValue({ configured: false });

const extension = (await import('../index.js')).default;
const pi = createMockPi();
Expand All @@ -198,9 +256,8 @@ describe('@parallel-web/pi-extension', () => {

await command.handler([], ctx);

expect(mocks.loginWithParallel).toHaveBeenCalledWith(ctx);
expect(ctx.ui.notify).toHaveBeenCalledWith(
'Parallel login completed.',
expect.stringContaining('authenticated (PARALLEL_API_KEY)'),
'info'
);
});
Expand Down Expand Up @@ -242,88 +299,54 @@ describe('@parallel-web/pi-extension', () => {
);
});

it('web_search should prompt for auth, login, and retry when no credentials are present', async () => {
it('web_search should direct the user to /login parallel when no credentials are present', async () => {
mocks.getParallelApiKey.mockResolvedValue(undefined);
mocks.loginWithParallel.mockResolvedValue('fresh-api-key');
mocks.runParallelSearch.mockResolvedValue({ ok: true });

const extension = (await import('../index.js')).default;
const pi = createMockPi();
extension(pi as unknown as ExtensionAPI);

const searchTool = getRegisteredTool(pi, 'web_search');
const ctx = createToolContext({
hasUI: true,
ui: {
confirm: vi.fn().mockResolvedValue(true),
notify: vi.fn(),
input: vi.fn(),
},
});

const result = await searchTool.execute(
'tool-call-id',
{ objective: 'Find docs', search_queries: ['parallel docs'] },
undefined,
undefined,
ctx
await expect(
searchTool.execute(
'tool-call-id',
{ objective: 'Find docs', search_queries: ['parallel docs'] },
undefined,
undefined,
createToolContext({ hasUI: true })
)
).rejects.toThrow(
'Parallel authentication required. Run `/login parallel` in Pi, or set PARALLEL_API_KEY.'
);

expect(ctx.ui.confirm).toHaveBeenCalledWith(
'Parallel authentication required',
'This tool needs Parallel auth. Start browser login now?'
);
expect(mocks.loginWithParallel).toHaveBeenCalledWith(ctx);
expect(mocks.runParallelSearch).toHaveBeenCalledWith(
'fresh-api-key',
{
objective: 'Find docs',
search_queries: ['parallel docs'],
client_model: undefined,
session_id: expect.any(String),
},
undefined
);
expect(result.content[0].text).toBe(JSON.stringify({ ok: true }, null, 2));
expect(mocks.runParallelSearch).not.toHaveBeenCalled();
});

it('web_search should reauthenticate and retry on auth failures when UI is available', async () => {
it('web_search should direct the user to re-login when the stored credential is rejected', async () => {
mocks.getParallelApiKey.mockResolvedValue('stale-api-key');
mocks.isParallelAuthenticationError.mockReturnValue(true);
mocks.runParallelSearch
.mockRejectedValueOnce(new Error('unauthorized'))
.mockResolvedValueOnce({ ok: true });
mocks.loginWithParallel.mockResolvedValue('fresh-api-key');
mocks.runParallelSearch.mockRejectedValue(new Error('unauthorized'));

const extension = (await import('../index.js')).default;
const pi = createMockPi();
extension(pi as unknown as ExtensionAPI);

const searchTool = getRegisteredTool(pi, 'web_search');
const ctx = createToolContext({
hasUI: true,
ui: {
confirm: vi.fn().mockResolvedValue(true),
notify: vi.fn(),
input: vi.fn(),
},
});

const result = await searchTool.execute(
'tool-call-id',
{ objective: 'Find docs', search_queries: ['parallel docs'] },
undefined,
undefined,
ctx
await expect(
searchTool.execute(
'tool-call-id',
{ objective: 'Find docs', search_queries: ['parallel docs'] },
undefined,
undefined,
createToolContext({ hasUI: true })
)
).rejects.toThrow(
'Parallel rejected the stored credential. Run `/login parallel` in Pi to sign in again.'
);

expect(mocks.clearStoredParallelApiKey).toHaveBeenCalledWith(ctx);
expect(ctx.ui.confirm).toHaveBeenCalledWith(
'Parallel authentication expired',
'Stored Parallel auth was rejected. Sign in again now?'
);
expect(mocks.runParallelSearch).toHaveBeenCalledTimes(2);
expect(result.content[0].text).toBe(JSON.stringify({ ok: true }, null, 2));
expect(mocks.runParallelSearch).toHaveBeenCalledTimes(1);
});

it('web_search should reject invalid PARALLEL_API_KEY values clearly', async () => {
Expand Down
Loading
Loading