diff --git a/src/plugins/api-server/index.ts b/src/plugins/api-server/index.ts index eee5f3ae..d97721f5 100644 --- a/src/plugins/api-server/index.ts +++ b/src/plugins/api-server/index.ts @@ -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' @@ -229,6 +230,7 @@ function createApiServerPlugin(): OpenACPPlugin { // Resolve optional services for route deps const topicManager = ctx.getService('topic-manager') const commandRegistry = ctx.getService('command-registry') + const contextManager = ctx.getService('context') // Build auth pre-handler for route-level auth on unauthenticated route groups const routeAuthPreHandler = createAuthPreHandler(() => secret, () => jwtSecret, tokenStore) @@ -240,6 +242,7 @@ function createApiServerPlugin(): OpenACPPlugin { getVersion, commandRegistry, authPreHandler: routeAuthPreHandler, + contextManager, } // Register all route plugins under /api/v1/ diff --git a/src/plugins/api-server/middleware/error-handler.ts b/src/plugins/api-server/middleware/error-handler.ts index 03794713..e2de3ea8 100644 --- a/src/plugins/api-server/middleware/error-handler.ts +++ b/src/plugins/api-server/middleware/error-handler.ts @@ -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, @@ -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: { diff --git a/src/plugins/api-server/routes/sessions.ts b/src/plugins/api-server/routes/sessions.ts index 82cded16..6de6604a 100644 --- a/src/plugins/api-server/routes/sessions.ts +++ b/src/plugins/api-server/routes/sessions.ts @@ -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 { @@ -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', diff --git a/src/plugins/api-server/routes/types.ts b/src/plugins/api-server/routes/types.ts index 3d37a708..62a3599c 100644 --- a/src/plugins/api-server/routes/types.ts +++ b/src/plugins/api-server/routes/types.ts @@ -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. @@ -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; } diff --git a/src/plugins/context/__tests__/context-manager-history.test.ts b/src/plugins/context/__tests__/context-manager-history.test.ts new file mode 100644 index 00000000..f0032b37 --- /dev/null +++ b/src/plugins/context/__tests__/context-manager-history.test.ts @@ -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(); + }); +}); diff --git a/src/plugins/context/context-manager.ts b/src/plugins/context/context-manager.ts index 855bc9ec..374cc21d 100644 --- a/src/plugins/context/context-manager.ts +++ b/src/plugins/context/context-manager.ts @@ -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 { + if (!this.historyStore) return null; + return this.historyStore.read(sessionId); + } + register(provider: ContextProvider): void { this.providers.push(provider); } diff --git a/src/plugins/context/history/__tests__/history-store.test.ts b/src/plugins/context/history/__tests__/history-store.test.ts index 86dec108..b52ed78c 100644 --- a/src/plugins/context/history/__tests__/history-store.test.ts +++ b/src/plugins/context/history/__tests__/history-store.test.ts @@ -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"); diff --git a/src/plugins/context/history/history-store.ts b/src/plugins/context/history/history-store.ts index e6d9e2ce..413b3ecc 100644 --- a/src/plugins/context/history/history-store.ts +++ b/src/plugins/context/history/history-store.ts @@ -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; } } diff --git a/src/plugins/context/index.ts b/src/plugins/context/index.ts index 9ca43611..439ce420 100644 --- a/src/plugins/context/index.ts +++ b/src/plugins/context/index.ts @@ -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