Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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<ApplyResult> => {
const payload = await buildDialogPayload(
Expand Down
123 changes: 115 additions & 8 deletions packages/opencode/src/rpc/port-file.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<void> {
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<void>
} = {},
): Promise<string> {
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<string> => {
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<void> {
let projectDirs: Dirent<string>[]
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(() => {})
Comment thread
iceteaSA marked this conversation as resolved.
}
if (resolve(dir) !== active) await rmdir(dir).catch(() => {})
}
}

export async function discoverPortFile(
Expand Down
38 changes: 31 additions & 7 deletions packages/opencode/src/rpc/rpc-dir.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,46 @@
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()
// A relative override is anchored to projectDirectory (shared by both processes)
// 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<RpcDirResolution> {
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 }
20 changes: 18 additions & 2 deletions packages/opencode/src/rpc/rpc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -21,6 +21,8 @@ export interface RpcServerHandle {

export interface RpcServerOptions {
dir: string
secureDir?: boolean
sweepRoot?: string
drain: typeof drainNotifications
apply: (request: ApplyRequest) => Promise<ApplyResult>
timeoutMs?: number
Expand Down Expand Up @@ -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,
Expand Down
91 changes: 84 additions & 7 deletions packages/opencode/src/tests/rpc-dir.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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)
})
})
Loading