From 24b03e8c2304b4673d2dd3787e688d9f2e67b719 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 28 Jul 2026 00:57:36 +0800 Subject: [PATCH 1/4] feat(schema,daemon,desktop): fork on-disk state by channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A local build and an installed release shared ~/.linkcode, ~/LinkCode, and the asset store, so only one could hold 19523: the loser's client then dialed a peer on a different WIRE_PROTOCOL_VERSION and every frame was dropped in silence. Channel now picks the whole universe, and the port hunt reads it off the occupant's identity so both daemons coexist. The daemon cannot see app.isPackaged, so it resolves its own channel: injected LINKCODE_CHANNEL, else the tsup build stamp, else development. Resolution is per call — instrument.ts derives a state path in its module body, before index.ts runs. --- apps/daemon/scripts/dev-clean.mts | 2 +- apps/daemon/src/__tests__/config.test.ts | 60 ++++++++++++++- apps/daemon/src/config.ts | 10 +-- apps/daemon/src/index.ts | 13 +++- apps/daemon/src/paths.ts | 21 ++++- apps/daemon/src/runtime.ts | 13 +++- apps/daemon/tsup.config.ts | 5 ++ .../main/__tests__/daemon-supervisor.test.ts | 14 ++++ apps/desktop/src/main/constants.ts | 10 ++- apps/desktop/src/main/daemon-discovery.ts | 6 +- apps/desktop/src/main/daemon-supervisor.ts | 7 +- .../src/main/default-picker-directory.ts | 5 +- packages/foundation/common/src/node/index.ts | 5 +- .../foundation/schema/src/daemon-runtime.ts | 24 ++++-- .../schema/src/model/daemon-discovery.ts | 7 ++ packages/foundation/schema/src/product.ts | 76 +++++++++++++++++-- .../src/native/opencode/history-server.ts | 15 +++- .../host/assets/src/__tests__/paths.test.ts | 36 +++++++-- packages/host/assets/src/paths.ts | 31 +++++--- 19 files changed, 297 insertions(+), 63 deletions(-) diff --git a/apps/daemon/scripts/dev-clean.mts b/apps/daemon/scripts/dev-clean.mts index 99c90e23f..ae90d3fe3 100644 --- a/apps/daemon/scripts/dev-clean.mts +++ b/apps/daemon/scripts/dev-clean.mts @@ -1,6 +1,6 @@ import { rmSync } from 'node:fs'; import { databasePath, runtimeFilePath } from '../src/config'; -// Resolves through config.ts so a fork's renamed STATE_DIR_BASENAME or an active +// Resolves through config.ts so a fork's renamed state dir, the resolved channel, or an active // LINKCODE_PROFILE cleans the same universe the dev daemon will actually use. for (const path of [databasePath(), runtimeFilePath()]) rmSync(path, { force: true }); diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index 363d2b93f..7a5cb1a0f 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -11,19 +11,23 @@ import { runtimeFilePath, } from '../config'; import { logger } from '../logger'; -import { telemetryConfigCachePath } from '../paths'; +import { daemonChannel, telemetryConfigCachePath } from '../paths'; let savedHome: string | undefined; -// loadConfig() reads ~/.linkcode/config.json; point HOME at a fresh temp dir per test. +// loadConfig() reads the channel's config.json; point HOME at a fresh temp dir per test. The +// channel is pinned to release so these cases keep asserting plain `~/.linkcode` — running the TS +// source would otherwise resolve as development. The channel axis itself is covered further down. beforeEach(() => { savedHome = process.env.HOME; process.env.HOME = mkdtempSync(join(tmpdir(), 'linkcode-config-')); + process.env.LINKCODE_CHANNEL = 'release'; }); afterEach(() => { process.env.HOME = savedHome; delete process.env.LINKCODE_PROFILE; + delete process.env.LINKCODE_CHANNEL; vi.restoreAllMocks(); }); @@ -134,6 +138,58 @@ describe('profile-scoped state paths', () => { }); }); +// CODE-460: a local build must never land in the installed release's universe — that shared +// `~/.linkcode` is what let a dev daemon hold 19523 and serve a release client frames it drops. +describe('channel-scoped state paths', () => { + it('forks the development channel into its own state directory', () => { + process.env.LINKCODE_CHANNEL = 'development'; + const root = join(process.env.HOME ?? '', '.linkcode.development'); + expect(daemonChannel()).toBe('development'); + expect(databasePath()).toBe(join(root, 'daemon.db')); + expect(runtimeFilePath()).toBe(join(root, 'runtime.json')); + expect(hqCredentialsPath()).toBe(join(root, 'hq.json')); + expect(telemetryConfigCachePath()).toBe(join(root, 'telemetry-config.json')); + }); + + it('composes the channel with a profile, dot before hyphen', () => { + process.env.LINKCODE_CHANNEL = 'development'; + process.env.LINKCODE_PROFILE = 'alpha'; + expect(databasePath()).toBe( + join(process.env.HOME ?? '', '.linkcode.development-alpha', 'daemon.db'), + ); + }); + + // The dot separator is what makes this impossible to express as a profile name, so a release + // build can never be talked into the development directory by `--profile=development`. + it('keeps a profile named after the channel out of the development directory', () => { + process.env.LINKCODE_PROFILE = 'development'; + expect(databasePath()).toBe(join(process.env.HOME ?? '', '.linkcode-development', 'daemon.db')); + }); + + it('defaults to development when nothing is injected — an unstamped build is a working copy', () => { + delete process.env.LINKCODE_CHANNEL; + expect(daemonChannel()).toBe('development'); + expect(databasePath()).toBe(join(process.env.HOME ?? '', '.linkcode.development', 'daemon.db')); + }); + + it('aborts on an invalid channel instead of silently picking a universe', () => { + process.env.LINKCODE_CHANNEL = 'prod'; + expect(() => daemonChannel()).toThrow(TypeError); + expect(() => databasePath()).toThrow(TypeError); + }); + + it('lets an injected channel outrank the build stamp', () => { + // The devshell pack ships a bundle stamped `release` inside a development shell. + process.env.LINKCODE_BUILD_CHANNEL = 'release'; + process.env.LINKCODE_CHANNEL = 'development'; + try { + expect(daemonChannel()).toBe('development'); + } finally { + delete process.env.LINKCODE_BUILD_CHANNEL; + } + }); +}); + describe('loadConfig accounts', () => { it('keeps valid accounts and drops an invalid one, logging the error', () => { const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index c06d08741..8a6d72ac5 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -10,12 +10,12 @@ import { ProviderConfigSchema, SimulatorConsentStateSchema, } from '@linkcode/schema'; -import { WORKSPACES_DIRNAME } from '@linkcode/schema/product'; +import { workspacesDirName } from '@linkcode/schema/product'; import type { TransportServerOptions } from '@linkcode/transport/server'; import { logger } from './logger'; -import { daemonProfile, daemonStateDir } from './paths'; +import { daemonChannel, daemonProfile, daemonStateDir } from './paths'; -export { daemonProfile } from './paths'; +export { daemonChannel, daemonProfile } from './paths'; /** * Daemon configuration: `config.json` in the profile's state dir (optional) with env overrides. @@ -57,7 +57,7 @@ export function databasePath(): string { /** Runtime discovery file advertising the running daemon's bound endpoints, next to config.json. */ export function runtimeFilePath(): string { - return daemonRuntimeFilePath(daemonProfile()); + return daemonRuntimeFilePath(daemonChannel(), daemonProfile()); } /** HQ sign-in state (session token + registered device id), next to config.json; written 0600. */ @@ -81,7 +81,7 @@ export function deviceKeysDir(): string { * independently — a system-plane invariant enforced regardless of which client is connected. */ export function chatWorkspaceRoot(): string { - return join(homedir(), WORKSPACES_DIRNAME); + return join(homedir(), workspacesDirName(daemonChannel())); } export function loadConfig(): DaemonConfig { diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index f963e8d07..fe9139ad9 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -30,6 +30,7 @@ import { installAsarSpawnFix } from './asar-spawn'; import type { DaemonConfig } from './config'; import { chatWorkspaceRoot, + daemonChannel, daemonProfile, databasePath, loadConfig, @@ -135,8 +136,10 @@ const teardown: Runtime.Teardown = (exit, onExit) => { */ async function main(): Promise { // Resolved before anything (subcommands included) touches state paths: an invalid - // LINKCODE_PROFILE must abort here, not mid-command or as a default-profile daemon. + // LINKCODE_PROFILE or LINKCODE_CHANNEL must abort here, not mid-command or as a daemon that + // silently landed in the default universe. const profile = daemonProfile(); + const channel = daemonChannel(); // Subcommands run and exit instead of booting the host (a running daemon // picks the new sign-in state up on its next restart). @@ -152,8 +155,9 @@ async function main(): Promise { Shared, Effect.gen(function* () { const config = loadConfig(); - // One daemon per profile — a second instance would share this profile's daemon.db and split - // sessions. Daemons of other profiles live in sibling state dirs and are not visible here. + // One daemon per universe (channel × profile) — a second instance would share this + // universe's daemon.db and split sessions. Daemons of other channels/profiles live in + // sibling state dirs and are not visible here. const running = yield* Effect.promise(findRunningDaemon); if (running) { const urls = running.listeners.map((listener) => listener.url).join(', '); @@ -168,6 +172,9 @@ async function main(): Promise { pid: process.pid, startedAt: Date.now(), ...(profile !== undefined && { profile }), + // Absent means release on the wire, so only development needs stating — this keeps a + // release daemon's identity and runtime.json byte-identical to pre-split ones. + ...(channel !== 'release' && { channel }), }; const hub = new Hub(); yield* Effect.addFinalizer(() => diff --git a/apps/daemon/src/paths.ts b/apps/daemon/src/paths.ts index 793919ed9..1576ef5e4 100644 --- a/apps/daemon/src/paths.ts +++ b/apps/daemon/src/paths.ts @@ -1,15 +1,32 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; +import type { ProductChannel } from '@linkcode/schema'; import { linkcodeStateDirName, parseProfileName } from '@linkcode/schema'; +import { resolveProductChannel } from '@linkcode/schema/product'; /** Profile from `LINKCODE_PROFILE`; invalid names fail boot instead of crossing state universes. */ export function daemonProfile(): string | undefined { return parseProfileName(process.env.LINKCODE_PROFILE); } -/** The daemon's profile-aware state directory. Safe to import before logging/Sentry initialization. */ +/** + * This build's channel, which picks the whole on-disk universe (CODE-460). `LINKCODE_CHANNEL` — + * injected by the desktop supervisor — outranks the build-time marker, because the devshell pack + * ships a tsup bundle stamped `release` inside a `development` shell. tsup replaces + * `process.env.LINKCODE_BUILD_CHANNEL` with a literal (see its `define`); running the TS source + * leaves it undefined, so a dev daemon defaults to `development` with nothing to remember. + * + * Resolved on every call, never cached at module load: `instrument.ts` derives a state path in its + * module body, and `--import` runs it before `index.ts` — anything hung off entry-point side + * effects would capture the wrong universe (the CODE-166 bug class). + */ +export function daemonChannel(): ProductChannel { + return resolveProductChannel(process.env.LINKCODE_CHANNEL, process.env.LINKCODE_BUILD_CHANNEL); +} + +/** The daemon's channel × profile state directory. Safe to import before logging/Sentry initialization. */ export function daemonStateDir(): string { - return join(homedir(), linkcodeStateDirName(daemonProfile())); + return join(homedir(), linkcodeStateDirName(daemonChannel(), daemonProfile())); } export function telemetryConfigCachePath(): string { diff --git a/apps/daemon/src/runtime.ts b/apps/daemon/src/runtime.ts index a1b7e650a..3d4aef121 100644 --- a/apps/daemon/src/runtime.ts +++ b/apps/daemon/src/runtime.ts @@ -90,9 +90,10 @@ async function huntFrom( if (!isAddrInUse(err)) throw err; const probeUrl = httpUrl(listener.host, port); const occupant = await probeDaemonIdentity(probeUrl); - // Our own pid = another of this daemon's listeners hunted onto the port; another profile - // (absent field = default) is an isolated universe, not a double-start — hunt past both. - if (occupant && occupant.pid !== identity.pid && occupant.profile === identity.profile) { + // Our own pid = another of this daemon's listeners hunted onto the port; another universe + // (channel × profile, absent fields = release × default) is isolated, not a double-start — + // hunt past both. This is what lets a dev daemon start while a release one holds 19523. + if (occupant && occupant.pid !== identity.pid && sameUniverse(occupant, identity)) { throw new DaemonAlreadyRunningError(occupant, probeUrl); } if (attempt + 1 >= PORT_HUNT_ATTEMPTS) { @@ -105,6 +106,12 @@ async function huntFrom( } } +/** Same state universe = same `daemon.db`. Both fields are optional on the wire and default to the + * pre-split universe, so a daemon predating either axis compares equal to release × default. */ +function sameUniverse(a: DaemonIdentity, b: DaemonIdentity): boolean { + return (a.channel ?? 'release') === (b.channel ?? 'release') && a.profile === b.profile; +} + /** * The daemon advertised by the runtime file, or `null`: file missing/malformed (stale leftovers * are overwritten on next start), pid dead, or the endpoint not answering as a linkcode daemon. diff --git a/apps/daemon/tsup.config.ts b/apps/daemon/tsup.config.ts index 958365d81..b37736abd 100644 --- a/apps/daemon/tsup.config.ts +++ b/apps/daemon/tsup.config.ts @@ -10,6 +10,11 @@ export default defineConfig({ // Desktop packaging copies only index.js into the asar (electron.vite.config.ts // bundle-daemon-artifact); a split bundle boots to ERR_MODULE_NOT_FOUND on the missing chunk-*.js. splitting: false, + // Stamps the channel into the bundle (CODE-460): a built daemon is a release artifact, while + // running the TS source leaves this undefined so `daemonChannel()` defaults to development. The + // desktop supervisor's LINKCODE_CHANNEL still outranks it — the devshell pack ships this very + // bundle inside a development shell. + define: { 'process.env.LINKCODE_BUILD_CHANNEL': JSON.stringify('release') }, // Inlined CJS deps call `require()`; esbuild's ESM output has none, so provide one or boot dies // with "Dynamic require of ... is not supported". The private alias avoids redeclaring a bundled // module's own preserved `import { createRequire }` (the banner is prepended after esbuild). diff --git a/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts b/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts index e9a291fc4..697cac433 100644 --- a/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts +++ b/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts @@ -218,4 +218,18 @@ describe('daemon supervisor recovery', () => { const forkArgs = mocks.fork.mock.calls[0] as [string, string[], { execArgv?: string[] }]; expect(forkArgs[2].execArgv).toEqual([]); }); + + // The child cannot infer this: the devshell pack bundles a daemon stamped `release` at build + // time, so only the shell knows it is a development build (CODE-460). Without the injection + // that daemon would serve the installed release's state directory and port. + it("injects the shell's channel into the daemon it spawns", async () => { + await startSupervisor(); + + const forkArgs = mocks.fork.mock.calls[0] as [ + string, + string[], + { env?: Record }, + ]; + expect(forkArgs[2].env?.LINKCODE_CHANNEL).toBe('development'); + }); }); diff --git a/apps/desktop/src/main/constants.ts b/apps/desktop/src/main/constants.ts index db9a3d11c..746a7f7f8 100644 --- a/apps/desktop/src/main/constants.ts +++ b/apps/desktop/src/main/constants.ts @@ -1,4 +1,6 @@ +import type { ProductChannel } from '@linkcode/schema/daemon-runtime'; import { parseProfileName } from '@linkcode/schema/daemon-runtime'; +import { workspacesDirName } from '@linkcode/schema/product'; import { app, dialog } from 'electron'; import { extractErrorMessage } from 'foxts/extract-error-message'; @@ -13,7 +15,7 @@ import { extractErrorMessage } from 'foxts/extract-error-message'; * (`--profile=` / `LINKCODE_PROFILE`) forking the same surfaces again; passed on to the * supervised daemon, which forks its state dir and device identity (see `apps/daemon/src/config.ts`). */ -export type Channel = 'release' | 'development'; +export type Channel = ProductChannel; export const CHANNEL: Channel = import.meta.env.MODE !== 'production' || !app.isPackaged ? 'development' : 'release'; @@ -53,5 +55,7 @@ const BASE_ID = export const APP_ID = PROFILE === undefined ? BASE_ID : `${BASE_ID}.${PROFILE}`; -/** `~/LinkCode` holds user workspaces — shared across channels and profiles on purpose. */ -export { WORKSPACES_DIRNAME as DEFAULT_WORKSPACES_DIRNAME } from '@linkcode/schema/product'; +/** The channel's workspace directory (`~/LinkCode`, `~/LinkCode Development`) — shared across + * that channel's profiles on purpose, but never across channels (CODE-460). Must agree with the + * daemon's `chatWorkspaceRoot()`, which derives the same name from its own resolved channel. */ +export const DEFAULT_WORKSPACES_DIRNAME = workspacesDirName(CHANNEL); diff --git a/apps/desktop/src/main/daemon-discovery.ts b/apps/desktop/src/main/daemon-discovery.ts index b9f071c5e..6a9c13972 100644 --- a/apps/desktop/src/main/daemon-discovery.ts +++ b/apps/desktop/src/main/daemon-discovery.ts @@ -2,7 +2,7 @@ import { mkdirSync, watch } from 'node:fs'; import { basename, dirname } from 'node:path'; import { daemonRuntimeFilePath, isPidAlive, readJsonFileSync } from '@linkcode/common/node'; import { DAEMON_DEFAULT_URL, DaemonRuntimeInfoSchema } from '@linkcode/schema'; -import { PROFILE } from './constants'; +import { CHANNEL, PROFILE } from './constants'; import { getSettings } from './settings'; /** @@ -14,7 +14,7 @@ export function resolveDaemonUrl(): string { } function discoverRuntimeUrl(): string | null { - const file = daemonRuntimeFilePath(PROFILE); + const file = daemonRuntimeFilePath(CHANNEL, PROFILE); const parsed = DaemonRuntimeInfoSchema.safeParse(readJsonFileSync(file)); if (!parsed.success || !isPidAlive(parsed.data.pid)) return null; // The renderer connects over Socket.IO; ignore listeners it cannot dial. @@ -30,7 +30,7 @@ const RUNTIME_WATCH_DEBOUNCE_MS = 100; * the file is created/removed across daemon lifetimes, and a watcher on a deleted inode goes blind. */ export function watchDaemonRuntime(onChange: () => void): () => void { - const file = daemonRuntimeFilePath(PROFILE); + const file = daemonRuntimeFilePath(CHANNEL, PROFILE); // The daemon creates its state dir on first start; create it up front so watching never races that. mkdirSync(dirname(file), { recursive: true }); let debounce: NodeJS.Timeout | null = null; diff --git a/apps/desktop/src/main/daemon-supervisor.ts b/apps/desktop/src/main/daemon-supervisor.ts index a171edf9a..fc7389518 100644 --- a/apps/desktop/src/main/daemon-supervisor.ts +++ b/apps/desktop/src/main/daemon-supervisor.ts @@ -4,14 +4,14 @@ import { DAEMON_EXIT_ALREADY_RUNNING } from '@linkcode/schema'; import type { UtilityProcess } from 'electron'; import { app, utilityProcess } from 'electron'; import log from 'electron-log'; -import { PROFILE } from './constants'; +import { CHANNEL, PROFILE } from './constants'; import { watchDaemonRuntime } from './daemon-discovery'; import { getSettings } from './settings'; /** * Supervises the bundled daemon (out/daemon/index.mjs): forks it under Electron's Node via * `utilityProcess`, started on app-ready, SIGTERMed on quit; closing windows leaves it running. - * It only spawns — the one-daemon-per-profile contract lives in the daemon itself + * It only spawns — the one-daemon-per-universe contract lives in the daemon itself * (apps/daemon/src/runtime.ts): an external daemon makes the child exit * DAEMON_EXIT_ALREADY_RUNNING and the supervisor stands down; which daemon clients dial is * discovery's job (runtime.json). @@ -98,6 +98,9 @@ function spawnDaemon(): void { // inherited LINKCODE_PROFILE, and the default universe must not leak a stray env value through. if (PROFILE === undefined) delete env.LINKCODE_PROFILE; else env.LINKCODE_PROFILE = PROFILE; + // The channel cannot be inferred by the child: the devshell pack bundles a daemon stamped + // `release` at build time, and only this shell knows it is a development build (CODE-460). + env.LINKCODE_CHANNEL = CHANNEL; const sidecar = sidecarPath(); if (existsSync(sidecar)) env.LINKCODE_PTY_SIDECAR_PATH = sidecar; else log.warn(`[linkcode/desktop] pty sidecar missing at ${sidecar}; terminals unavailable`); diff --git a/apps/desktop/src/main/default-picker-directory.ts b/apps/desktop/src/main/default-picker-directory.ts index c8ac14567..24ea549bc 100644 --- a/apps/desktop/src/main/default-picker-directory.ts +++ b/apps/desktop/src/main/default-picker-directory.ts @@ -4,8 +4,9 @@ import { app } from 'electron'; import { DEFAULT_WORKSPACES_DIRNAME } from './constants'; /** - * Default directory the native file/folder picker opens into (a visible `~/LinkCode`, created on - * demand). Unrelated to `WorkspaceRecord` despite the naming overlap — purely a picker default path. + * Default directory the native file/folder picker opens into (this channel's visible workspace + * root, created on demand). Unrelated to `WorkspaceRecord` despite the naming overlap — purely a + * picker default path. */ export async function ensureDefaultPickerDirectory(): Promise { const dir = join(app.getPath('home'), DEFAULT_WORKSPACES_DIRNAME); diff --git a/packages/foundation/common/src/node/index.ts b/packages/foundation/common/src/node/index.ts index fd4b6c390..ded1e7bcd 100644 --- a/packages/foundation/common/src/node/index.ts +++ b/packages/foundation/common/src/node/index.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import { createServer } from 'node:net'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import type { ProductChannel } from '@linkcode/schema/daemon-runtime'; import { daemonRuntimeFileSegments } from '@linkcode/schema/daemon-runtime'; /** @@ -34,8 +35,8 @@ export function isPidAlive(pid: number): boolean { } /** Path of the daemon's runtime discovery file under the user's home directory. */ -export function daemonRuntimeFilePath(profile?: string): string { - return join(homedir(), ...daemonRuntimeFileSegments(profile)); +export function daemonRuntimeFilePath(channel: ProductChannel, profile?: string): string { + return join(homedir(), ...daemonRuntimeFileSegments(channel, profile)); } /** Ask the OS for a free loopback port (bind 0, read, close). Check-then-use — the port can be diff --git a/packages/foundation/schema/src/daemon-runtime.ts b/packages/foundation/schema/src/daemon-runtime.ts index 3a54649e5..83173aa91 100644 --- a/packages/foundation/schema/src/daemon-runtime.ts +++ b/packages/foundation/schema/src/daemon-runtime.ts @@ -1,6 +1,10 @@ /** Zero-dependency half of the daemon discovery contract (see `model/daemon-discovery.ts`); * kept zod-free so the sandboxed Electron preload (no `require('zod')`) can import it. */ -import { STATE_DIR_BASENAME } from './product'; +import type { ProductChannel } from './product'; +import { stateDirBasename } from './product'; + +export type { ProductChannel } from './product'; +export { parseProductChannel } from './product'; /** Default TCP port of the local daemon: 0x4C43 — ascii "LC". */ export const DAEMON_DEFAULT_PORT = 19523; @@ -25,16 +29,22 @@ export function parseProfileName(raw: string | undefined): string | undefined { return raw; } -/** The daemon state directory name under the user's home: `.linkcode`, or a profile sibling. - * Validates its input so no caller can interpolate a traversal or separator — safety lives here. */ -export function linkcodeStateDirName(profile?: string): string { +/** The daemon state directory name under the user's home: the channel's base name, or a profile + * sibling of it. Validates its input so no caller can interpolate a traversal or separator — + * safety lives here. `channel` is required: defaulting it would let a missed call site silently + * land a development build in the release universe, which is the whole bug class this prevents. */ +export function linkcodeStateDirName(channel: ProductChannel, profile?: string): string { const parsed = parseProfileName(profile); - return parsed === undefined ? STATE_DIR_BASENAME : `${STATE_DIR_BASENAME}-${parsed}`; + const base = stateDirBasename(channel); + return parsed === undefined ? base : `${base}-${parsed}`; } /** Runtime discovery file the daemon writes after binding, as path segments under the user's home directory. */ -export function daemonRuntimeFileSegments(profile?: string): readonly [string, string] { - return [linkcodeStateDirName(profile), 'runtime.json']; +export function daemonRuntimeFileSegments( + channel: ProductChannel, + profile?: string, +): readonly [string, string] { + return [linkcodeStateDirName(channel, profile), 'runtime.json']; } /** Exit code of a daemon that stood down because a live daemon already serves this profile (see diff --git a/packages/foundation/schema/src/model/daemon-discovery.ts b/packages/foundation/schema/src/model/daemon-discovery.ts index dd4f7545f..ad4c454cf 100644 --- a/packages/foundation/schema/src/model/daemon-discovery.ts +++ b/packages/foundation/schema/src/model/daemon-discovery.ts @@ -14,10 +14,13 @@ export { daemonRuntimeFileSegments, linkcodeStateDirName, PROFILE_NAME_PATTERN, + type ProductChannel, + parseProductChannel, parseProfileName, } from '../daemon-runtime'; import { PROFILE_NAME_PATTERN } from '../daemon-runtime'; +import { PRODUCT_CHANNELS } from '../product'; /** HTTP path every daemon listener answers with its `DaemonIdentity`. */ export const DAEMON_IDENTITY_PATH = '/linkcode'; @@ -29,6 +32,10 @@ export const DaemonIdentitySchema = z.object({ startedAt: TimestampSchema, /** The daemon's profile; absent means the default profile (pre-profile daemons included). */ profile: z.string().regex(PROFILE_NAME_PATTERN).optional(), + /** The daemon's channel; absent means `release` — every pre-CODE-460 daemon predates the split + * and served the universe that is now release's alone. Together with `profile` this identifies + * the state universe, which is what the port hunt compares to tell a double-start from a neighbor. */ + channel: z.enum(PRODUCT_CHANNELS).optional(), }); export type DaemonIdentity = z.infer; diff --git a/packages/foundation/schema/src/product.ts b/packages/foundation/schema/src/product.ts index 2221b3521..3bec12883 100644 --- a/packages/foundation/schema/src/product.ts +++ b/packages/foundation/schema/src/product.ts @@ -2,14 +2,74 @@ * (docs/FORKING.md). Zero-dependency and zod-free so the sandboxed Electron preload can * import it (same contract as `daemon-runtime.ts`). */ -/** Base name of the per-user state directory under `$HOME` (`~/.linkcode`); profiles use - * the `-` sibling — see `linkcodeStateDirName` in `daemon-runtime.ts`. */ -export const STATE_DIR_BASENAME = '.linkcode'; +/** + * Build lineage, and with it an entire parallel on-disk footprint: `development` is any build + * that is not the released app. Every path below forks on it, so a local build can never share + * a state directory, a port, or a `daemon.db` with an installed release (CODE-460). + */ +export const PRODUCT_CHANNELS = ['release', 'development'] as const; + +export type ProductChannel = (typeof PRODUCT_CHANNELS)[number]; + +/** Absent/empty → `undefined` so the caller's own default applies; an unrecognized value throws, + * because a typo in an injected `LINKCODE_CHANNEL` must abort rather than silently fork state. */ +export function parseProductChannel(raw: string | undefined): ProductChannel | undefined { + if (raw === undefined || raw === '') return undefined; + if (!isProductChannel(raw)) { + throw new TypeError( + `invalid channel ${JSON.stringify(raw)}: expected one of ${PRODUCT_CHANNELS.join(', ')}`, + ); + } + return raw; +} + +function isProductChannel(raw: string): raw is ProductChannel { + return (PRODUCT_CHANNELS as readonly string[]).includes(raw); +} + +/** + * The channel a host process runs in: an injected value (the desktop supervisor's + * `LINKCODE_CHANNEL`) outranks the build-time stamp, and a build with neither is a working copy. + * + * Takes two strings rather than an env object on purpose: the stamp reaches a bundle through + * esbuild's `define`, which only substitutes a literal `process.env.LINKCODE_BUILD_CHANNEL` at the + * call site. Passing `process.env` here would defeat it and every bundle would read as development. + */ +export function resolveProductChannel( + injected: string | undefined, + buildStamp: string | undefined, +): ProductChannel { + return parseProductChannel(injected) ?? parseProductChannel(buildStamp) ?? 'development'; +} + +const BRAND = 'LinkCode'; +const DEVELOPMENT_BRAND = `${BRAND} Development`; +const STATE_BRAND = '.linkcode'; + +/** + * Base name of the per-user state directory under `$HOME` (`~/.linkcode`); profiles use the + * `-` sibling — see `linkcodeStateDirName` in `daemon-runtime.ts`. + * + * The development suffix is dot-separated on purpose: `PROFILE_NAME_PATTERN` forbids dots, so + * `.linkcode.development` can never collide with the sibling `--profile=development` would pick. + */ +export function stateDirBasename(channel: ProductChannel): string { + return channel === 'development' ? `${STATE_BRAND}.development` : STATE_BRAND; +} /** Directory under `$HOME` holding user workspaces and the daemon's chat root (`~/LinkCode`). - * Shared across channels and profiles on purpose. */ -export const WORKSPACES_DIRNAME = 'LinkCode'; + * Forks by channel only — profiles of one channel share their workspaces on purpose. */ +export function workspacesDirName(channel: ProductChannel): string { + return channel === 'development' ? DEVELOPMENT_BRAND : BRAND; +} + +/** Directory name under the platform data dir holding the managed-asset store. Mirrors the + * desktop shell's `APP_NAME`, so a channel's store sits beside that channel's `userData`. */ +export function dataDirName(channel: ProductChannel): string { + return channel === 'development' ? DEVELOPMENT_BRAND : BRAND; +} -/** Directory name under the platform data dir holding the managed-asset store; XDG (linux) - * paths use the lowercase form. */ -export const DATA_DIRNAME = 'LinkCode'; +/** XDG (linux) flavour of {@link dataDirName}: lowercase, and hyphenated instead of spaced. */ +export function xdgDataDirName(channel: ProductChannel): string { + return channel === 'development' ? 'linkcode-development' : 'linkcode'; +} diff --git a/packages/host/agent-adapter/src/native/opencode/history-server.ts b/packages/host/agent-adapter/src/native/opencode/history-server.ts index 43576e101..f94daf0b9 100644 --- a/packages/host/agent-adapter/src/native/opencode/history-server.ts +++ b/packages/host/agent-adapter/src/native/opencode/history-server.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { allocatePort } from '@linkcode/common/node'; import { linkcodeStateDirName } from '@linkcode/schema/daemon-runtime'; +import { resolveProductChannel } from '@linkcode/schema/product'; import crossSpawn from 'cross-spawn'; import { extractErrorMessage } from 'foxts/extract-error-message'; @@ -60,6 +61,17 @@ interface ServerGeneration { ready: Promise; } +/** A neutral directory inside this channel's daemon state dir: opencode indexes its cwd as the + * default workspace, so the server must not start anywhere the user keeps code. The build stamp is + * read as a literal because that is what tsup's `define` substitutes (see `resolveProductChannel`). */ +function defaultNeutralCwd(): string { + const channel = resolveProductChannel( + process.env.LINKCODE_CHANNEL, + process.env.LINKCODE_BUILD_CHANNEL, + ); + return join(homedir(), linkcodeStateDirName(channel), 'opencode-history'); +} + /** Exit is read off the process itself — both fields stay null until it is truly gone, so there * is no shadow flag to drift out of sync with reality. */ function processExited(proc: HistoryServerProcess): boolean { @@ -109,8 +121,7 @@ export class OpencodeHistoryServer implements OpencodeHistoryServerLike { constructor(options: OpencodeHistoryServerOptions = {}) { this.spawnServer = options.spawnServer ?? defaultSpawnServer; this.allocatePort = options.allocatePort ?? allocatePort; - this.neutralCwd = - options.neutralCwd ?? join(homedir(), linkcodeStateDirName(), 'opencode-history'); + this.neutralCwd = options.neutralCwd ?? defaultNeutralCwd(); this.idleMs = options.idleMs ?? 60000; this.readyTimeoutMs = options.readyTimeoutMs ?? 30000; this.shutdownGraceMs = options.shutdownGraceMs ?? 5000; diff --git a/packages/host/assets/src/__tests__/paths.test.ts b/packages/host/assets/src/__tests__/paths.test.ts index fa19b0590..c55a7cd1c 100644 --- a/packages/host/assets/src/__tests__/paths.test.ts +++ b/packages/host/assets/src/__tests__/paths.test.ts @@ -3,25 +3,31 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { assetDir, assetsRootFor, versionDir } from '../paths'; const home = '/home/u'; +const channel = 'release'; describe('assetsRootFor', () => { it('prefers the LINKCODE_ASSETS_DIR override on any platform', () => { expect( - assetsRootFor({ platform: 'darwin', env: { LINKCODE_ASSETS_DIR: '/e2e/assets' }, home }), + assetsRootFor({ + platform: 'darwin', + env: { LINKCODE_ASSETS_DIR: '/e2e/assets' }, + home, + channel, + }), ).toBe('/e2e/assets'); }); it('maps darwin to Application Support', () => { - expect(assetsRootFor({ platform: 'darwin', env: {}, home })).toBe( + expect(assetsRootFor({ platform: 'darwin', env: {}, home, channel })).toBe( '/home/u/Library/Application Support/LinkCode/assets', ); }); it('maps linux to XDG_DATA_HOME when set, else ~/.local/share', () => { - expect(assetsRootFor({ platform: 'linux', env: { XDG_DATA_HOME: '/xdg' }, home })).toBe( - '/xdg/linkcode/assets', - ); - expect(assetsRootFor({ platform: 'linux', env: {}, home })).toBe( + expect( + assetsRootFor({ platform: 'linux', env: { XDG_DATA_HOME: '/xdg' }, home, channel }), + ).toBe('/xdg/linkcode/assets'); + expect(assetsRootFor({ platform: 'linux', env: {}, home, channel })).toBe( '/home/u/.local/share/linkcode/assets', ); }); @@ -32,12 +38,28 @@ describe('assetsRootFor', () => { platform: 'win32', env: { LOCALAPPDATA: String.raw`C:\Users\u\AppData\Local` }, home, + channel, }), ).toBe(String.raw`C:\Users\u\AppData\Local/LinkCode/assets`); - expect(assetsRootFor({ platform: 'win32', env: {}, home })).toBe( + expect(assetsRootFor({ platform: 'win32', env: {}, home, channel })).toBe( '/home/u/AppData/Local/LinkCode/assets', ); }); + + // The store is what a boot GC prunes to the running daemon's version pins, so a shared root + // would let a dev daemon delete an installed release's binaries (CODE-460). + it('forks the development channel onto its own root, per platform', () => { + const development = 'development'; + expect(assetsRootFor({ platform: 'darwin', env: {}, home, channel: development })).toBe( + '/home/u/Library/Application Support/LinkCode Development/assets', + ); + expect(assetsRootFor({ platform: 'win32', env: {}, home, channel: development })).toBe( + '/home/u/AppData/Local/LinkCode Development/assets', + ); + expect(assetsRootFor({ platform: 'linux', env: {}, home, channel: development })).toBe( + '/home/u/.local/share/linkcode-development/assets', + ); + }); }); describe('store layout', () => { diff --git a/packages/host/assets/src/paths.ts b/packages/host/assets/src/paths.ts index 75b069d75..56a7027d3 100644 --- a/packages/host/assets/src/paths.ts +++ b/packages/host/assets/src/paths.ts @@ -3,8 +3,8 @@ import { mkdirSync, mkdtempSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; -import type { ManagedAssetId } from '@linkcode/schema'; -import { DATA_DIRNAME } from '@linkcode/schema/product'; +import type { ManagedAssetId, ProductChannel } from '@linkcode/schema'; +import { dataDirName, resolveProductChannel, xdgDataDirName } from '@linkcode/schema/product'; /** * Store layout `////…`: the id's `:` becomes a directory level @@ -17,33 +17,42 @@ interface RootContext { platform: typeof process.platform; env: Record; home: string; + /** Forks the store per channel (CODE-460), so a dev daemon's version pins can never GC an + * installed release's binaries out from under it. */ + channel: ProductChannel; } /** Pure core of {@link assetsRoot}, parameterized for tests. */ export function assetsRootFor(ctx: RootContext): string { const override = ctx.env.LINKCODE_ASSETS_DIR; if (override) return override; + const dirname = dataDirName(ctx.channel); switch (ctx.platform) { case 'darwin': - return join(ctx.home, 'Library', 'Application Support', DATA_DIRNAME, 'assets'); + return join(ctx.home, 'Library', 'Application Support', dirname, 'assets'); case 'win32': - return join( - ctx.env.LOCALAPPDATA ?? join(ctx.home, 'AppData', 'Local'), - DATA_DIRNAME, - 'assets', - ); + return join(ctx.env.LOCALAPPDATA ?? join(ctx.home, 'AppData', 'Local'), dirname, 'assets'); default: return join( ctx.env.XDG_DATA_HOME ?? join(ctx.home, '.local', 'share'), - DATA_DIRNAME.toLowerCase(), + xdgDataDirName(ctx.channel), 'assets', ); } } -/** The per-user store root: `LINKCODE_ASSETS_DIR` wins, else the platform data directory. */ +/** The per-user store root: `LINKCODE_ASSETS_DIR` wins, else the channel's platform data directory. */ export function assetsRoot(): string { - return assetsRootFor({ platform: process.platform, env: process.env, home: homedir() }); + return assetsRootFor({ + platform: process.platform, + env: process.env, + home: homedir(), + channel: resolveProductChannel( + process.env.LINKCODE_CHANNEL, + // Literal on purpose — tsup's `define` substitutes it when this is bundled into the daemon. + process.env.LINKCODE_BUILD_CHANNEL, + ), + }); } export function assetDir(id: ManagedAssetId): string { From 1ae3f27c1ac978c78fba67046ffb326fcf1b26d1 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 28 Jul 2026 00:58:29 +0800 Subject: [PATCH 2/4] test(e2e): align the harness daemons with the shell's channel The dist bundle is stamped release while a dev Electron shell resolves as development, so a harness that spawns one without LINKCODE_CHANNEL leaves the two in different state dirs and the shell never finds runtime.json. --- apps/daemon/e2e/startup.e2e.ts | 2 ++ apps/desktop/e2e/file-tree.e2e.mts | 9 ++++++++- apps/desktop/e2e/notifications.e2e.mts | 9 ++++++++- apps/desktop/e2e/packaged-smoke.e2e.mts | 5 ++++- apps/desktop/e2e/simulator-live.mts | 3 +++ apps/desktop/e2e/simulator-panel.e2e.mts | 3 +++ 6 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/daemon/e2e/startup.e2e.ts b/apps/daemon/e2e/startup.e2e.ts index aec919472..a843129d0 100644 --- a/apps/daemon/e2e/startup.e2e.ts +++ b/apps/daemon/e2e/startup.e2e.ts @@ -66,6 +66,8 @@ async function main(): Promise { let client: LinkCodeClient | null = null; try { + // Plain `.linkcode`, not the development sibling: this drives the tsup bundle, which is + // stamped `release` at build time (CODE-460). Running the TS source would land elsewhere. const runtimePath = join(home, '.linkcode', 'runtime.json'); const runtime = await waitFor( () => { diff --git a/apps/desktop/e2e/file-tree.e2e.mts b/apps/desktop/e2e/file-tree.e2e.mts index 569b1df0d..2a9a86b24 100644 --- a/apps/desktop/e2e/file-tree.e2e.mts +++ b/apps/desktop/e2e/file-tree.e2e.mts @@ -241,7 +241,14 @@ async function main(): Promise { try { daemon = spawn(process.execPath, ['dist/index.js'], { cwd: daemonDir, - env: { ...process.env, HOME: home, LINKCODE_PORT: String(PORT) }, + // The dist bundle is stamped `release`, the dev Electron shell resolves as `development` — + // without this they pick different state dirs and the shell never finds runtime.json. + env: { + ...process.env, + HOME: home, + LINKCODE_PORT: String(PORT), + LINKCODE_CHANNEL: 'development', + }, stdio: 'ignore', }); await waitForDaemon(); diff --git a/apps/desktop/e2e/notifications.e2e.mts b/apps/desktop/e2e/notifications.e2e.mts index 892f388a7..958541bc6 100644 --- a/apps/desktop/e2e/notifications.e2e.mts +++ b/apps/desktop/e2e/notifications.e2e.mts @@ -131,7 +131,14 @@ async function main(): Promise { try { daemon = spawn(process.execPath, ['dist/index.js'], { cwd: daemonDir, - env: { ...process.env, HOME: home, LINKCODE_PORT: String(PORT) }, + // The dist bundle is stamped `release`, the dev Electron shell resolves as `development` — + // without this they pick different state dirs and the shell never finds runtime.json. + env: { + ...process.env, + HOME: home, + LINKCODE_PORT: String(PORT), + LINKCODE_CHANNEL: 'development', + }, stdio: 'ignore', }); await waitForDaemon(); diff --git a/apps/desktop/e2e/packaged-smoke.e2e.mts b/apps/desktop/e2e/packaged-smoke.e2e.mts index fee66b8dc..3807cc92f 100644 --- a/apps/desktop/e2e/packaged-smoke.e2e.mts +++ b/apps/desktop/e2e/packaged-smoke.e2e.mts @@ -43,7 +43,10 @@ async function main(): Promise { const home = join(root, 'home'); const config = join(root, 'config'); const profile = `packaged-smoke-${process.pid}`; - const stateDir = join(home, `.linkcode-${profile}`); + // The devshell pack is the development channel, and its supervisor injects that into the daemon + // it spawns — so the state dir is the development sibling, profile-suffixed (CODE-460). Asserting + // the plain `.linkcode-` here would prove the channel injection had been lost. + const stateDir = join(home, `.linkcode.development-${profile}`); const runtimePath = join(stateDir, 'runtime.json'); mkdirSync(home); mkdirSync(config); diff --git a/apps/desktop/e2e/simulator-live.mts b/apps/desktop/e2e/simulator-live.mts index 1bfac3599..e7a61a59b 100644 --- a/apps/desktop/e2e/simulator-live.mts +++ b/apps/desktop/e2e/simulator-live.mts @@ -113,6 +113,9 @@ async function main(): Promise { HOME: home, LINKCODE_PORT: String(PORT), LINKCODE_PROFILE: profile, + // The dist bundle is stamped `release`, the dev Electron shell resolves as `development` — + // without this they pick different state dirs and the shell never finds runtime.json. + LINKCODE_CHANNEL: 'development', LINKCODE_SIM_SIDECAR_PATH: simSidecar, }, stdio: 'ignore', diff --git a/apps/desktop/e2e/simulator-panel.e2e.mts b/apps/desktop/e2e/simulator-panel.e2e.mts index 12d5bd163..317515acc 100644 --- a/apps/desktop/e2e/simulator-panel.e2e.mts +++ b/apps/desktop/e2e/simulator-panel.e2e.mts @@ -695,6 +695,9 @@ async function main(): Promise { HOME: home, LINKCODE_PORT: String(PORT), LINKCODE_PROFILE: profile, + // The dist bundle is stamped `release`, the dev Electron shell resolves as `development` — + // without this they pick different state dirs and the shell never finds runtime.json. + LINKCODE_CHANNEL: 'development', LINKCODE_SIM_SIDECAR_PATH: simSidecar, }, stdio: 'ignore', From bcc47d9149aa4378624002dca585fb35ac9d3729 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 28 Jul 2026 00:59:18 +0800 Subject: [PATCH 3/4] docs(devenv): document the channel split and drop the dev profile The devenv scripts no longer pin LINKCODE_PROFILE=dev: the development channel is its own universe now, so the profile axis goes back to meaning a second universe within one channel. --- apps/daemon/AGENTS.md | 25 +++++++++++++++++-------- apps/desktop/AGENTS.md | 2 +- devenv.nix | 9 ++++++--- docs/DEVELOPMENT.md | 22 +++++++++++++++++++--- docs/ENVIRONMENT.md | 8 +++++--- 5 files changed, 48 insertions(+), 18 deletions(-) diff --git a/apps/daemon/AGENTS.md b/apps/daemon/AGENTS.md index 0fdb34629..479422022 100644 --- a/apps/daemon/AGENTS.md +++ b/apps/daemon/AGENTS.md @@ -6,10 +6,18 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr ## State surfaces — all under the profile's state dir -- Default state dir is `~/.linkcode/`; `LINKCODE_PROFILE=` (`[a-z0-9-]`, ≤32 chars, invalid - aborts boot) forks the whole universe to the sibling `~/.linkcode-/` — including `hq.json` / - `device-key.pem`, so each profile registers as its own HQ device (deliberate: the relay allows one - uplink per device id). `~/LinkCode` workspaces and the managed asset store stay shared. +- The state dir is picked by **channel × profile** (CODE-460). Channel comes from + `daemonChannel()` (`src/paths.ts`): injected `LINKCODE_CHANNEL` → tsup's build-time + `LINKCODE_BUILD_CHANNEL` stamp (`release`) → `development`. So `~/.linkcode/` is the released + app's alone and running the source lands in `~/.linkcode.development/`; the suffix is + dot-separated because profile names forbid dots, making collision with `--profile=development` + impossible. `LINKCODE_PROFILE=` (`[a-z0-9-]`, ≤32 chars, invalid aborts boot) then forks + the sibling `~/.linkcode[.development]-/` — including `hq.json` / `device-key.pem`, so each + universe registers as its own HQ device (deliberate: the relay allows one uplink per device id). + Workspaces (`~/LinkCode` vs `~/LinkCode Development`) and the managed asset store fork by channel + but are shared across a channel's profiles. **Resolve the channel per call, never at module + load** — `instrument.ts` derives a state path in its module body, and `--import` runs it before + `index.ts` (the CODE-166 bug class). - **Paths are owned by `src/config.ts`** (`configPath` / `databasePath` / `runtimeFilePath`) — never scatter `homedir()` joins elsewhere. `os.homedir()` is read at call time, so a fake `$HOME` fully redirects config/db/runtime (this is what isolates an E2E daemon). @@ -29,13 +37,14 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr in `packages/foundation/schema`). `LINKCODE_PORT` / `LINKCODE_HOST` override every listener. On `EADDRINUSE` a listener hunts **upward** up to 10 ports (19523–19532); clients must read `runtime.json`, never assume 19523. -- **One daemon per profile**, enforced by the daemon (not the desktop supervisor): `main()` calls +- **One daemon per universe (channel × profile)**, enforced by the daemon (not the desktop supervisor): `main()` calls `findRunningDaemon()` — parse the profile's `runtime.json` → pid alive? → `GET /linkcode` identity pid matches? A live one makes the new process log `already running (pid N)` and `process.exit(3)` (`DAEMON_EXIT_ALREADY_RUNNING`) — an **explicit** exit because Electron's `utilityProcess` keeps the - parent IPC channel and the event loop alive forever. The identity (and `runtime.json`) carries an - optional `profile` field (absent = default profile): the port hunt treats a live daemon of - **another** profile as a port neighbor and hunts past it, so profiles coexist on adjacent ports. + parent IPC channel and the event loop alive forever. The identity (and `runtime.json`) carries + optional `profile` and `channel` fields (absent = default profile / release, which is what every + pre-split daemon is): the port hunt treats a live daemon of **another** universe as a port + neighbor and hunts past it, so a dev daemon starts fine on 19524 while a release holds 19523. Health: `curl http://127.0.0.1:19523/linkcode`. ## Packaging: spawn, binaries & bundle diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index a5396e3ff..9f344e71a 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -50,4 +50,4 @@ Both fail only in packaged builds, produce no error, and are invisible to typech ## Identity isolation (channel × profile) -- **A local/unpackaged build must be isolated from an installed release on four axes** — app name, `userData` dir, single-instance lock, and OS keychain (safeStorage). The identity is `CHANNEL` (`release`/`development` → `LinkCode` vs `LinkCode Development`) × optional `PROFILE` (`--profile`/`LINKCODE_PROFILE`, suffixing ` ()`), both in `src/main/constants.ts`; a production bundle run by the dev Electron binary is still the `development` channel. `src/main/identity.ts` applies the identity and MUST stay the first import of `index.ts` — any module body that runs before it captures paths in the wrong universe (the CODE-166 bug class). Skipping any axis clobbers release settings, steals its lock, or poisons its keychain entry; channels and the default profile share `~/.linkcode` and `~/LinkCode` on purpose, and the supervisor injects the resolved `LINKCODE_PROFILE` into the daemon it spawns. Full consequences, procedure, and the keychain cleanup command → [`docs/DEVELOPMENT.md`](../../docs/DEVELOPMENT.md). +- **A local/unpackaged build must be isolated from an installed release on four axes** — app name, `userData` dir, single-instance lock, and OS keychain (safeStorage). The identity is `CHANNEL` (`release`/`development` → `LinkCode` vs `LinkCode Development`) × optional `PROFILE` (`--profile`/`LINKCODE_PROFILE`, suffixing ` ()`), both in `src/main/constants.ts`; a production bundle run by the dev Electron binary is still the `development` channel. `src/main/identity.ts` applies the identity and MUST stay the first import of `index.ts` — any module body that runs before it captures paths in the wrong universe (the CODE-166 bug class). Skipping any axis clobbers release settings, steals its lock, or poisons its keychain entry. Since CODE-460 the daemon-side state (`~/.linkcode` vs `~/.linkcode.development`), the workspace root, and the managed asset store fork by channel too, so the supervisor injects **both** `LINKCODE_PROFILE` and `LINKCODE_CHANNEL` into the daemon it spawns — the channel injection is load-bearing for the devshell pack, whose bundled daemon is stamped `release` at build time. Discovery must read `runtime.json` through the same pair (`daemonRuntimeFilePath(CHANNEL, PROFILE)`) or the shell dials the other channel's daemon. Full consequences, the per-channel path table, and the keychain cleanup command → [`docs/DEVELOPMENT.md`](../../docs/DEVELOPMENT.md). diff --git a/devenv.nix b/devenv.nix index 9d2238b39..5600a957d 100644 --- a/devenv.nix +++ b/devenv.nix @@ -72,8 +72,11 @@ fi ''; - scripts.daemon.exec = "pnpm run --filter @linkcode/daemon build:rust && LINKCODE_PROFILE=dev pnpm run --filter @linkcode/daemon dev"; - scripts.desktop.exec = "LINKCODE_PROFILE=dev pnpm run --filter @linkcode/desktop dev"; + # No LINKCODE_PROFILE here: the development channel now owns its own state dir, workspaces, and + # asset store (CODE-460), so a local run can never contend with an installed release. Pass + # --profile / LINKCODE_PROFILE yourself only to fork a second universe within this channel. + scripts.daemon.exec = "pnpm run --filter @linkcode/daemon build:rust && pnpm run --filter @linkcode/daemon dev"; + scripts.desktop.exec = "pnpm run --filter @linkcode/desktop dev"; scripts.mobile.exec = "pnpm run --filter @linkcode/mobile ios"; - scripts.app.exec = "pnpm run --filter @linkcode/daemon build:rust && LINKCODE_PROFILE=dev pnpm --filter @linkcode/daemon --filter @linkcode/desktop --parallel dev"; + scripts.app.exec = "pnpm run --filter @linkcode/daemon build:rust && pnpm --filter @linkcode/daemon --filter @linkcode/desktop --parallel dev"; } diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d3f9ed4b3..6619f7de5 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -319,10 +319,12 @@ git apply --3way "$(ls -t .devenv/state/prek/patches/*.patch | head -1)" ## Identity: channel × profile isolation -The desktop identity is two orthogonal axes (`apps/desktop/src/main/constants.ts`), and app name, `userData` dir, single-instance lock, and OS keychain (safeStorage) all derive from them; `src/main/identity.ts` applies the identity as main's **first import**, and boot logs a `userData: ` line as self-evidence. +The identity is two orthogonal axes (`apps/desktop/src/main/constants.ts`), and the desktop's app name, `userData` dir, single-instance lock, and OS keychain (safeStorage) all derive from them; `src/main/identity.ts` applies the identity as main's **first import**, and boot logs a `userData: ` line as self-evidence. Since CODE-460 the **daemon's** state follows the same two axes, so a local build and an installed release share nothing at all. - **channel** — `CHANNEL === 'development'` for any build that is not the released app: `MODE !== 'production' || !app.isPackaged` (a production bundle run by the dev Electron binary is still a dev shell). `APP_NAME` is `'LinkCode Development'` for dev, `'LinkCode'` for release. Skipping any isolation axis clobbers release settings, steals its instance lock (the second instance exits 0 silently), or writes a safeStorage key under the dev binary's code signature — after which the release app prompts for the keychain password on first launch (macOS keychain ACLs pin the creator cdhash). -- **profile** — an optional isolated universe: `--profile=` (or `LINKCODE_PROFILE`; `[a-z0-9-]`, ≤32 chars, invalid aborts boot). It suffixes the app name (`LinkCode Development (alpha)`) — forking the same four axes again — and is injected as `LINKCODE_PROFILE` into the supervised daemon, which forks its state dir to `~/.linkcode-` and its HQ device identity with it. Two profiles therefore run side by side: daemons hunt past each other's ports, and each desktop follows its own `runtime.json`. The devenv `daemon`/`desktop`/`app` scripts set `LINKCODE_PROFILE=dev` on their dev commands, so the daemon and the desktop share the profile and agree on its `runtime.json`; for another profile, invoke `pnpm -F … dev` directly with the value you want. +- **profile** — an optional isolated universe *within* a channel: `--profile=` (or `LINKCODE_PROFILE`; `[a-z0-9-]`, ≤32 chars, invalid aborts boot). It suffixes the app name (`LinkCode Development (alpha)`) — forking the same four axes again — and is injected as `LINKCODE_PROFILE` into the supervised daemon, which forks its state dir and HQ device identity with it. Profiles run side by side: daemons hunt past each other's ports, and each desktop follows its own `runtime.json`. The devenv `daemon`/`desktop`/`app` scripts pass **no** profile — the development channel is already its own universe; pass one yourself only to fork a second universe within that channel. + +The daemon is a separate process and cannot see `app.isPackaged`, so it resolves its own channel (`apps/daemon/src/paths.ts`): the desktop supervisor's injected `LINKCODE_CHANNEL` wins, else the build-time stamp tsup bakes in (`process.env.LINKCODE_BUILD_CHANNEL` → `release`), else `development`. Running the TS source is therefore a development daemon with nothing to remember, while a packaged one is release — and the devshell pack, which ships a `release`-stamped bundle inside a development shell, is corrected by the injection. Resolution is per call, never cached at module load: `instrument.ts` derives a state path in its module body and `--import` runs it before `index.ts`. Clean a polluted machine (also after the `LinkCode Dev` → `LinkCode Development` rename, which orphaned the old dir and keychain entry by design — a rename migration would carry ciphertext the new keychain entry cannot decrypt): @@ -332,7 +334,21 @@ security delete-generic-password -s "LinkCode Dev Safe Storage" # pre-rename l rm -rf "$HOME/Library/Application Support/LinkCode Dev" # pre-rename leftover ``` -Every channel and the default profile deliberately **share** `~/.linkcode` (daemon) and `~/LinkCode` (workspaces); only an explicit `--profile` forks the daemon state, and `~/LinkCode` plus the managed asset store stay shared even then. This sharing is why the devenv dev scripts default to the `dev` profile: a dev daemon on the default profile would contend for `~/.linkcode`/`19523` with an installed release, and whichever binds first wins — the loser's client then dials a peer on a different `WIRE_PROTOCOL_VERSION`, every frame is silently dropped, and it surfaces as "Unable to connect to the daemon". `package:devshell` uses `electron-builder.devshell.yml`; release packaging is CI-only (the old `dist` script was removed). +### What each channel owns on disk + +Names come from `packages/foundation/schema/src/product.ts` — the one file a fork edits to rename its footprint. + +| | release | development | +| --- | --- | --- | +| daemon state (`config.json`, `daemon.db`, `runtime.json`, `hq.json`, `device-key.pem`) | `~/.linkcode` | `~/.linkcode.development` | +| workspaces + daemon chat root | `~/LinkCode` | `~/LinkCode Development` | +| managed asset store | `…/Application Support/LinkCode/assets` | `…/Application Support/LinkCode Development/assets` | + +A profile appends `-` to the **state** directory only (`~/.linkcode.development-alpha`); workspaces and the asset store fork by channel alone. The development suffix is dot-separated on purpose: profile names forbid dots, so `--profile=development` can never reach the development channel's directory. + +Two things stay shared across channels by design: the agent CLIs' own homes (`~/.claude`, `~/.codex` — separating them would force a second agent login and cut you off from the CLI you use in a terminal), and the `linkcode://` scheme's OS-global nature, which is why the dev shell claims `linkcode-dev://` instead (CODE-182). + +Before CODE-460 the daemon state and workspaces were shared, and a dev daemon on the default profile contended for `~/.linkcode`/`19523` with an installed release: whichever bound first won, the loser's client dialed a peer on a different `WIRE_PROTOCOL_VERSION`, every frame was silently dropped, and it surfaced only as "Unable to connect to the daemon". Both daemons now coexist — the port hunt reads the occupant's `channel` off `GET /linkcode` and treats another channel as a neighbor, so the second one lands on 19524. `package:devshell` uses `electron-builder.devshell.yml`; release packaging is CI-only (the old `dist` script was removed). ## Formatting and linting diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 9982c0be2..7f0f1ab54 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -15,7 +15,9 @@ Read by the daemon, desktop, webview, or mobile at run time. | Variable | Read at | Effect | | --- | --- | --- | -| `LINKCODE_PROFILE` | `apps/daemon/src/config.ts` | Isolated state universe: forks the daemon state dir to `~/.linkcode-`, plus DB, `runtime.json`, and HQ device identity. `[a-z0-9-]`, ≤32 chars; invalid aborts boot. Desktop reads it too, where `--profile=` outranks it, and re-injects the resolved value into the supervised daemon. Unset = the shared `~/.linkcode`. | +| `LINKCODE_CHANNEL` | `apps/daemon/src/paths.ts` | Picks the daemon's on-disk universe: `release` → `~/.linkcode` + `~/LinkCode` + `…/LinkCode/assets`; `development` → the `LinkCode Development` / `.linkcode.development` set (CODE-460). Outranks the build-time stamp, which is why the desktop supervisor always injects its own `CHANNEL` — the devshell pack's bundled daemon is stamped `release`. Any other value aborts boot. | +| `LINKCODE_BUILD_CHANNEL` | `apps/daemon/tsup.config.ts` (`define`) | **Build-time stamp, not a runtime knob.** tsup replaces the literal with `release`, so a built daemon defaults to release and the TS source defaults to development. Setting it by hand in a shell works but is not the supported override — use `LINKCODE_CHANNEL`. | +| `LINKCODE_PROFILE` | `apps/daemon/src/config.ts` | Isolated state universe *within a channel*: forks the state dir to the `-` sibling (`~/.linkcode.development-alpha`), plus DB, `runtime.json`, and HQ device identity. `[a-z0-9-]`, ≤32 chars; invalid aborts boot. Workspaces and the asset store do not fork by profile. Desktop reads it too, where `--profile=` outranks it, and re-injects the resolved value into the supervised daemon. Unset = the channel's default universe. | | `LINKCODE_PORT` | `apps/daemon/src/config.ts` | Overrides every configured listener's port. Must parse as an integer in `1..65535`, otherwise the config value stands. | | `LINKCODE_HOST` | `apps/daemon/src/config.ts` | Overrides every listener's bind host. | | `LINKCODE_PTY_SIDECAR_PATH` | `apps/daemon/src/pty/sidecar.ts` | Absolute path to the `linkcode-pty` binary; always wins. Dev falls back to `target/release/linkcode-pty`; a bundled `dist/` daemon has no fallback and disables terminals. The packaged desktop supervisor sets it to `/sidecar/`. | @@ -81,8 +83,8 @@ client configuration or new build. | `LINKCODE_REQUIRE_PTY_SIDECAR` | `apps/daemon/tests/integration/pty-sidecar.test.ts`, `terminal-flood.test.ts` | `1` turns a missing `linkcode-pty` binary from a silent skip into a hard failure. CI sets it; set it locally too when you mean to exercise the real wire protocol. | | `LINKCODE_PTY_SIDECAR_PATH` | `apps/daemon/e2e/startup.e2e.ts` | Points the spawned daemon at the compiled sidecar (CI uses `target/debug/linkcode-pty`). | | `LINKCODE_HOST`, `LINKCODE_PORT` | daemon/webview/desktop E2E harnesses | Pin the harness daemon to `127.0.0.1` on an ephemeral port. | -| `LINKCODE_PROFILE` | desktop E2E | Isolates a run's state universe. Must be identical on both sides — the desktop app and its daemon — or they follow different `runtime.json` files. | -| `HOME` | every E2E harness | Redirected to a fresh temp dir so runs never touch the real `~/.linkcode`. Use a *fresh* one per run. | +| `LINKCODE_PROFILE` | desktop E2E | Isolates a run's state universe. Must be identical on both sides — the desktop app and its daemon — or they follow different `runtime.json` files. The same applies to `LINKCODE_CHANNEL` when a harness sets it. | +| `HOME` | every E2E harness | Redirected to a fresh temp dir so runs never touch the real state dirs. Use a *fresh* one per run. | | `LINKCODE_E2E_KEEP_OPEN` | `apps/desktop/e2e/simulator-panel.e2e.mts` | `1` hands the app over at the pause and waits for you to close the window instead of running a timer. The two section-close checks after the pause are given up in exchange — the alternative is yanking the window away from whoever is driving it. | | `LINKCODE_E2E_HOLD_MS` | `apps/desktop/e2e/simulator-panel.e2e.mts` | Widens the pause that leaves the window live to be driven by hand (default `30000`). For demoing the simulator panel rather than checking it. | | `LINKCODE_E2E_SKIP_RECLAIM` | `apps/desktop/e2e/simulator-panel.e2e.mts` | `1` drops the closing CODE-419 reclaim check, whose last act is to SIGTERM the daemon — correct in a test, looks like a crash in a demo. The run then proves everything *except* reclaim-on-shutdown. | From 8df5a657b802b993a441d3e78c86f1ddc9536217 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 28 Jul 2026 00:59:44 +0800 Subject: [PATCH 4/4] test(daemon): cover the channel axis of the port hunt Both channels' default profile is undefined, so without comparing channel a dev daemon reads a release one as a double-start and exits 3 instead of hunting to the next port. An absent field still means release. --- apps/daemon/tests/integration/runtime.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/daemon/tests/integration/runtime.test.ts b/apps/daemon/tests/integration/runtime.test.ts index 2fcaa047b..371dd8c72 100644 --- a/apps/daemon/tests/integration/runtime.test.ts +++ b/apps/daemon/tests/integration/runtime.test.ts @@ -163,6 +163,40 @@ describe('listenWithPortHunt', () => { ), ).rejects.toBeInstanceOf(DaemonAlreadyRunningError); }); + + // CODE-460: this is what lets a dev daemon start while an installed release holds 19523. + // Both default profiles are `undefined`, so without the channel comparison the dev daemon + // reads the release one as a double-start and exits 3. + it('hunts past a live daemon of another channel', async () => { + const port = await serveIdentity(identity(4242)); + const { server, url } = await listenWithPortHunt( + { type: 'ws', port, host: '127.0.0.1' }, + { ...identity(process.pid), channel: 'development' }, + ); + expect(url).toBe(`ws://127.0.0.1:${port + 1}`); + await server.close(); + }); + + it('refuses to hunt past a live daemon of the same channel', async () => { + const port = await serveIdentity({ ...identity(4242), channel: 'development' }); + await expect( + listenWithPortHunt( + { type: 'ws', port, host: '127.0.0.1' }, + { ...identity(process.pid), channel: 'development' }, + ), + ).rejects.toBeInstanceOf(DaemonAlreadyRunningError); + }); + + // An absent channel is release's, so a pre-CODE-460 daemon still reads as a double-start. + it('treats an occupant with no channel field as release', async () => { + const port = await serveIdentity(identity(4242)); + await expect( + listenWithPortHunt( + { type: 'ws', port, host: '127.0.0.1' }, + { ...identity(process.pid), channel: 'release' }, + ), + ).rejects.toBeInstanceOf(DaemonAlreadyRunningError); + }); }); describe('findRunningDaemon', () => {