sync: update from private repo (d31b280)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-11 11:28:40 +00:00
parent d061ad08d8
commit 3b1645cc91
96 changed files with 4627 additions and 287 deletions
+4 -1
View File
@@ -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) => {
+81 -2
View File
@@ -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`);
+31 -2
View File
@@ -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) {
+36
View File
@@ -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)
+72 -20
View File
@@ -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(
+4 -1
View File
@@ -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,
+81
View File
@@ -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:/);
});
});
+26
View File
@@ -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
View File
@@ -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();
+135
View File
@@ -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}$/);
});
});
+171
View File
@@ -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;
}