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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"author": "ArcBox Labs <team@arcbox.dev>",
"dependencies": {
"@better-auth/api-key": "1.7.0-rc.2",
"@better-auth/electron": "1.7.0-rc.2",
"@linkcode/cloud": "file:../../packages/vendor/linkcode-cloud-0.1.0.tgz",
"@linkcode/common": "workspace:*",
Expand Down
37 changes: 35 additions & 2 deletions apps/desktop/src/main/__tests__/cloud-hosted-billing.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
handlers: new Map<string, () => unknown>(),
handlers: new Map<string, (...args: unknown[]) => unknown>(),
createApiKey: vi.fn(),
getSession: vi.fn(),
openExternal: vi.fn(),
setAsDefaultProtocolClient: vi.fn(),
setupMain: vi.fn(),
Expand All @@ -11,8 +13,20 @@ vi.mock('@better-auth/electron/client', () => ({
electronClient: () => ({}),
}));

vi.mock('@better-auth/api-key/client', () => ({
apiKeyClient: () => ({}),
}));

vi.mock('better-auth/client/plugins', () => ({
organizationClient: () => ({}),
}));

vi.mock('better-auth/client', () => ({
createAuthClient: () => ({ setupMain: mocks.setupMain }),
createAuthClient: () => ({
setupMain: mocks.setupMain,
getSession: mocks.getSession,
apiKey: { create: mocks.createApiKey },
}),
}));

vi.mock('electron', () => ({
Expand Down Expand Up @@ -42,6 +56,11 @@ describe('desktop hosted billing handoff', () => {
mocks.handlers.clear();
mocks.setAsDefaultProtocolClient.mockReturnValue(true);
mocks.openExternal.mockResolvedValue(undefined);
mocks.getSession.mockResolvedValue({
data: { session: { activeOrganizationId: 'org_1' } },
error: null,
});
mocks.createApiKey.mockResolvedValue({ data: { key: 'lc-gateway-secret' }, error: null });
});

it('opens the SDK URL with a channel-specific native return target', async () => {
Expand All @@ -56,4 +75,18 @@ describe('desktop hosted billing handoff', () => {
'https://console.linkcode.ai/billing?returnTarget=linkcode-dev%3A%2F%2Fbilling%2Freturn',
);
});

it('mints a Gateway key in the signed-in session organization', async () => {
const { setupCloudAuth } = await import('../cloud-auth/client');
const { CLOUD_CREATE_GATEWAY_KEY_CHANNEL } = await import('../../shared/cloud');
setupCloudAuth();

await expect(
mocks.handlers.get(CLOUD_CREATE_GATEWAY_KEY_CHANNEL)?.({}, 'LinkCode Gateway'),
).resolves.toBe('lc-gateway-secret');
expect(mocks.createApiKey).toHaveBeenCalledWith({
name: 'LinkCode Gateway',
organizationId: 'org_1',
});
});
});
21 changes: 21 additions & 0 deletions apps/desktop/src/main/cloud-auth/client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { resolve } from 'node:path';
import { apiKeyClient } from '@better-auth/api-key/client';
import { electronClient } from '@better-auth/electron/client';
import { createHostedBillingUrl } from '@linkcode/cloud';
import { createAuthClient } from 'better-auth/client';
import { organizationClient } from 'better-auth/client/plugins';
import { app, BrowserWindow, ipcMain, shell } from 'electron';
import { z } from 'zod';
import {
CLOUD_CLAIM_DEEP_LINK_CHANNEL,
CLOUD_CREATE_GATEWAY_KEY_CHANNEL,
CLOUD_OPEN_HOSTED_BILLING_CHANNEL,
} from '../../shared/cloud';
import { CHANNEL } from '../constants';
Expand Down Expand Up @@ -45,6 +49,8 @@ export const authClient = createAuthClient({
// privileged scheme); the footer renders initials.
userImageProxy: { enabled: false },
}),
organizationClient(),
apiKeyClient(),
],
});

Expand All @@ -58,6 +64,18 @@ function claimDeepLink(): boolean {
: app.setAsDefaultProtocolClient(CLOUD_AUTH_SCHEME);
}

async function createGatewayKey(name: unknown): Promise<string> {
const parsedName = z.string().trim().min(1).max(80).parse(name);
const session = await authClient.getSession();
if (session.error) throw new Error(session.error.message);
const organizationId = session.data?.session.activeOrganizationId;
if (!organizationId) throw new Error('Sign in to LinkCode Cloud, then try again');

const created = await authClient.apiKey.create({ name: parsedName, organizationId });
if (created.error) throw new Error(created.error.message);
return created.data.key;
}

/**
* Wire the auth client into main. Once a config object is passed, every feature must be opted into
* explicitly: `scheme` = protocol + deep-link handlers, `bridges` = the IPC handlers the preload
Expand All @@ -80,4 +98,7 @@ export function setupCloudAuth(): void {
createHostedBillingUrl({ returnTarget: `${CLOUD_AUTH_SCHEME}://billing/return` }),
);
});
ipcMain.handle(CLOUD_CREATE_GATEWAY_KEY_CHANNEL, (_event, name: unknown) =>
createGatewayKey(name),
);
}
2 changes: 2 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createElectronSystemBridge } from '@linkcode/ipc/electron-renderer';
import { contextBridge, ipcRenderer } from 'electron';
import {
CLOUD_CLAIM_DEEP_LINK_CHANNEL,
CLOUD_CREATE_GATEWAY_KEY_CHANNEL,
CLOUD_IM_BINDINGS_CHANNEL,
CLOUD_IM_CREATE_BINDING_CHANNEL,
CLOUD_IM_DELETE_BINDING_CHANNEL,
Expand Down Expand Up @@ -35,6 +36,7 @@ contextBridge.exposeInMainWorld('linkcodeCloud', {
listHosts: () => ipcRenderer.invoke(CLOUD_LIST_HOSTS_CHANNEL),
claimDeepLink: () => ipcRenderer.invoke(CLOUD_CLAIM_DEEP_LINK_CHANNEL),
openHostedBilling: () => ipcRenderer.invoke(CLOUD_OPEN_HOSTED_BILLING_CHANNEL),
createGatewayKey: (name: string) => ipcRenderer.invoke(CLOUD_CREATE_GATEWAY_KEY_CHANNEL, name),
im: {
overview: () => ipcRenderer.invoke(CLOUD_IM_OVERVIEW_CHANNEL),
bindings: () => ipcRenderer.invoke(CLOUD_IM_BINDINGS_CHANNEL),
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/src/cloud-auth/bridges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface CloudDataBridges {
claimDeepLink: () => Promise<boolean>;
/** Opens Cloud's hosted billing surface in the system browser. */
openHostedBilling: () => Promise<void>;
/** Mints a LinkCode Gateway key in the signed-in user's active Cloud organization. */
createGatewayKey: (name: string) => Promise<string>;
/** IM Channel management (`/im/*`); same session-in-main model as listHosts. */
im: CloudImSource;
};
Expand All @@ -51,6 +53,8 @@ export const cloudDataBridge: CloudDataBridges['linkcodeCloud'] = {
claimDeepLink: () => traceRendererIpc('cloud.claim-deep-link', () => cloudSource.claimDeepLink()),
openHostedBilling: () =>
traceRendererIpc('cloud.open-hosted-billing', () => cloudSource.openHostedBilling()),
createGatewayKey: (name) =>
traceRendererIpc('cloud.create-gateway-key', () => cloudSource.createGatewayKey(name)),
im: {
overview: () => traceRendererIpc('cloud.im.overview', () => cloudSource.im.overview()),
bindings: () => traceRendererIpc('cloud.im.bindings', () => cloudSource.im.bindings()),
Expand Down
14 changes: 13 additions & 1 deletion apps/desktop/src/renderer/src/settings/providers-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { ProvidersSettingsPanel } from '@linkcode/workbench';
import { cloudDataBridge } from '../cloud-auth/bridges';
import { useCloudAccount } from '../cloud-auth/use-cloud-account';

// A transport-backed workbench container: reachable above the connection gate (the `ungated`
// slot), degrading to loading/error while the daemon is unreachable — like the history-import tab.
export function ProvidersTab(): React.ReactNode {
return <ProvidersSettingsPanel />;
const cloud = useCloudAccount();
return (
<ProvidersSettingsPanel
linkCodeGateway={{
signedIn: cloud.account !== null,
signingIn: cloud.authenticating,
signIn: cloud.signIn,
createKey: cloudDataBridge.createGatewayKey,
}}
/>
);
}
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/src/shell/desktop-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function DesktopShell({
onDownloadAgent,
onContinueUnverified,
onOpenProviderSettings,
onOpenBilling,
conversation,
respondingRequestIds,
responseErrors,
Expand Down Expand Up @@ -456,6 +457,7 @@ export function DesktopShell({
cwd={active?.cwd}
runtimeCues={runtimeCues}
onOpenProviderSettings={onOpenProviderSettings}
onOpenBilling={onOpenBilling}
respondingRequestIds={respondingRequestIds}
responseErrors={responseErrors}
TerminalBlockComponent={TerminalBlockComponent}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { WorkbenchShellProps } from '@linkcode/workbench';
import { useNavigationHistoryStore, useProvidersSettingsStore } from '@linkcode/workbench';
import { systemBridge } from '@renderer/ipc';
import { cloudDataBridge } from '../cloud-auth/bridges';
import { openDesktopSettings, useDesktopSettingsStore } from '../settings/store';
import { DesktopShell } from './desktop-shell';

Expand All @@ -16,6 +17,9 @@ export function DesktopWorkbenchShell({ header, ...props }: WorkbenchShellProps)
useProvidersSettingsStore.getState().startAdd();
openDesktopSettings('providers');
}}
onOpenBilling={() => {
void cloudDataBridge.openHostedBilling();
}}
onOpenAutomations={() => useNavigationHistoryStore.getState().openOverlay('automations')}
onImportHistory={() => openDesktopSettings('history-import')}
themeType={theme}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/shared/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export const CLOUD_CLAIM_DEEP_LINK_CHANNEL = 'linkcode.cloud.claim-deep-link';
// Opens the Cloud-owned billing surface; all billing and checkout state stays in the browser.
export const CLOUD_OPEN_HOSTED_BILLING_CHANNEL = 'linkcode.cloud.open-hosted-billing';

// Mints a LinkCode Gateway key from the authenticated Cloud session. The secret crosses this
// bridge once, then the renderer hands it to the daemon-owned account vault.
export const CLOUD_CREATE_GATEWAY_KEY_CHANNEL = 'linkcode.cloud.create-gateway-key';

// IM Channel management (`/im/*` on the cloud API).
export const CLOUD_IM_OVERVIEW_CHANNEL = 'linkcode.cloud.im.overview';
export const CLOUD_IM_BINDINGS_CHANNEL = 'linkcode.cloud.im.bindings';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { AgentRuntimes } from '@linkcode/schema';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AgentRuntimeOnboarding } from '../../../agent-runtime/onboarding';
import { AddAccountForm } from '../add-flow';
import { AddAccountForm, ServiceCatalogView } from '../add-flow';

function translateKey(key: string): string {
return key;
Expand Down Expand Up @@ -164,4 +164,49 @@ describe('non-subscription account creation', () => {
),
);
});

it('adds LinkCode Gateway only after the explicit user action', async () => {
const createKey = vi.fn().mockResolvedValue('lc-gateway-key');
const onSubmit = vi.fn();
render(
<AddAccountForm
serviceId="linkcode-gateway"
runtimes={undefined}
onboarding={onboarding()}
busy={false}
linkCodeGateway={{
signedIn: true,
signingIn: false,
signIn: vi.fn(),
createKey,
}}
onBack={vi.fn()}
onSubmit={onSubmit}
/>,
);

expect(createKey).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'linkCodeUseGateway' }));

await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(createKey).toHaveBeenCalledWith('serviceName.linkcode-gateway');
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
service: 'linkcode-gateway',
credential: { type: 'auth-token', token: 'lc-gateway-key' },
endpoint: { protocol: 'openai-chat', baseUrl: 'https://gateway.linkcode.ai/v1' },
}),
);
});

it('keeps the Desktop-only Gateway out of clients without its host bridge', () => {
const onPick = vi.fn();
const { rerender } = render(<ServiceCatalogView onPick={onPick} />);
expect(screen.queryByText('serviceName.linkcode-gateway')).toBeNull();

rerender(<ServiceCatalogView onPick={onPick} linkCodeGatewayAvailable />);
fireEvent.click(screen.getByText('serviceName.linkcode-gateway'));
expect(onPick).toHaveBeenCalledWith('linkcode-gateway');
});
});
Loading
Loading