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
29 changes: 27 additions & 2 deletions apps/daemon/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Account } from '@linkcode/schema';
Expand All @@ -13,6 +13,7 @@ import {
loadConfig,
runtimeFilePath,
saveAccounts,
saveProviderConfiguration,
} from '../config';
import { logger } from '../logger';
import { daemonChannel, telemetryConfigCachePath } from '../paths';
Expand Down Expand Up @@ -62,7 +63,7 @@ const validAccount: Account = {
label: 'Personal key',
credential: { type: 'api-key', key: 'sk-test' },
createdAt: 0,
};
} satisfies Account;

describe('loadConfig providers', () => {
it('keeps valid provider entries and drops an invalid one, logging the error', () => {
Expand Down Expand Up @@ -267,6 +268,30 @@ describe('loadConfig accounts', () => {
});
});

describe('saveProviderConfiguration', () => {
it('atomically persists providers and accounts without exposing their secrets', () => {
const dir = join(process.env.HOME ?? '', '.linkcode');
mkdirSync(dir, { recursive: true });
const path = join(dir, 'config.json');
writeFileSync(path, JSON.stringify({ hostname: '127.0.0.1' }));

saveProviderConfiguration(
vault,
{ codex: { enabled: true, activeAccountId: 'acc_1', apiKey: 'sk-provider' } },
[validAccount],
);

expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({
hostname: '127.0.0.1',
providers: { codex: { enabled: true, activeAccountId: 'acc_1' } },
accounts: [{ ...validAccount, credential: { type: 'api-key' } }],
});
expect(vault.refs.get('provider:codex')).toBe('sk-provider');
expect(vault.refs.get('account:acc_1')).toBe('sk-test');
expect(statSync(path).mode & 0o777).toBe(0o600);
});
});

// CODE-371: config.json used to hold provider api keys and account credentials in the clear. The
// vault owns them now, and an upgrade has to move them without the user re-entering anything.
describe('credential storage', () => {
Expand Down
59 changes: 55 additions & 4 deletions apps/daemon/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import {
closeSync,
fsyncSync,
mkdirSync,
openSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { daemonRuntimeFilePath } from '@linkcode/common/node';
Expand Down Expand Up @@ -245,22 +255,63 @@ export function saveAccounts(vault: SecretVault, accounts: Accounts): void {
writeConfigField('accounts', detachAccountSecrets(accountSecrets(vault), accounts));
}

export function saveProviderConfiguration(
vault: SecretVault,
providers: ProvidersConfig,
accounts: Accounts,
): void {
const detachedProviders = detachProviderSecrets(providerSecrets(vault), providers);
const detachedAccounts = detachAccountSecrets(accountSecrets(vault), accounts);
writeConfigFields({ providers: detachedProviders, accounts: detachedAccounts });
}

/** Read-modify-write a single top-level field of config.json, preserving the rest; `0600`. */
function writeConfigField(
key: 'providers' | 'accounts' | 'simulatorConsent',
value: unknown,
): void {
writeConfigFields({ [key]: value });
}

function writeConfigFields(fields: Partial<Record<keyof ConfigFile, unknown>>): void {
const path = configPath();
const directory = dirname(path);
let file: Record<string, unknown> = {};
try {
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
if (isRecord(parsed)) file = parsed;
} catch {
// Start from an empty document if the file is missing or malformed.
}
file[key] = value;
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
Object.assign(file, fields);
mkdirSync(directory, { recursive: true });
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
let descriptor: number | undefined;
try {
descriptor = openSync(temporaryPath, 'wx', 0o600);
writeFileSync(descriptor, `${JSON.stringify(file, null, 2)}\n`);
fsyncSync(descriptor);
closeSync(descriptor);
descriptor = undefined;
renameSync(temporaryPath, path);
try {
const directoryDescriptor = openSync(directory, 'r');
try {
fsyncSync(directoryDescriptor);
} finally {
closeSync(directoryDescriptor);
}
} catch {
// Directory fsync is unsupported on some platforms; rename still preserves atomicity.
}
} finally {
if (descriptor !== undefined) closeSync(descriptor);
try {
unlinkSync(temporaryPath);
} catch {
// The temporary file is absent after a successful rename or an early open failure.
}
}
}

function createDefaultSocketIoListener(file: ConfigFile): DaemonListenerConfig {
Expand Down
9 changes: 8 additions & 1 deletion apps/daemon/src/provider-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ProviderConfigStore } from '@linkcode/engine';
import { accountBinding } from '@linkcode/engine';
import type { Accounts, ProvidersConfig } from '@linkcode/schema';
import { saveAccounts, saveProviders } from './config';
import { saveAccounts, saveProviderConfiguration, saveProviders } from './config';
import type { SecretVault } from './secrets';

/**
Expand All @@ -26,5 +27,11 @@ export function createProviderConfigStore(
accounts = next;
saveAccounts(vault, next);
},
createAndBindAccount(agent, account) {
const next = accountBinding(providers, accounts, agent, account);
saveProviderConfiguration(vault, next.providers, next.accounts);
providers = next.providers;
accounts = next.accounts;
},
};
}
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/src/shell/desktop-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export function DesktopShell({
onLoginAgent,
onSubmitLoginCode,
onCancelLogin,
onUseApiKey,
conversation,
respondingRequestIds,
responseErrors,
Expand Down Expand Up @@ -416,6 +417,7 @@ export function DesktopShell({
onLoginAgent={onLoginAgent}
onSubmitLoginCode={onSubmitLoginCode}
onCancelLogin={onCancelLogin}
onUseApiKey={onUseApiKey}
onMentionQueryChange={onMentionQueryChange}
onSubmit={onSubmitDraft}
onPickDirectory={pickDirectory}
Expand All @@ -437,6 +439,7 @@ export function DesktopShell({
onLoginAgent={onLoginAgent}
onSubmitLoginCode={onSubmitLoginCode}
onCancelLogin={onCancelLogin}
onUseApiKey={onUseApiKey}
respondingRequestIds={respondingRequestIds}
responseErrors={responseErrors}
TerminalBlockComponent={TerminalBlockComponent}
Expand Down
16 changes: 16 additions & 0 deletions packages/client/core/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type {
Account,
AccountEndpoint,
AccountModel,
AccountSecret,
Accounts,
AgentEvent,
AgentHistoryId,
Expand Down Expand Up @@ -352,6 +356,9 @@ export class LinkCodeClient {
this.pending.resolve('configGet', p.replyTo, p.providers);
this.pending.resolve('accountsGet', p.replyTo, p.accounts);
break;
case 'config.probe-models.result':
this.pending.resolve('accountModels', p.replyTo, p.models);
break;
case 'agent-runtime.listed':
this.pending.resolve('agentRuntimeList', p.replyTo, p.runtimes);
break;
Expand Down Expand Up @@ -712,6 +719,11 @@ export class LinkCodeClient {
return this.control.getAccounts();
}

/** Model list an endpoint serves, read daemon-side with a not-yet-saved secret. */
probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise<AccountModel[]> {
return this.control.probeAccountModels(endpoint, secret);
}

listAgentRuntimes(): Promise<AgentRuntimes> {
return this.control.listAgentRuntimes();
}
Expand Down Expand Up @@ -943,6 +955,10 @@ export class LinkCodeClient {
return this.control.setProviderConfig(providers);
}

createAndBindAccount(agent: AgentKind, account: Account): Promise<RequestAck> {
return this.control.createAndBindAccount(agent, account);
}

setAccounts(accounts: Accounts): Promise<RequestAck> {
return this.control.setAccounts(accounts);
}
Expand Down
24 changes: 24 additions & 0 deletions packages/client/core/src/client/control-channel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type {
Account,
AccountEndpoint,
AccountModel,
AccountSecret,
Accounts,
AgentHistoryId,
AgentHistoryListOptions,
Expand Down Expand Up @@ -407,6 +411,26 @@ export class ControlChannel {
}));
}

createAndBindAccount(agent: AgentKind, account: Account): Promise<RequestAck> {
return this.sendCorrelated('ack', (clientReqId) => ({
kind: 'config.account.create-and-bind',
clientReqId,
agent,
account,
}));
}

/** Ask the daemon what an endpoint serves, using a not-yet-saved secret: the account forms offer
* the answer as the model picker. The daemon must do it — the renderer's CSP blocks the fetch. */
probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise<AccountModel[]> {
return this.sendCorrelated('accountModels', (clientReqId) => ({
kind: 'config.probe-models',
clientReqId,
endpoint,
secret,
}));
}

/** Persist the daemon-owned global account pool (data plane). Preserves the provider config. */
setAccounts(accounts: Accounts): Promise<RequestAck> {
return this.sendCorrelated('ack', (clientReqId) => ({
Expand Down
3 changes: 3 additions & 0 deletions packages/client/core/src/client/pending-registry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
AccountModel,
Accounts,
AgentHistoryListResult,
AgentHistoryReadResult,
Expand Down Expand Up @@ -71,6 +72,7 @@ export interface PendingValueMap {
historyRead: AgentHistoryReadResult;
configGet: ProvidersConfig;
accountsGet: Accounts;
accountModels: AccountModel[];
agentRuntimeList: AgentRuntimes;
agentCatalog: AgentStartCatalog;
assetList: ManagedAssetStatus[];
Expand Down Expand Up @@ -124,6 +126,7 @@ export class PendingRegistry {
historyRead: new Map(),
configGet: new Map(),
accountsGet: new Map(),
accountModels: new Map(),
agentRuntimeList: new Map(),
agentCatalog: new Map(),
assetList: new Map(),
Expand Down
16 changes: 16 additions & 0 deletions packages/client/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import type {
} from '@linkcode/client-core';
import { LinkCodeClient } from '@linkcode/client-core';
import type {
Account,
AccountEndpoint,
AccountModel,
AccountSecret,
Accounts,
AgentHistoryId,
AgentHistoryListResult,
Expand Down Expand Up @@ -209,6 +213,10 @@ export class LinkCodeSdkClient {
return toResult(this.raw.setProviderConfig(providers));
}

createAndBindAccount(agent: AgentKind, account: Account): RequestResult<{ ok: true }> {
return toResult(this.raw.createAndBindAccount(agent, account));
}

/** Read the daemon-owned global account pool (data plane). */
getAccounts(): RequestResult<Accounts> {
return toResult(this.raw.getAccounts());
Expand All @@ -219,6 +227,14 @@ export class LinkCodeSdkClient {
return toResult(this.raw.setAccounts(accounts));
}

/** Enumerate what an endpoint serves, using a secret that is not saved yet. */
probeAccountModels(
endpoint: AccountEndpoint,
secret: AccountSecret,
): RequestResult<AccountModel[]> {
return toResult(this.raw.probeAccountModels(endpoint, secret));
}

/** Which agent CLIs the host can actually spawn (probed once at daemon boot). */
listAgentRuntimes(): RequestResult<AgentRuntimes> {
return toResult(this.raw.listAgentRuntimes());
Expand Down
16 changes: 16 additions & 0 deletions packages/client/sdk/src/operations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { HistoryListClientOptions, HistoryReadClientOptions } from '@linkcode/client-core';
import type {
Account,
AccountEndpoint,
AccountModel,
AccountSecret,
Accounts,
AgentHistoryId,
AgentHistoryListResult,
Expand Down Expand Up @@ -176,6 +180,12 @@ export function setProviderConfig(
return resolveClient(options).setProviderConfig(options.providers);
}

export function createAndBindAccount(
options: Options<{ agent: AgentKind; account: Account }>,
): RequestResult<{ ok: true }> {
return resolveClient(options).createAndBindAccount(options.agent, options.account);
}

export function getAccounts(options?: Options): RequestResult<Accounts> {
return resolveClient(options).getAccounts();
}
Expand All @@ -184,6 +194,12 @@ export function setAccounts(options: Options<{ accounts: Accounts }>): RequestRe
return resolveClient(options).setAccounts(options.accounts);
}

export function probeAccountModels(
options: Options<{ endpoint: AccountEndpoint; secret: AccountSecret }>,
): RequestResult<AccountModel[]> {
return resolveClient(options).probeAccountModels(options.endpoint, options.secret);
}

/** Which agent CLIs the host can actually spawn (probed once at daemon boot). */
export function listAgentRuntimes(options?: Options): RequestResult<AgentRuntimes> {
return resolveClient(options).listAgentRuntimes();
Expand Down
Loading
Loading