sync: update from private repo (2ef0fa6)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
517142c61d
commit
1602d52510
32
Dockerfile
32
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
|
||||
|
||||
@ -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 を開き、新規タスクを作成(タイトル + 依頼内容を入力)。
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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 に書き込まれる。
|
||||
|
||||
54
docs/tools/updateuseragents.md
Normal file
54
docs/tools/updateuseragents.md
Normal file
@ -0,0 +1,54 @@
|
||||
# ReadUserAgents / UpdateUserAgents
|
||||
|
||||
ユーザーごとの常時指示書 `AGENTS.md` を読み書きするツール。`AGENTS.md` は各タスクのシステムプロンプトに自動注入される「このユーザーが常に守ってほしいこと」を書いた個人ファイル。memory(`UpdateUserMemory`)が事実の断片を貯めるのに対し、`AGENTS.md` は振る舞いの方針そのもの。
|
||||
|
||||
両ツールは META_TOOL(常時利用可能)。piece の `allowed_tools` に書かなくても使える。per-user 機能なので、認証済みユーザーのコンテキスト(`ctx.userId`)が必要。
|
||||
|
||||
## ReadUserAgents
|
||||
|
||||
引数なし。現在の `AGENTS.md` 全文を切り詰めずに返す。**編集前に必ず呼ぶこと** — `replace` は原文と完全一致する文字列が必要で、システムプロンプトへの注入版は 64KB で切り詰められている場合があるため。
|
||||
|
||||
ファイルが無い/空のときは、その旨と「`append` で新規作成できる」案内を返す(エラーではない)。
|
||||
|
||||
## UpdateUserAgents
|
||||
|
||||
`AGENTS.md` を 2 モードで編集する。書き込み前に旧版を `trash/agents-history/AGENTS.md.{timestamp}.bak` に自動退避(直近 10 件を保持)。事故時はそこから復元できる。
|
||||
|
||||
### パラメータ
|
||||
|
||||
| 名前 | 必須 | 説明 |
|
||||
|---|---|---|
|
||||
| `mode` | ○ | `"replace"` または `"append"` |
|
||||
| `old_text` | replace時○ | 置換対象の既存文字列。**ちょうど 1 回だけ**一致する必要がある |
|
||||
| `new_text` | ○ | replace の置換後テキスト/append で末尾に足すテキスト |
|
||||
|
||||
### mode="replace"
|
||||
|
||||
`old_text` を `new_text` に置き換える。コアの `Edit` ツールと同じ「一意一致」セマンティクス。
|
||||
|
||||
- 0 回一致 → エラー(`ReadUserAgents` で原文を確認するよう促す)
|
||||
- 2 回以上一致 → エラー(前後の文脈を含めて一意にするよう促す)
|
||||
- ファイルが空 → エラー(`append` を案内)
|
||||
|
||||
`new_text` 内の `$&` や `$1` は特殊扱いされない(リテラルとして入る)。
|
||||
|
||||
### mode="append"
|
||||
|
||||
`new_text` を末尾に追記する。既存末尾の空行を整理し、空行 1 行を挟んで足す。ファイルが空なら `new_text` がそのまま新規ファイルになる。
|
||||
|
||||
### 制限・エラー
|
||||
|
||||
- 結果が 64KB を超えると `writeUserAgentsMd` が拒否(`AGENTS.md exceeds 65536 bytes`)
|
||||
- `ctx.userId` が無い(認証なし)→ エラー
|
||||
- 監査ログ `user_agents_updated`(mode / バイト数 / スナップショット有無)を記録
|
||||
|
||||
## 典型フロー
|
||||
|
||||
1. `ReadUserAgents` で現在の内容を取得
|
||||
2. ユーザー依頼に応じて `UpdateUserAgents({ mode: "replace", old_text: "...", new_text: "..." })` または `mode: "append"`
|
||||
3. 戻り値でバイト数とスナップショット退避を確認
|
||||
|
||||
## 関連
|
||||
|
||||
- `UpdateUserMemory` / `ReadUserMemory` — 事実の断片を貯める個人 memory(`docs/tools` の該当 doc 参照)
|
||||
- UI からの編集は Settings 経由(HTTP `GET/PUT/DELETE /agents-md`)
|
||||
71
scripts/setup-lib.d.mts
Normal file
71
scripts/setup-lib.d.mts
Normal file
@ -0,0 +1,71 @@
|
||||
// Type declarations for the dependency-free setup helpers in setup-lib.mjs.
|
||||
// The browser setup wizard server (src/bridge/setup-api.ts) imports this module
|
||||
// at runtime from BOTH the dev (ts-node) and dist layouts via the relative path
|
||||
// `../../scripts/setup-lib.mjs`, which resolves to <root>/scripts in either
|
||||
// case (Codex P2 #7). These types let TypeScript check that usage.
|
||||
|
||||
export type ConnectionType = 'direct' | 'aao_gateway';
|
||||
|
||||
export const ALL_ROLES: readonly ['auto', 'fast', 'quality', 'title', 'reflection'];
|
||||
export const CONNECTION_TYPES: readonly ConnectionType[];
|
||||
|
||||
export function parseEndpoint(
|
||||
input: unknown,
|
||||
): { endpoint: string; base: string; error?: undefined } | { error: string; endpoint?: undefined; base?: undefined };
|
||||
|
||||
export interface SnakeWorkerEntry {
|
||||
id: string;
|
||||
connection_type: ConnectionType;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
roles: string[];
|
||||
max_concurrency: number;
|
||||
enabled: boolean;
|
||||
vlm: boolean;
|
||||
api_key?: string;
|
||||
}
|
||||
|
||||
export interface CamelWorkerEntry {
|
||||
id: string;
|
||||
connectionType: ConnectionType;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
roles: string[];
|
||||
maxConcurrency: number;
|
||||
enabled: boolean;
|
||||
vlm: boolean;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface BuildWorkerArgs {
|
||||
connectionType: ConnectionType;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export function buildWorkerEntry(args: BuildWorkerArgs): SnakeWorkerEntry;
|
||||
export function buildLlmWorkerCamel(args: BuildWorkerArgs): CamelWorkerEntry;
|
||||
|
||||
export function isLlmConfigured(
|
||||
workers: ReadonlyArray<{ enabled?: boolean; endpoint?: unknown; model?: unknown } | null | undefined> | unknown,
|
||||
): boolean;
|
||||
|
||||
export function renderConfigYaml(worker: SnakeWorkerEntry): string;
|
||||
export function renderDotenv(existingText: string | undefined, opts: { port: number | string }): string;
|
||||
|
||||
export function parseAnswersFromEnv(
|
||||
env: Record<string, string | undefined>,
|
||||
): { answers: Record<string, unknown>; error?: undefined } | { error: string; answers?: undefined };
|
||||
|
||||
export interface ProbeArgs {
|
||||
endpoint: string;
|
||||
base: string;
|
||||
apiKey?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export function probeModels(
|
||||
args: ProbeArgs,
|
||||
): Promise<{ ok: boolean; models: string[]; error?: string }>;
|
||||
@ -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);
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
@ -705,6 +705,49 @@ describe('POST /api/local/tasks/:taskId/comments and /cancel ownership', () => {
|
||||
.send({ body: 'hi from alice', author: 'user' });
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
// Regression: two comments in quick succession to an idle task used to spawn
|
||||
// two queued jobs; the newest became latestJob so the task showed "Inbox"
|
||||
// while an older job ran. Now the second comment reuses the still-pending job.
|
||||
it('rapid comments to an idle task reuse the pending job (no duplicate)', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-dup-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
|
||||
const aliceUser: Express.User = { ...alice, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private', workspacePath: join(tempDir, 'ws') });
|
||||
const app = buildAppForUser(aliceUser);
|
||||
|
||||
const r1 = await request(app).post(`/api/local/tasks/${task.id}/comments`).send({ body: 'first', author: 'user' });
|
||||
expect(r1.status).toBe(201);
|
||||
const r2 = await request(app).post(`/api/local/tasks/${task.id}/comments`).send({ body: 'second', author: 'user' });
|
||||
expect(r2.status).toBe(201);
|
||||
expect(r2.body.reusedPending).toBe(true);
|
||||
expect(r2.body.jobId).toBe(r1.body.jobId);
|
||||
|
||||
const count = (repo.getDb()
|
||||
.prepare('SELECT COUNT(*) AS c FROM jobs WHERE repo = ? AND issue_number = ?')
|
||||
.get(localTaskRepoName(task.id), task.id) as { c: number }).c;
|
||||
expect(count).toBe(1);
|
||||
|
||||
const userComments = (await repo.listLocalTaskComments(task.id)).filter(c => c.author === 'user');
|
||||
expect(userComments.map(c => c.body)).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('a comment to a terminal (succeeded) task creates a fresh job', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-term-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
|
||||
const aliceUser: Express.User = { ...alice, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private', workspacePath: join(tempDir, 'ws') });
|
||||
const prev = await repo.createJob({ repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go' });
|
||||
await repo.updateJob(prev.id, { status: 'succeeded' });
|
||||
|
||||
const res = await request(buildAppForUser(aliceUser))
|
||||
.post(`/api/local/tasks/${task.id}/comments`).send({ body: 'again', author: 'user' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.reusedPending).toBe(false);
|
||||
expect(res.body.jobId).not.toBe(prev.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/local/tasks browserSessionProfileId owner check', () => {
|
||||
@ -1106,3 +1149,84 @@ describe('POST /api/local/tasks/:id/continue — user-custom piece resolution',
|
||||
expect(res.body.error).toBe('piece_not_found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/local/tasks/evaluate-prompt (prompt coach)', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lt-coach-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildApp(
|
||||
evaluatePrompt?: (r: { instruction: string; piece?: string; userId: string; signal?: AbortSignal }) => Promise<unknown>,
|
||||
evaluatePromptTimeoutMs?: number,
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
mountLocalTasksApi(app, {
|
||||
repo,
|
||||
worktreeDir: join(tempDir, 'workspaces'),
|
||||
authActive: false,
|
||||
evaluatePrompt,
|
||||
evaluatePromptTimeoutMs,
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
it('returns 503 when the coach is not configured', async () => {
|
||||
const res = await request(buildApp(undefined)).post('/api/local/tasks/evaluate-prompt').send({ instruction: 'x' });
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it('returns 400 when instruction is empty', async () => {
|
||||
const res = await request(buildApp(async () => ({}))).post('/api/local/tasks/evaluate-prompt').send({ instruction: ' ' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('passes instruction/piece/userId/signal through and returns the evaluation', async () => {
|
||||
let captured: { instruction: string; piece?: string; userId: string; signal?: AbortSignal } | null = null;
|
||||
const evaluatePrompt = async (r: { instruction: string; piece?: string; userId: string; signal?: AbortSignal }) => {
|
||||
captured = r;
|
||||
return { overall: 72, axes: [], rewrite: 'better', predicted_piece: null, maestro_tips: [], personalized: [] };
|
||||
};
|
||||
const res = await request(buildApp(evaluatePrompt))
|
||||
.post('/api/local/tasks/evaluate-prompt')
|
||||
.send({ instruction: 'summarize this pdf', piece: 'research' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.overall).toBe(72);
|
||||
expect(captured!.instruction).toBe('summarize this pdf');
|
||||
expect(captured!.piece).toBe('research');
|
||||
expect(captured!.userId).toBe('local');
|
||||
// A timeout AbortSignal must be threaded so a slow LLM call can be cancelled.
|
||||
expect(captured!.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('aborts the evaluation signal when it exceeds the timeout', async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
const evaluatePrompt = (r: { instruction: string; piece?: string; userId: string; signal?: AbortSignal }) =>
|
||||
new Promise<unknown>((_resolve, reject) => {
|
||||
capturedSignal = r.signal;
|
||||
r.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
});
|
||||
// 10ms timeout: the coach never resolves, so the route aborts and 502s.
|
||||
const res = await request(buildApp(evaluatePrompt, 10))
|
||||
.post('/api/local/tasks/evaluate-prompt')
|
||||
.send({ instruction: 'slow one' });
|
||||
expect(res.status).toBe(502);
|
||||
expect(capturedSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 502 when the coach throws', async () => {
|
||||
const res = await request(buildApp(async () => { throw new Error('llm down'); }))
|
||||
.post('/api/local/tasks/evaluate-prompt')
|
||||
.send({ instruction: 'x' });
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
@ -45,6 +45,19 @@ export interface LocalTasksApiOptions {
|
||||
* rest of the no-auth path. Defaults to `true` (owner stays req.user.id).
|
||||
*/
|
||||
authActive?: boolean;
|
||||
/**
|
||||
* Optional. On-demand prompt coach: evaluates a draft task prompt (before any
|
||||
* task is created) using the cheap model + the user's own context. When unset,
|
||||
* the /evaluate-prompt route returns 503. Stateless — nothing is persisted.
|
||||
*/
|
||||
evaluatePrompt?: (req: {
|
||||
instruction: string;
|
||||
piece?: string;
|
||||
userId: string;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<unknown>;
|
||||
/** Timeout (ms) for a prompt-coach evaluation before it is aborted. Default 30000. */
|
||||
evaluatePromptTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void {
|
||||
@ -352,12 +365,13 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
|
||||
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
|
||||
|
||||
// running / dispatching / waiting_subtasks 中: コメント保存のみ(agent-loop が注入する)
|
||||
const isActive = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
|
||||
const commentKind = isActive ? 'interjection' : 'comment';
|
||||
// A job actively running injects the comment at its next iteration — save
|
||||
// it as an interjection and don't touch the job queue.
|
||||
const isRunning = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
|
||||
const commentKind = isRunning ? 'interjection' : 'comment';
|
||||
const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind);
|
||||
|
||||
if (isActive) {
|
||||
if (isRunning) {
|
||||
logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`);
|
||||
res.status(201).json({ comment, jobId: prevJob!.id, interjection: true });
|
||||
return;
|
||||
@ -374,7 +388,11 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
? `${body}\n\n添付ファイル(input/ に保存済み): ${savedFileNames.join(', ')}`
|
||||
: body;
|
||||
|
||||
const job = await repo.createJob({
|
||||
// Atomically reuse a still-pending job (e.g. a queued job from a comment
|
||||
// sent a moment earlier) or create one. Without this, rapid/concurrent
|
||||
// comments each spawned a job; the newest (queued) became latestJob and the
|
||||
// task showed "Inbox" while an older job ran in the background.
|
||||
const { job, created } = repo.createJobIfNoPending({
|
||||
repo: localTaskRepoName(taskId),
|
||||
issueNumber: taskId,
|
||||
instruction,
|
||||
@ -387,9 +405,13 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
visibilityScopeOrgId: task!.visibilityScopeOrgId,
|
||||
browserSessionProfileId: task!.browserSessionProfileId ?? null,
|
||||
});
|
||||
await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });
|
||||
if (created) {
|
||||
await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });
|
||||
} else {
|
||||
logger.info(`[local-tasks-api] comment ${comment.id} appended to pending job ${job.id} (${job.status}) on task ${taskId}; no duplicate job created`);
|
||||
}
|
||||
|
||||
res.status(201).json({ comment, jobId: job.id });
|
||||
res.status(201).json({ comment, jobId: job.id, reusedPending: !created });
|
||||
} catch (err) {
|
||||
logger.error(`Local task comment create API error: ${err}`);
|
||||
res.status(500).json({ error: 'Failed to post local task comment' });
|
||||
@ -486,6 +508,49 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
}
|
||||
});
|
||||
|
||||
// On-demand prompt coach. Evaluates a draft prompt before the task is
|
||||
// created (stateless), so it never touches the DB or piece-runner. The owner
|
||||
// context (memory / AGENTS.md / skills / visible pieces) is keyed on the
|
||||
// requesting user, falling back to 'local' for unauthenticated/no-auth calls.
|
||||
app.post('/api/local/tasks/evaluate-prompt', dynamicJson(), async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!opts.evaluatePrompt) {
|
||||
res.status(503).json({ error: 'Prompt coach is not configured' });
|
||||
return;
|
||||
}
|
||||
const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : '';
|
||||
if (!instruction.trim()) {
|
||||
res.status(400).json({ error: 'instruction is required' });
|
||||
return;
|
||||
}
|
||||
const piece = typeof req.body?.piece === 'string' ? req.body.piece : undefined;
|
||||
const userId = (req.user as Express.User | undefined)?.id ?? 'local';
|
||||
// Abort the underlying LLM stream on timeout so a slow model doesn't keep
|
||||
// consuming tokens/connections in the background after we've given up.
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutMs = opts.evaluatePromptTimeoutMs ?? 30000;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error('timeout'));
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
opts.evaluatePrompt({ instruction, piece, userId, signal: controller.signal }),
|
||||
timeout,
|
||||
]);
|
||||
res.json(result);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Prompt coach evaluation failed: ${err}`);
|
||||
res.status(502).json({ error: 'Prompt evaluation failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const taskId = parseTaskId(req.params.taskId);
|
||||
|
||||
@ -7,6 +7,13 @@ import { logger } from '../logger.js';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { ConfigManager } from '../config-manager.js';
|
||||
import { mountConfigApi } from './config-api.js';
|
||||
import {
|
||||
mountSetupApi,
|
||||
buildNoAuthConfigGate,
|
||||
newNoAuthConfigGateState,
|
||||
ensureSetupToken,
|
||||
clearSetupToken,
|
||||
} from './setup-api.js';
|
||||
import { mountPiecesApi } from './pieces-api.js';
|
||||
import { mountToolsApi } from './tools-api.js';
|
||||
import { mountSkillsApi } from './skills-api.js';
|
||||
@ -121,6 +128,13 @@ export interface CoreServerOptions {
|
||||
configuredRepos?: string[];
|
||||
generateTitle?: (body: string, ownerId?: string) => Promise<string>;
|
||||
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
||||
/** On-demand prompt coach for the create dialog (stateless draft evaluation). */
|
||||
evaluatePrompt?: (req: {
|
||||
instruction: string;
|
||||
piece?: string;
|
||||
userId: string;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<unknown>;
|
||||
configManager?: ConfigManager;
|
||||
piecesDir?: string;
|
||||
customPiecesDir?: string;
|
||||
@ -911,6 +925,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
authActive,
|
||||
generateTitle: opts.generateTitle,
|
||||
selectPiece: opts.selectPiece,
|
||||
evaluatePrompt: opts.evaluatePrompt,
|
||||
pieceExists: opts.piecesDir
|
||||
? (name: string, ownerId?: string) => {
|
||||
// Mirror the worker's per-user → global-custom → builtin resolution order.
|
||||
@ -966,7 +981,57 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// able to dispatch `/health` to the gateway sub-app when running
|
||||
// (LiteLLM-shape JSON — CRITICAL-3 fix).
|
||||
|
||||
// === Browser setup wizard (fresh no-auth install onboarding) ===
|
||||
// Token lifecycle + endpoints. The no-auth `/api/config` mutation gate
|
||||
// (Codex P1 #3) must register BEFORE mountConfigApi so it intercepts
|
||||
// PUT/POST while a setup window is open. See setup-api.ts.
|
||||
if (opts.configManager) {
|
||||
const setupDataDir = join(process.cwd(), 'data');
|
||||
const setupListenPort =
|
||||
opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port);
|
||||
// Synchronously seed the gate state to 'pending' (fail-closed for auth
|
||||
// writes) BEFORE the async lifecycle below resolves, so a request arriving
|
||||
// in the boot window can't slip an auth bootstrap through (Codex P1 #2).
|
||||
const setupGateState = newNoAuthConfigGateState();
|
||||
|
||||
// Token lifecycle (needs the async setup-lib import). Created only on a
|
||||
// fresh no-auth install (needs-setup); cleared otherwise so existing
|
||||
// configured deployments keep their prior open behavior. On failure the
|
||||
// gate stays 'pending' (fail-closed), never silently open.
|
||||
void (async () => {
|
||||
try {
|
||||
if (authActive) {
|
||||
clearSetupToken(setupDataDir);
|
||||
return;
|
||||
}
|
||||
const { isLlmConfigured } = await import('../../scripts/setup-lib.mjs');
|
||||
const workers = opts.configManager?.getConfig().provider?.workers ?? [];
|
||||
if (!isLlmConfigured(workers)) {
|
||||
const token = ensureSetupToken(setupDataDir);
|
||||
setupGateState.mode = 'token-required';
|
||||
logger.info(`[setup] open the UI and enter this setup token: ${token}`);
|
||||
logger.info('[setup] llm configured=false → wizard active');
|
||||
} else {
|
||||
clearSetupToken(setupDataDir);
|
||||
setupGateState.mode = 'open';
|
||||
logger.info('[setup] llm configured=true → wizard off');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`[setup] token lifecycle failed: ${String(e)}`);
|
||||
}
|
||||
})();
|
||||
|
||||
// No-auth /api/config auth-write gate (only mounted in no-auth mode).
|
||||
if (!authActive) {
|
||||
app.use('/api/config', buildNoAuthConfigGate(setupDataDir, setupGateState));
|
||||
}
|
||||
|
||||
mountSetupApi(app, opts.configManager, {
|
||||
authActive,
|
||||
listenPort: setupListenPort,
|
||||
dataDir: setupDataDir,
|
||||
});
|
||||
|
||||
mountConfigApi(app, opts.configManager);
|
||||
}
|
||||
|
||||
|
||||
428
src/bridge/setup-api.test.ts
Normal file
428
src/bridge/setup-api.test.ts
Normal file
@ -0,0 +1,428 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { mkdtempSync, writeFileSync, existsSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { ConfigManager } from '../config-manager.js';
|
||||
import {
|
||||
mountSetupApi,
|
||||
buildNoAuthConfigGate,
|
||||
newNoAuthConfigGateState,
|
||||
type NoAuthConfigGateState,
|
||||
ensureSetupToken,
|
||||
readSetupToken,
|
||||
clearSetupToken,
|
||||
setupTokenPath,
|
||||
tokensMatch,
|
||||
} from './setup-api.js';
|
||||
import { mountConfigApi } from './config-api.js';
|
||||
|
||||
const UNCONFIGURED = 'config_version: 2\n';
|
||||
const CONFIGURED = [
|
||||
'config_version: 2',
|
||||
'llm:',
|
||||
' workers:',
|
||||
' - id: w1',
|
||||
' connection_type: direct',
|
||||
' endpoint: http://localhost:11434/v1',
|
||||
' model: llama3',
|
||||
' roles: [auto, fast, quality, title, reflection]',
|
||||
' max_concurrency: 1',
|
||||
' enabled: true',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function makeApp(
|
||||
yaml: string,
|
||||
opts: { authActive?: boolean; withConfigApi?: boolean; gateMode?: NoAuthConfigGateState['mode'] } = {},
|
||||
) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'setup-api-'));
|
||||
writeFileSync(join(dir, 'config.yaml'), yaml);
|
||||
const cm = new ConfigManager(join(dir, 'config.yaml'));
|
||||
const dataDir = join(dir, 'data');
|
||||
const authActive = opts.authActive === true;
|
||||
const gateState = newNoAuthConfigGateState();
|
||||
gateState.mode = opts.gateMode ?? 'token-required';
|
||||
const app = express();
|
||||
if (opts.withConfigApi) {
|
||||
app.use('/api/config', express.json());
|
||||
if (!authActive) app.use('/api/config', buildNoAuthConfigGate(dataDir, gateState));
|
||||
}
|
||||
mountSetupApi(app, cm, { authActive, listenPort: 9876, dataDir, deployHint: 'docker' });
|
||||
if (opts.withConfigApi) mountConfigApi(app, cm);
|
||||
return { app, cm, dir, dataDir, gateState };
|
||||
}
|
||||
|
||||
describe('setup-api: token helpers', () => {
|
||||
let dataDir: string;
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'setup-tok-'));
|
||||
});
|
||||
|
||||
it('ensureSetupToken creates a 0600 file and reuses it on second call', () => {
|
||||
const t1 = ensureSetupToken(dataDir);
|
||||
expect(t1).toMatch(/^[0-9a-f]{64}$/);
|
||||
const mode = statSync(setupTokenPath(dataDir)).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
const t2 = ensureSetupToken(dataDir);
|
||||
expect(t2).toBe(t1); // reuse, no regenerate race
|
||||
expect(readSetupToken(dataDir)).toBe(t1);
|
||||
});
|
||||
|
||||
it('readSetupToken returns null when absent and clearSetupToken is idempotent', () => {
|
||||
expect(readSetupToken(dataDir)).toBeNull();
|
||||
expect(() => clearSetupToken(dataDir)).not.toThrow();
|
||||
ensureSetupToken(dataDir);
|
||||
expect(existsSync(setupTokenPath(dataDir))).toBe(true);
|
||||
clearSetupToken(dataDir);
|
||||
expect(existsSync(setupTokenPath(dataDir))).toBe(false);
|
||||
expect(() => clearSetupToken(dataDir)).not.toThrow();
|
||||
});
|
||||
|
||||
it('tokensMatch is true only for an exact match (length-normalized)', () => {
|
||||
const t = 'a'.repeat(64);
|
||||
expect(tokensMatch(t, t)).toBe(true);
|
||||
expect(tokensMatch('a'.repeat(63), t)).toBe(false); // length mismatch
|
||||
expect(tokensMatch('b'.repeat(64), t)).toBe(false); // same length, wrong
|
||||
expect(tokensMatch('', t)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-api: GET /api/setup/status', () => {
|
||||
it('reports needsSetup=true with tokenRequired when fresh + token present', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED);
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
needsSetup: true,
|
||||
authActive: false,
|
||||
port: 9876,
|
||||
deployHint: 'docker',
|
||||
tokenRequired: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports needsSetup=false when an LLM worker is configured', async () => {
|
||||
const { app } = makeApp(CONFIGURED);
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.needsSetup).toBe(false);
|
||||
});
|
||||
|
||||
it('tokenRequired=false when auth is active', async () => {
|
||||
const { app } = makeApp(UNCONFIGURED, { authActive: true });
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
expect(res.body.authActive).toBe(true);
|
||||
expect(res.body.tokenRequired).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-api: token gate on /api/setup/apply', () => {
|
||||
it('403 without a token header', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED);
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).post('/api/setup/apply').send({ port: 1234 });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 with a wrong-length token (timingSafeEqual path)', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED);
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', 'short').send({ port: 1234 });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('410 once an LLM worker is configured (closed)', async () => {
|
||||
const { app, dataDir } = makeApp(CONFIGURED);
|
||||
ensureSetupToken(dataDir);
|
||||
const token = readSetupToken(dataDir)!;
|
||||
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({ port: 1234 });
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
|
||||
it('410 when auth is active', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { authActive: true });
|
||||
ensureSetupToken(dataDir);
|
||||
const token = readSetupToken(dataDir)!;
|
||||
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({ port: 1234 });
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-api: no-auth /api/config auth-write gate (Codex P1 #2/#3)', () => {
|
||||
it('blocks an auth.* PUT without a token in token-required mode', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows an auth.* PUT with the correct token', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
ensureSetupToken(dataDir);
|
||||
const token = readSetupToken(dataDir)!;
|
||||
const res = await request(app).put('/api/config').set('X-Setup-Token', token).send({ auth: { local: { enabled: false } } });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('does NOT gate non-auth writes (no Settings regression) even in token-required mode', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).put('/api/config').send({ server: { port: 5555 } });
|
||||
expect(res.status).toBe(200); // no token needed for a non-auth write
|
||||
});
|
||||
|
||||
it('fails closed (503) on an auth.* write while the boot lifecycle is pending (boot TOCTOU)', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'pending' });
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it('refuses an auth.* write after the token is cleared (completing setup cannot reopen the hole)', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
// token-required but no token file present (post-apply state)
|
||||
expect(readSetupToken(dataDir)).toBeNull();
|
||||
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows auth.* writes in 'open' mode (existing configured no-auth deployment, no regression)", async () => {
|
||||
const { app } = makeApp(CONFIGURED, { withConfigApi: true, gateMode: 'open' });
|
||||
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('leaves GET /api/config open', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
ensureSetupToken(dataDir);
|
||||
const res = await request(app).get('/api/config');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /api/config masks the bootstrap admin password after a local-auth apply (P1 #1)', async () => {
|
||||
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
|
||||
const token = ensureSetupToken(dataDir);
|
||||
const applied = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({
|
||||
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
|
||||
auth: { mode: 'local', local: { email: 'admin@x.io', password: 'supersecret9' } },
|
||||
});
|
||||
expect(applied.status).toBe(200);
|
||||
const res = await request(app).get('/api/config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(JSON.stringify(res.body)).not.toContain('supersecret9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-api: POST /api/setup/apply', () => {
|
||||
function freshApp() {
|
||||
const ctx = makeApp(UNCONFIGURED);
|
||||
const token = ensureSetupToken(ctx.dataDir);
|
||||
return { ...ctx, token };
|
||||
}
|
||||
|
||||
it('LLM-only apply reflects a camelCase worker and clears the token (restart not required)', async () => {
|
||||
const { app, cm, dataDir, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'qwen3' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true, restartRequired: false });
|
||||
const workers = cm.getConfig().provider.workers ?? [];
|
||||
const w = workers.find((x) => x.model === 'qwen3');
|
||||
// If the camelCase keys had been double-converted (P1 #1 regression), the
|
||||
// worker's endpoint/model/roles would not survive into provider.workers.
|
||||
expect(w).toBeTruthy();
|
||||
expect(w!.endpoint).toBe('http://localhost:11434/v1');
|
||||
expect(w!.proxy).not.toBe(true); // connectionType:'direct' → proxy omitted/false
|
||||
expect(w!.roles).toEqual(expect.arrayContaining(['auto', 'fast', 'quality', 'title', 'reflection']));
|
||||
// window closed: token removed so no-auth /api/config reverts to open
|
||||
expect(readSetupToken(dataDir)).toBeNull();
|
||||
});
|
||||
|
||||
it('gateway apply requires an apiKey', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ llm: { connectionType: 'aao_gateway', endpoint: 'http://gw:4000/v1', model: 'm' } });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('port apply sets restartRequired and keeps the token', async () => {
|
||||
const { app, cm, dataDir, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' }, port: 8088 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.restartRequired).toBe(true);
|
||||
expect(cm.getConfig().server?.port).toBe(8088);
|
||||
expect(readSetupToken(dataDir)).not.toBeNull(); // kept until restart
|
||||
});
|
||||
|
||||
it('rejects an incomplete local auth block (shared validator)', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ auth: { mode: 'local', local: { email: 'a@b.c' } } }); // no password
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an incomplete oauth block', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ auth: { mode: 'oauth', oauth: { provider: 'gitea', clientId: 'x', clientSecret: 'y', callbackUrl: 'http://h/cb' } } }); // gitea needs baseUrl
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects oauth without an admin email (would brick: no admin after restart)', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({
|
||||
auth: { mode: 'oauth', oauth: { provider: 'google', clientId: 'x', clientSecret: 'y', callbackUrl: 'http://h/cb' } },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.error)).toMatch(/admin email/i);
|
||||
});
|
||||
|
||||
it('applies a complete oauth block and writes auth.adminEmails', async () => {
|
||||
const { app, cm, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({
|
||||
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
|
||||
auth: {
|
||||
mode: 'oauth',
|
||||
oauth: { provider: 'google', clientId: 'cid', clientSecret: 'sec', callbackUrl: 'http://h/cb', adminEmail: 'boss@x.io' },
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.restartRequired).toBe(true);
|
||||
expect(res.body.adminEmail).toBe('boss@x.io');
|
||||
expect(JSON.stringify(res.body)).not.toContain('sec'); // client secret not echoed
|
||||
const auth = cm.getConfig().auth;
|
||||
expect(auth?.adminEmails).toContain('boss@x.io');
|
||||
expect(auth?.providers?.google?.clientId).toBe('cid');
|
||||
});
|
||||
|
||||
it('applies a complete local auth block, returns adminEmail, never echoes the password', async () => {
|
||||
const { app, cm, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({
|
||||
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
|
||||
auth: { mode: 'local', local: { email: 'admin@x.io', password: 'supersecret1' } },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.restartRequired).toBe(true);
|
||||
expect(res.body.adminEmail).toBe('admin@x.io');
|
||||
expect(JSON.stringify(res.body)).not.toContain('supersecret1');
|
||||
const auth = cm.getConfig().auth;
|
||||
expect(auth?.local?.enabled).toBe(true);
|
||||
expect(auth?.local?.bootstrapAdmin?.email).toBe('admin@x.io');
|
||||
});
|
||||
|
||||
it('400 when nothing to apply', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a link-local / metadata LLM endpoint (apply SSRF denylist, parity with probe)', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ llm: { connectionType: 'direct', endpoint: 'http://169.254.169.254/v1', model: 'm' } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.error)).toMatch(/not allowed|blocked/);
|
||||
});
|
||||
|
||||
it('rejects an over-long local auth password', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/apply')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({
|
||||
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
|
||||
auth: { mode: 'local', local: { email: 'a@b.c', password: 'x'.repeat(2000) } },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-api: POST /api/setup/probe', () => {
|
||||
function freshApp() {
|
||||
const ctx = makeApp(UNCONFIGURED);
|
||||
const token = ensureSetupToken(ctx.dataDir);
|
||||
return { ...ctx, token };
|
||||
}
|
||||
|
||||
it('400 on an invalid connectionType', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/probe')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ connectionType: 'bogus', endpoint: 'http://localhost:11434' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('400 + blocked message for the cloud metadata IP (SSRF denylist)', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/probe')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ connectionType: 'direct', endpoint: 'http://169.254.169.254/v1' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.error)).toMatch(/not allowed|blocked/);
|
||||
});
|
||||
|
||||
describe('against a stub Ollama server', () => {
|
||||
let server: Server;
|
||||
let url: string;
|
||||
beforeEach(async () => {
|
||||
server = createServer((req, res) => {
|
||||
if (req.url === '/api/tags') {
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ models: [{ name: 'llama3' }, { name: 'qwen3' }] }));
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end('no');
|
||||
}
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||
const addr = server.address();
|
||||
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||
url = `http://127.0.0.1:${port}/v1`;
|
||||
});
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
});
|
||||
|
||||
it('returns the model list from the live endpoint', async () => {
|
||||
const { app, token } = freshApp();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/probe')
|
||||
.set('X-Setup-Token', token)
|
||||
.send({ connectionType: 'direct', endpoint: url });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.models).toEqual(['llama3', 'qwen3']);
|
||||
});
|
||||
});
|
||||
});
|
||||
552
src/bridge/setup-api.ts
Normal file
552
src/bridge/setup-api.ts
Normal file
@ -0,0 +1,552 @@
|
||||
// Browser setup wizard server API. Mounts three endpoints that let a fresh,
|
||||
// no-auth `docker compose up` install configure its LLM connection (+ optional
|
||||
// port / auth) from the browser, without hand-editing config.yaml.
|
||||
//
|
||||
// GET /api/setup/status — public; never returns secrets
|
||||
// POST /api/setup/probe — setup-token gated; live model probe (SSRF-guarded)
|
||||
// POST /api/setup/apply — setup-token gated; writes a narrow config patch
|
||||
//
|
||||
// Security model (see docs/superpowers/specs/2026-06-16-browser-setup-wizard-design.md):
|
||||
// during the no-auth window a host-readable one-time token (data/.setup-token)
|
||||
// gates every mutating path — including no-auth `/api/config` mutations (Codex
|
||||
// P1 #3) — so that exposing the port doesn't let an anonymous caller bootstrap
|
||||
// an admin via `auth.local.bootstrapAdmin`. Token comparison is constant-time.
|
||||
//
|
||||
// The pure LLM logic (parseEndpoint / probeModels / camelCase worker builder /
|
||||
// isLlmConfigured) is shared with the CLI wizard via scripts/setup-lib.mjs,
|
||||
// imported at runtime by the SAME relative path from dev and dist layouts
|
||||
// (Codex P2 #7).
|
||||
|
||||
import express, { type Application, type Request, type Response, type RequestHandler } from 'express';
|
||||
import {
|
||||
closeSync,
|
||||
constants as fsConstants,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { ConfigManager } from '../config-manager.js';
|
||||
import type { AuthProviderConfig } from '../config.js';
|
||||
import { isProviderConfigured } from './auth.js';
|
||||
import { isPrivateOrForbidden, pinnedFetch } from '../net/ssrf-strict.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared setup-core loader (CLI + browser share ONE implementation).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SetupLib = typeof import('../../scripts/setup-lib.mjs');
|
||||
let _setupLib: Promise<SetupLib> | null = null;
|
||||
function setupLib(): Promise<SetupLib> {
|
||||
return (_setupLib ??= import('../../scripts/setup-lib.mjs'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup-token persistence (Codex P1 #4).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SETUP_TOKEN_FILE = '.setup-token';
|
||||
|
||||
export function setupTokenPath(dataDir: string): string {
|
||||
return join(dataDir, SETUP_TOKEN_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the one-time setup token atomically (O_CREAT|O_EXCL, 0600) or reuse an
|
||||
* existing one. Reusing on EEXIST avoids a generate race across boots/processes
|
||||
* that would otherwise print one token but persist another. Call ONLY during the
|
||||
* no-auth + needs-setup window.
|
||||
*/
|
||||
export function ensureSetupToken(dataDir: string, _attempt = 0): string {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const p = setupTokenPath(dataDir);
|
||||
try {
|
||||
const fd = openSync(p, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
|
||||
try {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
writeSync(fd, token);
|
||||
return token;
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
const existing = readFileSync(p, 'utf8').trim();
|
||||
if (existing) return existing;
|
||||
// Empty/corrupt file — replace it. Bound the retry so a pathological
|
||||
// FS (file keeps reappearing empty, or unlink/create racing) can't spin
|
||||
// forever.
|
||||
if (_attempt >= 3) {
|
||||
throw new Error('[setup] could not initialize the setup token (empty token file kept reappearing)');
|
||||
}
|
||||
unlinkSync(p);
|
||||
return ensureSetupToken(dataDir, _attempt + 1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function readSetupToken(dataDir: string): string | null {
|
||||
try {
|
||||
const t = readFileSync(setupTokenPath(dataDir), 'utf8').trim();
|
||||
return t || null;
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSetupToken(dataDir: string): void {
|
||||
try {
|
||||
unlinkSync(setupTokenPath(dataDir));
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Constant-time token compare with length normalization (no early return). */
|
||||
export function tokensMatch(provided: string, actual: string): boolean {
|
||||
const a = Buffer.from(provided, 'utf8');
|
||||
const b = Buffer.from(actual, 'utf8');
|
||||
if (a.length !== b.length) {
|
||||
// Burn an equal-length compare so a length mismatch isn't faster.
|
||||
timingSafeEqual(b, b);
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSRF guard for probe (Codex P1 #6).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SSRF policy for probe/apply targets. We ALLOW the safe-private ranges a
|
||||
// self-hosted LLM legitimately uses (loopback, RFC1918, CGNAT, IPv6 ULA) but
|
||||
// BLOCK everything isPrivateOrForbidden() flags as dangerous: link-local /
|
||||
// cloud-metadata (169.254.0.0/16 incl. 169.254.169.254 + IMDS), IPv4-mapped,
|
||||
// NAT64, AWS IMDS IPv6, multicast, reserved, broadcast. The block set is the
|
||||
// vetted ssrf-strict policy MINUS the safe-private subset, so any new dangerous
|
||||
// range added there is inherited here automatically.
|
||||
function isSafePrivate(ip: string, family: 4 | 6): boolean {
|
||||
const a = ip.toLowerCase();
|
||||
if (family === 4) {
|
||||
return (
|
||||
/^127\./.test(a) || // loopback
|
||||
/^10\./.test(a) || // RFC1918
|
||||
/^192\.168\./.test(a) || // RFC1918
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(a) || // RFC1918 172.16/12
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a) // 100.64.0.0/10 CGNAT
|
||||
);
|
||||
}
|
||||
if (a === '::1') return true; // loopback
|
||||
if (/^f[cd]/.test(a)) return true; // fc00::/7 ULA
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBlockedProbeAddress(ip: string, family: 4 | 6): boolean {
|
||||
return isPrivateOrForbidden(ip, family) && !isSafePrivate(ip, family);
|
||||
}
|
||||
|
||||
// Resolve the endpoint host ONCE, vet every resolved address, and return the
|
||||
// pinned IP. The probe fetch then connects to THIS IP (via pinnedFetch) so a
|
||||
// rebinding DNS server can't pass a benign IP at check time and serve a
|
||||
// metadata IP at fetch time (Codex P1 #4 TOCTOU). Brackets/zone-ids are
|
||||
// stripped so an IPv6 literal like `[fe80::1]` or `[::ffff:a9fe:a9fe]` can't
|
||||
// slip past the address check. Literal IPs skip DNS.
|
||||
async function resolveProbeTarget(
|
||||
hostname: string,
|
||||
): Promise<{ ok: true; ip: string; family: 4 | 6 } | { ok: false; reason: string }> {
|
||||
const host = hostname.replace(/^\[/, '').replace(/\]$/, '').split('%')[0];
|
||||
const lit = isIP(host);
|
||||
if (lit === 4 || lit === 6) {
|
||||
const fam = lit as 4 | 6;
|
||||
if (isBlockedProbeAddress(host, fam)) return { ok: false, reason: 'endpoint host is not allowed' };
|
||||
return { ok: true, ip: host, family: fam };
|
||||
}
|
||||
let addrs: Array<{ address: string; family: number }>;
|
||||
try {
|
||||
addrs = await dnsLookup(host, { all: true });
|
||||
} catch {
|
||||
return { ok: false, reason: 'could not resolve endpoint host' };
|
||||
}
|
||||
if (!addrs.length) return { ok: false, reason: 'endpoint host did not resolve' };
|
||||
for (const a of addrs) {
|
||||
if (isBlockedProbeAddress(a.address, a.family as 4 | 6)) {
|
||||
return { ok: false, reason: 'endpoint resolves to a blocked address' };
|
||||
}
|
||||
}
|
||||
return { ok: true, ip: addrs[0].address, family: addrs[0].family as 4 | 6 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth block validation — shares completeness logic with boot fail-closed
|
||||
// (Codex P1 #5). We only persist a block that WILL make auth active next boot.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AuthApplyInput {
|
||||
mode?: unknown;
|
||||
local?: { email?: unknown; password?: unknown; allowSignup?: unknown };
|
||||
oauth?: {
|
||||
provider?: unknown;
|
||||
clientId?: unknown;
|
||||
clientSecret?: unknown;
|
||||
callbackUrl?: unknown;
|
||||
baseUrl?: unknown;
|
||||
adminEmail?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type AuthValidation =
|
||||
| { ok: true; patch: Record<string, unknown>; adminEmail?: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function validateAuthBlock(auth: AuthApplyInput): AuthValidation {
|
||||
const mode = auth.mode;
|
||||
if (mode === 'local') {
|
||||
const email = String(auth.local?.email ?? '').trim();
|
||||
const password = String(auth.local?.password ?? '');
|
||||
if (!email || !password) return { ok: false, error: 'local auth requires email and password' };
|
||||
if (password.length < 8) return { ok: false, error: 'local auth password must be at least 8 characters' };
|
||||
if (password.length > 1024) return { ok: false, error: 'local auth password is too long (max 1024 characters)' };
|
||||
return {
|
||||
ok: true,
|
||||
adminEmail: email,
|
||||
patch: {
|
||||
local: {
|
||||
enabled: true,
|
||||
allowSignup: auth.local?.allowSignup === true,
|
||||
bootstrapAdmin: { email, password },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (mode === 'oauth') {
|
||||
const o = auth.oauth ?? {};
|
||||
const provider = o.provider;
|
||||
if (provider !== 'google' && provider !== 'gitea') {
|
||||
return { ok: false, error: 'oauth provider must be google or gitea' };
|
||||
}
|
||||
const candidate: AuthProviderConfig = {
|
||||
clientId: o.clientId ? String(o.clientId) : '',
|
||||
clientSecret: o.clientSecret ? String(o.clientSecret) : '',
|
||||
callbackUrl: o.callbackUrl ? String(o.callbackUrl) : '',
|
||||
...(provider === 'gitea' ? { baseUrl: o.baseUrl ? String(o.baseUrl) : '' } : {}),
|
||||
};
|
||||
// Reuse the exact boot-time completeness check so we never write a partial
|
||||
// OAuth block that would fail-closed (refuse to boot) on restart.
|
||||
if (!isProviderConfigured(candidate, provider)) {
|
||||
return { ok: false, error: `oauth ${provider} config is incomplete (need clientId, clientSecret, callbackUrl${provider === 'gitea' ? ', baseUrl' : ''})` };
|
||||
}
|
||||
// Require an admin email and write it into auth.adminEmails. Without this,
|
||||
// the first OAuth user logs in as 'pending' with NO admin existing — the
|
||||
// operator can't reach Settings/config and the instance is bricked until a
|
||||
// manual config edit (Codex P2 #5). adminEmails auto-promotes a matching
|
||||
// pending user to admin on first login (auth.ts).
|
||||
const adminEmail = String(auth.oauth?.adminEmail ?? '').trim();
|
||||
if (!adminEmail || !adminEmail.includes('@')) {
|
||||
return { ok: false, error: 'oauth setup requires an admin email (the account that should become admin on first login)' };
|
||||
}
|
||||
return { ok: true, adminEmail, patch: { providers: { [provider]: candidate }, adminEmails: [adminEmail] } };
|
||||
}
|
||||
return { ok: false, error: "auth.mode must be 'local' or 'oauth'" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mount.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MountSetupApiOptions {
|
||||
/** True when an OAuth provider or local auth is active (no setup window). */
|
||||
authActive: boolean;
|
||||
/** Resolved listen port (for the restart hint shown by the wizard). */
|
||||
listenPort: number;
|
||||
/** Directory holding data/.setup-token (defaults derived by caller). */
|
||||
dataDir: string;
|
||||
/** Optional override; auto-detected from /.dockerenv otherwise. */
|
||||
deployHint?: 'docker' | 'source';
|
||||
}
|
||||
|
||||
function detectDeployHint(): 'docker' | 'source' {
|
||||
return existsSync('/.dockerenv') ? 'docker' : 'source';
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute needs-setup on EVERY request (never cache): the moment the runtime
|
||||
* has a usable LLM worker, probe/apply close (410). Operates on the resolved
|
||||
* `provider.workers` so the synthetic empty-model default and OLLAMA_* env
|
||||
* overrides are accounted for (Codex P1 #2).
|
||||
*/
|
||||
async function computeNeedsSetup(configManager: ConfigManager): Promise<boolean> {
|
||||
const { isLlmConfigured } = await setupLib();
|
||||
const workers = configManager.getConfig().provider?.workers ?? [];
|
||||
return !isLlmConfigured(workers);
|
||||
}
|
||||
|
||||
export function mountSetupApi(
|
||||
app: Application,
|
||||
configManager: ConfigManager,
|
||||
opts: MountSetupApiOptions,
|
||||
): void {
|
||||
const { authActive, listenPort, dataDir } = opts;
|
||||
const deployHint = opts.deployHint ?? detectDeployHint();
|
||||
|
||||
// GET status — public, no secrets. Drives the UI gate.
|
||||
app.get('/api/setup/status', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const needsSetup = await computeNeedsSetup(configManager);
|
||||
const tokenRequired = !authActive && readSetupToken(dataDir) !== null;
|
||||
res.json({ needsSetup, authActive, port: listenPort, deployHint, tokenRequired });
|
||||
} catch (e) {
|
||||
logger.warn(`[setup-api] status failed: ${String(e)}`);
|
||||
res.status(500).json({ needsSetup: false, authActive, error: 'status unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// Token gate for probe/apply. "Closed" (410) takes precedence over
|
||||
// "forbidden" (403) so a configured/auth-active server signals closure
|
||||
// regardless of the supplied token.
|
||||
const requireSetupToken: RequestHandler = (req, res, next) => {
|
||||
void (async () => {
|
||||
if (authActive) {
|
||||
res.status(410).json({ ok: false, error: 'setup is closed (auth active)' });
|
||||
return;
|
||||
}
|
||||
if (!(await computeNeedsSetup(configManager))) {
|
||||
res.status(410).json({ ok: false, error: 'setup is closed (llm already configured)' });
|
||||
return;
|
||||
}
|
||||
const actual = readSetupToken(dataDir);
|
||||
if (!actual) {
|
||||
res.status(410).json({ ok: false, error: 'setup is closed' });
|
||||
return;
|
||||
}
|
||||
const provided = String(req.header('x-setup-token') ?? '');
|
||||
if (!provided || !tokensMatch(provided, actual)) {
|
||||
res.status(403).json({ ok: false, error: 'invalid setup token' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
})().catch((e) => {
|
||||
logger.warn(`[setup-api] token gate failed: ${String(e)}`);
|
||||
res.status(500).json({ ok: false, error: 'setup gate error' });
|
||||
});
|
||||
};
|
||||
|
||||
// POST probe — live model discovery, SSRF-guarded.
|
||||
app.post('/api/setup/probe', express.json(), requireSetupToken, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { CONNECTION_TYPES, parseEndpoint, probeModels } = await setupLib();
|
||||
const body = (req.body ?? {}) as { connectionType?: unknown; endpoint?: unknown; apiKey?: unknown };
|
||||
const connectionType = String(body.connectionType ?? '');
|
||||
if (!CONNECTION_TYPES.includes(connectionType as never)) {
|
||||
res.status(400).json({ ok: false, error: `connectionType must be one of ${CONNECTION_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const ep = parseEndpoint(body.endpoint);
|
||||
if (ep.error || !ep.endpoint) {
|
||||
res.status(400).json({ ok: false, error: ep.error ?? 'invalid endpoint' });
|
||||
return;
|
||||
}
|
||||
const apiKey = body.apiKey ? String(body.apiKey) : undefined;
|
||||
if (connectionType === 'aao_gateway' && !apiKey) {
|
||||
res.status(400).json({ ok: false, error: 'apiKey is required for aao_gateway' });
|
||||
return;
|
||||
}
|
||||
const target = await resolveProbeTarget(new URL(ep.endpoint).hostname);
|
||||
if (!target.ok) {
|
||||
res.status(400).json({ ok: false, models: [], error: target.reason });
|
||||
return;
|
||||
}
|
||||
// Pin the connection to the vetted IP and forbid redirects so a 3xx can't
|
||||
// bounce to an internal target.
|
||||
const pinned: typeof fetch = (url, init) =>
|
||||
pinnedFetch(String(url), {
|
||||
...(init as RequestInit),
|
||||
pinnedIp: target.ip,
|
||||
family: target.family,
|
||||
redirect: 'manual',
|
||||
});
|
||||
const result = await probeModels({ endpoint: ep.endpoint, base: ep.base, apiKey, fetchImpl: pinned });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
logger.warn(`[setup-api] probe failed: ${String(e)}`);
|
||||
res.status(500).json({ ok: false, models: [], error: 'probe failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST apply — narrow config patch (LLM + optional port + optional auth).
|
||||
app.post('/api/setup/apply', express.json(), requireSetupToken, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { CONNECTION_TYPES, parseEndpoint, buildLlmWorkerCamel } = await setupLib();
|
||||
const body = (req.body ?? {}) as {
|
||||
llm?: { connectionType?: unknown; endpoint?: unknown; model?: unknown; apiKey?: unknown };
|
||||
port?: unknown;
|
||||
auth?: AuthApplyInput;
|
||||
};
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
let restartRequired = false;
|
||||
let adminEmail: string | undefined;
|
||||
|
||||
// --- LLM (camelCase v2 shape; Codex P1 #1) ---
|
||||
if (body.llm) {
|
||||
const connectionType = String(body.llm.connectionType ?? '');
|
||||
if (!CONNECTION_TYPES.includes(connectionType as never)) {
|
||||
res.status(400).json({ ok: false, error: `llm.connectionType must be one of ${CONNECTION_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const ep = parseEndpoint(body.llm.endpoint);
|
||||
if (ep.error || !ep.endpoint) {
|
||||
res.status(400).json({ ok: false, error: `llm.endpoint: ${ep.error ?? 'invalid endpoint'}` });
|
||||
return;
|
||||
}
|
||||
const model = String(body.llm.model ?? '').trim();
|
||||
if (!model) {
|
||||
res.status(400).json({ ok: false, error: 'llm.model is required' });
|
||||
return;
|
||||
}
|
||||
const apiKey = body.llm.apiKey ? String(body.llm.apiKey) : undefined;
|
||||
if (connectionType === 'aao_gateway' && !apiKey) {
|
||||
res.status(400).json({ ok: false, error: 'llm.apiKey is required for aao_gateway' });
|
||||
return;
|
||||
}
|
||||
// Apply the same SSRF denylist as probe before PERSISTING the endpoint:
|
||||
// a token holder could otherwise skip probe and write a link-local /
|
||||
// metadata target straight into config, which every later worker LLM
|
||||
// call would then hit. Keeps probe and apply consistent.
|
||||
const epTarget = await resolveProbeTarget(new URL(ep.endpoint).hostname);
|
||||
if (!epTarget.ok) {
|
||||
res.status(400).json({ ok: false, error: `llm.endpoint: ${epTarget.reason}` });
|
||||
return;
|
||||
}
|
||||
const worker = buildLlmWorkerCamel({
|
||||
connectionType: connectionType as 'direct' | 'aao_gateway',
|
||||
endpoint: ep.endpoint,
|
||||
model,
|
||||
apiKey,
|
||||
});
|
||||
patch.llm = { workers: [worker] };
|
||||
}
|
||||
|
||||
// --- Port (optional; restart) ---
|
||||
if (body.port !== undefined && body.port !== null && body.port !== '') {
|
||||
const port = Number(body.port);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
res.status(400).json({ ok: false, error: 'port must be an integer 1-65535' });
|
||||
return;
|
||||
}
|
||||
patch.server = { port };
|
||||
restartRequired = true;
|
||||
}
|
||||
|
||||
// --- Auth (optional; restart) ---
|
||||
if (body.auth) {
|
||||
const v = validateAuthBlock(body.auth);
|
||||
if (!v.ok) {
|
||||
res.status(400).json({ ok: false, error: v.error });
|
||||
return;
|
||||
}
|
||||
patch.auth = v.patch;
|
||||
adminEmail = v.adminEmail;
|
||||
restartRequired = true;
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
res.status(400).json({ ok: false, error: 'nothing to apply' });
|
||||
return;
|
||||
}
|
||||
|
||||
const etag = configManager.getConfigForApi().etag;
|
||||
const result = configManager.updateConfig(patch, etag);
|
||||
if (!result.ok) {
|
||||
const status = (result as { conflict?: boolean }).conflict ? 409 : 400;
|
||||
res.status(status).json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// LLM-only apply with no restart: the setup window's job is done. Drop the
|
||||
// token so no-auth `/api/config` reverts to its prior (open) behavior and
|
||||
// probe/apply 410. When a restart IS required (port/auth), keep the token
|
||||
// until the next boot clears it — this also keeps the just-written
|
||||
// bootstrapAdmin protected against an overwrite in the no-auth gap.
|
||||
if (!restartRequired) clearSetupToken(dataDir);
|
||||
|
||||
logger.info(
|
||||
`[setup-api] apply ok llm=${!!patch.llm} port=${!!patch.server} auth=${!!patch.auth} restart=${restartRequired}`,
|
||||
);
|
||||
res.json({ ok: true, restartRequired, ...(adminEmail ? { adminEmail } : {}) });
|
||||
} catch (e) {
|
||||
logger.warn(`[setup-api] apply failed: ${String(e)}`);
|
||||
res.status(500).json({ ok: false, error: 'apply failed' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-resolved state for the no-auth `/api/config` gate. Starts 'pending'
|
||||
* (set synchronously at mount, BEFORE the async boot lifecycle determines
|
||||
* fresh-vs-configured) so a request landing in that window can't slip through
|
||||
* (Codex P1 #2 boot TOCTOU). The lifecycle flips it to:
|
||||
* - 'token-required' on a fresh no-auth install (a setup token was created)
|
||||
* - 'open' on a deployment that already had an LLM configured at boot
|
||||
*/
|
||||
export interface NoAuthConfigGateState {
|
||||
mode: 'pending' | 'open' | 'token-required';
|
||||
}
|
||||
|
||||
export function newNoAuthConfigGateState(): NoAuthConfigGateState {
|
||||
return { mode: 'pending' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard no-auth `/api/config` mutations that would change `auth.*` (the only
|
||||
* privilege-escalation vector in no-auth mode — writing bootstrapAdmin/OAuth
|
||||
* to seize admin on the next restart). NON-auth writes (llm/port/tools) keep
|
||||
* the prior open no-auth behavior, so the Settings UI is not regressed.
|
||||
*
|
||||
* - 'pending' → fail closed (503): the boot lifecycle hasn't resolved
|
||||
* yet, so we must not let an auth write through during the boot race.
|
||||
* - 'open' → allow (a deployment already configured at boot keeps its
|
||||
* prior behavior; this is the no-regression path).
|
||||
* - 'token-required' → require a valid setup token for auth writes. Once the
|
||||
* token is gone (cleared after setup completes) auth writes are refused
|
||||
* outright, so completing the wizard can't reopen the bootstrap hole
|
||||
* (Codex P1 #3). GET/HEAD always pass so the UI can read config.
|
||||
*/
|
||||
export function buildNoAuthConfigGate(dataDir: string, state: NoAuthConfigGateState): RequestHandler {
|
||||
return (req: Request, res: Response, next) => {
|
||||
if (req.method === 'GET' || req.method === 'HEAD') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const body = req.body as unknown;
|
||||
const touchesAuth = !!body && typeof body === 'object' && !Array.isArray(body) && 'auth' in (body as object);
|
||||
if (!touchesAuth) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (state.mode === 'pending') {
|
||||
res.status(503).json({ ok: false, error: 'setup initializing, retry shortly' });
|
||||
return;
|
||||
}
|
||||
if (state.mode === 'open') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// token-required
|
||||
const actual = readSetupToken(dataDir);
|
||||
const provided = String(req.header('x-setup-token') ?? '');
|
||||
if (actual && provided && tokensMatch(provided, actual)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({ ok: false, error: 'setup token required to change auth config during initial setup' });
|
||||
};
|
||||
}
|
||||
56
src/bridge/setup-dist-resolution.test.ts
Normal file
56
src/bridge/setup-dist-resolution.test.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Codex P2 #7: the setup wizard server shares its pure LLM logic with the CLI
|
||||
// via scripts/setup-lib.mjs, imported at RUNTIME by the relative specifier
|
||||
// `../../scripts/setup-lib.mjs`. Both the dev (src/bridge) and built
|
||||
// (dist/bridge) modules sit two levels under the repo root, so that specifier
|
||||
// resolves to <root>/scripts/setup-lib.mjs in either layout. If the file goes
|
||||
// missing from the runtime image (e.g. the Dockerfile stops copying it), the
|
||||
// setup endpoints 500 on first boot. These tests fix that contract.
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url)); // .../src/bridge
|
||||
const repoRoot = resolve(here, '../..');
|
||||
|
||||
describe('setup-lib distribution resolution (P2 #7)', () => {
|
||||
it('the shared .mjs exists at <root>/scripts and is reachable via ../../scripts from bridge', () => {
|
||||
// src/bridge and dist/bridge both resolve `../../scripts` to <root>/scripts.
|
||||
const fromBridge = resolve(here, '../../scripts/setup-lib.mjs');
|
||||
expect(fromBridge).toBe(resolve(repoRoot, 'scripts/setup-lib.mjs'));
|
||||
expect(existsSync(fromBridge)).toBe(true);
|
||||
});
|
||||
|
||||
it('importing the runtime specifier yields the exports setup-api depends on', async () => {
|
||||
const lib = await import('../../scripts/setup-lib.mjs');
|
||||
for (const name of ['isLlmConfigured', 'parseEndpoint', 'probeModels', 'buildLlmWorkerCamel', 'CONNECTION_TYPES']) {
|
||||
expect(lib[name as keyof typeof lib], `missing export ${name}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('the Dockerfile runtime stage copies the shared .mjs into the image', () => {
|
||||
const dockerfile = readFileSync(resolve(repoRoot, 'Dockerfile'), 'utf8');
|
||||
// Must appear AFTER the second FROM (runtime stage), not only in the builder.
|
||||
const stages = dockerfile.split(/^FROM /m);
|
||||
const runtimeStage = stages[stages.length - 1];
|
||||
expect(runtimeStage).toMatch(/COPY\s+scripts\/setup-lib\.mjs\s+\.\/scripts\/setup-lib\.mjs/);
|
||||
});
|
||||
|
||||
it('the Dockerfile ships an UNCONFIGURED default config.yaml so a fresh install shows the wizard', () => {
|
||||
const dockerfile = readFileSync(resolve(repoRoot, 'Dockerfile'), 'utf8');
|
||||
// Baking the fully-worked example as the live config (it has an enabled
|
||||
// localhost worker) would mark a fresh install "configured" and suppress
|
||||
// the wizard. The image must instead write a minimal config_version:2 file.
|
||||
expect(dockerfile).not.toMatch(/COPY\s+config\.yaml\.example\s+\.\/config\.yaml\s*$/m);
|
||||
expect(dockerfile).toMatch(/config_version:\s*2[^\n]*>\s*\.\/config\.yaml/);
|
||||
});
|
||||
|
||||
it('the shipped example IS configured (guards the test above from going stale)', () => {
|
||||
// Sanity: confirm config.yaml.example really does carry a usable worker, so
|
||||
// the "ship a minimal default instead" fix is actually load-bearing.
|
||||
const example = readFileSync(resolve(repoRoot, 'config.yaml.example'), 'utf8');
|
||||
expect(example).toMatch(/^\s*model:\s*\S+/m);
|
||||
expect(example).toMatch(/^\s*endpoint:\s*\S+/m);
|
||||
});
|
||||
});
|
||||
@ -55,6 +55,8 @@ const META_TOOLS = new Set<string>([
|
||||
'RunUserScript',
|
||||
'UpdateUserMemory',
|
||||
'ReadUserMemory',
|
||||
'ReadUserAgents',
|
||||
'UpdateUserAgents',
|
||||
'WriteUserScript',
|
||||
'Brainstorm',
|
||||
'ReadAppDoc',
|
||||
|
||||
@ -15,6 +15,10 @@ const SENSITIVE_PATHS = [
|
||||
'auth.sessionSecret',
|
||||
'auth.providers.google.clientSecret',
|
||||
'auth.providers.gitea.clientSecret',
|
||||
// The browser setup wizard writes a first-admin password here. In no-auth
|
||||
// mode GET /api/config is unauthenticated, so an unmasked value would let any
|
||||
// caller read the bootstrap admin password before the auth-enabling restart.
|
||||
'auth.local.bootstrapAdmin.password',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -1724,3 +1724,46 @@ describe('Repository title derivation from Mission Brief goal', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository.createJobIfNoPending', () => {
|
||||
let tempDir = '';
|
||||
afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } });
|
||||
function makeRepo(): Repository {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-pending-'));
|
||||
return new Repository(join(tempDir, 'orchestrator.db'));
|
||||
}
|
||||
|
||||
it('creates a job when none exists', async () => {
|
||||
const repo = makeRepo();
|
||||
try {
|
||||
const r = repo.createJobIfNoPending({ repo: 'local/task-1', issueNumber: 1, instruction: 'go' });
|
||||
expect(r.created).toBe(true);
|
||||
expect(r.job.status).toBe('queued');
|
||||
} finally { repo.close(); }
|
||||
});
|
||||
|
||||
it('reuses an existing queued/running job instead of creating a duplicate', async () => {
|
||||
const repo = makeRepo();
|
||||
try {
|
||||
const first = repo.createJobIfNoPending({ repo: 'local/task-2', issueNumber: 2, instruction: 'a' });
|
||||
const second = repo.createJobIfNoPending({ repo: 'local/task-2', issueNumber: 2, instruction: 'b' });
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.job.id).toBe(first.job.id);
|
||||
await repo.updateJob(first.job.id, { status: 'running' });
|
||||
const third = repo.createJobIfNoPending({ repo: 'local/task-2', issueNumber: 2, instruction: 'c' });
|
||||
expect(third.created).toBe(false);
|
||||
expect(third.job.id).toBe(first.job.id);
|
||||
} finally { repo.close(); }
|
||||
});
|
||||
|
||||
it('creates a fresh job once the previous one is terminal', async () => {
|
||||
const repo = makeRepo();
|
||||
try {
|
||||
const first = repo.createJobIfNoPending({ repo: 'local/task-3', issueNumber: 3, instruction: 'a' });
|
||||
await repo.updateJob(first.job.id, { status: 'succeeded' });
|
||||
const second = repo.createJobIfNoPending({ repo: 'local/task-3', issueNumber: 3, instruction: 'b' });
|
||||
expect(second.created).toBe(true);
|
||||
expect(second.job.id).not.toBe(first.job.id);
|
||||
} finally { repo.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
@ -1305,7 +1305,13 @@ export class Repository {
|
||||
logger.info('Repository: jobs table migration complete');
|
||||
}
|
||||
|
||||
async createJob(params: CreateJobParams): Promise<Job> {
|
||||
// Non-terminal job states. A task with a job in any of these already has work
|
||||
// pending/running, so a new user comment should be appended to it rather than
|
||||
// spawning a second job. waiting_human is excluded on purpose: a comment there
|
||||
// is the answer that resumes via a fresh job.
|
||||
private static readonly PENDING_JOB_STATES = ['queued', 'dispatching', 'running', 'waiting_subtasks', 'retry'] as const;
|
||||
|
||||
private insertJobSync(params: CreateJobParams): Job {
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const pieceName = params.pieceName ?? 'chat';
|
||||
@ -1346,6 +1352,35 @@ export class Repository {
|
||||
return job;
|
||||
}
|
||||
|
||||
async createJob(params: CreateJobParams): Promise<Job> {
|
||||
return this.insertJobSync(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reuse-or-create. If a non-terminal job already exists for the
|
||||
* issue, return it (created=false) without inserting; otherwise insert a new
|
||||
* queued job (created=true). better-sqlite3 transactions run synchronously, so
|
||||
* the check+insert can't interleave with a concurrent request — this closes
|
||||
* the TOCTOU race where two near-simultaneous comments each spawned a job,
|
||||
* leaving the newest (queued) duplicate as the task's latestJob while an older
|
||||
* job actually ran (status stuck on "Inbox").
|
||||
*/
|
||||
createJobIfNoPending(params: CreateJobParams): { job: Job; created: boolean } {
|
||||
const states = Repository.PENDING_JOB_STATES;
|
||||
const placeholders = states.map(() => '?').join(',');
|
||||
const tx = this.db.transaction((): { job: Job; created: boolean } => {
|
||||
const existing = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM jobs WHERE repo = ? AND issue_number = ? AND status IN (${placeholders})
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT 1`,
|
||||
)
|
||||
.get(params.repo, params.issueNumber, ...states) as JobRow | undefined;
|
||||
if (existing) return { job: rowToJob(existing), created: false };
|
||||
return { job: this.insertJobSync(params), created: true };
|
||||
});
|
||||
return tx();
|
||||
}
|
||||
|
||||
async getJob(id: string, opts?: { viewer?: Express.User }): Promise<Job | null> {
|
||||
const viewerClause = opts?.viewer
|
||||
? buildVisibilityWhere(opts.viewer, 'j')
|
||||
|
||||
138
src/engine/prompt-coach.test.ts
Normal file
138
src/engine/prompt-coach.test.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
buildCoachMessages,
|
||||
normalizeCoachResult,
|
||||
runPromptCoach,
|
||||
COACH_LIMITS,
|
||||
type PromptCoachDeps,
|
||||
} from './prompt-coach.js';
|
||||
|
||||
function makeDeps(over: Partial<PromptCoachDeps> = {}): PromptCoachDeps {
|
||||
return {
|
||||
userFolderRoot: '/data/users',
|
||||
pieceCatalog: {
|
||||
getForUser: () => [
|
||||
{ name: 'chat', description: '汎用の会話・調査タスク' },
|
||||
{ name: 'research', description: 'Web を調べてレポートにまとめる' },
|
||||
],
|
||||
},
|
||||
skillCatalog: {
|
||||
getForUser: () => [
|
||||
{ name: 'pdf-fill', description: 'PDF フォームに記入する' },
|
||||
],
|
||||
},
|
||||
readMemory: () => '- [Foo](foo.md) — ユーザーは日本語で回答を好む',
|
||||
readAgents: () => 'AGENTS: 常に結論から書く',
|
||||
callLlm: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const REQ = { instruction: 'PDF を読んで要約して', piece: undefined, userId: 'u1' };
|
||||
|
||||
describe('buildCoachMessages', () => {
|
||||
it('全ソースが揃っている時はそれぞれをプロンプトに含める', () => {
|
||||
const { system, user, pieceCandidates } = buildCoachMessages(makeDeps(), REQ);
|
||||
expect(system).toContain('日本語');
|
||||
expect(user).toContain('PDF を読んで要約して');
|
||||
// context sources
|
||||
expect(user).toContain('ユーザーは日本語で回答を好む'); // memory
|
||||
expect(user).toContain('常に結論から書く'); // agents
|
||||
expect(user).toContain('pdf-fill'); // skills
|
||||
expect(user).toContain('research'); // pieces
|
||||
expect(pieceCandidates).toEqual(['chat', 'research']);
|
||||
});
|
||||
|
||||
it('各ソースが欠損(null/空)でも例外なく組み立てる', () => {
|
||||
const deps = makeDeps({
|
||||
readMemory: () => null,
|
||||
readAgents: () => null,
|
||||
skillCatalog: { getForUser: () => [] },
|
||||
pieceCatalog: { getForUser: () => [] },
|
||||
});
|
||||
const { user, pieceCandidates } = buildCoachMessages(deps, REQ);
|
||||
expect(user).toContain('PDF を読んで要約して');
|
||||
expect(pieceCandidates).toEqual([]);
|
||||
});
|
||||
|
||||
it('巨大なソースは上限で切り詰める', () => {
|
||||
const huge = 'あ'.repeat(COACH_LIMITS.memoryChars + 5000);
|
||||
const deps = makeDeps({ readMemory: () => huge });
|
||||
const { user } = buildCoachMessages(deps, REQ);
|
||||
// memory section should not carry the full oversized blob
|
||||
expect(user.length).toBeLessThan(huge.length);
|
||||
});
|
||||
|
||||
it('下書き本文も上限で切り詰める', () => {
|
||||
const huge = 'x'.repeat(COACH_LIMITS.instructionChars + 5000);
|
||||
const { user } = buildCoachMessages(makeDeps(), { ...REQ, instruction: huge });
|
||||
expect(user.length).toBeLessThan(huge.length + 2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCoachResult', () => {
|
||||
const candidates = ['chat', 'research'];
|
||||
|
||||
it('スコアを範囲内にクランプし、配列を既定値で埋める', () => {
|
||||
const r = normalizeCoachResult(
|
||||
{
|
||||
overall: 250,
|
||||
axes: [{ name: '明確さ', score: 99, comment: 'ok' }],
|
||||
rewrite: '改善案',
|
||||
predicted_piece: { name: 'research', reason: 'Web 調査だから' },
|
||||
maestro_tips: [{ feature: '添付', suggestion: 'PDF を添付できます' }],
|
||||
personalized: ['既にスキル所有'],
|
||||
},
|
||||
candidates,
|
||||
);
|
||||
expect(r.overall).toBe(100);
|
||||
expect(r.axes[0].score).toBe(10);
|
||||
expect(r.predicted_piece).toEqual({ name: 'research', reason: 'Web 調査だから' });
|
||||
expect(r.maestro_tips).toHaveLength(1);
|
||||
expect(r.personalized).toEqual(['既にスキル所有']);
|
||||
});
|
||||
|
||||
it('候補に無い predicted_piece は null に落とす', () => {
|
||||
const r = normalizeCoachResult(
|
||||
{ overall: 50, rewrite: 'x', predicted_piece: { name: 'does-not-exist', reason: 'r' } },
|
||||
candidates,
|
||||
);
|
||||
expect(r.predicted_piece).toBeNull();
|
||||
});
|
||||
|
||||
it('壊れた/欠損フィールドでも安全な既定値を返す', () => {
|
||||
const r = normalizeCoachResult({}, candidates);
|
||||
expect(r.overall).toBe(0);
|
||||
expect(r.axes).toEqual([]);
|
||||
expect(r.rewrite).toBe('');
|
||||
expect(r.predicted_piece).toBeNull();
|
||||
expect(r.maestro_tips).toEqual([]);
|
||||
expect(r.personalized).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPromptCoach', () => {
|
||||
it('callLlm の生出力を正規化して返す', async () => {
|
||||
const deps = makeDeps({
|
||||
callLlm: vi.fn().mockResolvedValue({
|
||||
overall: 80,
|
||||
axes: [{ name: '具体性', score: 7, comment: '良い' }],
|
||||
rewrite: 'もっと具体的に',
|
||||
predicted_piece: { name: 'chat', reason: '汎用' },
|
||||
maestro_tips: [],
|
||||
personalized: [],
|
||||
}),
|
||||
});
|
||||
const r = await runPromptCoach(deps, REQ);
|
||||
expect(r.overall).toBe(80);
|
||||
expect(r.predicted_piece?.name).toBe('chat');
|
||||
expect(deps.callLlm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('LLM エラーは伝播する', async () => {
|
||||
const deps = makeDeps({
|
||||
callLlm: vi.fn().mockRejectedValue(new Error('LLM down')),
|
||||
});
|
||||
await expect(runPromptCoach(deps, REQ)).rejects.toThrow('LLM down');
|
||||
});
|
||||
});
|
||||
307
src/engine/prompt-coach.ts
Normal file
307
src/engine/prompt-coach.ts
Normal file
@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Prompt coach — on-demand, context-aware evaluation of a draft task prompt
|
||||
* before it is submitted from the create dialog.
|
||||
*
|
||||
* Goal: teach the user to write better prompts. Given a draft instruction we
|
||||
* read the user's own context (memory index, AGENTS.md, owned skills, visible
|
||||
* pieces) and ask a cheap model to score the draft, rewrite it, predict the
|
||||
* piece, and surface MAESTRO features the user might not know about.
|
||||
*
|
||||
* This module is deliberately free of HTTP / LLM-client wiring: the LLM call
|
||||
* is injected (`deps.callLlm`) so the prompt assembly and result normalization
|
||||
* are unit-testable without a backend. The route handler in
|
||||
* bridge/local-tasks-api.ts builds the concrete `callLlm` over the cheap
|
||||
* `titleClient`.
|
||||
*/
|
||||
|
||||
export interface PromptCoachAxis {
|
||||
name: string;
|
||||
score: number; // 0-10
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface PromptCoachPiecePrediction {
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PromptCoachTip {
|
||||
feature: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
export interface PromptCoachResult {
|
||||
/** Overall draft quality, 0-100. */
|
||||
overall: number;
|
||||
/** Per-axis scores: 明確さ / 具体性 / コンテキスト / 成果物指定. */
|
||||
axes: PromptCoachAxis[];
|
||||
/** A rewritten, improved version of the draft (Japanese). */
|
||||
rewrite: string;
|
||||
/** Predicted piece, constrained to the user's visible catalog, or null. */
|
||||
predicted_piece: PromptCoachPiecePrediction | null;
|
||||
/** MAESTRO feature suggestions (attachments, schedules, SSH, knowledge…). */
|
||||
maestro_tips: PromptCoachTip[];
|
||||
/** Notes grounded in the user's memory / AGENTS.md / skills. */
|
||||
personalized: string[];
|
||||
}
|
||||
|
||||
export interface PromptCoachRequest {
|
||||
instruction: string;
|
||||
/** Optional piece the user pre-selected in the dialog ('auto' = unset). */
|
||||
piece?: string;
|
||||
userId: string;
|
||||
/** Aborts the underlying LLM call when the route times out. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Minimal shape of the catalogs the coach reads (name + description). */
|
||||
export interface CatalogLike {
|
||||
getForUser(userId: string): Array<{ name: string; description: string }>;
|
||||
}
|
||||
|
||||
export interface PromptCoachDeps {
|
||||
userFolderRoot: string;
|
||||
pieceCatalog: CatalogLike;
|
||||
skillCatalog: CatalogLike;
|
||||
/**
|
||||
* Performs the actual cheap-model call. Receives the assembled prompts and
|
||||
* returns the raw parsed tool arguments (the model is forced to call the
|
||||
* submit_evaluation tool). Throws on LLM error.
|
||||
*/
|
||||
callLlm: (args: {
|
||||
system: string;
|
||||
user: string;
|
||||
userId: string;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<Record<string, unknown>>;
|
||||
/** Reads MEMORY.md one-line index. Injectable for tests. */
|
||||
readMemory?: (rootDir: string, userId: string) => string | null;
|
||||
/** Reads the user's AGENTS.md. Injectable for tests. */
|
||||
readAgents?: (rootDir: string, userId: string) => string | null;
|
||||
}
|
||||
|
||||
/** Size caps so the bundle fits a cheap model's context window. */
|
||||
export const COACH_LIMITS = {
|
||||
instructionChars: 8000,
|
||||
memoryChars: 4000,
|
||||
agentsChars: 4000,
|
||||
skillDescChars: 120,
|
||||
maxSkills: 60,
|
||||
pieceDescChars: 140,
|
||||
maxPieces: 80,
|
||||
} as const;
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s;
|
||||
return `${s.slice(0, max)}\n…[truncated]`;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
'あなたは MAESTRO(自律エージェント実行基盤)のプロンプト改善コーチです。',
|
||||
'ユーザーがタスクを送信する前の下書きを評価し、より良い書き方を教えて、プロンプト力を育てるのが目的です。',
|
||||
'出力は必ず submit_evaluation ツールを 1 回だけ呼んで返してください。すべて日本語で書きます。',
|
||||
'',
|
||||
'評価の観点(axes)は次の 4 つに固定:',
|
||||
'- 明確さ: 何をしてほしいかが曖昧でないか',
|
||||
'- 具体性: 対象・条件・範囲が具体的か',
|
||||
'- コンテキスト: 判断に必要な前提・背景が与えられているか',
|
||||
'- 成果物指定: 出力フォーマット・粒度・宛先が指定されているか',
|
||||
'各 axis は 0-10、overall は 0-100 で採点します。',
|
||||
'',
|
||||
'rewrite には、ユーザーがそのまま使える改善済みの依頼文を入れます。',
|
||||
'predicted_piece は、与えられた piece 候補の中から最も適切なものを 1 つ選び、name は候補名と完全一致させます。該当が無ければ null。',
|
||||
'maestro_tips では、この下書きで活かせる MAESTRO 機能(ファイル添付 / 定期実行スケジュール / SSH 操作 / ナレッジ検索 / サブタスク並列実行 など)を提案します。当てはまらなければ空配列。',
|
||||
'personalized では、ユーザーの memory / AGENTS.md / 所有スキルを踏まえた指摘を入れます(例: 「該当スキルを既に所有しているので言及不要」「AGENTS.md の方針と矛盾」「memory にある前提は省略可能」)。根拠が無ければ空配列。',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Builds the system + user prompts and the list of candidate piece names.
|
||||
* Pure: depends only on the injected catalogs/readers, never on globals.
|
||||
*/
|
||||
export function buildCoachMessages(
|
||||
deps: PromptCoachDeps,
|
||||
req: PromptCoachRequest,
|
||||
): { system: string; user: string; pieceCandidates: string[] } {
|
||||
const readMemory = deps.readMemory ?? (() => null);
|
||||
const readAgents = deps.readAgents ?? (() => null);
|
||||
|
||||
const memory = readMemory(deps.userFolderRoot, req.userId);
|
||||
const agents = readAgents(deps.userFolderRoot, req.userId);
|
||||
const skills = deps.skillCatalog.getForUser(req.userId).slice(0, COACH_LIMITS.maxSkills);
|
||||
const pieces = deps.pieceCatalog.getForUser(req.userId).slice(0, COACH_LIMITS.maxPieces);
|
||||
const pieceCandidates = pieces.map((p) => p.name);
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
sections.push('## 評価対象の下書き');
|
||||
sections.push(truncate(req.instruction.trim(), COACH_LIMITS.instructionChars) || '(空)');
|
||||
|
||||
if (req.piece && req.piece !== 'auto') {
|
||||
sections.push('## ユーザーが選択中の piece');
|
||||
sections.push(req.piece);
|
||||
}
|
||||
|
||||
if (memory && memory.trim()) {
|
||||
sections.push('## ユーザーの memory(MEMORY.md 一覧)');
|
||||
sections.push(truncate(memory.trim(), COACH_LIMITS.memoryChars));
|
||||
}
|
||||
|
||||
if (agents && agents.trim()) {
|
||||
sections.push('## ユーザーの AGENTS.md(作業方針)');
|
||||
sections.push(truncate(agents.trim(), COACH_LIMITS.agentsChars));
|
||||
}
|
||||
|
||||
if (skills.length > 0) {
|
||||
sections.push('## ユーザーが利用できるスキル');
|
||||
sections.push(
|
||||
skills
|
||||
.map((s) => `- ${s.name}: ${truncate((s.description ?? '').trim(), COACH_LIMITS.skillDescChars)}`)
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
if (pieces.length > 0) {
|
||||
sections.push('## piece 候補(predicted_piece.name はこの中から選ぶ)');
|
||||
sections.push(
|
||||
pieces
|
||||
.map((p) => `- ${p.name}: ${truncate((p.description ?? '').trim(), COACH_LIMITS.pieceDescChars)}`)
|
||||
.join('\n'),
|
||||
);
|
||||
} else {
|
||||
sections.push('## piece 候補');
|
||||
sections.push('(候補なし。predicted_piece は null にする)');
|
||||
}
|
||||
|
||||
return { system: SYSTEM_PROMPT, user: sections.join('\n\n'), pieceCandidates };
|
||||
}
|
||||
|
||||
function clampNumber(v: unknown, min: number, max: number, fallback: number): number {
|
||||
const n = typeof v === 'number' && Number.isFinite(v) ? v : Number(v);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.round(n)));
|
||||
}
|
||||
|
||||
function asString(v: unknown): string {
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and clamps the raw LLM tool arguments into a PromptCoachResult.
|
||||
* Never throws on malformed input — fills safe defaults instead — so a quirky
|
||||
* model response degrades gracefully rather than 500-ing the route.
|
||||
*/
|
||||
export function normalizeCoachResult(
|
||||
raw: Record<string, unknown>,
|
||||
pieceCandidates: string[],
|
||||
): PromptCoachResult {
|
||||
const axes: PromptCoachAxis[] = Array.isArray(raw.axes)
|
||||
? raw.axes.slice(0, 4).map((a) => {
|
||||
const o = (a ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
name: asString(o.name),
|
||||
score: clampNumber(o.score, 0, 10, 0),
|
||||
comment: asString(o.comment),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
let predicted: PromptCoachPiecePrediction | null = null;
|
||||
const rawPiece = raw.predicted_piece as Record<string, unknown> | null | undefined;
|
||||
if (rawPiece && typeof rawPiece === 'object') {
|
||||
const name = asString(rawPiece.name);
|
||||
if (name && pieceCandidates.includes(name)) {
|
||||
predicted = { name, reason: asString(rawPiece.reason) };
|
||||
}
|
||||
}
|
||||
|
||||
const maestroTips: PromptCoachTip[] = Array.isArray(raw.maestro_tips)
|
||||
? raw.maestro_tips
|
||||
.map((t) => {
|
||||
const o = (t ?? {}) as Record<string, unknown>;
|
||||
return { feature: asString(o.feature), suggestion: asString(o.suggestion) };
|
||||
})
|
||||
.filter((t) => t.feature || t.suggestion)
|
||||
: [];
|
||||
|
||||
const personalized: string[] = Array.isArray(raw.personalized)
|
||||
? raw.personalized.map(asString).filter((s) => s.length > 0)
|
||||
: [];
|
||||
|
||||
return {
|
||||
overall: clampNumber(raw.overall, 0, 100, 0),
|
||||
axes,
|
||||
rewrite: asString(raw.rewrite),
|
||||
predicted_piece: predicted,
|
||||
maestro_tips: maestroTips,
|
||||
personalized,
|
||||
};
|
||||
}
|
||||
|
||||
/** Assembles the bundle, runs the injected LLM call, normalizes the result. */
|
||||
export async function runPromptCoach(
|
||||
deps: PromptCoachDeps,
|
||||
req: PromptCoachRequest,
|
||||
): Promise<PromptCoachResult> {
|
||||
const { system, user, pieceCandidates } = buildCoachMessages(deps, req);
|
||||
const raw = await deps.callLlm({ system, user, userId: req.userId, signal: req.signal });
|
||||
return normalizeCoachResult(raw ?? {}, pieceCandidates);
|
||||
}
|
||||
|
||||
/** OpenAI tools-format schema. The model is forced to call this. */
|
||||
export const PROMPT_COACH_TOOL_SCHEMA = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'submit_evaluation',
|
||||
description: 'Submit the evaluation of the draft task prompt.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['overall', 'axes', 'rewrite', 'predicted_piece', 'maestro_tips', 'personalized'],
|
||||
properties: {
|
||||
overall: { type: 'number', minimum: 0, maximum: 100 },
|
||||
axes: {
|
||||
type: 'array',
|
||||
maxItems: 4,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['name', 'score', 'comment'],
|
||||
properties: {
|
||||
name: { type: 'string', maxLength: 40 },
|
||||
score: { type: 'number', minimum: 0, maximum: 10 },
|
||||
comment: { type: 'string', maxLength: 400 },
|
||||
},
|
||||
},
|
||||
},
|
||||
rewrite: { type: 'string', maxLength: 4000 },
|
||||
predicted_piece: {
|
||||
type: ['object', 'null'],
|
||||
additionalProperties: false,
|
||||
required: ['name', 'reason'],
|
||||
properties: {
|
||||
name: { type: 'string', maxLength: 80 },
|
||||
reason: { type: 'string', maxLength: 400 },
|
||||
},
|
||||
},
|
||||
maestro_tips: {
|
||||
type: 'array',
|
||||
maxItems: 6,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['feature', 'suggestion'],
|
||||
properties: {
|
||||
feature: { type: 'string', maxLength: 60 },
|
||||
suggestion: { type: 'string', maxLength: 400 },
|
||||
},
|
||||
},
|
||||
},
|
||||
personalized: {
|
||||
type: 'array',
|
||||
maxItems: 8,
|
||||
items: { type: 'string', maxLength: 400 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@ -28,6 +28,8 @@ const DOCS_DIR = path.join(REPO_ROOT, 'docs', 'tools');
|
||||
// 関連ツールが同じ doc を参照できるようエイリアスを定義
|
||||
// キー・値ともに小文字
|
||||
const TOOL_DOC_ALIASES: Record<string, string> = {
|
||||
// updateuseragents.md にまとめる
|
||||
readuseragents: 'updateuseragents',
|
||||
// checklist.md にまとめる
|
||||
createchecklist: 'checklist',
|
||||
checkitem: 'checklist',
|
||||
|
||||
@ -378,10 +378,12 @@ export async function getToolDefs(
|
||||
// - MissionUpdate: タスクの目標 / 進捗のピン止めメモを更新 (会話が長くなって
|
||||
// 最初の要件を見失わないため。常時上書き可能、未指定フィールドは保持)
|
||||
// - ListUserAssets / RunUserScript: ユーザーフォルダのスクリプト探索・実行
|
||||
// - ReadUserAgents / UpdateUserAgents: per-user AGENTS.md (常時指示書) の読み書き
|
||||
// (UpdateUserMemory が事実断片、こちらは振る舞い方針そのもの)
|
||||
// - Brainstorm: 着手前 or 行き詰まり時の多アプローチ比較 (issue #247)
|
||||
// - ReadAppDoc / ListAppDocs / GetMyOrchestratorState: Help アシスタント用 (#help piece)
|
||||
// ただし他の piece からも参照できるようにメタ扱い
|
||||
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserTemplate', 'RenderUserTemplate', 'WriteUserScript', 'WriteUserTemplate', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill'];
|
||||
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserAgents', 'UpdateUserAgents', 'ReadUserTemplate', 'RenderUserTemplate', 'WriteUserScript', 'WriteUserTemplate', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill'];
|
||||
const effectiveAllowed = [...allowedTools];
|
||||
for (const meta of META_TOOLS) {
|
||||
if (!effectiveAllowed.includes(meta) && meta in allDefs) {
|
||||
|
||||
@ -4,6 +4,8 @@ import {
|
||||
rmSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
} from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
@ -769,3 +771,120 @@ describe('RunUserScript: audit log hook', () => {
|
||||
expect(auditCalls.some(c => c.action === 'user_script_denied')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ReadUserAgents / UpdateUserAgents ─────────────────────────────────────────
|
||||
|
||||
describe('ReadUserAgents / UpdateUserAgents', () => {
|
||||
const agentsPath = () => join(userFolderRoot, TEST_USER, 'AGENTS.md');
|
||||
|
||||
it('ReadUserAgents returns a friendly note when AGENTS.md does not exist', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('ReadUserAgents', {}, ctx);
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(r?.output).toContain('does not exist yet');
|
||||
});
|
||||
|
||||
it('append creates AGENTS.md when empty', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'append', new_text: '# 方針\n常に日本語で答える' }, ctx);
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 方針\n常に日本語で答える');
|
||||
});
|
||||
|
||||
it('append adds a blank line between existing content and new text', async () => {
|
||||
writeFileSync(agentsPath(), '# 既存\nルール1');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'ルール2' }, ctx);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 既存\nルール1\n\nルール2');
|
||||
});
|
||||
|
||||
it('ReadUserAgents returns the full current content', async () => {
|
||||
writeFileSync(agentsPath(), 'line A\nline B');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('ReadUserAgents', {}, ctx);
|
||||
expect(r?.output).toBe('line A\nline B');
|
||||
});
|
||||
|
||||
it('replace swaps an exact unique match', async () => {
|
||||
writeFileSync(agentsPath(), '# 方針\n古い行\nおわり');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: '古い行', new_text: '新しい行' }, ctx);
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 方針\n新しい行\nおわり');
|
||||
});
|
||||
|
||||
it('replace does not interpret $-patterns in new_text', async () => {
|
||||
writeFileSync(agentsPath(), 'price: PLACEHOLDER yen');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'PLACEHOLDER', new_text: '$100 & $&' }, ctx);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('price: $100 & $& yen');
|
||||
});
|
||||
|
||||
it('replace errors when old_text is not found', async () => {
|
||||
writeFileSync(agentsPath(), 'something');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'missing', new_text: 'x' }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('not found');
|
||||
});
|
||||
|
||||
it('replace errors when old_text matches more than once', async () => {
|
||||
writeFileSync(agentsPath(), 'dup\ndup');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'dup', new_text: 'x' }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('matches 2 times');
|
||||
});
|
||||
|
||||
it('replace on an empty file is rejected with a hint to append', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'x', new_text: 'y' }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('append');
|
||||
});
|
||||
|
||||
it('snapshots the prior version to trash/agents-history before overwriting', async () => {
|
||||
writeFileSync(agentsPath(), 'original content');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'more' }, ctx);
|
||||
|
||||
const histDir = join(userFolderRoot, TEST_USER, 'trash', 'agents-history');
|
||||
const backups = readdirSync(histDir).filter((f) => f.startsWith('AGENTS.md.') && f.endsWith('.bak'));
|
||||
expect(backups.length).toBe(1);
|
||||
expect(readFileSync(join(histDir, backups[0]!), 'utf-8')).toBe('original content');
|
||||
});
|
||||
|
||||
it('rejects when result would exceed the 64KB cap', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const big = 'x'.repeat(64 * 1024 + 1);
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'append', new_text: big }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('exceeds');
|
||||
});
|
||||
|
||||
it('emits a user_agents_updated audit event', async () => {
|
||||
const auditCalls: Array<{ action: string; detail: object }> = [];
|
||||
setUserFolderToolDeps({
|
||||
sessRepo: null as never,
|
||||
masterKeyPath: '',
|
||||
userFolderRoot,
|
||||
auditLog: (action, detail) => auditCalls.push({ action, detail }),
|
||||
});
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'hello' }, ctx);
|
||||
expect(auditCalls.some((c) => c.action === 'user_agents_updated')).toBe(true);
|
||||
});
|
||||
|
||||
it('both tools require an authenticated user', async () => {
|
||||
const ctx = buildCtx({ userId: undefined });
|
||||
const r1 = await executeTool('ReadUserAgents', {}, ctx);
|
||||
const r2 = await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'x' }, ctx);
|
||||
expect(r1?.isError).toBe(true);
|
||||
expect(r2?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('registers both tools in TOOL_DEFS', () => {
|
||||
expect(TOOL_DEFS['ReadUserAgents']).toBeDefined();
|
||||
expect(TOOL_DEFS['UpdateUserAgents']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -20,6 +20,9 @@ import {
|
||||
userRoot,
|
||||
assertOwnerAccess,
|
||||
resolveUserSubdir,
|
||||
readUserAgentsMdRaw,
|
||||
writeUserAgentsMd,
|
||||
snapshotUserAgentsMd,
|
||||
} from '../../user-folder/paths.js';
|
||||
import {
|
||||
upsertMemoryEntry,
|
||||
@ -124,6 +127,48 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
},
|
||||
},
|
||||
|
||||
ReadUserAgents: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'ReadUserAgents',
|
||||
description:
|
||||
'Returns the caller\'s full AGENTS.md (per-user standing instructions, auto-injected into every task). Read it before editing. Details via ReadToolDoc({ name: "UpdateUserAgents" }).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
UpdateUserAgents: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'UpdateUserAgents',
|
||||
description:
|
||||
'Edits the caller\'s AGENTS.md via "replace" (old_text→new_text, must match exactly once) or "append". Snapshots the prior version first. Details via ReadToolDoc({ name: "UpdateUserAgents" }).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['replace', 'append'],
|
||||
description: '"replace" swaps old_text for new_text; "append" adds new_text to the end (also creates the file if empty).',
|
||||
},
|
||||
old_text: {
|
||||
type: 'string',
|
||||
description: 'Exact existing text to replace. Required for "replace"; must appear exactly once (add surrounding context to disambiguate).',
|
||||
},
|
||||
new_text: {
|
||||
type: 'string',
|
||||
description: 'For "replace", the replacement text; for "append", the text to add. Required.',
|
||||
},
|
||||
},
|
||||
required: ['mode', 'new_text'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ListUserAssets: {
|
||||
type: 'function',
|
||||
function: {
|
||||
@ -309,6 +354,111 @@ async function executeReadUserMemory(
|
||||
return { output, isError: false };
|
||||
}
|
||||
|
||||
// ── ReadUserAgents implementation ─────────────────────────────────────────────
|
||||
|
||||
async function executeReadUserAgents(
|
||||
_input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
if (!ctx.userId) {
|
||||
return { output: 'ReadUserAgents requires an authenticated user', isError: true };
|
||||
}
|
||||
const content = readUserAgentsMdRaw(getUserFolderRoot(), ctx.userId);
|
||||
if (content === null || content.length === 0) {
|
||||
return {
|
||||
output:
|
||||
'AGENTS.md does not exist yet (empty). Use UpdateUserAgents with mode="append" to create it.',
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
return { output: content, isError: false };
|
||||
}
|
||||
|
||||
// ── UpdateUserAgents implementation ───────────────────────────────────────────
|
||||
|
||||
async function executeUpdateUserAgents(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
if (!ctx.userId) {
|
||||
return { output: 'UpdateUserAgents requires an authenticated user', isError: true };
|
||||
}
|
||||
|
||||
const mode = input['mode'];
|
||||
if (mode !== 'replace' && mode !== 'append') {
|
||||
return { output: 'UpdateUserAgents: "mode" must be "replace" or "append"', isError: true };
|
||||
}
|
||||
|
||||
const newText = input['new_text'];
|
||||
if (typeof newText !== 'string') {
|
||||
return { output: 'UpdateUserAgents: "new_text" is required', isError: true };
|
||||
}
|
||||
|
||||
const folderRoot = getUserFolderRoot();
|
||||
const current = readUserAgentsMdRaw(folderRoot, ctx.userId) ?? '';
|
||||
|
||||
let next: string;
|
||||
if (mode === 'replace') {
|
||||
const oldText = input['old_text'];
|
||||
if (typeof oldText !== 'string' || oldText.length === 0) {
|
||||
return { output: 'UpdateUserAgents: "old_text" is required for mode "replace"', isError: true };
|
||||
}
|
||||
if (current.length === 0) {
|
||||
return {
|
||||
output: 'UpdateUserAgents: AGENTS.md is empty — use mode "append" to add content',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const count = current.split(oldText).length - 1;
|
||||
if (count === 0) {
|
||||
return {
|
||||
output:
|
||||
'UpdateUserAgents: "old_text" not found in AGENTS.md. Call ReadUserAgents to see the exact current content.',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (count > 1) {
|
||||
return {
|
||||
output: `UpdateUserAgents: "old_text" matches ${count} times — include more surrounding context to make it unique.`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
// Function replacement avoids `$&`/`$1` being interpreted in new_text.
|
||||
next = current.replace(oldText, () => newText);
|
||||
} else {
|
||||
// append
|
||||
if (newText.length === 0) {
|
||||
return { output: 'UpdateUserAgents: "new_text" must be non-empty for mode "append"', isError: true };
|
||||
}
|
||||
next = current.length === 0 ? newText : `${current.replace(/\n*$/, '')}\n\n${newText}`;
|
||||
}
|
||||
|
||||
// Snapshot the prior version before overwriting (best-effort; missing/empty → null).
|
||||
let backup: string | null = null;
|
||||
try {
|
||||
backup = snapshotUserAgentsMd(folderRoot, ctx.userId);
|
||||
} catch {
|
||||
/* snapshot is best-effort; proceed with the write */
|
||||
}
|
||||
|
||||
try {
|
||||
writeUserAgentsMd(folderRoot, ctx.userId, next);
|
||||
} catch (err) {
|
||||
return { output: `UpdateUserAgents: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
_deps?.auditLog?.(
|
||||
'user_agents_updated',
|
||||
{ userId: ctx.userId, mode, bytes: Buffer.byteLength(next, 'utf-8'), snapshotted: backup !== null },
|
||||
ctx.taskId ?? null,
|
||||
);
|
||||
|
||||
return {
|
||||
output: `AGENTS.md updated (mode=${mode}, now ${Buffer.byteLength(next, 'utf-8')} bytes)${backup ? ' — previous version snapshotted to trash/agents-history/' : ''}`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ListUserAssets implementation ─────────────────────────────────────────────
|
||||
|
||||
async function executeListUserAssets(
|
||||
@ -626,6 +776,8 @@ export async function executeTool(
|
||||
): Promise<ToolResult | null> {
|
||||
if (name === 'UpdateUserMemory') return executeUpdateUserMemory(input, ctx);
|
||||
if (name === 'ReadUserMemory') return executeReadUserMemory(input, ctx);
|
||||
if (name === 'ReadUserAgents') return executeReadUserAgents(input, ctx);
|
||||
if (name === 'UpdateUserAgents') return executeUpdateUserAgents(input, ctx);
|
||||
if (name === 'ListUserAssets') return executeListUserAssets(input, ctx);
|
||||
if (name === 'RunUserScript') return executeRunUserScript(input, ctx);
|
||||
if (name === 'WriteUserScript') return executeWriteUserScript(input, ctx);
|
||||
|
||||
@ -77,6 +77,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
|
||||
// user-folder.ts
|
||||
'ListUserAssets', 'ReadUserMemory',
|
||||
'RunUserScript', 'UpdateUserMemory', 'WriteUserScript',
|
||||
'ReadUserAgents', 'UpdateUserAgents',
|
||||
// brainstorm.ts
|
||||
'Brainstorm',
|
||||
// app-docs.ts
|
||||
|
||||
@ -105,10 +105,43 @@ describe('ssh/path-policy validateRemotePath', () => {
|
||||
expect(validateRemotePath('\\\\srv\\share\\agent\\file', '\\\\srv\\share\\agent').ok).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses repeated backslashes in Windows path', () => {
|
||||
it('collapses repeated backslashes and emits forward-slash form', () => {
|
||||
const r = validateRemotePath('C:\\Users\\\\agent\\\\file', 'C:\\Users\\agent');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.normalized).toBe('C:\\Users\\agent\\file');
|
||||
// SFTP wire form: backslashes → forward slashes, drive path gets leading '/'.
|
||||
expect(r.normalized).toBe('/C:/Users/agent/file');
|
||||
});
|
||||
|
||||
it('normalizes Windows drive paths to /C:/... (leading slash) for SFTP', () => {
|
||||
expect(validateRemotePath('C:\\Users\\agent\\f', 'C:\\Users\\agent').normalized).toBe(
|
||||
'/C:/Users/agent/f',
|
||||
);
|
||||
// Already-canonical leading-slash form is idempotent.
|
||||
expect(validateRemotePath('/C:/Users/agent/f', '/C:/Users/agent').normalized).toBe(
|
||||
'/C:/Users/agent/f',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts forward-slash Windows drive prefix (natural Win OpenSSH config)', () => {
|
||||
// Regression: detectSeparator used to flag any drive path as backslash-style,
|
||||
// so a forward-slash prefix + forward-slash path was wrongly rejected.
|
||||
expect(validateRemotePath('C:/Users/agent', 'C:/Users/agent').ok).toBe(true);
|
||||
expect(validateRemotePath('C:/Users/agent/file.txt', 'C:/Users/agent').ok).toBe(true);
|
||||
expect(validateRemotePath('C:/Users/agent/file.txt', 'C:/Users/agent').normalized).toBe(
|
||||
'/C:/Users/agent/file.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts mixed separators between prefix and candidate', () => {
|
||||
// Backslash prefix + forward-slash path (and vice versa) must line up.
|
||||
expect(validateRemotePath('C:/Users/agent/file', 'C:\\Users\\agent').ok).toBe(true);
|
||||
expect(validateRemotePath('C:\\Users\\agent\\file', 'C:/Users/agent').ok).toBe(true);
|
||||
});
|
||||
|
||||
it('emits forward-slash UNC form', () => {
|
||||
const r = validateRemotePath('\\\\srv\\share\\agent\\file', '\\\\srv\\share\\agent');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.normalized).toBe('//srv/share/agent/file');
|
||||
});
|
||||
|
||||
it('rejects empty prefix candidate via outside_prefix when prefix is non-trivial', () => {
|
||||
|
||||
@ -48,24 +48,42 @@ export interface LocalPathResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the primary path separator used in a prefix string.
|
||||
* Windows-style: drive letter (`C:\`), UNC (`\\server\share`), or backslashes
|
||||
* without forward slashes. POSIX-style: everything else.
|
||||
* Canonicalise a remote path to forward-slash form for comparison AND for the
|
||||
* wire.
|
||||
*
|
||||
* SFTP uses '/' as its separator on every platform — Windows OpenSSH's
|
||||
* sftp-server canonicalises drive paths to '/C:/Users/...' (leading slash,
|
||||
* forward slashes). Comparing in '/'-space lets a prefix or candidate written
|
||||
* with backslashes, forward slashes, or a mix all line up, and guarantees the
|
||||
* path we ultimately hand to `sftp.createWriteStream` / `sftp.stat` is in the
|
||||
* one form every server accepts.
|
||||
*
|
||||
* 'C:\\Users\\agent' → '/C:/Users/agent'
|
||||
* 'C:/Users/agent' → '/C:/Users/agent'
|
||||
* '/C:/Users/agent' → '/C:/Users/agent' (idempotent)
|
||||
* '\\\\srv\\share\\agent' → '//srv/share/agent' (UNC head preserved)
|
||||
* '/home/u' → '/home/u' (POSIX unchanged)
|
||||
*
|
||||
* Caller has already rejected `..` segments, so posix.normalize cannot escape.
|
||||
*/
|
||||
function detectSeparator(p: string): '/' | '\\' {
|
||||
if (/^[a-zA-Z]:[\\/]/.test(p)) return '\\';
|
||||
if (p.startsWith('\\\\')) return '\\';
|
||||
if (p.includes('\\') && !p.includes('/')) return '\\';
|
||||
return '/';
|
||||
function toForwardSlash(s: string): string {
|
||||
// Preserve a UNC double-leading separator ('\\\\server' / '//server') as '//'.
|
||||
const uncHead = /^[\\/]{2}/.test(s) ? '//' : '';
|
||||
const body = path.posix.normalize(s.slice(uncHead.length).replace(/\\/g, '/'));
|
||||
let out = uncHead + body;
|
||||
// A bare drive path ('C:/...') gets the leading slash Windows OpenSSH expects.
|
||||
if (/^[A-Za-z]:\//.test(out)) out = '/' + out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a candidate REMOTE path against the per-connection prefix.
|
||||
*
|
||||
* Pure string operations — no FS I/O (remote FS isn't ours to stat).
|
||||
* Supports both POSIX (`/`) and Windows (`\`) path styles. The prefix's
|
||||
* primary separator is used to compare; mixing styles between prefix and
|
||||
* candidate path will trip the segment-boundary check.
|
||||
* Separator-agnostic: prefix and candidate are canonicalised to forward-slash
|
||||
* form (toForwardSlash) before comparing, so '/', '\', or a mix all line up.
|
||||
* The `normalized` result is the '/'-delimited form handed to SFTP — Windows
|
||||
* drive paths come back as '/C:/Users/...', which Windows OpenSSH accepts.
|
||||
*
|
||||
* prefix = '/home/u'
|
||||
* '/home/u' → ok
|
||||
@ -78,8 +96,9 @@ function detectSeparator(p: string): '/' | '\\' {
|
||||
* '' → empty
|
||||
* '/foo\x00bar' → has_nul
|
||||
*
|
||||
* prefix = 'C:\\Users\\agent'
|
||||
* 'C:\\Users\\agent\\file' → ok
|
||||
* prefix = 'C:\\Users\\agent' (or 'C:/Users/agent' — same result)
|
||||
* 'C:\\Users\\agent\\file' → ok (normalized '/C:/Users/agent/file')
|
||||
* 'C:/Users/agent/file' → ok (normalized '/C:/Users/agent/file')
|
||||
* 'C:\\Users\\agent2\\f' → outside_prefix
|
||||
* 'C:\\..\\Windows\\sys' → has_parent_ref
|
||||
*
|
||||
@ -101,32 +120,18 @@ export function validateRemotePath(remotePath: string, prefix: string): RemotePa
|
||||
return { ok: false, reason: 'has_parent_ref' };
|
||||
}
|
||||
|
||||
const sep = detectSeparator(prefix);
|
||||
// POSIX paths normalize via path.posix (collapses '//' and '/./').
|
||||
// Windows paths get a lightweight collapse of repeated backslashes only;
|
||||
// posix.normalize would corrupt drive letters or UNC heads.
|
||||
const normalize = (s: string): string => {
|
||||
if (sep === '/') return path.posix.normalize(s);
|
||||
// Preserve leading '\\' (UNC) by capturing it before collapsing.
|
||||
const uncHead = s.startsWith('\\\\') ? '\\\\' : '';
|
||||
const body = s.slice(uncHead.length).replace(/\\{2,}/g, '\\');
|
||||
return uncHead + body;
|
||||
};
|
||||
|
||||
// Compare in forward-slash space (see toForwardSlash). The normalized result
|
||||
// is what gets handed to SFTP, so it is always '/'-delimited.
|
||||
const stripTrailingSep = (s: string): string =>
|
||||
s.length > 1 && (s.endsWith('/') || s.endsWith('\\')) ? s.slice(0, -1) : s;
|
||||
s.length > 1 && s.endsWith('/') ? s.slice(0, -1) : s;
|
||||
|
||||
const normalized = normalize(remotePath);
|
||||
const normalizedTrimmed = stripTrailingSep(normalized);
|
||||
const prefixTrimmed = stripTrailingSep(prefix);
|
||||
const normalizedTrimmed = stripTrailingSep(toForwardSlash(remotePath));
|
||||
const prefixTrimmed = stripTrailingSep(toForwardSlash(prefix));
|
||||
|
||||
if (normalizedTrimmed === prefixTrimmed) {
|
||||
return { ok: true, normalized: normalizedTrimmed };
|
||||
}
|
||||
const prefixWithSep =
|
||||
prefixTrimmed === '/' || prefixTrimmed === '\\'
|
||||
? prefixTrimmed
|
||||
: `${prefixTrimmed}${sep}`;
|
||||
const prefixWithSep = prefixTrimmed === '/' ? '/' : `${prefixTrimmed}/`;
|
||||
if (normalizedTrimmed.startsWith(prefixWithSep)) {
|
||||
return { ok: true, normalized: normalizedTrimmed };
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { mkdirSync, chmodSync, existsSync, readFileSync, writeFileSync, unlinkSync, statSync, openSync, readSync, closeSync, rmSync } from 'fs';
|
||||
import { mkdirSync, chmodSync, existsSync, readFileSync, writeFileSync, unlinkSync, statSync, openSync, readSync, closeSync, rmSync, readdirSync, copyFileSync } from 'fs';
|
||||
import { resolve, join, relative, isAbsolute } from 'path';
|
||||
|
||||
/** The shared no-auth / local-auth system owner. Its folder is never deleted. */
|
||||
@ -152,3 +152,79 @@ export function readUserAgentsMd(rootDir: string, ownerId: string): string | nul
|
||||
while (safe > 0 && (buf[safe - 1]! & 0xc0) === 0x80) safe--;
|
||||
return buf.subarray(0, safe).toString('utf-8') + `\n\n[truncated: original was ${stat.size} bytes]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the FULL, untruncated AGENTS.md for editing.
|
||||
*
|
||||
* Unlike readUserAgentsMd (which caps at 64KB and appends a "[truncated]" note
|
||||
* for prompt injection), this returns the exact on-disk bytes so an editing
|
||||
* tool can match/replace against the real content. Returns null when the file
|
||||
* is missing or the ownerId is invalid.
|
||||
*/
|
||||
export function readUserAgentsMdRaw(rootDir: string, ownerId: string): string | null {
|
||||
let path: string;
|
||||
try {
|
||||
path = join(userRoot(rootDir, ownerId), 'AGENTS.md');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stat = statSync(path);
|
||||
if (!stat.isFile()) return null;
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const AGENTS_HISTORY_KEEP = 10;
|
||||
|
||||
/**
|
||||
* Snapshot the current AGENTS.md to trash/agents-history/ before an edit, so an
|
||||
* LLM mis-edit can be recovered. Keeps the most recent AGENTS_HISTORY_KEEP
|
||||
* backups and prunes older ones. No-op (returns null) when AGENTS.md is missing
|
||||
* or empty. Returns the backup path on success.
|
||||
*/
|
||||
export function snapshotUserAgentsMd(rootDir: string, ownerId: string): string | null {
|
||||
let src: string;
|
||||
try {
|
||||
src = join(userRoot(rootDir, ownerId), 'AGENTS.md');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(src);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!stat.isFile() || stat.size === 0) return null;
|
||||
|
||||
const histDir = join(userRoot(rootDir, ownerId), 'trash', 'agents-history');
|
||||
if (!existsSync(histDir)) {
|
||||
mkdirSync(histDir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(histDir, 0o700);
|
||||
}
|
||||
// ISO timestamp in the filename → lexical sort == chronological order.
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const dest = join(histDir, `AGENTS.md.${ts}.bak`);
|
||||
copyFileSync(src, dest);
|
||||
|
||||
// Prune oldest backups, keeping the most recent AGENTS_HISTORY_KEEP.
|
||||
try {
|
||||
const backups = readdirSync(histDir)
|
||||
.filter((f) => f.startsWith('AGENTS.md.') && f.endsWith('.bak'))
|
||||
.sort();
|
||||
for (let i = 0; i < backups.length - AGENTS_HISTORY_KEEP; i++) {
|
||||
try {
|
||||
unlinkSync(join(histDir, backups[i]!));
|
||||
} catch {
|
||||
/* best-effort prune */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best-effort prune */
|
||||
}
|
||||
|
||||
return dest;
|
||||
}
|
||||
|
||||
@ -19,7 +19,10 @@ import { runMigrations } from './db/migrate.js';
|
||||
import { logger } from './logger.js';
|
||||
import { accessSync, existsSync, mkdirSync, constants } from 'fs';
|
||||
import { dirname, resolve, join } from 'path';
|
||||
import { OpenAICompatClient } from './llm/openai-compat.js';
|
||||
import { OpenAICompatClient, type Message, type ToolDef, type LLMEvent } from './llm/openai-compat.js';
|
||||
import { runPromptCoach, PROMPT_COACH_TOOL_SCHEMA } from './engine/prompt-coach.js';
|
||||
import { readUserAgentsMd } from './user-folder/paths.js';
|
||||
import { readMemoryIndex } from './user-folder/memory.js';
|
||||
import { setLlmUsageRecorder } from './llm/usage-recorder.js';
|
||||
import { llmRoutingKey } from './llm/routing-key.js';
|
||||
import { ConfigManager } from './config-manager.js';
|
||||
@ -266,6 +269,49 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// On-demand prompt coach (create-dialog draft evaluation). Reuses the cheap
|
||||
// title client and forces the submit_evaluation tool, mirroring reflection.
|
||||
const evaluatePrompt = titleClient
|
||||
? (r: { instruction: string; piece?: string; userId: string; signal?: AbortSignal }) =>
|
||||
runPromptCoach(
|
||||
{
|
||||
userFolderRoot,
|
||||
pieceCatalog,
|
||||
skillCatalog,
|
||||
readMemory: readMemoryIndex,
|
||||
readAgents: readUserAgentsMd,
|
||||
callLlm: async ({ system, user, userId, signal }) => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
];
|
||||
let input: Record<string, unknown> | null = null;
|
||||
let errorMsg: string | null = null;
|
||||
for await (const event of titleClient!.chat(
|
||||
messages,
|
||||
[PROMPT_COACH_TOOL_SCHEMA as unknown as ToolDef],
|
||||
signal,
|
||||
{ userId },
|
||||
{
|
||||
temperature: 0.2,
|
||||
toolChoice: { type: 'function', function: { name: 'submit_evaluation' } },
|
||||
},
|
||||
) as AsyncGenerator<LLMEvent>) {
|
||||
if (event.type === 'tool_use' && event.name === 'submit_evaluation' && input === null) {
|
||||
input = event.input;
|
||||
} else if (event.type === 'error') {
|
||||
errorMsg = event.error;
|
||||
}
|
||||
}
|
||||
if (errorMsg !== null) throw new Error(`prompt-coach LLM ${errorMsg}`);
|
||||
if (input === null) throw new Error('prompt-coach LLM returned no submit_evaluation tool_call');
|
||||
return input;
|
||||
},
|
||||
},
|
||||
r,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// スケジューラ起動 (selectPiece 注入で 'auto' を実 piece に解決)
|
||||
// task_kind='script' をサポートするため、sessRepo / masterKeyPath / userFolderRoot も渡す。
|
||||
const sessRepoForScheduler = new BrowserSessionRepo(repo.getDb());
|
||||
@ -292,6 +338,7 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
|
||||
configuredRepos: [],
|
||||
generateTitle,
|
||||
selectPiece,
|
||||
evaluatePrompt,
|
||||
configManager,
|
||||
piecesDir,
|
||||
customPiecesDir,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSetupState } from './hooks/useSetupState';
|
||||
import { SetupWizard } from './components/setup/SetupWizard';
|
||||
import { LocalTask, type Visibility } from './api';
|
||||
import { useUrlState } from './hooks/useUrlState';
|
||||
import { useToast } from './hooks/useToast';
|
||||
@ -119,6 +121,51 @@ function AuthenticatedApp() {
|
||||
const authEnabled = auth.mode !== 'disabled';
|
||||
const user = auth.mode === 'authenticated' ? auth.user : null;
|
||||
|
||||
return <SetupGate isAdmin={isAdmin} authEnabled={authEnabled} user={user} />;
|
||||
}
|
||||
|
||||
// First-run gate: when the runtime has no usable LLM, show the full-screen
|
||||
// setup wizard instead of the app. Fail-open — if the status check errors, fall
|
||||
// through to the app rather than blocking it.
|
||||
function SetupGate({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnabled: boolean; user: AuthUser | null }) {
|
||||
const { data: setup, isLoading } = useSetupState();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-dvh flex items-center justify-center bg-slate-50">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (setup?.needsSetup) {
|
||||
const canConfigure = !setup.authActive || isAdmin;
|
||||
if (canConfigure) {
|
||||
return (
|
||||
<SetupWizard
|
||||
status={setup}
|
||||
onComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['setup'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workers'] });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="h-dvh flex items-center justify-center bg-slate-50 px-4">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-lg font-semibold text-slate-800 mb-2">Setup needed</h1>
|
||||
<p className="text-sm text-slate-600">
|
||||
This server has no language model configured yet. Ask an administrator to finish setup from Settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AppInner isAdmin={isAdmin} authEnabled={authEnabled} user={user} />;
|
||||
}
|
||||
|
||||
|
||||
@ -231,6 +231,35 @@ export async function createLocalTask(input: CreateLocalTaskInput): Promise<{ ta
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface PromptCoachAxis {
|
||||
name: string;
|
||||
score: number;
|
||||
comment: string;
|
||||
}
|
||||
export interface PromptCoachResult {
|
||||
overall: number;
|
||||
axes: PromptCoachAxis[];
|
||||
rewrite: string;
|
||||
predicted_piece: { name: string; reason: string } | null;
|
||||
maestro_tips: Array<{ feature: string; suggestion: string }>;
|
||||
personalized: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand prompt coach: evaluates a draft task prompt before it is submitted.
|
||||
* Stateless — nothing is persisted. Returns 503 when the coach is unconfigured.
|
||||
*/
|
||||
export async function evaluatePrompt(input: { instruction: string; piece?: string }): Promise<PromptCoachResult> {
|
||||
const res = await fetch(`${BASE}/local/tasks/evaluate-prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? 'Failed to evaluate prompt');
|
||||
return data as PromptCoachResult;
|
||||
}
|
||||
|
||||
export async function fetchLocalTask(taskId: number): Promise<LocalTask> {
|
||||
const res = await fetch(`${BASE}/local/tasks/${taskId}`);
|
||||
const data = await res.json();
|
||||
|
||||
@ -6,6 +6,7 @@ import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
|
||||
import { ChatPetOverlay } from '../pets/ChatPetOverlay';
|
||||
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
|
||||
import { SubtaskInlineCard } from './SubtaskInlineCard';
|
||||
import RotatingTips from './RotatingTips';
|
||||
import { useJobStream } from '../../hooks/useJobStream';
|
||||
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
|
||||
|
||||
@ -192,6 +193,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
|
||||
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
|
||||
const inputLocked = jobStatus === 'dispatching';
|
||||
// A job is already accepted but not yet running. The task is NOT idle: a new
|
||||
// message must fold into this pending job (server reuses it, no duplicate),
|
||||
// so the composer presents it as an addition rather than a fresh request.
|
||||
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
|
||||
const hasActiveJob = isBusy || isPending;
|
||||
|
||||
// Release the submit lock once the agent is visibly responding: the new user
|
||||
// comment is reflected in the list AND the job has been picked up by a worker
|
||||
@ -201,10 +207,10 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
if (!submitting) return;
|
||||
const baseline = submitBaselineRef.current;
|
||||
if (baseline === null) return;
|
||||
if (comments.length > baseline && isBusy) {
|
||||
if (comments.length > baseline && hasActiveJob) {
|
||||
releaseSubmitting();
|
||||
}
|
||||
}, [submitting, comments.length, isBusy]);
|
||||
}, [submitting, comments.length, hasActiveJob]);
|
||||
|
||||
// Clear the safety-net timeout on unmount.
|
||||
useEffect(() => {
|
||||
@ -383,6 +389,9 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General rotating tips to make the wait useful (running only). */}
|
||||
{isBusy && <RotatingTips />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -406,17 +415,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
|
||||
{/* Composer */}
|
||||
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-3" style={{ paddingBottom: 'calc(12px + env(safe-area-inset-bottom, 0px))' }}>
|
||||
{isBusy && (
|
||||
{hasActiveJob && (
|
||||
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
|
||||
canInterject
|
||||
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 text-amber-700 dark:text-amber-300'
|
||||
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
|
||||
: isPending
|
||||
? 'bg-surface-2 border border-hairline text-slate-600 dark:text-slate-300'
|
||||
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
<span>{canInterject ? t('pane.interjectHint') : t('pane.agentRunningWait')}</span>
|
||||
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
|
||||
</div>
|
||||
)}
|
||||
{sendError && !isBusy && (
|
||||
@ -468,7 +479,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
onPaste={e => void handlePaste(e)}
|
||||
rows={2}
|
||||
disabled={inputLocked}
|
||||
placeholder={inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : t('pane.placeholder.default')}
|
||||
placeholder={inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
|
||||
className="flex-1 resize-y border border-hairline rounded-md px-2.5 py-2 text-sm text-slate-900 outline-none focus:border-accent focus:ring-2 focus:ring-accent-ring min-h-[56px] disabled:bg-surface disabled:text-slate-400 disabled:cursor-not-allowed transition-shadow"
|
||||
/>
|
||||
{isBusy && onCancel ? (
|
||||
@ -498,6 +509,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
{cancelling ? t('pane.stopping') : t('pane.stop')}
|
||||
</button>
|
||||
</div>
|
||||
) : isPending ? (
|
||||
<button
|
||||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={handleSubmit}
|
||||
title={t('pane.addToQueuedHint')}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 10 20 15 15 20" />
|
||||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||||
</svg>
|
||||
{t('pane.addToQueued')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
disabled={submitting || inputLocked || (!draft.trim() && attachments.length === 0)}
|
||||
|
||||
33
ui/src/components/chat/RotatingTips.tips.test.ts
Normal file
33
ui/src/components/chat/RotatingTips.tips.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import ja from '../../i18n/locales/ja/chat.json';
|
||||
import en from '../../i18n/locales/en/chat.json';
|
||||
|
||||
// Pure JSON parity check for the rotating-tips strings consumed by
|
||||
// RotatingTips.tsx. The component itself is DOM + timer based and isn't
|
||||
// exercised here; this guards the data contract (chat.tips.items) instead.
|
||||
describe('chat tips (RotatingTips data)', () => {
|
||||
it('ja and en both define a non-empty tips.items array', () => {
|
||||
expect(Array.isArray(ja.tips?.items)).toBe(true);
|
||||
expect(Array.isArray(en.tips?.items)).toBe(true);
|
||||
expect(ja.tips.items.length).toBeGreaterThan(0);
|
||||
expect(en.tips.items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('ja and en have the same number of tips (no locale drift)', () => {
|
||||
expect(ja.tips.items.length).toBe(en.tips.items.length);
|
||||
});
|
||||
|
||||
it('every tip is a non-empty string', () => {
|
||||
for (const list of [ja.tips.items, en.tips.items]) {
|
||||
for (const tip of list) {
|
||||
expect(typeof tip).toBe('string');
|
||||
expect(tip.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('defines a tips.label in both locales', () => {
|
||||
expect(typeof ja.tips.label).toBe('string');
|
||||
expect(typeof en.tips.label).toBe('string');
|
||||
});
|
||||
});
|
||||
61
ui/src/components/chat/RotatingTips.tsx
Normal file
61
ui/src/components/chat/RotatingTips.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* RotatingTips — a subtle, rotating general "did you know" line shown while the
|
||||
* agent is running, to make the wait useful. Tips are static, curated, and
|
||||
* localized (chat.tips.items). Mount this only when the job is busy; it stops
|
||||
* rotating automatically on unmount.
|
||||
*
|
||||
* Personalized, prompt-specific coaching is a separate feature (the on-demand
|
||||
* prompt evaluator in the create dialog) — these tips are general only.
|
||||
*/
|
||||
const ROTATE_MS = 7000;
|
||||
const FADE_MS = 300;
|
||||
|
||||
export default function RotatingTips() {
|
||||
const { t } = useTranslation('chat');
|
||||
const items = t('tips.items', { returnObjects: true }) as unknown;
|
||||
const tips = Array.isArray(items) ? (items as string[]) : [];
|
||||
|
||||
// Random start so the same tip isn't always first; guarded for empty list.
|
||||
const [idx, setIdx] = useState(() => (tips.length ? Math.floor(Math.random() * tips.length) : 0));
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (tips.length <= 1) return;
|
||||
const pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||
const rotate = setInterval(() => {
|
||||
setVisible(false);
|
||||
const swap = setTimeout(() => {
|
||||
setIdx((i) => (i + 1) % tips.length);
|
||||
setVisible(true);
|
||||
pendingTimeouts.delete(swap);
|
||||
}, FADE_MS);
|
||||
pendingTimeouts.add(swap);
|
||||
}, ROTATE_MS);
|
||||
return () => {
|
||||
clearInterval(rotate);
|
||||
pendingTimeouts.forEach(clearTimeout);
|
||||
};
|
||||
}, [tips.length]);
|
||||
|
||||
if (tips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex justify-start" aria-live="polite">
|
||||
<div className="inline-flex items-start gap-2 px-2.5 py-1 max-w-[80%] text-2xs text-slate-500 dark:text-slate-400">
|
||||
<span className="flex-shrink-0 opacity-70" aria-hidden="true">💡</span>
|
||||
<span className="font-medium flex-shrink-0 text-slate-400 dark:text-slate-500">
|
||||
{t('tips.label')}
|
||||
</span>
|
||||
<span
|
||||
className={`transition-opacity ease-in-out ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{ transitionDuration: `${FADE_MS}ms` }}
|
||||
>
|
||||
{tips[idx]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles } from '../../api';
|
||||
import { AttachmentDropzone } from './AttachmentDropzone';
|
||||
import { PromptCoachPanel } from './PromptCoachPanel';
|
||||
import { ScheduleFields } from './ScheduleFields';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { resolvePieceOptions } from '../../lib/splitPieces';
|
||||
@ -196,6 +197,13 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
{/* Attachments */}
|
||||
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
|
||||
|
||||
{/* Prompt coach (on-demand draft evaluation) */}
|
||||
<PromptCoachPanel
|
||||
body={form.body}
|
||||
piece={initialPiece ?? form.piece}
|
||||
onApplyRewrite={(text) => setForm(prev => ({ ...prev, body: text }))}
|
||||
/>
|
||||
|
||||
{/* MCP warnings (always visible when applicable) */}
|
||||
{missingMcp.length > 0 && (
|
||||
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/15 border border-yellow-300 dark:border-yellow-500/30 rounded text-xs text-yellow-900 dark:text-yellow-300 space-y-2">
|
||||
|
||||
142
ui/src/components/create/PromptCoachPanel.tsx
Normal file
142
ui/src/components/create/PromptCoachPanel.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { evaluatePrompt, type PromptCoachResult } from '../../api';
|
||||
|
||||
interface PromptCoachPanelProps {
|
||||
/** Current draft body from the dialog's textarea. */
|
||||
body: string;
|
||||
/** Selected piece ('auto' = unset). */
|
||||
piece?: string;
|
||||
/** Inject the rewrite suggestion back into the textarea. */
|
||||
onApplyRewrite: (text: string) => void;
|
||||
}
|
||||
|
||||
function scoreColor(score: number, max: number): string {
|
||||
const ratio = max > 0 ? score / max : 0;
|
||||
if (ratio >= 0.75) return 'text-emerald-600 dark:text-emerald-400';
|
||||
if (ratio >= 0.4) return 'text-amber-600 dark:text-amber-400';
|
||||
return 'text-rose-600 dark:text-rose-400';
|
||||
}
|
||||
|
||||
export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPanelProps) {
|
||||
const { t } = useTranslation('create');
|
||||
const mutation = useMutation<PromptCoachResult, Error, void>({
|
||||
mutationFn: () => evaluatePrompt({ instruction: body.trim(), piece }),
|
||||
});
|
||||
// Discard a stale evaluation once the draft (body or piece) changes, so the
|
||||
// panel never shows a score/rewrite that no longer matches the textarea —
|
||||
// and "Apply to request" can't overwrite the draft with an outdated rewrite.
|
||||
const { reset } = mutation;
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [body, piece, reset]);
|
||||
const result = mutation.data;
|
||||
const disabled = !body.trim() || mutation.isPending;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => mutation.mutate()}
|
||||
className="px-3 py-1.5 border border-accent/40 text-accent rounded-xl text-xs font-bold hover:bg-accent/5 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
|
||||
>
|
||||
{mutation.isPending && (
|
||||
<span className="inline-block w-3 h-3 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
)}
|
||||
{mutation.isPending ? t('coach.evaluating') : t('coach.evaluate')}
|
||||
</button>
|
||||
<span className="text-2xs text-slate-400">{t('coach.hint')}</span>
|
||||
</div>
|
||||
|
||||
{mutation.isError && (
|
||||
<div className="mt-2 text-xs text-rose-600">{t('coach.failed')}</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="mt-3 space-y-3 border border-slate-200 dark:border-slate-700 rounded-xl p-4 bg-slate-50/60 dark:bg-slate-800/40">
|
||||
{/* Overall score */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-bold text-slate-500">{t('coach.overall')}</span>
|
||||
<span className={`text-2xl font-extrabold ${scoreColor(result.overall, 100)}`}>{result.overall}</span>
|
||||
<span className="text-xs text-slate-400">/ 100</span>
|
||||
</div>
|
||||
|
||||
{/* Axes */}
|
||||
{result.axes.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{result.axes.map((ax, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200">{ax.name}</span>
|
||||
<span className={`font-bold ${scoreColor(ax.score, 10)}`}>{ax.score}/10</span>
|
||||
</div>
|
||||
{ax.comment && <div className="text-slate-500 dark:text-slate-400 mt-0.5">{ax.comment}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rewrite suggestion */}
|
||||
{result.rewrite && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-bold text-slate-500">{t('coach.rewrite')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApplyRewrite(result.rewrite)}
|
||||
className="px-2 py-0.5 rounded bg-accent text-accent-fg text-2xs font-bold hover:bg-accent-deep"
|
||||
>
|
||||
{t('coach.applyRewrite')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-slate-700 dark:text-slate-200 whitespace-pre-wrap bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-slate-700 rounded-lg p-2.5 leading-relaxed">
|
||||
{result.rewrite}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Predicted piece */}
|
||||
{result.predicted_piece && (
|
||||
<div className="text-xs">
|
||||
<span className="font-bold text-slate-500">{t('coach.predictedPiece')}</span>{' '}
|
||||
<span className="font-semibold text-accent">{result.predicted_piece.name}</span>
|
||||
{result.predicted_piece.reason && (
|
||||
<span className="text-slate-500 dark:text-slate-400"> — {result.predicted_piece.reason}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MAESTRO tips */}
|
||||
{result.maestro_tips.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-500 mb-1">{t('coach.maestroTips')}</div>
|
||||
<ul className="space-y-1">
|
||||
{result.maestro_tips.map((tip, i) => (
|
||||
<li key={i} className="text-xs text-slate-600 dark:text-slate-300">
|
||||
<span className="font-semibold">{tip.feature}</span>
|
||||
{tip.suggestion && <span className="text-slate-500 dark:text-slate-400"> — {tip.suggestion}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Personalized notes */}
|
||||
{result.personalized.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-500 mb-1">{t('coach.personalized')}</div>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{result.personalized.map((note, i) => (
|
||||
<li key={i} className="text-xs text-slate-600 dark:text-slate-300">{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
313
ui/src/components/setup/SetupWizard.tsx
Normal file
313
ui/src/components/setup/SetupWizard.tsx
Normal file
@ -0,0 +1,313 @@
|
||||
import { useState } from 'react';
|
||||
import type { SetupStatus } from '../../hooks/useSetupState';
|
||||
|
||||
// Full-screen first-run onboarding for a fresh, no-auth `docker compose up`.
|
||||
// Configures the LLM connection (required) plus an optional server port and
|
||||
// auth bootstrap, then applies everything in ONE call to /api/setup/apply.
|
||||
// See docs/superpowers/specs/2026-06-16-browser-setup-wizard-design.md.
|
||||
|
||||
type ConnectionType = 'direct' | 'aao_gateway';
|
||||
type AuthMode = 'none' | 'local' | 'oauth';
|
||||
type OAuthProvider = 'google' | 'gitea';
|
||||
|
||||
interface ApplyResult {
|
||||
ok: boolean;
|
||||
restartRequired: boolean;
|
||||
adminEmail?: string;
|
||||
}
|
||||
|
||||
const PRIMARY_BTN =
|
||||
'px-4 py-2 text-sm font-semibold bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50 transition-colors';
|
||||
const SECONDARY_BTN =
|
||||
'px-4 py-2 text-sm text-accent border border-accent rounded-lg hover:bg-accent-soft disabled:opacity-50 transition-colors';
|
||||
const INPUT =
|
||||
'w-full px-3 py-2 text-sm border border-slate-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-accent/40';
|
||||
const LABEL = 'block text-sm font-medium text-slate-700 mb-1';
|
||||
|
||||
function restartCommand(hint: SetupStatus['deployHint']): string {
|
||||
return hint === 'docker' ? 'docker compose restart' : 'scripts/server.sh restart';
|
||||
}
|
||||
|
||||
export function SetupWizard({ status, onComplete }: { status: SetupStatus; onComplete: () => void }) {
|
||||
const tokenRequired = status.tokenRequired;
|
||||
|
||||
const [token, setToken] = useState('');
|
||||
|
||||
// Step 1 — LLM (required)
|
||||
const [connectionType, setConnectionType] = useState<ConnectionType>('direct');
|
||||
const [endpoint, setEndpoint] = useState('http://localhost:11434/v1');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [probing, setProbing] = useState(false);
|
||||
const [probeError, setProbeError] = useState<string | null>(null);
|
||||
|
||||
// Step 2 — port (optional)
|
||||
const [port, setPort] = useState('');
|
||||
|
||||
// Step 3 — auth (optional)
|
||||
const [authMode, setAuthMode] = useState<AuthMode>('none');
|
||||
const [localEmail, setLocalEmail] = useState('');
|
||||
const [localPassword, setLocalPassword] = useState('');
|
||||
const [oauthProvider, setOauthProvider] = useState<OAuthProvider>('gitea');
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [clientSecret, setClientSecret] = useState('');
|
||||
const [callbackUrl, setCallbackUrl] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [oauthAdminEmail, setOauthAdminEmail] = useState('');
|
||||
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<ApplyResult | null>(null);
|
||||
|
||||
function headers(): Record<string, string> {
|
||||
const h: Record<string, string> = { 'content-type': 'application/json' };
|
||||
if (tokenRequired && token.trim()) h['x-setup-token'] = token.trim();
|
||||
return h;
|
||||
}
|
||||
|
||||
async function runProbe() {
|
||||
setProbing(true);
|
||||
setProbeError(null);
|
||||
setModels([]);
|
||||
try {
|
||||
const res = await fetch('/api/setup/probe', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ connectionType, endpoint, apiKey: apiKey || undefined }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
setProbeError(data.error || `connection failed (HTTP ${res.status})`);
|
||||
return;
|
||||
}
|
||||
const list: string[] = Array.isArray(data.models) ? data.models : [];
|
||||
setModels(list);
|
||||
if (list.length && !model) setModel(list[0]);
|
||||
if (!list.length) setProbeError('connected, but no models were returned — enter a model name manually');
|
||||
} catch (e) {
|
||||
setProbeError(String(e));
|
||||
} finally {
|
||||
setProbing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function authBlock(): unknown | undefined {
|
||||
if (authMode === 'local') {
|
||||
return { mode: 'local', local: { email: localEmail.trim(), password: localPassword } };
|
||||
}
|
||||
if (authMode === 'oauth') {
|
||||
return {
|
||||
mode: 'oauth',
|
||||
oauth: {
|
||||
provider: oauthProvider,
|
||||
clientId: clientId.trim(),
|
||||
clientSecret: clientSecret.trim(),
|
||||
callbackUrl: callbackUrl.trim(),
|
||||
adminEmail: oauthAdminEmail.trim(),
|
||||
...(oauthProvider === 'gitea' ? { baseUrl: baseUrl.trim() } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const canApply =
|
||||
(!tokenRequired || token.trim().length > 0) &&
|
||||
endpoint.trim().length > 0 &&
|
||||
model.trim().length > 0 &&
|
||||
(connectionType !== 'aao_gateway' || apiKey.trim().length > 0) &&
|
||||
(authMode !== 'local' || (localEmail.trim().length > 0 && localPassword.length >= 8)) &&
|
||||
(authMode !== 'oauth' ||
|
||||
(clientId.trim() &&
|
||||
clientSecret.trim() &&
|
||||
callbackUrl.trim() &&
|
||||
oauthAdminEmail.trim().includes('@') &&
|
||||
(oauthProvider !== 'gitea' || baseUrl.trim())));
|
||||
|
||||
async function runApply() {
|
||||
setApplying(true);
|
||||
setApplyError(null);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
llm: { connectionType, endpoint: endpoint.trim(), model: model.trim(), apiKey: apiKey || undefined },
|
||||
};
|
||||
if (port.trim()) body.port = Number(port.trim());
|
||||
const auth = authBlock();
|
||||
if (auth) body.auth = auth;
|
||||
|
||||
const res = await fetch('/api/setup/apply', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
setApplyError(data.error || `apply failed (HTTP ${res.status})`);
|
||||
return;
|
||||
}
|
||||
const r: ApplyResult = { ok: true, restartRequired: !!data.restartRequired, adminEmail: data.adminEmail };
|
||||
setResult(r);
|
||||
if (!r.restartRequired) {
|
||||
// LLM-only: the wizard's job is done — refresh into the app.
|
||||
onComplete();
|
||||
}
|
||||
} catch (e) {
|
||||
setApplyError(String(e));
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Finish screen (restart required) ---
|
||||
if (result?.restartRequired) {
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="text-xl font-semibold text-slate-800 mb-2">Almost there — one restart needed</h1>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Your LLM connection is live. The port and/or authentication changes take effect after a restart.
|
||||
</p>
|
||||
<div className="bg-slate-900 text-slate-100 text-sm rounded-lg px-4 py-3 font-mono mb-4 select-all">
|
||||
{restartCommand(status.deployHint)}
|
||||
</div>
|
||||
{result.adminEmail && (
|
||||
<p className="text-sm text-slate-600">
|
||||
After restart, sign in as <span className="font-semibold">{result.adminEmail}</span> with the password you
|
||||
just set. Change it from <span className="font-medium">Settings → Account</span> afterwards.
|
||||
</p>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Wizard form ---
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="text-xl font-semibold text-slate-800 mb-1">Welcome — let's get you set up</h1>
|
||||
<p className="text-sm text-slate-600 mb-6">
|
||||
Connect a language model to start running tasks. Optionally set the server port and turn on sign-in.
|
||||
</p>
|
||||
|
||||
{tokenRequired && (
|
||||
<Section title="Setup token" subtitle="Find it in the server logs: “open the UI and enter this setup token”.">
|
||||
<input className={INPUT} value={token} onChange={(e) => setToken(e.target.value)} placeholder="paste the setup token" />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="1 · Language model" subtitle="Required. This is reflected immediately — no restart.">
|
||||
<label className={LABEL}>Connection</label>
|
||||
<select
|
||||
className={`${INPUT} mb-3`}
|
||||
value={connectionType}
|
||||
onChange={(e) => setConnectionType(e.target.value as ConnectionType)}
|
||||
>
|
||||
<option value="direct">Direct (Ollama / vLLM / OpenAI-compatible)</option>
|
||||
<option value="aao_gateway">AAO gateway</option>
|
||||
</select>
|
||||
|
||||
<label className={LABEL}>Endpoint URL</label>
|
||||
<input className={`${INPUT} mb-3`} value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="http://localhost:11434/v1" />
|
||||
|
||||
{connectionType === 'aao_gateway' && (
|
||||
<>
|
||||
<label className={LABEL}>API key</label>
|
||||
<input className={`${INPUT} mb-3`} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-aao-…" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<button className={SECONDARY_BTN} onClick={runProbe} disabled={probing || !endpoint.trim() || (tokenRequired && !token.trim())}>
|
||||
{probing ? 'Testing…' : 'Test connection'}
|
||||
</button>
|
||||
{probeError && <span className="text-sm text-red-600">{probeError}</span>}
|
||||
{!probeError && models.length > 0 && <span className="text-sm text-green-600">Found {models.length} model(s)</span>}
|
||||
</div>
|
||||
|
||||
<label className={LABEL}>Model</label>
|
||||
{models.length > 0 ? (
|
||||
<select className={INPUT} value={model} onChange={(e) => setModel(e.target.value)}>
|
||||
{!models.includes(model) && model && <option value={model}>{model}</option>}
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input className={INPUT} value={model} onChange={(e) => setModel(e.target.value)} placeholder="e.g. llama3.1:8b" />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="2 · Server port" subtitle="Optional. Leave blank to keep the current port. Takes effect after a restart.">
|
||||
<input className={INPUT} value={port} onChange={(e) => setPort(e.target.value)} placeholder={String(status.port)} inputMode="numeric" />
|
||||
</Section>
|
||||
|
||||
<Section title="3 · Sign-in" subtitle="Optional. Leave off to stay open (no login). Takes effect after a restart.">
|
||||
<select className={`${INPUT} mb-3`} value={authMode} onChange={(e) => setAuthMode(e.target.value as AuthMode)}>
|
||||
<option value="none">No sign-in (open)</option>
|
||||
<option value="local">Email + password (first admin)</option>
|
||||
<option value="oauth">OAuth (Google / Gitea)</option>
|
||||
</select>
|
||||
|
||||
{authMode === 'local' && (
|
||||
<>
|
||||
<label className={LABEL}>Admin email</label>
|
||||
<input className={`${INPUT} mb-3`} value={localEmail} onChange={(e) => setLocalEmail(e.target.value)} placeholder="admin@example.com" />
|
||||
<label className={LABEL}>Admin password (min 8 chars)</label>
|
||||
<input className={INPUT} type="password" value={localPassword} onChange={(e) => setLocalPassword(e.target.value)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{authMode === 'oauth' && (
|
||||
<>
|
||||
<label className={LABEL}>Provider</label>
|
||||
<select className={`${INPUT} mb-3`} value={oauthProvider} onChange={(e) => setOauthProvider(e.target.value as OAuthProvider)}>
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="google">Google</option>
|
||||
</select>
|
||||
<label className={LABEL}>Client ID</label>
|
||||
<input className={`${INPUT} mb-3`} value={clientId} onChange={(e) => setClientId(e.target.value)} />
|
||||
<label className={LABEL}>Client secret</label>
|
||||
<input className={`${INPUT} mb-3`} type="password" value={clientSecret} onChange={(e) => setClientSecret(e.target.value)} />
|
||||
<label className={LABEL}>Callback URL</label>
|
||||
<input className={`${INPUT} mb-3`} value={callbackUrl} onChange={(e) => setCallbackUrl(e.target.value)} placeholder="https://your-host/auth/gitea/callback" />
|
||||
<label className={LABEL}>Admin email</label>
|
||||
<input className={`${INPUT} mb-3`} value={oauthAdminEmail} onChange={(e) => setOauthAdminEmail(e.target.value)} placeholder="you@example.com — becomes admin on first login" />
|
||||
{oauthProvider === 'gitea' && (
|
||||
<>
|
||||
<label className={LABEL}>Gitea base URL</label>
|
||||
<input className={INPUT} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://gitea.example.com" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{applyError && <p className="text-sm text-red-600 mb-3">{applyError}</p>}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button className={PRIMARY_BTN} onClick={runApply} disabled={applying || !canApply}>
|
||||
{applying ? 'Applying…' : 'Apply & start'}
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-dvh overflow-y-auto bg-slate-50 flex items-start justify-center py-10 px-4">
|
||||
<div className="w-full max-w-xl bg-white border border-slate-200 rounded-2xl shadow-sm p-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-6 pb-6 border-b border-slate-100 last:border-b-0">
|
||||
<h2 className="text-sm font-semibold text-slate-800">{title}</h2>
|
||||
{subtitle && <p className="text-xs text-slate-500 mb-3">{subtitle}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
ui/src/hooks/useSetupState.ts
Normal file
32
ui/src/hooks/useSetupState.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export interface SetupStatus {
|
||||
/** True when the runtime has no usable LLM worker → show the wizard. */
|
||||
needsSetup: boolean;
|
||||
/** True when an OAuth provider or local auth is active. */
|
||||
authActive: boolean;
|
||||
/** Resolved listen port (for the restart hint). */
|
||||
port: number;
|
||||
/** How the server was started, for tailored restart instructions. */
|
||||
deployHint: 'docker' | 'source';
|
||||
/** True when a setup token must be supplied with mutating setup calls. */
|
||||
tokenRequired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls GET /api/setup/status. Short staleTime so the wizard disappears
|
||||
* promptly once the LLM is configured (the server recomputes needsSetup per
|
||||
* request). Never throws into the gate — on error we treat it as "no wizard".
|
||||
*/
|
||||
export function useSetupState() {
|
||||
return useQuery<SetupStatus>({
|
||||
queryKey: ['setup', 'status'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/setup/status');
|
||||
if (!res.ok) throw new Error(`setup status ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "実行中",
|
||||
|
||||
@ -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": "キャンセル",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user