diff --git a/package-lock.json b/package-lock.json index 73e964b..c66214e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.17", + "version": "0.1.0-alpha.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.17", + "version": "0.1.0-alpha.18", "license": "MIT", "dependencies": { "@opencode-ai/sdk": "^1.18.2", diff --git a/package.json b/package.json index dce1aae..e4792d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.17", + "version": "0.1.0-alpha.18", "publishConfig": { "access": "public" }, diff --git a/plugin.manifest.json b/plugin.manifest.json index 6bba280..95c4f85 100644 --- a/plugin.manifest.json +++ b/plugin.manifest.json @@ -1,6 +1,6 @@ { "plugin_name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.17", + "version": "0.1.0-alpha.18", "manifest_fingerprint": "", "signature": "", "capabilities": [ diff --git a/src/contextPackHydration.test.ts b/src/contextPackHydration.test.ts index 7a32c7a..2822af8 100644 --- a/src/contextPackHydration.test.ts +++ b/src/contextPackHydration.test.ts @@ -21,6 +21,7 @@ import { PENDING_CONTEXT_FILENAME, activateProvidedSessionWorkContext, buildContextPackRequest, + clearPrivateSessionContext, clearSessionWorkContext, hydrateContextPack as hydrateContextPackImpl, resolveContextPackConfig, @@ -463,6 +464,84 @@ describe('opencode context-pack hydration', () => { } }); + it('clears only the deleted session context pack and pending activation', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-project-')); + const stateRoot = testStateRoot(projectDir); + const env = { + ORGX_API_KEY: 'oxk_test', + ORGX_TASK_ID: 'task-1', + }; + try { + for (const sessionId of ['session-a', 'session-b']) { + const result = await hydrateContextPackImpl({ + env, + projectDir, + sessionId, + stateRoot, + fetchImpl: vi.fn(async () => response({ sessionWorkContext })), + spawnImpl: () => { + throw new Error('orgx-wizard unavailable'); + }, + }); + expect(result.contextPackPath).toBeTruthy(); + expect(result.sessionContext?.pendingPath).toBeTruthy(); + } + const sessionA = stateDirectory(projectDir, 'session-a'); + const sessionB = stateDirectory(projectDir, 'session-b'); + + await expect( + clearPrivateSessionContext({ + env, + projectDir, + sessionId: 'session-a', + stateRoot, + }) + ).resolves.toEqual({ + cleared: true, + reason: 'private_state_cleared', + removedFiles: 2, + }); + + expect(existsSync(join(sessionA, CONTEXT_PACK_FILENAME))).toBe(false); + expect(existsSync(join(sessionA, PENDING_CONTEXT_FILENAME))).toBe(false); + expect(existsSync(join(sessionB, CONTEXT_PACK_FILENAME))).toBe(true); + expect(existsSync(join(sessionB, PENDING_CONTEXT_FILENAME))).toBe(true); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('fails closed on a symlinked session state directory without deleting its target', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-project-')); + const stateRoot = testStateRoot(projectDir); + const outside = mkdtempSync(join(tmpdir(), 'orgx-opencode-outside-state-')); + const sessionDir = stateDirectory(projectDir, 'session-a'); + const packPath = join(outside, CONTEXT_PACK_FILENAME); + const pendingPath = join(outside, PENDING_CONTEXT_FILENAME); + mkdirSync(stateRoot, { recursive: true }); + writeFileSync(packPath, 'other-session-pack'); + writeFileSync(pendingPath, 'other-session-pending'); + symlinkSync(outside, sessionDir, 'dir'); + try { + await expect( + clearPrivateSessionContext({ + projectDir, + sessionId: 'session-a', + stateRoot, + }) + ).resolves.toEqual({ + cleared: false, + reason: 'private_state_unsafe', + removedFiles: 0, + }); + expect(readFileSync(packPath, 'utf8')).toBe('other-session-pack'); + expect(readFileSync(pendingPath, 'utf8')).toBe('other-session-pending'); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + } + }); + it('refuses an explicitly configured state root inside the repository and clears the session lease', async () => { const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-project-')); const repoStateRoot = join(projectDir, '.private-runtime'); diff --git a/src/contextPackHydration.ts b/src/contextPackHydration.ts index 8fb1660..1e5348d 100644 --- a/src/contextPackHydration.ts +++ b/src/contextPackHydration.ts @@ -108,6 +108,16 @@ export type ContextPackHydrationResult = { additionalContext?: string; }; +export type PrivateSessionContextClearance = { + cleared: boolean; + reason: + | 'private_state_cleared' | 'private_state_absent' + | 'project_directory_unavailable' | 'session_id_unavailable' + | 'private_state_unsafe' + | 'private_state_clear_failed'; + removedFiles: number; +}; + function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } @@ -430,6 +440,112 @@ async function removePendingContext(state: PrivateContextState): Promise { await removePrivateContextFile(state, PENDING_CONTEXT_FILENAME); } +async function resolveExactPrivateContextFile( + stateDir: string, + filename: typeof CONTEXT_PACK_FILENAME | typeof PENDING_CONTEXT_FILENAME +): Promise { + const candidate = resolve(stateDir, filename); + if (dirname(candidate) !== stateDir || !isPathWithin(stateDir, candidate)) { + throw new Error('unsafe_private_context_path'); + } + try { + const file = await lstat(candidate); + if (!file.isFile() && !file.isSymbolicLink()) { + throw new Error('unsafe_private_context_file'); + } + return candidate; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +async function unlinkPrivateContextFile(path: string): Promise { + try { + await unlink(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + +function privateContextClearFailureReason( + error: unknown +): PrivateSessionContextClearance['reason'] { + const message = error instanceof Error ? error.message : ''; + return /unsafe|unavailable|inside_project/.test(message) + ? 'private_state_unsafe' + : 'private_state_clear_failed'; +} + +// Recompute terminal paths from the validated project/session tuple so callers +// cannot select a filename or another session's persisted state. +export async function clearPrivateSessionContext({ + env = process.env, + projectDir, + sessionId, + stateRoot, +}: { + env?: Env; + projectDir?: string; + sessionId?: string; + stateRoot?: string; +} = {}): Promise { + const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); + if (!normalizedProjectDir) { + return { cleared: false, reason: 'project_directory_unavailable', removedFiles: 0 }; + } + const normalizedSessionId = pickString(sessionId); + if (!normalizedSessionId || byteLength(normalizedSessionId) > 512) { + return { cleared: false, reason: 'session_id_unavailable', removedFiles: 0 }; + } + const privateState: PrivateContextState = { + env, + projectDir: normalizedProjectDir, + sessionId: normalizedSessionId, + stateRoot, + }; + try { + const stateDir = await privateStateDirectory(privateState, false); + if (!stateDir) { + return { cleared: true, reason: 'private_state_absent', removedFiles: 0 }; + } + const candidates = await Promise.all([ + resolveExactPrivateContextFile(stateDir, CONTEXT_PACK_FILENAME), + resolveExactPrivateContextFile(stateDir, PENDING_CONTEXT_FILENAME), + ]); + const removals = await Promise.allSettled( + candidates + .filter((candidate): candidate is string => Boolean(candidate)) + .map(unlinkPrivateContextFile) + ); + const removedFiles = removals.filter( + (result) => result.status === 'fulfilled' && result.value + ).length; + const failure = removals.find((result) => result.status === 'rejected'); + if (failure?.status === 'rejected') { + return { + cleared: false, + reason: privateContextClearFailureReason(failure.reason), + removedFiles, + }; + } + return { + cleared: true, + reason: + removedFiles > 0 ? 'private_state_cleared' : 'private_state_absent', + removedFiles, + }; + } catch (error) { + return { + cleared: false, + reason: privateContextClearFailureReason(error), + removedFiles: 0, + }; + } +} + export async function persistPendingSessionWorkContext( state: PrivateContextState, context: Record diff --git a/src/peer.test.ts b/src/peer.test.ts index a4174b0..f3d0a6c 100644 --- a/src/peer.test.ts +++ b/src/peer.test.ts @@ -110,7 +110,7 @@ describe('startPeer', () => { ); expect(presence?.body).toMatchObject({ - gateway_version: '0.1.0-alpha.17', + gateway_version: '0.1.0-alpha.18', metadata: { execution_provider: null, execution_provider_id: null, diff --git a/src/plugin.test.ts b/src/plugin.test.ts index 623737b..2511f67 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -1,5 +1,20 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; +import { + CONTEXT_PACK_FILENAME, + PENDING_CONTEXT_FILENAME, + clearPrivateSessionContext, + resolvePrivateContextStateDirectory, +} from './contextPackHydration'; import { createOrgXOpenCodePlugin } from './plugin'; import { clearRuntimeSessionHydration, @@ -46,6 +61,13 @@ async function loadHooks( clearSessionWorkContext: opts?.clearSessionWorkContext ?? vi.fn(async () => ({ cleared: true, reason: 'wizard_cleared' })), + clearPrivateSessionContext: + opts?.clearPrivateSessionContext ?? + vi.fn(async () => ({ + cleared: true, + reason: 'private_state_absent', + removedFiles: 0, + })), }); return (await plugin(input as never)) as PluginHooks; } @@ -295,9 +317,15 @@ describe('OrgXOpenCodePlugin', () => { cleared: true, reason: 'wizard_cleared', } as const)); + const clearPrivateSessionContext = vi.fn(async () => ({ + cleared: true, + reason: 'private_state_cleared', + removedFiles: 2, + } as const)); const hooks = await loadHooks({ hydrateContextPack, clearSessionWorkContext, + clearPrivateSessionContext, logger: createLogger(), env: { ORGX_API_KEY: 'oxk_test', @@ -324,6 +352,73 @@ describe('OrgXOpenCodePlugin', () => { projectDir: '/work/repo', sessionId: 'session-a', }); + expect(clearPrivateSessionContext).toHaveBeenCalledTimes(1); + expect(clearPrivateSessionContext).toHaveBeenCalledWith({ + env: { + ORGX_WORKSPACE_ID: 'workspace-123', + }, + projectDir: '/work/repo', + sessionId: 'session-a', + }); + }); + + it('removes only the deleted session owner-state files at the terminal event', async () => { + const projectDir = mkdtempSync( + join(tmpdir(), 'orgx-opencode-plugin-project-') + ); + const wizardHome = mkdtempSync( + join(tmpdir(), 'orgx-opencode-plugin-state-') + ); + const env = { ORGX_WIZARD_CONFIG_HOME: wizardHome }; + const stateDir = (sessionId: string) => + resolvePrivateContextStateDirectory({ env, projectDir, sessionId })!; + try { + for (const sessionId of ['session-a', 'session-b']) { + mkdirSync(stateDir(sessionId), { recursive: true }); + writeFileSync( + join(stateDir(sessionId), CONTEXT_PACK_FILENAME), + `${sessionId}-pack` + ); + writeFileSync( + join(stateDir(sessionId), PENDING_CONTEXT_FILENAME), + `${sessionId}-pending` + ); + } + const hooks = await loadHooks( + { + env, + clearPrivateSessionContext, + logger: createLogger(), + }, + { + directory: projectDir, + serverUrl: new URL('http://localhost:4096'), + } + ); + + await hooks.event({ + event: { + type: 'session.deleted', + properties: { info: { id: 'session-a' } }, + }, + }); + + expect( + existsSync(join(stateDir('session-a'), CONTEXT_PACK_FILENAME)) + ).toBe(false); + expect( + existsSync(join(stateDir('session-a'), PENDING_CONTEXT_FILENAME)) + ).toBe(false); + expect( + existsSync(join(stateDir('session-b'), CONTEXT_PACK_FILENAME)) + ).toBe(true); + expect( + existsSync(join(stateDir('session-b'), PENDING_CONTEXT_FILENAME)) + ).toBe(true); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(wizardHome, { recursive: true, force: true }); + } }); it('waits for in-flight hydration before clearing the terminal session lease', async () => { diff --git a/src/plugin.ts b/src/plugin.ts index fe0d961..9ed9b84 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -4,10 +4,12 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import type { StartedPeer, StartPeerOptions } from './peer.js'; import { MAX_ADDITIONAL_CONTEXT_BYTES, + clearPrivateSessionContext, clearSessionWorkContext, hydrateContextPack, resolveSafeBaseUrl, type ContextPackHydrationResult, + type PrivateSessionContextClearance, type SessionContextClearance, } from './contextPackHydration.js'; import { capturePluginException } from './sentry.js'; @@ -38,6 +40,11 @@ type ClearSessionWorkContext = (input: { projectDir?: string; sessionId?: string; }) => Promise; +type ClearPrivateSessionContext = (input: { + env?: Env; + projectDir?: string; + sessionId?: string; +}) => Promise; function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) @@ -82,6 +89,7 @@ export type CreateOrgXOpenCodePluginOptions = { bridgeSessionSummary?: BridgeSessionSummary; hydrateContextPack?: HydrateContextPack; clearSessionWorkContext?: ClearSessionWorkContext; + clearPrivateSessionContext?: ClearPrivateSessionContext; }; export function createOrgXOpenCodePlugin( @@ -100,6 +108,8 @@ export function createOrgXOpenCodePlugin( const hydrate = opts.hydrateContextPack ?? hydrateContextPack; const clearContext = opts.clearSessionWorkContext ?? clearSessionWorkContext; + const clearPrivateContext = + opts.clearPrivateSessionContext ?? clearPrivateSessionContext; let peer: Promise | null = null; const contextHydrations = new Map< string, @@ -264,21 +274,42 @@ export function createOrgXOpenCodePlugin( if (event.type === 'session.deleted' && sessionId) { const key = contextHydrationKey(projectDir, sessionId); if (key) await contextHydrations.get(key); - const clearance = await clearContext({ - env, - projectDir, - sessionId, - }).catch((error) => { - capturePluginException(error, { - stage: 'session_context_clear', - }); - return { cleared: false, reason: 'wizard_unavailable' } as const; - }); + const [clearance, privateClearance] = await Promise.all([ + clearContext({ + env, + projectDir, + sessionId, + }).catch((error) => { + capturePluginException(error, { + stage: 'session_context_clear', + }); + return { cleared: false, reason: 'wizard_unavailable' } as const; + }), + clearPrivateContext({ + env, + projectDir, + sessionId, + }).catch((error) => { + capturePluginException(error, { + stage: 'private_session_context_clear', + }); + return { + cleared: false, + reason: 'private_state_clear_failed', + removedFiles: 0, + } as const; + }), + ]); if (!clearance.cleared) { logger.warn( `[orgx-opencode-plugin] session context clear unverified: ${clearance.reason}` ); } + if (!privateClearance.cleared) { + logger.warn( + `[orgx-opencode-plugin] private session context clear unverified: ${privateClearance.reason}` + ); + } if (key) contextHydrations.delete(key); clearRuntimeSessionHydration(projectDir, sessionId); }