From 08bd18c35f15512dd8f99b64c073186daea27a3b Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:41:29 +0200 Subject: [PATCH] fix(rpc): sweep stale RPC state and name its directories The RPC state tree only ever shrank as a side effect: a dead-pid port file was removed when a TUI happened to discover a server in that same project directory, and nothing removed the directories themselves. On this machine that left 14 project directories, 4 of them empty. The server now sweeps at startup, dropping dead-pid port files and removing directories left empty. It never touches a live pid, never removes the directory it is about to use, and a sweep failure is logged rather than propagated - collecting old state must not stop a session from starting. Writing a port file survives a concurrent sweep. Another project's process can legitimately rmdir this directory in the window after mkdir returns: it is empty, and it is not that process's active directory, so both of the sweep's guards pass. The write then failed ENOENT, the server never started, and the loader swallowed it - leaving a session whose modals all silently do nothing, which is the failure this change set out to remove. Create, write and rename are retried once as a unit, since the directory can vanish before any of them. Unparseable and malformed port files are collected too. A parse error was previously swallowed by both the sweep and discovery, so a corrupt file could be neither used nor removed, and a file containing the literal null parsed successfully then threw on the pid lookup, aborting the sweep for every project under the shared root. Reading, parsing, shape-checking and the liveness test are now separate stages with narrow handling, so malformed data cannot abort the tree while a genuine programming error stays visible. A file must carry both a finite pid and a finite port to be considered usable, matching what discovery already required - a pid-only file was previously unusable and uncollectable. Unlinking a corrupt file is safe because writePortFile writes to a .tmp name and renames, and only port-*.json is considered, so a parse failure means genuinely corrupt rather than mid-write. Discovery stays silent about them, since it runs on the TUI polling path where a per-poll line would be noise. Directories are named openai-auth- so an operator can see at a glance which state belongs to this plugin, and are created 0700. The port files inside were already 0600, but a world-readable directory still discloses which projects exist. Only directories matching the plugin's own naming are swept or chmod-ed; a configured override path may belong to something else and is left alone. Resolution deliberately does not consult liveness. An earlier revision kept using a legacy directory while it still held a live server, which reads as a safe migration but makes the answer depend on WHEN it is asked: a server could resolve to the legacy path, the old process could exit, and the TUI would then resolve to the new one and never find it. Both halves derive the path from the project directory alone, so any two resolutions agree regardless of what exited in between. Plugin changes are restart-gated, so a session cannot be running this resolver against an older build's server anyway. Legacy directories are still swept once nothing live remains in them. --- packages/opencode/src/index.ts | 7 +- packages/opencode/src/rpc/port-file.ts | 123 ++++++++++- packages/opencode/src/rpc/rpc-dir.ts | 38 +++- packages/opencode/src/rpc/rpc-server.ts | 20 +- packages/opencode/src/tests/rpc-dir.test.ts | 91 +++++++- .../opencode/src/tests/rpc-port-file.test.ts | 201 +++++++++++++++++- .../opencode/src/tests/rpc-server.test.ts | 99 ++++++++- packages/opencode/src/tui.tsx | 21 +- 8 files changed, 559 insertions(+), 41 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index bf5e249..b6671e5 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -94,7 +94,7 @@ import type { ApplyResult, CommandModalName, } from './rpc/protocol' -import { getRpcDir } from './rpc/rpc-dir' +import { resolveRpcDir } from './rpc/rpc-dir' import { type RpcServerHandle, startRpcServer } from './rpc/rpc-server' import { type AccountQuota, @@ -1442,6 +1442,7 @@ export async function CodexAuthPlugin( let rpcServer: RpcServerHandle | null = null if (input.directory) { + const rpcDir = await resolveRpcDir(input.directory) const rpcGlobal = globalThis as { __openaiAuthRpcServer?: RpcServerHandle } @@ -1451,7 +1452,9 @@ export async function CodexAuthPlugin( } try { rpcServer = await startRpcServer({ - dir: getRpcDir(input.directory), + dir: rpcDir.dir, + secureDir: rpcDir.secureDir, + sweepRoot: rpcDir.sweepRoot, drain: drainNotifications, apply: async (request: ApplyRequest): Promise => { const payload = await buildDialogPayload( diff --git a/packages/opencode/src/rpc/port-file.ts b/packages/opencode/src/rpc/port-file.ts index 5abcc81..1a8aadd 100644 --- a/packages/opencode/src/rpc/port-file.ts +++ b/packages/opencode/src/rpc/port-file.ts @@ -1,12 +1,18 @@ +import type { Dirent } from 'node:fs' import { + chmod, mkdir, readdir, readFile, rename, + rmdir, unlink, writeFile, } from 'node:fs/promises' -import { join } from 'node:path' +import { join, resolve } from 'node:path' +import { createLogger } from '../logger' + +const log = createLogger('rpc') export interface PortFileEntry { port: number @@ -24,17 +30,118 @@ function pidAlive(pid: number): boolean { } } +function isManagedRpcStateDir(name: string): boolean { + return /^(?:openai-auth-)?[0-9a-f]{16}$/.test(name) +} + +function isUsablePortFileEntry(value: unknown): value is PortFileEntry { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + typeof (value as { pid?: unknown }).pid === 'number' && + Number.isFinite((value as { pid: number }).pid) && + typeof (value as { port?: unknown }).port === 'number' && + Number.isFinite((value as { port: number }).port) + ) +} + +async function removeCorruptPortFile(portFile: string): Promise { + log.debug('rpc corrupt port file', { pid: process.pid, portFile }) + await unlink(portFile).catch(() => {}) +} + export async function writePortFile( dir: string, entry: { port: number; token: string; pid: number }, + options: { + secureDir?: boolean + beforeWrite?: () => void | Promise + } = {}, ): Promise { - await mkdir(dir, { recursive: true }) - const full: PortFileEntry = { ...entry, startedAt: Date.now() } - const target = join(dir, `port-${entry.pid}.json`) - const tmp = `${target}.${process.pid}.tmp` - await writeFile(tmp, JSON.stringify(full), { encoding: 'utf8', mode: 0o600 }) - await rename(tmp, target) - return target + // The directory can be removed by another project's sweep between our + // mkdir and the first writeFile/rename, so the whole create-then-rename + // unit is retried once on ENOENT. The retry recreates the directory; a + // persistent ENOENT (e.g. permission, read-only parent) will surface on + // the second attempt — failing fast beats an unbounded loop. + const writeOnce = async (): Promise => { + await mkdir(dir, { + recursive: true, + mode: options.secureDir ? 0o700 : undefined, + }) + if (options.secureDir) await chmod(dir, 0o700) + await options.beforeWrite?.() + const full: PortFileEntry = { ...entry, startedAt: Date.now() } + const target = join(dir, `port-${entry.pid}.json`) + const tmp = `${target}.${process.pid}.tmp` + await writeFile(tmp, JSON.stringify(full), { + encoding: 'utf8', + mode: 0o600, + }) + await rename(tmp, target) + return target + } + try { + return await writeOnce() + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return await writeOnce() + } + throw error + } +} + +export async function sweepRpcState( + root: string, + activeDir: string, +): Promise { + let projectDirs: Dirent[] + try { + projectDirs = await readdir(root, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + + const active = resolve(activeDir) + for (const projectDir of projectDirs) { + if (!projectDir.isDirectory() || !isManagedRpcStateDir(projectDir.name)) { + continue + } + const dir = join(root, projectDir.name) + let names: string[] + try { + names = await readdir(dir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } + for (const name of names) { + if (!name.startsWith('port-') || !name.endsWith('.json')) continue + const portFile = join(dir, name) + let raw: string | undefined + try { + raw = await readFile(portFile, 'utf8') + } catch { + continue + } + if (raw === undefined) continue + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + await removeCorruptPortFile(portFile) + continue + } + if (!isUsablePortFileEntry(parsed)) { + await removeCorruptPortFile(portFile) + continue + } + const entry = parsed + if (!pidAlive(entry.pid)) await unlink(portFile).catch(() => {}) + } + if (resolve(dir) !== active) await rmdir(dir).catch(() => {}) + } } export async function discoverPortFile( diff --git a/packages/opencode/src/rpc/rpc-dir.ts b/packages/opencode/src/rpc/rpc-dir.ts index 5af3b25..4122694 100644 --- a/packages/opencode/src/rpc/rpc-dir.ts +++ b/packages/opencode/src/rpc/rpc-dir.ts @@ -1,9 +1,27 @@ import { createHash } from 'node:crypto' import { homedir, tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' const RPC_DIR_ENV = 'OPENCODE_OPENAI_AUTH_RPC_DIR' +export interface RpcDirResolution { + dir: string + secureDir: boolean + sweepRoot?: string +} + +function rpcHash(projectDirectory: string): string { + return createHash('sha256') + .update(projectDirectory) + .digest('hex') + .slice(0, 16) +} + +function defaultRpcRoot(): string { + const base = process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state') + return join(base, 'cortexkit', 'openai-auth', 'rpc') +} + // Both processes must resolve the SAME dir from the SAME project directory. export function getRpcDir(projectDirectory: string): string { const override = process.env[RPC_DIR_ENV]?.trim() @@ -11,12 +29,18 @@ export function getRpcDir(projectDirectory: string): string { // so server and TUI halves always resolve the same dir. An absolute override is // used as-is (resolve(base, absolute) returns the absolute path unchanged). if (override) return resolve(projectDirectory, override) - const hash = createHash('sha256') - .update(projectDirectory) - .digest('hex') - .slice(0, 16) - const base = process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state') - return join(base, 'cortexkit', 'openai-auth', 'rpc', hash) + return join(defaultRpcRoot(), `openai-auth-${rpcHash(projectDirectory)}`) +} + +export async function resolveRpcDir( + projectDirectory: string, +): Promise { + const override = process.env[RPC_DIR_ENV]?.trim() + if (override) { + return { dir: resolve(projectDirectory, override), secureDir: false } + } + const dir = getRpcDir(projectDirectory) + return { dir, secureDir: true, sweepRoot: dirname(dir) } } export { tmpdir } diff --git a/packages/opencode/src/rpc/rpc-server.ts b/packages/opencode/src/rpc/rpc-server.ts index b546916..9bbb9a5 100644 --- a/packages/opencode/src/rpc/rpc-server.ts +++ b/packages/opencode/src/rpc/rpc-server.ts @@ -8,7 +8,7 @@ import { import { join } from 'node:path' import { createLogger } from '../logger' import type { drainNotifications } from './notifications' -import { writePortFile } from './port-file' +import { sweepRpcState, writePortFile } from './port-file' import type { ApplyRequest, ApplyResult } from './protocol' const log = createLogger('rpc') @@ -21,6 +21,8 @@ export interface RpcServerHandle { export interface RpcServerOptions { dir: string + secureDir?: boolean + sweepRoot?: string drain: typeof drainNotifications apply: (request: ApplyRequest) => Promise timeoutMs?: number @@ -112,8 +114,22 @@ export async function startRpcServer( }) }) server.unref() + if (options.sweepRoot) { + try { + await sweepRpcState(options.sweepRoot, options.dir) + } catch (error) { + log.warn('rpc state sweep failed', { + pid: process.pid, + error: error instanceof Error ? error.message : String(error), + }) + } + } try { - await writePortFile(options.dir, { port, token, pid: process.pid }) + await writePortFile( + options.dir, + { port, token, pid: process.pid }, + { secureDir: options.secureDir }, + ) log.debug('rpc server pid', { pid: process.pid, rpcPort: port, diff --git a/packages/opencode/src/tests/rpc-dir.test.ts b/packages/opencode/src/tests/rpc-dir.test.ts index cc8982c..c9ffd6b 100644 --- a/packages/opencode/src/tests/rpc-dir.test.ts +++ b/packages/opencode/src/tests/rpc-dir.test.ts @@ -1,22 +1,35 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { resolve } from 'node:path' -import { getRpcDir } from '../rpc/rpc-dir' +import { mkdtemp, rm, stat, unlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { writePortFile } from '../rpc/port-file' +import { getRpcDir, resolveRpcDir } from '../rpc/rpc-dir' const ENV_KEY = 'OPENCODE_OPENAI_AUTH_RPC_DIR' let savedEnv: string | undefined +let savedStateHome: string | undefined +let tempDir: string | undefined beforeEach(() => { savedEnv = process.env[ENV_KEY] + savedStateHome = process.env.XDG_STATE_HOME delete process.env[ENV_KEY] }) -afterEach(() => { +afterEach(async () => { if (savedEnv === undefined) { delete process.env[ENV_KEY] } else { process.env[ENV_KEY] = savedEnv } + if (savedStateHome === undefined) { + delete process.env.XDG_STATE_HOME + } else { + process.env.XDG_STATE_HOME = savedStateHome + } + if (tempDir) await rm(tempDir, { recursive: true, force: true }) + tempDir = undefined }) describe('getRpcDir', () => { @@ -55,15 +68,15 @@ describe('getRpcDir', () => { expect(result).toBe('/var/custom/rpc') }) - test('no override falls back to XDG hashed path', () => { + test('no override falls back to an openai-auth-prefixed XDG hashed path', () => { // env already deleted in beforeEach const result = getRpcDir('/tmp/projA') expect(result).toContain('cortexkit/openai-auth/rpc') - // 16-char hex hash + // 16-char hex hash with a greppable plugin prefix const parts = result.split('/') - const hash = parts[parts.length - 1] - expect(hash).toMatch(/^[0-9a-f]{16}$/) + const name = parts[parts.length - 1] + expect(name).toMatch(/^openai-auth-[0-9a-f]{16}$/) }) test('same projectDirectory always produces same no-override path', () => { @@ -77,4 +90,68 @@ describe('getRpcDir', () => { const b = getRpcDir('/tmp/projB') expect(a).not.toBe(b) }) + + test('server and TUI resolution return the identical managed path', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'oa-rpc-dir-')) + process.env.XDG_STATE_HOME = tempDir + + const server = await resolveRpcDir('/tmp/project') + const tui = await resolveRpcDir('/tmp/project') + + expect(server.dir).toBe(tui.dir) + expect(server.secureDir).toBe(true) + expect(server.sweepRoot).toBe(tui.sweepRoot) + }) + + test('override remains anchored but is never treated as a managed directory', async () => { + process.env[ENV_KEY] = '.custom-rpc' + + const resolved = await resolveRpcDir('/tmp/project') + + expect(resolved.dir).toBe(resolve('/tmp/project', '.custom-rpc')) + expect(resolved.secureDir).toBe(false) + expect(resolved.sweepRoot).toBeUndefined() + }) + + test('resolution uses the new directory even when a legacy entry is live', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'oa-rpc-dir-')) + process.env.XDG_STATE_HOME = tempDir + const projectDirectory = '/tmp/project' + const legacyDir = getRpcDir(projectDirectory).replace( + /openai-auth-([0-9a-f]{16})$/, + '$1', + ) + await writePortFile(legacyDir, { + port: 1, + token: 'live', + pid: process.pid, + }) + + const resolved = await resolveRpcDir(projectDirectory) + + expect(resolved.dir).toBe(getRpcDir(projectDirectory)) + expect(resolved.secureDir).toBe(true) + expect(await stat(legacyDir)).toBeDefined() + }) + + test('resolution stays stable when legacy liveness changes between calls', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'oa-rpc-dir-')) + process.env.XDG_STATE_HOME = tempDir + const projectDirectory = '/tmp/project' + const legacyDir = getRpcDir(projectDirectory).replace( + /openai-auth-([0-9a-f]{16})$/, + '$1', + ) + await writePortFile(legacyDir, { + port: 1, + token: 'live', + pid: process.pid, + }) + + const beforeExit = await resolveRpcDir(projectDirectory) + await unlink(join(legacyDir, `port-${process.pid}.json`)) + const afterExit = await resolveRpcDir(projectDirectory) + + expect(afterExit.dir).toBe(beforeExit.dir) + }) }) diff --git a/packages/opencode/src/tests/rpc-port-file.test.ts b/packages/opencode/src/tests/rpc-port-file.test.ts index 09b3c66..fe8114d 100644 --- a/packages/opencode/src/tests/rpc-port-file.test.ts +++ b/packages/opencode/src/tests/rpc-port-file.test.ts @@ -1,11 +1,26 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { + chmod, + mkdir, + mkdtemp, + readdir, + rm, + rmdir, + stat, + unlink, + writeFile, +} from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { discoverPortFile, writePortFile } from '../rpc/port-file' +import { + discoverPortFile, + sweepRpcState, + writePortFile, +} from '../rpc/port-file' let dir: string const childProcesses: Array> = [] +const permissionTest = process.getuid?.() === 0 ? test.skip : test beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'oa-rpc-')) @@ -187,4 +202,186 @@ describe('port-file', () => { expect(fallback?.port).toBe(2) expect(fallback?.token).toBe('other') }) + + test('sweepRpcState removes a dead port file but leaves a live one untouched', async () => { + const deadDir = join(dir, 'openai-auth-deadbeefdeadbeef') + const liveDir = join(dir, 'openai-auth-cafebabecafebabe') + const livePid = spawnLivePid() + await writePortFile(deadDir, { port: 1, token: 'dead', pid: 99999999 }) + await writeFile(join(deadDir, 'keep'), 'x', 'utf8') + await writePortFile(liveDir, { port: 2, token: 'live', pid: livePid }) + + await sweepRpcState(dir, join(dir, 'active')) + + expect(await readdir(deadDir)).toEqual(['keep']) + expect(await readdir(liveDir)).toEqual([`port-${livePid}.json`]) + }) + + test('sweepRpcState removes a port file that has a pid but no port', async () => { + const projectDir = join(dir, 'openai-auth-deadbeefdeadbeef') + const noPortFile = join(projectDir, `port-${process.pid}.json`) + await mkdir(projectDir, { recursive: true }) + await writeFile(noPortFile, JSON.stringify({ pid: process.pid }), 'utf8') + + await sweepRpcState(dir, join(dir, 'active')) + + await expect(stat(noPortFile)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test('sweepRpcState removes an emptied project dir but never its active dir', async () => { + const staleDir = join(dir, 'openai-auth-deadbeefdeadbeef') + const activeDir = join(dir, 'openai-auth-cafebabecafebabe') + await writePortFile(staleDir, { port: 1, token: 'dead', pid: 99999999 }) + await writePortFile(activeDir, { port: 2, token: 'dead', pid: 99999999 }) + + await sweepRpcState(dir, activeDir) + + await expect(stat(staleDir)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readdir(activeDir)).toEqual([]) + }) + + test('sweepRpcState collects dead legacy state but preserves live legacy state', async () => { + const deadLegacyDir = join(dir, 'deadbeefdeadbeef') + const liveLegacyDir = join(dir, 'cafebabecafebabe') + const livePid = spawnLivePid() + await writePortFile(deadLegacyDir, { + port: 1, + token: 'dead', + pid: 99999999, + }) + await writePortFile(liveLegacyDir, { + port: 2, + token: 'live', + pid: livePid, + }) + + await sweepRpcState(dir, join(dir, 'active')) + + await expect(stat(deadLegacyDir)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readdir(liveLegacyDir)).toEqual([`port-${livePid}.json`]) + }) + + test('sweepRpcState removes a corrupt port file', async () => { + const projectDir = join(dir, 'openai-auth-deadbeefdeadbeef') + const corruptFile = join(projectDir, 'port-corrupt.json') + await mkdir(projectDir, { recursive: true }) + await writeFile(corruptFile, '{', 'utf8') + + await sweepRpcState(dir, join(dir, 'active')) + + await expect(stat(corruptFile)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test('sweepRpcState leaves a valid live port file in a directory with a corrupt file', async () => { + const projectDir = join(dir, 'openai-auth-deadbeefdeadbeef') + const livePid = spawnLivePid() + await writePortFile(projectDir, { port: 1, token: 'live', pid: livePid }) + await writeFile(join(projectDir, 'port-corrupt.json'), '{', 'utf8') + + await sweepRpcState(dir, join(dir, 'active')) + + expect(await readdir(projectDir)).toEqual([`port-${livePid}.json`]) + }) + + permissionTest( + 'sweepRpcState ignores an unlink failure for a corrupt port file', + async () => { + const projectDir = join(dir, 'openai-auth-deadbeefdeadbeef') + const corruptFile = join(projectDir, 'port-corrupt.json') + await mkdir(projectDir, { recursive: true }) + await writeFile(corruptFile, '{', 'utf8') + await chmod(projectDir, 0o500) + + try { + await expect(unlink(corruptFile)).rejects.toMatchObject({ + code: 'EACCES', + }) + await expect( + sweepRpcState(dir, join(dir, 'active')), + ).resolves.toBeUndefined() + } finally { + await chmod(projectDir, 0o700) + } + }, + ) + + test('writePortFile recovers when the directory is removed between mkdir and write', async () => { + const projectDir = join(dir, 'openai-auth-deadbeefdeadbeef') + let dirRemoved = false + const target = await writePortFile( + projectDir, + { port: 7777, token: 'survives', pid: process.pid }, + { + beforeWrite: async () => { + if (dirRemoved) return + dirRemoved = true + await rmdir(projectDir) + }, + }, + ) + + expect(dirRemoved).toBe(true) + expect(await stat(target)).toBeDefined() + const found = await discoverPortFile(projectDir) + expect(found?.port).toBe(7777) + expect(found?.token).toBe('survives') + }) + + test('writePortFile does not retry forever on a persistent ENOENT', async () => { + const projectDir = join(dir, 'openai-auth-deadbeefdeadbeef') + let calls = 0 + await expect( + writePortFile( + projectDir, + { port: 1, token: 'x', pid: process.pid }, + { + beforeWrite: async () => { + calls += 1 + await rmdir(projectDir) + }, + }, + ), + ).rejects.toMatchObject({ code: 'ENOENT' }) + // Bounded: one initial attempt + exactly one retry = 2 invocations of + // beforeWrite. A third attempt would not help; we want to fail fast. + expect(calls).toBe(2) + }) + + test('sweepRpcState removes unusable entries and continues through sibling directories', async () => { + const projectDir = join(dir, 'openai-auth-deadbeefdeadbeef') + const siblingDir = join(dir, 'openai-auth-cafebabecafebabe') + await mkdir(projectDir, { recursive: true }) + await mkdir(siblingDir, { recursive: true }) + const unusableEntries = [ + ['port-null.json', 'null'], + ['port-number.json', '123'], + ['port-array.json', '[]'], + ['port-string.json', '"str"'], + ['port-bool.json', 'true'], + ] as const + const unusableFiles = unusableEntries.map(([name]) => + join(projectDir, name), + ) + for (const [name, value] of unusableEntries) { + await writeFile(join(projectDir, name), value, 'utf8') + } + const deadHere = join(projectDir, 'port-dead-here.json') + const deadSibling = join(siblingDir, 'port-dead-sibling.json') + await writeFile( + deadHere, + JSON.stringify({ port: 1, token: 'dead', pid: 99999999, startedAt: 1 }), + 'utf8', + ) + await writeFile( + deadSibling, + JSON.stringify({ port: 2, token: 'dead', pid: 99999999, startedAt: 1 }), + 'utf8', + ) + + await sweepRpcState(dir, join(dir, 'active')) + + for (const file of [...unusableFiles, deadHere, deadSibling]) { + await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + } + }) }) diff --git a/packages/opencode/src/tests/rpc-server.test.ts b/packages/opencode/src/tests/rpc-server.test.ts index feaa387..65324b6 100644 --- a/packages/opencode/src/tests/rpc-server.test.ts +++ b/packages/opencode/src/tests/rpc-server.test.ts @@ -1,8 +1,18 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { mkdtemp, rm } from 'node:fs/promises' +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises' import http from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { flushForTest } from '../logger' import { drainNotifications, pushNotification, @@ -181,4 +191,91 @@ describe('rpc-server', () => { await expect(reqPromise).resolves.toBeUndefined() }) + + test('starts when the state sweep fails', async () => { + dir = await mkdtemp(join(tmpdir(), 'oa-rpcsrv-')) + const badSweepRoot = join(dir, 'not-a-directory') + const logFile = join(dir, 'rpc.log') + const savedLogFile = process.env.OPENCODE_OPENAI_AUTH_LOG_FILE + + try { + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = logFile + await writeFile(badSweepRoot, 'x', 'utf8') + const server = await startRpcServer({ + dir: join(dir, 'rpc'), + sweepRoot: badSweepRoot, + drain: drainNotifications, + apply: async () => ({ text: 'ok', knobs: {} }), + }) + stop = server.stop + + expect( + (await fetch(`http://127.0.0.1:${server.port}/health`)).status, + ).toBe(200) + await flushForTest() + const log = await readFile(logFile, 'utf8') + expect(log).toContain('WARN [rpc] rpc state sweep failed') + expect(log).toContain(`"pid":${process.pid}`) + } finally { + if (savedLogFile === undefined) { + delete process.env.OPENCODE_OPENAI_AUTH_LOG_FILE + } else { + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = savedLogFile + } + } + }) + + test('startup sweeps stale project state outside the active directory', async () => { + dir = await mkdtemp(join(tmpdir(), 'oa-rpcsrv-')) + const root = join(dir, 'state') + const staleDir = join(root, 'openai-auth-deadbeefdeadbeef') + await mkdir(staleDir, { recursive: true }) + await writeFile( + join(staleDir, 'port-99999999.json'), + JSON.stringify({ port: 1, token: 'dead', pid: 99999999, startedAt: 1 }), + { encoding: 'utf8', mode: 0o600 }, + ) + + const server = await startRpcServer({ + dir: join(root, 'openai-auth-cafebabecafebabe'), + sweepRoot: root, + drain: drainNotifications, + apply: async () => ({ text: 'ok', knobs: {} }), + }) + stop = server.stop + + expect(await readdir(root)).toEqual(['openai-auth-cafebabecafebabe']) + }) + + test('creates a managed RPC directory with 0700 permissions', async () => { + dir = await mkdtemp(join(tmpdir(), 'oa-rpcsrv-')) + const managedDir = join(dir, 'managed', 'rpc') + await mkdir(managedDir, { recursive: true, mode: 0o755 }) + await chmod(managedDir, 0o755) + + const server = await startRpcServer({ + dir: managedDir, + secureDir: true, + drain: drainNotifications, + apply: async () => ({ text: 'ok', knobs: {} }), + }) + stop = server.stop + + expect((await stat(managedDir)).mode & 0o777).toBe(0o700) + }) + + test('does not chmod a foreign RPC override directory', async () => { + dir = await mkdtemp(join(tmpdir(), 'oa-rpcsrv-')) + await chmod(dir, 0o755) + + const server = await startRpcServer({ + dir, + secureDir: false, + drain: drainNotifications, + apply: async () => ({ text: 'ok', knobs: {} }), + }) + stop = server.stop + + expect((await stat(dir)).mode & 0o777).toBe(0o755) + }) }) diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index 6b7be0a..1e38515 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -18,7 +18,7 @@ import { } from 'solid-js' import { createLogger } from './logger.js' import { createRpcClient } from './rpc/rpc-client.js' -import { getRpcDir } from './rpc/rpc-dir.js' +import { resolveRpcDir } from './rpc/rpc-dir.js' import { type AccountQuota, computeQuotaPacing, @@ -856,17 +856,14 @@ const tui: TuiPlugin = async (api) => { if (!rpcPollStarted) { rpcPollStarted = true const myPid = process.pid - const rpcClient = createRpcClient( - getRpcDir(api.state.path.directory ?? ''), - myPid, - (entry) => { - log.debug('rpc tui pid', { - myPid, - matchedPortFilePid: entry?.pid ?? null, - rpcPort: entry?.port ?? null, - }) - }, - ) + const rpcDir = await resolveRpcDir(api.state.path.directory ?? '') + const rpcClient = createRpcClient(rpcDir.dir, myPid, (entry) => { + log.debug('rpc tui pid', { + myPid, + matchedPortFilePid: entry?.pid ?? null, + rpcPort: entry?.port ?? null, + }) + }) let lastNotificationId = 0 let rpcInFlight = false setInterval(() => {