sync: update from private repo (2ef0fa6)
Some checks failed
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

View File

@ -76,18 +76,21 @@ RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/python-requireme
WORKDIR /app
# Shared, world-readable Playwright browser cache so the non-root `node` user can
# launch Chromium (npm ci's playwright postinstall downloads it here).
# launch Chromium. The browser binary is fetched explicitly below.
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
COPY package.json package-lock.json* ./
# build-essential compiles better-sqlite3's native addon (removed afterward to
# stay lean). `npm ci` also runs Playwright's postinstall → downloads Chromium
# into PLAYWRIGHT_BROWSERS_PATH. `playwright install-deps chromium` then adds the
# Chromium shared-library apt deps. chmod makes the browser readable by `node`.
# stay lean). `npx playwright install --with-deps chromium` downloads the
# Chromium binary INTO PLAYWRIGHT_BROWSERS_PATH and installs its shared-library
# apt deps in one step. NOTE: `npm ci` alone does NOT download the browser —
# Playwright dropped the npm-install postinstall browser download, so relying on
# it left /ms-playwright absent and the chmod below failing on a clean build
# (issue #528). chmod makes the browser readable by the non-root `node` user.
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& npm ci --omit=dev \
&& npx playwright install-deps chromium \
&& npx playwright install --with-deps chromium \
&& chmod -R go+rX /ms-playwright \
&& npm cache clean --force \
&& apt-get purge -y build-essential \
@ -99,9 +102,24 @@ COPY --from=builder /app/ui/dist ./ui/dist
COPY --from=builder /app/vendor ./vendor
COPY pieces ./pieces
COPY docs ./docs
# Browser setup wizard shares its pure LLM logic (probe / parse / worker build /
# isLlmConfigured) with the CLI wizard via this single .mjs. The server imports
# it at runtime as `../../scripts/setup-lib.mjs` (resolves to /app/scripts from
# /app/dist/bridge), so it MUST ship in the runtime image — without it the setup
# endpoints 500 on first `docker compose up` (Codex P2 #7). The .d.mts is
# build-time only and intentionally omitted.
COPY scripts/setup-lib.mjs ./scripts/setup-lib.mjs
# Ship a runnable default while still allowing a config bind-mount.
COPY config.yaml.example ./config.yaml
# Ship config.yaml.example as a REFERENCE, plus a MINIMAL unconfigured
# config.yaml. A fresh `docker compose up` (no ./config.yaml bind-mount, no
# OLLAMA_* env) must detect "no LLM configured" and show the browser setup
# wizard — baking the fully-worked example (which has an enabled localhost
# worker) would mark the install "configured", suppress the wizard, and leave
# the user pointed at a localhost Ollama that isn't running. Mounting
# ./config.yaml or setting OLLAMA_BASE_URL/OLLAMA_MODEL overrides this default
# and skips the wizard.
COPY config.yaml.example ./config.yaml.example
RUN printf 'config_version: 2\n' > ./config.yaml
# The app runs as the non-root `node` user and writes its state under ./data
# (db, users, skills, secrets) — relative to WORKDIR /app, i.e. /app/data — plus

View File

@ -97,13 +97,28 @@ API には Bash ツールが含まれるため、認証なしで LAN に晒す
## 5. Docker で起動
```bash
cp .env.example .env # OLLAMA_BASE_URL / OLLAMA_MODEL を設定
docker compose up -d
# http://localhost:9876
```
DB とワークスペースは named volume`maestro-data` / `maestro-workspaces`に永続化される。Compose は既定で `127.0.0.1:9876` のみに公開する。`config.yaml` をホストからマウントする場合は `docker-compose.yml` のコメントを参照。
### ブラウザ・セットアップウィザード(`config.yaml` を編集しない)
LLM 未設定のまま起動すると、アプリの代わりに**全画面のセットアップウィザード**が出る。`npm run setup` のブラウザ版で、次を順に設定する。
1. **LLM** — 接続タイプ・エンドポイントを入れて疎通テスト、モデルを選択。即時反映・再起動不要。
2. **サーバーポート**(任意)— 再起動で反映。
3. **サインイン**(任意)— メール+パスワード(初回 adminまたは Google/Gitea OAuth。再起動で反映。
ウィザードは admin アカウントを作成できるため、変更系の呼び出しには**ワンタイム・セットアップトークン**が必要。起動時にサーバーログへ出力されるので、次で読み取る。
```bash
docker compose logs | grep "setup token"
```
これをウィザード最初の入力欄に貼る。トークンは初回の無認証ウィンドウ中だけ有効で、LLM を設定した時点(または認証を有効化して再起動した時点)で失効する。非対話で設定したい場合は、`docker compose up` の前に `.env``cp .env.example .env`)へ `OLLAMA_BASE_URL` / `OLLAMA_MODEL` を設定しておけば、ウィザードは出ない。
## 6. 最初のタスク
1. UI を開き、新規タスクを作成(タイトル + 依頼内容を入力)。

View File

@ -97,13 +97,28 @@ first. The upgrade script detects this and offers to set it for you.
## 5. Launch with Docker
```bash
cp .env.example .env # set OLLAMA_BASE_URL / OLLAMA_MODEL
docker compose up -d
# http://localhost:9876
```
The DB and workspaces are persisted in named volumes (`maestro-data` / `maestro-workspaces`). By default Compose exposes only `127.0.0.1:9876`. If you want to mount `config.yaml` from the host, see the comments in `docker-compose.yml`.
### Browser setup wizard (no `config.yaml` editing)
When you start with no LLM configured, opening the UI shows a **full-screen setup wizard** instead of the app — the browser equivalent of `npm run setup`. It walks you through:
1. **Language model** — connection type, endpoint, a live connection test, then pick a model. Applied immediately, no restart.
2. **Server port** (optional) — takes effect after a restart.
3. **Sign-in** (optional) — email + password (first admin) or Google/Gitea OAuth. Takes effect after a restart.
Because the wizard can bootstrap an admin account, mutating setup calls require a **one-time setup token**. It is printed to the server logs at startup — read it with:
```bash
docker compose logs | grep "setup token"
```
Paste that token into the wizard's first field. The token only exists during the initial no-auth window and stops working once an LLM is configured (or once auth is enabled and the server restarts). If you'd rather configure non-interactively, set `OLLAMA_BASE_URL` / `OLLAMA_MODEL` in a `.env` file (`cp .env.example .env`) before `docker compose up` — the wizard then doesn't appear.
## 6. Your first task
1. Open the UI and create a new task (enter a title + the request body).

View File

@ -147,6 +147,16 @@ SshDownload({
- 親ディレクトリは呼び出し側で作成済にしておくこと。`Write` 相当の mkdir-p は行わない (e.g. `output/foo/bar.txt` を指定するなら、事前に `Bash({command: "mkdir -p output/foo"})` 等で作成)
- `remote_path` の prefix 配下チェック、サイズ上限 (`ssh.max_download_size_mb`)、SSRF チェックは Upload と同じ
## Windows (OpenSSH) 接続先のパス
Windows の OpenSSH SFTP サーバはパス区切りに **フォワードスラッシュ (`/`)** を使い、ドライブ付き絶対パスを `/C:/Users/...` (先頭スラッシュ + スラッシュ区切り) という正規形で扱う。
`remote_path` と接続の `remote_path_prefix` は区切り文字を問わず受け付ける。`C:\Users\agent``C:/Users/agent``/C:/Users/agent` のいずれで書いても内部でスラッシュ正規形に統一され、SFTP には常に `/C:/Users/agent/...` の形で渡る。混在 (prefix はバックスラッシュ、パスはスラッシュ等) も可。
- prefix 例: `C:\Users\agent` または `/C:/Users/agent` のどちらでも可
- 転送先パス例: `SshUpload({ remote_path: "C:\\Users\\agent\\output\\report.csv" })` → SFTP には `/C:/Users/agent/output/report.csv` で送信
- UNC 共有 (`\\server\share\...`) は `//server/share/...` に正規化される
## Host key TOFU フロー (LLM 側で完結しない)
接続を新規作成した直後は host key が観測されていない (`host_key_b64 IS NULL`)。最初の `/test` 呼び出し (または最初の Exec/Upload/Download) で鍵を観測すると、`host_key_first_observe` エラーが返り、`host_key_b64` / `host_key_fingerprint` / `host_key_pending_token` が DB に書き込まれる。

View File

@ -0,0 +1,54 @@
# ReadUserAgents / UpdateUserAgents
ユーザーごとの常時指示書 `AGENTS.md` を読み書きするツール。`AGENTS.md` は各タスクのシステムプロンプトに自動注入される「このユーザーが常に守ってほしいこと」を書いた個人ファイル。memory`UpdateUserMemory`)が事実の断片を貯めるのに対し、`AGENTS.md` は振る舞いの方針そのもの。
両ツールは META_TOOL常時利用可能。piece の `allowed_tools` に書かなくても使える。per-user 機能なので、認証済みユーザーのコンテキスト(`ctx.userId`)が必要。
## ReadUserAgents
引数なし。現在の `AGENTS.md` 全文を切り詰めずに返す。**編集前に必ず呼ぶこと** — `replace` は原文と完全一致する文字列が必要で、システムプロンプトへの注入版は 64KB で切り詰められている場合があるため。
ファイルが無い/空のときは、その旨と「`append` で新規作成できる」案内を返す(エラーではない)。
## UpdateUserAgents
`AGENTS.md` を 2 モードで編集する。書き込み前に旧版を `trash/agents-history/AGENTS.md.{timestamp}.bak` に自動退避(直近 10 件を保持)。事故時はそこから復元できる。
### パラメータ
| 名前 | 必須 | 説明 |
|---|---|---|
| `mode` | ○ | `"replace"` または `"append"` |
| `old_text` | replace時○ | 置換対象の既存文字列。**ちょうど 1 回だけ**一致する必要がある |
| `new_text` | ○ | replace の置換後テキストappend で末尾に足すテキスト |
### mode="replace"
`old_text``new_text` に置き換える。コアの `Edit` ツールと同じ「一意一致」セマンティクス。
- 0 回一致 → エラー(`ReadUserAgents` で原文を確認するよう促す)
- 2 回以上一致 → エラー(前後の文脈を含めて一意にするよう促す)
- ファイルが空 → エラー(`append` を案内)
`new_text` 内の `$&``$1` は特殊扱いされない(リテラルとして入る)。
### mode="append"
`new_text` を末尾に追記する。既存末尾の空行を整理し、空行 1 行を挟んで足す。ファイルが空なら `new_text` がそのまま新規ファイルになる。
### 制限・エラー
- 結果が 64KB を超えると `writeUserAgentsMd` が拒否(`AGENTS.md exceeds 65536 bytes`
- `ctx.userId` が無い(認証なし)→ エラー
- 監査ログ `user_agents_updated`mode / バイト数 / スナップショット有無)を記録
## 典型フロー
1. `ReadUserAgents` で現在の内容を取得
2. ユーザー依頼に応じて `UpdateUserAgents({ mode: "replace", old_text: "...", new_text: "..." })` または `mode: "append"`
3. 戻り値でバイト数とスナップショット退避を確認
## 関連
- `UpdateUserMemory` / `ReadUserMemory` — 事実の断片を貯める個人 memory`docs/tools` の該当 doc 参照)
- UI からの編集は Settings 経由HTTP `GET/PUT/DELETE /agents-md`

71
scripts/setup-lib.d.mts Normal file
View File

@ -0,0 +1,71 @@
// Type declarations for the dependency-free setup helpers in setup-lib.mjs.
// The browser setup wizard server (src/bridge/setup-api.ts) imports this module
// at runtime from BOTH the dev (ts-node) and dist layouts via the relative path
// `../../scripts/setup-lib.mjs`, which resolves to <root>/scripts in either
// case (Codex P2 #7). These types let TypeScript check that usage.
export type ConnectionType = 'direct' | 'aao_gateway';
export const ALL_ROLES: readonly ['auto', 'fast', 'quality', 'title', 'reflection'];
export const CONNECTION_TYPES: readonly ConnectionType[];
export function parseEndpoint(
input: unknown,
): { endpoint: string; base: string; error?: undefined } | { error: string; endpoint?: undefined; base?: undefined };
export interface SnakeWorkerEntry {
id: string;
connection_type: ConnectionType;
endpoint: string;
model: string;
roles: string[];
max_concurrency: number;
enabled: boolean;
vlm: boolean;
api_key?: string;
}
export interface CamelWorkerEntry {
id: string;
connectionType: ConnectionType;
endpoint: string;
model: string;
roles: string[];
maxConcurrency: number;
enabled: boolean;
vlm: boolean;
apiKey?: string;
}
export interface BuildWorkerArgs {
connectionType: ConnectionType;
endpoint: string;
model: string;
apiKey?: string;
}
export function buildWorkerEntry(args: BuildWorkerArgs): SnakeWorkerEntry;
export function buildLlmWorkerCamel(args: BuildWorkerArgs): CamelWorkerEntry;
export function isLlmConfigured(
workers: ReadonlyArray<{ enabled?: boolean; endpoint?: unknown; model?: unknown } | null | undefined> | unknown,
): boolean;
export function renderConfigYaml(worker: SnakeWorkerEntry): string;
export function renderDotenv(existingText: string | undefined, opts: { port: number | string }): string;
export function parseAnswersFromEnv(
env: Record<string, string | undefined>,
): { answers: Record<string, unknown>; error?: undefined } | { error: string; answers?: undefined };
export interface ProbeArgs {
endpoint: string;
base: string;
apiKey?: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
}
export function probeModels(
args: ProbeArgs,
): Promise<{ ok: boolean; models: string[]; error?: string }>;

View File

@ -38,6 +38,60 @@ export function buildWorkerEntry({ connectionType, endpoint, model, apiKey }) {
return entry;
}
// Build the single worker entry in **camelCase v2 shape** that
// ConfigManager.updateConfig expects (it snake-cases on save). The CLI writes
// raw YAML via buildWorkerEntry (snake_case); the browser wizard instead PUTs a
// JSON config object through updateConfig, which requires camelCase keys.
// Passing buildWorkerEntry's snake_case output to updateConfig double-converts
// and corrupts keys (Codex review P1 #1) — so the wizard MUST use this builder.
export function buildLlmWorkerCamel({ connectionType, endpoint, model, apiKey }) {
const entry = {
id: connectionType === 'aao_gateway' ? 'gateway' : 'local-llm',
connectionType,
endpoint,
model,
roles: [...ALL_ROLES],
maxConcurrency: 1,
enabled: true,
vlm: false,
};
if (connectionType === 'aao_gateway') entry.apiKey = apiKey;
return entry;
}
// Detect whether the runtime has a usable LLM connection. Operate on the
// RESOLVED worker list (provider.workers from ConfigManager.getConfig(), after
// loadConfig has applied OLLAMA_* env overrides). A fresh install's synthetic
// default worker carries an endpoint but an EMPTY model (config-normalize.ts
// EMPTY_MODEL=''), so requiring a non-empty model naturally excludes it.
// An env override (OLLAMA_MODEL) fills the model, so it counts as configured.
// (Codex review P1 #2.)
// Execution roles a worker must cover for normal task running. A worker that
// only serves e.g. 'title' or 'reflection' can't run user tasks, so the wizard
// must NOT consider the install configured (Codex P2 #6). Empty/missing roles
// mean "all roles" at runtime (src/worker.ts defaults to auto/fast/quality), so
// they count as covering execution.
const EXECUTION_ROLES = ['auto', 'fast', 'quality'];
function workerCoversExecution(w) {
const roles = w.roles;
if (!Array.isArray(roles) || roles.length === 0) return true;
return roles.some((r) => EXECUTION_ROLES.includes(r));
}
export function isLlmConfigured(workers) {
const list = Array.isArray(workers) ? workers : [];
return list.some(
(w) =>
w &&
w.enabled !== false &&
typeof w.endpoint === 'string' &&
w.endpoint.trim() !== '' &&
typeof w.model === 'string' &&
w.model.trim() !== '' &&
workerCoversExecution(w),
);
}
// Quote a scalar when a YAML plain scalar would be ambiguous or invalid.
function yamlScalar(v) {
if (typeof v === 'boolean' || typeof v === 'number') return String(v);

View File

@ -1,7 +1,68 @@
import { describe, it, expect } from 'vitest';
import { parseEndpoint, buildWorkerEntry, ALL_ROLES, renderConfigYaml, renderDotenv, parseAnswersFromEnv, probeModels } from './setup-lib.mjs';
import { parseEndpoint, buildWorkerEntry, buildLlmWorkerCamel, isLlmConfigured, ALL_ROLES, renderConfigYaml, renderDotenv, parseAnswersFromEnv, probeModels } from './setup-lib.mjs';
import { parse as parseYaml } from 'yaml';
describe('buildLlmWorkerCamel', () => {
it('returns camelCase v2 keys for a direct worker (no apiKey)', () => {
const w = buildLlmWorkerCamel({ connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'llama3' });
expect(w).toEqual({
id: 'local-llm',
connectionType: 'direct',
endpoint: 'http://localhost:11434/v1',
model: 'llama3',
roles: [...ALL_ROLES],
maxConcurrency: 1,
enabled: true,
vlm: false,
});
// Must NOT carry snake_case keys that would double-convert in updateConfig (P1 #1).
expect(w).not.toHaveProperty('connection_type');
expect(w).not.toHaveProperty('max_concurrency');
});
it('adds apiKey (camelCase) for aao_gateway', () => {
const w = buildLlmWorkerCamel({ connectionType: 'aao_gateway', endpoint: 'http://gw:9876/v1', model: 'm', apiKey: 'sk-aao-x' });
expect(w.id).toBe('gateway');
expect(w.apiKey).toBe('sk-aao-x');
expect(w).not.toHaveProperty('api_key');
});
});
describe('isLlmConfigured', () => {
it('false for empty / non-array', () => {
expect(isLlmConfigured([])).toBe(false);
expect(isLlmConfigured(undefined)).toBe(false);
});
it('false for the synthetic default (endpoint set, empty model)', () => {
expect(isLlmConfigured([{ id: 'default', endpoint: 'http://localhost:11434/v1', model: '', enabled: true }])).toBe(false);
});
it('false when the only usable worker is disabled', () => {
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', enabled: false }])).toBe(false);
});
it('true when a usable worker has endpoint + model', () => {
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm' }])).toBe(true);
});
it('true if any one worker is usable (mixed)', () => {
expect(isLlmConfigured([
{ endpoint: 'http://a/v1', model: '', enabled: true },
{ endpoint: 'http://b/v1', model: 'good', enabled: true },
])).toBe(true);
});
it('treats whitespace-only model/endpoint as empty', () => {
expect(isLlmConfigured([{ endpoint: ' ', model: 'm' }])).toBe(false);
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: ' ' }])).toBe(false);
});
it('requires execution-role coverage — a title/reflection-only worker does not count', () => {
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', roles: ['title'] }])).toBe(false);
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', roles: ['title', 'reflection'] }])).toBe(false);
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', roles: ['auto'] }])).toBe(true);
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', roles: ['quality', 'title'] }])).toBe(true);
});
it('treats empty/missing roles as covering execution (runtime default auto/fast/quality)', () => {
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', roles: [] }])).toBe(true);
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm' }])).toBe(true);
});
});
describe('parseEndpoint', () => {
it('keeps a full http URL and derives the /v1-stripped base', () => {
expect(parseEndpoint('http://localhost:11434/v1')).toEqual({

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: 'a@x.com', 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: 'a@x.com', 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);
});
});

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,
});
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);

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);
}

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: 'admin@x.io', 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: 'a@b.c' } } }); // 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: 'boss@x.io' },
},
});
expect(res.status).toBe(200);
expect(res.body.restartRequired).toBe(true);
expect(res.body.adminEmail).toBe('boss@x.io');
expect(JSON.stringify(res.body)).not.toContain('sec'); // client secret not echoed
const auth = cm.getConfig().auth;
expect(auth?.adminEmails).toContain('boss@x.io');
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: 'admin@x.io', password: 'supersecret1' } },
});
expect(res.status).toBe(200);
expect(res.body.restartRequired).toBe(true);
expect(res.body.adminEmail).toBe('admin@x.io');
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('admin@x.io');
});
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: 'a@b.c', 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
src/bridge/setup-api.ts Normal file
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' });
};
}

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);
});
});

View File

@ -55,6 +55,8 @@ const META_TOOLS = new Set<string>([
'RunUserScript',
'UpdateUserMemory',
'ReadUserMemory',
'ReadUserAgents',
'UpdateUserAgents',
'WriteUserScript',
'Brainstorm',
'ReadAppDoc',

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',
];
/**

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(); }
});
});

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')

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
src/engine/prompt-coach.ts Normal file
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;

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',

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) {

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();
});
});

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);

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

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', () => {

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 };
}

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;
}

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,

View File

@ -1,6 +1,8 @@
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSetupState } from './hooks/useSetupState';
import { SetupWizard } from './components/setup/SetupWizard';
import { LocalTask, type Visibility } from './api';
import { useUrlState } from './hooks/useUrlState';
import { useToast } from './hooks/useToast';
@ -119,6 +121,51 @@ function AuthenticatedApp() {
const authEnabled = auth.mode !== 'disabled';
const user = auth.mode === 'authenticated' ? auth.user : null;
return <SetupGate isAdmin={isAdmin} authEnabled={authEnabled} user={user} />;
}
// First-run gate: when the runtime has no usable LLM, show the full-screen
// setup wizard instead of the app. Fail-open — if the status check errors, fall
// through to the app rather than blocking it.
function SetupGate({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnabled: boolean; user: AuthUser | null }) {
const { data: setup, isLoading } = useSetupState();
const queryClient = useQueryClient();
if (isLoading) {
return (
<div className="h-dvh flex items-center justify-center bg-slate-50">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (setup?.needsSetup) {
const canConfigure = !setup.authActive || isAdmin;
if (canConfigure) {
return (
<SetupWizard
status={setup}
onComplete={() => {
queryClient.invalidateQueries({ queryKey: ['setup'] });
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['config'] });
queryClient.invalidateQueries({ queryKey: ['workers'] });
}}
/>
);
}
return (
<div className="h-dvh flex items-center justify-center bg-slate-50 px-4">
<div className="max-w-md text-center">
<h1 className="text-lg font-semibold text-slate-800 mb-2">Setup needed</h1>
<p className="text-sm text-slate-600">
This server has no language model configured yet. Ask an administrator to finish setup from Settings.
</p>
</div>
</div>
);
}
return <AppInner isAdmin={isAdmin} authEnabled={authEnabled} user={user} />;
}

View File

@ -231,6 +231,35 @@ export async function createLocalTask(input: CreateLocalTaskInput): Promise<{ ta
return data;
}
export interface PromptCoachAxis {
name: string;
score: number;
comment: string;
}
export interface PromptCoachResult {
overall: number;
axes: PromptCoachAxis[];
rewrite: string;
predicted_piece: { name: string; reason: string } | null;
maestro_tips: Array<{ feature: string; suggestion: string }>;
personalized: string[];
}
/**
* On-demand prompt coach: evaluates a draft task prompt before it is submitted.
* Stateless nothing is persisted. Returns 503 when the coach is unconfigured.
*/
export async function evaluatePrompt(input: { instruction: string; piece?: string }): Promise<PromptCoachResult> {
const res = await fetch(`${BASE}/local/tasks/evaluate-prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to evaluate prompt');
return data as PromptCoachResult;
}
export async function fetchLocalTask(taskId: number): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`);
const data = await res.json();

View File

@ -6,6 +6,7 @@ import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
import { ChatPetOverlay } from '../pets/ChatPetOverlay';
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
import { SubtaskInlineCard } from './SubtaskInlineCard';
import RotatingTips from './RotatingTips';
import { useJobStream } from '../../hooks/useJobStream';
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
@ -192,6 +193,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
const inputLocked = jobStatus === 'dispatching';
// A job is already accepted but not yet running. The task is NOT idle: a new
// message must fold into this pending job (server reuses it, no duplicate),
// so the composer presents it as an addition rather than a fresh request.
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
const hasActiveJob = isBusy || isPending;
// Release the submit lock once the agent is visibly responding: the new user
// comment is reflected in the list AND the job has been picked up by a worker
@ -201,10 +207,10 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
if (!submitting) return;
const baseline = submitBaselineRef.current;
if (baseline === null) return;
if (comments.length > baseline && isBusy) {
if (comments.length > baseline && hasActiveJob) {
releaseSubmitting();
}
}, [submitting, comments.length, isBusy]);
}, [submitting, comments.length, hasActiveJob]);
// Clear the safety-net timeout on unmount.
useEffect(() => {
@ -383,6 +389,9 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
)}
</div>
)}
{/* General rotating tips to make the wait useful (running only). */}
{isBusy && <RotatingTips />}
</div>
</div>
@ -406,17 +415,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
{/* Composer */}
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-3" style={{ paddingBottom: 'calc(12px + env(safe-area-inset-bottom, 0px))' }}>
{isBusy && (
{hasActiveJob && (
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
canInterject
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 text-amber-700 dark:text-amber-300'
: isPending
? 'bg-surface-2 border border-hairline text-slate-600 dark:text-slate-300'
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
}`}>
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<span>{canInterject ? t('pane.interjectHint') : t('pane.agentRunningWait')}</span>
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
</div>
)}
{sendError && !isBusy && (
@ -468,7 +479,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
onPaste={e => void handlePaste(e)}
rows={2}
disabled={inputLocked}
placeholder={inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : t('pane.placeholder.default')}
placeholder={inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
className="flex-1 resize-y border border-hairline rounded-md px-2.5 py-2 text-sm text-slate-900 outline-none focus:border-accent focus:ring-2 focus:ring-accent-ring min-h-[56px] disabled:bg-surface disabled:text-slate-400 disabled:cursor-not-allowed transition-shadow"
/>
{isBusy && onCancel ? (
@ -498,6 +509,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
{cancelling ? t('pane.stopping') : t('pane.stop')}
</button>
</div>
) : isPending ? (
<button
disabled={submitting || (!draft.trim() && attachments.length === 0)}
onClick={handleSubmit}
title={t('pane.addToQueuedHint')}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 10 20 15 15 20" />
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
</svg>
{t('pane.addToQueued')}
</button>
) : (
<button
disabled={submitting || inputLocked || (!draft.trim() && attachments.length === 0)}

View File

@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import ja from '../../i18n/locales/ja/chat.json';
import en from '../../i18n/locales/en/chat.json';
// Pure JSON parity check for the rotating-tips strings consumed by
// RotatingTips.tsx. The component itself is DOM + timer based and isn't
// exercised here; this guards the data contract (chat.tips.items) instead.
describe('chat tips (RotatingTips data)', () => {
it('ja and en both define a non-empty tips.items array', () => {
expect(Array.isArray(ja.tips?.items)).toBe(true);
expect(Array.isArray(en.tips?.items)).toBe(true);
expect(ja.tips.items.length).toBeGreaterThan(0);
expect(en.tips.items.length).toBeGreaterThan(0);
});
it('ja and en have the same number of tips (no locale drift)', () => {
expect(ja.tips.items.length).toBe(en.tips.items.length);
});
it('every tip is a non-empty string', () => {
for (const list of [ja.tips.items, en.tips.items]) {
for (const tip of list) {
expect(typeof tip).toBe('string');
expect(tip.trim().length).toBeGreaterThan(0);
}
}
});
it('defines a tips.label in both locales', () => {
expect(typeof ja.tips.label).toBe('string');
expect(typeof en.tips.label).toBe('string');
});
});

View File

@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
/**
* RotatingTips a subtle, rotating general "did you know" line shown while the
* agent is running, to make the wait useful. Tips are static, curated, and
* localized (chat.tips.items). Mount this only when the job is busy; it stops
* rotating automatically on unmount.
*
* Personalized, prompt-specific coaching is a separate feature (the on-demand
* prompt evaluator in the create dialog) these tips are general only.
*/
const ROTATE_MS = 7000;
const FADE_MS = 300;
export default function RotatingTips() {
const { t } = useTranslation('chat');
const items = t('tips.items', { returnObjects: true }) as unknown;
const tips = Array.isArray(items) ? (items as string[]) : [];
// Random start so the same tip isn't always first; guarded for empty list.
const [idx, setIdx] = useState(() => (tips.length ? Math.floor(Math.random() * tips.length) : 0));
const [visible, setVisible] = useState(true);
useEffect(() => {
if (tips.length <= 1) return;
const pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();
const rotate = setInterval(() => {
setVisible(false);
const swap = setTimeout(() => {
setIdx((i) => (i + 1) % tips.length);
setVisible(true);
pendingTimeouts.delete(swap);
}, FADE_MS);
pendingTimeouts.add(swap);
}, ROTATE_MS);
return () => {
clearInterval(rotate);
pendingTimeouts.forEach(clearTimeout);
};
}, [tips.length]);
if (tips.length === 0) return null;
return (
<div className="flex justify-start" aria-live="polite">
<div className="inline-flex items-start gap-2 px-2.5 py-1 max-w-[80%] text-2xs text-slate-500 dark:text-slate-400">
<span className="flex-shrink-0 opacity-70" aria-hidden="true">💡</span>
<span className="font-medium flex-shrink-0 text-slate-400 dark:text-slate-500">
{t('tips.label')}
</span>
<span
className={`transition-opacity ease-in-out ${visible ? 'opacity-100' : 'opacity-0'}`}
style={{ transitionDuration: `${FADE_MS}ms` }}
>
{tips[idx]}
</span>
</div>
</div>
);
}

View File

@ -4,6 +4,7 @@ import * as Dialog from '@radix-ui/react-dialog';
import { useQuery } from '@tanstack/react-query';
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles } from '../../api';
import { AttachmentDropzone } from './AttachmentDropzone';
import { PromptCoachPanel } from './PromptCoachPanel';
import { ScheduleFields } from './ScheduleFields';
import { usePieceList } from '../../hooks/usePieces';
import { resolvePieceOptions } from '../../lib/splitPieces';
@ -196,6 +197,13 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
{/* Attachments */}
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
{/* Prompt coach (on-demand draft evaluation) */}
<PromptCoachPanel
body={form.body}
piece={initialPiece ?? form.piece}
onApplyRewrite={(text) => setForm(prev => ({ ...prev, body: text }))}
/>
{/* MCP warnings (always visible when applicable) */}
{missingMcp.length > 0 && (
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/15 border border-yellow-300 dark:border-yellow-500/30 rounded text-xs text-yellow-900 dark:text-yellow-300 space-y-2">

View File

@ -0,0 +1,142 @@
import { useEffect } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { evaluatePrompt, type PromptCoachResult } from '../../api';
interface PromptCoachPanelProps {
/** Current draft body from the dialog's textarea. */
body: string;
/** Selected piece ('auto' = unset). */
piece?: string;
/** Inject the rewrite suggestion back into the textarea. */
onApplyRewrite: (text: string) => void;
}
function scoreColor(score: number, max: number): string {
const ratio = max > 0 ? score / max : 0;
if (ratio >= 0.75) return 'text-emerald-600 dark:text-emerald-400';
if (ratio >= 0.4) return 'text-amber-600 dark:text-amber-400';
return 'text-rose-600 dark:text-rose-400';
}
export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPanelProps) {
const { t } = useTranslation('create');
const mutation = useMutation<PromptCoachResult, Error, void>({
mutationFn: () => evaluatePrompt({ instruction: body.trim(), piece }),
});
// Discard a stale evaluation once the draft (body or piece) changes, so the
// panel never shows a score/rewrite that no longer matches the textarea —
// and "Apply to request" can't overwrite the draft with an outdated rewrite.
const { reset } = mutation;
useEffect(() => {
reset();
}, [body, piece, reset]);
const result = mutation.data;
const disabled = !body.trim() || mutation.isPending;
return (
<div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={disabled}
onClick={() => mutation.mutate()}
className="px-3 py-1.5 border border-accent/40 text-accent rounded-xl text-xs font-bold hover:bg-accent/5 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
>
{mutation.isPending && (
<span className="inline-block w-3 h-3 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
)}
{mutation.isPending ? t('coach.evaluating') : t('coach.evaluate')}
</button>
<span className="text-2xs text-slate-400">{t('coach.hint')}</span>
</div>
{mutation.isError && (
<div className="mt-2 text-xs text-rose-600">{t('coach.failed')}</div>
)}
{result && (
<div className="mt-3 space-y-3 border border-slate-200 dark:border-slate-700 rounded-xl p-4 bg-slate-50/60 dark:bg-slate-800/40">
{/* Overall score */}
<div className="flex items-baseline gap-2">
<span className="text-xs font-bold text-slate-500">{t('coach.overall')}</span>
<span className={`text-2xl font-extrabold ${scoreColor(result.overall, 100)}`}>{result.overall}</span>
<span className="text-xs text-slate-400">/ 100</span>
</div>
{/* Axes */}
{result.axes.length > 0 && (
<div className="space-y-1.5">
{result.axes.map((ax, i) => (
<div key={i} className="text-xs">
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-700 dark:text-slate-200">{ax.name}</span>
<span className={`font-bold ${scoreColor(ax.score, 10)}`}>{ax.score}/10</span>
</div>
{ax.comment && <div className="text-slate-500 dark:text-slate-400 mt-0.5">{ax.comment}</div>}
</div>
))}
</div>
)}
{/* Rewrite suggestion */}
{result.rewrite && (
<div>
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-bold text-slate-500">{t('coach.rewrite')}</span>
<button
type="button"
onClick={() => onApplyRewrite(result.rewrite)}
className="px-2 py-0.5 rounded bg-accent text-accent-fg text-2xs font-bold hover:bg-accent-deep"
>
{t('coach.applyRewrite')}
</button>
</div>
<div className="text-xs text-slate-700 dark:text-slate-200 whitespace-pre-wrap bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-slate-700 rounded-lg p-2.5 leading-relaxed">
{result.rewrite}
</div>
</div>
)}
{/* Predicted piece */}
{result.predicted_piece && (
<div className="text-xs">
<span className="font-bold text-slate-500">{t('coach.predictedPiece')}</span>{' '}
<span className="font-semibold text-accent">{result.predicted_piece.name}</span>
{result.predicted_piece.reason && (
<span className="text-slate-500 dark:text-slate-400"> {result.predicted_piece.reason}</span>
)}
</div>
)}
{/* MAESTRO tips */}
{result.maestro_tips.length > 0 && (
<div>
<div className="text-xs font-bold text-slate-500 mb-1">{t('coach.maestroTips')}</div>
<ul className="space-y-1">
{result.maestro_tips.map((tip, i) => (
<li key={i} className="text-xs text-slate-600 dark:text-slate-300">
<span className="font-semibold">{tip.feature}</span>
{tip.suggestion && <span className="text-slate-500 dark:text-slate-400"> {tip.suggestion}</span>}
</li>
))}
</ul>
</div>
)}
{/* Personalized notes */}
{result.personalized.length > 0 && (
<div>
<div className="text-xs font-bold text-slate-500 mb-1">{t('coach.personalized')}</div>
<ul className="list-disc list-inside space-y-0.5">
{result.personalized.map((note, i) => (
<li key={i} className="text-xs text-slate-600 dark:text-slate-300">{note}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,313 @@
import { useState } from 'react';
import type { SetupStatus } from '../../hooks/useSetupState';
// Full-screen first-run onboarding for a fresh, no-auth `docker compose up`.
// Configures the LLM connection (required) plus an optional server port and
// auth bootstrap, then applies everything in ONE call to /api/setup/apply.
// See docs/superpowers/specs/2026-06-16-browser-setup-wizard-design.md.
type ConnectionType = 'direct' | 'aao_gateway';
type AuthMode = 'none' | 'local' | 'oauth';
type OAuthProvider = 'google' | 'gitea';
interface ApplyResult {
ok: boolean;
restartRequired: boolean;
adminEmail?: string;
}
const PRIMARY_BTN =
'px-4 py-2 text-sm font-semibold bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50 transition-colors';
const SECONDARY_BTN =
'px-4 py-2 text-sm text-accent border border-accent rounded-lg hover:bg-accent-soft disabled:opacity-50 transition-colors';
const INPUT =
'w-full px-3 py-2 text-sm border border-slate-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-accent/40';
const LABEL = 'block text-sm font-medium text-slate-700 mb-1';
function restartCommand(hint: SetupStatus['deployHint']): string {
return hint === 'docker' ? 'docker compose restart' : 'scripts/server.sh restart';
}
export function SetupWizard({ status, onComplete }: { status: SetupStatus; onComplete: () => void }) {
const tokenRequired = status.tokenRequired;
const [token, setToken] = useState('');
// Step 1 — LLM (required)
const [connectionType, setConnectionType] = useState<ConnectionType>('direct');
const [endpoint, setEndpoint] = useState('http://localhost:11434/v1');
const [apiKey, setApiKey] = useState('');
const [model, setModel] = useState('');
const [models, setModels] = useState<string[]>([]);
const [probing, setProbing] = useState(false);
const [probeError, setProbeError] = useState<string | null>(null);
// Step 2 — port (optional)
const [port, setPort] = useState('');
// Step 3 — auth (optional)
const [authMode, setAuthMode] = useState<AuthMode>('none');
const [localEmail, setLocalEmail] = useState('');
const [localPassword, setLocalPassword] = useState('');
const [oauthProvider, setOauthProvider] = useState<OAuthProvider>('gitea');
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [callbackUrl, setCallbackUrl] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [oauthAdminEmail, setOauthAdminEmail] = useState('');
const [applying, setApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [result, setResult] = useState<ApplyResult | null>(null);
function headers(): Record<string, string> {
const h: Record<string, string> = { 'content-type': 'application/json' };
if (tokenRequired && token.trim()) h['x-setup-token'] = token.trim();
return h;
}
async function runProbe() {
setProbing(true);
setProbeError(null);
setModels([]);
try {
const res = await fetch('/api/setup/probe', {
method: 'POST',
headers: headers(),
body: JSON.stringify({ connectionType, endpoint, apiKey: apiKey || undefined }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setProbeError(data.error || `connection failed (HTTP ${res.status})`);
return;
}
const list: string[] = Array.isArray(data.models) ? data.models : [];
setModels(list);
if (list.length && !model) setModel(list[0]);
if (!list.length) setProbeError('connected, but no models were returned — enter a model name manually');
} catch (e) {
setProbeError(String(e));
} finally {
setProbing(false);
}
}
function authBlock(): unknown | undefined {
if (authMode === 'local') {
return { mode: 'local', local: { email: localEmail.trim(), password: localPassword } };
}
if (authMode === 'oauth') {
return {
mode: 'oauth',
oauth: {
provider: oauthProvider,
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
callbackUrl: callbackUrl.trim(),
adminEmail: oauthAdminEmail.trim(),
...(oauthProvider === 'gitea' ? { baseUrl: baseUrl.trim() } : {}),
},
};
}
return undefined;
}
const canApply =
(!tokenRequired || token.trim().length > 0) &&
endpoint.trim().length > 0 &&
model.trim().length > 0 &&
(connectionType !== 'aao_gateway' || apiKey.trim().length > 0) &&
(authMode !== 'local' || (localEmail.trim().length > 0 && localPassword.length >= 8)) &&
(authMode !== 'oauth' ||
(clientId.trim() &&
clientSecret.trim() &&
callbackUrl.trim() &&
oauthAdminEmail.trim().includes('@') &&
(oauthProvider !== 'gitea' || baseUrl.trim())));
async function runApply() {
setApplying(true);
setApplyError(null);
try {
const body: Record<string, unknown> = {
llm: { connectionType, endpoint: endpoint.trim(), model: model.trim(), apiKey: apiKey || undefined },
};
if (port.trim()) body.port = Number(port.trim());
const auth = authBlock();
if (auth) body.auth = auth;
const res = await fetch('/api/setup/apply', {
method: 'POST',
headers: headers(),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setApplyError(data.error || `apply failed (HTTP ${res.status})`);
return;
}
const r: ApplyResult = { ok: true, restartRequired: !!data.restartRequired, adminEmail: data.adminEmail };
setResult(r);
if (!r.restartRequired) {
// LLM-only: the wizard's job is done — refresh into the app.
onComplete();
}
} catch (e) {
setApplyError(String(e));
} finally {
setApplying(false);
}
}
// --- Finish screen (restart required) ---
if (result?.restartRequired) {
return (
<Shell>
<h1 className="text-xl font-semibold text-slate-800 mb-2">Almost there one restart needed</h1>
<p className="text-sm text-slate-600 mb-4">
Your LLM connection is live. The port and/or authentication changes take effect after a restart.
</p>
<div className="bg-slate-900 text-slate-100 text-sm rounded-lg px-4 py-3 font-mono mb-4 select-all">
{restartCommand(status.deployHint)}
</div>
{result.adminEmail && (
<p className="text-sm text-slate-600">
After restart, sign in as <span className="font-semibold">{result.adminEmail}</span> with the password you
just set. Change it from <span className="font-medium">Settings Account</span> afterwards.
</p>
)}
</Shell>
);
}
// --- Wizard form ---
return (
<Shell>
<h1 className="text-xl font-semibold text-slate-800 mb-1">Welcome let's get you set up</h1>
<p className="text-sm text-slate-600 mb-6">
Connect a language model to start running tasks. Optionally set the server port and turn on sign-in.
</p>
{tokenRequired && (
<Section title="Setup token" subtitle="Find it in the server logs: “open the UI and enter this setup token”.">
<input className={INPUT} value={token} onChange={(e) => setToken(e.target.value)} placeholder="paste the setup token" />
</Section>
)}
<Section title="1 · Language model" subtitle="Required. This is reflected immediately — no restart.">
<label className={LABEL}>Connection</label>
<select
className={`${INPUT} mb-3`}
value={connectionType}
onChange={(e) => setConnectionType(e.target.value as ConnectionType)}
>
<option value="direct">Direct (Ollama / vLLM / OpenAI-compatible)</option>
<option value="aao_gateway">AAO gateway</option>
</select>
<label className={LABEL}>Endpoint URL</label>
<input className={`${INPUT} mb-3`} value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="http://localhost:11434/v1" />
{connectionType === 'aao_gateway' && (
<>
<label className={LABEL}>API key</label>
<input className={`${INPUT} mb-3`} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-aao-…" />
</>
)}
<div className="flex items-center gap-3 mb-3">
<button className={SECONDARY_BTN} onClick={runProbe} disabled={probing || !endpoint.trim() || (tokenRequired && !token.trim())}>
{probing ? 'Testing…' : 'Test connection'}
</button>
{probeError && <span className="text-sm text-red-600">{probeError}</span>}
{!probeError && models.length > 0 && <span className="text-sm text-green-600">Found {models.length} model(s)</span>}
</div>
<label className={LABEL}>Model</label>
{models.length > 0 ? (
<select className={INPUT} value={model} onChange={(e) => setModel(e.target.value)}>
{!models.includes(model) && model && <option value={model}>{model}</option>}
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : (
<input className={INPUT} value={model} onChange={(e) => setModel(e.target.value)} placeholder="e.g. llama3.1:8b" />
)}
</Section>
<Section title="2 · Server port" subtitle="Optional. Leave blank to keep the current port. Takes effect after a restart.">
<input className={INPUT} value={port} onChange={(e) => setPort(e.target.value)} placeholder={String(status.port)} inputMode="numeric" />
</Section>
<Section title="3 · Sign-in" subtitle="Optional. Leave off to stay open (no login). Takes effect after a restart.">
<select className={`${INPUT} mb-3`} value={authMode} onChange={(e) => setAuthMode(e.target.value as AuthMode)}>
<option value="none">No sign-in (open)</option>
<option value="local">Email + password (first admin)</option>
<option value="oauth">OAuth (Google / Gitea)</option>
</select>
{authMode === 'local' && (
<>
<label className={LABEL}>Admin email</label>
<input className={`${INPUT} mb-3`} value={localEmail} onChange={(e) => setLocalEmail(e.target.value)} placeholder="admin@example.com" />
<label className={LABEL}>Admin password (min 8 chars)</label>
<input className={INPUT} type="password" value={localPassword} onChange={(e) => setLocalPassword(e.target.value)} />
</>
)}
{authMode === 'oauth' && (
<>
<label className={LABEL}>Provider</label>
<select className={`${INPUT} mb-3`} value={oauthProvider} onChange={(e) => setOauthProvider(e.target.value as OAuthProvider)}>
<option value="gitea">Gitea</option>
<option value="google">Google</option>
</select>
<label className={LABEL}>Client ID</label>
<input className={`${INPUT} mb-3`} value={clientId} onChange={(e) => setClientId(e.target.value)} />
<label className={LABEL}>Client secret</label>
<input className={`${INPUT} mb-3`} type="password" value={clientSecret} onChange={(e) => setClientSecret(e.target.value)} />
<label className={LABEL}>Callback URL</label>
<input className={`${INPUT} mb-3`} value={callbackUrl} onChange={(e) => setCallbackUrl(e.target.value)} placeholder="https://your-host/auth/gitea/callback" />
<label className={LABEL}>Admin email</label>
<input className={`${INPUT} mb-3`} value={oauthAdminEmail} onChange={(e) => setOauthAdminEmail(e.target.value)} placeholder="you@example.com — becomes admin on first login" />
{oauthProvider === 'gitea' && (
<>
<label className={LABEL}>Gitea base URL</label>
<input className={INPUT} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://gitea.example.com" />
</>
)}
</>
)}
</Section>
{applyError && <p className="text-sm text-red-600 mb-3">{applyError}</p>}
<div className="flex justify-end pt-2">
<button className={PRIMARY_BTN} onClick={runApply} disabled={applying || !canApply}>
{applying ? 'Applying…' : 'Apply & start'}
</button>
</div>
</Shell>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="h-dvh overflow-y-auto bg-slate-50 flex items-start justify-center py-10 px-4">
<div className="w-full max-w-xl bg-white border border-slate-200 rounded-2xl shadow-sm p-8">{children}</div>
</div>
);
}
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<div className="mb-6 pb-6 border-b border-slate-100 last:border-b-0">
<h2 className="text-sm font-semibold text-slate-800">{title}</h2>
{subtitle && <p className="text-xs text-slate-500 mb-3">{subtitle}</p>}
{children}
</div>
);
}

View File

@ -0,0 +1,32 @@
import { useQuery } from '@tanstack/react-query';
export interface SetupStatus {
/** True when the runtime has no usable LLM worker → show the wizard. */
needsSetup: boolean;
/** True when an OAuth provider or local auth is active. */
authActive: boolean;
/** Resolved listen port (for the restart hint). */
port: number;
/** How the server was started, for tailored restart instructions. */
deployHint: 'docker' | 'source';
/** True when a setup token must be supplied with mutating setup calls. */
tokenRequired: boolean;
}
/**
* Polls GET /api/setup/status. Short staleTime so the wizard disappears
* promptly once the LLM is configured (the server recomputes needsSetup per
* request). Never throws into the gate on error we treat it as "no wizard".
*/
export function useSetupState() {
return useQuery<SetupStatus>({
queryKey: ['setup', 'status'],
queryFn: async () => {
const res = await fetch('/api/setup/status');
if (!res.ok) throw new Error(`setup status ${res.status}`);
return res.json();
},
retry: false,
staleTime: 10_000,
});
}

View File

@ -8,11 +8,15 @@
"sendFailed": "Failed to send",
"interjectHint": "Agent running — send a message to give it instructions",
"agentRunningWait": "The agent is running your task. Please wait a moment.",
"queuedHint": "Queued — starting shortly",
"addToQueued": "Add to task",
"addToQueuedHint": "Adds this instruction to the queued task (no new task is created)",
"resend": "Resend",
"attachFile": "Attach a file",
"placeholder": {
"dispatching": "Assigning job...",
"interject": "Instruct the running agent...",
"queued": "Add an instruction to the queued task…",
"default": "Type a message... (Ctrl+Enter to send)"
},
"interject": "Interject",
@ -21,6 +25,30 @@
"stop": "Stop",
"send": "Send"
},
"tips": {
"label": "TIP",
"items": [
"Attachments land in input/, and results are written to output/.",
"You can send a message while the agent runs to add instructions mid-task.",
"Repeating the same work? Register it as a recurring run from Schedules.",
"Put standing rules in AGENTS.md and they apply automatically to every task.",
"Save facts worth keeping to memory, and future tasks will reference them.",
"Split large research into subtasks to run them in parallel and finish faster.",
"Register an SSH connection to let the agent operate remote servers.",
"Turn frequent procedures into a Skill so the agent can call them.",
"Ingest documents into Knowledge to search across them.",
"The piece is auto-selected from your prompt, but you can also pick it manually at creation.",
"Attach images or PDFs and the agent will read them as it works.",
"The more specific your instructions, the better the result — add the format and constraints you want.",
"Name a skill (\"use the X skill\") to make the agent use it for sure.",
"Each piece exposes different tools. If a tool is missing, try switching the piece.",
"You can change a task's piece later — switch it if the agent behaves unexpectedly.",
"Not sure how something works? The Help docs explain each feature.",
"Connect an MCP server to call external tools directly from the agent.",
"The Usage tab shows LLM consumption per user and per model.",
"Web search results aren't always current — open the source page to verify anything important."
]
},
"subtask": {
"title": "Subtasks",
"running": "Running",

View File

@ -51,6 +51,18 @@
"runAt": "Run at",
"weekdays": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
},
"coach": {
"evaluate": "Evaluate prompt",
"evaluating": "Evaluating...",
"hint": "Score & improve your draft before sending",
"failed": "Evaluation failed. Please try again shortly.",
"overall": "Overall",
"rewrite": "Suggested rewrite",
"applyRewrite": "Apply to request",
"predictedPiece": "Predicted task type:",
"maestroTips": "MAESTRO features you could use",
"personalized": "Notes for you"
},
"attachments": { "title": "Attachments", "hint": "Drag & drop or choose files" },
"errors": { "bodyRequired": "Request is required", "scheduleFailed": "Failed to create schedule" },
"cancel": "Cancel",

View File

@ -8,11 +8,15 @@
"sendFailed": "送信に失敗しました",
"interjectHint": "エージェント実行中 — メッセージで指示を送れます",
"agentRunningWait": "エージェントがタスクを実行中です。少々お待ちください。",
"queuedHint": "実行待ち … まもなく開始します",
"addToQueued": "追加で送信",
"addToQueuedHint": "実行待ちのタスクにこの指示を追加します(新しいタスクは作成しません)",
"resend": "再送信",
"attachFile": "ファイルを添付",
"placeholder": {
"dispatching": "ジョブ割り当て中...",
"interject": "実行中のエージェントに指示...",
"queued": "実行待ちのタスクに指示を追加…",
"default": "メッセージを入力... (Ctrl+Enter で送信)"
},
"interject": "割り込み",
@ -21,6 +25,30 @@
"stop": "停止",
"send": "送信"
},
"tips": {
"label": "TIP",
"items": [
"添付ファイルは input/ に入り、成果物は output/ に書き出されます。",
"実行中でもメッセージを送れば、エージェントに追加指示を割り込ませられます。",
"同じ作業を繰り返すなら、スケジュールから定期実行に登録できます。",
"AGENTS.md に方針を書いておくと、毎回のタスクに自動で反映されます。",
"覚えておいてほしい事実は memory に残すと、次回以降のタスクで参照されます。",
"大きめの調査はサブタスクに分割すると、並列で速く進みます。",
"リモートサーバーの操作は、SSH 接続を登録するとエージェントから実行できます。",
"よく使う手順はスキルとして登録すると、エージェントが呼び出せます。",
"ナレッジにドキュメントを取り込むと、横断検索で参照できます。",
"piece は内容から自動で選ばれますが、作成時に手動で指定もできます。",
"画像や PDF も添付すれば、エージェントが読み取って作業します。",
"指示は具体的なほど精度が上がります。成果物の形式や条件も添えてみてください。",
"「○○のスキルを使って」と名前を挙げると、そのスキルを確実に使わせられます。",
"piece ごとに使えるツールが違います。必要なツールが無いときは piece を切り替えてみてください。",
"タスクの piece は後からでも変更できます。想定と違う動きをしたら切り替えを。",
"使い方に迷ったら、ヘルプのドキュメントに各機能の説明があります。",
"MCP サーバーを接続すると、外部ツールをエージェントから直接呼べます。",
"使用量タブで、ユーザーやモデルごとの LLM 利用状況を確認できます。",
"Web 検索の結果は最新とは限りません。重要な情報は元ページを開いて裏取りを。"
]
},
"subtask": {
"title": "サブタスク",
"running": "実行中",

View File

@ -51,6 +51,18 @@
"runAt": "実行日時",
"weekdays": ["日曜", "月曜", "火曜", "水曜", "木曜", "金曜", "土曜"]
},
"coach": {
"evaluate": "プロンプトを評価",
"evaluating": "評価中...",
"hint": "送信前に下書きを採点・改善",
"failed": "評価に失敗しました。しばらくして再試行してください。",
"overall": "総合スコア",
"rewrite": "改善案",
"applyRewrite": "本文に反映",
"predictedPiece": "推定タスクタイプ:",
"maestroTips": "活用できる MAESTRO 機能",
"personalized": "あなた向けの指摘"
},
"attachments": { "title": "添付ファイル", "hint": "ドラッグ&ドロップまたはファイル選択" },
"errors": { "bodyRequired": "依頼内容は必須です", "scheduleFailed": "スケジュール作成に失敗しました" },
"cancel": "キャンセル",