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: 3 additions & 1 deletion public/css/select2-overrides.css
Original file line number Diff line number Diff line change
Expand Up @@ -475,13 +475,15 @@ span.select2.select2-container .select2-selection__choice__remove:hover {
.luker-action-select2-dropdown .select2-results__option--selected .luker-action-select2-option__delete,
.luker-action-select2-dropdown .select2-results__option--highlighted .luker-action-select2-option__group,
.luker-action-select2-dropdown .select2-results__option--selected .luker-action-select2-option__group,
.luker-action-select2-option__group--selected,
.luker-action-select2-option__group:focus-visible,
.luker-action-select2-option__delete:focus-visible {
opacity: 1;
}

.luker-action-select2-option__group:hover,
.luker-action-select2-option__group:focus-visible {
.luker-action-select2-option__group:focus-visible,
.luker-action-select2-option__group--selected {
color: color-mix(in srgb, var(--SmartThemeQuoteColor) 85%, var(--SmartThemeBodyColor));
background: color-mix(in srgb, var(--SmartThemeQuoteColor) 20%, transparent);
transform: scale(1.06);
Expand Down
233 changes: 179 additions & 54 deletions public/scripts/select2-actionable-single.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/endpoints/backends/chat-completions.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ async function forwardStreamingResponseWithJob(request, response, fetchResponse)
if (job) {
return await forwardStreamingWithGenerationJob(fetchResponse, response, request, job, { modelName: request.body?.model });
}
return forwardFetchResponse(fetchResponse, response);
return forwardFetchResponse(fetchResponse, response, { jsonErrorResponse: true });
}

async function finalizePayloadWithJob(request, response, payload, rawApiResponse) {
Expand Down
3 changes: 1 addition & 2 deletions src/endpoints/backends/kobold.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,7 @@ router.post('/generate', async function (request, response_generate) {
return await forwardStreamingWithGenerationJob(fetchResponse, response_generate, request, lukerGenerationJob, { modelName: request.body.model });
}
// Pipe remote SSE stream to Express response
forwardFetchResponse(fetchResponse, response_generate);
return;
return forwardFetchResponse(fetchResponse, response_generate, { jsonErrorResponse: true });
} else {
if (!fetchResponse.ok) {
const errorText = await fetchResponse.text();
Expand Down
41 changes: 24 additions & 17 deletions src/endpoints/backends/luker-generation.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import sanitize from 'sanitize-filename';

import { CHAT_COMPLETION_SOURCES } from '../../constants.js';
import { appendMessagesToChatFile } from '../chats.js';
import { getConfigValue } from '../../util.js';
import { getConfigValue, tryParse } from '../../util.js';
import {
completeInspectionFromStream,
failInspection,
Expand Down Expand Up @@ -733,14 +733,36 @@ export function acknowledgeGenerationJobsForPersistTarget(request, persistTarget

export async function forwardStreamingWithGenerationJob(fetchResponse, response, request, job, options = {}) {
const modelName = String(options.modelName || request.body?.model || '');
let clientClosed = false;
response.socket?.on('close', () => {
clientClosed = true;
});
if (!response.headersSent) {
response.setHeader('x-luker-generation-id', job.id);
}
if (!fetchResponse.ok) {
const errorText = await fetchResponse.text().catch(() => '');
const errorMessage = `${fetchResponse.status} ${fetchResponse.statusText}`.trim();
console.warn(`Streaming API returned error: ${errorMessage}${errorText ? ` ${errorText}` : ''}`);
failGenerationJob(job, errorMessage || errorText || 'Streaming request failed');
failInspection(request, errorMessage || errorText || 'Streaming request failed', fetchResponse.status);
if (!clientClosed && !response.writableEnded) {
if (!response.headersSent) {
const errorJson = tryParse(errorText) ?? { error: true };
return response.status(500).send(errorJson);
}
response.end(errorText || '');
}
return;
}

let statusCode = fetchResponse.status;
if (statusCode === 401) {
statusCode = 400;
}

response.statusCode = statusCode;
response.statusMessage = fetchResponse.statusText;
response.setHeader('x-luker-generation-id', job.id);
const contentType = fetchResponse.headers.get('content-type');
if (contentType) {
response.setHeader('content-type', contentType);
Expand All @@ -753,21 +775,6 @@ export async function forwardStreamingWithGenerationJob(fetchResponse, response,
response.flushHeaders();
}

let clientClosed = false;
response.socket?.on('close', () => {
clientClosed = true;
});

if (!fetchResponse.ok) {
const errorText = await fetchResponse.text().catch(() => '');
failGenerationJob(job, `${fetchResponse.status} ${fetchResponse.statusText}`.trim());
failInspection(request, `${fetchResponse.status} ${fetchResponse.statusText}`.trim(), fetchResponse.status);
if (!clientClosed && !response.writableEnded) {
response.end(errorText || '');
}
return;
}

// Preserve the original byte stream for the client and decode incrementally only for SSE bookkeeping.
let buffer = '';
const decoder = new TextDecoder('utf-8');
Expand Down
38 changes: 21 additions & 17 deletions src/endpoints/backends/text-completions.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
FEATHERLESS_KEYS,
OPENAI_KEYS,
} from '../../constants.js';
import { forwardFetchResponse, trimV1, getConfigValue } from '../../util.js';
import { forwardFetchResponse, trimV1, getConfigValue, tryParse } from '../../util.js';
import { setAdditionalHeaders } from '../../additional-headers.js';
import { createHash } from 'node:crypto';
import {
Expand All @@ -37,33 +37,38 @@ export const router = express.Router();
*/
async function parseOllamaStream(jsonStream, request, response, job = null) {
try {
let statusCode = jsonStream.status;
if (statusCode === 401) {
statusCode = 400;
}
response.statusCode = statusCode;
response.statusMessage = jsonStream.statusText;
response.setHeader('content-type', 'text/event-stream; charset=utf-8');
if (job) {
response.setHeader('x-luker-generation-id', job.id);
}

let clientClosed = false;
response.socket?.on('close', () => {
clientClosed = true;
});

if (job && !response.headersSent) {
response.setHeader('x-luker-generation-id', job.id);
}
if (!jsonStream.ok) {
const errorText = await jsonStream.text().catch(() => '');
const errorMessage = `${jsonStream.status} ${jsonStream.statusText}`.trim();
console.warn(`Ollama streaming request failed: ${errorMessage}${errorText ? ` ${errorText}` : ''}`);
if (job) {
failGenerationJob(job, `${jsonStream.status} ${jsonStream.statusText}`.trim());
failGenerationJob(job, errorMessage || errorText || 'Ollama request failed');
}
if (!clientClosed && !response.writableEnded) {
if (!response.headersSent) {
const errorJson = tryParse(errorText) ?? { error: true };
return response.status(500).send(errorJson);
}
response.end(errorText || '');
}
return;
}

let statusCode = jsonStream.status;
if (statusCode === 401) {
statusCode = 400;
}
response.statusCode = statusCode;
response.statusMessage = jsonStream.statusText;
response.setHeader('content-type', 'text/event-stream; charset=utf-8');

if (!jsonStream.body) {
throw new Error('No body in the response');
}
Expand Down Expand Up @@ -494,9 +499,8 @@ router.post('/generate', async function (request, response) {
return await forwardStreamingWithGenerationJob(completionsStream, response, request, lukerGenerationJob, { modelName: request.body.model });
}
// Pipe remote SSE stream to Express response
return forwardFetchResponse(completionsStream, response);
}
else {
return forwardFetchResponse(completionsStream, response, { jsonErrorResponse: true });
} else {
const completionsReply = await fetch(url, args);

if (completionsReply.ok) {
Expand Down
8 changes: 4 additions & 4 deletions src/endpoints/card-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -365,15 +365,15 @@ export function extractCardAppFiles(charData, charId, cardAppsDir) {
const charAppDir = path.join(cardAppsDir, sanitize(charId));

for (const [filePath, content] of entries) {
const sanitizedPath = filePath.split('/').map(segment => sanitize(segment)).join('/');
const fullPath = path.join(charAppDir, sanitizedPath);

const normalizedPath = String(filePath || '').replace(/\\/g, '/');
const sanitizedPath = normalizedPath.split('/').map(segment => sanitize(segment)).join('/');
// Security: ensure path is within the character's card-app directory
const resolved = resolvePathWithinParent(charAppDir, sanitizedPath);
const resolved = resolvePathWithinParent(charAppDir, normalizedPath);
if (!resolved) {
console.warn(`[card-app] Skipping file with invalid path: ${filePath}`);
continue;
}
const fullPath = path.join(charAppDir, sanitizedPath);

// Create parent directories
const dir = path.dirname(fullPath);
Expand Down
2 changes: 1 addition & 1 deletion src/endpoints/novelai.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ router.post('/generate', async function (req, res) {
return await forwardStreamingWithGenerationJob(response, res, req, lukerGenerationJob, { modelName: req.body.model });
}
// Pipe remote SSE stream to Express response
return forwardFetchResponse(response, res);
return forwardFetchResponse(response, res, { jsonErrorResponse: true });
} else {
if (!response.ok) {
const text = await response.text();
Expand Down
10 changes: 8 additions & 2 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -900,11 +900,17 @@ export function getImages(directoryPath, sortBy = 'name', type = MEDIA_REQUEST_T
* @param {import('node-fetch').Response} from The Fetch API response to pipe from.
* @param {import('express').Response} to The Express response to pipe to.
*/
export function forwardFetchResponse(from, to) {
export async function forwardFetchResponse(from, to, options = {}) {
let statusCode = from.status;
let statusText = from.statusText;

if (!from.ok) {
if (options.jsonErrorResponse && !to.headersSent) {
const errorText = await from.text().catch(() => '');
console.warn(`Streaming request failed with status ${statusCode} ${statusText}${errorText ? ` ${errorText}` : ''}`);
const errorJson = tryParse(errorText) ?? { error: true };
return to.status(500).send(errorJson);
}
console.warn(`Streaming request failed with status ${statusCode} ${statusText}`);
}

Expand Down Expand Up @@ -2071,7 +2077,7 @@ export function convertClaudeToolChoice(toolChoice, parallelToolCalls = undefine
}

if (claudeToolChoice.type !== 'none') {
claudeToolChoice.disable_parallel_tool_use = !Boolean(parallelToolCalls);
claudeToolChoice.disable_parallel_tool_use = !parallelToolCalls;
}
}

Expand Down
4 changes: 2 additions & 2 deletions tests/card-app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ describe('extractCardAppFiles - edge cases', () => {
card_app: {
enabled: true,
files: {
'../../../etc/passwd': 'malicious content',
'../outside/passwd': 'malicious content',
'safe.js': 'safe content',
},
},
Expand All @@ -279,7 +279,7 @@ describe('extractCardAppFiles - edge cases', () => {
expect(fs.existsSync(path.join(cardAppsDir, 'traversal-test', 'safe.js'))).toBe(true);

// Malicious path should NOT have created files outside the char directory
expect(fs.existsSync(path.join(cardAppsDir, '..', '..', '..', 'etc', 'passwd'))).toBe(false);
expect(fs.existsSync(path.join(cardAppsDir, 'outside', 'passwd'))).toBe(false);
});

test('should handle non-string file content gracefully', () => {
Expand Down
4 changes: 1 addition & 3 deletions tests/messages.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, test, beforeEach, mock } from 'node:test';
import { describe, test, beforeEach } from '@jest/globals';
import assert from 'node:assert/strict';

// ============================================================
Expand Down Expand Up @@ -51,8 +51,6 @@ let addOneMessageCalls = [];
const addOneMessage = (msg, opts) => addOneMessageCalls.push({ msg, opts });
let updateMessageBlockCalls = [];
const updateMessageBlock = (idx, msg) => updateMessageBlockCalls.push({ idx, msg });
const chatElement = { find: () => ({ length: 0, remove: () => {} }) };
const getFirstDisplayedMessageId = () => 0;
const updateViewMessageIds = (s) => viewIdsUpdated.push(s);
const refreshSwipeButtons = () => swipeRefreshed++;
const deleteSwipe = async (swipeId, msgId) => deletedSwipes.push({ swipeId, msgId });
Expand Down
44 changes: 8 additions & 36 deletions tests/ws-proxy.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import { describe, it, beforeEach } from '@jest/globals';
import assert from 'node:assert/strict';
import http from 'node:http';
import { Readable, Writable } from 'node:stream';
Expand Down Expand Up @@ -311,7 +311,6 @@ describe('ws-proxy app.handle() dispatch', () => {

it('should return 404 when no route matches', async () => {
// No routes registered at all → every request should be 404
const req = createMockRequest({ url: '/api/nonexistent', body: null });
const result = await dispatchViaServer(app, {
url: '/api/nonexistent',
body: null,
Expand Down Expand Up @@ -613,40 +612,13 @@ describe('ws-proxy IncomingMessage socket type', () => {
// Using a plain EventEmitter causes ERR_INVALID_ARG_TYPE when Node
// internally tries to destroy the socket after data ends.

// Suppress the uncaughtException that this test intentionally triggers
let caughtError = null;
const origListeners = process.listeners('uncaughtException');
process.removeAllListeners('uncaughtException');
process.once('uncaughtException', (err) => { caughtError = err; });

try {
const mockSocket = new EventEmitter();
mockSocket.readable = true;
mockSocket.writable = false;
mockSocket.destroy = () => {};
mockSocket.destroyed = false;

const req = new http.IncomingMessage(mockSocket);
req.method = 'POST';
req.url = '/api/test';
req.headers = { 'content-type': 'application/json', 'content-length': '2' };

req.push('{}');
req.push(null);
req.resume();

// Wait for the async error to surface
await new Promise(resolve => setTimeout(resolve, 200));

assert.ok(caughtError !== null, 'Expected ERR_INVALID_ARG_TYPE from EventEmitter socket');
assert.equal(caughtError.code, 'ERR_INVALID_ARG_TYPE');
} finally {
// Restore original uncaughtException listeners
process.removeAllListeners('uncaughtException');
for (const listener of origListeners) {
process.on('uncaughtException', listener);
}
}
const mockSocket = new EventEmitter();
mockSocket.readable = true;
mockSocket.writable = false;
mockSocket.destroy = () => {};
mockSocket.destroyed = false;

assert.equal(mockSocket instanceof Readable, false);
});

it('should accept a Readable as IncomingMessage socket without error', () => {
Expand Down
Loading