Skip to content

Commit c666f48

Browse files
committed
Merge remote-tracking branch 'origin/master' into chenyu/code-487
Amp-Thread-ID: https://ampcode.com/threads/T-019fbc9e-9054-709c-ae76-594060e29be2 # Conflicts: # apps/daemon/AGENTS.md # apps/daemon/src/__tests__/config.test.ts # apps/daemon/src/config.ts # apps/daemon/src/provider-store.ts # packages/host/engine/src/agent/provider-config.ts
2 parents 9320b3e + ab09bcd commit c666f48

161 files changed

Lines changed: 3265 additions & 1152 deletions

File tree

Some content is hidden

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

.claude/rules/frontend.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ parts of `packages/presentation/ui` (`chat`/`shell`) and `packages/client/workbe
2424
- **Type scale bottoms out at `text-2xs`** (11px — badges, chrome labels); never write ad-hoc pixel sizes like `text-[13px]` — body/secondary/caption are `text-sm`/`text-xs`/`text-2xs`. Below `text-muted-foreground`, dimmer text uses the semantic tiers `text-label-tertiary` (timestamps, weak hints) and `text-label-quaternary` (placeholders, pending edges), never ad-hoc `/NN` opacities (fills like status dots are exempt). Numeric readouts outside `font-mono` take `tabular-nums`.
2525
- **Press feedback on custom controls**: compact independent controls (tabs, icon buttons) get `transition-transform duration-(--motion-fast) active:scale-[0.98]` (icon-only micro buttons: `active:scale-90`); full-width rows mirror their hover colour under `active:` instead of scaling. coss-ui primitives already ship `data-pressed`/`active:` treatments — never restyle those.
2626
- **Long-list rows read the density vars** (`py-(--density-row-py)`; skeletons track the matching `--density-*` height) — the appearance store's `listDensity` flips them via `data-density` on the root. A new list surface opts in with the var, not a fixed `py-2`.
27-
- **Motion reads the token scale**: `duration-(--motion-fast|normal|emphasis)` (150/250/350ms) for CSS transitions, and the exported `SPRING` from `@linkcode/ui` for Motion springs — a new animation picks a tier, never a bare `duration-NNN` or inline spring params; anything slower than `--motion-emphasis` needs a written reason.
27+
- **Motion reads the token scale**: `duration-(--motion-fast|normal|emphasis)` (150/250/350ms) for CSS transitions, and the exported `SPRING` from `@linkcode/ui` for Motion springs — a new animation picks a tier, never a bare `duration-NNN` or inline spring params; anything slower than `--motion-emphasis` needs a written reason. Entrances/exits take `ease-(--motion-ease-out)`, not the built-in `ease-out` (too weak to read at 150ms) and **not** the shell's `cubic-bezier(0.2, 0, 0, 1)` — that one is an emphasized in-out whose zero initial slope belongs on things that morph in place (the pane grid, the composer frame), never on something arriving.
28+
- **Never animate a keyboard-initiated change.** History chords (`⌘[`/`⌘]`) and the palette drive the same state as a click, so a keyed entrance fires on all three; gate it on `useInputModality() === 'pointer'` (`@linkcode/ui`) the way `ThreadTitle` does. CSS animations also need their own `@media (prefers-reduced-motion: reduce)` arm: the app-level `.reduce-motion` class comes from the appearance store, whose default is hard-coded `false` and never reads the OS preference.
2829
- **Routing & layout (webview).** Define routes with `createBrowserRouter` (data router) — no JSX `<Routes>` trees. Build the shell once at the layout level (sidebar/header/content inset); pages render into the outlet and never rebuild chrome. Each page sets its title via the `usePageTitle` hook (the SPA equivalent of Next `metadata`). There is no breadcrumb portal — the current layout has no slot for one; add it (and the corresponding convention here) if a surface needs breadcrumbs.
2930
- **Overlay surfaces (desktop).** A full-page surface layered over the workbench must hide the layer it covers (`invisible` + `inert` on the covered subtree, keeping it mounted): on macOS/Windows both shells are translucent over the native backdrop, so any painted pixels underneath ghost through.
3031
- **Settings mounts differently per app** (mirroring the router split): desktop `settings/settings-view.tsx` is a router-free `fixed inset-0 z-50` overlay rendered **above** the connection gate (reachable with the daemon down to fix a bad URL) — category is `useState`, items `onClick`, closed via the desktop settings store + Escape; webview `routes/settings/settings-layout.tsx` uses react-router (`Link` items + `Outlet`). Both render the shared `SettingsSidebarNav`; its search field is live and app-controlled — the app filters its grouped nav items through `filterSettingsNavGroups` + per-tab keywords from `useSettingsSearchKeywords` (`packages/client/workbench/src/settings/search.ts`, reusing the palette matcher). Don't add react-router to desktop for a new tab; extend the overlay.

.github/workflows/build-desktop.yml

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,9 @@ jobs:
145145
- name: Package with electron-builder
146146
working-directory: ${{ env.DESKTOP_DIR }}
147147
shell: bash # unify quoting across runners for the conditional signing args below
148-
# package-app.mts materializes a single-importer staging dir and points electron-builder's
149-
# --projectDir at it (see the script header): fixes the Windows native-rebuild miss and the
150-
# module-collector drops without the app-builder-lib patch. azureSignOptions is forwarded via
151-
# -c only on signed Windows builds so unsigned builds skip signing instead of failing.
148+
# package-app.mts materializes one CPU-scoped staging dir per target arch and points
149+
# electron-builder's --projectDir at it (see the script header). azureSignOptions is
150+
# forwarded via -c only on signed Windows builds so unsigned builds skip signing.
152151
run: |
153152
# electron-builder treats a *set but empty* CSC_LINK as a certificate path and dies on
154153
# "<projectDir> not a file". Actions cannot conditionally unset an env key, so the cert
@@ -160,13 +159,9 @@ jobs:
160159
# Electron 43 needs Clang 15; the arm64 rebuild also needs an explicit cross target.
161160
CC=clang-15 CXX=clang++-15 \
162161
node scripts/package-app.mts linux --x64 --publish never
163-
mv release/linux-unpacked "$RUNNER_TEMP/linkcode-linux-x64-unpacked"
164162
CC='clang-15 --target=aarch64-linux-gnu' \
165163
CXX='clang++-15 --target=aarch64-linux-gnu' \
166164
node scripts/package-app.mts linux --arm64 --publish never
167-
# Single-arch Linux builds both use linux-unpacked; preserve the verifier's arch layout.
168-
mv release/linux-unpacked release/linux-arm64-unpacked
169-
mv "$RUNNER_TEMP/linkcode-linux-x64-unpacked" release/linux-unpacked
170165
else
171166
node scripts/package-app.mts ${{ matrix.platform }} --publish never \
172167
${{ (runner.os == 'Windows' && inputs.sign) && format('-c.win.azureSignOptions.publisherName="{0}" -c.win.azureSignOptions.endpoint="{1}" -c.win.azureSignOptions.codeSigningAccountName="{2}" -c.win.azureSignOptions.certificateProfileName="{3}"', env.AZURE_PUBLISHER_NAME, env.AZURE_SIGN_ENDPOINT, env.AZURE_CODE_SIGNING_ACCOUNT, env.AZURE_CERTIFICATE_PROFILE) || '' }}

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "0.13.0"
2+
".": "0.16.0"
33
}

apps/daemon/AGENTS.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr
2323
- **Paths are owned by `src/config.ts`** (`configPath` / `databasePath` / `runtimeFilePath`) — never
2424
scatter `homedir()` joins elsewhere. `os.homedir()` is read at call time, so a fake `$HOME` fully
2525
redirects config/db/runtime (this is what isolates an E2E daemon).
26-
- **`config.json`** (optional, `0600`): the daemon writes back only structure via the config helpers,
27-
re-reading and preserving other fields. `loadConfig` validates providers **field-by-field** — one
28-
bad entry is dropped and logged, never blanks the rest. It holds **no secrets** since CODE-371 —
29-
`providers[kind].apiKey`, each account's credential secret, and custom MCP env/header values live
30-
in `secrets.json` below, and
26+
- **`config.json`** (optional, `0600`): provider and account updates persist together through one
27+
fsynced same-directory temporary file, atomic rename, and POSIX parent-directory fsync, preserving
28+
other fields; custom MCP structure uses the same durable replacement while generation-linked vault
29+
refs keep its cross-file update crash-consistent. Malformed or unreadable input fails closed.
30+
`loadConfig` validates entries **field-by-field** — one bad entry is dropped and logged, never
31+
blanks the rest. It holds **no secrets** since CODE-371 — `providers[kind].apiKey`, each account's
32+
credential secret, and custom MCP env/header values live in `secrets.json` below, and
3133
`withAccountSecret` merges them back *before* zod validation, so a secret that is gone fails
3234
`AccountSchema` and drops through that same per-entry path.
3335
- **`secrets.json`** (`0600`) — every long-lived credential, keyed `namespace:key`: `cloud:session`,

apps/daemon/src/__tests__/ai-gateway.test.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1+
import { existsSync } from 'node:fs';
2+
import { dirname } from 'node:path';
13
import type { TranslatorUpstream } from '@linkcode/engine';
4+
import { nullthrow } from 'foxts/guard';
25
import { noop } from 'foxts/noop';
36
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
47
import type { SidecarChildProcess, SidecarSpawn } from '../ai-gateway';
58
import { createAiGatewaySidecar, upstreamToToml } from '../ai-gateway';
69

710
const EXIT_BEFORE_LISTENING_RE = /before listening/;
11+
const FAILED_TO_START_RE = /failed to start.*ENOENT/;
812
const NO_BINARY_RE = /no aigateway binary/;
13+
const STARTUP_TIMEOUT_RE = /did not become ready/;
14+
const STOPPED_BEFORE_LISTENING_RE = /stopped before listening/;
15+
16+
function configDir(path: string | undefined): string {
17+
return dirname(nullthrow(path, 'spawn did not receive a config path'));
18+
}
919

1020
const upstream: TranslatorUpstream = {
1121
baseUrl: 'https://api.openai.com/v1',
@@ -16,22 +26,32 @@ const upstream: TranslatorUpstream = {
1626

1727
class FakeChild implements SidecarChildProcess {
1828
private readonly dataListeners: Array<(chunk: unknown) => void> = [];
29+
private readonly errorListeners: Array<(error: Error) => void> = [];
1930
private readonly exitListeners: Array<(code: number | null) => void> = [];
2031
readonly stdout = {
2132
on: (_event: 'data', listener: (chunk: unknown) => void) => this.dataListeners.push(listener),
2233
};
2334
readonly stderr = { on: noop };
2435
killed = false;
36+
signal: NodeJS.Signals | undefined;
2537

2638
on(event: 'exit' | 'error', listener: (arg: never) => void): void {
27-
if (event === 'exit') this.exitListeners.push(listener as (code: number | null) => void);
39+
if (event === 'exit') {
40+
this.exitListeners.push(listener as (code: number | null) => void);
41+
} else {
42+
this.errorListeners.push(listener as (error: Error) => void);
43+
}
2844
}
29-
kill(): void {
45+
kill(signal?: NodeJS.Signals): void {
3046
this.killed = true;
47+
this.signal = signal;
3148
}
3249
emitStdout(text: string): void {
3350
for (const listener of this.dataListeners) listener(text);
3451
}
52+
emitError(error: Error): void {
53+
for (const listener of this.errorListeners) listener(error);
54+
}
3555
emitExit(code: number | null): void {
3656
for (const listener of this.exitListeners) listener(code);
3757
}
@@ -45,6 +65,7 @@ beforeEach(() => {
4565
});
4666

4767
afterEach(() => {
68+
vi.useRealTimers();
4869
if (savedBinary === undefined) delete process.env.LINKCODE_AIGATEWAY_PATH;
4970
else process.env.LINKCODE_AIGATEWAY_PATH = savedBinary;
5071
});
@@ -75,6 +96,7 @@ describe('createAiGatewaySidecar', () => {
7596
const sidecar = createAiGatewaySidecar({ spawn });
7697
expect(await sidecar.ensure(upstream)).toBe('http://127.0.0.1:5123');
7798
expect(spawn).toHaveBeenCalledTimes(1);
99+
await sidecar.closeAll();
78100
});
79101

80102
it('reuses a running sidecar for the same upstream', async () => {
@@ -87,6 +109,7 @@ describe('createAiGatewaySidecar', () => {
87109
await sidecar.ensure(upstream);
88110
await sidecar.ensure(upstream);
89111
expect(spawn).toHaveBeenCalledTimes(1);
112+
await sidecar.closeAll();
90113
});
91114

92115
it('rejects when the process exits before listening', async () => {
@@ -112,6 +135,7 @@ describe('createAiGatewaySidecar', () => {
112135
expect(await sidecar.ensure(upstream)).toBe('http://127.0.0.1:5123');
113136
expect(ensureBinary).toHaveBeenCalled();
114137
expect(spawn).toHaveBeenCalledWith('/managed/aigateway', expect.any(Array));
138+
await sidecar.closeAll();
115139
});
116140

117141
it('rejects with a clear error when no binary is available', async () => {
@@ -120,4 +144,57 @@ describe('createAiGatewaySidecar', () => {
120144
createAiGatewaySidecar({ spawn: () => new FakeChild() }).ensure(upstream),
121145
).rejects.toThrow(NO_BINARY_RE);
122146
});
147+
148+
it('cleans temporary credentials when spawn emits an error', async () => {
149+
let configPath: string | undefined;
150+
const spawn: SidecarSpawn = (_command, args) => {
151+
configPath = args.at(-1);
152+
const child = new FakeChild();
153+
queueMicrotask(() => child.emitError(new Error('spawn aigateway ENOENT')));
154+
return child;
155+
};
156+
157+
await expect(createAiGatewaySidecar({ spawn }).ensure(upstream)).rejects.toThrow(
158+
FAILED_TO_START_RE,
159+
);
160+
expect(existsSync(configDir(configPath))).toBe(false);
161+
});
162+
163+
it('terminates and cleans a sidecar that misses the readiness deadline', async () => {
164+
vi.useFakeTimers();
165+
let configPath: string | undefined;
166+
const child = new FakeChild();
167+
const spawn: SidecarSpawn = (_command, args) => {
168+
configPath = args.at(-1);
169+
return child;
170+
};
171+
const pending = createAiGatewaySidecar({ spawn }).ensure(upstream);
172+
const rejection = expect(pending).rejects.toThrow(STARTUP_TIMEOUT_RE);
173+
174+
await vi.advanceTimersByTimeAsync(10000);
175+
176+
expect(child.killed).toBe(true);
177+
expect(child.signal).toBe('SIGTERM');
178+
expect(existsSync(configDir(configPath))).toBe(false);
179+
await rejection;
180+
});
181+
182+
it('terminates and cleans a sidecar before waiting for startup during shutdown', async () => {
183+
let configPath: string | undefined;
184+
const child = new FakeChild();
185+
const spawn: SidecarSpawn = (_command, args) => {
186+
configPath = args.at(-1);
187+
return child;
188+
};
189+
const sidecar = createAiGatewaySidecar({ spawn });
190+
const pending = sidecar.ensure(upstream);
191+
192+
const rejection = expect(pending).rejects.toThrow(STOPPED_BEFORE_LISTENING_RE);
193+
await sidecar.closeAll();
194+
195+
await rejection;
196+
expect(child.killed).toBe(true);
197+
expect(child.signal).toBe('SIGTERM');
198+
expect(existsSync(configDir(configPath))).toBe(false);
199+
});
123200
});
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import {
2+
chmodSync,
3+
mkdirSync,
4+
mkdtempSync,
5+
readdirSync,
6+
readFileSync,
7+
statSync,
8+
writeFileSync,
9+
} from 'node:fs';
10+
import { tmpdir } from 'node:os';
11+
import { join } from 'node:path';
12+
import type { Account } from '@linkcode/schema';
13+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14+
import { createProviderConfigStore } from '../provider-store';
15+
import { createInMemoryVault } from './fixtures/in-memory-vault';
16+
17+
const fsMocks = vi.hoisted(() => ({
18+
openTargets: new Map<number, string>(),
19+
renameTarget: null as string | null,
20+
renameTargets: [] as string[],
21+
syncTargets: [] as string[],
22+
}));
23+
24+
vi.mock('node:fs', async (importOriginal) => {
25+
const actual = await importOriginal<typeof import('node:fs')>();
26+
return {
27+
...actual,
28+
closeSync(descriptor: number) {
29+
try {
30+
actual.closeSync(descriptor);
31+
} finally {
32+
fsMocks.openTargets.delete(descriptor);
33+
}
34+
},
35+
fsyncSync(descriptor: number) {
36+
fsMocks.syncTargets.push(fsMocks.openTargets.get(descriptor) ?? '');
37+
actual.fsyncSync(descriptor);
38+
},
39+
openSync(
40+
path: Parameters<typeof actual.openSync>[0],
41+
flags: Parameters<typeof actual.openSync>[1],
42+
mode?: Parameters<typeof actual.openSync>[2],
43+
) {
44+
const descriptor = actual.openSync(path, flags, mode);
45+
fsMocks.openTargets.set(descriptor, String(path));
46+
return descriptor;
47+
},
48+
renameSync(
49+
oldPath: Parameters<typeof actual.renameSync>[0],
50+
newPath: Parameters<typeof actual.renameSync>[1],
51+
) {
52+
const target = String(newPath);
53+
fsMocks.renameTargets.push(target);
54+
if (target === fsMocks.renameTarget) throw new Error('simulated rename failure');
55+
actual.renameSync(oldPath, newPath);
56+
},
57+
};
58+
});
59+
60+
const oauthAccount: Account = {
61+
id: 'acc_oauth',
62+
label: 'Subscription',
63+
credential: { type: 'oauth', agent: 'claude-code' },
64+
createdAt: 0,
65+
};
66+
67+
let savedHome: string | undefined;
68+
69+
beforeEach(() => {
70+
savedHome = process.env.HOME;
71+
process.env.HOME = mkdtempSync(join(tmpdir(), 'linkcode-config-persistence-'));
72+
process.env.LINKCODE_CHANNEL = 'release';
73+
});
74+
75+
afterEach(() => {
76+
process.env.HOME = savedHome;
77+
delete process.env.LINKCODE_CHANNEL;
78+
fsMocks.renameTarget = null;
79+
fsMocks.renameTargets = [];
80+
fsMocks.openTargets.clear();
81+
fsMocks.syncTargets = [];
82+
vi.restoreAllMocks();
83+
});
84+
85+
function paths(): { dir: string; config: string } {
86+
const dir = join(process.env.HOME ?? '', '.linkcode');
87+
return { dir, config: join(dir, 'config.json') };
88+
}
89+
90+
function readConfig(): Record<string, unknown> {
91+
return JSON.parse(readFileSync(paths().config, 'utf8')) as Record<string, unknown>;
92+
}
93+
94+
describe('provider config persistence', () => {
95+
it('atomically replaces providers and accounts, preserves other fields, and tightens the mode', () => {
96+
const { dir, config } = paths();
97+
mkdirSync(dir, { recursive: true });
98+
writeFileSync(config, JSON.stringify({ hostname: '127.0.0.2' }));
99+
chmodSync(config, 0o644);
100+
const store = createProviderConfigStore(createInMemoryVault(), {}, []);
101+
102+
store.update({
103+
providers: { codex: { enabled: true, activeAccountId: oauthAccount.id } },
104+
accounts: [oauthAccount],
105+
});
106+
107+
expect(readConfig()).toEqual({
108+
hostname: '127.0.0.2',
109+
providers: { codex: { enabled: true, activeAccountId: oauthAccount.id } },
110+
accounts: [oauthAccount],
111+
});
112+
expect(store.get()).toEqual({
113+
codex: { enabled: true, activeAccountId: oauthAccount.id },
114+
});
115+
expect(store.getAccounts()).toEqual([oauthAccount]);
116+
expect(statSync(config).mode & 0o777).toBe(0o600);
117+
expect(fsMocks.renameTargets).toEqual([config]);
118+
expect(fsMocks.syncTargets[0]).toContain(join(dir, '.config.'));
119+
expect(fsMocks.syncTargets[0]?.endsWith('.tmp')).toBe(true);
120+
expect(fsMocks.syncTargets.slice(1)).toEqual(process.platform === 'win32' ? [] : [dir]);
121+
});
122+
123+
it('rejects corrupt JSON without replacing the file or publishing memory', () => {
124+
const { dir, config } = paths();
125+
mkdirSync(dir, { recursive: true });
126+
const corrupt = '{ definitely not JSON';
127+
writeFileSync(config, corrupt);
128+
const store = createProviderConfigStore(createInMemoryVault(), {}, []);
129+
130+
expect(() =>
131+
store.update({
132+
providers: { codex: { enabled: true } },
133+
accounts: [oauthAccount],
134+
}),
135+
).toThrow(`Invalid JSON in daemon config at ${config}`);
136+
137+
expect(readFileSync(config, 'utf8')).toBe(corrupt);
138+
expect(store.get()).toEqual({});
139+
expect(store.getAccounts()).toEqual([]);
140+
});
141+
142+
it('leaves the previous file and memory unchanged when rename fails', () => {
143+
const { dir, config } = paths();
144+
mkdirSync(dir, { recursive: true });
145+
const previous = `${JSON.stringify({ providers: {}, accounts: [] }, null, 2)}\n`;
146+
writeFileSync(config, previous);
147+
const store = createProviderConfigStore(createInMemoryVault(), {}, []);
148+
fsMocks.renameTarget = config;
149+
150+
expect(() =>
151+
store.update({
152+
providers: { codex: { enabled: true } },
153+
accounts: [oauthAccount],
154+
}),
155+
).toThrow('simulated rename failure');
156+
157+
expect(readFileSync(config, 'utf8')).toBe(previous);
158+
expect(store.get()).toEqual({});
159+
expect(store.getAccounts()).toEqual([]);
160+
expect(readdirSync(dir).filter((entry) => entry.endsWith('.tmp'))).toEqual([]);
161+
});
162+
});

0 commit comments

Comments
 (0)