diff --git a/Dockerfile b/Dockerfile index dc6d98c..72ee140 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docs/getting-started.ja.md b/docs/getting-started.ja.md index 2f163f6..0eed072 100644 --- a/docs/getting-started.ja.md +++ b/docs/getting-started.ja.md @@ -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 を開き、新規タスクを作成(タイトル + 依頼内容を入力)。 diff --git a/docs/getting-started.md b/docs/getting-started.md index 0ea4f07..ab8705b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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). diff --git a/docs/tools/ssh-tools.md b/docs/tools/ssh-tools.md index 2ad6e97..492e0f6 100644 --- a/docs/tools/ssh-tools.md +++ b/docs/tools/ssh-tools.md @@ -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 に書き込まれる。 diff --git a/docs/tools/updateuseragents.md b/docs/tools/updateuseragents.md new file mode 100644 index 0000000..eefaae3 --- /dev/null +++ b/docs/tools/updateuseragents.md @@ -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`) diff --git a/scripts/setup-lib.d.mts b/scripts/setup-lib.d.mts new file mode 100644 index 0000000..3b207de --- /dev/null +++ b/scripts/setup-lib.d.mts @@ -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 /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, +): { answers: Record; 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 }>; diff --git a/scripts/setup-lib.mjs b/scripts/setup-lib.mjs index 86ab603..2c4dbd5 100644 --- a/scripts/setup-lib.mjs +++ b/scripts/setup-lib.mjs @@ -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); diff --git a/scripts/setup-lib.test.mjs b/scripts/setup-lib.test.mjs index 61d7259..2f55039 100644 --- a/scripts/setup-lib.test.mjs +++ b/scripts/setup-lib.test.mjs @@ -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({ diff --git a/src/bridge/local-tasks-api.test.ts b/src/bridge/local-tasks-api.test.ts index 226c447..d4f6f43 100644 --- a/src/bridge/local-tasks-api.test.ts +++ b/src/bridge/local-tasks-api.test.ts @@ -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, + 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((_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); + }); +}); diff --git a/src/bridge/local-tasks-api.ts b/src/bridge/local-tasks-api.ts index 693db83..318160c 100644 --- a/src/bridge/local-tasks-api.ts +++ b/src/bridge/local-tasks-api.ts @@ -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; + /** Timeout (ms) for a prompt-coach evaluation before it is aborted. Default 30000. */ + evaluatePromptTimeoutMs?: number; } export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void { @@ -352,12 +365,13 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions) const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId); - // running / dispatching / waiting_subtasks 中: コメント保存のみ(agent-loop が注入する) - const isActive = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks'); - const commentKind = isActive ? 'interjection' : 'comment'; + // A job actively running injects the comment at its next iteration — save + // it as an interjection and don't touch the job queue. + const isRunning = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks'); + const commentKind = isRunning ? 'interjection' : 'comment'; const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind); - if (isActive) { + if (isRunning) { logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`); res.status(201).json({ comment, jobId: prevJob!.id, interjection: true }); return; @@ -374,7 +388,11 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions) ? `${body}\n\n添付ファイル(input/ に保存済み): ${savedFileNames.join(', ')}` : body; - const job = await repo.createJob({ + // Atomically reuse a still-pending job (e.g. a queued job from a comment + // sent a moment earlier) or create one. Without this, rapid/concurrent + // comments each spawned a job; the newest (queued) became latestJob and the + // task showed "Inbox" while an older job ran in the background. + const { job, created } = repo.createJobIfNoPending({ repo: localTaskRepoName(taskId), issueNumber: taskId, instruction, @@ -387,9 +405,13 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions) visibilityScopeOrgId: task!.visibilityScopeOrgId, browserSessionProfileId: task!.browserSessionProfileId ?? null, }); - await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId }); + if (created) { + await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId }); + } else { + logger.info(`[local-tasks-api] comment ${comment.id} appended to pending job ${job.id} (${job.status}) on task ${taskId}; no duplicate job created`); + } - res.status(201).json({ comment, jobId: job.id }); + res.status(201).json({ comment, jobId: job.id, reusedPending: !created }); } catch (err) { logger.error(`Local task comment create API error: ${err}`); res.status(500).json({ error: 'Failed to post local task comment' }); @@ -486,6 +508,49 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions) } }); + // On-demand prompt coach. Evaluates a draft prompt before the task is + // created (stateless), so it never touches the DB or piece-runner. The owner + // context (memory / AGENTS.md / skills / visible pieces) is keyed on the + // requesting user, falling back to 'local' for unauthenticated/no-auth calls. + app.post('/api/local/tasks/evaluate-prompt', dynamicJson(), async (req: Request, res: Response) => { + try { + if (!opts.evaluatePrompt) { + res.status(503).json({ error: 'Prompt coach is not configured' }); + return; + } + const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : ''; + if (!instruction.trim()) { + res.status(400).json({ error: 'instruction is required' }); + return; + } + const piece = typeof req.body?.piece === 'string' ? req.body.piece : undefined; + const userId = (req.user as Express.User | undefined)?.id ?? 'local'; + // Abort the underlying LLM stream on timeout so a slow model doesn't keep + // consuming tokens/connections in the background after we've given up. + const controller = new AbortController(); + let timer: ReturnType | undefined; + const timeoutMs = opts.evaluatePromptTimeoutMs ?? 30000; + const timeout = new Promise((_, 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); diff --git a/src/bridge/server.ts b/src/bridge/server.ts index aff9fb5..74da720 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -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; selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise; + /** On-demand prompt coach for the create dialog (stateless draft evaluation). */ + evaluatePrompt?: (req: { + instruction: string; + piece?: string; + userId: string; + signal?: AbortSignal; + }) => Promise; 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); } diff --git a/src/bridge/setup-api.test.ts b/src/bridge/setup-api.test.ts new file mode 100644 index 0000000..1149a9c --- /dev/null +++ b/src/bridge/setup-api.test.ts @@ -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((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((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']); + }); + }); +}); diff --git a/src/bridge/setup-api.ts b/src/bridge/setup-api.ts new file mode 100644 index 0000000..25f18d3 --- /dev/null +++ b/src/bridge/setup-api.ts @@ -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 | null = null; +function setupLib(): Promise { + 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; 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 { + 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 = {}; + 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' }); + }; +} diff --git a/src/bridge/setup-dist-resolution.test.ts b/src/bridge/setup-dist-resolution.test.ts new file mode 100644 index 0000000..54b62fa --- /dev/null +++ b/src/bridge/setup-dist-resolution.test.ts @@ -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 /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 /scripts and is reachable via ../../scripts from bridge', () => { + // src/bridge and dist/bridge both resolve `../../scripts` to /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); + }); +}); diff --git a/src/bridge/tools-api.ts b/src/bridge/tools-api.ts index b34c94c..c0b9f26 100644 --- a/src/bridge/tools-api.ts +++ b/src/bridge/tools-api.ts @@ -55,6 +55,8 @@ const META_TOOLS = new Set([ 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', + 'ReadUserAgents', + 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm', 'ReadAppDoc', diff --git a/src/config-manager.ts b/src/config-manager.ts index 63edbc4..522a5d2 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -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', ]; /** diff --git a/src/db/repository.test.ts b/src/db/repository.test.ts index 2caddc4..0bf8bb1 100644 --- a/src/db/repository.test.ts +++ b/src/db/repository.test.ts @@ -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(); } + }); +}); diff --git a/src/db/repository.ts b/src/db/repository.ts index 5b93e84..ce7abbb 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -1305,7 +1305,13 @@ export class Repository { logger.info('Repository: jobs table migration complete'); } - async createJob(params: CreateJobParams): Promise { + // 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 { + 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 { const viewerClause = opts?.viewer ? buildVisibilityWhere(opts.viewer, 'j') diff --git a/src/engine/prompt-coach.test.ts b/src/engine/prompt-coach.test.ts new file mode 100644 index 0000000..2acb0ab --- /dev/null +++ b/src/engine/prompt-coach.test.ts @@ -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 { + 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'); + }); +}); diff --git a/src/engine/prompt-coach.ts b/src/engine/prompt-coach.ts new file mode 100644 index 0000000..14867fc --- /dev/null +++ b/src/engine/prompt-coach.ts @@ -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>; + /** Reads MEMORY.md one-line index. Injectable for tests. */ + readMemory?: (rootDir: string, userId: string) => string | null; + /** Reads the user's AGENTS.md. Injectable for tests. */ + readAgents?: (rootDir: string, userId: string) => string | null; +} + +/** Size caps so the bundle fits a cheap model's context window. */ +export const COACH_LIMITS = { + instructionChars: 8000, + memoryChars: 4000, + agentsChars: 4000, + skillDescChars: 120, + maxSkills: 60, + pieceDescChars: 140, + maxPieces: 80, +} as const; + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return `${s.slice(0, max)}\n…[truncated]`; +} + +const SYSTEM_PROMPT = [ + 'あなたは MAESTRO(自律エージェント実行基盤)のプロンプト改善コーチです。', + 'ユーザーがタスクを送信する前の下書きを評価し、より良い書き方を教えて、プロンプト力を育てるのが目的です。', + '出力は必ず submit_evaluation ツールを 1 回だけ呼んで返してください。すべて日本語で書きます。', + '', + '評価の観点(axes)は次の 4 つに固定:', + '- 明確さ: 何をしてほしいかが曖昧でないか', + '- 具体性: 対象・条件・範囲が具体的か', + '- コンテキスト: 判断に必要な前提・背景が与えられているか', + '- 成果物指定: 出力フォーマット・粒度・宛先が指定されているか', + '各 axis は 0-10、overall は 0-100 で採点します。', + '', + 'rewrite には、ユーザーがそのまま使える改善済みの依頼文を入れます。', + 'predicted_piece は、与えられた piece 候補の中から最も適切なものを 1 つ選び、name は候補名と完全一致させます。該当が無ければ null。', + 'maestro_tips では、この下書きで活かせる MAESTRO 機能(ファイル添付 / 定期実行スケジュール / SSH 操作 / ナレッジ検索 / サブタスク並列実行 など)を提案します。当てはまらなければ空配列。', + 'personalized では、ユーザーの memory / AGENTS.md / 所有スキルを踏まえた指摘を入れます(例: 「該当スキルを既に所有しているので言及不要」「AGENTS.md の方針と矛盾」「memory にある前提は省略可能」)。根拠が無ければ空配列。', +].join('\n'); + +/** + * Builds the system + user prompts and the list of candidate piece names. + * Pure: depends only on the injected catalogs/readers, never on globals. + */ +export function buildCoachMessages( + deps: PromptCoachDeps, + req: PromptCoachRequest, +): { system: string; user: string; pieceCandidates: string[] } { + const readMemory = deps.readMemory ?? (() => null); + const readAgents = deps.readAgents ?? (() => null); + + const memory = readMemory(deps.userFolderRoot, req.userId); + const agents = readAgents(deps.userFolderRoot, req.userId); + const skills = deps.skillCatalog.getForUser(req.userId).slice(0, COACH_LIMITS.maxSkills); + const pieces = deps.pieceCatalog.getForUser(req.userId).slice(0, COACH_LIMITS.maxPieces); + const pieceCandidates = pieces.map((p) => p.name); + + const sections: string[] = []; + + sections.push('## 評価対象の下書き'); + sections.push(truncate(req.instruction.trim(), COACH_LIMITS.instructionChars) || '(空)'); + + if (req.piece && req.piece !== 'auto') { + sections.push('## ユーザーが選択中の piece'); + sections.push(req.piece); + } + + if (memory && memory.trim()) { + sections.push('## ユーザーの memory(MEMORY.md 一覧)'); + sections.push(truncate(memory.trim(), COACH_LIMITS.memoryChars)); + } + + if (agents && agents.trim()) { + sections.push('## ユーザーの AGENTS.md(作業方針)'); + sections.push(truncate(agents.trim(), COACH_LIMITS.agentsChars)); + } + + if (skills.length > 0) { + sections.push('## ユーザーが利用できるスキル'); + sections.push( + skills + .map((s) => `- ${s.name}: ${truncate((s.description ?? '').trim(), COACH_LIMITS.skillDescChars)}`) + .join('\n'), + ); + } + + if (pieces.length > 0) { + sections.push('## piece 候補(predicted_piece.name はこの中から選ぶ)'); + sections.push( + pieces + .map((p) => `- ${p.name}: ${truncate((p.description ?? '').trim(), COACH_LIMITS.pieceDescChars)}`) + .join('\n'), + ); + } else { + sections.push('## piece 候補'); + sections.push('(候補なし。predicted_piece は null にする)'); + } + + return { system: SYSTEM_PROMPT, user: sections.join('\n\n'), pieceCandidates }; +} + +function clampNumber(v: unknown, min: number, max: number, fallback: number): number { + const n = typeof v === 'number' && Number.isFinite(v) ? v : Number(v); + if (!Number.isFinite(n)) return fallback; + return Math.max(min, Math.min(max, Math.round(n))); +} + +function asString(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +/** + * Validates and clamps the raw LLM tool arguments into a PromptCoachResult. + * Never throws on malformed input — fills safe defaults instead — so a quirky + * model response degrades gracefully rather than 500-ing the route. + */ +export function normalizeCoachResult( + raw: Record, + pieceCandidates: string[], +): PromptCoachResult { + const axes: PromptCoachAxis[] = Array.isArray(raw.axes) + ? raw.axes.slice(0, 4).map((a) => { + const o = (a ?? {}) as Record; + 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 | 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; + 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 { + 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; diff --git a/src/engine/tools/docs.ts b/src/engine/tools/docs.ts index 0f06000..6bb388b 100644 --- a/src/engine/tools/docs.ts +++ b/src/engine/tools/docs.ts @@ -28,6 +28,8 @@ const DOCS_DIR = path.join(REPO_ROOT, 'docs', 'tools'); // 関連ツールが同じ doc を参照できるようエイリアスを定義 // キー・値ともに小文字 const TOOL_DOC_ALIASES: Record = { + // updateuseragents.md にまとめる + readuseragents: 'updateuseragents', // checklist.md にまとめる createchecklist: 'checklist', checkitem: 'checklist', diff --git a/src/engine/tools/index.ts b/src/engine/tools/index.ts index b51c39f..82b1893 100644 --- a/src/engine/tools/index.ts +++ b/src/engine/tools/index.ts @@ -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) { diff --git a/src/engine/tools/user-folder.test.ts b/src/engine/tools/user-folder.test.ts index 247a6dd..3a20f07 100644 --- a/src/engine/tools/user-folder.test.ts +++ b/src/engine/tools/user-folder.test.ts @@ -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(); + }); +}); diff --git a/src/engine/tools/user-folder.ts b/src/engine/tools/user-folder.ts index 856eeb5..0a1417e 100644 --- a/src/engine/tools/user-folder.ts +++ b/src/engine/tools/user-folder.ts @@ -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 = { }, }, + 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, + ctx: ToolContext, +): Promise { + 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, + ctx: ToolContext, +): Promise { + 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 { 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); diff --git a/src/metrics/tool-name-allowlist.ts b/src/metrics/tool-name-allowlist.ts index 1469d1a..ec51ebd 100644 --- a/src/metrics/tool-name-allowlist.ts +++ b/src/metrics/tool-name-allowlist.ts @@ -77,6 +77,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray = [ // user-folder.ts 'ListUserAssets', 'ReadUserMemory', 'RunUserScript', 'UpdateUserMemory', 'WriteUserScript', + 'ReadUserAgents', 'UpdateUserAgents', // brainstorm.ts 'Brainstorm', // app-docs.ts diff --git a/src/ssh/path-policy.test.ts b/src/ssh/path-policy.test.ts index 3ffc609..b30415f 100644 --- a/src/ssh/path-policy.test.ts +++ b/src/ssh/path-policy.test.ts @@ -105,10 +105,43 @@ describe('ssh/path-policy validateRemotePath', () => { expect(validateRemotePath('\\\\srv\\share\\agent\\file', '\\\\srv\\share\\agent').ok).toBe(true); }); - it('collapses repeated backslashes in Windows path', () => { + it('collapses repeated backslashes and emits forward-slash form', () => { const r = validateRemotePath('C:\\Users\\\\agent\\\\file', 'C:\\Users\\agent'); expect(r.ok).toBe(true); - expect(r.normalized).toBe('C:\\Users\\agent\\file'); + // SFTP wire form: backslashes → forward slashes, drive path gets leading '/'. + expect(r.normalized).toBe('/C:/Users/agent/file'); + }); + + it('normalizes Windows drive paths to /C:/... (leading slash) for SFTP', () => { + expect(validateRemotePath('C:\\Users\\agent\\f', 'C:\\Users\\agent').normalized).toBe( + '/C:/Users/agent/f', + ); + // Already-canonical leading-slash form is idempotent. + expect(validateRemotePath('/C:/Users/agent/f', '/C:/Users/agent').normalized).toBe( + '/C:/Users/agent/f', + ); + }); + + it('accepts forward-slash Windows drive prefix (natural Win OpenSSH config)', () => { + // Regression: detectSeparator used to flag any drive path as backslash-style, + // so a forward-slash prefix + forward-slash path was wrongly rejected. + expect(validateRemotePath('C:/Users/agent', 'C:/Users/agent').ok).toBe(true); + expect(validateRemotePath('C:/Users/agent/file.txt', 'C:/Users/agent').ok).toBe(true); + expect(validateRemotePath('C:/Users/agent/file.txt', 'C:/Users/agent').normalized).toBe( + '/C:/Users/agent/file.txt', + ); + }); + + it('accepts mixed separators between prefix and candidate', () => { + // Backslash prefix + forward-slash path (and vice versa) must line up. + expect(validateRemotePath('C:/Users/agent/file', 'C:\\Users\\agent').ok).toBe(true); + expect(validateRemotePath('C:\\Users\\agent\\file', 'C:/Users/agent').ok).toBe(true); + }); + + it('emits forward-slash UNC form', () => { + const r = validateRemotePath('\\\\srv\\share\\agent\\file', '\\\\srv\\share\\agent'); + expect(r.ok).toBe(true); + expect(r.normalized).toBe('//srv/share/agent/file'); }); it('rejects empty prefix candidate via outside_prefix when prefix is non-trivial', () => { diff --git a/src/ssh/path-policy.ts b/src/ssh/path-policy.ts index 60eeaf1..b97d6b0 100644 --- a/src/ssh/path-policy.ts +++ b/src/ssh/path-policy.ts @@ -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 }; } diff --git a/src/user-folder/paths.ts b/src/user-folder/paths.ts index c744f49..f6bcdae 100644 --- a/src/user-folder/paths.ts +++ b/src/user-folder/paths.ts @@ -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; +} diff --git a/src/worker-bootstrap.ts b/src/worker-bootstrap.ts index 923fc3c..4022b44 100644 --- a/src/worker-bootstrap.ts +++ b/src/worker-bootstrap.ts @@ -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 { } : 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 | 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) { + 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 { configuredRepos: [], generateTitle, selectPiece, + evaluatePrompt, configManager, piecesDir, customPiecesDir, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 3f4af20..e3dccd7 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 ; +} + +// 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 ( +
+
+
+ ); + } + + if (setup?.needsSetup) { + const canConfigure = !setup.authActive || isAdmin; + if (canConfigure) { + return ( + { + queryClient.invalidateQueries({ queryKey: ['setup'] }); + queryClient.invalidateQueries({ queryKey: ['auth'] }); + queryClient.invalidateQueries({ queryKey: ['config'] }); + queryClient.invalidateQueries({ queryKey: ['workers'] }); + }} + /> + ); + } + return ( +
+
+

Setup needed

+

+ This server has no language model configured yet. Ask an administrator to finish setup from Settings. +

+
+
+ ); + } + return ; } diff --git a/ui/src/api.ts b/ui/src/api.ts index 733b1ba..45b5fab 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -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 { + 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 { const res = await fetch(`${BASE}/local/tasks/${taskId}`); const data = await res.json(); diff --git a/ui/src/components/chat/ChatPane.tsx b/ui/src/components/chat/ChatPane.tsx index 4c78d5b..37f41b2 100644 --- a/ui/src/components/chat/ChatPane.tsx +++ b/ui/src/components/chat/ChatPane.tsx @@ -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 )}
)} + + {/* General rotating tips to make the wait useful (running only). */} + {isBusy && } @@ -406,17 +415,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ {/* Composer */}
- {isBusy && ( + {hasActiveJob && (
- {canInterject ? t('pane.interjectHint') : t('pane.agentRunningWait')} + {canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}
)} {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')}
+ ) : isPending ? ( + ) : ( + {t('coach.hint')} + + + {mutation.isError && ( +
{t('coach.failed')}
+ )} + + {result && ( +
+ {/* Overall score */} +
+ {t('coach.overall')} + {result.overall} + / 100 +
+ + {/* Axes */} + {result.axes.length > 0 && ( +
+ {result.axes.map((ax, i) => ( +
+
+ {ax.name} + {ax.score}/10 +
+ {ax.comment &&
{ax.comment}
} +
+ ))} +
+ )} + + {/* Rewrite suggestion */} + {result.rewrite && ( +
+
+ {t('coach.rewrite')} + +
+
+ {result.rewrite} +
+
+ )} + + {/* Predicted piece */} + {result.predicted_piece && ( +
+ {t('coach.predictedPiece')}{' '} + {result.predicted_piece.name} + {result.predicted_piece.reason && ( + — {result.predicted_piece.reason} + )} +
+ )} + + {/* MAESTRO tips */} + {result.maestro_tips.length > 0 && ( +
+
{t('coach.maestroTips')}
+
    + {result.maestro_tips.map((tip, i) => ( +
  • + {tip.feature} + {tip.suggestion && — {tip.suggestion}} +
  • + ))} +
+
+ )} + + {/* Personalized notes */} + {result.personalized.length > 0 && ( +
+
{t('coach.personalized')}
+
    + {result.personalized.map((note, i) => ( +
  • {note}
  • + ))} +
+
+ )} +
+ )} + + ); +} diff --git a/ui/src/components/setup/SetupWizard.tsx b/ui/src/components/setup/SetupWizard.tsx new file mode 100644 index 0000000..785306d --- /dev/null +++ b/ui/src/components/setup/SetupWizard.tsx @@ -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('direct'); + const [endpoint, setEndpoint] = useState('http://localhost:11434/v1'); + const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState(''); + const [models, setModels] = useState([]); + const [probing, setProbing] = useState(false); + const [probeError, setProbeError] = useState(null); + + // Step 2 — port (optional) + const [port, setPort] = useState(''); + + // Step 3 — auth (optional) + const [authMode, setAuthMode] = useState('none'); + const [localEmail, setLocalEmail] = useState(''); + const [localPassword, setLocalPassword] = useState(''); + const [oauthProvider, setOauthProvider] = useState('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(null); + const [result, setResult] = useState(null); + + function headers(): Record { + const h: Record = { '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 = { + 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 ( + +

Almost there — one restart needed

+

+ Your LLM connection is live. The port and/or authentication changes take effect after a restart. +

+
+ {restartCommand(status.deployHint)} +
+ {result.adminEmail && ( +

+ After restart, sign in as {result.adminEmail} with the password you + just set. Change it from Settings → Account afterwards. +

+ )} +
+ ); + } + + // --- Wizard form --- + return ( + +

Welcome — let's get you set up

+

+ Connect a language model to start running tasks. Optionally set the server port and turn on sign-in. +

+ + {tokenRequired && ( +
+ setToken(e.target.value)} placeholder="paste the setup token" /> +
+ )} + +
+ + + + + setEndpoint(e.target.value)} placeholder="http://localhost:11434/v1" /> + + {connectionType === 'aao_gateway' && ( + <> + + setApiKey(e.target.value)} placeholder="sk-aao-…" /> + + )} + +
+ + {probeError && {probeError}} + {!probeError && models.length > 0 && Found {models.length} model(s)} +
+ + + {models.length > 0 ? ( + + ) : ( + setModel(e.target.value)} placeholder="e.g. llama3.1:8b" /> + )} +
+ +
+ setPort(e.target.value)} placeholder={String(status.port)} inputMode="numeric" /> +
+ +
+ + + {authMode === 'local' && ( + <> + + setLocalEmail(e.target.value)} placeholder="admin@example.com" /> + + setLocalPassword(e.target.value)} /> + + )} + + {authMode === 'oauth' && ( + <> + + + + setClientId(e.target.value)} /> + + setClientSecret(e.target.value)} /> + + setCallbackUrl(e.target.value)} placeholder="https://your-host/auth/gitea/callback" /> + + setOauthAdminEmail(e.target.value)} placeholder="you@example.com — becomes admin on first login" /> + {oauthProvider === 'gitea' && ( + <> + + setBaseUrl(e.target.value)} placeholder="https://gitea.example.com" /> + + )} + + )} +
+ + {applyError &&

{applyError}

} + +
+ +
+
+ ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {subtitle &&

{subtitle}

} + {children} +
+ ); +} diff --git a/ui/src/hooks/useSetupState.ts b/ui/src/hooks/useSetupState.ts new file mode 100644 index 0000000..a067946 --- /dev/null +++ b/ui/src/hooks/useSetupState.ts @@ -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({ + 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, + }); +} diff --git a/ui/src/i18n/locales/en/chat.json b/ui/src/i18n/locales/en/chat.json index 257aa87..d788c48 100644 --- a/ui/src/i18n/locales/en/chat.json +++ b/ui/src/i18n/locales/en/chat.json @@ -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", diff --git a/ui/src/i18n/locales/en/create.json b/ui/src/i18n/locales/en/create.json index eb37e11..b321c2a 100644 --- a/ui/src/i18n/locales/en/create.json +++ b/ui/src/i18n/locales/en/create.json @@ -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", diff --git a/ui/src/i18n/locales/ja/chat.json b/ui/src/i18n/locales/ja/chat.json index 1406ed2..bb7fd66 100644 --- a/ui/src/i18n/locales/ja/chat.json +++ b/ui/src/i18n/locales/ja/chat.json @@ -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": "実行中", diff --git a/ui/src/i18n/locales/ja/create.json b/ui/src/i18n/locales/ja/create.json index 23bf5d6..b29d27a 100644 --- a/ui/src/i18n/locales/ja/create.json +++ b/ui/src/i18n/locales/ja/create.json @@ -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": "キャンセル",