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

This commit is contained in:
oss-sync
2026-06-17 05:23:28 +00:00
parent 517142c61d
commit 1602d52510
42 changed files with 3387 additions and 64 deletions
+125 -1
View File
@@ -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);
});
});
+72 -7
View File
@@ -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);
+65
View File
@@ -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);
}
+428
View File
@@ -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']);
});
});
});
+552
View File
@@ -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' });
};
}
+56
View File
@@ -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);
});
});
+2
View File
@@ -55,6 +55,8 @@ const META_TOOLS = new Set<string>([
'RunUserScript',
'UpdateUserMemory',
'ReadUserMemory',
'ReadUserAgents',
'UpdateUserAgents',
'WriteUserScript',
'Brainstorm',
'ReadAppDoc',
+4
View File
@@ -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',
];
/**
+43
View File
@@ -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
View File
@@ -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')
+138
View File
@@ -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');
});
});
+307
View File
@@ -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('## ユーザーの memoryMEMORY.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;
+2
View File
@@ -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',
+3 -1
View File
@@ -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) {
+119
View File
@@ -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();
});
});
+152
View File
@@ -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);
+1
View File
@@ -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
+35 -2
View File
@@ -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
View File
@@ -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 };
}
+77 -1
View File
@@ -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
View File
@@ -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,