This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { IncomingMessage, Server as HttpServer } from 'node:http';
|
||||
import type { Server as HttpsServer } from 'node:https';
|
||||
import type { Socket } from 'node:net';
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
import { Router, json, type Request, type Response } from 'express';
|
||||
@@ -81,7 +82,9 @@ const PATH_RE = /^\/+api\/local\/tasks\/([^/]+)\/console\/ws$/;
|
||||
* (the client gets a 1006 abnormal close) so we don't leak failure
|
||||
* reasons over the upgrade channel. The reason is always logged.
|
||||
*/
|
||||
export function attachConsoleWs(server: HttpServer, deps: ConsoleWsDeps): void {
|
||||
// Both http.Server and https.Server emit the 'upgrade' event used for WSS,
|
||||
// so either type is a valid host for the console WebSocket upgrade handler.
|
||||
export function attachConsoleWs(server: HttpServer | HttpsServer, deps: ConsoleWsDeps): void {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on('upgrade', async (req, socket, head) => {
|
||||
|
||||
@@ -37,7 +37,11 @@ function makeUser(overrides: Partial<Express.User> = {}): Express.User {
|
||||
};
|
||||
}
|
||||
|
||||
function makeApp(repo: Repository, user?: Express.User): express.Application {
|
||||
function makeApp(
|
||||
repo: Repository,
|
||||
user?: Express.User,
|
||||
opts: { authActive?: boolean } = {},
|
||||
): express.Application {
|
||||
const app = express();
|
||||
if (user) {
|
||||
app.use((req, _res, next) => {
|
||||
@@ -45,7 +49,7 @@ function makeApp(repo: Repository, user?: Express.User): express.Application {
|
||||
next();
|
||||
});
|
||||
}
|
||||
mountLocalFilesApi(app, repo);
|
||||
mountLocalFilesApi(app, repo, { authActive: opts.authActive ?? true });
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -55,6 +59,7 @@ beforeEach(() => {
|
||||
mkdirSync(join(ws, 'output', 'sub'), { recursive: true });
|
||||
writeFileSync(join(ws, 'input', 'data.csv'), 'a,b\n1,2\n');
|
||||
writeFileSync(join(ws, 'output', 'report.md'), '# report');
|
||||
writeFileSync(join(ws, 'output', 'report.html'), '<!doctype html><script>window.__ran = true</script><h1>report</h1>');
|
||||
writeFileSync(join(ws, 'output', 'sub', 'nested.txt'), 'nested');
|
||||
// A file just outside the workspace that traversal must never reach.
|
||||
writeFileSync(join(ws, '..', `outside-${process.pid}.txt`), 'secret');
|
||||
@@ -153,6 +158,80 @@ describe('GET /api/local/tasks/:taskId/files/raw', () => {
|
||||
expect(res.headers['content-type']).toContain('markdown');
|
||||
});
|
||||
|
||||
it('sandboxes raw HTML by default', async () => {
|
||||
const res = await request(makeApp(makeRepo(), makeUser()))
|
||||
.get('/api/local/tasks/1/files/raw?section=output&path=report.html');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||
});
|
||||
|
||||
it('allows the task OWNER to open trusted raw HTML without the sandbox header', async () => {
|
||||
const res = await request(makeApp(makeRepo(), makeUser()))
|
||||
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-security-policy']).toBeUndefined();
|
||||
expect(res.headers['content-type']).toContain('html');
|
||||
});
|
||||
|
||||
it("keeps trusted raw HTML sandboxed even for an admin on another user's task (user→admin lure)", async () => {
|
||||
const res = await request(makeApp(makeRepo(), makeUser({ id: 'admin-9', role: 'admin' })))
|
||||
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||
});
|
||||
|
||||
it('keeps trusted raw HTML sandboxed for a NON-owner viewer of a shared task', async () => {
|
||||
const repo = makeRepo({
|
||||
getLocalTask: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
ownerId: 'user-1',
|
||||
visibility: 'public',
|
||||
workspacePath: ws,
|
||||
}),
|
||||
} as Partial<Repository>);
|
||||
const res = await request(makeApp(repo, makeUser({ id: 'user-2' })))
|
||||
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||
});
|
||||
|
||||
it('keeps trusted raw HTML sandboxed for an ownerless task even when authenticated', async () => {
|
||||
const repo = makeRepo({
|
||||
getLocalTask: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
ownerId: null,
|
||||
visibility: 'public',
|
||||
workspacePath: ws,
|
||||
}),
|
||||
} as Partial<Repository>);
|
||||
const res = await request(makeApp(repo, makeUser()))
|
||||
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||
});
|
||||
|
||||
it('keeps trusted raw HTML sandboxed when auth is on but no user is present', async () => {
|
||||
const res = await request(makeApp(makeRepo()))
|
||||
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||
});
|
||||
|
||||
it('allows trusted raw HTML in no-auth mode (sole operator owns every task)', async () => {
|
||||
const res = await request(makeApp(makeRepo(), undefined, { authActive: false }))
|
||||
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-security-policy']).toBeUndefined();
|
||||
expect(res.headers['content-type']).toContain('html');
|
||||
});
|
||||
|
||||
it('still sandboxes non-HTML in no-auth mode even with trusted=1', async () => {
|
||||
const res = await request(makeApp(makeRepo(), undefined, { authActive: false }))
|
||||
.get('/api/local/tasks/1/files/raw?section=output&path=report.md&trusted=1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||
});
|
||||
|
||||
it('rejects traversal reads with 400 and never serves outside files', async () => {
|
||||
const res = await request(makeApp(makeRepo(), makeUser()))
|
||||
.get(`/api/local/tasks/1/files/raw?section=input&path=..%2F..%2Foutside-${process.pid}.txt`);
|
||||
|
||||
@@ -6,7 +6,20 @@ import { logger } from '../logger.js';
|
||||
import { parseTaskId } from './validation.js';
|
||||
import { ensurePathWithin, isPathEscapeError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
|
||||
|
||||
export function mountLocalFilesApi(app: Application, repo: Repository): void {
|
||||
export interface LocalFilesApiOptions {
|
||||
/** Whether the auth subsystem is wired. When false (no-auth single-user
|
||||
* deployment) there is no req.user, so the sole local operator owns every
|
||||
* task and is allowed to open their own generated HTML with trusted=1.
|
||||
* Defaults to true (owner identity comes from req.user). */
|
||||
authActive?: boolean;
|
||||
}
|
||||
|
||||
export function mountLocalFilesApi(
|
||||
app: Application,
|
||||
repo: Repository,
|
||||
opts: LocalFilesApiOptions = {},
|
||||
): void {
|
||||
const authActive = opts.authActive ?? true;
|
||||
|
||||
app.get('/api/local/tasks/:taskId/files', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -125,7 +138,23 @@ export function mountLocalFilesApi(app: Application, repo: Repository): void {
|
||||
res.status(400).json({ error: 'path must point to a file' });
|
||||
return;
|
||||
}
|
||||
setUntrustedFileResponseHeaders(res);
|
||||
// trusted=1 drops the CSP sandbox so the owner's own generated HTML can
|
||||
// run on the app origin. STRICTLY owner-only — self-XSS at worst:
|
||||
// - org/public visibility lets other users VIEW the task, but serving
|
||||
// someone else's HTML unsandboxed here would be stored XSS against
|
||||
// the viewer;
|
||||
// - admins are excluded too: another user's HTML running in an ADMIN
|
||||
// session would be a user→admin privilege-escalation lure.
|
||||
// No-auth single-user mode has no req.user; the sole operator owns every
|
||||
// task, so they are the owner for this purpose (self-XSS only — there is
|
||||
// no second principal to attack).
|
||||
const trustedAllowed = authActive
|
||||
? !!viewer && task.ownerId != null && viewer.id === task.ownerId
|
||||
: true;
|
||||
const trustedHtml = req.query.trusted === '1' && /\.html?$/i.test(filePath) && trustedAllowed;
|
||||
if (!trustedHtml) {
|
||||
setUntrustedFileResponseHeaders(res);
|
||||
}
|
||||
res.type(extname(filePath) || 'application/octet-stream');
|
||||
res.send(readFileSync(filePath));
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { tmpdir } from 'os';
|
||||
import { Repository, localTaskRepoName } from '../db/repository.js';
|
||||
import { BrowserSessionRepo } from '../db/browser-session-repo.js';
|
||||
import { mountLocalTasksApi } from './local-tasks-api.js';
|
||||
import { buildLocalConversationContext } from '../engine/local-context.js';
|
||||
|
||||
describe('POST /api/local/tasks with visibility', () => {
|
||||
let tempDir = '';
|
||||
@@ -888,6 +889,41 @@ describe('POST /api/local/tasks/:id/continue', () => {
|
||||
expect(handoff?.body).toContain('ssh-ops');
|
||||
});
|
||||
|
||||
it('persists the switch instruction as the latest user request so the agent follows it', async () => {
|
||||
const { task } = await setupTaskWithTerminalJob();
|
||||
const res = await request(app)
|
||||
.post(`/api/local/tasks/${task.id}/continue`)
|
||||
.send({ piece: 'ssh-ops', instruction: 'use output/manual.md to set up foo' });
|
||||
expect(res.status).toBe(201);
|
||||
const comments = await repo.listLocalTaskComments(task.id);
|
||||
// The switch text must exist as a user 'request' comment...
|
||||
const userRequests = comments.filter((c) => c.author === 'user' && c.kind === 'request');
|
||||
const switchComment = userRequests.find((c) => c.body === 'use output/manual.md to set up foo');
|
||||
expect(switchComment).toBeTruthy();
|
||||
// ...and be the LATEST user instruction (newer than the original 'b' body and
|
||||
// the prior agent result), which is what buildLocalConversationContext keys on.
|
||||
const userInstructionKinds = ['comment', 'request', 'interjection'];
|
||||
const latestUserInstruction = [...comments]
|
||||
.reverse()
|
||||
.find((c) => c.author === 'user' && userInstructionKinds.includes(c.kind));
|
||||
expect(latestUserInstruction?.body).toBe('use output/manual.md to set up foo');
|
||||
|
||||
// End-to-end: feeding the resulting comments + the continued job's
|
||||
// instruction into the worker's context builder must put the switch text
|
||||
// under the active "## タスク" heading, NOT the demoted
|
||||
// "## オリジナルタスク (参考、対応済みの可能性あり)" slot that caused the
|
||||
// agent to re-follow earlier instructions.
|
||||
const ctx = buildLocalConversationContext({
|
||||
comments,
|
||||
jobInstruction: 'use output/manual.md to set up foo',
|
||||
inputFiles: [],
|
||||
outputFiles: [],
|
||||
});
|
||||
expect(ctx).toContain('## タスク');
|
||||
expect(ctx).toContain('use output/manual.md to set up foo');
|
||||
expect(ctx).not.toContain('## オリジナルタスク');
|
||||
});
|
||||
|
||||
it('returns 409 job_in_progress when prev job is running', async () => {
|
||||
const { task } = await setupTaskWithTerminalJob({ status: 'running' });
|
||||
const res = await request(app)
|
||||
|
||||
@@ -8,11 +8,12 @@ import { resolveJobScheduling } from '../scheduling.js';
|
||||
import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js';
|
||||
import { getLocalWorkspacePath, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
|
||||
import { jobEventBus, type JobStreamEvent } from './job-events.js';
|
||||
import { buildTitleFallback } from '../title-generation.js';
|
||||
|
||||
export interface LocalTasksApiOptions {
|
||||
repo: Repository;
|
||||
worktreeDir?: string;
|
||||
generateTitle?: (body: string) => Promise<string>;
|
||||
generateTitle?: (body: string, ownerId?: string) => Promise<string>;
|
||||
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
||||
/**
|
||||
* Server-side validator for piece names accepted by the
|
||||
@@ -126,28 +127,23 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
browserSessionProfileId = n;
|
||||
}
|
||||
|
||||
let taskTitle = (body.title ?? '').trim();
|
||||
const userTitle = (body.title ?? '').trim();
|
||||
const rawPiece = (body.piece ?? 'auto').trim();
|
||||
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
|
||||
|
||||
// タイトル生成と piece 分類を並列実行
|
||||
const [generatedTitle, autoSelectedPiece] = await Promise.all([
|
||||
// タイトル生成
|
||||
(!taskTitle && opts.generateTitle)
|
||||
? Promise.race([
|
||||
opts.generateTitle(body.body.trim()),
|
||||
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
|
||||
]).catch((e: unknown) => { logger.warn(`Title generation failed: ${e}`); return ''; })
|
||||
: Promise.resolve(''),
|
||||
// piece 分類('auto' の場合のみ); userId を渡し per-user カタログを使用
|
||||
(rawPiece === 'auto' && opts.selectPiece)
|
||||
? opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id).catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
|
||||
: Promise.resolve(rawPiece),
|
||||
]);
|
||||
// Title is NOT generated by an LLM at creation time anymore — that fired a
|
||||
// second concurrent LLM request per task and churned gateway backend
|
||||
// slots. Instead we set a cheap synchronous fallback now, and the agent
|
||||
// upgrades it during the run by deriving from the Mission Brief goal
|
||||
// (see Repository.updateMissionBriefSync). On-demand AI regeneration is
|
||||
// available via POST /api/local/tasks/:id/regenerate-title.
|
||||
const autoSelectedPiece = (rawPiece === 'auto' && opts.selectPiece)
|
||||
? await opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id)
|
||||
.catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
|
||||
: rawPiece;
|
||||
|
||||
if (!taskTitle) {
|
||||
taskTitle = generatedTitle || body.body.trim().slice(0, 40).replace(/\n/g, ' ');
|
||||
}
|
||||
const taskTitle = userTitle || buildTitleFallback(body.body.trim());
|
||||
const titleSource: 'auto' | 'user' = userTitle ? 'user' : 'auto';
|
||||
const piece = autoSelectedPiece;
|
||||
const profile = body.profile ?? 'auto';
|
||||
const outputFormat = body.outputFormat ?? 'markdown';
|
||||
@@ -168,6 +164,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
|
||||
const task = await repo.createLocalTask({
|
||||
title: taskTitle,
|
||||
titleSource,
|
||||
body: body.body.trim(),
|
||||
pieceName: piece,
|
||||
profile,
|
||||
@@ -406,7 +403,18 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
||||
if (!checkTaskOwnership(req, res, task)) return;
|
||||
|
||||
const updates: { visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
|
||||
const updates: { title?: string; titleSource?: 'user'; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
|
||||
if (req.body.title !== undefined) {
|
||||
if (typeof req.body.title !== 'string') {
|
||||
res.status(400).json({ error: 'title must be a string' }); return;
|
||||
}
|
||||
const trimmed = req.body.title.trim();
|
||||
if (!trimmed) { res.status(400).json({ error: 'title must not be empty' }); return; }
|
||||
if (trimmed.length > 200) { res.status(400).json({ error: 'title must be 200 characters or less' }); return; }
|
||||
// Manual edit pins the title: the agent never auto-overwrites a user title.
|
||||
updates.title = trimmed;
|
||||
updates.titleSource = 'user';
|
||||
}
|
||||
if (req.body.visibility !== undefined) {
|
||||
const v = req.body.visibility;
|
||||
if (!['private', 'org', 'public'].includes(v)) {
|
||||
@@ -443,6 +451,41 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
}
|
||||
});
|
||||
|
||||
// On-demand AI title regeneration. Unlike the old creation-time path this
|
||||
// only fires when the user explicitly asks (a button), so it never adds a
|
||||
// concurrent LLM request to the task-creation hot path. Owner/admin only.
|
||||
app.post('/api/local/tasks/:taskId/regenerate-title', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const taskId = parseTaskId(req.params.taskId);
|
||||
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
|
||||
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
||||
if (!checkTaskOwnership(req, res, task)) return;
|
||||
if (!opts.generateTitle) { res.status(503).json({ error: 'Title generation is not configured' }); return; }
|
||||
|
||||
let title = '';
|
||||
try {
|
||||
title = await Promise.race([
|
||||
// Ownerless (no-auth) tasks attribute to 'local', matching the
|
||||
// worker/piece-runner convention (ownerId ?? 'local').
|
||||
opts.generateTitle(task!.body, task!.ownerId ?? 'local'),
|
||||
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.warn(`Title regeneration failed (task=${taskId}): ${e}`);
|
||||
res.status(502).json({ error: 'Title generation failed' }); return;
|
||||
}
|
||||
// Empty model output is not an error: fall back to the cheap synchronous
|
||||
// title so the button always yields something (matching the old creation
|
||||
// path's behaviour).
|
||||
title = (title ?? '').trim() || buildTitleFallback(task!.body);
|
||||
await repo.updateLocalTask(taskId, { title, titleSource: 'agent' });
|
||||
res.json({ title });
|
||||
} catch (err) {
|
||||
logger.error(`Regenerate title API error: ${err}`);
|
||||
res.status(500).json({ error: 'Failed to regenerate title' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const taskId = parseTaskId(req.params.taskId);
|
||||
@@ -556,6 +599,15 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
|
||||
await repo.updateLocalTask(taskId, { pieceName: piece });
|
||||
|
||||
// Persist the switch-time instruction as a user request. Without this it
|
||||
// lives only in job.instruction, and buildLocalConversationContext picks
|
||||
// the *latest user comment* as the current instruction — so a stale older
|
||||
// comment would win and the switch text would be demoted to the "original
|
||||
// task (possibly already handled)" slot, making the agent re-follow prior
|
||||
// instructions instead of the new one. Mirrors the create path, which
|
||||
// also persists the body as a 'request' comment.
|
||||
await repo.addLocalTaskComment(taskId, 'user', instruction.trim(), 'request');
|
||||
|
||||
// Surface the handoff in the timeline so the user (and the LLM, when
|
||||
// it later inspects task comments) can see when piece switches happened.
|
||||
await repo.addLocalTaskComment(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'url';
|
||||
import { existsSync } from 'fs';
|
||||
import express from 'express';
|
||||
import type { Server } from 'http';
|
||||
import type { Server as HttpsServer } from 'https';
|
||||
import type { SessionManager, BrowserSession } from '../engine/browser-session.js';
|
||||
import type { UpgradeAuthChecker } from './auth.js';
|
||||
import { logger } from '../logger.js';
|
||||
@@ -78,7 +79,9 @@ export function createNovncRouter(): Router {
|
||||
* - authenticateUpgrade 未設定 (dev モード) は session 存在確認だけで通す
|
||||
*/
|
||||
export function setupNovncWebSocketProxy(
|
||||
server: Server,
|
||||
// Both http.Server and https.Server emit the 'upgrade' event used for WSS,
|
||||
// so either type works here as a WebSocket proxy host.
|
||||
server: Server | HttpsServer,
|
||||
getSessionManager: () => SessionManager | null,
|
||||
authenticateUpgrade?: UpgradeAuthChecker,
|
||||
authorizeSession?: NovncSessionAuthorizer,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { createServer as createHttpsServer, type Server } from 'https';
|
||||
import { request as httpsRequest } from 'https';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { resolveTlsOptions } from '../net/tls-options.js';
|
||||
import { SERVER_TLS_DEFAULTS } from '../server/config.js';
|
||||
|
||||
describe('native HTTPS listener (self-signed)', () => {
|
||||
let server: Server | undefined;
|
||||
let dir: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (server) await new Promise<void>((r) => server!.close(() => r()));
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
server = undefined;
|
||||
dir = undefined;
|
||||
});
|
||||
|
||||
it('completes a TLS>=1.2 handshake and serves the app over https', { timeout: 15000 }, async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'tls-listener-'));
|
||||
const resolved = resolveTlsOptions({ ...SERVER_TLS_DEFAULTS, enabled: true, selfSignedDir: dir });
|
||||
server = createHttpsServer(
|
||||
{ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion },
|
||||
(_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('ok');
|
||||
},
|
||||
);
|
||||
await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r));
|
||||
const addr = server.address();
|
||||
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||
|
||||
const result = await new Promise<{ code: number; body: string; proto: string | null }>(
|
||||
(resolve, reject) => {
|
||||
const req = httpsRequest(
|
||||
{ host: '127.0.0.1', port, path: '/', rejectUnauthorized: false },
|
||||
(res) => {
|
||||
// Capture protocol before the socket is torn down (socket may be
|
||||
// null by the time 'end' fires so we snapshot it on 'response').
|
||||
const proto = (res.socket as import('tls').TLSSocket | null)?.getProtocol?.() ?? null;
|
||||
let d = '';
|
||||
res.on('data', (c) => (d += c));
|
||||
res.on('end', () =>
|
||||
resolve({ code: res.statusCode ?? 0, body: d, proto }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
},
|
||||
);
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.body).toBe('ok');
|
||||
expect(['TLSv1.2', 'TLSv1.3']).toContain(result.proto);
|
||||
});
|
||||
|
||||
it('a strict client rejects the self-signed cert', async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'tls-listener-strict-'));
|
||||
const resolved = resolveTlsOptions({ ...SERVER_TLS_DEFAULTS, enabled: true, selfSignedDir: dir });
|
||||
server = createHttpsServer(
|
||||
{ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion },
|
||||
(_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('ok');
|
||||
},
|
||||
);
|
||||
await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r));
|
||||
const addr = server.address();
|
||||
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||
const outcome = await new Promise<string>((resolve) => {
|
||||
const req = httpsRequest(
|
||||
{ host: '127.0.0.1', port, path: '/', rejectUnauthorized: true },
|
||||
() => resolve('UNEXPECTED_OK'),
|
||||
);
|
||||
req.on('error', (e) => resolve('rejected:' + (e as NodeJS.ErrnoException).code));
|
||||
req.end();
|
||||
});
|
||||
expect(outcome).toMatch(/^rejected:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeEffectiveSecureCookie, shouldWarnDoubleTls } from './server.js';
|
||||
|
||||
describe('computeEffectiveSecureCookie', () => {
|
||||
it('is true when secure_cookie is on (proxy mode)', () => {
|
||||
expect(computeEffectiveSecureCookie(true, false)).toBe(true);
|
||||
});
|
||||
it('is true when native TLS is on even if secure_cookie is off', () => {
|
||||
expect(computeEffectiveSecureCookie(false, true)).toBe(true);
|
||||
});
|
||||
it('is false when neither', () => {
|
||||
expect(computeEffectiveSecureCookie(false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldWarnDoubleTls', () => {
|
||||
it('warns when native TLS and secure_cookie (proxy signal) are both on', () => {
|
||||
expect(shouldWarnDoubleTls(true, true)).toBe(true);
|
||||
});
|
||||
it('does not warn for a plain native-TLS install (secure_cookie off)', () => {
|
||||
expect(shouldWarnDoubleTls(true, false)).toBe(false);
|
||||
});
|
||||
it('does not warn when TLS is disabled', () => {
|
||||
expect(shouldWarnDoubleTls(false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
+112
-14
@@ -16,6 +16,7 @@ import { mountBrandingApi, resolveBranding } from './branding-api.js';
|
||||
import { createBrowserApi } from './browser-api.js';
|
||||
import { createBrowserSessionApi } from './browser-session-api.js';
|
||||
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
|
||||
import { createUsageRouter } from './usage-api.js';
|
||||
import { SessionManager } from '../engine/browser-session.js';
|
||||
import { createNovncRouter, setupNovncWebSocketProxy } from './novnc-proxy.js';
|
||||
import { setSessionManager } from '../engine/tools/browser.js';
|
||||
@@ -104,6 +105,11 @@ import { createNotesApi } from './notes-api.js';
|
||||
import { mountGateway, type GatewayMountHandle } from './gateway-mount.js';
|
||||
import { readGatewayConfig } from '../gateway/config.js';
|
||||
import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js';
|
||||
import { createServer as createHttpsServer } from 'https';
|
||||
import { X509Certificate } from 'crypto';
|
||||
import { mergeServerConfig } from '../server/config.js';
|
||||
import { resolveTlsOptions } from '../net/tls-options.js';
|
||||
import { createHttpRedirectServer } from '../net/http-redirect.js';
|
||||
|
||||
const __filenameServer = fileURLToPath(import.meta.url);
|
||||
const __dirnameServer = dirname(__filenameServer);
|
||||
@@ -112,7 +118,7 @@ export interface CoreServerOptions {
|
||||
repo: Repository;
|
||||
worktreeDir?: string;
|
||||
configuredRepos?: string[];
|
||||
generateTitle?: (body: string) => Promise<string>;
|
||||
generateTitle?: (body: string, ownerId?: string) => Promise<string>;
|
||||
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
||||
configManager?: ConfigManager;
|
||||
piecesDir?: string;
|
||||
@@ -170,6 +176,14 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
gatewayMount: GatewayMountHandle | null;
|
||||
/** True when an OAuth provider or local auth is active. False = no-auth mode. */
|
||||
authActive: boolean;
|
||||
/**
|
||||
* Resolved server config snapshot — computed once with the real listen port
|
||||
* so both the cookie-secure decision (inside createCoreServer) and the
|
||||
* listener branch (inside startCoreServer) share the SAME object.
|
||||
* A config hot-reload between the two would otherwise cause tls.enabled to
|
||||
* disagree between the cookie flag and the actual listener type.
|
||||
*/
|
||||
serverConfig: ReturnType<typeof mergeServerConfig>;
|
||||
} {
|
||||
const { repo, worktreeDir } = opts;
|
||||
const app = express();
|
||||
@@ -276,6 +290,16 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
}
|
||||
let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;
|
||||
|
||||
// Resolve server config ONCE here with the real listen port (threaded in via
|
||||
// opts.listenPort by startCoreServer). Both the cookie-secure decision below
|
||||
// and the listener branch in startCoreServer consume this same snapshot so a
|
||||
// config hot-reload between the two calls cannot produce a mismatch between
|
||||
// tls.enabled and the cookie secure flag.
|
||||
const serverCfg = mergeServerConfig(loadConfig().server, {
|
||||
freshInstall: false,
|
||||
httpsPort: opts.listenPort ?? Number(process.env['PORT'] ?? 9876),
|
||||
});
|
||||
|
||||
if (authActive) {
|
||||
// Idempotently seed the shared `local` system admin (id='local', the same
|
||||
// owner the no-auth path uses) so an existing single-user / no-auth
|
||||
@@ -286,9 +310,21 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
logger.info(`[auth] seeded local system admin id=local email=${bootstrap.email}`);
|
||||
}
|
||||
|
||||
// Compose effective secureCookie: native TLS termination also requires
|
||||
// the secure flag on session cookies, even when no upstream proxy is
|
||||
// present. IMPORTANT: trust-proxy (line ~191) stays keyed on the
|
||||
// ORIGINAL opts.authConfig.secureCookie — native TLS must NOT enable it.
|
||||
const effectiveSecureCookie = computeEffectiveSecureCookie(
|
||||
!!opts.authConfig?.secureCookie,
|
||||
serverCfg.tls.enabled,
|
||||
);
|
||||
const authConfigForSetup = opts.authConfig
|
||||
? { ...opts.authConfig, secureCookie: effectiveSecureCookie }
|
||||
: opts.authConfig;
|
||||
|
||||
const auth = setupAuth(
|
||||
repo,
|
||||
opts.authConfig!,
|
||||
authConfigForSetup!,
|
||||
() => {
|
||||
const b = resolveBranding(opts.configManager);
|
||||
return { appName: b.appName, loginPageTitle: b.loginPageTitle };
|
||||
@@ -330,6 +366,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// per-piece write authz (built-in/global-custom → admin, user-custom → owner)
|
||||
// is enforced inside pieces-api.ts handlers.
|
||||
app.use('/api/pieces', requireAuth);
|
||||
app.use('/api/usage', requireAuth);
|
||||
// Scheduled tasks: any authenticated user can create/list (visibility-filtered).
|
||||
// PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14).
|
||||
app.use('/api/scheduled-tasks', requireAuth);
|
||||
@@ -893,10 +930,11 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
});
|
||||
|
||||
// --- Local files API ---
|
||||
mountLocalFilesApi(app, repo);
|
||||
mountLocalFilesApi(app, repo, { authActive });
|
||||
|
||||
// --- Subtask activity API ---
|
||||
app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
|
||||
app.use('/api/usage', createUsageRouter(repo, { authActive }));
|
||||
|
||||
// --- Subtask files API (listing MUST come before wildcard) ---
|
||||
mountSubtaskFilesApi(app, repo);
|
||||
@@ -1176,7 +1214,19 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
return isOwner || user.role === 'admin';
|
||||
};
|
||||
|
||||
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive };
|
||||
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive, serverConfig: serverCfg };
|
||||
}
|
||||
|
||||
/** Cookie `secure` must be set whenever the user-facing scheme is https —
|
||||
* via an upstream TLS proxy (secureCookie) OR native TLS termination. */
|
||||
export function computeEffectiveSecureCookie(secureCookie: boolean, tlsEnabled: boolean): boolean {
|
||||
return secureCookie || tlsEnabled;
|
||||
}
|
||||
|
||||
/** Heuristic: native TLS + the proxy signal (secure_cookie) likely means a
|
||||
* reverse proxy is also terminating TLS → double-TLS misconfiguration. */
|
||||
export function shouldWarnDoubleTls(tlsEnabled: boolean, secureCookie: boolean): boolean {
|
||||
return tlsEnabled && secureCookie;
|
||||
}
|
||||
|
||||
export function finalizeServer(app: express.Application): express.Application {
|
||||
@@ -1231,6 +1281,7 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
workerMetrics,
|
||||
gatewayMount,
|
||||
authActive,
|
||||
serverConfig,
|
||||
// Forward the actual port to createCoreServer so the admin gateway
|
||||
// status endpoint reports the real bind port (not the PORT env
|
||||
// guess). See `listenPort` doc on CoreServerOptions.
|
||||
@@ -1255,18 +1306,65 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
// 127.0.0.1:9876 port mapping instead.
|
||||
const host = process.env['HOST'] ?? '127.0.0.1';
|
||||
const isLoopbackBind = host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
||||
const server = finalApp.listen(port, host, () => {
|
||||
logger.info(`Core server listening on ${host}:${port}`);
|
||||
if (!isLoopbackBind && !authActive) {
|
||||
logger.warn(
|
||||
`[security] Listening on ${host} with authentication DISABLED. The agent API ` +
|
||||
`(including the Bash tool) is reachable by anyone who can reach this host — ` +
|
||||
`this is effectively unauthenticated remote code execution. Enable auth in ` +
|
||||
`config.yaml (auth.local or an OAuth provider) before exposing a non-loopback ` +
|
||||
`interface, or unset HOST to bind 127.0.0.1.`,
|
||||
// Use the config snapshot already resolved in createCoreServer (same port,
|
||||
// same loadConfig() call) — no second read so a hot-reload between the two
|
||||
// cannot make the cookie-secure flag disagree with the listener type.
|
||||
const tls = serverConfig.tls;
|
||||
let server: import('http').Server | import('https').Server;
|
||||
if (tls.enabled) {
|
||||
// Augment the self-signed SAN list with the redirect target host and the
|
||||
// (non-wildcard) bind host so that browsers following the HTTP→HTTPS redirect
|
||||
// always land on a hostname that is covered by the certificate. Provided-cert
|
||||
// deployments are unaffected because resolveTlsOptions ignores selfSignedHosts
|
||||
// when cert_file/key_file are set.
|
||||
const extraSan = [
|
||||
tls.redirectHost,
|
||||
host && host !== '0.0.0.0' && host !== '::' ? host : null,
|
||||
].filter((h): h is string => !!h);
|
||||
const tlsForResolve = extraSan.length
|
||||
? { ...tls, selfSignedHosts: [...tls.selfSignedHosts, ...extraSan] }
|
||||
: tls;
|
||||
const resolved = resolveTlsOptions(tlsForResolve); // fatal throw on bad operator cert
|
||||
server = createHttpsServer({ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion }, finalApp);
|
||||
server.listen(port, host, () => {
|
||||
const source = tls.certFile ? `provided(${tls.certFile})` : 'self-signed';
|
||||
const fp = new X509Certificate(resolved.cert).fingerprint256;
|
||||
logger.info(`Core server listening on https://${host}:${port} cert=${source} sha256=${fp}`);
|
||||
if (shouldWarnDoubleTls(true, !!opts.authConfig?.secureCookie)) {
|
||||
logger.warn(
|
||||
`[security] server.tls.enabled is ON while auth.secure_cookie is also ON (reverse-proxy signal). ` +
|
||||
`If a TLS-terminating proxy is in front of this app, set server.tls.enabled: false to avoid double TLS.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
if (tls.httpRedirect) {
|
||||
if (tls.redirectHost == null && (host === '0.0.0.0' || host === '::')) {
|
||||
logger.warn(
|
||||
`[server] HTTP->HTTPS redirect host falls back to the wildcard bind address (${host}); browsers cannot follow it. ` +
|
||||
`Set server.tls.redirect_host to the externally reachable hostname.`,
|
||||
);
|
||||
}
|
||||
const pinnedHost = tls.redirectHost ?? (isLoopbackBind ? 'localhost' : host);
|
||||
const redirector = createHttpRedirectServer({ httpsPort: port, pinnedHost });
|
||||
redirector.on('error', (e) => logger.warn(`[server] HTTP redirect listener error: ${(e as Error).message}`));
|
||||
redirector.listen(tls.httpRedirectPort, host, () =>
|
||||
logger.info(`HTTP->HTTPS redirect listening on http://${host}:${tls.httpRedirectPort}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
server = finalApp.listen(port, host, () => {
|
||||
logger.info(`Core server listening on ${host}:${port}`);
|
||||
if (!isLoopbackBind && !authActive) {
|
||||
logger.warn(
|
||||
`[security] Listening on ${host} with authentication DISABLED. The agent API ` +
|
||||
`(including the Bash tool) is reachable by anyone who can reach this host — ` +
|
||||
`this is effectively unauthenticated remote code execution. Enable auth in ` +
|
||||
`config.yaml (auth.local or an OAuth provider) before exposing a non-loopback ` +
|
||||
`interface, or unset HOST to bind 127.0.0.1.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 起動と同時に CAPTCHA Pool の idle GC を回す (task session を 5 分アイドルで GC)
|
||||
if (browserSessionManager) browserSessionManager.startIdleGc();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Usage dashboard API (GET /api/usage/daily) tests.
|
||||
*
|
||||
* Coverage:
|
||||
* - admin sees all users + byUser breakdown; non-admin scoped to own rows
|
||||
* - no-auth (authActive=false) sees everyone (scope 'all')
|
||||
* - day / week / month bucketing collapses model/route correctly
|
||||
* - inclusive range, default range, from>to → 400, range-too-large → 400
|
||||
* - invalid dates fall back to defaults (not 500)
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { Repository } from '../db/repository.js';
|
||||
import { createUsageRouter } from './usage-api.js';
|
||||
|
||||
function makeApp(repo: Repository, opts: { authActive: boolean; user?: { id: string; role?: string } }) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
if (opts.user) (req as unknown as { user: unknown }).user = opts.user;
|
||||
next();
|
||||
});
|
||||
app.use('/api/usage', createUsageRouter(repo, { authActive: opts.authActive }));
|
||||
return app;
|
||||
}
|
||||
|
||||
function seed(repo: Repository, rows: Array<{ day: string; userId: string; source: 'gateway' | 'direct'; model?: string; route?: string; tin: number; tout: number; req?: number }>) {
|
||||
for (const r of rows) {
|
||||
repo.incrementLlmUsage({
|
||||
day: r.day, userId: r.userId, source: r.source,
|
||||
model: r.model ?? 'm', route: r.route ?? 'r',
|
||||
tokensIn: r.tin, tokensOut: r.tout, requests: r.req ?? 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('GET /api/usage/daily', () => {
|
||||
let repo: Repository;
|
||||
beforeEach(() => {
|
||||
repo = new Repository(':memory:');
|
||||
seed(repo, [
|
||||
{ day: '2026-06-10', userId: 'u1', source: 'gateway', tin: 100, tout: 40 },
|
||||
{ day: '2026-06-10', userId: 'u1', source: 'direct', model: 'x', route: 'h', tin: 10, tout: 5 },
|
||||
{ day: '2026-06-11', userId: 'u1', source: 'gateway', tin: 7, tout: 3 },
|
||||
{ day: '2026-06-11', userId: 'u2', source: 'direct', tin: 1000, tout: 500 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('non-admin sees only their own rows (scope=self, no byUser)', async () => {
|
||||
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
|
||||
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.scope).toBe('self');
|
||||
expect(res.body.byUser).toBeUndefined();
|
||||
// u1 only: gateway 100+40+7+3=150, direct 10+5=15
|
||||
expect(res.body.totals.gateway).toMatchObject({ tokensIn: 107, tokensOut: 43, requests: 2 });
|
||||
expect(res.body.totals.direct).toMatchObject({ tokensIn: 10, tokensOut: 5, requests: 1 });
|
||||
});
|
||||
|
||||
it('admin sees all users with a byUser breakdown', async () => {
|
||||
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
|
||||
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.scope).toBe('all');
|
||||
expect(res.body.totals.direct.tokensIn).toBe(1010); // u1 10 + u2 1000
|
||||
const users = (res.body.byUser as Array<{ userId: string }>).map((u) => u.userId).sort();
|
||||
expect(users).toEqual(['u1', 'u2']);
|
||||
// sorted by total tokens desc → u2 (1500) first
|
||||
expect(res.body.byUser[0].userId).toBe('u2');
|
||||
});
|
||||
|
||||
it('resolves byUser display names (real users → name, sentinels verbatim)', async () => {
|
||||
const u = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' });
|
||||
seed(repo, [
|
||||
{ day: '2026-06-11', userId: u.id, source: 'direct', tin: 5, tout: 5 },
|
||||
{ day: '2026-06-11', userId: 'local', source: 'direct', tin: 1, tout: 1 },
|
||||
]);
|
||||
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
|
||||
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
|
||||
const byId = Object.fromEntries((res.body.byUser as Array<{ userId: string; displayName: string }>).map((r) => [r.userId, r.displayName]));
|
||||
expect(byId[u.id]).toBe('Alice');
|
||||
expect(byId['local']).toBe('local'); // sentinel returned verbatim for UI localization
|
||||
});
|
||||
|
||||
it('no-auth mode (authActive=false) sees everyone', async () => {
|
||||
const app = makeApp(repo, { authActive: false }); // no req.user
|
||||
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.scope).toBe('all');
|
||||
expect(res.body.byUser.length).toBe(2);
|
||||
});
|
||||
|
||||
it('day granularity yields one bucket per active day', async () => {
|
||||
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
|
||||
const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-11&granularity=day');
|
||||
expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10', '2026-06-11']);
|
||||
});
|
||||
|
||||
it('month granularity collapses days into a YYYY-MM bucket', async () => {
|
||||
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
|
||||
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30&granularity=month');
|
||||
expect(res.body.series).toHaveLength(1);
|
||||
expect(res.body.series[0].bucket).toBe('2026-06');
|
||||
});
|
||||
|
||||
it('week granularity uses an ISO YYYY-Www bucket', async () => {
|
||||
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
|
||||
const res = await request(app).get('/api/usage/daily?from=2026-06-08&to=2026-06-14&granularity=week');
|
||||
// 2026-06-10 / -11 fall in ISO week 24 of 2026
|
||||
expect(res.body.series).toHaveLength(1);
|
||||
expect(res.body.series[0].bucket).toBe('2026-W24');
|
||||
});
|
||||
|
||||
it('rejects from > to with 400', async () => {
|
||||
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
|
||||
const res = await request(app).get('/api/usage/daily?from=2026-06-30&to=2026-06-01');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an absurdly large range with 400', async () => {
|
||||
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
|
||||
const res = await request(app).get('/api/usage/daily?from=2000-01-01&to=2026-06-30');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('falls back to defaults for invalid dates (no 500)', async () => {
|
||||
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
|
||||
const res = await request(app).get('/api/usage/daily?from=2026-99-99');
|
||||
expect(res.status).toBe(200);
|
||||
// default window is the last 30 days, ending today
|
||||
expect(res.body.to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { Repository, LlmUsageDailyAgg } from '../db/repository.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Per-user LLM usage dashboard API. Reads the llm_usage_daily ledger
|
||||
* (gateway + direct, recorded at the OpenAICompatClient completion
|
||||
* boundary) and shapes a time series for the Usage tab.
|
||||
*
|
||||
* Visibility: admin (and the no-auth single-user local mode) see every
|
||||
* user's usage; a non-admin authenticated user sees only their own rows.
|
||||
* This is a separate lens from the gateway per-key billing view — the two
|
||||
* are never summed.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||
*/
|
||||
|
||||
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** True only for a real calendar day in 'YYYY-MM-DD' (rejects 2026-99-99). */
|
||||
function isValidDay(s: unknown): s is string {
|
||||
if (typeof s !== 'string' || !DAY_RE.test(s)) return false;
|
||||
const d = new Date(`${s}T00:00:00.000Z`);
|
||||
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
|
||||
}
|
||||
const MAX_RANGE_DAYS = 800; // ~2y guard so a hand-crafted range can't scan unbounded
|
||||
type Granularity = 'day' | 'week' | 'month';
|
||||
|
||||
interface Counters {
|
||||
tokensIn: number;
|
||||
tokensOut: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
function emptyCounters(): Counters {
|
||||
return { tokensIn: 0, tokensOut: 0, requests: 0 };
|
||||
}
|
||||
|
||||
function addInto(target: Counters, row: LlmUsageDailyAgg): void {
|
||||
target.tokensIn += row.tokensIn;
|
||||
target.tokensOut += row.tokensOut;
|
||||
target.requests += row.requests;
|
||||
}
|
||||
|
||||
function utcToday(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** day - n days, as 'YYYY-MM-DD' (UTC). */
|
||||
function shiftDay(day: string, deltaDays: number): string {
|
||||
const d = new Date(`${day}T00:00:00.000Z`);
|
||||
d.setUTCDate(d.getUTCDate() + deltaDays);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Inclusive day count between two 'YYYY-MM-DD' (UTC). */
|
||||
function dayDiff(from: string, to: string): number {
|
||||
const a = Date.parse(`${from}T00:00:00.000Z`);
|
||||
const b = Date.parse(`${to}T00:00:00.000Z`);
|
||||
return Math.round((b - a) / 86_400_000);
|
||||
}
|
||||
|
||||
/** ISO-8601 week key 'YYYY-Www' for a 'YYYY-MM-DD' day (UTC). */
|
||||
function isoWeekKey(day: string): string {
|
||||
const d = new Date(`${day}T00:00:00.000Z`);
|
||||
// ISO week: Thursday of the current week decides the year.
|
||||
const dayNum = (d.getUTCDay() + 6) % 7; // Mon=0 .. Sun=6
|
||||
d.setUTCDate(d.getUTCDate() - dayNum + 3);
|
||||
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
|
||||
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
|
||||
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
|
||||
const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86_400_000));
|
||||
return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function bucketKey(day: string, granularity: Granularity): string {
|
||||
if (granularity === 'month') return day.slice(0, 7);
|
||||
if (granularity === 'week') return isoWeekKey(day);
|
||||
return day;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-friendly label for a usage owner id. Real users resolve to their
|
||||
* name (or email) so the admin breakdown isn't a wall of opaque ids; the
|
||||
* 'local' / 'system' sentinels are returned verbatim so the UI can localize
|
||||
* them. Falls back to the raw id when no user row exists.
|
||||
*/
|
||||
function resolveDisplayName(repo: Repository, userId: string): string {
|
||||
if (userId === 'local' || userId === 'system') return userId;
|
||||
const u = repo.getUserById(userId);
|
||||
return u?.name || u?.email || userId;
|
||||
}
|
||||
|
||||
export function createUsageRouter(repo: Repository, opts: { authActive: boolean }): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /daily?from=YYYY-MM-DD&to=YYYY-MM-DD&granularity=day|week|month
|
||||
router.get('/daily', (req: Request, res: Response) => {
|
||||
try {
|
||||
const to = isValidDay(req.query['to']) ? req.query['to'] : utcToday();
|
||||
const from = isValidDay(req.query['from']) ? req.query['from'] : shiftDay(to, -29);
|
||||
if (from > to) {
|
||||
res.status(400).json({ error: 'from must be on or before to' });
|
||||
return;
|
||||
}
|
||||
if (dayDiff(from, to) > MAX_RANGE_DAYS) {
|
||||
res.status(400).json({ error: `range too large (max ${MAX_RANGE_DAYS} days)` });
|
||||
return;
|
||||
}
|
||||
const gq = req.query['granularity'];
|
||||
const granularity: Granularity =
|
||||
gq === 'week' || gq === 'month' ? gq : 'day';
|
||||
|
||||
// Visibility: a non-admin authenticated user is scoped to their own
|
||||
// rows. Admin and the no-auth local mode see everyone.
|
||||
const user = req.user as Express.User | undefined;
|
||||
const isAdmin = !opts.authActive || user?.role === 'admin';
|
||||
const scopeUserId = isAdmin ? undefined : (user?.id ?? 'local');
|
||||
|
||||
const rows = repo.queryLlmUsageDaily({ from, to, userId: scopeUserId });
|
||||
|
||||
// Bucket by (bucketKey, source). Buckets are sparse — only days with
|
||||
// usage appear; the client fills gaps for the chart.
|
||||
const buckets = new Map<string, { gateway: Counters; direct: Counters }>();
|
||||
const totals = { gateway: emptyCounters(), direct: emptyCounters() };
|
||||
const byUser = new Map<string, Counters>();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = bucketKey(row.day, granularity);
|
||||
let b = buckets.get(key);
|
||||
if (!b) {
|
||||
b = { gateway: emptyCounters(), direct: emptyCounters() };
|
||||
buckets.set(key, b);
|
||||
}
|
||||
const sourceKey = row.source === 'gateway' ? 'gateway' : 'direct';
|
||||
addInto(b[sourceKey], row);
|
||||
addInto(totals[sourceKey], row);
|
||||
if (isAdmin) {
|
||||
let u = byUser.get(row.userId);
|
||||
if (!u) { u = emptyCounters(); byUser.set(row.userId, u); }
|
||||
addInto(u, row);
|
||||
}
|
||||
}
|
||||
|
||||
const series = Array.from(buckets.entries())
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||
.map(([bucket, c]) => ({ bucket, gateway: c.gateway, direct: c.direct }));
|
||||
|
||||
res.json({
|
||||
from,
|
||||
to,
|
||||
granularity,
|
||||
scope: isAdmin ? 'all' : 'self',
|
||||
series,
|
||||
totals,
|
||||
...(isAdmin
|
||||
? {
|
||||
byUser: Array.from(byUser.entries())
|
||||
.map(([userId, c]) => ({ userId, displayName: resolveDisplayName(repo, userId), ...c }))
|
||||
.sort((a, b) => (b.tokensIn + b.tokensOut) - (a.tokensIn + a.tokensOut)),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`[usage-api] /daily failed: ${String(e)}`);
|
||||
res.status(500).json({ error: 'Failed to load usage' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in New Issue
Block a user