This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
@@ -705,6 +705,49 @@ describe('POST /api/local/tasks/:taskId/comments and /cancel ownership', () => {
|
||||
.send({ body: 'hi from alice', author: 'user' });
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
// Regression: two comments in quick succession to an idle task used to spawn
|
||||
// two queued jobs; the newest became latestJob so the task showed "Inbox"
|
||||
// while an older job ran. Now the second comment reuses the still-pending job.
|
||||
it('rapid comments to an idle task reuse the pending job (no duplicate)', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-dup-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
|
||||
const aliceUser: Express.User = { ...alice, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private', workspacePath: join(tempDir, 'ws') });
|
||||
const app = buildAppForUser(aliceUser);
|
||||
|
||||
const r1 = await request(app).post(`/api/local/tasks/${task.id}/comments`).send({ body: 'first', author: 'user' });
|
||||
expect(r1.status).toBe(201);
|
||||
const r2 = await request(app).post(`/api/local/tasks/${task.id}/comments`).send({ body: 'second', author: 'user' });
|
||||
expect(r2.status).toBe(201);
|
||||
expect(r2.body.reusedPending).toBe(true);
|
||||
expect(r2.body.jobId).toBe(r1.body.jobId);
|
||||
|
||||
const count = (repo.getDb()
|
||||
.prepare('SELECT COUNT(*) AS c FROM jobs WHERE repo = ? AND issue_number = ?')
|
||||
.get(localTaskRepoName(task.id), task.id) as { c: number }).c;
|
||||
expect(count).toBe(1);
|
||||
|
||||
const userComments = (await repo.listLocalTaskComments(task.id)).filter(c => c.author === 'user');
|
||||
expect(userComments.map(c => c.body)).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('a comment to a terminal (succeeded) task creates a fresh job', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-term-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
|
||||
const aliceUser: Express.User = { ...alice, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private', workspacePath: join(tempDir, 'ws') });
|
||||
const prev = await repo.createJob({ repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go' });
|
||||
await repo.updateJob(prev.id, { status: 'succeeded' });
|
||||
|
||||
const res = await request(buildAppForUser(aliceUser))
|
||||
.post(`/api/local/tasks/${task.id}/comments`).send({ body: 'again', author: 'user' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.reusedPending).toBe(false);
|
||||
expect(res.body.jobId).not.toBe(prev.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/local/tasks browserSessionProfileId owner check', () => {
|
||||
@@ -1106,3 +1149,84 @@ describe('POST /api/local/tasks/:id/continue — user-custom piece resolution',
|
||||
expect(res.body.error).toBe('piece_not_found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/local/tasks/evaluate-prompt (prompt coach)', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lt-coach-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildApp(
|
||||
evaluatePrompt?: (r: { instruction: string; piece?: string; userId: string; signal?: AbortSignal }) => Promise<unknown>,
|
||||
evaluatePromptTimeoutMs?: number,
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
mountLocalTasksApi(app, {
|
||||
repo,
|
||||
worktreeDir: join(tempDir, 'workspaces'),
|
||||
authActive: false,
|
||||
evaluatePrompt,
|
||||
evaluatePromptTimeoutMs,
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
it('returns 503 when the coach is not configured', async () => {
|
||||
const res = await request(buildApp(undefined)).post('/api/local/tasks/evaluate-prompt').send({ instruction: 'x' });
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it('returns 400 when instruction is empty', async () => {
|
||||
const res = await request(buildApp(async () => ({}))).post('/api/local/tasks/evaluate-prompt').send({ instruction: ' ' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('passes instruction/piece/userId/signal through and returns the evaluation', async () => {
|
||||
let captured: { instruction: string; piece?: string; userId: string; signal?: AbortSignal } | null = null;
|
||||
const evaluatePrompt = async (r: { instruction: string; piece?: string; userId: string; signal?: AbortSignal }) => {
|
||||
captured = r;
|
||||
return { overall: 72, axes: [], rewrite: 'better', predicted_piece: null, maestro_tips: [], personalized: [] };
|
||||
};
|
||||
const res = await request(buildApp(evaluatePrompt))
|
||||
.post('/api/local/tasks/evaluate-prompt')
|
||||
.send({ instruction: 'summarize this pdf', piece: 'research' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.overall).toBe(72);
|
||||
expect(captured!.instruction).toBe('summarize this pdf');
|
||||
expect(captured!.piece).toBe('research');
|
||||
expect(captured!.userId).toBe('local');
|
||||
// A timeout AbortSignal must be threaded so a slow LLM call can be cancelled.
|
||||
expect(captured!.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('aborts the evaluation signal when it exceeds the timeout', async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
const evaluatePrompt = (r: { instruction: string; piece?: string; userId: string; signal?: AbortSignal }) =>
|
||||
new Promise<unknown>((_resolve, reject) => {
|
||||
capturedSignal = r.signal;
|
||||
r.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
});
|
||||
// 10ms timeout: the coach never resolves, so the route aborts and 502s.
|
||||
const res = await request(buildApp(evaluatePrompt, 10))
|
||||
.post('/api/local/tasks/evaluate-prompt')
|
||||
.send({ instruction: 'slow one' });
|
||||
expect(res.status).toBe(502);
|
||||
expect(capturedSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 502 when the coach throws', async () => {
|
||||
const res = await request(buildApp(async () => { throw new Error('llm down'); }))
|
||||
.post('/api/local/tasks/evaluate-prompt')
|
||||
.send({ instruction: 'x' });
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,19 @@ export interface LocalTasksApiOptions {
|
||||
* rest of the no-auth path. Defaults to `true` (owner stays req.user.id).
|
||||
*/
|
||||
authActive?: boolean;
|
||||
/**
|
||||
* Optional. On-demand prompt coach: evaluates a draft task prompt (before any
|
||||
* task is created) using the cheap model + the user's own context. When unset,
|
||||
* the /evaluate-prompt route returns 503. Stateless — nothing is persisted.
|
||||
*/
|
||||
evaluatePrompt?: (req: {
|
||||
instruction: string;
|
||||
piece?: string;
|
||||
userId: string;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<unknown>;
|
||||
/** Timeout (ms) for a prompt-coach evaluation before it is aborted. Default 30000. */
|
||||
evaluatePromptTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void {
|
||||
@@ -352,12 +365,13 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
|
||||
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
|
||||
|
||||
// running / dispatching / waiting_subtasks 中: コメント保存のみ(agent-loop が注入する)
|
||||
const isActive = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
|
||||
const commentKind = isActive ? 'interjection' : 'comment';
|
||||
// A job actively running injects the comment at its next iteration — save
|
||||
// it as an interjection and don't touch the job queue.
|
||||
const isRunning = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
|
||||
const commentKind = isRunning ? 'interjection' : 'comment';
|
||||
const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind);
|
||||
|
||||
if (isActive) {
|
||||
if (isRunning) {
|
||||
logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`);
|
||||
res.status(201).json({ comment, jobId: prevJob!.id, interjection: true });
|
||||
return;
|
||||
@@ -374,7 +388,11 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
? `${body}\n\n添付ファイル(input/ に保存済み): ${savedFileNames.join(', ')}`
|
||||
: body;
|
||||
|
||||
const job = await repo.createJob({
|
||||
// Atomically reuse a still-pending job (e.g. a queued job from a comment
|
||||
// sent a moment earlier) or create one. Without this, rapid/concurrent
|
||||
// comments each spawned a job; the newest (queued) became latestJob and the
|
||||
// task showed "Inbox" while an older job ran in the background.
|
||||
const { job, created } = repo.createJobIfNoPending({
|
||||
repo: localTaskRepoName(taskId),
|
||||
issueNumber: taskId,
|
||||
instruction,
|
||||
@@ -387,9 +405,13 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
visibilityScopeOrgId: task!.visibilityScopeOrgId,
|
||||
browserSessionProfileId: task!.browserSessionProfileId ?? null,
|
||||
});
|
||||
await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });
|
||||
if (created) {
|
||||
await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });
|
||||
} else {
|
||||
logger.info(`[local-tasks-api] comment ${comment.id} appended to pending job ${job.id} (${job.status}) on task ${taskId}; no duplicate job created`);
|
||||
}
|
||||
|
||||
res.status(201).json({ comment, jobId: job.id });
|
||||
res.status(201).json({ comment, jobId: job.id, reusedPending: !created });
|
||||
} catch (err) {
|
||||
logger.error(`Local task comment create API error: ${err}`);
|
||||
res.status(500).json({ error: 'Failed to post local task comment' });
|
||||
@@ -486,6 +508,49 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
}
|
||||
});
|
||||
|
||||
// On-demand prompt coach. Evaluates a draft prompt before the task is
|
||||
// created (stateless), so it never touches the DB or piece-runner. The owner
|
||||
// context (memory / AGENTS.md / skills / visible pieces) is keyed on the
|
||||
// requesting user, falling back to 'local' for unauthenticated/no-auth calls.
|
||||
app.post('/api/local/tasks/evaluate-prompt', dynamicJson(), async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!opts.evaluatePrompt) {
|
||||
res.status(503).json({ error: 'Prompt coach is not configured' });
|
||||
return;
|
||||
}
|
||||
const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : '';
|
||||
if (!instruction.trim()) {
|
||||
res.status(400).json({ error: 'instruction is required' });
|
||||
return;
|
||||
}
|
||||
const piece = typeof req.body?.piece === 'string' ? req.body.piece : undefined;
|
||||
const userId = (req.user as Express.User | undefined)?.id ?? 'local';
|
||||
// Abort the underlying LLM stream on timeout so a slow model doesn't keep
|
||||
// consuming tokens/connections in the background after we've given up.
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutMs = opts.evaluatePromptTimeoutMs ?? 30000;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error('timeout'));
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
opts.evaluatePrompt({ instruction, piece, userId, signal: controller.signal }),
|
||||
timeout,
|
||||
]);
|
||||
res.json(result);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Prompt coach evaluation failed: ${err}`);
|
||||
res.status(502).json({ error: 'Prompt evaluation failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const taskId = parseTaskId(req.params.taskId);
|
||||
|
||||
@@ -7,6 +7,13 @@ import { logger } from '../logger.js';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { ConfigManager } from '../config-manager.js';
|
||||
import { mountConfigApi } from './config-api.js';
|
||||
import {
|
||||
mountSetupApi,
|
||||
buildNoAuthConfigGate,
|
||||
newNoAuthConfigGateState,
|
||||
ensureSetupToken,
|
||||
clearSetupToken,
|
||||
} from './setup-api.js';
|
||||
import { mountPiecesApi } from './pieces-api.js';
|
||||
import { mountToolsApi } from './tools-api.js';
|
||||
import { mountSkillsApi } from './skills-api.js';
|
||||
@@ -121,6 +128,13 @@ export interface CoreServerOptions {
|
||||
configuredRepos?: string[];
|
||||
generateTitle?: (body: string, ownerId?: string) => Promise<string>;
|
||||
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
||||
/** On-demand prompt coach for the create dialog (stateless draft evaluation). */
|
||||
evaluatePrompt?: (req: {
|
||||
instruction: string;
|
||||
piece?: string;
|
||||
userId: string;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<unknown>;
|
||||
configManager?: ConfigManager;
|
||||
piecesDir?: string;
|
||||
customPiecesDir?: string;
|
||||
@@ -911,6 +925,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
authActive,
|
||||
generateTitle: opts.generateTitle,
|
||||
selectPiece: opts.selectPiece,
|
||||
evaluatePrompt: opts.evaluatePrompt,
|
||||
pieceExists: opts.piecesDir
|
||||
? (name: string, ownerId?: string) => {
|
||||
// Mirror the worker's per-user → global-custom → builtin resolution order.
|
||||
@@ -966,7 +981,57 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// able to dispatch `/health` to the gateway sub-app when running
|
||||
// (LiteLLM-shape JSON — CRITICAL-3 fix).
|
||||
|
||||
// === Browser setup wizard (fresh no-auth install onboarding) ===
|
||||
// Token lifecycle + endpoints. The no-auth `/api/config` mutation gate
|
||||
// (Codex P1 #3) must register BEFORE mountConfigApi so it intercepts
|
||||
// PUT/POST while a setup window is open. See setup-api.ts.
|
||||
if (opts.configManager) {
|
||||
const setupDataDir = join(process.cwd(), 'data');
|
||||
const setupListenPort =
|
||||
opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port);
|
||||
// Synchronously seed the gate state to 'pending' (fail-closed for auth
|
||||
// writes) BEFORE the async lifecycle below resolves, so a request arriving
|
||||
// in the boot window can't slip an auth bootstrap through (Codex P1 #2).
|
||||
const setupGateState = newNoAuthConfigGateState();
|
||||
|
||||
// Token lifecycle (needs the async setup-lib import). Created only on a
|
||||
// fresh no-auth install (needs-setup); cleared otherwise so existing
|
||||
// configured deployments keep their prior open behavior. On failure the
|
||||
// gate stays 'pending' (fail-closed), never silently open.
|
||||
void (async () => {
|
||||
try {
|
||||
if (authActive) {
|
||||
clearSetupToken(setupDataDir);
|
||||
return;
|
||||
}
|
||||
const { isLlmConfigured } = await import('../../scripts/setup-lib.mjs');
|
||||
const workers = opts.configManager?.getConfig().provider?.workers ?? [];
|
||||
if (!isLlmConfigured(workers)) {
|
||||
const token = ensureSetupToken(setupDataDir);
|
||||
setupGateState.mode = 'token-required';
|
||||
logger.info(`[setup] open the UI and enter this setup token: ${token}`);
|
||||
logger.info('[setup] llm configured=false → wizard active');
|
||||
} else {
|
||||
clearSetupToken(setupDataDir);
|
||||
setupGateState.mode = 'open';
|
||||
logger.info('[setup] llm configured=true → wizard off');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`[setup] token lifecycle failed: ${String(e)}`);
|
||||
}
|
||||
})();
|
||||
|
||||
// No-auth /api/config auth-write gate (only mounted in no-auth mode).
|
||||
if (!authActive) {
|
||||
app.use('/api/config', buildNoAuthConfigGate(setupDataDir, setupGateState));
|
||||
}
|
||||
|
||||
mountSetupApi(app, opts.configManager, {
|
||||
authActive,
|
||||
listenPort: setupListenPort,
|
||||
dataDir: setupDataDir,
|
||||
});
|
||||
|
||||
mountConfigApi(app, opts.configManager);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { mkdtempSync, writeFileSync, existsSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { ConfigManager } from '../config-manager.js';
|
||||
import {
|
||||
mountSetupApi,
|
||||
buildNoAuthConfigGate,
|
||||
newNoAuthConfigGateState,
|
||||
type NoAuthConfigGateState,
|
||||
ensureSetupToken,
|
||||
readSetupToken,
|
||||
clearSetupToken,
|
||||
setupTokenPath,
|
||||
tokensMatch,
|
||||
} from './setup-api.js';
|
||||
import { mountConfigApi } from './config-api.js';
|
||||
|
||||
const UNCONFIGURED = 'config_version: 2\n';
|
||||
const CONFIGURED = [
|
||||
'config_version: 2',
|
||||
'llm:',
|
||||
' workers:',
|
||||
' - id: w1',
|
||||
' connection_type: direct',
|
||||
' endpoint: http://localhost:11434/v1',
|
||||
' model: llama3',
|
||||
' roles: [auto, fast, quality, title, reflection]',
|
||||
' max_concurrency: 1',
|
||||
' enabled: true',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function makeApp(
|
||||
yaml: string,
|
||||
opts: { authActive?: boolean; withConfigApi?: boolean; gateMode?: NoAuthConfigGateState['mode'] } = {},
|
||||
) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'setup-api-'));
|
||||
writeFileSync(join(dir, 'config.yaml'), yaml);
|
||||
const cm = new ConfigManager(join(dir, 'config.yaml'));
|
||||
const dataDir = join(dir, 'data');
|
||||
const authActive = opts.authActive === true;
|
||||
const gateState = newNoAuthConfigGateState();
|
||||
gateState.mode = opts.gateMode ?? 'token-required';
|
||||
const app = express();
|
||||
if (opts.withConfigApi) {
|
||||
app.use('/api/config', express.json());
|
||||
if (!authActive) app.use('/api/config', buildNoAuthConfigGate(dataDir, gateState));
|
||||
}
|
||||
mountSetupApi(app, cm, { authActive, listenPort: 9876, dataDir, deployHint: 'docker' });
|
||||
if (opts.withConfigApi) mountConfigApi(app, cm);
|
||||
return { app, cm, dir, dataDir, gateState };
|
||||
}
|
||||
|
||||
describe('setup-api: token helpers', () => {
|
||||
let dataDir: string;
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'setup-tok-'));
|
||||
});
|
||||
|
||||
it('ensureSetupToken creates a 0600 file and reuses it on second call', () => {
|
||||
const t1 = ensureSetupToken(dataDir);
|
||||
expect(t1).toMatch(/^[0-9a-f]{64}$/);
|
||||
const mode = statSync(setupTokenPath(dataDir)).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
const t2 = ensureSetupToken(dataDir);
|
||||
expect(t2).toBe(t1); // reuse, no regenerate race
|
||||
expect(readSetupToken(dataDir)).toBe(t1);
|
||||
});
|
||||
|
||||
it('readSetupToken returns null when absent and clearSetupToken is idempotent', () => {
|
||||
expect(readSetupToken(dataDir)).toBeNull();
|
||||
expect(() => clearSetupToken(dataDir)).not.toThrow();
|
||||
ensureSetupToken(dataDir);
|
||||
expect(existsSync(setupTokenPath(dataDir))).toBe(true);
|
||||
clearSetupToken(dataDir);
|
||||
expect(existsSync(setupTokenPath(dataDir))).toBe(false);
|
||||
expect(() => clearSetupToken(dataDir)).not.toThrow();
|
||||
});
|
||||
|
||||
it('tokensMatch is true only for an exact match (length-normalized)', () => {
|
||||
const t = 'a'.repeat(64);
|
||||
expect(tokensMatch(t, t)).toBe(true);
|
||||
expect(tokensMatch('a'.repeat(63), t)).toBe(false); // length mismatch
|
||||
expect(tokensMatch('b'.repeat(64), t)).toBe(false); // same length, wrong
|
||||
expect(tokensMatch('', t)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-api: GET /api/setup/status', () => {
|
||||
it('reports needsSetup=true with tokenRequired when fresh + token present', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED);
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
needsSetup: true,
|
||||
authActive: false,
|
||||
port: 9876,
|
||||
deployHint: 'docker',
|
||||
tokenRequired: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports needsSetup=false when an LLM worker is configured', async () => {
|
||||
const { app } = makeApp(CONFIGURED);
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.needsSetup).toBe(false);
|
||||
});
|
||||
|
||||
it('tokenRequired=false when auth is active', async () => {
|
||||
const { app } = makeApp(UNCONFIGURED, { authActive: true });
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
expect(res.body.authActive).toBe(true);
|
||||
expect(res.body.tokenRequired).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-api: token gate on /api/setup/apply', () => {
|
||||
it('403 without a token header', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED);
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).post('/api/setup/apply').send({ port: 1234 });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 with a wrong-length token (timingSafeEqual path)', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED);
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', 'short').send({ port: 1234 });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('410 once an LLM worker is configured (closed)', async () => {
|
||||
const { app, dataDir } = makeApp(CONFIGURED);
|
||||
ensureSetupToken(dataDir);
|
||||
const token = readSetupToken(dataDir)!;
|
||||
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({ port: 1234 });
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
|
||||
it('410 when auth is active', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { authActive: true });
|
||||
ensureSetupToken(dataDir);
|
||||
const token = readSetupToken(dataDir)!;
|
||||
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({ port: 1234 });
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-api: no-auth /api/config auth-write gate (Codex P1 #2/#3)', () => {
|
||||
it('blocks an auth.* PUT without a token in token-required mode', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows an auth.* PUT with the correct token', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
ensureSetupToken(dataDir);
|
||||
const token = readSetupToken(dataDir)!;
|
||||
const res = await request(app).put('/api/config').set('X-Setup-Token', token).send({ auth: { local: { enabled: false } } });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('does NOT gate non-auth writes (no Settings regression) even in token-required mode', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).put('/api/config').send({ server: { port: 5555 } });
|
||||
expect(res.status).toBe(200); // no token needed for a non-auth write
|
||||
});
|
||||
|
||||
it('fails closed (503) on an auth.* write while the boot lifecycle is pending (boot TOCTOU)', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'pending' });
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it('refuses an auth.* write after the token is cleared (completing setup cannot reopen the hole)', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
// token-required but no token file present (post-apply state)
|
||||
expect(readSetupToken(dataDir)).toBeNull();
|
||||
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows auth.* writes in 'open' mode (existing configured no-auth deployment, no regression)", async () => {
|
||||
const { app } = makeApp(CONFIGURED, { withConfigApi: true, gateMode: 'open' });
|
||||
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('leaves GET /api/config open', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).get('/api/config');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /api/config masks the bootstrap admin password after a local-auth apply (P1 #1)', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
const token = ensureSetupToken(dataDir);
|
||||
const applied = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({
|
||||
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
|
||||
auth: { mode: 'local', local: { email: '[email protected]', password: 'supersecret9' } },
|
||||
});
|
||||
expect(applied.status).toBe(200);
|
||||
const res = await request(app).get('/api/config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(JSON.stringify(res.body)).not.toContain('supersecret9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-api: POST /api/setup/apply', () => {
|
||||
function freshApp() {
|
||||
const ctx = makeApp(UNCONFIGURED);
|
||||
const token = ensureSetupToken(ctx.dataDir);
|
||||
return { ...ctx, token };
|
||||
}
|
||||
|
||||
it('LLM-only apply reflects a camelCase worker and clears the token (restart not required)', async () => {
|
||||
const { app, cm, dataDir, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'qwen3' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true, restartRequired: false });
|
||||
const workers = cm.getConfig().provider.workers ?? [];
|
||||
const w = workers.find((x) => x.model === 'qwen3');
|
||||
// If the camelCase keys had been double-converted (P1 #1 regression), the
|
||||
// worker's endpoint/model/roles would not survive into provider.workers.
|
||||
expect(w).toBeTruthy();
|
||||
expect(w!.endpoint).toBe('http://localhost:11434/v1');
|
||||
expect(w!.proxy).not.toBe(true); // connectionType:'direct' → proxy omitted/false
|
||||
expect(w!.roles).toEqual(expect.arrayContaining(['auto', 'fast', 'quality', 'title', 'reflection']));
|
||||
// window closed: token removed so no-auth /api/config reverts to open
|
||||
expect(readSetupToken(dataDir)).toBeNull();
|
||||
});
|
||||
|
||||
it('gateway apply requires an apiKey', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ llm: { connectionType: 'aao_gateway', endpoint: 'http://gw:4000/v1', model: 'm' } });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('port apply sets restartRequired and keeps the token', async () => {
|
||||
const { app, cm, dataDir, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' }, port: 8088 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.restartRequired).toBe(true);
|
||||
expect(cm.getConfig().server?.port).toBe(8088);
|
||||
expect(readSetupToken(dataDir)).not.toBeNull(); // kept until restart
|
||||
});
|
||||
|
||||
it('rejects an incomplete local auth block (shared validator)', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ auth: { mode: 'local', local: { email: '[email protected]' } } }); // no password
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an incomplete oauth block', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ auth: { mode: 'oauth', oauth: { provider: 'gitea', clientId: 'x', clientSecret: 'y', callbackUrl: 'http://h/cb' } } }); // gitea needs baseUrl
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects oauth without an admin email (would brick: no admin after restart)', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({
|
||||
auth: { mode: 'oauth', oauth: { provider: 'google', clientId: 'x', clientSecret: 'y', callbackUrl: 'http://h/cb' } },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.error)).toMatch(/admin email/i);
|
||||
});
|
||||
|
||||
it('applies a complete oauth block and writes auth.adminEmails', async () => {
|
||||
const { app, cm, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({
|
||||
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
|
||||
auth: {
|
||||
mode: 'oauth',
|
||||
oauth: { provider: 'google', clientId: 'cid', clientSecret: 'sec', callbackUrl: 'http://h/cb', adminEmail: '[email protected]' },
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.restartRequired).toBe(true);
|
||||
expect(res.body.adminEmail).toBe('[email protected]');
|
||||
expect(JSON.stringify(res.body)).not.toContain('sec'); // client secret not echoed
|
||||
const auth = cm.getConfig().auth;
|
||||
expect(auth?.adminEmails).toContain('[email protected]');
|
||||
expect(auth?.providers?.google?.clientId).toBe('cid');
|
||||
});
|
||||
|
||||
it('applies a complete local auth block, returns adminEmail, never echoes the password', async () => {
|
||||
const { app, cm, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({
|
||||
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
|
||||
auth: { mode: 'local', local: { email: '[email protected]', password: 'supersecret1' } },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.restartRequired).toBe(true);
|
||||
expect(res.body.adminEmail).toBe('[email protected]');
|
||||
expect(JSON.stringify(res.body)).not.toContain('supersecret1');
|
||||
const auth = cm.getConfig().auth;
|
||||
expect(auth?.local?.enabled).toBe(true);
|
||||
expect(auth?.local?.bootstrapAdmin?.email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('400 when nothing to apply', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a link-local / metadata LLM endpoint (apply SSRF denylist, parity with probe)', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ llm: { connectionType: 'direct', endpoint: 'http://169.254.169.254/v1', model: 'm' } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.error)).toMatch(/not allowed|blocked/);
|
||||
});
|
||||
|
||||
it('rejects an over-long local auth password', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({
|
||||
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
|
||||
auth: { mode: 'local', local: { email: '[email protected]', password: 'x'.repeat(2000) } },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-api: POST /api/setup/probe', () => {
|
||||
function freshApp() {
|
||||
const ctx = makeApp(UNCONFIGURED);
|
||||
const token = ensureSetupToken(ctx.dataDir);
|
||||
return { ...ctx, token };
|
||||
}
|
||||
|
||||
it('400 on an invalid connectionType', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/probe')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ connectionType: 'bogus', endpoint: 'http://localhost:11434' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('400 + blocked message for the cloud metadata IP (SSRF denylist)', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/probe')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ connectionType: 'direct', endpoint: 'http://169.254.169.254/v1' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.error)).toMatch(/not allowed|blocked/);
|
||||
});
|
||||
|
||||
describe('against a stub Ollama server', () => {
|
||||
let server: Server;
|
||||
let url: string;
|
||||
beforeEach(async () => {
|
||||
server = createServer((req, res) => {
|
||||
if (req.url === '/api/tags') {
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ models: [{ name: 'llama3' }, { name: 'qwen3' }] }));
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end('no');
|
||||
}
|
||||
});
|
||||
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;
|
||||
url = `http://127.0.0.1:${port}/v1`;
|
||||
});
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
});
|
||||
|
||||
it('returns the model list from the live endpoint', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/probe')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ connectionType: 'direct', endpoint: url });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.models).toEqual(['llama3', 'qwen3']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,552 @@
|
||||
// Browser setup wizard server API. Mounts three endpoints that let a fresh,
|
||||
// no-auth `docker compose up` install configure its LLM connection (+ optional
|
||||
// port / auth) from the browser, without hand-editing config.yaml.
|
||||
//
|
||||
// GET /api/setup/status — public; never returns secrets
|
||||
// POST /api/setup/probe — setup-token gated; live model probe (SSRF-guarded)
|
||||
// POST /api/setup/apply — setup-token gated; writes a narrow config patch
|
||||
//
|
||||
// Security model (see docs/superpowers/specs/2026-06-16-browser-setup-wizard-design.md):
|
||||
// during the no-auth window a host-readable one-time token (data/.setup-token)
|
||||
// gates every mutating path — including no-auth `/api/config` mutations (Codex
|
||||
// P1 #3) — so that exposing the port doesn't let an anonymous caller bootstrap
|
||||
// an admin via `auth.local.bootstrapAdmin`. Token comparison is constant-time.
|
||||
//
|
||||
// The pure LLM logic (parseEndpoint / probeModels / camelCase worker builder /
|
||||
// isLlmConfigured) is shared with the CLI wizard via scripts/setup-lib.mjs,
|
||||
// imported at runtime by the SAME relative path from dev and dist layouts
|
||||
// (Codex P2 #7).
|
||||
|
||||
import express, { type Application, type Request, type Response, type RequestHandler } from 'express';
|
||||
import {
|
||||
closeSync,
|
||||
constants as fsConstants,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { ConfigManager } from '../config-manager.js';
|
||||
import type { AuthProviderConfig } from '../config.js';
|
||||
import { isProviderConfigured } from './auth.js';
|
||||
import { isPrivateOrForbidden, pinnedFetch } from '../net/ssrf-strict.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared setup-core loader (CLI + browser share ONE implementation).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SetupLib = typeof import('../../scripts/setup-lib.mjs');
|
||||
let _setupLib: Promise<SetupLib> | null = null;
|
||||
function setupLib(): Promise<SetupLib> {
|
||||
return (_setupLib ??= import('../../scripts/setup-lib.mjs'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup-token persistence (Codex P1 #4).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SETUP_TOKEN_FILE = '.setup-token';
|
||||
|
||||
export function setupTokenPath(dataDir: string): string {
|
||||
return join(dataDir, SETUP_TOKEN_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the one-time setup token atomically (O_CREAT|O_EXCL, 0600) or reuse an
|
||||
* existing one. Reusing on EEXIST avoids a generate race across boots/processes
|
||||
* that would otherwise print one token but persist another. Call ONLY during the
|
||||
* no-auth + needs-setup window.
|
||||
*/
|
||||
export function ensureSetupToken(dataDir: string, _attempt = 0): string {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const p = setupTokenPath(dataDir);
|
||||
try {
|
||||
const fd = openSync(p, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
|
||||
try {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
writeSync(fd, token);
|
||||
return token;
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
const existing = readFileSync(p, 'utf8').trim();
|
||||
if (existing) return existing;
|
||||
// Empty/corrupt file — replace it. Bound the retry so a pathological
|
||||
// FS (file keeps reappearing empty, or unlink/create racing) can't spin
|
||||
// forever.
|
||||
if (_attempt >= 3) {
|
||||
throw new Error('[setup] could not initialize the setup token (empty token file kept reappearing)');
|
||||
}
|
||||
unlinkSync(p);
|
||||
return ensureSetupToken(dataDir, _attempt + 1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function readSetupToken(dataDir: string): string | null {
|
||||
try {
|
||||
const t = readFileSync(setupTokenPath(dataDir), 'utf8').trim();
|
||||
return t || null;
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSetupToken(dataDir: string): void {
|
||||
try {
|
||||
unlinkSync(setupTokenPath(dataDir));
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Constant-time token compare with length normalization (no early return). */
|
||||
export function tokensMatch(provided: string, actual: string): boolean {
|
||||
const a = Buffer.from(provided, 'utf8');
|
||||
const b = Buffer.from(actual, 'utf8');
|
||||
if (a.length !== b.length) {
|
||||
// Burn an equal-length compare so a length mismatch isn't faster.
|
||||
timingSafeEqual(b, b);
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSRF guard for probe (Codex P1 #6).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SSRF policy for probe/apply targets. We ALLOW the safe-private ranges a
|
||||
// self-hosted LLM legitimately uses (loopback, RFC1918, CGNAT, IPv6 ULA) but
|
||||
// BLOCK everything isPrivateOrForbidden() flags as dangerous: link-local /
|
||||
// cloud-metadata (169.254.0.0/16 incl. 169.254.169.254 + IMDS), IPv4-mapped,
|
||||
// NAT64, AWS IMDS IPv6, multicast, reserved, broadcast. The block set is the
|
||||
// vetted ssrf-strict policy MINUS the safe-private subset, so any new dangerous
|
||||
// range added there is inherited here automatically.
|
||||
function isSafePrivate(ip: string, family: 4 | 6): boolean {
|
||||
const a = ip.toLowerCase();
|
||||
if (family === 4) {
|
||||
return (
|
||||
/^127\./.test(a) || // loopback
|
||||
/^10\./.test(a) || // RFC1918
|
||||
/^192\.168\./.test(a) || // RFC1918
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(a) || // RFC1918 172.16/12
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a) // 100.64.0.0/10 CGNAT
|
||||
);
|
||||
}
|
||||
if (a === '::1') return true; // loopback
|
||||
if (/^f[cd]/.test(a)) return true; // fc00::/7 ULA
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBlockedProbeAddress(ip: string, family: 4 | 6): boolean {
|
||||
return isPrivateOrForbidden(ip, family) && !isSafePrivate(ip, family);
|
||||
}
|
||||
|
||||
// Resolve the endpoint host ONCE, vet every resolved address, and return the
|
||||
// pinned IP. The probe fetch then connects to THIS IP (via pinnedFetch) so a
|
||||
// rebinding DNS server can't pass a benign IP at check time and serve a
|
||||
// metadata IP at fetch time (Codex P1 #4 TOCTOU). Brackets/zone-ids are
|
||||
// stripped so an IPv6 literal like `[fe80::1]` or `[::ffff:a9fe:a9fe]` can't
|
||||
// slip past the address check. Literal IPs skip DNS.
|
||||
async function resolveProbeTarget(
|
||||
hostname: string,
|
||||
): Promise<{ ok: true; ip: string; family: 4 | 6 } | { ok: false; reason: string }> {
|
||||
const host = hostname.replace(/^\[/, '').replace(/\]$/, '').split('%')[0];
|
||||
const lit = isIP(host);
|
||||
if (lit === 4 || lit === 6) {
|
||||
const fam = lit as 4 | 6;
|
||||
if (isBlockedProbeAddress(host, fam)) return { ok: false, reason: 'endpoint host is not allowed' };
|
||||
return { ok: true, ip: host, family: fam };
|
||||
}
|
||||
let addrs: Array<{ address: string; family: number }>;
|
||||
try {
|
||||
addrs = await dnsLookup(host, { all: true });
|
||||
} catch {
|
||||
return { ok: false, reason: 'could not resolve endpoint host' };
|
||||
}
|
||||
if (!addrs.length) return { ok: false, reason: 'endpoint host did not resolve' };
|
||||
for (const a of addrs) {
|
||||
if (isBlockedProbeAddress(a.address, a.family as 4 | 6)) {
|
||||
return { ok: false, reason: 'endpoint resolves to a blocked address' };
|
||||
}
|
||||
}
|
||||
return { ok: true, ip: addrs[0].address, family: addrs[0].family as 4 | 6 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth block validation — shares completeness logic with boot fail-closed
|
||||
// (Codex P1 #5). We only persist a block that WILL make auth active next boot.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AuthApplyInput {
|
||||
mode?: unknown;
|
||||
local?: { email?: unknown; password?: unknown; allowSignup?: unknown };
|
||||
oauth?: {
|
||||
provider?: unknown;
|
||||
clientId?: unknown;
|
||||
clientSecret?: unknown;
|
||||
callbackUrl?: unknown;
|
||||
baseUrl?: unknown;
|
||||
adminEmail?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type AuthValidation =
|
||||
| { ok: true; patch: Record<string, unknown>; adminEmail?: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function validateAuthBlock(auth: AuthApplyInput): AuthValidation {
|
||||
const mode = auth.mode;
|
||||
if (mode === 'local') {
|
||||
const email = String(auth.local?.email ?? '').trim();
|
||||
const password = String(auth.local?.password ?? '');
|
||||
if (!email || !password) return { ok: false, error: 'local auth requires email and password' };
|
||||
if (password.length < 8) return { ok: false, error: 'local auth password must be at least 8 characters' };
|
||||
if (password.length > 1024) return { ok: false, error: 'local auth password is too long (max 1024 characters)' };
|
||||
return {
|
||||
ok: true,
|
||||
adminEmail: email,
|
||||
patch: {
|
||||
local: {
|
||||
enabled: true,
|
||||
allowSignup: auth.local?.allowSignup === true,
|
||||
bootstrapAdmin: { email, password },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (mode === 'oauth') {
|
||||
const o = auth.oauth ?? {};
|
||||
const provider = o.provider;
|
||||
if (provider !== 'google' && provider !== 'gitea') {
|
||||
return { ok: false, error: 'oauth provider must be google or gitea' };
|
||||
}
|
||||
const candidate: AuthProviderConfig = {
|
||||
clientId: o.clientId ? String(o.clientId) : '',
|
||||
clientSecret: o.clientSecret ? String(o.clientSecret) : '',
|
||||
callbackUrl: o.callbackUrl ? String(o.callbackUrl) : '',
|
||||
...(provider === 'gitea' ? { baseUrl: o.baseUrl ? String(o.baseUrl) : '' } : {}),
|
||||
};
|
||||
// Reuse the exact boot-time completeness check so we never write a partial
|
||||
// OAuth block that would fail-closed (refuse to boot) on restart.
|
||||
if (!isProviderConfigured(candidate, provider)) {
|
||||
return { ok: false, error: `oauth ${provider} config is incomplete (need clientId, clientSecret, callbackUrl${provider === 'gitea' ? ', baseUrl' : ''})` };
|
||||
}
|
||||
// Require an admin email and write it into auth.adminEmails. Without this,
|
||||
// the first OAuth user logs in as 'pending' with NO admin existing — the
|
||||
// operator can't reach Settings/config and the instance is bricked until a
|
||||
// manual config edit (Codex P2 #5). adminEmails auto-promotes a matching
|
||||
// pending user to admin on first login (auth.ts).
|
||||
const adminEmail = String(auth.oauth?.adminEmail ?? '').trim();
|
||||
if (!adminEmail || !adminEmail.includes('@')) {
|
||||
return { ok: false, error: 'oauth setup requires an admin email (the account that should become admin on first login)' };
|
||||
}
|
||||
return { ok: true, adminEmail, patch: { providers: { [provider]: candidate }, adminEmails: [adminEmail] } };
|
||||
}
|
||||
return { ok: false, error: "auth.mode must be 'local' or 'oauth'" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mount.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MountSetupApiOptions {
|
||||
/** True when an OAuth provider or local auth is active (no setup window). */
|
||||
authActive: boolean;
|
||||
/** Resolved listen port (for the restart hint shown by the wizard). */
|
||||
listenPort: number;
|
||||
/** Directory holding data/.setup-token (defaults derived by caller). */
|
||||
dataDir: string;
|
||||
/** Optional override; auto-detected from /.dockerenv otherwise. */
|
||||
deployHint?: 'docker' | 'source';
|
||||
}
|
||||
|
||||
function detectDeployHint(): 'docker' | 'source' {
|
||||
return existsSync('/.dockerenv') ? 'docker' : 'source';
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute needs-setup on EVERY request (never cache): the moment the runtime
|
||||
* has a usable LLM worker, probe/apply close (410). Operates on the resolved
|
||||
* `provider.workers` so the synthetic empty-model default and OLLAMA_* env
|
||||
* overrides are accounted for (Codex P1 #2).
|
||||
*/
|
||||
async function computeNeedsSetup(configManager: ConfigManager): Promise<boolean> {
|
||||
const { isLlmConfigured } = await setupLib();
|
||||
const workers = configManager.getConfig().provider?.workers ?? [];
|
||||
return !isLlmConfigured(workers);
|
||||
}
|
||||
|
||||
export function mountSetupApi(
|
||||
app: Application,
|
||||
configManager: ConfigManager,
|
||||
opts: MountSetupApiOptions,
|
||||
): void {
|
||||
const { authActive, listenPort, dataDir } = opts;
|
||||
const deployHint = opts.deployHint ?? detectDeployHint();
|
||||
|
||||
// GET status — public, no secrets. Drives the UI gate.
|
||||
app.get('/api/setup/status', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const needsSetup = await computeNeedsSetup(configManager);
|
||||
const tokenRequired = !authActive && readSetupToken(dataDir) !== null;
|
||||
res.json({ needsSetup, authActive, port: listenPort, deployHint, tokenRequired });
|
||||
} catch (e) {
|
||||
logger.warn(`[setup-api] status failed: ${String(e)}`);
|
||||
res.status(500).json({ needsSetup: false, authActive, error: 'status unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// Token gate for probe/apply. "Closed" (410) takes precedence over
|
||||
// "forbidden" (403) so a configured/auth-active server signals closure
|
||||
// regardless of the supplied token.
|
||||
const requireSetupToken: RequestHandler = (req, res, next) => {
|
||||
void (async () => {
|
||||
if (authActive) {
|
||||
res.status(410).json({ ok: false, error: 'setup is closed (auth active)' });
|
||||
return;
|
||||
}
|
||||
if (!(await computeNeedsSetup(configManager))) {
|
||||
res.status(410).json({ ok: false, error: 'setup is closed (llm already configured)' });
|
||||
return;
|
||||
}
|
||||
const actual = readSetupToken(dataDir);
|
||||
if (!actual) {
|
||||
res.status(410).json({ ok: false, error: 'setup is closed' });
|
||||
return;
|
||||
}
|
||||
const provided = String(req.header('x-setup-token') ?? '');
|
||||
if (!provided || !tokensMatch(provided, actual)) {
|
||||
res.status(403).json({ ok: false, error: 'invalid setup token' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
})().catch((e) => {
|
||||
logger.warn(`[setup-api] token gate failed: ${String(e)}`);
|
||||
res.status(500).json({ ok: false, error: 'setup gate error' });
|
||||
});
|
||||
};
|
||||
|
||||
// POST probe — live model discovery, SSRF-guarded.
|
||||
app.post('/api/setup/probe', express.json(), requireSetupToken, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { CONNECTION_TYPES, parseEndpoint, probeModels } = await setupLib();
|
||||
const body = (req.body ?? {}) as { connectionType?: unknown; endpoint?: unknown; apiKey?: unknown };
|
||||
const connectionType = String(body.connectionType ?? '');
|
||||
if (!CONNECTION_TYPES.includes(connectionType as never)) {
|
||||
res.status(400).json({ ok: false, error: `connectionType must be one of ${CONNECTION_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const ep = parseEndpoint(body.endpoint);
|
||||
if (ep.error || !ep.endpoint) {
|
||||
res.status(400).json({ ok: false, error: ep.error ?? 'invalid endpoint' });
|
||||
return;
|
||||
}
|
||||
const apiKey = body.apiKey ? String(body.apiKey) : undefined;
|
||||
if (connectionType === 'aao_gateway' && !apiKey) {
|
||||
res.status(400).json({ ok: false, error: 'apiKey is required for aao_gateway' });
|
||||
return;
|
||||
}
|
||||
const target = await resolveProbeTarget(new URL(ep.endpoint).hostname);
|
||||
if (!target.ok) {
|
||||
res.status(400).json({ ok: false, models: [], error: target.reason });
|
||||
return;
|
||||
}
|
||||
// Pin the connection to the vetted IP and forbid redirects so a 3xx can't
|
||||
// bounce to an internal target.
|
||||
const pinned: typeof fetch = (url, init) =>
|
||||
pinnedFetch(String(url), {
|
||||
...(init as RequestInit),
|
||||
pinnedIp: target.ip,
|
||||
family: target.family,
|
||||
redirect: 'manual',
|
||||
});
|
||||
const result = await probeModels({ endpoint: ep.endpoint, base: ep.base, apiKey, fetchImpl: pinned });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
logger.warn(`[setup-api] probe failed: ${String(e)}`);
|
||||
res.status(500).json({ ok: false, models: [], error: 'probe failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST apply — narrow config patch (LLM + optional port + optional auth).
|
||||
app.post('/api/setup/apply', express.json(), requireSetupToken, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { CONNECTION_TYPES, parseEndpoint, buildLlmWorkerCamel } = await setupLib();
|
||||
const body = (req.body ?? {}) as {
|
||||
llm?: { connectionType?: unknown; endpoint?: unknown; model?: unknown; apiKey?: unknown };
|
||||
port?: unknown;
|
||||
auth?: AuthApplyInput;
|
||||
};
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
let restartRequired = false;
|
||||
let adminEmail: string | undefined;
|
||||
|
||||
// --- LLM (camelCase v2 shape; Codex P1 #1) ---
|
||||
if (body.llm) {
|
||||
const connectionType = String(body.llm.connectionType ?? '');
|
||||
if (!CONNECTION_TYPES.includes(connectionType as never)) {
|
||||
res.status(400).json({ ok: false, error: `llm.connectionType must be one of ${CONNECTION_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const ep = parseEndpoint(body.llm.endpoint);
|
||||
if (ep.error || !ep.endpoint) {
|
||||
res.status(400).json({ ok: false, error: `llm.endpoint: ${ep.error ?? 'invalid endpoint'}` });
|
||||
return;
|
||||
}
|
||||
const model = String(body.llm.model ?? '').trim();
|
||||
if (!model) {
|
||||
res.status(400).json({ ok: false, error: 'llm.model is required' });
|
||||
return;
|
||||
}
|
||||
const apiKey = body.llm.apiKey ? String(body.llm.apiKey) : undefined;
|
||||
if (connectionType === 'aao_gateway' && !apiKey) {
|
||||
res.status(400).json({ ok: false, error: 'llm.apiKey is required for aao_gateway' });
|
||||
return;
|
||||
}
|
||||
// Apply the same SSRF denylist as probe before PERSISTING the endpoint:
|
||||
// a token holder could otherwise skip probe and write a link-local /
|
||||
// metadata target straight into config, which every later worker LLM
|
||||
// call would then hit. Keeps probe and apply consistent.
|
||||
const epTarget = await resolveProbeTarget(new URL(ep.endpoint).hostname);
|
||||
if (!epTarget.ok) {
|
||||
res.status(400).json({ ok: false, error: `llm.endpoint: ${epTarget.reason}` });
|
||||
return;
|
||||
}
|
||||
const worker = buildLlmWorkerCamel({
|
||||
connectionType: connectionType as 'direct' | 'aao_gateway',
|
||||
endpoint: ep.endpoint,
|
||||
model,
|
||||
apiKey,
|
||||
});
|
||||
patch.llm = { workers: [worker] };
|
||||
}
|
||||
|
||||
// --- Port (optional; restart) ---
|
||||
if (body.port !== undefined && body.port !== null && body.port !== '') {
|
||||
const port = Number(body.port);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
res.status(400).json({ ok: false, error: 'port must be an integer 1-65535' });
|
||||
return;
|
||||
}
|
||||
patch.server = { port };
|
||||
restartRequired = true;
|
||||
}
|
||||
|
||||
// --- Auth (optional; restart) ---
|
||||
if (body.auth) {
|
||||
const v = validateAuthBlock(body.auth);
|
||||
if (!v.ok) {
|
||||
res.status(400).json({ ok: false, error: v.error });
|
||||
return;
|
||||
}
|
||||
patch.auth = v.patch;
|
||||
adminEmail = v.adminEmail;
|
||||
restartRequired = true;
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
res.status(400).json({ ok: false, error: 'nothing to apply' });
|
||||
return;
|
||||
}
|
||||
|
||||
const etag = configManager.getConfigForApi().etag;
|
||||
const result = configManager.updateConfig(patch, etag);
|
||||
if (!result.ok) {
|
||||
const status = (result as { conflict?: boolean }).conflict ? 409 : 400;
|
||||
res.status(status).json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// LLM-only apply with no restart: the setup window's job is done. Drop the
|
||||
// token so no-auth `/api/config` reverts to its prior (open) behavior and
|
||||
// probe/apply 410. When a restart IS required (port/auth), keep the token
|
||||
// until the next boot clears it — this also keeps the just-written
|
||||
// bootstrapAdmin protected against an overwrite in the no-auth gap.
|
||||
if (!restartRequired) clearSetupToken(dataDir);
|
||||
|
||||
logger.info(
|
||||
`[setup-api] apply ok llm=${!!patch.llm} port=${!!patch.server} auth=${!!patch.auth} restart=${restartRequired}`,
|
||||
);
|
||||
res.json({ ok: true, restartRequired, ...(adminEmail ? { adminEmail } : {}) });
|
||||
} catch (e) {
|
||||
logger.warn(`[setup-api] apply failed: ${String(e)}`);
|
||||
res.status(500).json({ ok: false, error: 'apply failed' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-resolved state for the no-auth `/api/config` gate. Starts 'pending'
|
||||
* (set synchronously at mount, BEFORE the async boot lifecycle determines
|
||||
* fresh-vs-configured) so a request landing in that window can't slip through
|
||||
* (Codex P1 #2 boot TOCTOU). The lifecycle flips it to:
|
||||
* - 'token-required' on a fresh no-auth install (a setup token was created)
|
||||
* - 'open' on a deployment that already had an LLM configured at boot
|
||||
*/
|
||||
export interface NoAuthConfigGateState {
|
||||
mode: 'pending' | 'open' | 'token-required';
|
||||
}
|
||||
|
||||
export function newNoAuthConfigGateState(): NoAuthConfigGateState {
|
||||
return { mode: 'pending' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard no-auth `/api/config` mutations that would change `auth.*` (the only
|
||||
* privilege-escalation vector in no-auth mode — writing bootstrapAdmin/OAuth
|
||||
* to seize admin on the next restart). NON-auth writes (llm/port/tools) keep
|
||||
* the prior open no-auth behavior, so the Settings UI is not regressed.
|
||||
*
|
||||
* - 'pending' → fail closed (503): the boot lifecycle hasn't resolved
|
||||
* yet, so we must not let an auth write through during the boot race.
|
||||
* - 'open' → allow (a deployment already configured at boot keeps its
|
||||
* prior behavior; this is the no-regression path).
|
||||
* - 'token-required' → require a valid setup token for auth writes. Once the
|
||||
* token is gone (cleared after setup completes) auth writes are refused
|
||||
* outright, so completing the wizard can't reopen the bootstrap hole
|
||||
* (Codex P1 #3). GET/HEAD always pass so the UI can read config.
|
||||
*/
|
||||
export function buildNoAuthConfigGate(dataDir: string, state: NoAuthConfigGateState): RequestHandler {
|
||||
return (req: Request, res: Response, next) => {
|
||||
if (req.method === 'GET' || req.method === 'HEAD') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const body = req.body as unknown;
|
||||
const touchesAuth = !!body && typeof body === 'object' && !Array.isArray(body) && 'auth' in (body as object);
|
||||
if (!touchesAuth) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (state.mode === 'pending') {
|
||||
res.status(503).json({ ok: false, error: 'setup initializing, retry shortly' });
|
||||
return;
|
||||
}
|
||||
if (state.mode === 'open') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// token-required
|
||||
const actual = readSetupToken(dataDir);
|
||||
const provided = String(req.header('x-setup-token') ?? '');
|
||||
if (actual && provided && tokensMatch(provided, actual)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({ ok: false, error: 'setup token required to change auth config during initial setup' });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Codex P2 #7: the setup wizard server shares its pure LLM logic with the CLI
|
||||
// via scripts/setup-lib.mjs, imported at RUNTIME by the relative specifier
|
||||
// `../../scripts/setup-lib.mjs`. Both the dev (src/bridge) and built
|
||||
// (dist/bridge) modules sit two levels under the repo root, so that specifier
|
||||
// resolves to <root>/scripts/setup-lib.mjs in either layout. If the file goes
|
||||
// missing from the runtime image (e.g. the Dockerfile stops copying it), the
|
||||
// setup endpoints 500 on first boot. These tests fix that contract.
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url)); // .../src/bridge
|
||||
const repoRoot = resolve(here, '../..');
|
||||
|
||||
describe('setup-lib distribution resolution (P2 #7)', () => {
|
||||
it('the shared .mjs exists at <root>/scripts and is reachable via ../../scripts from bridge', () => {
|
||||
// src/bridge and dist/bridge both resolve `../../scripts` to <root>/scripts.
|
||||
const fromBridge = resolve(here, '../../scripts/setup-lib.mjs');
|
||||
expect(fromBridge).toBe(resolve(repoRoot, 'scripts/setup-lib.mjs'));
|
||||
expect(existsSync(fromBridge)).toBe(true);
|
||||
});
|
||||
|
||||
it('importing the runtime specifier yields the exports setup-api depends on', async () => {
|
||||
const lib = await import('../../scripts/setup-lib.mjs');
|
||||
for (const name of ['isLlmConfigured', 'parseEndpoint', 'probeModels', 'buildLlmWorkerCamel', 'CONNECTION_TYPES']) {
|
||||
expect(lib[name as keyof typeof lib], `missing export ${name}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('the Dockerfile runtime stage copies the shared .mjs into the image', () => {
|
||||
const dockerfile = readFileSync(resolve(repoRoot, 'Dockerfile'), 'utf8');
|
||||
// Must appear AFTER the second FROM (runtime stage), not only in the builder.
|
||||
const stages = dockerfile.split(/^FROM /m);
|
||||
const runtimeStage = stages[stages.length - 1];
|
||||
expect(runtimeStage).toMatch(/COPY\s+scripts\/setup-lib\.mjs\s+\.\/scripts\/setup-lib\.mjs/);
|
||||
});
|
||||
|
||||
it('the Dockerfile ships an UNCONFIGURED default config.yaml so a fresh install shows the wizard', () => {
|
||||
const dockerfile = readFileSync(resolve(repoRoot, 'Dockerfile'), 'utf8');
|
||||
// Baking the fully-worked example as the live config (it has an enabled
|
||||
// localhost worker) would mark a fresh install "configured" and suppress
|
||||
// the wizard. The image must instead write a minimal config_version:2 file.
|
||||
expect(dockerfile).not.toMatch(/COPY\s+config\.yaml\.example\s+\.\/config\.yaml\s*$/m);
|
||||
expect(dockerfile).toMatch(/config_version:\s*2[^\n]*>\s*\.\/config\.yaml/);
|
||||
});
|
||||
|
||||
it('the shipped example IS configured (guards the test above from going stale)', () => {
|
||||
// Sanity: confirm config.yaml.example really does carry a usable worker, so
|
||||
// the "ship a minimal default instead" fix is actually load-bearing.
|
||||
const example = readFileSync(resolve(repoRoot, 'config.yaml.example'), 'utf8');
|
||||
expect(example).toMatch(/^\s*model:\s*\S+/m);
|
||||
expect(example).toMatch(/^\s*endpoint:\s*\S+/m);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,8 @@ const META_TOOLS = new Set<string>([
|
||||
'RunUserScript',
|
||||
'UpdateUserMemory',
|
||||
'ReadUserMemory',
|
||||
'ReadUserAgents',
|
||||
'UpdateUserAgents',
|
||||
'WriteUserScript',
|
||||
'Brainstorm',
|
||||
'ReadAppDoc',
|
||||
|
||||
@@ -15,6 +15,10 @@ const SENSITIVE_PATHS = [
|
||||
'auth.sessionSecret',
|
||||
'auth.providers.google.clientSecret',
|
||||
'auth.providers.gitea.clientSecret',
|
||||
// The browser setup wizard writes a first-admin password here. In no-auth
|
||||
// mode GET /api/config is unauthenticated, so an unmasked value would let any
|
||||
// caller read the bootstrap admin password before the auth-enabling restart.
|
||||
'auth.local.bootstrapAdmin.password',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1724,3 +1724,46 @@ describe('Repository title derivation from Mission Brief goal', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository.createJobIfNoPending', () => {
|
||||
let tempDir = '';
|
||||
afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } });
|
||||
function makeRepo(): Repository {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-pending-'));
|
||||
return new Repository(join(tempDir, 'orchestrator.db'));
|
||||
}
|
||||
|
||||
it('creates a job when none exists', async () => {
|
||||
const repo = makeRepo();
|
||||
try {
|
||||
const r = repo.createJobIfNoPending({ repo: 'local/task-1', issueNumber: 1, instruction: 'go' });
|
||||
expect(r.created).toBe(true);
|
||||
expect(r.job.status).toBe('queued');
|
||||
} finally { repo.close(); }
|
||||
});
|
||||
|
||||
it('reuses an existing queued/running job instead of creating a duplicate', async () => {
|
||||
const repo = makeRepo();
|
||||
try {
|
||||
const first = repo.createJobIfNoPending({ repo: 'local/task-2', issueNumber: 2, instruction: 'a' });
|
||||
const second = repo.createJobIfNoPending({ repo: 'local/task-2', issueNumber: 2, instruction: 'b' });
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.job.id).toBe(first.job.id);
|
||||
await repo.updateJob(first.job.id, { status: 'running' });
|
||||
const third = repo.createJobIfNoPending({ repo: 'local/task-2', issueNumber: 2, instruction: 'c' });
|
||||
expect(third.created).toBe(false);
|
||||
expect(third.job.id).toBe(first.job.id);
|
||||
} finally { repo.close(); }
|
||||
});
|
||||
|
||||
it('creates a fresh job once the previous one is terminal', async () => {
|
||||
const repo = makeRepo();
|
||||
try {
|
||||
const first = repo.createJobIfNoPending({ repo: 'local/task-3', issueNumber: 3, instruction: 'a' });
|
||||
await repo.updateJob(first.job.id, { status: 'succeeded' });
|
||||
const second = repo.createJobIfNoPending({ repo: 'local/task-3', issueNumber: 3, instruction: 'b' });
|
||||
expect(second.created).toBe(true);
|
||||
expect(second.job.id).not.toBe(first.job.id);
|
||||
} finally { repo.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
+36
-1
@@ -1305,7 +1305,13 @@ export class Repository {
|
||||
logger.info('Repository: jobs table migration complete');
|
||||
}
|
||||
|
||||
async createJob(params: CreateJobParams): Promise<Job> {
|
||||
// Non-terminal job states. A task with a job in any of these already has work
|
||||
// pending/running, so a new user comment should be appended to it rather than
|
||||
// spawning a second job. waiting_human is excluded on purpose: a comment there
|
||||
// is the answer that resumes via a fresh job.
|
||||
private static readonly PENDING_JOB_STATES = ['queued', 'dispatching', 'running', 'waiting_subtasks', 'retry'] as const;
|
||||
|
||||
private insertJobSync(params: CreateJobParams): Job {
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const pieceName = params.pieceName ?? 'chat';
|
||||
@@ -1346,6 +1352,35 @@ export class Repository {
|
||||
return job;
|
||||
}
|
||||
|
||||
async createJob(params: CreateJobParams): Promise<Job> {
|
||||
return this.insertJobSync(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reuse-or-create. If a non-terminal job already exists for the
|
||||
* issue, return it (created=false) without inserting; otherwise insert a new
|
||||
* queued job (created=true). better-sqlite3 transactions run synchronously, so
|
||||
* the check+insert can't interleave with a concurrent request — this closes
|
||||
* the TOCTOU race where two near-simultaneous comments each spawned a job,
|
||||
* leaving the newest (queued) duplicate as the task's latestJob while an older
|
||||
* job actually ran (status stuck on "Inbox").
|
||||
*/
|
||||
createJobIfNoPending(params: CreateJobParams): { job: Job; created: boolean } {
|
||||
const states = Repository.PENDING_JOB_STATES;
|
||||
const placeholders = states.map(() => '?').join(',');
|
||||
const tx = this.db.transaction((): { job: Job; created: boolean } => {
|
||||
const existing = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM jobs WHERE repo = ? AND issue_number = ? AND status IN (${placeholders})
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT 1`,
|
||||
)
|
||||
.get(params.repo, params.issueNumber, ...states) as JobRow | undefined;
|
||||
if (existing) return { job: rowToJob(existing), created: false };
|
||||
return { job: this.insertJobSync(params), created: true };
|
||||
});
|
||||
return tx();
|
||||
}
|
||||
|
||||
async getJob(id: string, opts?: { viewer?: Express.User }): Promise<Job | null> {
|
||||
const viewerClause = opts?.viewer
|
||||
? buildVisibilityWhere(opts.viewer, 'j')
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
buildCoachMessages,
|
||||
normalizeCoachResult,
|
||||
runPromptCoach,
|
||||
COACH_LIMITS,
|
||||
type PromptCoachDeps,
|
||||
} from './prompt-coach.js';
|
||||
|
||||
function makeDeps(over: Partial<PromptCoachDeps> = {}): PromptCoachDeps {
|
||||
return {
|
||||
userFolderRoot: '/data/users',
|
||||
pieceCatalog: {
|
||||
getForUser: () => [
|
||||
{ name: 'chat', description: '汎用の会話・調査タスク' },
|
||||
{ name: 'research', description: 'Web を調べてレポートにまとめる' },
|
||||
],
|
||||
},
|
||||
skillCatalog: {
|
||||
getForUser: () => [
|
||||
{ name: 'pdf-fill', description: 'PDF フォームに記入する' },
|
||||
],
|
||||
},
|
||||
readMemory: () => '- [Foo](foo.md) — ユーザーは日本語で回答を好む',
|
||||
readAgents: () => 'AGENTS: 常に結論から書く',
|
||||
callLlm: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const REQ = { instruction: 'PDF を読んで要約して', piece: undefined, userId: 'u1' };
|
||||
|
||||
describe('buildCoachMessages', () => {
|
||||
it('全ソースが揃っている時はそれぞれをプロンプトに含める', () => {
|
||||
const { system, user, pieceCandidates } = buildCoachMessages(makeDeps(), REQ);
|
||||
expect(system).toContain('日本語');
|
||||
expect(user).toContain('PDF を読んで要約して');
|
||||
// context sources
|
||||
expect(user).toContain('ユーザーは日本語で回答を好む'); // memory
|
||||
expect(user).toContain('常に結論から書く'); // agents
|
||||
expect(user).toContain('pdf-fill'); // skills
|
||||
expect(user).toContain('research'); // pieces
|
||||
expect(pieceCandidates).toEqual(['chat', 'research']);
|
||||
});
|
||||
|
||||
it('各ソースが欠損(null/空)でも例外なく組み立てる', () => {
|
||||
const deps = makeDeps({
|
||||
readMemory: () => null,
|
||||
readAgents: () => null,
|
||||
skillCatalog: { getForUser: () => [] },
|
||||
pieceCatalog: { getForUser: () => [] },
|
||||
});
|
||||
const { user, pieceCandidates } = buildCoachMessages(deps, REQ);
|
||||
expect(user).toContain('PDF を読んで要約して');
|
||||
expect(pieceCandidates).toEqual([]);
|
||||
});
|
||||
|
||||
it('巨大なソースは上限で切り詰める', () => {
|
||||
const huge = 'あ'.repeat(COACH_LIMITS.memoryChars + 5000);
|
||||
const deps = makeDeps({ readMemory: () => huge });
|
||||
const { user } = buildCoachMessages(deps, REQ);
|
||||
// memory section should not carry the full oversized blob
|
||||
expect(user.length).toBeLessThan(huge.length);
|
||||
});
|
||||
|
||||
it('下書き本文も上限で切り詰める', () => {
|
||||
const huge = 'x'.repeat(COACH_LIMITS.instructionChars + 5000);
|
||||
const { user } = buildCoachMessages(makeDeps(), { ...REQ, instruction: huge });
|
||||
expect(user.length).toBeLessThan(huge.length + 2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCoachResult', () => {
|
||||
const candidates = ['chat', 'research'];
|
||||
|
||||
it('スコアを範囲内にクランプし、配列を既定値で埋める', () => {
|
||||
const r = normalizeCoachResult(
|
||||
{
|
||||
overall: 250,
|
||||
axes: [{ name: '明確さ', score: 99, comment: 'ok' }],
|
||||
rewrite: '改善案',
|
||||
predicted_piece: { name: 'research', reason: 'Web 調査だから' },
|
||||
maestro_tips: [{ feature: '添付', suggestion: 'PDF を添付できます' }],
|
||||
personalized: ['既にスキル所有'],
|
||||
},
|
||||
candidates,
|
||||
);
|
||||
expect(r.overall).toBe(100);
|
||||
expect(r.axes[0].score).toBe(10);
|
||||
expect(r.predicted_piece).toEqual({ name: 'research', reason: 'Web 調査だから' });
|
||||
expect(r.maestro_tips).toHaveLength(1);
|
||||
expect(r.personalized).toEqual(['既にスキル所有']);
|
||||
});
|
||||
|
||||
it('候補に無い predicted_piece は null に落とす', () => {
|
||||
const r = normalizeCoachResult(
|
||||
{ overall: 50, rewrite: 'x', predicted_piece: { name: 'does-not-exist', reason: 'r' } },
|
||||
candidates,
|
||||
);
|
||||
expect(r.predicted_piece).toBeNull();
|
||||
});
|
||||
|
||||
it('壊れた/欠損フィールドでも安全な既定値を返す', () => {
|
||||
const r = normalizeCoachResult({}, candidates);
|
||||
expect(r.overall).toBe(0);
|
||||
expect(r.axes).toEqual([]);
|
||||
expect(r.rewrite).toBe('');
|
||||
expect(r.predicted_piece).toBeNull();
|
||||
expect(r.maestro_tips).toEqual([]);
|
||||
expect(r.personalized).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPromptCoach', () => {
|
||||
it('callLlm の生出力を正規化して返す', async () => {
|
||||
const deps = makeDeps({
|
||||
callLlm: vi.fn().mockResolvedValue({
|
||||
overall: 80,
|
||||
axes: [{ name: '具体性', score: 7, comment: '良い' }],
|
||||
rewrite: 'もっと具体的に',
|
||||
predicted_piece: { name: 'chat', reason: '汎用' },
|
||||
maestro_tips: [],
|
||||
personalized: [],
|
||||
}),
|
||||
});
|
||||
const r = await runPromptCoach(deps, REQ);
|
||||
expect(r.overall).toBe(80);
|
||||
expect(r.predicted_piece?.name).toBe('chat');
|
||||
expect(deps.callLlm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('LLM エラーは伝播する', async () => {
|
||||
const deps = makeDeps({
|
||||
callLlm: vi.fn().mockRejectedValue(new Error('LLM down')),
|
||||
});
|
||||
await expect(runPromptCoach(deps, REQ)).rejects.toThrow('LLM down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Prompt coach — on-demand, context-aware evaluation of a draft task prompt
|
||||
* before it is submitted from the create dialog.
|
||||
*
|
||||
* Goal: teach the user to write better prompts. Given a draft instruction we
|
||||
* read the user's own context (memory index, AGENTS.md, owned skills, visible
|
||||
* pieces) and ask a cheap model to score the draft, rewrite it, predict the
|
||||
* piece, and surface MAESTRO features the user might not know about.
|
||||
*
|
||||
* This module is deliberately free of HTTP / LLM-client wiring: the LLM call
|
||||
* is injected (`deps.callLlm`) so the prompt assembly and result normalization
|
||||
* are unit-testable without a backend. The route handler in
|
||||
* bridge/local-tasks-api.ts builds the concrete `callLlm` over the cheap
|
||||
* `titleClient`.
|
||||
*/
|
||||
|
||||
export interface PromptCoachAxis {
|
||||
name: string;
|
||||
score: number; // 0-10
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface PromptCoachPiecePrediction {
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PromptCoachTip {
|
||||
feature: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
export interface PromptCoachResult {
|
||||
/** Overall draft quality, 0-100. */
|
||||
overall: number;
|
||||
/** Per-axis scores: 明確さ / 具体性 / コンテキスト / 成果物指定. */
|
||||
axes: PromptCoachAxis[];
|
||||
/** A rewritten, improved version of the draft (Japanese). */
|
||||
rewrite: string;
|
||||
/** Predicted piece, constrained to the user's visible catalog, or null. */
|
||||
predicted_piece: PromptCoachPiecePrediction | null;
|
||||
/** MAESTRO feature suggestions (attachments, schedules, SSH, knowledge…). */
|
||||
maestro_tips: PromptCoachTip[];
|
||||
/** Notes grounded in the user's memory / AGENTS.md / skills. */
|
||||
personalized: string[];
|
||||
}
|
||||
|
||||
export interface PromptCoachRequest {
|
||||
instruction: string;
|
||||
/** Optional piece the user pre-selected in the dialog ('auto' = unset). */
|
||||
piece?: string;
|
||||
userId: string;
|
||||
/** Aborts the underlying LLM call when the route times out. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Minimal shape of the catalogs the coach reads (name + description). */
|
||||
export interface CatalogLike {
|
||||
getForUser(userId: string): Array<{ name: string; description: string }>;
|
||||
}
|
||||
|
||||
export interface PromptCoachDeps {
|
||||
userFolderRoot: string;
|
||||
pieceCatalog: CatalogLike;
|
||||
skillCatalog: CatalogLike;
|
||||
/**
|
||||
* Performs the actual cheap-model call. Receives the assembled prompts and
|
||||
* returns the raw parsed tool arguments (the model is forced to call the
|
||||
* submit_evaluation tool). Throws on LLM error.
|
||||
*/
|
||||
callLlm: (args: {
|
||||
system: string;
|
||||
user: string;
|
||||
userId: string;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<Record<string, unknown>>;
|
||||
/** Reads MEMORY.md one-line index. Injectable for tests. */
|
||||
readMemory?: (rootDir: string, userId: string) => string | null;
|
||||
/** Reads the user's AGENTS.md. Injectable for tests. */
|
||||
readAgents?: (rootDir: string, userId: string) => string | null;
|
||||
}
|
||||
|
||||
/** Size caps so the bundle fits a cheap model's context window. */
|
||||
export const COACH_LIMITS = {
|
||||
instructionChars: 8000,
|
||||
memoryChars: 4000,
|
||||
agentsChars: 4000,
|
||||
skillDescChars: 120,
|
||||
maxSkills: 60,
|
||||
pieceDescChars: 140,
|
||||
maxPieces: 80,
|
||||
} as const;
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s;
|
||||
return `${s.slice(0, max)}\n…[truncated]`;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
'あなたは MAESTRO(自律エージェント実行基盤)のプロンプト改善コーチです。',
|
||||
'ユーザーがタスクを送信する前の下書きを評価し、より良い書き方を教えて、プロンプト力を育てるのが目的です。',
|
||||
'出力は必ず submit_evaluation ツールを 1 回だけ呼んで返してください。すべて日本語で書きます。',
|
||||
'',
|
||||
'評価の観点(axes)は次の 4 つに固定:',
|
||||
'- 明確さ: 何をしてほしいかが曖昧でないか',
|
||||
'- 具体性: 対象・条件・範囲が具体的か',
|
||||
'- コンテキスト: 判断に必要な前提・背景が与えられているか',
|
||||
'- 成果物指定: 出力フォーマット・粒度・宛先が指定されているか',
|
||||
'各 axis は 0-10、overall は 0-100 で採点します。',
|
||||
'',
|
||||
'rewrite には、ユーザーがそのまま使える改善済みの依頼文を入れます。',
|
||||
'predicted_piece は、与えられた piece 候補の中から最も適切なものを 1 つ選び、name は候補名と完全一致させます。該当が無ければ null。',
|
||||
'maestro_tips では、この下書きで活かせる MAESTRO 機能(ファイル添付 / 定期実行スケジュール / SSH 操作 / ナレッジ検索 / サブタスク並列実行 など)を提案します。当てはまらなければ空配列。',
|
||||
'personalized では、ユーザーの memory / AGENTS.md / 所有スキルを踏まえた指摘を入れます(例: 「該当スキルを既に所有しているので言及不要」「AGENTS.md の方針と矛盾」「memory にある前提は省略可能」)。根拠が無ければ空配列。',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Builds the system + user prompts and the list of candidate piece names.
|
||||
* Pure: depends only on the injected catalogs/readers, never on globals.
|
||||
*/
|
||||
export function buildCoachMessages(
|
||||
deps: PromptCoachDeps,
|
||||
req: PromptCoachRequest,
|
||||
): { system: string; user: string; pieceCandidates: string[] } {
|
||||
const readMemory = deps.readMemory ?? (() => null);
|
||||
const readAgents = deps.readAgents ?? (() => null);
|
||||
|
||||
const memory = readMemory(deps.userFolderRoot, req.userId);
|
||||
const agents = readAgents(deps.userFolderRoot, req.userId);
|
||||
const skills = deps.skillCatalog.getForUser(req.userId).slice(0, COACH_LIMITS.maxSkills);
|
||||
const pieces = deps.pieceCatalog.getForUser(req.userId).slice(0, COACH_LIMITS.maxPieces);
|
||||
const pieceCandidates = pieces.map((p) => p.name);
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
sections.push('## 評価対象の下書き');
|
||||
sections.push(truncate(req.instruction.trim(), COACH_LIMITS.instructionChars) || '(空)');
|
||||
|
||||
if (req.piece && req.piece !== 'auto') {
|
||||
sections.push('## ユーザーが選択中の piece');
|
||||
sections.push(req.piece);
|
||||
}
|
||||
|
||||
if (memory && memory.trim()) {
|
||||
sections.push('## ユーザーの memory(MEMORY.md 一覧)');
|
||||
sections.push(truncate(memory.trim(), COACH_LIMITS.memoryChars));
|
||||
}
|
||||
|
||||
if (agents && agents.trim()) {
|
||||
sections.push('## ユーザーの AGENTS.md(作業方針)');
|
||||
sections.push(truncate(agents.trim(), COACH_LIMITS.agentsChars));
|
||||
}
|
||||
|
||||
if (skills.length > 0) {
|
||||
sections.push('## ユーザーが利用できるスキル');
|
||||
sections.push(
|
||||
skills
|
||||
.map((s) => `- ${s.name}: ${truncate((s.description ?? '').trim(), COACH_LIMITS.skillDescChars)}`)
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
if (pieces.length > 0) {
|
||||
sections.push('## piece 候補(predicted_piece.name はこの中から選ぶ)');
|
||||
sections.push(
|
||||
pieces
|
||||
.map((p) => `- ${p.name}: ${truncate((p.description ?? '').trim(), COACH_LIMITS.pieceDescChars)}`)
|
||||
.join('\n'),
|
||||
);
|
||||
} else {
|
||||
sections.push('## piece 候補');
|
||||
sections.push('(候補なし。predicted_piece は null にする)');
|
||||
}
|
||||
|
||||
return { system: SYSTEM_PROMPT, user: sections.join('\n\n'), pieceCandidates };
|
||||
}
|
||||
|
||||
function clampNumber(v: unknown, min: number, max: number, fallback: number): number {
|
||||
const n = typeof v === 'number' && Number.isFinite(v) ? v : Number(v);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.round(n)));
|
||||
}
|
||||
|
||||
function asString(v: unknown): string {
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and clamps the raw LLM tool arguments into a PromptCoachResult.
|
||||
* Never throws on malformed input — fills safe defaults instead — so a quirky
|
||||
* model response degrades gracefully rather than 500-ing the route.
|
||||
*/
|
||||
export function normalizeCoachResult(
|
||||
raw: Record<string, unknown>,
|
||||
pieceCandidates: string[],
|
||||
): PromptCoachResult {
|
||||
const axes: PromptCoachAxis[] = Array.isArray(raw.axes)
|
||||
? raw.axes.slice(0, 4).map((a) => {
|
||||
const o = (a ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
name: asString(o.name),
|
||||
score: clampNumber(o.score, 0, 10, 0),
|
||||
comment: asString(o.comment),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
let predicted: PromptCoachPiecePrediction | null = null;
|
||||
const rawPiece = raw.predicted_piece as Record<string, unknown> | null | undefined;
|
||||
if (rawPiece && typeof rawPiece === 'object') {
|
||||
const name = asString(rawPiece.name);
|
||||
if (name && pieceCandidates.includes(name)) {
|
||||
predicted = { name, reason: asString(rawPiece.reason) };
|
||||
}
|
||||
}
|
||||
|
||||
const maestroTips: PromptCoachTip[] = Array.isArray(raw.maestro_tips)
|
||||
? raw.maestro_tips
|
||||
.map((t) => {
|
||||
const o = (t ?? {}) as Record<string, unknown>;
|
||||
return { feature: asString(o.feature), suggestion: asString(o.suggestion) };
|
||||
})
|
||||
.filter((t) => t.feature || t.suggestion)
|
||||
: [];
|
||||
|
||||
const personalized: string[] = Array.isArray(raw.personalized)
|
||||
? raw.personalized.map(asString).filter((s) => s.length > 0)
|
||||
: [];
|
||||
|
||||
return {
|
||||
overall: clampNumber(raw.overall, 0, 100, 0),
|
||||
axes,
|
||||
rewrite: asString(raw.rewrite),
|
||||
predicted_piece: predicted,
|
||||
maestro_tips: maestroTips,
|
||||
personalized,
|
||||
};
|
||||
}
|
||||
|
||||
/** Assembles the bundle, runs the injected LLM call, normalizes the result. */
|
||||
export async function runPromptCoach(
|
||||
deps: PromptCoachDeps,
|
||||
req: PromptCoachRequest,
|
||||
): Promise<PromptCoachResult> {
|
||||
const { system, user, pieceCandidates } = buildCoachMessages(deps, req);
|
||||
const raw = await deps.callLlm({ system, user, userId: req.userId, signal: req.signal });
|
||||
return normalizeCoachResult(raw ?? {}, pieceCandidates);
|
||||
}
|
||||
|
||||
/** OpenAI tools-format schema. The model is forced to call this. */
|
||||
export const PROMPT_COACH_TOOL_SCHEMA = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'submit_evaluation',
|
||||
description: 'Submit the evaluation of the draft task prompt.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['overall', 'axes', 'rewrite', 'predicted_piece', 'maestro_tips', 'personalized'],
|
||||
properties: {
|
||||
overall: { type: 'number', minimum: 0, maximum: 100 },
|
||||
axes: {
|
||||
type: 'array',
|
||||
maxItems: 4,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['name', 'score', 'comment'],
|
||||
properties: {
|
||||
name: { type: 'string', maxLength: 40 },
|
||||
score: { type: 'number', minimum: 0, maximum: 10 },
|
||||
comment: { type: 'string', maxLength: 400 },
|
||||
},
|
||||
},
|
||||
},
|
||||
rewrite: { type: 'string', maxLength: 4000 },
|
||||
predicted_piece: {
|
||||
type: ['object', 'null'],
|
||||
additionalProperties: false,
|
||||
required: ['name', 'reason'],
|
||||
properties: {
|
||||
name: { type: 'string', maxLength: 80 },
|
||||
reason: { type: 'string', maxLength: 400 },
|
||||
},
|
||||
},
|
||||
maestro_tips: {
|
||||
type: 'array',
|
||||
maxItems: 6,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['feature', 'suggestion'],
|
||||
properties: {
|
||||
feature: { type: 'string', maxLength: 60 },
|
||||
suggestion: { type: 'string', maxLength: 400 },
|
||||
},
|
||||
},
|
||||
},
|
||||
personalized: {
|
||||
type: 'array',
|
||||
maxItems: 8,
|
||||
items: { type: 'string', maxLength: 400 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -28,6 +28,8 @@ const DOCS_DIR = path.join(REPO_ROOT, 'docs', 'tools');
|
||||
// 関連ツールが同じ doc を参照できるようエイリアスを定義
|
||||
// キー・値ともに小文字
|
||||
const TOOL_DOC_ALIASES: Record<string, string> = {
|
||||
// updateuseragents.md にまとめる
|
||||
readuseragents: 'updateuseragents',
|
||||
// checklist.md にまとめる
|
||||
createchecklist: 'checklist',
|
||||
checkitem: 'checklist',
|
||||
|
||||
@@ -378,10 +378,12 @@ export async function getToolDefs(
|
||||
// - MissionUpdate: タスクの目標 / 進捗のピン止めメモを更新 (会話が長くなって
|
||||
// 最初の要件を見失わないため。常時上書き可能、未指定フィールドは保持)
|
||||
// - ListUserAssets / RunUserScript: ユーザーフォルダのスクリプト探索・実行
|
||||
// - ReadUserAgents / UpdateUserAgents: per-user AGENTS.md (常時指示書) の読み書き
|
||||
// (UpdateUserMemory が事実断片、こちらは振る舞い方針そのもの)
|
||||
// - Brainstorm: 着手前 or 行き詰まり時の多アプローチ比較 (issue #247)
|
||||
// - ReadAppDoc / ListAppDocs / GetMyOrchestratorState: Help アシスタント用 (#help piece)
|
||||
// ただし他の piece からも参照できるようにメタ扱い
|
||||
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserTemplate', 'RenderUserTemplate', 'WriteUserScript', 'WriteUserTemplate', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill'];
|
||||
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserAgents', 'UpdateUserAgents', 'ReadUserTemplate', 'RenderUserTemplate', 'WriteUserScript', 'WriteUserTemplate', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill'];
|
||||
const effectiveAllowed = [...allowedTools];
|
||||
for (const meta of META_TOOLS) {
|
||||
if (!effectiveAllowed.includes(meta) && meta in allDefs) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
rmSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
} from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
@@ -769,3 +771,120 @@ describe('RunUserScript: audit log hook', () => {
|
||||
expect(auditCalls.some(c => c.action === 'user_script_denied')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ReadUserAgents / UpdateUserAgents ─────────────────────────────────────────
|
||||
|
||||
describe('ReadUserAgents / UpdateUserAgents', () => {
|
||||
const agentsPath = () => join(userFolderRoot, TEST_USER, 'AGENTS.md');
|
||||
|
||||
it('ReadUserAgents returns a friendly note when AGENTS.md does not exist', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('ReadUserAgents', {}, ctx);
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(r?.output).toContain('does not exist yet');
|
||||
});
|
||||
|
||||
it('append creates AGENTS.md when empty', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'append', new_text: '# 方針\n常に日本語で答える' }, ctx);
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 方針\n常に日本語で答える');
|
||||
});
|
||||
|
||||
it('append adds a blank line between existing content and new text', async () => {
|
||||
writeFileSync(agentsPath(), '# 既存\nルール1');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'ルール2' }, ctx);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 既存\nルール1\n\nルール2');
|
||||
});
|
||||
|
||||
it('ReadUserAgents returns the full current content', async () => {
|
||||
writeFileSync(agentsPath(), 'line A\nline B');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('ReadUserAgents', {}, ctx);
|
||||
expect(r?.output).toBe('line A\nline B');
|
||||
});
|
||||
|
||||
it('replace swaps an exact unique match', async () => {
|
||||
writeFileSync(agentsPath(), '# 方針\n古い行\nおわり');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: '古い行', new_text: '新しい行' }, ctx);
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 方針\n新しい行\nおわり');
|
||||
});
|
||||
|
||||
it('replace does not interpret $-patterns in new_text', async () => {
|
||||
writeFileSync(agentsPath(), 'price: PLACEHOLDER yen');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'PLACEHOLDER', new_text: '$100 & $&' }, ctx);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('price: $100 & $& yen');
|
||||
});
|
||||
|
||||
it('replace errors when old_text is not found', async () => {
|
||||
writeFileSync(agentsPath(), 'something');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'missing', new_text: 'x' }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('not found');
|
||||
});
|
||||
|
||||
it('replace errors when old_text matches more than once', async () => {
|
||||
writeFileSync(agentsPath(), 'dup\ndup');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'dup', new_text: 'x' }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('matches 2 times');
|
||||
});
|
||||
|
||||
it('replace on an empty file is rejected with a hint to append', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'x', new_text: 'y' }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('append');
|
||||
});
|
||||
|
||||
it('snapshots the prior version to trash/agents-history before overwriting', async () => {
|
||||
writeFileSync(agentsPath(), 'original content');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'more' }, ctx);
|
||||
|
||||
const histDir = join(userFolderRoot, TEST_USER, 'trash', 'agents-history');
|
||||
const backups = readdirSync(histDir).filter((f) => f.startsWith('AGENTS.md.') && f.endsWith('.bak'));
|
||||
expect(backups.length).toBe(1);
|
||||
expect(readFileSync(join(histDir, backups[0]!), 'utf-8')).toBe('original content');
|
||||
});
|
||||
|
||||
it('rejects when result would exceed the 64KB cap', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const big = 'x'.repeat(64 * 1024 + 1);
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'append', new_text: big }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('exceeds');
|
||||
});
|
||||
|
||||
it('emits a user_agents_updated audit event', async () => {
|
||||
const auditCalls: Array<{ action: string; detail: object }> = [];
|
||||
setUserFolderToolDeps({
|
||||
sessRepo: null as never,
|
||||
masterKeyPath: '',
|
||||
userFolderRoot,
|
||||
auditLog: (action, detail) => auditCalls.push({ action, detail }),
|
||||
});
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'hello' }, ctx);
|
||||
expect(auditCalls.some((c) => c.action === 'user_agents_updated')).toBe(true);
|
||||
});
|
||||
|
||||
it('both tools require an authenticated user', async () => {
|
||||
const ctx = buildCtx({ userId: undefined });
|
||||
const r1 = await executeTool('ReadUserAgents', {}, ctx);
|
||||
const r2 = await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'x' }, ctx);
|
||||
expect(r1?.isError).toBe(true);
|
||||
expect(r2?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('registers both tools in TOOL_DEFS', () => {
|
||||
expect(TOOL_DEFS['ReadUserAgents']).toBeDefined();
|
||||
expect(TOOL_DEFS['UpdateUserAgents']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
userRoot,
|
||||
assertOwnerAccess,
|
||||
resolveUserSubdir,
|
||||
readUserAgentsMdRaw,
|
||||
writeUserAgentsMd,
|
||||
snapshotUserAgentsMd,
|
||||
} from '../../user-folder/paths.js';
|
||||
import {
|
||||
upsertMemoryEntry,
|
||||
@@ -124,6 +127,48 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
},
|
||||
},
|
||||
|
||||
ReadUserAgents: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'ReadUserAgents',
|
||||
description:
|
||||
'Returns the caller\'s full AGENTS.md (per-user standing instructions, auto-injected into every task). Read it before editing. Details via ReadToolDoc({ name: "UpdateUserAgents" }).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
UpdateUserAgents: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'UpdateUserAgents',
|
||||
description:
|
||||
'Edits the caller\'s AGENTS.md via "replace" (old_text→new_text, must match exactly once) or "append". Snapshots the prior version first. Details via ReadToolDoc({ name: "UpdateUserAgents" }).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['replace', 'append'],
|
||||
description: '"replace" swaps old_text for new_text; "append" adds new_text to the end (also creates the file if empty).',
|
||||
},
|
||||
old_text: {
|
||||
type: 'string',
|
||||
description: 'Exact existing text to replace. Required for "replace"; must appear exactly once (add surrounding context to disambiguate).',
|
||||
},
|
||||
new_text: {
|
||||
type: 'string',
|
||||
description: 'For "replace", the replacement text; for "append", the text to add. Required.',
|
||||
},
|
||||
},
|
||||
required: ['mode', 'new_text'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ListUserAssets: {
|
||||
type: 'function',
|
||||
function: {
|
||||
@@ -309,6 +354,111 @@ async function executeReadUserMemory(
|
||||
return { output, isError: false };
|
||||
}
|
||||
|
||||
// ── ReadUserAgents implementation ─────────────────────────────────────────────
|
||||
|
||||
async function executeReadUserAgents(
|
||||
_input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
if (!ctx.userId) {
|
||||
return { output: 'ReadUserAgents requires an authenticated user', isError: true };
|
||||
}
|
||||
const content = readUserAgentsMdRaw(getUserFolderRoot(), ctx.userId);
|
||||
if (content === null || content.length === 0) {
|
||||
return {
|
||||
output:
|
||||
'AGENTS.md does not exist yet (empty). Use UpdateUserAgents with mode="append" to create it.',
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
return { output: content, isError: false };
|
||||
}
|
||||
|
||||
// ── UpdateUserAgents implementation ───────────────────────────────────────────
|
||||
|
||||
async function executeUpdateUserAgents(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
if (!ctx.userId) {
|
||||
return { output: 'UpdateUserAgents requires an authenticated user', isError: true };
|
||||
}
|
||||
|
||||
const mode = input['mode'];
|
||||
if (mode !== 'replace' && mode !== 'append') {
|
||||
return { output: 'UpdateUserAgents: "mode" must be "replace" or "append"', isError: true };
|
||||
}
|
||||
|
||||
const newText = input['new_text'];
|
||||
if (typeof newText !== 'string') {
|
||||
return { output: 'UpdateUserAgents: "new_text" is required', isError: true };
|
||||
}
|
||||
|
||||
const folderRoot = getUserFolderRoot();
|
||||
const current = readUserAgentsMdRaw(folderRoot, ctx.userId) ?? '';
|
||||
|
||||
let next: string;
|
||||
if (mode === 'replace') {
|
||||
const oldText = input['old_text'];
|
||||
if (typeof oldText !== 'string' || oldText.length === 0) {
|
||||
return { output: 'UpdateUserAgents: "old_text" is required for mode "replace"', isError: true };
|
||||
}
|
||||
if (current.length === 0) {
|
||||
return {
|
||||
output: 'UpdateUserAgents: AGENTS.md is empty — use mode "append" to add content',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const count = current.split(oldText).length - 1;
|
||||
if (count === 0) {
|
||||
return {
|
||||
output:
|
||||
'UpdateUserAgents: "old_text" not found in AGENTS.md. Call ReadUserAgents to see the exact current content.',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (count > 1) {
|
||||
return {
|
||||
output: `UpdateUserAgents: "old_text" matches ${count} times — include more surrounding context to make it unique.`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
// Function replacement avoids `$&`/`$1` being interpreted in new_text.
|
||||
next = current.replace(oldText, () => newText);
|
||||
} else {
|
||||
// append
|
||||
if (newText.length === 0) {
|
||||
return { output: 'UpdateUserAgents: "new_text" must be non-empty for mode "append"', isError: true };
|
||||
}
|
||||
next = current.length === 0 ? newText : `${current.replace(/\n*$/, '')}\n\n${newText}`;
|
||||
}
|
||||
|
||||
// Snapshot the prior version before overwriting (best-effort; missing/empty → null).
|
||||
let backup: string | null = null;
|
||||
try {
|
||||
backup = snapshotUserAgentsMd(folderRoot, ctx.userId);
|
||||
} catch {
|
||||
/* snapshot is best-effort; proceed with the write */
|
||||
}
|
||||
|
||||
try {
|
||||
writeUserAgentsMd(folderRoot, ctx.userId, next);
|
||||
} catch (err) {
|
||||
return { output: `UpdateUserAgents: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
_deps?.auditLog?.(
|
||||
'user_agents_updated',
|
||||
{ userId: ctx.userId, mode, bytes: Buffer.byteLength(next, 'utf-8'), snapshotted: backup !== null },
|
||||
ctx.taskId ?? null,
|
||||
);
|
||||
|
||||
return {
|
||||
output: `AGENTS.md updated (mode=${mode}, now ${Buffer.byteLength(next, 'utf-8')} bytes)${backup ? ' — previous version snapshotted to trash/agents-history/' : ''}`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ListUserAssets implementation ─────────────────────────────────────────────
|
||||
|
||||
async function executeListUserAssets(
|
||||
@@ -626,6 +776,8 @@ export async function executeTool(
|
||||
): Promise<ToolResult | null> {
|
||||
if (name === 'UpdateUserMemory') return executeUpdateUserMemory(input, ctx);
|
||||
if (name === 'ReadUserMemory') return executeReadUserMemory(input, ctx);
|
||||
if (name === 'ReadUserAgents') return executeReadUserAgents(input, ctx);
|
||||
if (name === 'UpdateUserAgents') return executeUpdateUserAgents(input, ctx);
|
||||
if (name === 'ListUserAssets') return executeListUserAssets(input, ctx);
|
||||
if (name === 'RunUserScript') return executeRunUserScript(input, ctx);
|
||||
if (name === 'WriteUserScript') return executeWriteUserScript(input, ctx);
|
||||
|
||||
@@ -77,6 +77,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
|
||||
// user-folder.ts
|
||||
'ListUserAssets', 'ReadUserMemory',
|
||||
'RunUserScript', 'UpdateUserMemory', 'WriteUserScript',
|
||||
'ReadUserAgents', 'UpdateUserAgents',
|
||||
// brainstorm.ts
|
||||
'Brainstorm',
|
||||
// app-docs.ts
|
||||
|
||||
@@ -105,10 +105,43 @@ describe('ssh/path-policy validateRemotePath', () => {
|
||||
expect(validateRemotePath('\\\\srv\\share\\agent\\file', '\\\\srv\\share\\agent').ok).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses repeated backslashes in Windows path', () => {
|
||||
it('collapses repeated backslashes and emits forward-slash form', () => {
|
||||
const r = validateRemotePath('C:\\Users\\\\agent\\\\file', 'C:\\Users\\agent');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.normalized).toBe('C:\\Users\\agent\\file');
|
||||
// SFTP wire form: backslashes → forward slashes, drive path gets leading '/'.
|
||||
expect(r.normalized).toBe('/C:/Users/agent/file');
|
||||
});
|
||||
|
||||
it('normalizes Windows drive paths to /C:/... (leading slash) for SFTP', () => {
|
||||
expect(validateRemotePath('C:\\Users\\agent\\f', 'C:\\Users\\agent').normalized).toBe(
|
||||
'/C:/Users/agent/f',
|
||||
);
|
||||
// Already-canonical leading-slash form is idempotent.
|
||||
expect(validateRemotePath('/C:/Users/agent/f', '/C:/Users/agent').normalized).toBe(
|
||||
'/C:/Users/agent/f',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts forward-slash Windows drive prefix (natural Win OpenSSH config)', () => {
|
||||
// Regression: detectSeparator used to flag any drive path as backslash-style,
|
||||
// so a forward-slash prefix + forward-slash path was wrongly rejected.
|
||||
expect(validateRemotePath('C:/Users/agent', 'C:/Users/agent').ok).toBe(true);
|
||||
expect(validateRemotePath('C:/Users/agent/file.txt', 'C:/Users/agent').ok).toBe(true);
|
||||
expect(validateRemotePath('C:/Users/agent/file.txt', 'C:/Users/agent').normalized).toBe(
|
||||
'/C:/Users/agent/file.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts mixed separators between prefix and candidate', () => {
|
||||
// Backslash prefix + forward-slash path (and vice versa) must line up.
|
||||
expect(validateRemotePath('C:/Users/agent/file', 'C:\\Users\\agent').ok).toBe(true);
|
||||
expect(validateRemotePath('C:\\Users\\agent\\file', 'C:/Users/agent').ok).toBe(true);
|
||||
});
|
||||
|
||||
it('emits forward-slash UNC form', () => {
|
||||
const r = validateRemotePath('\\\\srv\\share\\agent\\file', '\\\\srv\\share\\agent');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.normalized).toBe('//srv/share/agent/file');
|
||||
});
|
||||
|
||||
it('rejects empty prefix candidate via outside_prefix when prefix is non-trivial', () => {
|
||||
|
||||
+38
-33
@@ -48,24 +48,42 @@ export interface LocalPathResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the primary path separator used in a prefix string.
|
||||
* Windows-style: drive letter (`C:\`), UNC (`\\server\share`), or backslashes
|
||||
* without forward slashes. POSIX-style: everything else.
|
||||
* Canonicalise a remote path to forward-slash form for comparison AND for the
|
||||
* wire.
|
||||
*
|
||||
* SFTP uses '/' as its separator on every platform — Windows OpenSSH's
|
||||
* sftp-server canonicalises drive paths to '/C:/Users/...' (leading slash,
|
||||
* forward slashes). Comparing in '/'-space lets a prefix or candidate written
|
||||
* with backslashes, forward slashes, or a mix all line up, and guarantees the
|
||||
* path we ultimately hand to `sftp.createWriteStream` / `sftp.stat` is in the
|
||||
* one form every server accepts.
|
||||
*
|
||||
* 'C:\\Users\\agent' → '/C:/Users/agent'
|
||||
* 'C:/Users/agent' → '/C:/Users/agent'
|
||||
* '/C:/Users/agent' → '/C:/Users/agent' (idempotent)
|
||||
* '\\\\srv\\share\\agent' → '//srv/share/agent' (UNC head preserved)
|
||||
* '/home/u' → '/home/u' (POSIX unchanged)
|
||||
*
|
||||
* Caller has already rejected `..` segments, so posix.normalize cannot escape.
|
||||
*/
|
||||
function detectSeparator(p: string): '/' | '\\' {
|
||||
if (/^[a-zA-Z]:[\\/]/.test(p)) return '\\';
|
||||
if (p.startsWith('\\\\')) return '\\';
|
||||
if (p.includes('\\') && !p.includes('/')) return '\\';
|
||||
return '/';
|
||||
function toForwardSlash(s: string): string {
|
||||
// Preserve a UNC double-leading separator ('\\\\server' / '//server') as '//'.
|
||||
const uncHead = /^[\\/]{2}/.test(s) ? '//' : '';
|
||||
const body = path.posix.normalize(s.slice(uncHead.length).replace(/\\/g, '/'));
|
||||
let out = uncHead + body;
|
||||
// A bare drive path ('C:/...') gets the leading slash Windows OpenSSH expects.
|
||||
if (/^[A-Za-z]:\//.test(out)) out = '/' + out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a candidate REMOTE path against the per-connection prefix.
|
||||
*
|
||||
* Pure string operations — no FS I/O (remote FS isn't ours to stat).
|
||||
* Supports both POSIX (`/`) and Windows (`\`) path styles. The prefix's
|
||||
* primary separator is used to compare; mixing styles between prefix and
|
||||
* candidate path will trip the segment-boundary check.
|
||||
* Separator-agnostic: prefix and candidate are canonicalised to forward-slash
|
||||
* form (toForwardSlash) before comparing, so '/', '\', or a mix all line up.
|
||||
* The `normalized` result is the '/'-delimited form handed to SFTP — Windows
|
||||
* drive paths come back as '/C:/Users/...', which Windows OpenSSH accepts.
|
||||
*
|
||||
* prefix = '/home/u'
|
||||
* '/home/u' → ok
|
||||
@@ -78,8 +96,9 @@ function detectSeparator(p: string): '/' | '\\' {
|
||||
* '' → empty
|
||||
* '/foo\x00bar' → has_nul
|
||||
*
|
||||
* prefix = 'C:\\Users\\agent'
|
||||
* 'C:\\Users\\agent\\file' → ok
|
||||
* prefix = 'C:\\Users\\agent' (or 'C:/Users/agent' — same result)
|
||||
* 'C:\\Users\\agent\\file' → ok (normalized '/C:/Users/agent/file')
|
||||
* 'C:/Users/agent/file' → ok (normalized '/C:/Users/agent/file')
|
||||
* 'C:\\Users\\agent2\\f' → outside_prefix
|
||||
* 'C:\\..\\Windows\\sys' → has_parent_ref
|
||||
*
|
||||
@@ -101,32 +120,18 @@ export function validateRemotePath(remotePath: string, prefix: string): RemotePa
|
||||
return { ok: false, reason: 'has_parent_ref' };
|
||||
}
|
||||
|
||||
const sep = detectSeparator(prefix);
|
||||
// POSIX paths normalize via path.posix (collapses '//' and '/./').
|
||||
// Windows paths get a lightweight collapse of repeated backslashes only;
|
||||
// posix.normalize would corrupt drive letters or UNC heads.
|
||||
const normalize = (s: string): string => {
|
||||
if (sep === '/') return path.posix.normalize(s);
|
||||
// Preserve leading '\\' (UNC) by capturing it before collapsing.
|
||||
const uncHead = s.startsWith('\\\\') ? '\\\\' : '';
|
||||
const body = s.slice(uncHead.length).replace(/\\{2,}/g, '\\');
|
||||
return uncHead + body;
|
||||
};
|
||||
|
||||
// Compare in forward-slash space (see toForwardSlash). The normalized result
|
||||
// is what gets handed to SFTP, so it is always '/'-delimited.
|
||||
const stripTrailingSep = (s: string): string =>
|
||||
s.length > 1 && (s.endsWith('/') || s.endsWith('\\')) ? s.slice(0, -1) : s;
|
||||
s.length > 1 && s.endsWith('/') ? s.slice(0, -1) : s;
|
||||
|
||||
const normalized = normalize(remotePath);
|
||||
const normalizedTrimmed = stripTrailingSep(normalized);
|
||||
const prefixTrimmed = stripTrailingSep(prefix);
|
||||
const normalizedTrimmed = stripTrailingSep(toForwardSlash(remotePath));
|
||||
const prefixTrimmed = stripTrailingSep(toForwardSlash(prefix));
|
||||
|
||||
if (normalizedTrimmed === prefixTrimmed) {
|
||||
return { ok: true, normalized: normalizedTrimmed };
|
||||
}
|
||||
const prefixWithSep =
|
||||
prefixTrimmed === '/' || prefixTrimmed === '\\'
|
||||
? prefixTrimmed
|
||||
: `${prefixTrimmed}${sep}`;
|
||||
const prefixWithSep = prefixTrimmed === '/' ? '/' : `${prefixTrimmed}/`;
|
||||
if (normalizedTrimmed.startsWith(prefixWithSep)) {
|
||||
return { ok: true, normalized: normalizedTrimmed };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, chmodSync, existsSync, readFileSync, writeFileSync, unlinkSync, statSync, openSync, readSync, closeSync, rmSync } from 'fs';
|
||||
import { mkdirSync, chmodSync, existsSync, readFileSync, writeFileSync, unlinkSync, statSync, openSync, readSync, closeSync, rmSync, readdirSync, copyFileSync } from 'fs';
|
||||
import { resolve, join, relative, isAbsolute } from 'path';
|
||||
|
||||
/** The shared no-auth / local-auth system owner. Its folder is never deleted. */
|
||||
@@ -152,3 +152,79 @@ export function readUserAgentsMd(rootDir: string, ownerId: string): string | nul
|
||||
while (safe > 0 && (buf[safe - 1]! & 0xc0) === 0x80) safe--;
|
||||
return buf.subarray(0, safe).toString('utf-8') + `\n\n[truncated: original was ${stat.size} bytes]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the FULL, untruncated AGENTS.md for editing.
|
||||
*
|
||||
* Unlike readUserAgentsMd (which caps at 64KB and appends a "[truncated]" note
|
||||
* for prompt injection), this returns the exact on-disk bytes so an editing
|
||||
* tool can match/replace against the real content. Returns null when the file
|
||||
* is missing or the ownerId is invalid.
|
||||
*/
|
||||
export function readUserAgentsMdRaw(rootDir: string, ownerId: string): string | null {
|
||||
let path: string;
|
||||
try {
|
||||
path = join(userRoot(rootDir, ownerId), 'AGENTS.md');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stat = statSync(path);
|
||||
if (!stat.isFile()) return null;
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const AGENTS_HISTORY_KEEP = 10;
|
||||
|
||||
/**
|
||||
* Snapshot the current AGENTS.md to trash/agents-history/ before an edit, so an
|
||||
* LLM mis-edit can be recovered. Keeps the most recent AGENTS_HISTORY_KEEP
|
||||
* backups and prunes older ones. No-op (returns null) when AGENTS.md is missing
|
||||
* or empty. Returns the backup path on success.
|
||||
*/
|
||||
export function snapshotUserAgentsMd(rootDir: string, ownerId: string): string | null {
|
||||
let src: string;
|
||||
try {
|
||||
src = join(userRoot(rootDir, ownerId), 'AGENTS.md');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(src);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!stat.isFile() || stat.size === 0) return null;
|
||||
|
||||
const histDir = join(userRoot(rootDir, ownerId), 'trash', 'agents-history');
|
||||
if (!existsSync(histDir)) {
|
||||
mkdirSync(histDir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(histDir, 0o700);
|
||||
}
|
||||
// ISO timestamp in the filename → lexical sort == chronological order.
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const dest = join(histDir, `AGENTS.md.${ts}.bak`);
|
||||
copyFileSync(src, dest);
|
||||
|
||||
// Prune oldest backups, keeping the most recent AGENTS_HISTORY_KEEP.
|
||||
try {
|
||||
const backups = readdirSync(histDir)
|
||||
.filter((f) => f.startsWith('AGENTS.md.') && f.endsWith('.bak'))
|
||||
.sort();
|
||||
for (let i = 0; i < backups.length - AGENTS_HISTORY_KEEP; i++) {
|
||||
try {
|
||||
unlinkSync(join(histDir, backups[i]!));
|
||||
} catch {
|
||||
/* best-effort prune */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best-effort prune */
|
||||
}
|
||||
|
||||
return dest;
|
||||
}
|
||||
|
||||
+48
-1
@@ -19,7 +19,10 @@ import { runMigrations } from './db/migrate.js';
|
||||
import { logger } from './logger.js';
|
||||
import { accessSync, existsSync, mkdirSync, constants } from 'fs';
|
||||
import { dirname, resolve, join } from 'path';
|
||||
import { OpenAICompatClient } from './llm/openai-compat.js';
|
||||
import { OpenAICompatClient, type Message, type ToolDef, type LLMEvent } from './llm/openai-compat.js';
|
||||
import { runPromptCoach, PROMPT_COACH_TOOL_SCHEMA } from './engine/prompt-coach.js';
|
||||
import { readUserAgentsMd } from './user-folder/paths.js';
|
||||
import { readMemoryIndex } from './user-folder/memory.js';
|
||||
import { setLlmUsageRecorder } from './llm/usage-recorder.js';
|
||||
import { llmRoutingKey } from './llm/routing-key.js';
|
||||
import { ConfigManager } from './config-manager.js';
|
||||
@@ -266,6 +269,49 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// On-demand prompt coach (create-dialog draft evaluation). Reuses the cheap
|
||||
// title client and forces the submit_evaluation tool, mirroring reflection.
|
||||
const evaluatePrompt = titleClient
|
||||
? (r: { instruction: string; piece?: string; userId: string; signal?: AbortSignal }) =>
|
||||
runPromptCoach(
|
||||
{
|
||||
userFolderRoot,
|
||||
pieceCatalog,
|
||||
skillCatalog,
|
||||
readMemory: readMemoryIndex,
|
||||
readAgents: readUserAgentsMd,
|
||||
callLlm: async ({ system, user, userId, signal }) => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
];
|
||||
let input: Record<string, unknown> | null = null;
|
||||
let errorMsg: string | null = null;
|
||||
for await (const event of titleClient!.chat(
|
||||
messages,
|
||||
[PROMPT_COACH_TOOL_SCHEMA as unknown as ToolDef],
|
||||
signal,
|
||||
{ userId },
|
||||
{
|
||||
temperature: 0.2,
|
||||
toolChoice: { type: 'function', function: { name: 'submit_evaluation' } },
|
||||
},
|
||||
) as AsyncGenerator<LLMEvent>) {
|
||||
if (event.type === 'tool_use' && event.name === 'submit_evaluation' && input === null) {
|
||||
input = event.input;
|
||||
} else if (event.type === 'error') {
|
||||
errorMsg = event.error;
|
||||
}
|
||||
}
|
||||
if (errorMsg !== null) throw new Error(`prompt-coach LLM ${errorMsg}`);
|
||||
if (input === null) throw new Error('prompt-coach LLM returned no submit_evaluation tool_call');
|
||||
return input;
|
||||
},
|
||||
},
|
||||
r,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// スケジューラ起動 (selectPiece 注入で 'auto' を実 piece に解決)
|
||||
// task_kind='script' をサポートするため、sessRepo / masterKeyPath / userFolderRoot も渡す。
|
||||
const sessRepoForScheduler = new BrowserSessionRepo(repo.getDb());
|
||||
@@ -292,6 +338,7 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
|
||||
configuredRepos: [],
|
||||
generateTitle,
|
||||
selectPiece,
|
||||
evaluatePrompt,
|
||||
configManager,
|
||||
piecesDir,
|
||||
customPiecesDir,
|
||||
|
||||
Reference in New Issue
Block a user