feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import Database from 'better-sqlite3';
|
||||
import { TOOL_DEFS, executeTool, resolveAppDocPath, setAppDocsDeps } from './app-docs.js';
|
||||
import type { ToolContext } from './core.js';
|
||||
|
||||
const baseCtx: ToolContext = {
|
||||
workspacePath: '/tmp/app-docs-test',
|
||||
editAllowed: false,
|
||||
};
|
||||
|
||||
describe('app-docs: TOOL_DEFS', () => {
|
||||
it('exposes ReadAppDoc, ListAppDocs, GetMyOrchestratorState', () => {
|
||||
expect(TOOL_DEFS).toHaveProperty('ReadAppDoc');
|
||||
expect(TOOL_DEFS).toHaveProperty('ListAppDocs');
|
||||
expect(TOOL_DEFS).toHaveProperty('GetMyOrchestratorState');
|
||||
});
|
||||
});
|
||||
|
||||
describe('app-docs: resolveAppDocPath (path safety)', () => {
|
||||
it('rejects claude-md (internal doc)', () => {
|
||||
expect(resolveAppDocPath('claude-md')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects CLAUDE.md (internal doc)', () => {
|
||||
expect(resolveAppDocPath('CLAUDE.md')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects agents-md (internal doc)', () => {
|
||||
expect(resolveAppDocPath('agents-md')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects AGENTS.md (internal doc)', () => {
|
||||
expect(resolveAppDocPath('AGENTS.md')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects readme / README.md (internal doc)', () => {
|
||||
expect(resolveAppDocPath('readme')).toBeNull();
|
||||
expect(resolveAppDocPath('README.md')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects docs/superpowers/* (internal implementation plans)', () => {
|
||||
expect(resolveAppDocPath('docs/superpowers/plans/foo')).toBeNull();
|
||||
expect(resolveAppDocPath('docs/superpowers/anything')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects docs/maintenance-checklist (internal ops reference)', () => {
|
||||
expect(resolveAppDocPath('docs/maintenance-checklist')).toBeNull();
|
||||
expect(resolveAppDocPath('docs/maintenance-checklist.md')).toBeNull();
|
||||
});
|
||||
|
||||
it('still resolves docs/mcp (allowed user-facing doc)', () => {
|
||||
const r = resolveAppDocPath('docs/mcp');
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.label).toBe('docs/mcp');
|
||||
expect(r!.path).toMatch(/docs\/mcp\.md$/);
|
||||
});
|
||||
|
||||
it('resolves piece/<name>', () => {
|
||||
const r = resolveAppDocPath('piece/chat');
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.label).toBe('pieces/chat.yaml');
|
||||
expect(r!.path).toMatch(/pieces\/chat\.yaml$/);
|
||||
});
|
||||
|
||||
it('resolves docs/<path> with auto .md suffix', () => {
|
||||
const r = resolveAppDocPath('docs/architecture');
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.label).toBe('docs/architecture');
|
||||
expect(r!.path).toMatch(/docs\/architecture\.md$/);
|
||||
});
|
||||
|
||||
it('resolves docs/<path>.md without doubling extension', () => {
|
||||
const r = resolveAppDocPath('docs/architecture.md');
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.path).toMatch(/docs\/architecture\.md$/);
|
||||
expect(r!.path).not.toMatch(/\.md\.md$/);
|
||||
});
|
||||
|
||||
it('resolves tool/<name> (lowercased)', () => {
|
||||
const r = resolveAppDocPath('tool/BrowseWeb');
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.label).toBe('docs/tools/browseweb.md');
|
||||
});
|
||||
|
||||
it('rejects path traversal via ..', () => {
|
||||
expect(resolveAppDocPath('../etc/passwd')).toBeNull();
|
||||
expect(resolveAppDocPath('docs/../../etc/passwd')).toBeNull();
|
||||
expect(resolveAppDocPath('piece/../secret')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects names with invalid characters', () => {
|
||||
expect(resolveAppDocPath('piece/with space')).toBeNull();
|
||||
expect(resolveAppDocPath('piece/$shell')).toBeNull();
|
||||
expect(resolveAppDocPath('docs/with;semicolon')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects unknown top-level names', () => {
|
||||
expect(resolveAppDocPath('random-name')).toBeNull();
|
||||
expect(resolveAppDocPath('config-yaml')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects empty / non-string', () => {
|
||||
expect(resolveAppDocPath('')).toBeNull();
|
||||
expect(resolveAppDocPath(null as unknown as string)).toBeNull();
|
||||
expect(resolveAppDocPath(undefined as unknown as string)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('app-docs: ReadAppDoc execution', () => {
|
||||
it('rejects claude-md with an error (internal doc blocked)', async () => {
|
||||
const res = await executeTool('ReadAppDoc', { name: 'claude-md' }, baseCtx);
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.isError).toBe(true);
|
||||
expect(res!.output).toContain('不正な name');
|
||||
});
|
||||
|
||||
it('returns error with hint when name is missing', async () => {
|
||||
const res = await executeTool('ReadAppDoc', {}, baseCtx);
|
||||
expect(res!.isError).toBe(true);
|
||||
expect(res!.output).toContain('name パラメータ');
|
||||
});
|
||||
|
||||
it('returns error with hint when name is invalid', async () => {
|
||||
const res = await executeTool('ReadAppDoc', { name: 'bogus name with space' }, baseCtx);
|
||||
expect(res!.isError).toBe(true);
|
||||
expect(res!.output).toContain('不正な name');
|
||||
expect(res!.output).toContain('ListAppDocs');
|
||||
});
|
||||
|
||||
it('returns error when name resolves but file does not exist', async () => {
|
||||
const res = await executeTool('ReadAppDoc', { name: 'docs/no-such-doc' }, baseCtx);
|
||||
expect(res!.isError).toBe(true);
|
||||
expect(res!.output).toContain('存在しません');
|
||||
});
|
||||
|
||||
it('reads an existing piece YAML', async () => {
|
||||
const res = await executeTool('ReadAppDoc', { name: 'piece/chat' }, baseCtx);
|
||||
expect(res!.isError).toBe(false);
|
||||
expect(res!.output).toContain('pieces/chat.yaml');
|
||||
expect(res!.output).toContain('name: chat');
|
||||
});
|
||||
|
||||
it('returns null for unrelated tool name', async () => {
|
||||
const res = await executeTool('SomeOtherTool', {}, baseCtx);
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('app-docs: ListAppDocs', () => {
|
||||
it('groups output into piece / docs / tools sections (no project overview)', async () => {
|
||||
const res = await executeTool('ListAppDocs', {}, baseCtx);
|
||||
expect(res!.isError).toBe(false);
|
||||
const out = res!.output;
|
||||
// Removed section
|
||||
expect(out).not.toContain('# プロジェクト概要');
|
||||
// Remaining sections
|
||||
expect(out).toContain('# Piece 一覧');
|
||||
expect(out).toContain('# ドキュメント');
|
||||
expect(out).toContain('# ツール参照');
|
||||
// Should mention at least one known piece
|
||||
expect(out).toContain('piece/chat');
|
||||
});
|
||||
|
||||
it('does not list CLAUDE.md / AGENTS.md / README.md', async () => {
|
||||
const res = await executeTool('ListAppDocs', {}, baseCtx);
|
||||
const out = res!.output;
|
||||
expect(out).not.toContain('claude-md');
|
||||
expect(out).not.toContain('agents-md');
|
||||
expect(out).not.toContain('readme');
|
||||
expect(out).not.toContain('CLAUDE.md');
|
||||
expect(out).not.toContain('AGENTS.md');
|
||||
});
|
||||
|
||||
it('does not list docs/superpowers or docs/maintenance-checklist', async () => {
|
||||
const res = await executeTool('ListAppDocs', {}, baseCtx);
|
||||
const out = res!.output;
|
||||
expect(out).not.toContain('superpowers');
|
||||
expect(out).not.toContain('maintenance-checklist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('app-docs: GetMyOrchestratorState', () => {
|
||||
let tmpDir: string;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'app-docs-state-'));
|
||||
db = new Database(':memory:');
|
||||
// Minimal schema: just the columns we read
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
CREATE TABLE local_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
piece_name TEXT,
|
||||
owner_id TEXT,
|
||||
state TEXT,
|
||||
created_at TEXT
|
||||
);
|
||||
CREATE TABLE jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
repo TEXT,
|
||||
issue_number INTEGER,
|
||||
status TEXT,
|
||||
created_at TEXT
|
||||
);
|
||||
CREATE TABLE mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
auth_kind TEXT,
|
||||
owner_id TEXT,
|
||||
enabled INTEGER
|
||||
);
|
||||
CREATE TABLE user_mcp_tokens (
|
||||
user_id TEXT,
|
||||
server_id TEXT,
|
||||
expires_at TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
db.prepare('INSERT INTO users (id, email, name, role) VALUES (?, ?, ?, ?)').run('alice', '[email protected]', 'Alice', 'admin');
|
||||
|
||||
db.prepare('INSERT INTO local_tasks (title, piece_name, owner_id, state, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('Hello task', 'chat', 'alice', 'open', '2026-05-10 09:00:00');
|
||||
db.prepare('INSERT INTO local_tasks (title, piece_name, owner_id, state, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('Other task', 'research', 'alice', 'open', '2026-05-09 09:00:00');
|
||||
|
||||
db.prepare('INSERT INTO mcp_servers (id, name, auth_kind, owner_id, enabled) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('canva', 'Canva', 'oauth', null, 1);
|
||||
db.prepare('INSERT INTO mcp_servers (id, name, auth_kind, owner_id, enabled) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('tundoc', 'Tundoc', 'api_key', 'alice', 1);
|
||||
|
||||
db.prepare('INSERT INTO user_mcp_tokens (user_id, server_id, expires_at) VALUES (?, ?, ?)')
|
||||
.run('alice', 'canva', '2027-01-01 00:00:00');
|
||||
|
||||
setAppDocsDeps({ db, userFolderRoot: tmpDir });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
setAppDocsDeps(null);
|
||||
});
|
||||
|
||||
it('requires userId in ctx', async () => {
|
||||
const res = await executeTool('GetMyOrchestratorState', {}, baseCtx);
|
||||
expect(res!.isError).toBe(true);
|
||||
expect(res!.output).toContain('authenticated user');
|
||||
});
|
||||
|
||||
it('returns sections covering user / tasks / MCP / user folder', async () => {
|
||||
const ctx: ToolContext = { ...baseCtx, userId: 'alice' };
|
||||
const res = await executeTool('GetMyOrchestratorState', {}, ctx);
|
||||
expect(res!.isError).toBe(false);
|
||||
const out = res!.output;
|
||||
expect(out).toContain('## ユーザー');
|
||||
expect(out).toContain('Alice');
|
||||
expect(out).toContain('## 最近のタスク');
|
||||
expect(out).toContain('Hello task');
|
||||
expect(out).toContain('## MCP サーバー');
|
||||
expect(out).toContain('canva');
|
||||
expect(out).toContain('連携済み');
|
||||
expect(out).toContain('tundoc');
|
||||
expect(out).toContain('api_key');
|
||||
expect(out).toContain('## ユーザーフォルダ');
|
||||
});
|
||||
|
||||
it('handles empty memory/scripts dirs cleanly', async () => {
|
||||
// Build empty user folder
|
||||
mkdirSync(join(tmpDir, 'alice'));
|
||||
mkdirSync(join(tmpDir, 'alice', 'memory'));
|
||||
mkdirSync(join(tmpDir, 'alice', 'scripts'));
|
||||
writeFileSync(join(tmpDir, 'alice', 'AGENTS.md'), 'hello');
|
||||
|
||||
const ctx: ToolContext = { ...baseCtx, userId: 'alice' };
|
||||
const res = await executeTool('GetMyOrchestratorState', {}, ctx);
|
||||
expect(res!.isError).toBe(false);
|
||||
expect(res!.output).toContain('AGENTS.md: 5 bytes');
|
||||
expect(res!.output).toContain('memory/: 0 件');
|
||||
expect(res!.output).toContain('scripts/: 0 件');
|
||||
});
|
||||
|
||||
it('does not leak OAuth secrets or tokens in output', async () => {
|
||||
const ctx: ToolContext = { ...baseCtx, userId: 'alice' };
|
||||
const res = await executeTool('GetMyOrchestratorState', {}, ctx);
|
||||
const out = res!.output;
|
||||
// Should NOT contain any of the encrypted blob / token columns
|
||||
expect(out).not.toMatch(/oauth_client_secret/);
|
||||
expect(out).not.toMatch(/static_token/);
|
||||
expect(out).not.toMatch(/access_token/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user