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: 3 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ If you use Pi on a desktop, most of it carries over:
- Project instructions from `AGENTS.md` or `CLAUDE.md`
- Import and export of Pi CLI session files (JSONL)

What differs: files go through Acode's `fsOperation` (local, SAF, FTP, SFTP), `bash` only exists on Terminal-backed folders, and OAuth uses a custom tab with device approval or an app callback instead of a localhost server. Pi packages, tmux and the Pi TUI are not included.
What differs: files go through Acode's `fsOperation` (local, SAF, FTP, SFTP), `bash` only exists on Terminal-backed folders, and OAuth uses Acode's native browser and callback support. Pi packages, tmux and the Pi TUI are not included.

## Requirements

Expand Down Expand Up @@ -102,11 +102,13 @@ Add credentials under **Provider access**. Keys and tokens are kept in Acode's p
| -------------- | --------------------- |
| OpenRouter | OpenRouter account |
| Codex | ChatGPT Plus / Pro |
| ChatGPT | ChatGPT Plus / Pro |
| GitHub Copilot | Copilot subscription |
| xAI | Grok / X subscription |
| Kimi Code | Kimi Code |

- **OpenRouter** opens a custom tab and connects when you approve. No code to paste.
- **ChatGPT** uses OpenAI's official Sign in with ChatGPT integration. Tap **Continue with ChatGPT**, approve plan usage, then tap **Return to Acode** if needed. On Android, use Chrome for browser sign-in. One ChatGPT account is saved at a time. **Disconnect** signs out; **Remove account** then forgets the saved account so you can connect another. Reconnecting uses the saved registration directly. Use **Manage usage** to review access and limits in ChatGPT. Requests use the public Responses API and your ChatGPT plan allowance.
- **Codex, xAI and Kimi Code** use device codes: tap **Copy code & sign in**, paste the code on the provider's page and approve. For Codex, first enable device-code authorization in ChatGPT under Settings → Security.
- **GitHub Copilot** uses github.com by default. **Other sign-in options** accepts an Enterprise domain.

Expand Down
18 changes: 16 additions & 2 deletions src/app/agentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ export class AgentController {
this.#state.authFlow = {
providerId,
status: "connecting",
signedIn: true,
message: "Signed in. Loading your models…",
};
this.#emit();
Expand Down Expand Up @@ -807,11 +808,24 @@ export class AgentController {

async removeCredential(providerId: ProviderId): Promise<void> {
if (this.#state.authFlow?.providerId === providerId) this.cancelSubscriptionLogin();
await this.providers.models.logout(providerId);
const flow = this.#state.authFlow;
const revoked =
providerId === "chatgpt"
? await this.providers.chatgptAccount.disconnect()
: (await this.providers.models.logout(providerId), true);
await this.providers.refreshModelAvailability(providerId);
if (this.settings.value.providerId === providerId) this.#refreshModels();
this.#state.authFlow = undefined;
if (this.#state.authFlow === flow) this.#state.authFlow = undefined;
this.#emit();
if (!revoked)
throw new Error(
"Signed out locally. Remote revocation could not be confirmed; disconnect Acode AI Agent in ChatGPT Settings → Usage.",
);
}

async forgetChatGPTAccount(): Promise<void> {
if (this.#state.authFlow?.providerId === "chatgpt") this.cancelSubscriptionLogin();
await this.providers.chatgptAccount.forget();
}

async hasCredential(providerId: ProviderId): Promise<boolean> {
Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ export type PublicAgentState = {
providerId: ProviderId;
status: "waiting" | "connecting" | "connected" | "error";
browserReturned?: boolean;
/** Credentials are saved; cancelling now would not undo the sign-in. */
signedIn?: boolean;
userCode?: string;
verificationUri?: string;
message?: string;
Expand Down
131 changes: 131 additions & 0 deletions src/platform/browserLoopback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/** Shared browser and native callback page; no remote assets are needed. */
export function loopbackPage(
title: string,
message: string,
returnUri: string,
script = "",
state: "loading" | "ready" | "error" = "ready",
): { html: string; policy: string } {
const nonce = crypto.randomUUID();
const policy = `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; worker-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'`;
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>ChatGPT sign-in · Acode</title><style nonce="${nonce}">
*{box-sizing:border-box}body{margin:0;min-height:100vh;min-height:100svh;display:grid;place-items:center;padding:24px;background:#101318;color:#f1f4f8;font:16px/1.6 system-ui,sans-serif}main{width:min(100%,420px);padding:32px;border:1px solid #2c3340;border-radius:24px;background:#191e27;box-shadow:0 16px 64px #0003}.brand{margin:0 0 24px;color:#9cb9ec;font-size:13px;font-weight:650;letter-spacing:.05em}#indicator{width:48px;height:48px;margin-bottom:20px;border-radius:50%;background:#263b32;color:#94e4b5;display:grid;place-items:center;font-size:24px}#indicator[data-state="ready"]:before{content:'✓'}#indicator[data-state="error"]{background:#423029;color:#ffc19d}#indicator[data-state="error"]:before{content:'!'}#indicator[data-state="loading"]{background:none;border:3px solid #303b50;border-top-color:#98bcff;animation:spin 1s linear infinite}h1{margin:0 0 12px;font-size:26px;line-height:1.25;letter-spacing:-.02em}#status{margin:0;color:#bbc5d4}a:not([hidden]){display:block;margin-top:28px;padding:13px 18px;border-radius:12px;background:#9cbfff;color:#111c31;text-align:center;text-decoration:none;font-weight:650}a:focus-visible{outline:3px solid #fff;outline-offset:4px}.hint{margin:18px 0 0;font-size:13px;color:#929eb0}@keyframes spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){#indicator{animation:none!important}}@media(prefers-color-scheme:light){body{background:#f3f5f9;color:#182135}main{background:#fff;border-color:#dce2ec}.brand{color:#365c98}#status{color:#526078}.hint{color:#66758c}}
</style></head><body><main><p class="brand">Acode AI Agent</p><div id="indicator" data-state="${state}" aria-hidden="true"></div><h1 id="title">${title}</h1><p id="status" role="status" aria-live="polite">${message}</p><a id="return" href="${returnUri}"${state === "loading" ? " hidden" : ""}>Return to Acode</a><p class="hint">${state === "loading" ? "This will only take a moment." : "If Acode does not open automatically, tap the button."}</p></main>${script ? `<script nonce="${nonce}">${script}</script>` : ""}</body></html>`;
return { html, policy };
}

/** Chrome owns the callback after setup; initial page/worker loading still needs Acode. */
export function browserLoopbackPages(
authorization: string,
state: string,
returnUri: string,
expiresAt: number,
): { start: string; worker: string; policy: string; startPolicy: string } {
const json = (value: unknown) => JSON.stringify(value).replaceAll("<", "\\u003c");
const callback = loopbackPage(
"You're signed in",
"Returning to Acode to finish connecting your ChatGPT account…",
returnUri,
`(${returnToAcode.toString()})(${json(returnUri)});`,
);
const start = loopbackPage(
"Connecting to ChatGPT",
"Preparing your secure sign-in…",
returnUri,
`(${prepareBrowser.toString()})(${json(authorization)},${json(state)});`,
"loading",
);
return {
policy: callback.policy,
startPolicy: start.policy,
start: start.html,
worker: `self.addEventListener('install',event=>event.waitUntil(self.skipWaiting()));
self.addEventListener('activate',event=>event.waitUntil(self.clients.claim()));
self.addEventListener('fetch',event=>{
const url=new URL(event.request.url);
if(event.request.method!=='GET'||url.pathname!=='/auth/callback')return;
const states=url.searchParams.getAll('state');
if(states.length!==1||states[0]!==${json(state)}||Date.now()>${expiresAt}){
event.respondWith(new Response('This sign-in callback is invalid or expired.',{status:400}));return;
}
event.respondWith(new Response(${json(callback.html)},{headers:{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store','Referrer-Policy':'no-referrer','Content-Security-Policy':${json(callback.policy)}}}));
event.waitUntil(self.registration.unregister());
});`,
};
}

// These functions are serialized into browser scripts; keep them self-contained.
async function prepareBrowser(authorization: string, state: string) {
let finished = false;
let registration: ServiceWorkerRegistration | undefined;
let worker: ServiceWorker | undefined;
let changed: (() => void) | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
(async () => {
const registered = await navigator.serviceWorker.register(
`/auth/sw.js?state=${encodeURIComponent(state)}`,
{ scope: "/auth/", updateViaCache: "none" },
);
if (finished) {
await registered.unregister();
return;
}
registration = registered;
worker =
registration.installing ?? registration.waiting ?? registration.active ?? undefined;
if (!worker) throw new Error("Browser callback could not start.");
if (worker.state !== "activated")
await new Promise<void>((resolve, reject) => {
changed = () => {
if (worker!.state === "activated") resolve();
else if (worker!.state === "redundant")
reject(new Error("Browser callback could not start."));
};
worker!.addEventListener("statechange", changed);
changed();
});
})(),
new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error("Browser callback preparation timed out.")),
15_000,
);
}),
]);
location.replace(authorization);
} catch {
void registration?.unregister().catch(() => undefined);
document.getElementById("title")!.textContent = "Sign-in could not start";
document.getElementById("indicator")!.dataset.state = "error";
document.getElementById("status")!.textContent =
"Return to Acode and try signing in again using Chrome.";
document.getElementById("return")!.hidden = false;
} finally {
finished = true;
clearTimeout(timeout);
if (worker && changed) worker.removeEventListener("statechange", changed);
}
}

function returnToAcode(returnUri: string) {
const query = new URLSearchParams(location.search);
history.replaceState(null, "", location.pathname);
// Android intent URIs carry their target after the fragment; plain URIs take the query at the end.
const suffix = returnUri.indexOf("#Intent;");
const split = suffix < 0 ? returnUri.length : suffix;
const target = returnUri.slice(0, split) + "?" + query.toString() + returnUri.slice(split);
document.getElementById("return")!.setAttribute("href", target);
if (query.has("error")) {
document.getElementById("title")!.textContent = "Sign-in cancelled";
document.getElementById("indicator")!.dataset.state = "error";
}
document.getElementById("status")!.textContent = query.has("error")
? "Sign-in was not approved. Return to Acode to try again."
: "Sign-in approved. Return to Acode to finish connecting.";
// Chrome can require a tap for an intent; retain the button if automatic opening is blocked.
try {
location.replace(target);
} catch {}
}
36 changes: 35 additions & 1 deletion src/platform/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class PortableCredentialStore implements CredentialStore {
#chains = new Map<string, Promise<unknown>>();
#extraIds: () => readonly string[];
#pending = new Map<string, PendingSignIn>();
#oauthStates = new Map<string, unknown>();

constructor(ctx: Acode.PluginContext | null, extraIds: () => readonly string[] = () => []) {
this.#ctx = ctx;
Expand All @@ -41,6 +42,7 @@ export class PortableCredentialStore implements CredentialStore {
"openrouter",
"openai",
"openai-codex",
"chatgpt",
"anthropic",
"github-copilot",
"google",
Expand Down Expand Up @@ -94,10 +96,15 @@ export class PortableCredentialStore implements CredentialStore {
);
}

delete(providerId: string, options?: AuthOperationOptions): Promise<void> {
delete(
providerId: string,
options?: AuthOperationOptions,
beforeDelete?: (current: Credential | undefined) => Promise<void>,
): Promise<void> {
return this.#enqueue(
providerId,
async () => {
if (beforeDelete) await beforeDelete(await this.read(providerId));
this.#memory.delete(providerId);
if (this.#ctx) await this.#ctx.setSecret(`${SECRET_PREFIX}${providerId}`, "");
this.changes.emit(providerId);
Expand All @@ -110,6 +117,33 @@ export class PortableCredentialStore implements CredentialStore {
return this.#enqueue(`pending:${providerId}`, () => this.#readPending(providerId));
}

/** Provider-owned registration/profile state, protected like the active credential. */
oauthState<T>(providerId: string): Promise<T | undefined> {
return this.#enqueue(`state:${providerId}`, () => this.#readOAuthState<T>(providerId));
}

updateOAuthState<T>(providerId: string, update: (state: T | undefined) => T): Promise<T> {
return this.#enqueue(`state:${providerId}`, async () => {
const next = update(await this.#readOAuthState<T>(providerId));
if (this.#ctx) await this.#ctx.setSecret(`oauth-state:${providerId}`, JSON.stringify(next));
else this.#oauthStates.set(providerId, next);
this.changes.emit(providerId);
return next;
});
}

async #readOAuthState<T>(providerId: string): Promise<T | undefined> {
if (!this.#ctx) return this.#oauthStates.get(providerId) as T | undefined;
const raw = await this.#ctx.getSecret(`oauth-state:${providerId}`, "");
if (!raw) return undefined;
try {
const value = JSON.parse(raw);
return value && typeof value === "object" && !Array.isArray(value) ? (value as T) : undefined;
} catch {
return undefined;
}
}

savePendingSignIn(providerId: string, pending: PendingSignIn): Promise<void> {
return this.#enqueue(`pending:${providerId}`, async () => {
if (this.#ctx)
Expand Down
Loading
Loading