From 1ca57cd6211db719d817200914d4759751c87631 Mon Sep 17 00:00:00 2001 From: lngdao Date: Thu, 2 Apr 2026 02:52:34 +0700 Subject: [PATCH 1/3] feat(api-server): add session history endpoint GET /api/v1/sessions/:sessionId/history returns the full conversation history for a session from the context plugin's HistoryStore. --- src/plugins/api-server/index.ts | 3 +++ src/plugins/api-server/routes/sessions.ts | 18 ++++++++++++++++++ src/plugins/api-server/routes/types.ts | 3 +++ src/plugins/context/index.ts | 1 + 4 files changed, 25 insertions(+) diff --git a/src/plugins/api-server/index.ts b/src/plugins/api-server/index.ts index eee5f3ae..62b2c61b 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 { HistoryStore } from '../context/history/history-store.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 historyStore = ctx.getService('history-store') // 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, + historyStore, } // Register all route plugins under /api/v1/ diff --git a/src/plugins/api-server/routes/sessions.ts b/src/plugins/api-server/routes/sessions.ts index 82cded16..b9396201 100644 --- a/src/plugins/api-server/routes/sessions.ts +++ b/src/plugins/api-server/routes/sessions.ts @@ -459,6 +459,24 @@ 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) => { + const { sessionId: rawId } = SessionIdParamSchema.parse(request.params); + const sessionId = decodeURIComponent(rawId); + if (!deps.historyStore) { + throw new NotFoundError('HISTORY_UNAVAILABLE', 'History store not available'); + } + const history = await deps.historyStore.read(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..2c604763 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 { HistoryStore } from '../../context/history/history-store.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; + /** History store for reading session conversation history. */ + historyStore?: HistoryStore; } diff --git a/src/plugins/context/index.ts b/src/plugins/context/index.ts index 9ca43611..36685924 100644 --- a/src/plugins/context/index.ts +++ b/src/plugins/context/index.ts @@ -60,6 +60,7 @@ const contextPlugin: OpenACPPlugin = { manager.register(new HistoryProvider(store, getRecords)) manager.register(new EntireProvider()) ctx.registerService('context', manager) + ctx.registerService('history-store', store) // Middleware: capture user prompts ctx.registerMiddleware('agent:beforePrompt', { From b94761552e4cf3073e2dbad6deef96c4a1c59235 Mon Sep 17 00:00:00 2001 From: lngdao Date: Thu, 2 Apr 2026 10:18:43 +0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(api-server):=20address=20PR=20#189=20re?= =?UTF-8?q?view=20=E2=80=94=20session=20check,=20path=20traversal,=20503?= =?UTF-8?q?=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add session existence check before querying history store - Add regex constraint to SessionIdParamSchema to prevent path traversal - Return 503 instead of 404 when history store is unavailable --- src/plugins/api-server/routes/sessions.ts | 13 +++++++++++-- src/plugins/api-server/schemas/sessions.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/plugins/api-server/routes/sessions.ts b/src/plugins/api-server/routes/sessions.ts index b9396201..d9d30545 100644 --- a/src/plugins/api-server/routes/sessions.ts +++ b/src/plugins/api-server/routes/sessions.ts @@ -463,11 +463,20 @@ export async function sessionRoutes( app.get<{ Params: { sessionId: string } }>( '/:sessionId/history', { preHandler: requireScopes('sessions:read') }, - async (request) => { + async (request, reply) => { const { sessionId: rawId } = SessionIdParamSchema.parse(request.params); const sessionId = decodeURIComponent(rawId); + const session = deps.core.sessionManager.getSession(sessionId); + if (!session) { + throw new NotFoundError( + 'SESSION_NOT_FOUND', + `Session "${sessionId}" not found`, + ); + } if (!deps.historyStore) { - throw new NotFoundError('HISTORY_UNAVAILABLE', 'History store not available'); + return reply.status(503).send({ + error: { code: 'HISTORY_UNAVAILABLE', message: 'History store not available', statusCode: 503 }, + }); } const history = await deps.historyStore.read(sessionId); if (!history) { diff --git a/src/plugins/api-server/schemas/sessions.ts b/src/plugins/api-server/schemas/sessions.ts index ab1d2c92..adb7fc95 100644 --- a/src/plugins/api-server/schemas/sessions.ts +++ b/src/plugins/api-server/schemas/sessions.ts @@ -40,7 +40,7 @@ export const UpdateSessionBodySchema = z.object({ }); export const SessionIdParamSchema = z.object({ - sessionId: z.string().min(1), + sessionId: z.string().min(1).regex(/^[a-zA-Z0-9_:\-]+$/, 'Invalid session ID format'), }); export const ConfigIdParamSchema = z.object({ From a846911d72db74c0848f76724f1c3c09d25cf5a8 Mon Sep 17 00:00:00 2001 From: 0xmrpeter <0xmrpeter@gmail.com> Date: Thu, 2 Apr 2026 11:22:20 +0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(api-server):=20address=20PR=20#189=20re?= =?UTF-8?q?view=20=E2=80=94=20security,=20encapsulation,=20error=20handlin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert SessionIdParamSchema regex that was a breaking change for all session routes - Add path traversal protection in HistoryStore.filePath() using path.basename() - Remove decodeURIComponent bypass risk in history route - Add ServiceUnavailableError class for consistent 503 error handling - Refactor: route history access through ContextManager.getHistory() instead of exposing internal HistoryStore as standalone service - Add path traversal and ContextManager.getHistory() tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugins/api-server/index.ts | 6 +- .../api-server/middleware/error-handler.ts | 21 +++++++ src/plugins/api-server/routes/sessions.ts | 16 ++--- src/plugins/api-server/routes/types.ts | 6 +- src/plugins/api-server/schemas/sessions.ts | 2 +- .../__tests__/context-manager-history.test.ts | 58 +++++++++++++++++++ src/plugins/context/context-manager.ts | 12 ++++ .../history/__tests__/history-store.test.ts | 27 +++++++++ src/plugins/context/history/history-store.ts | 7 ++- src/plugins/context/index.ts | 2 +- 10 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 src/plugins/context/__tests__/context-manager-history.test.ts diff --git a/src/plugins/api-server/index.ts b/src/plugins/api-server/index.ts index 62b2c61b..d97721f5 100644 --- a/src/plugins/api-server/index.ts +++ b/src/plugins/api-server/index.ts @@ -6,7 +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 { HistoryStore } from '../context/history/history-store.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' @@ -230,7 +230,7 @@ function createApiServerPlugin(): OpenACPPlugin { // Resolve optional services for route deps const topicManager = ctx.getService('topic-manager') const commandRegistry = ctx.getService('command-registry') - const historyStore = ctx.getService('history-store') + const contextManager = ctx.getService('context') // Build auth pre-handler for route-level auth on unauthenticated route groups const routeAuthPreHandler = createAuthPreHandler(() => secret, () => jwtSecret, tokenStore) @@ -242,7 +242,7 @@ function createApiServerPlugin(): OpenACPPlugin { getVersion, commandRegistry, authPreHandler: routeAuthPreHandler, - historyStore, + 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 d9d30545..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 { @@ -464,8 +464,7 @@ export async function sessionRoutes( '/:sessionId/history', { preHandler: requireScopes('sessions:read') }, async (request, reply) => { - const { sessionId: rawId } = SessionIdParamSchema.parse(request.params); - const sessionId = decodeURIComponent(rawId); + const { sessionId } = SessionIdParamSchema.parse(request.params); const session = deps.core.sessionManager.getSession(sessionId); if (!session) { throw new NotFoundError( @@ -473,12 +472,13 @@ export async function sessionRoutes( `Session "${sessionId}" not found`, ); } - if (!deps.historyStore) { - return reply.status(503).send({ - error: { code: 'HISTORY_UNAVAILABLE', message: 'History store not available', statusCode: 503 }, - }); + if (!deps.contextManager) { + throw new ServiceUnavailableError( + 'HISTORY_UNAVAILABLE', + 'History store not available', + ); } - const history = await deps.historyStore.read(sessionId); + const history = await deps.contextManager.getHistory(sessionId); if (!history) { throw new NotFoundError('HISTORY_NOT_FOUND', `No history for session "${sessionId}"`); } diff --git a/src/plugins/api-server/routes/types.ts b/src/plugins/api-server/routes/types.ts index 2c604763..62a3599c 100644 --- a/src/plugins/api-server/routes/types.ts +++ b/src/plugins/api-server/routes/types.ts @@ -2,7 +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 { HistoryStore } from '../../context/history/history-store.js'; +import type { ContextManager } from '../../context/context-manager.js'; /** * Dependencies injected into Fastify route plugins. @@ -16,6 +16,6 @@ export interface RouteDeps { commandRegistry?: CommandRegistry; /** Auth pre-handler for routes registered without global auth (e.g. system routes). */ authPreHandler?: preHandlerHookHandler; - /** History store for reading session conversation history. */ - historyStore?: HistoryStore; + /** Context manager for reading session conversation history. */ + contextManager?: ContextManager; } diff --git a/src/plugins/api-server/schemas/sessions.ts b/src/plugins/api-server/schemas/sessions.ts index adb7fc95..ab1d2c92 100644 --- a/src/plugins/api-server/schemas/sessions.ts +++ b/src/plugins/api-server/schemas/sessions.ts @@ -40,7 +40,7 @@ export const UpdateSessionBodySchema = z.object({ }); export const SessionIdParamSchema = z.object({ - sessionId: z.string().min(1).regex(/^[a-zA-Z0-9_:\-]+$/, 'Invalid session ID format'), + sessionId: z.string().min(1), }); export const ConfigIdParamSchema = z.object({ 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 36685924..439ce420 100644 --- a/src/plugins/context/index.ts +++ b/src/plugins/context/index.ts @@ -59,8 +59,8 @@ 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) - ctx.registerService('history-store', store) // Middleware: capture user prompts ctx.registerMiddleware('agent:beforePrompt', {