sync: update from private repo (a360d15)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-09 09:19:09 +00:00
parent 44df3a7da1
commit 483464597a
11 changed files with 328 additions and 16 deletions

View File

@ -6,7 +6,7 @@ import { join } from 'node:path';
import Database from 'better-sqlite3';
import express from 'express';
import request from 'supertest';
import { decideAccess, handleConsoleSocket, createConsoleStatusRouter } from './console-ws-api.js';
import { decideAccess, handleConsoleSocket, createConsoleStatusRouter, createConsoleSessionRouter } from './console-ws-api.js';
import { runMigrations } from '../db/migrate.js';
import { createAccessResolver } from '../ssh/access.js';
import { createGrantsRepo } from '../ssh/grants-repo.js';
@ -353,4 +353,57 @@ describe('createConsoleStatusRouter', () => {
const res = await request(app).get('/api/local/tasks/t1/console/status');
expect(res.status).toBe(401);
});
it('no-auth (authActive=false): synthesizes a local admin user instead of 401', async () => {
let seenUser: any;
const app = express();
app.use('/api', createConsoleStatusRouter({
registry: { get: () => null } as any,
requireAuth: (_req: any, _res: any, next: any) => next(),
resolveTask: async (_id: string, user: any) => { seenUser = user; return null; },
authActive: false,
}));
const res = await request(app).get('/api/local/tasks/t1/console/status');
// 200 active=false (task not visible shape), NOT 401 — the synthetic user
// cleared the auth guard. admin role makes null-owner no-auth tasks visible.
expect(res.status).toBe(200);
expect(seenUser).toEqual({ id: 'local', role: 'admin', orgIds: [] });
});
});
describe('createConsoleSessionRouter', () => {
it('no-auth (authActive=false): synthesizes a local admin user instead of 401', async () => {
let seenUser: any;
const app = express();
app.use(express.json());
app.use('/api', createConsoleSessionRouter({
sub: {} as any,
preflight: {} as any,
requireAuth: (_req: any, _res: any, next: any) => next(),
resolveTask: async (_id: string, user: any) => { seenUser = user; return null; },
authActive: false,
}));
const res = await request(app)
.post('/api/local/tasks/t1/console/session')
.send({ connection_id: 'c1' });
// 404 task_not_found (resolveTask returned null), NOT 401 — proves the
// synthetic user passed the auth guard before task resolution.
expect(res.status).toBe(404);
expect(seenUser).toEqual({ id: 'local', role: 'admin', orgIds: [] });
});
it('still returns 401 when auth is active and there is no user', async () => {
const app = express();
app.use(express.json());
app.use('/api', createConsoleSessionRouter({
sub: {} as any,
preflight: {} as any,
requireAuth: (_req: any, _res: any, next: any) => next(),
resolveTask: async () => null,
}));
const res = await request(app)
.post('/api/local/tasks/t1/console/session')
.send({ connection_id: 'c1' });
expect(res.status).toBe(401);
});
});

View File

@ -13,6 +13,20 @@ import { openConsoleSession } from '../engine/tools/ssh-console.js';
export interface SimpleUser { id: string; role: 'admin' | 'user' | string }
export interface SimpleTask { id: string; ownerId: string; visibility: string; pieceName: string }
/**
* Resolve the request user for the Console REST endpoints. In no-auth
* single-user mode (`authActive === false`) there is no `req.user`, so a
* stable `local` admin user is synthesized admin role is required for the
* no-auth task (owner_id NULL) to pass `buildVisibilityWhere` in resolveTask.
* Mirrors the WS upgrade path and notifications-api's no-auth synthetic user.
*/
function resolveConsoleUser(req: Request, authActive: boolean): SimpleUser | null {
const u = (req.user as SimpleUser | undefined) ?? null;
if (u) return u;
if (!authActive) return { id: 'local', role: 'admin', orgIds: [] } as SimpleUser & { orgIds: string[] };
return null;
}
export type AccessDecision =
| { allowed: true; canWrite: boolean }
| { allowed: false; reason: 'unauthenticated' | 'task_not_visible' | 'no_session' | 'no_grant' };
@ -245,6 +259,10 @@ export function createConsoleStatusRouter(deps: {
registry: SessionRegistry;
requireAuth: any;
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
/** No-auth single-user mode: synthesize a local admin user so the Console
* status poll works without login (admin role makes null-owner no-auth
* tasks visible via buildVisibilityWhere). Defaults to true. */
authActive?: boolean;
}): Router {
const r = Router();
r.get(
@ -252,7 +270,7 @@ export function createConsoleStatusRouter(deps: {
deps.requireAuth,
async (req: Request, res: Response) => {
const taskId = req.params.taskId!;
const user = (req.user as SimpleUser | undefined) ?? null;
const user = resolveConsoleUser(req, deps.authActive ?? true);
if (!user) {
res.status(401).json({ error: 'unauthenticated' });
return;
@ -366,6 +384,9 @@ export function createConsoleSessionRouter(deps: {
preflight: OpenConsoleDeps['preflight'];
requireAuth: any;
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
/** No-auth single-user mode: synthesize a local admin user so users can
* open Console sessions without login. Defaults to true. */
authActive?: boolean;
}): Router {
const r = Router();
r.post(
@ -373,7 +394,7 @@ export function createConsoleSessionRouter(deps: {
deps.requireAuth,
async (req: Request, res: Response) => {
const taskId = req.params.taskId!;
const user = (req.user as SimpleUser | undefined) ?? null;
const user = resolveConsoleUser(req, deps.authActive ?? true);
if (!user) {
res.status(401).json({ error: 'unauthenticated' });
return;

View File

@ -97,6 +97,48 @@ describe('POST /api/local/tasks with visibility', () => {
});
});
describe('POST /api/local/tasks in no-auth mode (synthetic local owner)', () => {
let tempDir = '';
let repo: Repository;
let app: express.Application;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-api-noauth-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
app = express();
app.use(express.json());
// No user middleware: simulates a no-auth deployment where req.user is
// undefined. mountLocalTasksApi is told auth is off so it registers the
// task under the stable 'local' owner instead of NULL.
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
authActive: false,
});
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
it('registers the task and its spawned job under owner "local" (not NULL)', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello no-auth',
piece: 'chat',
});
expect(res.status).toBe(201);
expect(res.body.task.ownerId).toBe('local');
// The spawned job must inherit 'local' so per-task reflection / visibility
// lookups (which key on jobs.owner_id) line up in no-auth.
const row = repo.getDb()
.prepare(`SELECT owner_id FROM jobs WHERE repo = ? ORDER BY created_at DESC LIMIT 1`)
.get(localTaskRepoName(res.body.task.id)) as { owner_id: string | null } | undefined;
expect(row?.owner_id).toBe('local');
});
});
describe('DELETE /api/local/tasks/:id owner-or-admin', () => {
let tempDir = '';
let repo: Repository;

View File

@ -36,10 +36,23 @@ export interface LocalTasksApiOptions {
* without a server restart. Clamped to [1, 1000] MB. Default: 50.
*/
getMaxUploadMb?: () => number;
/**
* Whether the auth subsystem is wired. When `false` (no-auth single-user
* deployment) requests carry no `req.user`, so new tasks/jobs are registered
* under the stable `local` owner instead of NULL keeping per-task
* reflection / visibility lookups (which key on owner_id) consistent with the
* rest of the no-auth path. Defaults to `true` (owner stays req.user.id).
*/
authActive?: boolean;
}
export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void {
const { repo, worktreeDir, sessRepo } = opts;
// No-auth single-user mode: register new tasks/jobs under the 'local' owner
// (the same namespace pieces/memory/reflection use) rather than letting the
// missing req.user fall through to a NULL owner. In auth mode this is
// undefined, so `req.user.id ?? noAuthOwner` keeps the real owner.
const noAuthOwner: string | undefined = (opts.authActive ?? true) ? undefined : 'local';
const resolveUploadLimit = (): string => {
const raw = opts.getMaxUploadMb?.() ?? 50;
@ -161,7 +174,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
outputFormat,
askPolicy,
priority,
ownerId: req.user?.id,
ownerId: req.user?.id ?? noAuthOwner,
visibility,
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
browserSessionProfileId,

View File

@ -379,6 +379,42 @@ describe('reflection-api', () => {
expect(res.body.pieceEdited).toBe(true);
});
it('no-auth: returns the snapshot for a NULL-owner task job via the synthetic local user', async () => {
// Reproduces the real no-auth deployment: task jobs are created with
// owner_id = NULL (req.user is undefined), while reflection snapshots are
// written under the 'local' namespace (reflectionOwner = job.ownerId ??
// 'local'). The badge query filtered `owner_id = 'local'`, which never
// matches a NULL owner, so the 🧠 badge stayed silently empty even though
// reflection ran and wrote to data/users/local/.reflection-history/.
const db = repo.getDb();
const now = new Date().toISOString();
db.prepare(`
INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class,
instruction, attempt, max_attempts, ask_count, subtask_depth, task_kind, created_at, updated_at, owner_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run('j-task9-001', 'local/task-9', 1, 'succeeded', 'chat', 'default', 'auto',
'No-auth task', 1, 1, 0, 0, 'agent', now, now, null);
const deps = { dataDir: tmpDir };
const { snapshotId } = await writeSnapshot(
deps,
{ 'pref.md': 'before' },
{ 'pref.md': 'after' },
makeMeta({ originalJobId: 'j-task9-001', userId: 'local', memoryChanges: 2, pieceEdited: false }),
undefined,
undefined,
new Date('2026-05-11T16:00:00Z'),
);
// No x-test-user-id header → the auth gate injects the synthetic 'local' user.
const res = await request(app).get('/api/local/reflection/latest-for-task/9');
expect(res.status).toBe(200);
expect(res.body).not.toBeNull();
expect(res.body.snapshotId).toBe(snapshotId);
expect(res.body.memoryChanges).toBe(2);
});
it('returns null when the task job is owned by another user', async () => {
// Insert a job owned by OTHER_ID for task 8
const db = repo.getDb();

View File

@ -265,6 +265,19 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router {
rows = db
.prepare(`SELECT id, owner_id FROM jobs WHERE repo = ? ORDER BY created_at DESC`)
.all(repoName) as JobRow[];
} else if (!authActive) {
// No-auth single-user mode: task jobs are created with owner_id = NULL
// (req.user is undefined), but the synthetic user id is 'local' and the
// reflection writer stores snapshots under 'local' (reflectionOwner =
// job.ownerId ?? 'local'). Match NULL-owner jobs so the badge links them
// — without this the `owner_id = 'local'` filter never matched the NULL
// owner and the 🧠 badge stayed silently empty. Safe: no-auth has exactly
// one user, and listSnapshots below already reads the 'local' namespace.
rows = db
.prepare(
`SELECT id, owner_id FROM jobs WHERE repo = ? AND (owner_id = ? OR owner_id IS NULL) ORDER BY created_at DESC`,
)
.all(repoName, u.id) as JobRow[];
} else {
rows = db
.prepare(

View File

@ -108,6 +108,40 @@ describe('POST /api/scheduled-tasks with visibility', () => {
});
});
describe('POST /api/scheduled-tasks in no-auth mode (synthetic local owner)', () => {
let nTempDir = '';
let nRepo: Repository;
let nApp: express.Application;
beforeEach(() => {
nTempDir = mkdtempSync(join(tmpdir(), 'sched-noauth-api-'));
nRepo = new Repository(join(nTempDir, 'db.sqlite'));
const nScheduler = new Scheduler(nRepo, join(nTempDir, 'workspaces'));
nApp = express();
nApp.use(express.json());
// No user middleware: no-auth deployment (req.user undefined). authActive:false
// tells the router to register the scheduled task under the 'local' owner so
// the jobs the scheduler later spawns inherit it (not NULL).
mountScheduledTasksApi(nApp, nRepo, nScheduler, { authActive: false });
});
afterEach(() => {
nRepo.close();
rmSync(nTempDir, { recursive: true, force: true });
});
it('registers the scheduled task under owner "local" instead of NULL', async () => {
const res = await request(nApp).post('/api/scheduled-tasks').send({
body: 'nightly thing',
scheduleType: 'daily',
hour: 9,
minute: 0,
});
expect(res.status).toBe(201);
expect(res.body.task.ownerId).toBe('local');
});
});
describe('POST /api/scheduled-tasks', () => {
it('should create a daily schedule', async () => {
const res = await request(app)

View File

@ -11,6 +11,14 @@ export interface ScheduledTasksApiOptions {
* field is silently dropped (legacy / no-auth deployments).
*/
sessRepo?: BrowserSessionRepo;
/**
* Whether the auth subsystem is wired. When `false` (no-auth single-user
* deployment) requests carry no `req.user`, so new scheduled tasks (and the
* jobs the scheduler spawns from them) are owned by the stable `local` user
* instead of NULL keeping per-task reflection / visibility consistent with
* the rest of the no-auth path. Defaults to `true`.
*/
authActive?: boolean;
}
export function mountScheduledTasksApi(
@ -20,6 +28,7 @@ export function mountScheduledTasksApi(
apiOpts: ScheduledTasksApiOptions = {},
): void {
const { sessRepo } = apiOpts;
const noAuthOwner: string | null = (apiOpts.authActive ?? true) ? null : 'local';
/**
* Validate and resolve a browserSessionProfileId from a request body.
@ -133,7 +142,7 @@ export function mountScheduledTasksApi(
return;
}
}
const ownerId = (req.user as Express.User | undefined)?.id ?? null;
const ownerId = (req.user as Express.User | undefined)?.id ?? noAuthOwner;
const profileBinding = resolveBrowserSessionProfileId(
req.body?.browserSessionProfileId,

View File

@ -585,6 +585,7 @@ export function createCoreServer(opts: CoreServerOptions): {
const sshDeps: SshApiDeps = {
db: repo.getDb(),
authActive,
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
@ -712,9 +713,11 @@ export function createCoreServer(opts: CoreServerOptions): {
registry: sessionRegistry,
resolveUserFromUpgrade: async (req) => {
if (!authenticateUpgrade) {
// Auth disabled: no user available — reject WS attaches.
// (In auth-off mode the WS layer simply isn't usable.)
return null;
// No-auth single-user mode: synthesize a stable `local` admin
// user so the Console terminal WS attaches (admin role makes
// the null-owner no-auth task visible in resolveTask). Mirrors
// the Console REST routers and notifications-api.
return { id: 'local', role: 'admin' };
}
const u = await authenticateUpgrade(req);
return u ? { id: u.id, role: u.role } : null;
@ -778,6 +781,7 @@ export function createCoreServer(opts: CoreServerOptions): {
'/api',
createConsoleStatusRouter({
registry: sessionRegistry,
authActive,
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
resolveTask: consoleDeps.resolveTask,
}),
@ -793,6 +797,7 @@ export function createCoreServer(opts: CoreServerOptions): {
createConsoleSessionRouter({
sub: sshSubsystem,
preflight: sshPreflight,
authActive,
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
resolveTask: consoleDeps.resolveTask,
}),
@ -822,6 +827,7 @@ export function createCoreServer(opts: CoreServerOptions): {
mountLocalTasksApi(app, {
repo,
worktreeDir,
authActive,
generateTitle: opts.generateTitle,
selectPiece: opts.selectPiece,
pieceExists: opts.piecesDir
@ -936,7 +942,7 @@ export function createCoreServer(opts: CoreServerOptions): {
if (opts.scheduler) {
app.use('/api/scheduled-tasks', express.json());
mountScheduledTasksApi(app, repo, opts.scheduler, { sessRepo });
mountScheduledTasksApi(app, repo, opts.scheduler, { sessRepo, authActive });
}
// Browser session API

View File

@ -65,6 +65,10 @@ function makeHarness(opts: {
tester?: SshTester;
forceUnlockLimit?: { windowMs: number; maxRequests: number };
onAccessRevoked?: SshApiDeps['onAccessRevoked'];
/** No-auth single-user mode: requireAuth/Admin become passthroughs and the
* user resolvers read req.user, mirroring how server.ts wires SshApiDeps
* when the auth subsystem is disabled. */
authActive?: boolean;
} = {}): Harness {
const db = makeDb();
const connectionRepo = createConnectionRepo(db);
@ -78,14 +82,15 @@ function makeHarness(opts: {
const userId = opts.userId ?? 'alice';
const isAdmin = !!opts.isAdmin;
const isAnon = !!opts.isAnon;
const authActive = opts.authActive ?? true;
const requireAuth: express.RequestHandler = (_req, res, next) => {
if (isAnon) { res.status(401).json({ error: 'unauthorized' }); return; }
if (authActive && isAnon) { res.status(401).json({ error: 'unauthorized' }); return; }
next();
};
const requireAdmin: express.RequestHandler = (_req, res, next) => {
if (isAnon) { res.status(401).json({ error: 'unauthorized' }); return; }
if (!isAdmin) { res.status(403).json({ error: 'admin_required' }); return; }
if (authActive && isAnon) { res.status(401).json({ error: 'unauthorized' }); return; }
if (authActive && !isAdmin) { res.status(403).json({ error: 'admin_required' }); return; }
next();
};
@ -118,9 +123,13 @@ function makeHarness(opts: {
db,
requireAuth,
requireAdmin,
getUserId: () => (isAnon ? null : userId),
isAdmin: () => isAdmin,
getOrgIds: () => opts.orgIds ?? [],
authActive,
getUserId: (req) =>
authActive ? (isAnon ? null : userId) : ((req.user as { id?: string } | undefined)?.id ?? null),
isAdmin: (req) =>
authActive ? isAdmin : ((req.user as { role?: string } | undefined)?.role === 'admin'),
getOrgIds: (req) =>
authActive ? (opts.orgIds ?? []) : ((req.user as { orgIds?: string[] } | undefined)?.orgIds ?? []),
connectionRepo,
grantsRepo,
auditRepo,
@ -199,6 +208,55 @@ describe('SSH API: auth gating', () => {
});
});
// ──────────────────────────────────────────────────────────────────────
// No-auth single-user mode: SSH must work without a logged-in user. The
// router synthesizes a 'local' owner so per-user connection storage has a
// stable key (mirrors notes-api / memory-api / user-folder-api).
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: no-auth local fallback (authActive=false)', () => {
it('POST /api/ssh/connections succeeds without auth and owns the connection as local', async () => {
const h = makeHarness({ authActive: false });
const res = await request(h.app).post('/api/ssh/connections').send({
label: 'prod',
host: 'srv.example.com',
port: 22,
username: 'deploy',
privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/home/deploy',
});
expect(res.status).toBe(201);
expect(res.body.connection.ownerId).toBe('local');
});
it('GET /api/ssh/connections returns 200 (not 401) without auth and lists the local connection', async () => {
const h = makeHarness({ authActive: false });
const id = await createOwnedConnection(h);
const res = await request(h.app).get('/api/ssh/connections');
expect(res.status).toBe(200);
expect(res.body.connections.find((c: { id: string }) => c.id === id)).toBeTruthy();
});
it('admin globals endpoint is reachable without auth in no-auth mode', async () => {
const h = makeHarness({ authActive: false });
const res = await request(h.app)
.post('/api/ssh/admin/globals')
.send({
reason: 'no-auth admin reachability',
label: 'shared',
host: 'shared.example.com',
port: 22,
username: 'ops',
privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/srv',
});
// Not 401/403 — the synthetic local user passes the admin gate. The exact
// 2xx/4xx depends on payload validation, which other tests cover.
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
});
describe('SSH API: maintenance mode', () => {
it('blocks user POST /connections with 503 + Retry-After', async () => {
const h = makeHarness();

View File

@ -25,7 +25,7 @@
* the pending host-key token (token only returned on test / observation).
*/
import { Router, type Request, type Response, type RequestHandler } from 'express';
import { Router, type Request, type Response, type NextFunction, type RequestHandler } from 'express';
import type Database from 'better-sqlite3';
import type { SshConnection, SshConnectionRepo, HostKeyVerifyResult } from '../ssh/connection-repo.js';
@ -78,6 +78,14 @@ export interface SshApiDeps {
getUserId(req: Request): string | null;
isAdmin(req: Request): boolean;
getOrgIds(req: Request): string[];
/**
* Whether the bridge wired the auth subsystem. When `false` (no-auth
* single-user deployment) requests carry no `req.user`, so the router
* synthesizes a stable `local` owner otherwise every endpoint 401s on
* the null userId and SSH is unusable. Mirrors notes-api / memory-api /
* user-folder-api. Defaults to `true` for existing (auth-only) callers.
*/
authActive?: boolean;
connectionRepo: SshConnectionRepo;
grantsRepo: SshGrantsRepo;
@ -279,9 +287,27 @@ function jsonError(res: Response, status: number, error: string, detail?: unknow
// User router — /api/ssh/connections + /api/ssh/grants
// ──────────────────────────────────────────────────────────────────────
/**
* No-auth single-user mode: synthesize a `local` user so the per-user
* connection store has a stable owner and `getUserId`/`isAdmin` resolve.
* Role `admin` keeps the connection-management surface fully usable for the
* lone local operator (in single-user mode the admin/grant checks only ever
* compare against `local`-owned rows, so this never widens cross-user access).
* No-op when auth is active (Passport has already populated `req.user`).
*/
function makeSshLocalUserMiddleware(authActive: boolean): RequestHandler {
return (req: Request, _res: Response, next: NextFunction) => {
if (!authActive && !(req as { user?: unknown }).user) {
(req as { user?: unknown }).user = { id: 'local', role: 'admin', orgIds: [] };
}
next();
};
}
export function createSshUserRouter(deps: SshApiDeps): Router {
const router = Router();
const testTimeoutMs = deps.connectionTestTimeoutMs ?? 5000;
router.use(makeSshLocalUserMiddleware(deps.authActive ?? true));
// GET /api/ssh/connections — list owned (and globals visible via grant).
// For Phase 5 we return:
@ -801,6 +827,7 @@ export function createSshUserRouter(deps: SshApiDeps): Router {
export function createSshAdminRouter(deps: SshApiDeps): Router {
const router = Router();
router.use(makeSshLocalUserMiddleware(deps.authActive ?? true));
// GET /api/ssh/admin/connections
router.get('/connections', deps.requireAdmin, (_req, res) => {