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
100 changes: 100 additions & 0 deletions packages/backend/src/workspace/provider-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
Expand All @@ -23,6 +24,58 @@ function realRuntimeService(agentDir: string) {
});
}

function readSettingsFile(agentDir: string) {
try {
return JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8")) as {
deviceId?: string;
};
} catch {
return undefined;
}
}

/**
* Stub runtime that records the LoginOptions Pace hands to an OAuth login,
* together with the settings file as another process would read it at that
* moment.
*/
function deviceIdCapturingRuntime(
agentDir: string,
onLogin: (deviceId: string | undefined, onDisk: { deviceId?: string } | undefined) => void,
): ProviderAuthRuntime {
return {
getProviderAuthStatus: () => ({ configured: false }),
getProviders: () => [{ id: "openai", name: "OpenAI", auth: { oauth: {} } }],
async login(_providerId, _type, _interaction, options) {
onLogin(options?.getDeviceId?.(), readSettingsFile(agentDir));
return { type: "oauth", access: "a", refresh: "r", expires: 0 };
},
getAvailableSnapshot: () => [],
getModel: () => undefined,
async completeSimple() {
throw new Error("not used");
},
async logout() {},
async refresh() {
return { aborted: false, errors: new Map() };
},
};
}

async function deviceIdFromLogin(agentDir: string) {
let seen: { deviceId?: string; onDisk?: { deviceId?: string } } = {};
const service = createProviderAuthService({
agentDir,
runtime: async () =>
deviceIdCapturingRuntime(agentDir, (deviceId, onDisk) => {
seen = { deviceId, onDisk };
}),
});

await service.loginOAuth("openai");
return seen;
}

// Pi counts a provider as configured when the ambient environment carries its
// credentials (API-key env vars, the AWS chain, gcloud ADC under $HOME), and it
// reads process.env live. Strip the host environment so these tests only see
Expand Down Expand Up @@ -184,6 +237,53 @@ describe("provider auth service", () => {
expect(selected).toBe("browser");
});

// OpenAI registers a ChatGPT subscription login against this installation's
// agent host ID and rejects the login when it is missing, so every login must
// send one and every later login must send the same one.
it("keeps one ChatGPT device ID for the installation", async () => {
const agentDir = await tempAgentDir();

const first = await deviceIdFromLogin(agentDir);

expect(first.deviceId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
// The login asks for the ID before it opens a browser, and the Pi TUI reads
// the same file from another process: it has to be saved by then.
expect(first.onDisk?.deviceId).toBe(first.deviceId);
await expect(deviceIdFromLogin(agentDir)).resolves.toEqual({
deviceId: first.deviceId,
onDisk: { deviceId: first.deviceId },
});
});

it("refuses the login when the installation ID cannot be saved", async () => {
const agentDir = await tempAgentDir();
await writeFile(join(agentDir, "settings.json"), "{ not json", "utf8");
const login = vi.fn(deviceIdCapturingRuntime(agentDir, () => {}).login);
const service = createProviderAuthService({
agentDir,
runtime: async () => ({ ...deviceIdCapturingRuntime(agentDir, () => {}), login }),
});

await expect(service.loginOAuth("openai")).rejects.toThrow(/settings\.json/);
expect(login).not.toHaveBeenCalled();
});

it("reuses the device ID the Pi TUI stored for this installation", async () => {
const agentDir = await tempAgentDir();
await writeFile(
join(agentDir, "settings.json"),
JSON.stringify({ deviceId: "3f2a6c1e-0b47-4d9a-8e11-5c6d7f8a9b0c" }),
"utf8",
);

await expect(deviceIdFromLogin(agentDir)).resolves.toEqual({
deviceId: "3f2a6c1e-0b47-4d9a-8e11-5c6d7f8a9b0c",
onDisk: { deviceId: "3f2a6c1e-0b47-4d9a-8e11-5c6d7f8a9b0c" },
});
});

it("reuses xai OAuth already stored in Pi auth.json", async () => {
const agentDir = await tempAgentDir();
await writeFile(
Expand Down
67 changes: 49 additions & 18 deletions packages/backend/src/workspace/provider-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// persist to auth.json and the runtime snapshot stays in sync.

import { join } from "node:path";
import { ModelRuntime, readStoredCredential } from "@earendil-works/pi-coding-agent";
import { ModelRuntime, SettingsManager, readStoredCredential } from "@earendil-works/pi-coding-agent";
import { openUrlInBrowser } from "./open-url";
import { readSettingsPreferredModel } from "./pi-settings";
import {
Expand Down Expand Up @@ -249,6 +249,31 @@ function settleOrAbort<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
});
}

/**
* Stable ID of this installation. OpenAI registers a ChatGPT subscription login
* against it and rejects the login without one. Pi keeps it in the global
* settings file, so reading it through the same settings manager registers the
* Pi TUI and Pace as one installation rather than two.
*
* The ID is saved before the login starts, not when the flow asks for it: Pi's
* setters only queue their write, and the Pi TUI reads that file from another
* process. A save that fails would hand OpenAI an ID no later process knows.
*/
async function resolveDeviceId(agentDir: string): Promise<string> {
const settings = SettingsManager.create(agentDir, agentDir, { projectTrusted: false });
const deviceId = settings.getOrCreateDeviceId();
await settings.flush();
const [failure] = settings.drainErrors();

if (failure) {
throw new Error(
`Could not save this installation's ID to ${failure.path ?? failure.scope}: ${failure.error.message}`,
);
}

return deviceId;
}

export function createProviderAuthService(
options: ProviderAuthServiceOptions,
): ProviderAuthService {
Expand Down Expand Up @@ -328,26 +353,32 @@ export function createProviderAuthService(
if (!provider.auth.oauth) {
throw new Error(`Provider "${providerId}" does not support subscription login.`);
}
await runtime.login(providerId, "oauth", {
prompt: (prompt: AuthPrompt) => {
// Codex asks browser vs device-code; GUI always takes the browser flow.
if (prompt.type === "select") {
const browser = prompt.options.find((option) => option.id === "browser");
if (browser) {
return Promise.resolve(browser.id);
const deviceId = await resolveDeviceId(options.agentDir);
await runtime.login(
providerId,
"oauth",
{
prompt: (prompt: AuthPrompt) => {
// Codex asks browser vs device-code; GUI always takes the browser flow.
if (prompt.type === "select") {
const browser = prompt.options.find((option) => option.id === "browser");
if (browser) {
return Promise.resolve(browser.id);
}
}
}

return pendPrompt();
},
notify: (event: AuthEvent) => {
if (event.type === "auth_url") {
void openExternal(event.url);
} else if (event.type === "device_code") {
void openExternal(event.verificationUri);
}
return pendPrompt();
},
notify: (event: AuthEvent) => {
if (event.type === "auth_url") {
void openExternal(event.url);
} else if (event.type === "device_code") {
void openExternal(event.verificationUri);
}
},
},
});
{ getDeviceId: () => deviceId },
);

return listStatus();
},
Expand Down
Loading