sync: update from private repo (a360d15)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
44df3a7da1
commit
483464597a
@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import request from 'supertest';
|
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 { runMigrations } from '../db/migrate.js';
|
||||||
import { createAccessResolver } from '../ssh/access.js';
|
import { createAccessResolver } from '../ssh/access.js';
|
||||||
import { createGrantsRepo } from '../ssh/grants-repo.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');
|
const res = await request(app).get('/api/local/tasks/t1/console/status');
|
||||||
expect(res.status).toBe(401);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,6 +13,20 @@ import { openConsoleSession } from '../engine/tools/ssh-console.js';
|
|||||||
export interface SimpleUser { id: string; role: 'admin' | 'user' | string }
|
export interface SimpleUser { id: string; role: 'admin' | 'user' | string }
|
||||||
export interface SimpleTask { id: string; ownerId: string; visibility: string; pieceName: 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 =
|
export type AccessDecision =
|
||||||
| { allowed: true; canWrite: boolean }
|
| { allowed: true; canWrite: boolean }
|
||||||
| { allowed: false; reason: 'unauthenticated' | 'task_not_visible' | 'no_session' | 'no_grant' };
|
| { allowed: false; reason: 'unauthenticated' | 'task_not_visible' | 'no_session' | 'no_grant' };
|
||||||
@ -245,6 +259,10 @@ export function createConsoleStatusRouter(deps: {
|
|||||||
registry: SessionRegistry;
|
registry: SessionRegistry;
|
||||||
requireAuth: any;
|
requireAuth: any;
|
||||||
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
|
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 {
|
}): Router {
|
||||||
const r = Router();
|
const r = Router();
|
||||||
r.get(
|
r.get(
|
||||||
@ -252,7 +270,7 @@ export function createConsoleStatusRouter(deps: {
|
|||||||
deps.requireAuth,
|
deps.requireAuth,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const taskId = req.params.taskId!;
|
const taskId = req.params.taskId!;
|
||||||
const user = (req.user as SimpleUser | undefined) ?? null;
|
const user = resolveConsoleUser(req, deps.authActive ?? true);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(401).json({ error: 'unauthenticated' });
|
res.status(401).json({ error: 'unauthenticated' });
|
||||||
return;
|
return;
|
||||||
@ -366,6 +384,9 @@ export function createConsoleSessionRouter(deps: {
|
|||||||
preflight: OpenConsoleDeps['preflight'];
|
preflight: OpenConsoleDeps['preflight'];
|
||||||
requireAuth: any;
|
requireAuth: any;
|
||||||
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
|
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 {
|
}): Router {
|
||||||
const r = Router();
|
const r = Router();
|
||||||
r.post(
|
r.post(
|
||||||
@ -373,7 +394,7 @@ export function createConsoleSessionRouter(deps: {
|
|||||||
deps.requireAuth,
|
deps.requireAuth,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const taskId = req.params.taskId!;
|
const taskId = req.params.taskId!;
|
||||||
const user = (req.user as SimpleUser | undefined) ?? null;
|
const user = resolveConsoleUser(req, deps.authActive ?? true);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(401).json({ error: 'unauthenticated' });
|
res.status(401).json({ error: 'unauthenticated' });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -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', () => {
|
describe('DELETE /api/local/tasks/:id owner-or-admin', () => {
|
||||||
let tempDir = '';
|
let tempDir = '';
|
||||||
let repo: Repository;
|
let repo: Repository;
|
||||||
|
|||||||
@ -36,10 +36,23 @@ export interface LocalTasksApiOptions {
|
|||||||
* without a server restart. Clamped to [1, 1000] MB. Default: 50.
|
* without a server restart. Clamped to [1, 1000] MB. Default: 50.
|
||||||
*/
|
*/
|
||||||
getMaxUploadMb?: () => number;
|
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 {
|
export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void {
|
||||||
const { repo, worktreeDir, sessRepo } = opts;
|
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 resolveUploadLimit = (): string => {
|
||||||
const raw = opts.getMaxUploadMb?.() ?? 50;
|
const raw = opts.getMaxUploadMb?.() ?? 50;
|
||||||
@ -161,7 +174,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
|||||||
outputFormat,
|
outputFormat,
|
||||||
askPolicy,
|
askPolicy,
|
||||||
priority,
|
priority,
|
||||||
ownerId: req.user?.id,
|
ownerId: req.user?.id ?? noAuthOwner,
|
||||||
visibility,
|
visibility,
|
||||||
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
|
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
|
||||||
browserSessionProfileId,
|
browserSessionProfileId,
|
||||||
|
|||||||
@ -379,6 +379,42 @@ describe('reflection-api', () => {
|
|||||||
expect(res.body.pieceEdited).toBe(true);
|
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 () => {
|
it('returns null when the task job is owned by another user', async () => {
|
||||||
// Insert a job owned by OTHER_ID for task 8
|
// Insert a job owned by OTHER_ID for task 8
|
||||||
const db = repo.getDb();
|
const db = repo.getDb();
|
||||||
|
|||||||
@ -265,6 +265,19 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router {
|
|||||||
rows = db
|
rows = db
|
||||||
.prepare(`SELECT id, owner_id FROM jobs WHERE repo = ? ORDER BY created_at DESC`)
|
.prepare(`SELECT id, owner_id FROM jobs WHERE repo = ? ORDER BY created_at DESC`)
|
||||||
.all(repoName) as JobRow[];
|
.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 {
|
} else {
|
||||||
rows = db
|
rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@ -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', () => {
|
describe('POST /api/scheduled-tasks', () => {
|
||||||
it('should create a daily schedule', async () => {
|
it('should create a daily schedule', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
|
|||||||
@ -11,6 +11,14 @@ export interface ScheduledTasksApiOptions {
|
|||||||
* field is silently dropped (legacy / no-auth deployments).
|
* field is silently dropped (legacy / no-auth deployments).
|
||||||
*/
|
*/
|
||||||
sessRepo?: BrowserSessionRepo;
|
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(
|
export function mountScheduledTasksApi(
|
||||||
@ -20,6 +28,7 @@ export function mountScheduledTasksApi(
|
|||||||
apiOpts: ScheduledTasksApiOptions = {},
|
apiOpts: ScheduledTasksApiOptions = {},
|
||||||
): void {
|
): void {
|
||||||
const { sessRepo } = apiOpts;
|
const { sessRepo } = apiOpts;
|
||||||
|
const noAuthOwner: string | null = (apiOpts.authActive ?? true) ? null : 'local';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and resolve a browserSessionProfileId from a request body.
|
* Validate and resolve a browserSessionProfileId from a request body.
|
||||||
@ -133,7 +142,7 @@ export function mountScheduledTasksApi(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ownerId = (req.user as Express.User | undefined)?.id ?? null;
|
const ownerId = (req.user as Express.User | undefined)?.id ?? noAuthOwner;
|
||||||
|
|
||||||
const profileBinding = resolveBrowserSessionProfileId(
|
const profileBinding = resolveBrowserSessionProfileId(
|
||||||
req.body?.browserSessionProfileId,
|
req.body?.browserSessionProfileId,
|
||||||
|
|||||||
@ -585,6 +585,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
|
|
||||||
const sshDeps: SshApiDeps = {
|
const sshDeps: SshApiDeps = {
|
||||||
db: repo.getDb(),
|
db: repo.getDb(),
|
||||||
|
authActive,
|
||||||
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
|
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
|
||||||
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
|
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
|
||||||
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
|
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
|
||||||
@ -712,9 +713,11 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
registry: sessionRegistry,
|
registry: sessionRegistry,
|
||||||
resolveUserFromUpgrade: async (req) => {
|
resolveUserFromUpgrade: async (req) => {
|
||||||
if (!authenticateUpgrade) {
|
if (!authenticateUpgrade) {
|
||||||
// Auth disabled: no user available — reject WS attaches.
|
// No-auth single-user mode: synthesize a stable `local` admin
|
||||||
// (In auth-off mode the WS layer simply isn't usable.)
|
// user so the Console terminal WS attaches (admin role makes
|
||||||
return null;
|
// 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);
|
const u = await authenticateUpgrade(req);
|
||||||
return u ? { id: u.id, role: u.role } : null;
|
return u ? { id: u.id, role: u.role } : null;
|
||||||
@ -778,6 +781,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
'/api',
|
'/api',
|
||||||
createConsoleStatusRouter({
|
createConsoleStatusRouter({
|
||||||
registry: sessionRegistry,
|
registry: sessionRegistry,
|
||||||
|
authActive,
|
||||||
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
|
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
|
||||||
resolveTask: consoleDeps.resolveTask,
|
resolveTask: consoleDeps.resolveTask,
|
||||||
}),
|
}),
|
||||||
@ -793,6 +797,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
createConsoleSessionRouter({
|
createConsoleSessionRouter({
|
||||||
sub: sshSubsystem,
|
sub: sshSubsystem,
|
||||||
preflight: sshPreflight,
|
preflight: sshPreflight,
|
||||||
|
authActive,
|
||||||
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
|
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
|
||||||
resolveTask: consoleDeps.resolveTask,
|
resolveTask: consoleDeps.resolveTask,
|
||||||
}),
|
}),
|
||||||
@ -822,6 +827,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
mountLocalTasksApi(app, {
|
mountLocalTasksApi(app, {
|
||||||
repo,
|
repo,
|
||||||
worktreeDir,
|
worktreeDir,
|
||||||
|
authActive,
|
||||||
generateTitle: opts.generateTitle,
|
generateTitle: opts.generateTitle,
|
||||||
selectPiece: opts.selectPiece,
|
selectPiece: opts.selectPiece,
|
||||||
pieceExists: opts.piecesDir
|
pieceExists: opts.piecesDir
|
||||||
@ -936,7 +942,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
|
|
||||||
if (opts.scheduler) {
|
if (opts.scheduler) {
|
||||||
app.use('/api/scheduled-tasks', express.json());
|
app.use('/api/scheduled-tasks', express.json());
|
||||||
mountScheduledTasksApi(app, repo, opts.scheduler, { sessRepo });
|
mountScheduledTasksApi(app, repo, opts.scheduler, { sessRepo, authActive });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browser session API
|
// Browser session API
|
||||||
|
|||||||
@ -65,6 +65,10 @@ function makeHarness(opts: {
|
|||||||
tester?: SshTester;
|
tester?: SshTester;
|
||||||
forceUnlockLimit?: { windowMs: number; maxRequests: number };
|
forceUnlockLimit?: { windowMs: number; maxRequests: number };
|
||||||
onAccessRevoked?: SshApiDeps['onAccessRevoked'];
|
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 {
|
} = {}): Harness {
|
||||||
const db = makeDb();
|
const db = makeDb();
|
||||||
const connectionRepo = createConnectionRepo(db);
|
const connectionRepo = createConnectionRepo(db);
|
||||||
@ -78,14 +82,15 @@ function makeHarness(opts: {
|
|||||||
const userId = opts.userId ?? 'alice';
|
const userId = opts.userId ?? 'alice';
|
||||||
const isAdmin = !!opts.isAdmin;
|
const isAdmin = !!opts.isAdmin;
|
||||||
const isAnon = !!opts.isAnon;
|
const isAnon = !!opts.isAnon;
|
||||||
|
const authActive = opts.authActive ?? true;
|
||||||
|
|
||||||
const requireAuth: express.RequestHandler = (_req, res, next) => {
|
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();
|
next();
|
||||||
};
|
};
|
||||||
const requireAdmin: express.RequestHandler = (_req, res, next) => {
|
const requireAdmin: express.RequestHandler = (_req, res, next) => {
|
||||||
if (isAnon) { res.status(401).json({ error: 'unauthorized' }); return; }
|
if (authActive && isAnon) { res.status(401).json({ error: 'unauthorized' }); return; }
|
||||||
if (!isAdmin) { res.status(403).json({ error: 'admin_required' }); return; }
|
if (authActive && !isAdmin) { res.status(403).json({ error: 'admin_required' }); return; }
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -118,9 +123,13 @@ function makeHarness(opts: {
|
|||||||
db,
|
db,
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
getUserId: () => (isAnon ? null : userId),
|
authActive,
|
||||||
isAdmin: () => isAdmin,
|
getUserId: (req) =>
|
||||||
getOrgIds: () => opts.orgIds ?? [],
|
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,
|
connectionRepo,
|
||||||
grantsRepo,
|
grantsRepo,
|
||||||
auditRepo,
|
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', () => {
|
describe('SSH API: maintenance mode', () => {
|
||||||
it('blocks user POST /connections with 503 + Retry-After', async () => {
|
it('blocks user POST /connections with 503 + Retry-After', async () => {
|
||||||
const h = makeHarness();
|
const h = makeHarness();
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
* the pending host-key token (token only returned on test / observation).
|
* 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 Database from 'better-sqlite3';
|
||||||
|
|
||||||
import type { SshConnection, SshConnectionRepo, HostKeyVerifyResult } from '../ssh/connection-repo.js';
|
import type { SshConnection, SshConnectionRepo, HostKeyVerifyResult } from '../ssh/connection-repo.js';
|
||||||
@ -78,6 +78,14 @@ export interface SshApiDeps {
|
|||||||
getUserId(req: Request): string | null;
|
getUserId(req: Request): string | null;
|
||||||
isAdmin(req: Request): boolean;
|
isAdmin(req: Request): boolean;
|
||||||
getOrgIds(req: Request): string[];
|
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;
|
connectionRepo: SshConnectionRepo;
|
||||||
grantsRepo: SshGrantsRepo;
|
grantsRepo: SshGrantsRepo;
|
||||||
@ -279,9 +287,27 @@ function jsonError(res: Response, status: number, error: string, detail?: unknow
|
|||||||
// User router — /api/ssh/connections + /api/ssh/grants
|
// 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 {
|
export function createSshUserRouter(deps: SshApiDeps): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const testTimeoutMs = deps.connectionTestTimeoutMs ?? 5000;
|
const testTimeoutMs = deps.connectionTestTimeoutMs ?? 5000;
|
||||||
|
router.use(makeSshLocalUserMiddleware(deps.authActive ?? true));
|
||||||
|
|
||||||
// GET /api/ssh/connections — list owned (and globals visible via grant).
|
// GET /api/ssh/connections — list owned (and globals visible via grant).
|
||||||
// For Phase 5 we return:
|
// For Phase 5 we return:
|
||||||
@ -801,6 +827,7 @@ export function createSshUserRouter(deps: SshApiDeps): Router {
|
|||||||
|
|
||||||
export function createSshAdminRouter(deps: SshApiDeps): Router {
|
export function createSshAdminRouter(deps: SshApiDeps): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
router.use(makeSshLocalUserMiddleware(deps.authActive ?? true));
|
||||||
|
|
||||||
// GET /api/ssh/admin/connections
|
// GET /api/ssh/admin/connections
|
||||||
router.get('/connections', deps.requireAdmin, (_req, res) => {
|
router.get('/connections', deps.requireAdmin, (_req, res) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user