feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+499
View File
@@ -0,0 +1,499 @@
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { mountPiecesApi } from './pieces-api.js';
function makeGeneralPieceYaml(): string {
return [
'name: general',
'description: 汎用タスク',
'max_movements: 25',
'initial_movement: understand',
'movements:',
' - name: understand',
' edit: false',
' persona: analyst',
' instruction: |',
' タスクを確認する。',
' allowed_tools: [Read, Glob]',
' default_next: execute',
' rules:',
' - condition: 方針が立った',
' next: execute',
' - name: execute',
' edit: true',
' persona: worker',
' instruction: |',
' 作業を実行する。',
' allowed_tools: [Read, Write]',
' default_next: COMPLETE',
' rules:',
' - condition: 完了',
' next: COMPLETE',
].join('\n');
}
function makeMinimalPieceYaml(name: string, description = 'x'): string {
return [
`name: ${name}`,
`description: ${description}`,
'max_movements: 1',
'initial_movement: only',
'movements:',
' - name: only',
' edit: false',
' persona: p',
' instruction: i',
' allowed_tools: [Read]',
' default_next: COMPLETE',
' rules: []',
].join('\n');
}
describe('Pieces API (no auth — legacy behavior)', () => {
let app: express.Application;
let piecesDir: string;
beforeEach(() => {
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-'));
piecesDir = join(tempDir, 'pieces');
mkdirSync(piecesDir);
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
app = express();
app.use(express.json());
mountPiecesApi(app, { piecesDir });
});
it('GET /api/pieces returns piece list', async () => {
const res = await request(app).get('/api/pieces');
expect(res.status).toBe(200);
expect(res.body.pieces).toHaveLength(1);
expect(res.body.pieces[0].name).toBe('general');
expect(res.body.pieces[0].source).toBe('builtin');
expect(res.body.pieces[0].custom).toBe(false);
});
it('GET /api/pieces/:name returns full piece', async () => {
const res = await request(app).get('/api/pieces/general');
expect(res.status).toBe(200);
expect(res.body.piece.name).toBe('general');
expect(res.body.piece.movements).toHaveLength(2);
expect(res.body.source).toBe('builtin');
});
it('GET /api/pieces/:name returns 404 for unknown', async () => {
const res = await request(app).get('/api/pieces/nonexistent');
expect(res.status).toBe(404);
});
it('PUT /api/pieces/:name updates piece', async () => {
const res = await request(app)
.put('/api/pieces/general')
.send({
name: 'general',
description: '更新済み',
max_movements: 30,
initial_movement: 'understand',
movements: [
{ name: 'understand', edit: false, persona: 'analyst', instruction: 'テスト', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('POST /api/pieces creates new piece', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'custom',
description: 'カスタム',
max_movements: 10,
initial_movement: 'work',
movements: [
{ name: 'work', edit: true, persona: 'worker', instruction: '作業する', allowed_tools: ['Read', 'Write'], default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(201);
expect(res.body.ok).toBe(true);
});
it('POST /api/pieces rejects duplicate name', async () => {
const res = await request(app)
.post('/api/pieces')
.send({ name: 'general', description: 'x', max_movements: 1, initial_movement: 'a', movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }] });
expect(res.status).toBe(409);
});
it('POST /api/pieces rejects rules[].next: COMPLETE (Phase 6b)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'phase6b-reject',
description: 'should be rejected',
max_movements: 1,
initial_movement: 'only',
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
rules: [{ condition: 'ok', next: 'COMPLETE' }] },
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/rules\[\]\.next cannot be "COMPLETE"/);
});
it('POST /api/pieces accepts default_next: COMPLETE (engine-internal sentinel)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'phase6b-default-ok',
description: 'default_next is fine',
max_movements: 1,
initial_movement: 'only',
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(201);
});
// Phase 4: per-movement SSH connection allowlist validation.
it('POST /api/pieces rejects SshExec without allowed_ssh_connections', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-missing-allowlist',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/allowed_ssh_connections is required/);
});
it('POST /api/pieces accepts SshExec with UUID allowlist', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-uuid-ok',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('POST /api/pieces accepts SshExec with ["*"] wildcard', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-wildcard-ok',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: ['*'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('POST /api/pieces accepts SshExec with empty allowlist (explicit deny)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-empty-ok',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: [],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('POST /api/pieces rejects allowed_ssh_connections with bad format', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-bad-format',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: ['BAD-NOT-LOWERCASE'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/must be '\*' or a lowercase hex/);
});
it('POST /api/pieces rejects non-array allowed_ssh_connections', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-non-array',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: 'not-an-array',
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/must be an array/);
});
it('POST /api/pieces accepts allowed_ssh_connections without SSH tools (no-op)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-noop',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('DELETE /api/pieces/:name deletes piece', async () => {
await request(app).post('/api/pieces').send({
name: 'deleteme', description: 'x', max_movements: 1, initial_movement: 'a',
movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }],
});
const res = await request(app).delete('/api/pieces/deleteme');
expect(res.status).toBe(200);
});
it('DELETE /api/pieces/general is forbidden', async () => {
const res = await request(app).delete('/api/pieces/general');
expect(res.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// Auth-aware tests (per-user custom pieces + non-admin write authz)
// ---------------------------------------------------------------------------
type UserShape = { id: string; role: 'admin' | 'user' };
function makeAuthApp(piecesDir: string, userPiecesRootDir: string, user: UserShape | null): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
if (user) (req as any).user = user;
next();
});
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
return app;
}
describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
let piecesDir: string;
let userPiecesRootDir: string;
beforeEach(() => {
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-auth-'));
piecesDir = join(tempDir, 'pieces');
userPiecesRootDir = join(tempDir, 'users');
mkdirSync(piecesDir);
mkdirSync(userPiecesRootDir);
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
writeFileSync(join(piecesDir, 'chat.yaml'), makeMinimalPieceYaml('chat', 'built-in chat'));
});
it('GET /api/pieces returns built-ins for any authenticated non-admin', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.get('/api/pieces');
expect(res.status).toBe(200);
expect(res.body.pieces.map((p: any) => p.name).sort()).toEqual(['chat', 'general']);
for (const p of res.body.pieces) {
expect(p.source).toBe('builtin');
expect(p.custom).toBe(false);
}
});
it('GET /api/pieces merges caller\'s user-custom pieces (own only)', async () => {
// Alice has my-tool, Bob has bob-tool
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-tool.yaml'), makeMinimalPieceYaml('my-tool', "alice's piece"));
mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-tool.yaml'), makeMinimalPieceYaml('bob-tool', "bob's piece"));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.get('/api/pieces');
expect(res.status).toBe(200);
const byName = Object.fromEntries(res.body.pieces.map((p: any) => [p.name, p]));
expect(Object.keys(byName).sort()).toEqual(['chat', 'general', 'my-tool']);
expect(byName['my-tool'].source).toBe('user-custom');
expect(byName['my-tool'].ownerId).toBe('alice');
expect(byName['my-tool'].custom).toBe(true);
// Bob's piece must not appear for Alice
expect(byName['bob-tool']).toBeUndefined();
});
it("GET /api/pieces — user-custom shadows built-in with the same name", async () => {
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'general.yaml'), makeMinimalPieceYaml('general', 'alice override'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.get('/api/pieces');
expect(res.status).toBe(200);
const general = res.body.pieces.find((p: any) => p.name === 'general');
expect(general.source).toBe('user-custom');
expect(general.description).toBe('alice override');
});
it('POST /api/pieces creates a user-custom piece for non-admin caller', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.post('/api/pieces')
.send({
name: 'alice-custom',
description: 'alice piece',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(201);
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'alice-custom.yaml'))).toBe(true);
// Built-in dir untouched
expect(existsSync(join(piecesDir, 'alice-custom.yaml'))).toBe(false);
});
it('POST /api/pieces by admin writes to piecesDir (legacy behavior)', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
.post('/api/pieces')
.send({
name: 'admin-piece',
description: 'admin piece',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(201);
expect(existsSync(join(piecesDir, 'admin-piece.yaml'))).toBe(true);
});
it('PUT /api/pieces/:name on built-in by non-admin returns 403', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.put('/api/pieces/general')
.send({
name: 'general',
description: 'should not write',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(403);
});
it('PUT /api/pieces/:name on own user-custom by owner returns 200', async () => {
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-piece.yaml'), makeMinimalPieceYaml('my-piece', 'v1'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.put('/api/pieces/my-piece')
.send({
name: 'my-piece',
description: 'v2',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(200);
});
it("PUT /api/pieces/:name — non-admin cannot edit another user's piece", async () => {
// Bob has a piece. Alice tries to edit it via the same name.
mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-only.yaml'), makeMinimalPieceYaml('bob-only', 'bob'));
// Alice doesn't have bob-only — the request 404s (she can't see it), proving isolation.
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.put('/api/pieces/bob-only')
.send({
name: 'bob-only',
description: 'hijacked',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(404);
});
it('DELETE /api/pieces/:name on built-in by non-admin returns 403', async () => {
// Use a deletable built-in (not general/chat which are protected separately)
writeFileSync(join(piecesDir, 'extra.yaml'), makeMinimalPieceYaml('extra', 'extra'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.delete('/api/pieces/extra');
expect(res.status).toBe(403);
expect(existsSync(join(piecesDir, 'extra.yaml'))).toBe(true);
});
it('DELETE /api/pieces/:name on own user-custom by owner returns 200', async () => {
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'), makeMinimalPieceYaml('goner', 'gone'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.delete('/api/pieces/goner');
expect(res.status).toBe(200);
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'))).toBe(false);
});
it('admin can edit and delete built-in pieces', async () => {
writeFileSync(join(piecesDir, 'admin-target.yaml'), makeMinimalPieceYaml('admin-target', 'before'));
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
const putRes = await request(adminApp).put('/api/pieces/admin-target').send({
name: 'admin-target',
description: 'after',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(putRes.status).toBe(200);
const delRes = await request(adminApp).delete('/api/pieces/admin-target');
expect(delRes.status).toBe(200);
expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(false);
});
});