Skip to content

Commit e575ba7

Browse files
authored
Merge branch 'master' into chenyu/code-527
2 parents d97106d + 870e1c5 commit e575ba7

43 files changed

Lines changed: 1676 additions & 146 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/daemon/src/__tests__/config.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,30 @@ describe('loadConfig accounts', () => {
269269
});
270270
});
271271

272+
describe('saveProviderConfiguration', () => {
273+
it('atomically persists providers and accounts without exposing their secrets', () => {
274+
const dir = join(process.env.HOME ?? '', '.linkcode');
275+
mkdirSync(dir, { recursive: true });
276+
const path = join(dir, 'config.json');
277+
writeFileSync(path, JSON.stringify({ hostname: '127.0.0.1' }));
278+
279+
saveProviderConfiguration(
280+
vault,
281+
{ codex: { enabled: true, activeAccountId: 'acc_1', apiKey: 'sk-provider' } },
282+
[validAccount],
283+
);
284+
285+
expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({
286+
hostname: '127.0.0.1',
287+
providers: { codex: { enabled: true, activeAccountId: 'acc_1' } },
288+
accounts: [{ ...validAccount, credential: { type: 'api-key' } }],
289+
});
290+
expect(vault.refs.get('provider:codex')).toBe('sk-provider');
291+
expect(vault.refs.get('account:acc_1')).toBe('sk-test');
292+
expect(statSync(path).mode & 0o777).toBe(0o600);
293+
});
294+
});
295+
272296
describe('loadConfig custom MCP servers', () => {
273297
const validServer = {
274298
id: 'custom-1',

apps/daemon/src/provider-store.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ProviderConfigStore } from '@linkcode/engine';
2+
import { accountBinding } from '@linkcode/engine';
23
import type { Accounts, CustomMcpServer, ProvidersConfig } from '@linkcode/schema';
34
import { saveCustomMcpServers, saveProviderConfiguration } from './config';
45
import type { SecretVault } from './secrets';
@@ -33,5 +34,11 @@ export function createProviderConfigStore(
3334
saveCustomMcpServers(vault, next, customMcpServers);
3435
customMcpServers = next;
3536
},
37+
createAndBindAccount(agent, account) {
38+
const next = accountBinding(providers, accounts, agent, account);
39+
saveProviderConfiguration(vault, next.providers, next.accounts);
40+
providers = next.providers;
41+
accounts = next.accounts;
42+
},
3643
};
3744
}

apps/desktop/electron-builder.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ extraResources:
7878
publish:
7979
provider: generic
8080
url: https://releases.linkcode.ai/desktop
81+
# electron-updater's DifferentialDownloader wires its progress callback only on the sequential
82+
# range path, so leaving this at its default true means a differential update reports no progress.
83+
useMultipleRangeRequest: false
8184

8285
# Per-arch artifacts on every platform (half the download of a universal binary). The explicit
8386
# ${arch} suffix is load-bearing: electron-updater picks the feed entry whose filename contains
Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,53 @@
11
// @vitest-environment jsdom
22

3-
import { cleanup, render, screen } from '@testing-library/react';
4-
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import type { UpdaterState } from '@linkcode/ipc';
4+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
56
import { AboutTab } from '../about-tab';
67

8+
const mocks = vi.hoisted(() => ({
9+
useUpdaterState: vi.fn(),
10+
checkForUpdates: vi.fn(),
11+
installUpdate: vi.fn(),
12+
}));
13+
714
vi.mock('../../ipc', () => ({
815
systemBridge: {
916
app: {
10-
checkForUpdates: vi.fn(),
17+
checkForUpdates: mocks.checkForUpdates,
18+
installUpdate: mocks.installUpdate,
1119
version: vi.fn(() => Promise.resolve('0.15.0')),
1220
},
1321
},
1422
}));
1523

1624
vi.mock('../../updater', () => ({
17-
useUpdaterState: () => ({
18-
status: 'downloading',
19-
version: '0.16.0',
20-
progress: 42.4,
21-
}),
25+
useUpdaterState: mocks.useUpdaterState,
2226
}));
2327

2428
vi.mock('use-intl', () => ({
2529
useTranslations() {
2630
const messages: Record<string, string> = {
2731
version: 'Version',
2832
checkForUpdates: 'Check for updates',
33+
restartToInstall: 'Restart to update',
2934
'status.downloading': 'Downloading update…',
35+
'status.downloaded': 'Update ready — restart to install.',
3036
};
3137
return (key: string) => messages[key] ?? key;
3238
},
3339
}));
3440

3541
describe('AboutTab', () => {
42+
beforeEach(() => {
43+
vi.clearAllMocks();
44+
mocks.useUpdaterState.mockReturnValue({
45+
status: 'downloading',
46+
version: '0.16.0',
47+
progress: 42.4,
48+
} satisfies UpdaterState);
49+
});
50+
3651
afterEach(cleanup);
3752

3853
it('shows accessible update download progress', async () => {
@@ -43,4 +58,20 @@ describe('AboutTab', () => {
4358
expect(progress.getAttribute('aria-valuenow')).toBe('42');
4459
expect(screen.getByText('42%')).toBeTruthy();
4560
});
61+
62+
// Main refuses a re-check while an update sits downloaded, so the check button must not be offered.
63+
it('offers the install instead of a check once the update is downloaded', () => {
64+
mocks.useUpdaterState.mockReturnValue({
65+
status: 'downloaded',
66+
version: '0.16.0',
67+
progress: null,
68+
} satisfies UpdaterState);
69+
render(<AboutTab />);
70+
71+
expect(screen.queryByRole('button', { name: 'Check for updates' })).toBeNull();
72+
73+
fireEvent.click(screen.getByRole('button', { name: 'Restart to update' }));
74+
expect(mocks.installUpdate).toHaveBeenCalledOnce();
75+
expect(mocks.checkForUpdates).not.toHaveBeenCalled();
76+
});
4677
});

apps/desktop/src/renderer/src/settings/about-tab.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,27 @@ export function AboutTab(): React.ReactNode {
4141
</Field>
4242
<div className="flex flex-col gap-2">
4343
<div className="flex items-center gap-3">
44-
<Button
45-
size="sm"
46-
variant="outline"
47-
onClick={() => {
48-
void systemBridge.app.checkForUpdates();
49-
}}
50-
>
51-
{t('checkForUpdates')}
52-
</Button>
44+
{/* Main refuses a re-check once an update is downloaded, so offer the install instead of a dead button. */}
45+
{status === 'downloaded' ? (
46+
<Button
47+
size="sm"
48+
onClick={() => {
49+
void systemBridge.app.installUpdate();
50+
}}
51+
>
52+
{t('restartToInstall')}
53+
</Button>
54+
) : (
55+
<Button
56+
size="sm"
57+
variant="outline"
58+
onClick={() => {
59+
void systemBridge.app.checkForUpdates();
60+
}}
61+
>
62+
{t('checkForUpdates')}
63+
</Button>
64+
)}
5365
{statusKey && progressPercent === null ? (
5466
<span className="text-muted-foreground text-xs">{t(statusKey)}</span>
5567
) : null}

apps/desktop/src/renderer/src/shell/desktop-shell.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,7 @@ export function DesktopShell({
8686
NewSessionBranchPickerComponent,
8787
onDownloadAgent,
8888
onContinueUnverified,
89-
onLoginAgent,
90-
onSubmitLoginCode,
91-
onCancelLogin,
89+
onOpenProviderSettings,
9290
conversation,
9391
respondingRequestIds,
9492
responseErrors,
@@ -438,9 +436,7 @@ export function DesktopShell({
438436
topContent={<ErrorBanner errorMessage={errorMessage} onDismissError={onDismissError} />}
439437
onContinueUnverified={onContinueUnverified}
440438
onDownloadAgent={onDownloadAgent}
441-
onLoginAgent={onLoginAgent}
442-
onSubmitLoginCode={onSubmitLoginCode}
443-
onCancelLogin={onCancelLogin}
439+
onOpenProviderSettings={onOpenProviderSettings}
444440
onMentionQueryChange={onMentionQueryChange}
445441
onSubmit={onSubmitDraft}
446442
onPickDirectory={pickDirectory}
@@ -459,9 +455,7 @@ export function DesktopShell({
459455
attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])}
460456
cwd={active?.cwd}
461457
runtimeCues={runtimeCues}
462-
onLoginAgent={onLoginAgent}
463-
onSubmitLoginCode={onSubmitLoginCode}
464-
onCancelLogin={onCancelLogin}
458+
onOpenProviderSettings={onOpenProviderSettings}
465459
respondingRequestIds={respondingRequestIds}
466460
responseErrors={responseErrors}
467461
TerminalBlockComponent={TerminalBlockComponent}
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { WorkbenchShellProps } from '@linkcode/workbench';
2-
import { useNavigationHistoryStore } from '@linkcode/workbench';
2+
import { useNavigationHistoryStore, useProvidersSettingsStore } from '@linkcode/workbench';
33
import { systemBridge } from '@renderer/ipc';
44
import { openDesktopSettings, useDesktopSettingsStore } from '../settings/store';
55
import { DesktopShell } from './desktop-shell';
@@ -8,13 +8,17 @@ export function DesktopWorkbenchShell({ header, ...props }: WorkbenchShellProps)
88
const theme = useDesktopSettingsStore((state) => state.theme);
99
return (
1010
<DesktopShell
11+
{...props}
1112
systemBridge={systemBridge}
1213
header={header}
1314
onOpenSettings={() => openDesktopSettings()}
15+
onOpenProviderSettings={() => {
16+
useProvidersSettingsStore.getState().startAdd();
17+
openDesktopSettings('providers');
18+
}}
1419
onOpenAutomations={() => useNavigationHistoryStore.getState().openOverlay('automations')}
1520
onImportHistory={() => openDesktopSettings('history-import')}
1621
themeType={theme}
17-
{...props}
1822
/>
1923
);
2024
}

apps/webview/src/shell/web-workbench-shell.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getResourcesPanelPresentation,
55
RESOURCES_FLOATING_COLUMN_WIDTH,
66
RESOURCES_FLOATING_MIN_WORKSPACE_WIDTH,
7+
useProvidersSettingsStore,
78
useResourcesPanelStore,
89
WorkspaceServicesMenu,
910
} from '@linkcode/workbench';
@@ -58,6 +59,10 @@ export function WebWorkbenchShell({
5859
<ShellFrame
5960
{...props}
6061
showPlanInPromptDock={!resourcesSurfaceOpen}
62+
onOpenProviderSettings={() => {
63+
useProvidersSettingsStore.getState().startAdd();
64+
void navigate('/settings/providers');
65+
}}
6166
onOpenAutomations={() => {
6267
void navigate('/automations');
6368
}}

packages/client/core/src/client.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import type {
2+
Account,
3+
AccountEndpoint,
4+
AccountModel,
5+
AccountSecret,
26
Accounts,
37
AgentEvent,
48
AgentHistoryId,
@@ -416,6 +420,9 @@ export class LinkCodeClient {
416420
case 'skill.updated':
417421
this.pending.resolve('skillSetEnabled', p.replyTo, p.skill);
418422
break;
423+
case 'config.probe-models.result':
424+
this.pending.resolve('accountModels', p.replyTo, p.models);
425+
break;
419426
case 'agent-runtime.listed':
420427
this.pending.resolve('agentRuntimeList', p.replyTo, p.runtimes);
421428
break;
@@ -821,6 +828,11 @@ export class LinkCodeClient {
821828
return this.control.getAccounts();
822829
}
823830

831+
/** Model list an endpoint serves, read daemon-side with a not-yet-saved secret. */
832+
probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise<AccountModel[]> {
833+
return this.control.probeAccountModels(endpoint, secret);
834+
}
835+
824836
/** Masked custom MCP servers (env/header keys only — the daemon never returns values). */
825837
getCustomMcpServers(): Promise<CustomMcpServerPublic[]> {
826838
return this.control.getCustomMcpServers();
@@ -1108,6 +1120,10 @@ export class LinkCodeClient {
11081120
return this.control.setProviderConfig(providers);
11091121
}
11101122

1123+
createAndBindAccount(agent: AgentKind, account: Account): Promise<RequestAck> {
1124+
return this.control.createAndBindAccount(agent, account);
1125+
}
1126+
11111127
setAccounts(accounts: Accounts): Promise<RequestAck> {
11121128
return this.control.setAccounts(accounts);
11131129
}

packages/client/core/src/client/control-channel.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import type {
2+
Account,
3+
AccountEndpoint,
4+
AccountModel,
5+
AccountSecret,
26
Accounts,
37
AgentHistoryId,
48
AgentHistoryListOptions,
@@ -548,6 +552,26 @@ export class ControlChannel {
548552
}));
549553
}
550554

555+
createAndBindAccount(agent: AgentKind, account: Account): Promise<RequestAck> {
556+
return this.sendCorrelated('ack', (clientReqId) => ({
557+
kind: 'config.account.create-and-bind',
558+
clientReqId,
559+
agent,
560+
account,
561+
}));
562+
}
563+
564+
/** Ask the daemon what an endpoint serves, using a not-yet-saved secret: the account forms offer
565+
* the answer as the model picker. The daemon must do it — the renderer's CSP blocks the fetch. */
566+
probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise<AccountModel[]> {
567+
return this.sendCorrelated('accountModels', (clientReqId) => ({
568+
kind: 'config.probe-models',
569+
clientReqId,
570+
endpoint,
571+
secret,
572+
}));
573+
}
574+
551575
/** Persist the daemon-owned global account pool (data plane). Preserves the provider config. */
552576
setAccounts(accounts: Accounts): Promise<RequestAck> {
553577
return this.sendCorrelated('ack', (clientReqId) => ({

0 commit comments

Comments
 (0)