Skip to content
Open
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
4 changes: 2 additions & 2 deletions packages/pi-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
],
"dependencies": {
"parallel-web": "0.5.0",
"typebox": "^1.1.37"
"typebox": "1.3.7"
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "*"
"@earendil-works/pi-coding-agent": "*"
},
"devDependencies": {
"@parallel-web/oauth": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion packages/pi-extension/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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(),
Expand Down
89 changes: 89 additions & 0 deletions packages/pi-extension/src/__tests__/parallel-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';

let fakeHome: string;

vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
homedir: () => fakeHome,
};
});

vi.mock('@parallel-web/oauth', () => ({
loginWithParallel: vi.fn(),
}));

function createCtx(): ExtensionContext {
return {
ui: {
notify: vi.fn(),
input: vi.fn(),
},
} as unknown as ExtensionContext;
}

describe('parallel-auth', () => {
beforeEach(async () => {
vi.resetModules();
fakeHome = mkdtempSync(join(tmpdir(), 'parallel-auth-test-'));
delete process.env.PARALLEL_API_KEY;
});

afterEach(() => {
rmSync(fakeHome, { recursive: true, force: true });
});

it('returns undefined when nothing is stored and no env var is set', async () => {
const { getParallelApiKey } = await import('../parallel-auth.js');
await expect(getParallelApiKey(createCtx())).resolves.toBeUndefined();
});

it('falls back to PARALLEL_API_KEY when nothing is stored', async () => {
process.env.PARALLEL_API_KEY = 'env-key';
const { getParallelApiKey } = await import('../parallel-auth.js');
await expect(getParallelApiKey(createCtx())).resolves.toBe('env-key');
});

it('stores and retrieves an api key, taking precedence over the env var', async () => {
process.env.PARALLEL_API_KEY = 'env-key';
const { getParallelApiKey, storeParallelApiKey } = await import(
'../parallel-auth.js'
);
const ctx = createCtx();

storeParallelApiKey(ctx, 'stored-key');

await expect(getParallelApiKey(ctx)).resolves.toBe('stored-key');
});

it('clears the stored api key', async () => {
const { getParallelApiKey, storeParallelApiKey, clearStoredParallelApiKey } =
await import('../parallel-auth.js');
const ctx = createCtx();

storeParallelApiKey(ctx, 'stored-key');
clearStoredParallelApiKey(ctx);

await expect(getParallelApiKey(ctx)).resolves.toBeUndefined();
});

it('logs in via the browser flow and persists the resulting key', async () => {
const { loginWithParallel: runParallelOAuth } = await import(
'@parallel-web/oauth'
);
vi.mocked(runParallelOAuth).mockResolvedValue({ apiKey: 'fresh-key' });

const { getParallelApiKey, loginWithParallel } = await import(
'../parallel-auth.js'
);
const ctx = createCtx();

await expect(loginWithParallel(ctx)).resolves.toBe('fresh-key');
await expect(getParallelApiKey(ctx)).resolves.toBe('fresh-key');
});
});
4 changes: 2 additions & 2 deletions packages/pi-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { randomUUID } from 'node:crypto';
import type {
ExtensionAPI,
ExtensionContext,
} from '@mariozechner/pi-coding-agent';
} from '@earendil-works/pi-coding-agent';
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
formatSize,
truncateHead,
} from '@mariozechner/pi-coding-agent';
} from '@earendil-works/pi-coding-agent';
import { Type } from 'typebox';
import {
clearStoredParallelApiKey,
Expand Down
66 changes: 34 additions & 32 deletions packages/pi-extension/src/parallel-auth.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,52 @@
import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
import { randomBytes } from 'node:crypto';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
import { loginWithParallel as runParallelOAuth } from '@parallel-web/oauth';

const PARALLEL_PROVIDER = 'parallel';
const CREDENTIALS_DIR = join(homedir(), '.parallel');
const CREDENTIALS_PATH = join(CREDENTIALS_DIR, 'pi-credentials.json');

type ApiKeyCredential = {
type: 'api_key';
key: string;
type StoredCredentials = {
apiKey: string;
};

type ParallelAuthStorage = {
get(provider: string): ApiKeyCredential | undefined;
set(provider: string, credential: ApiKeyCredential): void;
remove(provider: string): void;
getApiKey?(provider: string): Promise<string | undefined>;
};

function getAuthStorage(ctx: ExtensionContext): ParallelAuthStorage {
return ctx.modelRegistry.authStorage as ParallelAuthStorage;
function readStoredCredentials(): StoredCredentials | undefined {
try {
const raw = readFileSync(CREDENTIALS_PATH, 'utf8');
const parsed = JSON.parse(raw);
return typeof parsed?.apiKey === 'string' ? parsed : undefined;
} catch {
return undefined;
}
}

export async function getParallelApiKey(ctx: ExtensionContext) {
const authStorage = getAuthStorage(ctx);
if (authStorage.getApiKey) {
const apiKey = await authStorage.getApiKey(PARALLEL_PROVIDER);
if (apiKey) {
return apiKey;
}
}
function writeStoredCredentials(credentials: StoredCredentials) {
mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
// Write to a temp file first so a crash mid-write can't corrupt the credentials file.
const tmpPath = `${CREDENTIALS_PATH}.${randomBytes(4).toString('hex')}.tmp`;
writeFileSync(tmpPath, JSON.stringify(credentials), { mode: 0o600 });
rmSync(CREDENTIALS_PATH, { force: true });
writeFileSync(CREDENTIALS_PATH, readFileSync(tmpPath), { mode: 0o600 });
rmSync(tmpPath, { force: true });
}

const storedApiKey = authStorage.get(PARALLEL_PROVIDER)?.key;
if (storedApiKey) {
return storedApiKey;
export async function getParallelApiKey(_ctx: ExtensionContext) {
const stored = readStoredCredentials()?.apiKey;
if (stored) {
return stored;
}

return process.env.PARALLEL_API_KEY;
}

export function clearStoredParallelApiKey(ctx: ExtensionContext) {
getAuthStorage(ctx).remove(PARALLEL_PROVIDER);
export function clearStoredParallelApiKey(_ctx: ExtensionContext) {
rmSync(CREDENTIALS_PATH, { force: true });
}

export function storeParallelApiKey(ctx: ExtensionContext, apiKey: string) {
getAuthStorage(ctx).set(PARALLEL_PROVIDER, {
type: 'api_key',
key: apiKey,
});
export function storeParallelApiKey(_ctx: ExtensionContext, apiKey: string) {
writeStoredCredentials({ apiKey });
}

export async function loginWithParallel(ctx: ExtensionContext) {
Expand Down
Loading