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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@useorgx/orgx-opencode-plugin",
"version": "0.1.0-alpha.17",
"version": "0.1.0-alpha.18",
"publishConfig": {
"access": "public"
},
Expand Down
2 changes: 1 addition & 1 deletion plugin.manifest.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
79 changes: 79 additions & 0 deletions src/contextPackHydration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
PENDING_CONTEXT_FILENAME,
activateProvidedSessionWorkContext,
buildContextPackRequest,
clearPrivateSessionContext,
clearSessionWorkContext,
hydrateContextPack as hydrateContextPackImpl,
resolveContextPackConfig,
Expand Down Expand Up @@ -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');
Expand Down
116 changes: 116 additions & 0 deletions src/contextPackHydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
Expand Down Expand Up @@ -430,6 +440,112 @@ async function removePendingContext(state: PrivateContextState): Promise<void> {
await removePrivateContextFile(state, PENDING_CONTEXT_FILENAME);
}

async function resolveExactPrivateContextFile(
stateDir: string,
filename: typeof CONTEXT_PACK_FILENAME | typeof PENDING_CONTEXT_FILENAME
): Promise<string | null> {
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<boolean> {
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<PrivateSessionContextClearance> {
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<string, unknown>
Expand Down
2 changes: 1 addition & 1 deletion src/peer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions src/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down
Loading
Loading