Skip to content
Closed
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
3 changes: 3 additions & 0 deletions src/plugins/api-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { OpenACPPlugin, InstallContext } from '../../core/plugin/types.js'
import type { OpenACPCore } from '../../core/core.js'
import type { TopicManager } from '../telegram/topic-manager.js'
import type { CommandRegistry } from '../../core/command-registry.js'
import type { ContextManager } from '../context/context-manager.js'
import type { ApiServerInstance } from './server.js'
import type { RouteDeps } from './routes/types.js'
import { createChildLogger } from '../../core/utils/log.js'
Expand Down Expand Up @@ -229,6 +230,7 @@ function createApiServerPlugin(): OpenACPPlugin {
// Resolve optional services for route deps
const topicManager = ctx.getService<TopicManager>('topic-manager')
const commandRegistry = ctx.getService<CommandRegistry>('command-registry')
const contextManager = ctx.getService<ContextManager>('context')

// Build auth pre-handler for route-level auth on unauthenticated route groups
const routeAuthPreHandler = createAuthPreHandler(() => secret, () => jwtSecret, tokenStore)
Expand All @@ -240,6 +242,7 @@ function createApiServerPlugin(): OpenACPPlugin {
getVersion,
commandRegistry,
authPreHandler: routeAuthPreHandler,
contextManager,
}

// Register all route plugins under /api/v1/
Expand Down
21 changes: 21 additions & 0 deletions src/plugins/api-server/middleware/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ export class BadRequestError extends Error {
}
}

export class ServiceUnavailableError extends Error {
constructor(
public code: string,
message: string,
) {
super(message);
this.name = 'ServiceUnavailableError';
}
}

export class AuthError extends Error {
constructor(
public code: string,
Expand Down Expand Up @@ -80,6 +90,17 @@ export function globalErrorHandler(
return;
}

if (error instanceof ServiceUnavailableError) {
reply.status(503).send({
error: {
code: error.code,
message: error.message,
statusCode: 503,
},
});
return;
}

if (error instanceof AuthError) {
reply.status(error.statusCode).send({
error: {
Expand Down
29 changes: 28 additions & 1 deletion src/plugins/api-server/routes/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { RouteDeps } from './types.js';
import { NotFoundError } from '../middleware/error-handler.js';
import { NotFoundError, ServiceUnavailableError } from '../middleware/error-handler.js';
import { requireScopes } from '../middleware/auth.js';
import { createChildLogger } from '../../../core/utils/log.js';
import {
Expand Down Expand Up @@ -459,6 +459,33 @@ export async function sessionRoutes(
},
);

// GET /sessions/:sessionId/history — get full conversation history
app.get<{ Params: { sessionId: string } }>(
'/:sessionId/history',
{ preHandler: requireScopes('sessions:read') },
async (request, reply) => {
const { sessionId } = SessionIdParamSchema.parse(request.params);
const session = deps.core.sessionManager.getSession(sessionId);
if (!session) {
throw new NotFoundError(
'SESSION_NOT_FOUND',
`Session "${sessionId}" not found`,
);
}
if (!deps.contextManager) {
throw new ServiceUnavailableError(
'HISTORY_UNAVAILABLE',
'History store not available',
);
}
const history = await deps.contextManager.getHistory(sessionId);
if (!history) {
throw new NotFoundError('HISTORY_NOT_FOUND', `No history for session "${sessionId}"`);
}
return { history };
},
);

// DELETE /sessions/:sessionId — cancel a session
app.delete<{ Params: { sessionId: string } }>(
'/:sessionId',
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/api-server/routes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { preHandlerHookHandler } from 'fastify';
import type { OpenACPCore } from '../../../core/core.js';
import type { TopicManager } from '../../telegram/topic-manager.js';
import type { CommandRegistry } from '../../../core/command-registry.js';
import type { ContextManager } from '../../context/context-manager.js';

/**
* Dependencies injected into Fastify route plugins.
Expand All @@ -15,4 +16,6 @@ export interface RouteDeps {
commandRegistry?: CommandRegistry;
/** Auth pre-handler for routes registered without global auth (e.g. system routes). */
authPreHandler?: preHandlerHookHandler;
/** Context manager for reading session conversation history. */
contextManager?: ContextManager;
}
58 changes: 58 additions & 0 deletions src/plugins/context/__tests__/context-manager-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ContextManager } from "../context-manager.js";
import { HistoryStore } from "../history/history-store.js";
import type { SessionHistory } from "../history/types.js";

function makeHistory(sessionId: string): SessionHistory {
return {
version: 1,
sessionId,
turns: [
{
index: 0,
role: "user",
timestamp: "2026-01-01T00:00:00.000Z",
content: "Hello",
},
],
};
}

describe("ContextManager.getHistory", () => {
let tmpDir: string;
let store: HistoryStore;
let manager: ContextManager;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctx-mgr-history-test-"));
store = new HistoryStore(path.join(tmpDir, "history"));
manager = new ContextManager(path.join(tmpDir, "cache"));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it("returns null when no history store is set", async () => {
const result = await manager.getHistory("session-1");
expect(result).toBeNull();
});

it("returns history when store is set and session exists", async () => {
const history = makeHistory("session-1");
await store.write(history);
manager.setHistoryStore(store);

const result = await manager.getHistory("session-1");
expect(result).toEqual(history);
});

it("returns null when session has no history", async () => {
manager.setHistoryStore(store);
const result = await manager.getHistory("nonexistent");
expect(result).toBeNull();
});
});
12 changes: 12 additions & 0 deletions src/plugins/context/context-manager.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import * as os from "node:os";
import * as path from "node:path";
import type { ContextProvider, ContextQuery, ContextOptions, ContextResult, SessionListResult } from "./context-provider.js";
import type { HistoryStore } from "./history/history-store.js";
import type { SessionHistory } from "./history/types.js";
import { ContextCache } from "./context-cache.js";

export class ContextManager {
private providers: ContextProvider[] = [];
private cache: ContextCache;
private historyStore?: HistoryStore;

constructor(cachePath?: string) {
this.cache = new ContextCache(cachePath ?? path.join(os.homedir(), ".openacp", "cache", "entire"));
}

setHistoryStore(store: HistoryStore): void {
this.historyStore = store;
}

async getHistory(sessionId: string): Promise<SessionHistory | null> {
if (!this.historyStore) return null;
return this.historyStore.read(sessionId);
}

register(provider: ContextProvider): void {
this.providers.push(provider);
}
Expand Down
27 changes: 27 additions & 0 deletions src/plugins/context/history/__tests__/history-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,33 @@ describe("HistoryStore", () => {
});
});

describe("path traversal protection", () => {
it("strips directory components from session ID on read", async () => {
await store.write(makeHistory("legit-session"));
const result = await store.read("../../../etc/passwd");
expect(result).toBeNull();
});

it("strips directory components from session ID on exists", async () => {
const result = await store.exists("../../etc/passwd");
expect(result).toBe(false);
});

it("uses only basename when session ID contains path separators", async () => {
await store.write(makeHistory("safe-id"));
// path.basename("foo/bar/safe-id") => "safe-id", so reading with traversal won't reach it
const result = await store.read("../other-dir/safe-id");
// Should read basename "safe-id" from the store dir, which exists
expect(result?.sessionId).toBe("safe-id");
});

it("rejects session ID that resolves outside store directory", async () => {
// path.basename strips traversal, but double-check the resolved path stays within dir
const result = await store.read("..%2F..%2Fetc%2Fpasswd");
expect(result).toBeNull();
});
});

describe("corrupt JSON handling", () => {
it("returns null when the file contains invalid JSON", async () => {
const corruptPath = path.join(tmpDir, "corrupt-session.json");
Expand Down
7 changes: 6 additions & 1 deletion src/plugins/context/history/history-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export class HistoryStore {
}

private filePath(sessionId: string): string {
return path.join(this.dir, `${sessionId}.json`);
const basename = path.basename(sessionId);
const resolved = path.join(this.dir, `${basename}.json`);
if (!resolved.startsWith(this.dir)) {
throw new Error(`Invalid session ID: ${sessionId}`);
}
return resolved;
}
}
1 change: 1 addition & 0 deletions src/plugins/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const contextPlugin: OpenACPPlugin = {
const manager = new ContextManager(cachePath)
manager.register(new HistoryProvider(store, getRecords))
manager.register(new EntireProvider())
manager.setHistoryStore(store)
ctx.registerService('context', manager)

// Middleware: capture user prompts
Expand Down