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(() => {