Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d08a71c
feat(schema): plugin discovery, management, and custom MCP wire contr…
lucas77778 Jul 30, 2026
5b0a24c
feat(agent-adapter): discover standalone skills alongside plugin cata…
lucas77778 Jul 30, 2026
470be4e
fix(agent-adapter): report real claude plugin management capabilities…
lucas77778 Jul 30, 2026
db0951a
feat(engine): serve plugin discovery and enablement over the wire (CO…
lucas77778 Jul 30, 2026
ca13a7a
feat(engine,daemon): custom MCP server config plane (CODE-490)
lucas77778 Jul 30, 2026
1745941
feat(engine): inject enabled custom MCP servers at session start with…
lucas77778 Jul 30, 2026
2dc5edb
feat(client): plugin and custom MCP data plane (CODE-491)
lucas77778 Jul 30, 2026
d46ab60
feat(settings): Plugins tab with provider-grouped plugin cards (CODE-…
lucas77778 Jul 30, 2026
029aa9f
feat(settings): MCP tab — custom server management and plugin-provide…
lucas77778 Jul 30, 2026
1ffc66e
feat(settings): Skills tab — plugin and standalone skills (CODE-497)
lucas77778 Jul 30, 2026
0d11713
fix(settings): keep custom MCP secret rows out of the single-control …
lucas77778 Jul 30, 2026
657c092
fix(settings): drop the duplicate page title from the plugins shell (…
lucas77778 Jul 30, 2026
c0b9620
feat(schema,agent-adapter,engine): native per-skill enable/disable fo…
lucas77778 Jul 30, 2026
8d46783
feat(engine,client,settings): per-skill toggles from the wire to the …
lucas77778 Jul 30, 2026
644f6cd
fix(agent-adapter): accept real plugin-discovery output from current …
lucas77778 Jul 30, 2026
63ce7d7
feat(settings): split installed plugins from a Market tab of uninstal…
lucas77778 Jul 30, 2026
a7ef4bd
Merge remote-tracking branch 'origin/master' into chenyu/code-487
lucas77778 Jul 30, 2026
6e367a9
feat(agent-adapter): native codex plugin enable, install, and uninstall
lucas77778 Jul 30, 2026
16d0dea
feat(schema,engine): serve plugin install and uninstall over the wire
lucas77778 Jul 30, 2026
817b436
feat(client,settings): install and uninstall actions for codex plugins
lucas77778 Jul 30, 2026
94f7785
fix(agent-adapter): collapse duplicated ids in the codex plugin catalog
lucas77778 Jul 30, 2026
6a3adbf
perf(agent-adapter): read codex plugin detail only for installed plugins
lucas77778 Jul 30, 2026
e65f555
docs(agent-adapter): record the plugin and skill management provider …
lucas77778 Jul 30, 2026
6625d87
Merge remote-tracking branch 'origin/master' into chenyu/code-487
lucas77778 Jul 30, 2026
043537d
fix(test): resolve the worktree fixture temp path before comparing
lucas77778 Jul 30, 2026
1c331b4
fix(plugins): address review feedback
lucas77778 Jul 31, 2026
d01aa1a
Merge remote-tracking branch 'origin/master' into chenyu/code-487
lucas77778 Jul 31, 2026
16022d7
fix(plugins): address review feedback
lucas77778 Aug 1, 2026
e2a3f5a
fix(plugins): address latest review feedback
lucas77778 Aug 1, 2026
2f84c5e
fix(plugins): address Copilot review
lucas77778 Aug 1, 2026
2bdf210
fix(i18n): retain plugin search labels
lucas77778 Aug 1, 2026
9320b3e
fix(plugins): preserve MCP loading semantics
lucas77778 Aug 1, 2026
c666f48
Merge remote-tracking branch 'origin/master' into chenyu/code-487
lucas77778 Aug 1, 2026
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
24 changes: 14 additions & 10 deletions apps/daemon/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,25 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr
redirects config/db/runtime (this is what isolates an E2E daemon).
- **`config.json`** (optional, `0600`): provider and account updates persist together through one
fsynced same-directory temporary file, atomic rename, and POSIX parent-directory fsync, preserving
other fields; malformed or unreadable input fails closed. `loadConfig` validates entries
**field-by-field** — one bad entry is dropped and logged, never blanks the rest. It holds **no
secrets** since CODE-371 —
`providers[kind].apiKey` and each account's credential secret live in `secrets.json` below, and
other fields; custom MCP structure uses the same durable replacement while generation-linked vault
refs keep its cross-file update crash-consistent. Malformed or unreadable input fails closed.
`loadConfig` validates entries **field-by-field** — one bad entry is dropped and logged, never
blanks the rest. It holds **no secrets** since CODE-371 — `providers[kind].apiKey`, each account's
credential secret, and custom MCP env/header values live in `secrets.json` below, and
`withAccountSecret` merges them back *before* zod validation, so a secret that is gone fails
`AccountSchema` and drops through that same per-entry path.
- **`secrets.json`** (`0600`) — every long-lived credential, keyed `namespace:key` where the key is
the id the owning record already carries: `cloud:session`, `provider:<kind>`, `account:<id>`,
`device:software-key`. Custody is a 32-byte AES-256-GCM master key in the OS keyring
- **`secrets.json`** (`0600`) — every long-lived credential, keyed `namespace:key`: `cloud:session`,
`provider:<kind>`, `account:<id>`, custom MCP server/field tuples, `device:software-key`. Custody
is a 32-byte AES-256-GCM master key in the OS keyring
(`@napi-rs/keyring`, service = `keyringServiceName(channel, profile)` so a development daemon cannot
read the release one's), and the file is ciphertext.
- **`vault.namespace(name)` is the only way in** — there is no whole-store handle. That is what
makes `SecretStore.replaceAll` safe to hand out: a `save*` replaces its own namespace in one
write, so pruning a deleted account is implicit and cannot reach a neighbour's secrets. Adding a
subsystem is one entry in the `SecretNamespace` union plus its own key names; the vault stays
makes `SecretStore.replaceAll` safe to hand out: a normal `save*` replaces its own namespace in
one write, so pruning a deleted account is implicit and cannot reach a neighbour's secrets.
Custom MCP is the cross-file exception: refs include a config generation; save writes old+new
refs, atomically points `config.json` at the new generation, then prunes the old refs. Either side
of a process crash therefore names a complete snapshot. Adding a subsystem is one entry in the
`SecretNamespace` union plus its own key names; custom MCP values use the `custom-mcp` namespace. The vault stays
ignorant of what any of them mean.
- **The vault is constructed once, in `main()`, and passed down.** Every consumer takes a
`SecretVault` parameter and opens its own namespace — nothing reaches `secretVault()` by import.
Expand Down
266 changes: 263 additions & 3 deletions apps/daemon/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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';
import type { Account, Accounts, CustomMcpServer } from '@linkcode/schema';
import { DAEMON_DEFAULT_PORT, DAEMON_PORT_HUNT_SPAN, daemonBasePort } from '@linkcode/schema';
import { noop } from 'foxts/noop';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -12,10 +12,12 @@ import {
databasePath,
loadConfig,
runtimeFilePath,
saveCustomMcpServers,
saveProviderConfiguration,
} from '../config';
import { logger } from '../logger';
import { daemonChannel, telemetryConfigCachePath } from '../paths';
import { createProviderConfigStore } from '../provider-store';
import type { InMemoryVault } from './fixtures/in-memory-vault';
import { createInMemoryVault } from './fixtures/in-memory-vault';

Expand Down Expand Up @@ -62,7 +64,7 @@ const validAccount: Account = {
label: 'Personal key',
credential: { type: 'api-key', key: 'sk-test' },
createdAt: 0,
};
} satisfies Accounts[number];

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

describe('loadConfig custom MCP servers', () => {
const validServer = {
id: 'custom-1',
enabled: true,
server: {
type: 'stdio',
name: 'github',
command: 'gh-mcp',
env: { GITHUB_TOKEN: 'secret' },
},
createdAt: 1,
} as const satisfies CustomMcpServer;

function writeCustomMcpConfig(customMcpServers: unknown): void {
const dir = join(process.env.HOME ?? '', '.linkcode');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'config.json'), JSON.stringify({ customMcpServers }));
}

it('keeps valid servers and drops an invalid one without blanking the rest', () => {
const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop);
writeCustomMcpConfig([validServer, { id: 'broken', server: { type: 'stdio' } }]);

const config = loadConfig(vault);

expect(config.customMcpServers).toEqual([validServer]);
expect(errorSpy).toHaveBeenCalled();
});

it('round-trips through saveCustomMcpServers preserving other fields at mode 0600', () => {
writeCustomMcpConfig([]);
const path = join(process.env.HOME ?? '', '.linkcode', 'config.json');
writeFileSync(path, JSON.stringify({ providers: {}, customMcpServers: [] }));

saveCustomMcpServers(vault, [validServer], []);

const written: unknown = JSON.parse(readFileSync(path, 'utf8'));
expect(written).toEqual({
providers: {},
customMcpServers: {
v: 1,
generation: 1,
servers: [
{
...validServer,
server: { ...validServer.server, env: { GITHUB_TOKEN: null } },
},
],
},
});
expect(vault.refs.get('custom-mcp:[1,"custom-1","env","GITHUB_TOKEN"]')).toBe('secret');
expect(statSync(path).mode & 0o777).toBe(0o600);
expect(loadConfig(vault).customMcpServers).toEqual([validServer]);
});

it('keeps secrets distinct when server ids and keys contain delimiters', () => {
const servers: CustomMcpServer[] = [
{
...validServer,
id: 'a',
server: { ...validServer.server, env: { 'b:env:c': 'first' } },
},
{
...validServer,
id: 'a:env:b',
server: { ...validServer.server, name: 'second', env: { c: 'second' } },
},
];

saveCustomMcpServers(vault, servers, []);

expect(loadConfig(vault).customMcpServers).toEqual(servers);
});

it('loads a complete snapshot on either side of the config commit point', () => {
const nextServer: CustomMcpServer = {
...validServer,
server: { ...validServer.server, env: { NEXT_TOKEN: 'next-secret' } },
};
saveCustomMcpServers(vault, [validServer], []);
const previousConfig = readConfigFile();
const previousRefs = new Map(vault.refs);
saveCustomMcpServers(vault, [nextServer], [validServer]);
const nextConfig = readConfigFile();
const nextRefs = new Map(vault.refs);
const union = new Map([...previousRefs, ...nextRefs]);
const path = join(process.env.HOME ?? '', '.linkcode', 'config.json');

writeFileSync(path, JSON.stringify(previousConfig));
vault.refs.clear();
for (const [key, secret] of union) vault.refs.set(key, secret);
expect(loadConfig(vault).customMcpServers).toEqual([validServer]);

writeFileSync(path, JSON.stringify(nextConfig));
vault.refs.clear();
for (const [key, secret] of union) vault.refs.set(key, secret);
expect(loadConfig(vault).customMcpServers).toEqual([nextServer]);
});

it('replaces orphaned entries when reusing an interrupted generation', () => {
saveCustomMcpServers(vault, [validServer], []);
vault.refs.set('custom-mcp:[2,"custom-1","env","STALE_TOKEN"]', 'stale');
const nextServer: CustomMcpServer = {
...validServer,
server: { ...validServer.server, env: { GITHUB_TOKEN: 'next' } },
};

saveCustomMcpServers(vault, [nextServer], [validServer]);

expect([...vault.refs]).toEqual([['custom-mcp:[2,"custom-1","env","GITHUB_TOKEN"]', 'next']]);
expect(loadConfig(vault).customMcpServers).toEqual([nextServer]);
});

it('keeps a committed snapshot usable when stale-secret pruning fails', () => {
saveCustomMcpServers(vault, [validServer], []);
const nextServer: CustomMcpServer = {
...validServer,
server: { ...validServer.server, env: { GITHUB_TOKEN: 'next' } },
};
const baseVault = vault;
let replacements = 0;
const pruneFailure = new Error('prune failed');
const flakyVault: InMemoryVault = {
...baseVault,
namespace(name) {
const store = baseVault.namespace(name);
if (name !== 'custom-mcp') return store;
return {
...store,
replaceAll(entries) {
replacements += 1;
if (replacements === 2) throw pruneFailure;
store.replaceAll(entries);
},
};
},
};
const warning = vi.spyOn(logger, 'warn').mockImplementation(noop);

expect(() => saveCustomMcpServers(flakyVault, [nextServer], [validServer])).not.toThrow();

expect(loadConfig(flakyVault).customMcpServers).toEqual([nextServer]);
expect(warning).toHaveBeenCalledWith(
{ err: pruneFailure, operation: 'config.save-custom-mcp' },
'Custom MCP state committed but stale secret cleanup failed',
);
});

it('rejects a malformed versioned snapshot instead of guessing its generation', () => {
const warning = vi.spyOn(logger, 'warn').mockImplementation(noop);
writeCustomMcpConfig({ v: 1, generation: '1', servers: [validServer] });

expect(loadConfig(vault).customMcpServers).toEqual([]);
expect(warning).toHaveBeenCalled();
});
});

describe('createProviderConfigStore', () => {
it('does not publish provider, account, or custom MCP state when persistence fails', () => {
const oldProviders = { codex: { enabled: true } } as const;
const oldAccounts: Accounts = [validAccount];
const oldCustomMcpServers: CustomMcpServer[] = [];
const store = createProviderConfigStore(vault, oldProviders, oldAccounts, oldCustomMcpServers);
writeFileSync(join(process.env.HOME ?? '', '.linkcode'), 'not a directory');

expect(() => store.update({ providers: { 'claude-code': { enabled: true } } })).toThrow();
expect(() => store.update({ accounts: [] })).toThrow();
expect(() =>
store.setCustomMcpServers([
{
id: 'custom-1',
enabled: true,
server: { type: 'stdio', name: 'test', command: 'test' },
createdAt: 1,
},
]),
).toThrow();
expect(store.get()).toBe(oldProviders);
expect(store.getAccounts()).toBe(oldAccounts);
expect(store.getCustomMcpServers()).toBe(oldCustomMcpServers);
expect([...vault.refs.keys()].some((ref) => ref.startsWith('custom-mcp:'))).toBe(false);
});
});

// 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 Expand Up @@ -295,6 +481,80 @@ describe('credential storage', () => {
expect(raw).not.toContain('sk-test');
});

it('lazily moves inline custom MCP values while preserving their keys', () => {
vi.spyOn(logger, 'warn').mockImplementation(noop);
const server: CustomMcpServer = {
id: 'custom-http',
enabled: true,
server: {
type: 'http',
name: 'search',
url: 'https://mcp.example',
headers: { Authorization: 'Bearer legacy' },
},
createdAt: 1,
};
const dir = join(process.env.HOME ?? '', '.linkcode');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'config.json'), JSON.stringify({ customMcpServers: [server] }));

expect(loadConfig(vault).customMcpServers).toEqual([server]);
expect(vault.refs.get('custom-mcp:[1,"custom-http","headers","Authorization"]')).toBe(
'Bearer legacy',
);
expect(readConfigFile().customMcpServers).toEqual({
v: 1,
generation: 1,
servers: [{ ...server, server: { ...server.server, headers: { Authorization: null } } }],
});
expect(loadConfig(vault).customMcpServers).toEqual([server]);
});

it('migrates a legacy mix of inline and placeholder secrets as one snapshot', () => {
vi.spyOn(logger, 'warn').mockImplementation(noop);
const inline: CustomMcpServer = {
id: 'inline',
enabled: true,
server: {
type: 'stdio',
name: 'inline',
command: 'inline',
env: { INLINE_TOKEN: 'inline-secret' },
},
createdAt: 1,
};
const referenced: CustomMcpServer = {
id: 'referenced',
enabled: true,
server: {
type: 'stdio',
name: 'referenced',
command: 'referenced',
env: { STORED_TOKEN: 'stored-secret' },
},
createdAt: 2,
};
const dir = join(process.env.HOME ?? '', '.linkcode');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'config.json'),
JSON.stringify({
customMcpServers: [
inline,
{
...referenced,
server: { ...referenced.server, env: { STORED_TOKEN: null } },
},
],
}),
);
vault.refs.set('custom-mcp:["referenced","env","STORED_TOKEN"]', 'stored-secret');

expect(loadConfig(vault).customMcpServers).toEqual([inline, referenced]);
expect(readConfigFile().customMcpServers).toMatchObject({ v: 1, generation: 1 });
expect(loadConfig(vault).customMcpServers).toEqual([inline, referenced]);
});

it('round-trips an account through the vault without ever writing the secret', () => {
saveProviderConfiguration(vault, {}, [validAccount]);

Expand Down
Loading