fix: lighten session list payloads (#1538)

* lighten session list

* fetch full session on open

* sanitize session list

* sanitize global sessions

* preserve revert markers in session lists

* fix: preserve session metadata in list sanitizers

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Hristo Karamanliev
2026-06-11 23:06:19 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 1c274d0a56
commit 9685630436
8 changed files with 701 additions and 39 deletions
+123 -1
View File
@@ -93,6 +93,69 @@ export const createSseBoundaryTracker = () => {
};
};
const SESSION_LIST_ALLOWED_FIELDS = [
'id',
'slug',
'projectID',
'workspaceID',
'directory',
'path',
'parentID',
'title',
'agent',
'model',
'version',
'time',
'cost',
'tokens',
'share',
'metadata',
'project',
];
export const sanitizeSessionListItem = (session) => {
if (!session || typeof session !== 'object' || Array.isArray(session)) {
return session;
}
const sanitized = {};
for (const key of SESSION_LIST_ALLOWED_FIELDS) {
if (key in session) {
sanitized[key] = session[key];
}
}
const summary = session.summary;
if (summary && typeof summary === 'object' && !Array.isArray(summary)) {
const summaryWithoutDiffs = { ...summary };
delete summaryWithoutDiffs.diffs;
sanitized.summary = summaryWithoutDiffs;
}
const revert = session.revert;
if (revert && typeof revert === 'object' && !Array.isArray(revert)) {
const revertMarker = {};
if (typeof revert.messageID === 'string') {
revertMarker.messageID = revert.messageID;
}
if (typeof revert.partID === 'string') {
revertMarker.partID = revert.partID;
}
if (Object.keys(revertMarker).length > 0) {
sanitized.revert = revertMarker;
}
}
return sanitized;
};
export const sanitizeSessionListPayload = (payload) => {
if (!Array.isArray(payload)) {
return payload;
}
return payload.map((session) => sanitizeSessionListItem(session));
};
export const registerOpenCodeProxy = (app, deps) => {
const {
fs,
@@ -348,6 +411,57 @@ export const registerOpenCodeProxy = (app, deps) => {
}
};
const forwardSanitizedSessionListRequest = async (req, res, next, logLabel) => {
try {
const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0
? req.originalUrl
: (typeof req.url === 'string' ? req.url : '');
const upstreamPathRaw = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
const upstreamPath = await canonicalizeDirectoryQuery(upstreamPathRaw);
const upstream = await fetch(buildOpenCodeUrl(upstreamPath, ''), {
method: 'GET',
headers: {
...collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()),
accept: 'application/json',
'accept-encoding': 'identity',
},
});
res.status(upstream.status);
applyForwardProxyResponseHeaders(upstream.headers, res);
const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8';
const bodyText = await upstream.text();
if (!contentType.toLowerCase().includes('application/json')) {
res.setHeader('content-type', contentType);
res.end(bodyText);
return;
}
let payload;
try {
payload = JSON.parse(bodyText);
} catch {
res.setHeader('content-type', contentType);
res.end(bodyText);
return;
}
res.setHeader('content-type', contentType);
res.json(sanitizeSessionListPayload(payload));
} catch (error) {
if (isAbortError(error)) {
return;
}
console.error(`[proxy] OpenCode ${logLabel} proxy error:`, error?.message ?? error);
if (!res.headersSent) {
res.status(503).json({ error: 'OpenCode service unavailable' });
return;
}
next(error);
}
};
// Ensure API prefix is detected before proxying
app.use('/api', (_req, _res, next) => {
ensureOpenCodeApiPrefix();
@@ -453,7 +567,7 @@ export const registerOpenCodeProxy = (app, deps) => {
return bTime - aTime;
});
console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`);
return res.json(merged);
return res.json(sanitizeSessionListPayload(merged));
} catch (error) {
console.log(`[SessionMerge] Error: ${error.message}, falling through`);
next();
@@ -461,9 +575,17 @@ export const registerOpenCodeProxy = (app, deps) => {
});
}
app.get('/api/session', (req, res, next) => {
return forwardSanitizedSessionListRequest(req, res, next, 'session.list');
});
app.get('/api/global/event', forwardSseRequest);
app.get('/api/event', forwardSseRequest);
app.get('/api/experimental/session', (req, res, next) => {
return forwardSanitizedSessionListRequest(req, res, next, 'experimental.session');
});
// Generic proxy for non-SSE OpenCode API routes.
const apiProxy = createProxyMiddleware({
target: resolveProxyTarget(),
+193
View File
@@ -237,6 +237,199 @@ describe('OpenCode proxy SSE forwarding', () => {
expect(Number(data.contentLength)).toBeGreaterThan(0);
});
it('sanitizes experimental session list responses and forwards query params', async () => {
let seenQuery = null;
let seenAuth = null;
const upstream = express();
upstream.get('/experimental/session', (req, res) => {
seenQuery = req.query;
seenAuth = req.headers.authorization ?? null;
res.setHeader('X-Next-Cursor', '123');
res.json([
{
id: 'ses_1',
slug: 'alpha',
projectID: 'proj_1',
workspaceID: 'ws_1',
directory: '/repo/app',
path: '/repo/app',
parentID: 'ses_parent',
title: 'Alpha',
agent: 'build',
model: { id: 'gpt-5', providerID: 'openai', variant: 'default' },
version: '1.0.0',
time: { created: 1, updated: 2 },
cost: 7,
tokens: { input: 10, output: 20 },
share: { url: 'https://share.example/ses_1' },
project: { id: 'proj_1', worktree: '/repo/app' },
summary: {
additions: 5,
deletions: 3,
files: 2,
diffs: [{ patch: '@@ -1 +1 @@', additions: 5, deletions: 3 }],
},
metadata: { openchamber: { kind: 'review', originalSessionID: 'ses_original' } },
permission: [{ permission: 'todowrite', action: 'deny', pattern: '*' }],
revert: { messageID: 'msg_1', partID: 'part_1', snapshot: 'abc123', diff: 'diff --git a/x b/x' },
},
]);
});
upstreamServer = await listen(upstream);
const upstreamPort = upstreamServer.address().port;
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
const app = express();
registerOpenCodeProxy(app, {
fs: {
promises: {
realpath: async (value) => value === '/link/repo' ? '/real/repo' : value,
},
},
os: {},
path,
OPEN_CODE_READY_GRACE_MS: 0,
getRuntime: () => ({
openCodePort: upstreamPort,
openCodeBaseUrl: externalBaseUrl,
isOpenCodeReady: true,
openCodeNotReadySince: 0,
isRestartingOpenCode: false,
}),
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer session-token' }),
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
ensureOpenCodeApiPrefix: () => {},
});
proxyServer = await listen(app);
const proxyPort = proxyServer.address().port;
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/experimental/session?archived=false&limit=500&cursor=99&roots=true&directory=%2Flink%2Frepo`);
expect(response.status).toBe(200);
expect(response.headers.get('x-next-cursor')).toBe('123');
expect(seenAuth).toBe('Bearer session-token');
expect(seenQuery).toMatchObject({
archived: 'false',
limit: '500',
cursor: '99',
roots: 'true',
directory: '/real/repo',
});
await expect(response.json()).resolves.toEqual([
{
id: 'ses_1',
slug: 'alpha',
projectID: 'proj_1',
workspaceID: 'ws_1',
directory: '/repo/app',
path: '/repo/app',
parentID: 'ses_parent',
title: 'Alpha',
agent: 'build',
model: { id: 'gpt-5', providerID: 'openai', variant: 'default' },
version: '1.0.0',
time: { created: 1, updated: 2 },
cost: 7,
tokens: { input: 10, output: 20 },
share: { url: 'https://share.example/ses_1' },
metadata: { openchamber: { kind: 'review', originalSessionID: 'ses_original' } },
project: { id: 'proj_1', worktree: '/repo/app' },
summary: { additions: 5, deletions: 3, files: 2 },
revert: { messageID: 'msg_1', partID: 'part_1' },
},
]);
});
it('sanitizes session list responses without sanitizing session detail responses', async () => {
let seenListQuery = null;
const upstream = express();
upstream.get('/session', (req, res) => {
seenListQuery = req.query;
res.json([
{
id: 'ses_1',
directory: '/repo/app',
title: 'Alpha',
time: { created: 1, updated: 2 },
summary: {
additions: 5,
deletions: 3,
files: 2,
diffs: [{ patch: '@@ -1 +1 @@', additions: 5, deletions: 3 }],
},
metadata: { custom: { value: 'kept' } },
revert: { messageID: 'msg_1', partID: 'part_1', snapshot: 'abc123', diff: 'diff --git a/x b/x' },
},
]);
});
upstream.get('/session/abc', (_req, res) => {
res.json({
id: 'abc',
directory: '/repo/app',
title: 'Detail',
summary: { diffs: [{ patch: '@@ -1 +1 @@' }] },
revert: { messageID: 'msg_1', snapshot: 'abc123', diff: 'diff --git a/x b/x' },
});
});
upstreamServer = await listen(upstream);
const upstreamPort = upstreamServer.address().port;
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
const app = express();
registerOpenCodeProxy(app, {
fs: {
promises: {
realpath: async (value) => value === '/link/repo' ? '/real/repo' : value,
},
},
os: {},
path,
OPEN_CODE_READY_GRACE_MS: 0,
getRuntime: () => ({
openCodePort: upstreamPort,
openCodeBaseUrl: externalBaseUrl,
isOpenCodeReady: true,
openCodeNotReadySince: 0,
isRestartingOpenCode: false,
}),
getOpenCodeAuthHeaders: () => ({}),
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
ensureOpenCodeApiPrefix: () => {},
});
proxyServer = await listen(app);
const proxyPort = proxyServer.address().port;
const listResponse = await fetch(`http://127.0.0.1:${proxyPort}/api/session?directory=%2Flink%2Frepo`);
expect(listResponse.status).toBe(200);
expect(seenListQuery).toMatchObject({ directory: '/real/repo' });
await expect(listResponse.json()).resolves.toEqual([
{
id: 'ses_1',
directory: '/repo/app',
title: 'Alpha',
time: { created: 1, updated: 2 },
summary: { additions: 5, deletions: 3, files: 2 },
metadata: { custom: { value: 'kept' } },
revert: { messageID: 'msg_1', partID: 'part_1' },
},
]);
const detailResponse = await fetch(`http://127.0.0.1:${proxyPort}/api/session/abc`);
expect(detailResponse.status).toBe(200);
await expect(detailResponse.json()).resolves.toEqual({
id: 'abc',
directory: '/repo/app',
title: 'Detail',
summary: { diffs: [{ patch: '@@ -1 +1 @@' }] },
revert: { messageID: 'msg_1', snapshot: 'abc123', diff: 'diff --git a/x b/x' },
});
});
it('forwards unparsed SDK JSON bodies to generic API proxy requests', async () => {
const upstream = express();
upstream.post('/session/abc/revert', express.json(), (req, res) => {