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
4 changes: 2 additions & 2 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ on:
push:
branches: [main]
paths:
- 'site/**'
- '.github/workflows/site.yml'
- "site/**"
- ".github/workflows/site.yml"
workflow_dispatch:

permissions:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> [!TIP]
> **📖 See the docs & live demos → [galaxyproject.github.io/loom](https://galaxyproject.github.io/loom/)**
> — *Agentic Science with Galaxy*: an overview of Orbit, Loom, and Galaxy MCP, an animated walkthrough of real analyses, and getting-started guides.
> — _Agentic Science with Galaxy_: an overview of Orbit, Loom, and Galaxy MCP, an animated walkthrough of real analyses, and getting-started guides.

An AI research harness for [Galaxy](https://galaxyproject.org) bioinformatics, built on [Pi.dev](https://pi.dev).

Expand Down
1,390 changes: 628 additions & 762 deletions app/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@earendil-works/pi-ai": "^0.78.0",
"@earendil-works/pi-ai": "^0.83.0",
"@types/dompurify": "^3.0.5",
"@xyflow/react": "^12.10.2",
"dompurify": "^3.4.1",
Expand Down
7 changes: 4 additions & 3 deletions app/src/main/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { buildBrainEnv as buildBaseBrainEnv } from "../../../shared/brain-env.js
import { noLocalShellSpawnExtras } from "./local-shell.js";
import { TurnWatchdog } from "./turn-watchdog.js";
import { formatWindowTitle } from "./window-title.js";
import { isOAuthProvider } from "./oauth-handler.js";

/**
* How long the brain may stay completely silent mid-turn before Orbit treats the
Expand All @@ -31,8 +32,8 @@ const PROVIDER_ENV_MAP: Record<string, string> = {
deepseek: "DEEPSEEK_API_KEY",
};

/** Providers that authenticate via OAuth (~/.pi/agent/auth.json), not env vars. */
const OAUTH_PROVIDERS: ReadonlySet<string> = new Set(["openai-codex"]);
// Providers that authenticate via OAuth (~/.pi/agent/auth.json), not env vars.
// Sourced from pi's registry rather than a local list -- see oauth-handler.

/** Build the secret env vars injected into the brain subprocess. */
function buildSecretEnv(): Record<string, string> {
Expand All @@ -45,7 +46,7 @@ function buildSecretEnv(): Record<string, string> {
// If the user switched away from an API-key provider the old key is still in
// config.json (preserved on purpose so they can switch back); don't leak it
// into the env under a misrouted variable name.
if (!OAUTH_PROVIDERS.has(provider)) {
if (!isOAuthProvider(provider)) {
// Custom OpenAI-compatible endpoints route through pi's --api-key via
// LOOM_ACTIVE_LLM_API_KEY; built-in providers use their own env var.
const targetVar = isCustom
Expand Down
3 changes: 1 addition & 2 deletions app/src/main/galaxy-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@
import { validateGalaxyUrl } from "./galaxy-url.js";

export type GalaxyUserStatus =
| { ok: true; username?: string; email?: string }
| { ok: false; authFailed: boolean };
{ ok: true; username?: string; email?: string } | { ok: false; authFailed: boolean };

/** A non-empty string, or undefined -- Galaxy returns "" for unset fields. */
function nonEmptyString(v: unknown): string | undefined {
Expand Down
15 changes: 11 additions & 4 deletions app/src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import {
} from "./galaxy-status.js";
import { fetchGalaxyCurrentUser, type GalaxyUserStatus } from "./galaxy-user.js";
import { normalizeGalaxyUrl, validateGalaxyUrl } from "./galaxy-url.js";
import { getProviders, getModels } from "@earendil-works/pi-ai";
// pi 0.80 moved pi-ai's global API off the package root to /compat. pi's
// extension loader aliases the root back to compat, but this is Orbit's main
// process -- not an extension -- so it gets no alias and must import /compat
// directly or these are undefined at runtime.
import { getProviders, getModels } from "@earendil-works/pi-ai/compat";
import { isDeprecatedModelId } from "./model-catalog.js";
import { checkLatestVersion } from "./version-check.js";
import { resolveReleasePageUrl } from "./release-page.js";
Expand All @@ -29,7 +33,8 @@ import type { FeedbackPayload } from "../../../shared/feedback-contract.js";
import {
getOAuthStatus,
isOAuthProvider,
signInOpenAICodex,
listOAuthProviders,
signInOAuth,
signOutOAuth,
} from "./oauth-handler.js";
import { isLocalShellAvailable } from "./local-shell.js";
Expand Down Expand Up @@ -522,12 +527,14 @@ export function registerIpcHandlers(agent: AgentManager): void {
return getOAuthStatus(provider);
});

ipc.handle("oauth:providers", () => listOAuthProviders());

ipc.handle("oauth:sign-in", async (_e, provider: string) => {
if (provider !== "openai-codex") {
if (!isOAuthProvider(provider)) {
return { ok: false as const, error: `Unknown OAuth provider: ${provider}` };
}
try {
const status = await signInOpenAICodex();
const status = await signInOAuth(provider);
// Restart the brain so it picks up the new credential on next prompt.
agent.stop();
agent.start();
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import path from "node:path";
import os from "node:os";
import { pathToFileURL } from "node:url";
import { registerIpcHandlers, confirmCwdChange } from "./ipc-handlers.js";
import { primeOAuthProviders } from "./oauth-handler.js";
import { AgentManager } from "./agent.js";
import { registerFilesIpc, startFilesWatcher, stopFilesWatcher } from "./files-handler.js";
import { ProcMonitor } from "./proc-monitor.js";
Expand Down Expand Up @@ -284,6 +285,12 @@ function createWindow(cwd: string): void {
}

agentManager = new AgentManager(mainWindow, cwd);
// Which providers offer sign-in comes from pi's registry, and reading it is
// async. Kick it off here rather than awaiting: the seed list covers the
// provider that ships enabled, so a status check that lands first is still
// answered correctly and the full list is in place well before anyone opens
// Preferences.
void primeOAuthProviders().catch(() => {});
registerIpcHandlers(agentManager);
registerFilesIpc(() => agentManager?.getCwd() ?? cwd);
startFilesWatcher(mainWindow, cwd);
Expand Down
104 changes: 82 additions & 22 deletions app/src/main/oauth-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { shell } from "electron";
import { loginOpenAICodex } from "@earendil-works/pi-ai/oauth";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";

/**
* OAuth provider integration for the brain's auth.json. The brain (pi-coding-agent)
Expand All @@ -11,10 +11,45 @@ import { loginOpenAICodex } from "@earendil-works/pi-ai/oauth";
* to that file. The brain handles refresh on its own via AuthStorage's locking.
*/

const OAUTH_PROVIDERS = new Set<string>(["openai-codex"]);
/**
* Which providers authenticate by sign-in is pi's business, not ours: since pi
* 0.81 each provider carries its own `auth.oauth`, so we read the list off the
* registry instead of hardcoding it and going stale the next time pi adds one.
*
* That read is async and several callers are sync, so prime the cache once at
* startup (primeOAuthProviders) and let the sync accessors serve from it. The
* seed keeps pre-prime calls honest for the provider we know ships enabled --
* without it a status check racing startup would report "not an OAuth provider"
* and the UI would offer an API-key field for an account that has none.
*/
const SEED_OAUTH_PROVIDERS = ["openai-codex"];
let oauthProviders: Map<string, string> = new Map(SEED_OAUTH_PROVIDERS.map((id) => [id, ""]));

/** Read the OAuth-capable providers off pi's registry. Call once at startup. */
export async function primeOAuthProviders(): Promise<ReadonlyMap<string, string>> {
try {
const runtime = await ModelRuntime.create({ authPath: getAuthPath() });
const found = new Map<string, string>();
for (const provider of await runtime.getProviders()) {
const oauth = provider.auth?.oauth;
if (oauth?.login) found.set(provider.id, oauth.loginLabel || oauth.name || "");
}
// Never shrink below the seed -- an empty read means something is wrong with
// the registry, not that sign-in stopped existing.
if (found.size > 0) oauthProviders = found;
} catch (err) {
console.error("[oauth] could not read providers from the registry:", err);
}
return oauthProviders;
}

export function isOAuthProvider(provider: string | undefined): boolean {
return Boolean(provider && OAUTH_PROVIDERS.has(provider));
return Boolean(provider && oauthProviders.has(provider));
}

/** id -> button label, e.g. "openai-codex" -> "OpenAI (ChatGPT Plus/Pro)". */
export function listOAuthProviders(): Record<string, string> {
return Object.fromEntries(oauthProviders);
}

function getAuthPath(): string {
Expand Down Expand Up @@ -58,8 +93,7 @@ export interface OAuthStatus {
export function getOAuthStatus(provider: string): OAuthStatus {
const data = readAuthFile();
const cred = data[provider] as
| { type?: string; expires?: number; accountId?: string }
| undefined;
{ type?: string; expires?: number; accountId?: string } | undefined;
if (!cred || cred.type !== "oauth") return { signedIn: false };
const expiresInSeconds =
typeof cred.expires === "number" ? Math.floor((cred.expires - Date.now()) / 1000) : undefined;
Expand All @@ -74,32 +108,58 @@ export function signOutOAuth(provider: string): void {
}

/**
* Drive the OpenAI Codex OAuth flow. Opens the auth URL in the user's default
* browser; pi-ai's loginOpenAICodex spins up a local callback server on
* 127.0.0.1:1455 and returns once the browser hands back the code.
* Drive a provider's OAuth flow and persist the result to auth.json. Opens the
* auth URL in the user's default browser; the provider's flow runs a local
* callback server (127.0.0.1:1455 for OpenAI Codex) and returns once the
* browser hands the code back.
*
* pi 0.81 folded the per-service `loginOpenAICodex()` helper into the provider
* itself: auth now hangs off `provider.auth.oauth` as a login/refresh/toAuth
* triple, and driving login is the app's job. Asking the runtime for the
* provider is what makes this work for any of them rather than one by name.
*
* Throws if the flow fails (port conflict, user cancellation, network error).
*/
export async function signInOpenAICodex(): Promise<OAuthStatus> {
const creds = await loginOpenAICodex({
onAuth: ({ url }) => {
void shell.openExternal(url);
export async function signInOAuth(provider: string): Promise<OAuthStatus> {
const runtime = await ModelRuntime.create({ authPath: getAuthPath() });
const oauth = runtime.getProvider(provider)?.auth?.oauth;
if (!oauth?.login) {
throw new Error(`${provider} does not offer OAuth sign-in in this build of pi.`);
}

const creds = await oauth.login({
notify: (event) => {
if (event.type === "auth_url") {
void shell.openExternal(event.url);
return;
}
if (event.type === "device_code") {
// Device-code providers want the user to type a code on another page.
// Orbit has no UI for that yet, so open the page and log the code
// rather than silently appearing to hang.
void shell.openExternal(event.verificationUri);
console.log("[oauth] enter code:", event.userCode, "at", event.verificationUri);
return;
}
if (event.type === "progress" || event.type === "info") {
console.log("[oauth]", event.message);
}
},
// Fallback paste path -- only triggered if the local callback server fails
// to start (port already in use). Orbit doesn't surface a paste UI today,
// so we reject with a guidance message instead of hanging.
onPrompt: async () => {
// Fallback paste path -- triggered when a provider wants a code pasted back,
// or when the local callback server can't bind (port already in use). Orbit
// doesn't surface a paste UI today, so reject with guidance instead of
// hanging on a prompt nobody can answer.
prompt: async () => {
throw new Error(
"OAuth callback server could not bind to 127.0.0.1:1455. " +
"Free the port (e.g. quit Codex CLI) and try again.",
`${provider} needs a code pasted back to finish signing in, which Orbit ` +
`cannot prompt for yet. If you expected a browser redirect instead, ` +
`free the callback port (e.g. quit Codex CLI) and try again.`,
);
},
onProgress: (msg) => console.log("[oauth]", msg),
originator: "loom",
});

const data = readAuthFile();
data["openai-codex"] = {
data[provider] = {
type: "oauth",
access: creds.access,
refresh: creds.refresh,
Expand All @@ -108,5 +168,5 @@ export async function signInOpenAICodex(): Promise<OAuthStatus> {
};
writeAuthFile(data);

return getOAuthStatus("openai-codex");
return getOAuthStatus(provider);
}
3 changes: 3 additions & 0 deletions app/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export interface OrbitAPI {
key: string,
baseUrl?: string,
): Promise<{ valid: boolean; error?: string; models?: string[] }>;
/** Provider id -> sign-in button label, sourced from pi's registry. */
oauthProviders(): Promise<Record<string, string>>;
oauthStatus(
provider: string,
): Promise<{ signedIn: boolean; expiresInSeconds?: number; accountId?: string }>;
Expand Down Expand Up @@ -187,6 +189,7 @@ const api: OrbitAPI = {
setBypassPermissions: (enabled) => ipcRenderer.invoke("guardian:set-bypass", enabled),
validateApiKey: (provider, key, baseUrl) =>
ipcRenderer.invoke("apiKey:validate", provider, key, baseUrl),
oauthProviders: () => ipcRenderer.invoke("oauth:providers"),
oauthStatus: (provider) => ipcRenderer.invoke("oauth:status", provider),
oauthSignIn: (provider) => ipcRenderer.invoke("oauth:sign-in", provider),
oauthSignOut: (provider) => ipcRenderer.invoke("oauth:sign-out", provider),
Expand Down
Loading
Loading