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
Original file line number Diff line number Diff line change
Expand Up @@ -156,16 +156,11 @@ test('imports a local GitHub credential through the shared Host account path', a
connectionId: CONNECTION_ID,
modelId: discoveredModelId,
});
assert.deepEqual(await invoke(handlers, 'github-copilot:get-account-state'), {
provider: 'github-copilot',
runtimeState: 'authenticated',
});

assert.deepEqual(await invoke(handlers, 'github-copilot:refresh-tokens'), { ok: true });
assert.deepEqual(await invoke(handlers, 'github-copilot:logout'), { ok: true });
assert.equal(storedSecret, undefined);
assert.equal(catalog.connections[0]?.enabled, false);
assert.equal(changed, 3);
assert.equal(changed, 1);
// Interactive enrollment, account state, refresh, and sign-out belong to the
// Host OAuth coordinator's shared adapter; Desktop registers the local
// credential import and nothing else.
assert.deepEqual([...handlers.keys()], ['github-copilot:connect-existing-login']);
});

async function invoke(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => {

assert.deepEqual([...handlers.keys()].sort(), [...RUNTIME_HOST_OAUTH_IPC_CHANNELS].sort());

for (const prefix of ['openai-codex', 'xai-oauth']) {
for (const prefix of ['openai-codex', 'xai-oauth', 'github-copilot']) {
assert.equal(handlers.has(`${prefix}:get-auth-url`), true);
assert.equal(handlers.has(`${prefix}:complete-authorization`), true);
assert.equal(handlers.has(`${prefix}:get-account-state`), true);
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/oauth-connection-identities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ import type { OAuthLoginProvider } from '@maka/runtime-host/protocol';
export const INTERACTIVE_OAUTH_CONNECTION_SLUGS = {
'openai-codex': 'codex-subscription',
'xai-oauth': 'xai-oauth',
// Shared with the local `gh` credential import so both routes to a Copilot
// account land on one Connection instead of two.
'github-copilot': 'github-copilot',
} as const satisfies Readonly<Record<OAuthLoginProvider, string>>;
78 changes: 17 additions & 61 deletions apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import type { ModelInfo } from '@maka/core/llm-connections';
import type { SubscriptionActionResult } from '@maka/core/oauth-subscription';
import {
handleReconnectableRead,
type ReconnectableReadIpcMain,
} from './ipc-reconnect-policy.js';
import { GitHubCopilotSubscriptionService } from './oauth/github-copilot-subscription-service.js';
import type { ReconnectableReadIpcMain } from './ipc-reconnect-policy.js';
import { INTERACTIVE_OAUTH_CONNECTION_SLUGS } from './oauth-connection-identities.js';
import {
disableRuntimeHostAccountConnection,
ensureRuntimeHostAccountConnection,
findRuntimeHostAccountConnection,
runtimeHostAccountCredential,
setRuntimeHostAccountCredential,
synchronizeRuntimeHostAccountConnection,
type RuntimeHostAccountConnectionClient,
} from './runtime-host-account-connection.js';
import type { DesktopRuntimeHostClient } from './runtime-host-client.js';

const PROVIDER = 'github-copilot';
const CONNECTION_SLUG = 'github-copilot';
const CONNECTION_SLUG = INTERACTIVE_OAUTH_CONNECTION_SLUGS[PROVIDER];

type GitHubCopilotClient = RuntimeHostAccountConnectionClient &
Pick<DesktopRuntimeHostClient, 'setCredential'>;
Expand All @@ -36,10 +31,17 @@ export interface RuntimeHostGitHubCopilotIpcDeps {
readonly importExistingLogin?: () => Promise<ImportedGitHubCopilotCredential>;
}

/** Keeps local `gh` discovery in Desktop while committing its credential only to the Host vault. */
export function registerRuntimeHostGitHubCopilotIpc(
deps: RuntimeHostGitHubCopilotIpcDeps,
): void {
/**
* Desktop owns exactly one thing for GitHub Copilot: importing a credential
* that already exists on this machine (`gh` / a compatible PAT). Interactive
* enrollment is not here — the device grant runs through the Host's OAuth
* coordinator like every other account login, so there is one authority that
* serializes starts, owns supersede and cancellation, keeps the Host resident
* while polling, uses the configured network transport, and commits the
* credential atomically. Account state, refresh, and sign-out ride the same
* shared `github-copilot:*` channels the coordinator's IPC adapter registers.
*/
export function registerRuntimeHostGitHubCopilotIpc(deps: RuntimeHostGitHubCopilotIpcDeps): void {
const importExistingLogin = deps.importExistingLogin ?? importGitHubCopilotCredential;

deps.ipcMain.handle('github-copilot:connect-existing-login', async () => {
Expand All @@ -53,60 +55,18 @@ export function registerRuntimeHostGitHubCopilotIpc(
imported.result.models.map(({ id }) => id),
);
await setRuntimeHostAccountCredential(deps.client, connection, imported.secret);
await synchronizeRuntimeHostAccountConnection(deps.client, PROVIDER).catch(
() => undefined,
);
await synchronizeRuntimeHostAccountConnection(deps.client, PROVIDER).catch(() => undefined);
deps.emitConnectionListChanged();
return { ok: true as const };
} catch {
return storageFailure('GitHub Copilot login could not be committed to Runtime Host');
}
});

handleReconnectableRead(deps.ipcMain, 'github-copilot:get-account-state', async () => {
const connection = findRuntimeHostAccountConnection(
await deps.client.loadConnectionCatalog(),
PROVIDER,
);
const credential = connection
? await deps.client.queryCredential(runtimeHostAccountCredential(connection))
: null;
return {
provider: PROVIDER,
runtimeState: credential?.configured ? 'authenticated' : 'not_logged_in',
} as const;
});

deps.ipcMain.handle('github-copilot:refresh-tokens', async () => {
const connection = findRuntimeHostAccountConnection(
await deps.client.loadConnectionCatalog(),
PROVIDER,
);
if (!connection) return refreshFailure('GitHub Copilot is not connected');
const credential = await deps.client.queryCredential(
runtimeHostAccountCredential(connection),
);
if (!credential?.configured) return refreshFailure('GitHub Copilot is not connected');
const refreshed = await deps.client.fetchConnectionModels(connection.connectionId);
if (refreshed.kind !== 'committed') {
return refreshFailure(`GitHub Copilot refresh failed: ${refreshed.kind}`);
}
deps.emitConnectionListChanged();
return { ok: true as const };
});

deps.ipcMain.handle('github-copilot:logout', async () => {
try {
await disableRuntimeHostAccountConnection(deps.client, PROVIDER);
} catch {
return storageFailure('GitHub Copilot account could not be removed from Runtime Host');
}
deps.emitConnectionListChanged();
return { ok: true as const };
});
}

async function importGitHubCopilotCredential(): Promise<ImportedGitHubCopilotCredential> {
// The credential never reaches disk in Desktop: the secret is held in memory
// only long enough to be committed to the Host vault.
let secret: string | undefined;
const service = new GitHubCopilotSubscriptionService({
credentialStore: {
Expand All @@ -126,7 +86,3 @@ async function importGitHubCopilotCredential(): Promise<ImportedGitHubCopilotCre
function storageFailure(message: string) {
return { ok: false as const, reason: 'storage_failed' as const, message };
}

function refreshFailure(message: string) {
return { ok: false as const, reason: 'refresh_failed' as const, message };
}
24 changes: 23 additions & 1 deletion apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,9 +961,31 @@ export interface MakaBridge {
};
githubCopilotSubscription: {
connectExistingLogin(host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise<boolean>;
getAuthUrl(
host?: DesktopRuntimeHostRef,
): Promise<AuthorizationUrlPayload | SubscriptionActionResult>;
openAuthUrl(
authRequestId: string,
host?: DesktopRuntimeHostRef,
): Promise<SubscriptionActionResult>;
completeAuthorization(
authRequestId: string,
host?: DesktopRuntimeHostRef,
): Promise<SubscriptionActionResult>;
cancelAuthorization(
authRequestId?: string,
host?: DesktopRuntimeHostRef,
): Promise<{ ok: true }>;
getAccountState(host?: DesktopRuntimeHostRef): Promise<{
provider: 'github-copilot';
runtimeState: 'not_logged_in' | 'authenticated' | 'refreshing' | 'refresh_failed' | 'storage_failed';
runtimeState:
| 'not_logged_in'
| 'authorizing'
| 'authenticated'
| 'refreshing'
| 'refresh_failed'
| 'storage_failed';
errorMessage?: string;
}>;
refreshTokens(host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
Expand Down
17 changes: 16 additions & 1 deletion apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2250,9 +2250,24 @@ const makaBridge = {
connectExistingLogin(host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'github-copilot:connect-existing-login');
},
isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise<boolean> {
return invokeSelectedRuntimeHost(host, 'github-copilot:is-experimental-enabled');
},
getAuthUrl(host?: DesktopRuntimeHostRef): Promise<AuthorizationUrlPayload | SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'github-copilot:get-auth-url');
},
openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'github-copilot:open-auth-url', authRequestId);
},
completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'github-copilot:complete-authorization', authRequestId);
},
cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> {
return invokeSelectedRuntimeHost(host, 'github-copilot:cancel-authorization', authRequestId);
},
getAccountState(host?: DesktopRuntimeHostRef): Promise<{
provider: 'github-copilot';
runtimeState: 'not_logged_in' | 'authenticated' | 'refreshing' | 'refresh_failed' | 'storage_failed';
runtimeState: 'not_logged_in' | 'authorizing' | 'authenticated' | 'refreshing' | 'refresh_failed' | 'storage_failed';
errorMessage?: string;
}> {
return invokeSelectedRuntimeHost(host, 'github-copilot:get-account-state');
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ const zhCopy = {
openingBrowser: '打开浏览器…', logout: '退出登录', loggingOut: '退出中…',
copilotSubtitle: '导入兼容的 GitHub 登录;token 不会暴露给渲染进程。', copilotImported: '已导入 GitHub Copilot 订阅账号。',
copilotSetup: '请配置具有 Copilot Requests 权限的 fine-grained PAT;普通 gh auth login 可能不包含该权限。', importing: '导入中…',
copilotSignIn: '使用 GitHub 登录', copilotActionFailed: 'GitHub Copilot 账号操作失败',
reimport: '重新导入', importCredential: '导入兼容凭据', verifying: '验证中…', reverify: '重新验证', removing: '移除中…', removeLocal: '移除本地登录',
loadingAccount: '正在加载账号状态…', authorizing: '请在弹出的浏览器窗口完成登录。', refreshing: '正在刷新访问令牌…', refreshTokenFailed: '令牌刷新失败,请重新登录。',
cardAria: (name: string, status: string | undefined, description: string) => `打开 OAuth 登录:${name}${status ? `,状态:${status}` : ''},${description.replace(/[。.!!??]+$/u, '')}`,
Expand Down Expand Up @@ -308,6 +309,7 @@ const enCopy: ProviderSettingsCopy = {
openingBrowser: 'Opening browser…', logout: 'Sign out', loggingOut: 'Signing out…',
copilotSubtitle: 'Import a compatible GitHub sign-in. The token is never exposed to the renderer.', copilotImported: 'GitHub Copilot subscription account imported.',
copilotSetup: 'Configure a fine-grained PAT with Copilot Requests permission. A normal gh auth login may not include it.', importing: 'Importing…',
copilotSignIn: 'Sign in with GitHub', copilotActionFailed: 'GitHub Copilot account action failed',
reimport: 'Reimport', importCredential: 'Import compatible credentials', verifying: 'Verifying…', reverify: 'Verify again', removing: 'Removing…', removeLocal: 'Remove local sign-in',
loadingAccount: 'Loading account status…', authorizing: 'Complete sign-in in the browser window.', refreshing: 'Refreshing access token…', refreshTokenFailed: 'Token refresh failed. Sign in again.',
cardAria: (name: string, status: string | undefined, description: string) => `Open OAuth sign-in: ${name}${status ? `; status: ${status}` : ''}; ${description.replace(/[。.!!??]+$/u, '')}`,
Expand Down
85 changes: 60 additions & 25 deletions apps/desktop/src/renderer/settings/provider-oauth-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import {
Badge,
Button,
useMountedRef,
useToast,
useUiLocale,
} from '@maka/ui';
import { getProviderSettingsCopy, type ProviderSettingsCopy } from '../locales/settings-provider-copy';
import {
useOAuthLoginFlow,
subscriptionActionErrorMessage,
subscriptionResultMessage,
type OAuthLoginFlowBridge,
type SubscriptionSnapshot,
} from './use-oauth-login-flow';
import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js';
Expand Down Expand Up @@ -231,41 +231,76 @@ function SubscriptionLoginPanel(props: {

function GitHubCopilotLoginPanel(props: { onLoginSuccess(): void | Promise<void> }) {
const host = useRuntimeHostSettingsTarget();
const copy = getProviderSettingsCopy(useUiLocale()).oauthSection;
// The shared login-flow controller owns the snapshot refresh, the
// synchronous one-shot pending guard, and the unmount safety; Copilot
// rides it through the direct account flow (one bridge call per action,
// no browser handoff, no logout confirm) instead of owning a separate
// pending-action state machine here (#1042).
const locale = useUiLocale();
const copy = getProviderSettingsCopy(locale).oauthSection;
const toast = useToast();
const mountedRef = useMountedRef();
// The Host owns the device grant, so the panel drives the same browser-
// assisted controller as Codex and xAI: one attempt at a time, superseded
// and cancelled by the Host, with the user code arriving as the state hint.
const flow = useOAuthLoginFlow({
bridge: {
getAccountState: () => window.maka.githubCopilotSubscription.getAccountState(host),
logout: () => window.maka.githubCopilotSubscription.logout(host),
} as OAuthLoginFlowBridge,
bridge: runtimeHostOAuthLoginBridge(window.maka.githubCopilotSubscription, host),
display: { name: 'GitHub Copilot', shortName: 'GitHub Copilot' },
onLoginSuccess: props.onLoginSuccess,
direct: {
login: () => window.maka.githubCopilotSubscription.connectExistingLogin(host),
refreshTokens: () => window.maka.githubCopilotSubscription.refreshTokens(host),
},
});
const refreshTokens = flow.refreshTokens;
const loggedIn = flow.state?.runtimeState === 'authenticated' || flow.state?.runtimeState === 'refreshing';
// Sign-in is always offered, exactly as Codex and xAI are: the Host owns the
// enrollment gate and refuses the start with `experimental_disabled`, so the
// renderer must not carry a second copy of that decision.
const [directAction, setDirectAction] = useState<'import' | 'refresh' | null>(null);
const loggedIn = flow.isLoggedIn;
const actionBusy = flow.actionBusy || directAction !== null;
// Importing an existing `gh` credential and re-verifying it are single main
// process calls with no browser handoff, so they run beside the controller
// rather than through its authRequestId lifecycle.
const runDirectAction = async (
action: 'import' | 'refresh',
call: () => Promise<{ ok: boolean; message?: string }>,
) => {
if (actionBusy) return;
setDirectAction(action);
try {
const result = await call();
if (!mountedRef.current) return;
if (!result.ok) {
toast.error(
copy.copilotActionFailed,
subscriptionResultMessage(result.message, copy.copilotActionFailed, locale),
);
}
await flow.refresh();
if (result.ok && action === 'import' && mountedRef.current) await props.onLoginSuccess();
} catch (error) {
if (mountedRef.current) {
toast.error(copy.copilotActionFailed, subscriptionActionErrorMessage(error, locale));
}
} finally {
if (mountedRef.current) setDirectAction(null);
}
};
return (
<VStack gap={3} data-status={flow.runtimeState}>
<Text type="body">
{loggedIn
? copy.copilotImported
: flow.state?.runtimeState === 'refresh_failed' || flow.state?.runtimeState === 'storage_failed'
? flow.state.errorMessage
: copy.copilotSetup}
{loggedIn ? copy.copilotImported : (flow.errorMessage ?? copy.copilotSetup)}
</Text>
{flow.stateHint && (
<Text type="supporting" color="secondary" data-testid="github-copilot-device-code">
{copy.deviceCode} {flow.stateHint}
</Text>
)}
<HStack gap={2} hAlign="end">
<Button variant="primary" onClick={() => void flow.startLogin()} isDisabled={flow.actionBusy} label={flow.pendingAction === 'login' ? copy.importing : loggedIn ? copy.reimport : copy.importCredential} />
{!loggedIn && (
<Button variant="primary" onClick={() => void flow.startLogin()} isDisabled={actionBusy} label={flow.pendingAction === 'login' ? copy.openingBrowser : copy.copilotSignIn} />
)}
<Button
variant="secondary"
onClick={() => void runDirectAction('import', () => window.maka.githubCopilotSubscription.connectExistingLogin(host))}
isDisabled={actionBusy}
label={directAction === 'import' ? copy.importing : loggedIn ? copy.reimport : copy.importCredential}
/>
{loggedIn && (
<>
<Button variant="secondary" onClick={() => void refreshTokens?.()} isDisabled={flow.actionBusy} label={flow.pendingAction === 'refresh' ? copy.verifying : copy.reverify} />
<Button variant="ghost" onClick={() => void flow.logout()} isDisabled={flow.actionBusy} label={flow.pendingAction === 'logout' ? copy.removing : copy.removeLocal} />
<Button variant="secondary" onClick={() => void runDirectAction('refresh', () => window.maka.githubCopilotSubscription.refreshTokens(host))} isDisabled={actionBusy} label={directAction === 'refresh' ? copy.verifying : copy.reverify} />
<Button variant="ghost" onClick={() => void flow.logout()} isDisabled={actionBusy} label={flow.pendingAction === 'logout' ? copy.removing : copy.removeLocal} />
</>
)}
</HStack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export function runtimeHostConnectionsBridge(
}

export function runtimeHostOAuthLoginBridge(
bridge: typeof window.maka.openAiCodex | typeof window.maka.xaiOAuth,
bridge:
| typeof window.maka.openAiCodex
| typeof window.maka.xaiOAuth
| typeof window.maka.githubCopilotSubscription,
host: DesktopRuntimeHostRef,
): OAuthLoginFlowBridge {
return {
Expand Down
Loading