sync: update from private repo (6fcb0d0)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository, localTaskRepoName } from '../db/repository.js';
|
||||
@@ -911,3 +911,120 @@ describe('POST /api/local/tasks/:id/continue', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression: /continue must accept pieces that exist ONLY in the task
|
||||
// owner's per-user dir. The old pieceExists(name) callback never checked
|
||||
// that dir and always returned false → 400 piece_not_found for user-custom
|
||||
// pieces even though the worker would have loaded them successfully.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('POST /api/local/tasks/:id/continue — user-custom piece resolution', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
let app: express.Application;
|
||||
let aliceUser: Express.User;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lt-ucpiece-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const real = repo.createUser({ email: '[email protected]', name: 'uc', role: 'user', status: 'active' });
|
||||
aliceUser = {
|
||||
...real,
|
||||
orgIds: [],
|
||||
defaultVisibility: 'private',
|
||||
defaultVisibilityOrgId: null,
|
||||
};
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as unknown as { user: Express.User }).user = aliceUser;
|
||||
next();
|
||||
});
|
||||
|
||||
// Simulate the server.ts pieceExists implementation:
|
||||
// per-user dir → (global-custom skipped here) → builtin dir.
|
||||
const builtinDir = join(tempDir, 'pieces');
|
||||
const userFolderRoot = join(tempDir, 'users');
|
||||
mkdirSync(builtinDir, { recursive: true });
|
||||
// 'manual-writer' only exists in the builtin dir (used for the starter job).
|
||||
writeFileSync(
|
||||
join(builtinDir, 'manual-writer.yaml'),
|
||||
'name: manual-writer\nmovements: []\n',
|
||||
);
|
||||
// 'my-custom-piece' exists ONLY in Alice's per-user dir.
|
||||
const userPiecesPath = join(userFolderRoot, aliceUser.id, 'pieces');
|
||||
mkdirSync(userPiecesPath, { recursive: true });
|
||||
writeFileSync(
|
||||
join(userPiecesPath, 'my-custom-piece.yaml'),
|
||||
'name: my-custom-piece\nmovements: []\n',
|
||||
);
|
||||
|
||||
mountLocalTasksApi(app, {
|
||||
repo,
|
||||
worktreeDir: join(tempDir, 'workspaces'),
|
||||
pieceExists: (name: string, ownerId?: string) => {
|
||||
// Check owner's per-user dir first (mirrors worker resolution).
|
||||
if (ownerId) {
|
||||
if (existsSync(join(userFolderRoot, ownerId, 'pieces', `${name}.yaml`))) return true;
|
||||
}
|
||||
// Fall back to builtin dir.
|
||||
return existsSync(join(builtinDir, `${name}.yaml`));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts a piece that exists only in the owner per-user dir (regression: was 400)', async () => {
|
||||
// Set up a task owned by alice with a terminal starter job.
|
||||
const task = await repo.createLocalTask({
|
||||
title: 't',
|
||||
body: 'b',
|
||||
pieceName: 'manual-writer',
|
||||
ownerId: aliceUser.id,
|
||||
});
|
||||
const prev = await repo.createJob({
|
||||
repo: localTaskRepoName(task.id),
|
||||
issueNumber: task.id,
|
||||
instruction: 'go',
|
||||
pieceName: 'manual-writer',
|
||||
ownerId: aliceUser.id,
|
||||
});
|
||||
await repo.updateJob(prev.id, { status: 'succeeded' });
|
||||
|
||||
// 'my-custom-piece' is ONLY in alice's per-user dir — should succeed.
|
||||
const res = await request(app)
|
||||
.post(`/api/local/tasks/${task.id}/continue`)
|
||||
.send({ piece: 'my-custom-piece', instruction: 'continue with user-custom piece' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.jobId).toBeTruthy();
|
||||
const newJob = await repo.getJob(res.body.jobId);
|
||||
expect(newJob?.pieceName).toBe('my-custom-piece');
|
||||
});
|
||||
|
||||
it('rejects a piece that exists in neither builtin nor user-custom dir', async () => {
|
||||
const task = await repo.createLocalTask({
|
||||
title: 't',
|
||||
body: 'b',
|
||||
pieceName: 'manual-writer',
|
||||
ownerId: aliceUser.id,
|
||||
});
|
||||
const prev = await repo.createJob({
|
||||
repo: localTaskRepoName(task.id),
|
||||
issueNumber: task.id,
|
||||
instruction: 'go',
|
||||
pieceName: 'manual-writer',
|
||||
ownerId: aliceUser.id,
|
||||
});
|
||||
await repo.updateJob(prev.id, { status: 'succeeded' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/local/tasks/${task.id}/continue`)
|
||||
.send({ piece: 'nonexistent-piece', instruction: 'go' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('piece_not_found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,12 @@ export interface LocalTasksApiOptions {
|
||||
* Server-side validator for piece names accepted by the
|
||||
* /continue endpoint. Returns true if the piece is loadable.
|
||||
* When unset, /continue rejects all requests with 500 (misconfiguration).
|
||||
*
|
||||
* `ownerId` is the task owner's user id; when provided the implementation
|
||||
* MUST also check the owner's per-user piece dir so that user-custom pieces
|
||||
* are accepted (matching the resolution order the worker uses at run time).
|
||||
*/
|
||||
pieceExists?: (name: string) => boolean;
|
||||
pieceExists?: (name: string, ownerId?: string) => boolean;
|
||||
/**
|
||||
* Optional. When set, accepting browserSessionProfileId on task create
|
||||
* verifies the profile belongs to the requesting user. Without it, the
|
||||
@@ -506,7 +510,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
res.status(500).json({ error: 'piece_validation_unavailable' });
|
||||
return;
|
||||
}
|
||||
if (!opts.pieceExists(piece)) {
|
||||
if (!opts.pieceExists(piece, task?.ownerId ?? undefined)) {
|
||||
res.status(400).json({ error: 'piece_not_found', piece });
|
||||
return;
|
||||
}
|
||||
|
||||
+556
-13
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { mountPiecesApi } from './pieces-api.js';
|
||||
@@ -295,13 +295,16 @@ describe('Pieces API (no auth — legacy behavior)', () => {
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/:name deletes piece', async () => {
|
||||
it('DELETE /api/pieces/:name — legacy no-auth pieces land in piecesDir (builtin source) and are non-deletable', async () => {
|
||||
// In legacy no-auth mode with no customPiecesDir, POST writes to piecesDir.
|
||||
// Those files are resolved as source='builtin' and are non-deletable.
|
||||
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: [] }],
|
||||
movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], default_next: 'COMPLETE', rules: [] }],
|
||||
});
|
||||
const res = await request(app).delete('/api/pieces/deleteme');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/cannot delete a built-in/i);
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/general is forbidden', async () => {
|
||||
@@ -371,16 +374,21 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
||||
expect(byName['bob-tool']).toBeUndefined();
|
||||
});
|
||||
|
||||
it("GET /api/pieces — user-custom shadows built-in with the same name", async () => {
|
||||
it("GET /api/pieces — user-custom and built-in both appear when same name (no hiding)", 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');
|
||||
const generals = res.body.pieces.filter((p: any) => p.name === 'general');
|
||||
// Both builtin and user-custom should appear
|
||||
expect(generals).toHaveLength(2);
|
||||
const customGeneral = generals.find((p: any) => p.source === 'user-custom');
|
||||
const builtinGeneral = generals.find((p: any) => p.source === 'builtin');
|
||||
expect(customGeneral).toBeDefined();
|
||||
expect(customGeneral.description).toBe('alice override');
|
||||
expect(builtinGeneral).toBeDefined();
|
||||
});
|
||||
|
||||
it('POST /api/pieces creates a user-custom piece for non-admin caller', async () => {
|
||||
@@ -399,7 +407,9 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
||||
expect(existsSync(join(piecesDir, 'alice-custom.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('POST /api/pieces by admin writes to piecesDir (legacy behavior)', async () => {
|
||||
it('POST /api/pieces by admin writes to user-custom dir (not piecesDir)', async () => {
|
||||
// POST always targets user-custom dir regardless of admin role.
|
||||
// Admins edit built-ins via PUT on existing ones.
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
@@ -410,7 +420,9 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
||||
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);
|
||||
// Piece lands in admin's user-custom dir, NOT in piecesDir.
|
||||
expect(existsSync(join(userPiecesRootDir, 'admin1', 'pieces', 'admin-piece.yaml'))).toBe(true);
|
||||
expect(existsSync(join(piecesDir, 'admin-piece.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('PUT /api/pieces/:name on built-in by non-admin returns 403', async () => {
|
||||
@@ -479,10 +491,11 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
||||
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('admin can edit and delete built-in pieces', async () => {
|
||||
it('admin can edit built-in pieces (PUT 200) but NOT delete them (DELETE 403)', async () => {
|
||||
writeFileSync(join(piecesDir, 'admin-target.yaml'), makeMinimalPieceYaml('admin-target', 'before'));
|
||||
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
|
||||
|
||||
// Admin CAN edit (PUT) a built-in
|
||||
const putRes = await request(adminApp).put('/api/pieces/admin-target').send({
|
||||
name: 'admin-target',
|
||||
description: 'after',
|
||||
@@ -492,8 +505,538 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
||||
});
|
||||
expect(putRes.status).toBe(200);
|
||||
|
||||
// Admin CANNOT delete a built-in — 403 for everyone
|
||||
const delRes = await request(adminApp).delete('/api/pieces/admin-target');
|
||||
expect(delRes.status).toBe(200);
|
||||
expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(false);
|
||||
expect(delRes.status).toBe(403);
|
||||
expect(delRes.body.ok).toBe(false);
|
||||
expect(delRes.body.error).toMatch(/cannot delete a built-in/i);
|
||||
// File must still exist
|
||||
expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
// --- Task 2A: built-in must not be hidden by same-named custom ---
|
||||
it("GET /api/pieces — built-in is NOT hidden when user has a same-named custom", async () => {
|
||||
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice chat override'));
|
||||
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.get('/api/pieces');
|
||||
expect(res.status).toBe(200);
|
||||
// Both the user-custom AND the builtin should appear
|
||||
const chatPieces = res.body.pieces.filter((p: any) => p.name === 'chat');
|
||||
expect(chatPieces).toHaveLength(2);
|
||||
const sources = chatPieces.map((p: any) => p.source).sort();
|
||||
expect(sources).toEqual(['builtin', 'user-custom']);
|
||||
// Custom has the custom description, builtin has the original
|
||||
const customChat = chatPieces.find((p: any) => p.source === 'user-custom');
|
||||
const builtinChat = chatPieces.find((p: any) => p.source === 'builtin');
|
||||
expect(customChat.description).toBe('alice chat override');
|
||||
expect(builtinChat.description).toBe('built-in chat');
|
||||
});
|
||||
|
||||
// --- Task 2B: CreatePiece rejects name collision with built-in ---
|
||||
it('POST /api/pieces rejects custom creation with a built-in name', async () => {
|
||||
// 'general' and 'chat' are in piecesDir (built-in)
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'chat',
|
||||
description: 'my chat',
|
||||
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(409);
|
||||
expect(res.body.error).toMatch(/built-in/i);
|
||||
});
|
||||
|
||||
it('POST /api/pieces with a fresh custom name still works for non-admin', async () => {
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'fresh-custom',
|
||||
description: 'fresh',
|
||||
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);
|
||||
});
|
||||
|
||||
// --- Task 2C: regression — non-admin DELETE of built-in → 403 ---
|
||||
it('non-admin DELETE of built-in returns 403', async () => {
|
||||
writeFileSync(join(piecesDir, 'deletable-builtin.yaml'), makeMinimalPieceYaml('deletable-builtin', 'del'));
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.delete('/api/pieces/deletable-builtin');
|
||||
expect(res.status).toBe(403);
|
||||
expect(existsSync(join(piecesDir, 'deletable-builtin.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
// --- Fix 2: GET /api/pieces/:name?source=builtin fetches the specific source ---
|
||||
it('GET /api/pieces/:name?source=builtin returns builtin even when user-custom exists with same name', async () => {
|
||||
// Alice has a user-custom 'chat' that overrides by default priority
|
||||
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
||||
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.get('/api/pieces/chat?source=builtin');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.source).toBe('builtin');
|
||||
expect(res.body.piece.description).toBe('built-in chat');
|
||||
});
|
||||
|
||||
it('GET /api/pieces/:name without ?source uses priority resolution (user-custom first)', async () => {
|
||||
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
||||
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.get('/api/pieces/chat');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.source).toBe('user-custom');
|
||||
expect(res.body.piece.description).toBe('alice custom chat');
|
||||
});
|
||||
|
||||
// --- Fix 3: DELETE of a user-custom named 'chat' by its owner must succeed ---
|
||||
it("DELETE /api/pieces/chat on owner's user-custom named 'chat' returns 200 (guard only blocks builtin)", async () => {
|
||||
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
||||
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.delete('/api/pieces/chat');
|
||||
expect(res.status).toBe(200);
|
||||
// User-custom file removed; built-in chat still present
|
||||
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'))).toBe(false);
|
||||
expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/chat on builtin by non-admin returns 403', async () => {
|
||||
// No user-custom for alice, so findPieceForCaller resolves to builtin
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.delete('/api/pieces/chat');
|
||||
expect(res.status).toBe(403);
|
||||
expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
// --- Fix source param for PUT/DELETE (P1-b) ---
|
||||
|
||||
it('PUT /api/pieces/chat?source=builtin by admin updates the builtin, not a same-named custom', async () => {
|
||||
// Alice (admin) has a user-custom chat AND there is a builtin chat.
|
||||
mkdirSync(join(userPiecesRootDir, 'admin1', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'admin custom chat'));
|
||||
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
|
||||
|
||||
const res = await request(adminApp).put('/api/pieces/chat?source=builtin').send({
|
||||
name: 'chat',
|
||||
description: 'builtin updated',
|
||||
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);
|
||||
// Builtin was updated
|
||||
const builtinContent = readFileSync(join(piecesDir, 'chat.yaml'), 'utf-8');
|
||||
expect(builtinContent).toContain('builtin updated');
|
||||
// Custom is untouched
|
||||
const customContent = readFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), 'utf-8');
|
||||
expect(customContent).toContain('admin custom chat');
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/chat?source=builtin by admin deletes the builtin, not same-named custom', async () => {
|
||||
// admin1 has a user-custom chat AND builtin chat
|
||||
mkdirSync(join(userPiecesRootDir, 'admin1', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'admin custom'));
|
||||
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
|
||||
|
||||
const res = await request(adminApp).delete('/api/pieces/chat?source=builtin');
|
||||
// Built-in pieces are non-deletable for everyone (including admins)
|
||||
expect(res.status).toBe(403);
|
||||
// Both still intact
|
||||
expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
|
||||
expect(existsSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/extra?source=user-custom targets user-custom, not builtin', async () => {
|
||||
writeFileSync(join(piecesDir, 'extra.yaml'), makeMinimalPieceYaml('extra', 'builtin extra'));
|
||||
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'extra.yaml'), makeMinimalPieceYaml('extra', 'alice extra'));
|
||||
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.delete('/api/pieces/extra?source=user-custom');
|
||||
expect(res.status).toBe(200);
|
||||
// User-custom removed
|
||||
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'extra.yaml'))).toBe(false);
|
||||
// Builtin untouched
|
||||
expect(existsSync(join(piecesDir, 'extra.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
// P2 fix: GET /api/pieces/:name (no ?source) includes source in response body
|
||||
// so the UI can derive the correct read-only state regardless of URL params.
|
||||
it('GET /api/pieces/general (no ?source) returns source: builtin in the body', async () => {
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.get('/api/pieces/general');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.piece.name).toBe('general');
|
||||
expect(res.body.source).toBe('builtin');
|
||||
expect(res.body.custom).toBe(false);
|
||||
});
|
||||
|
||||
it('POST /api/pieces creates in user-custom dir for admin (not piecesDir)', async () => {
|
||||
// Regression for P2: POST always targets user-custom, admin is no exception.
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'admin-new-custom',
|
||||
description: 'admin custom 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, 'admin1', 'pieces', 'admin-new-custom.yaml'))).toBe(true);
|
||||
expect(existsSync(join(piecesDir, 'admin-new-custom.yaml'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding 2 regression: authenticated POST with userPiecesRootDir UNSET → 503
|
||||
// Never falls through to shared/builtin dirs for an authenticated user.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Pieces API (Finding 2: POST auth fallback hole)', () => {
|
||||
let piecesDir: string;
|
||||
|
||||
function makeAppWithAuthButNoUserPiecesRoot(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();
|
||||
});
|
||||
// Intentionally: no userPiecesRootDir configured, no customPiecesDir.
|
||||
mountPiecesApi(app, { piecesDir });
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-f2-'));
|
||||
piecesDir = join(tempDir, 'pieces');
|
||||
mkdirSync(piecesDir);
|
||||
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
|
||||
});
|
||||
|
||||
it('authenticated non-admin POST with userPiecesRootDir unset returns 503 (does not write shared/builtin)', async () => {
|
||||
const app = makeAppWithAuthButNoUserPiecesRoot({ id: 'alice', role: 'user' });
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'should-not-exist',
|
||||
description: 'fallback hole test',
|
||||
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(503);
|
||||
expect(res.body.ok).toBe(false);
|
||||
expect(res.body.error).toMatch(/not configured/i);
|
||||
// Must NOT have written anything to the builtin dir.
|
||||
expect(existsSync(join(piecesDir, 'should-not-exist.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('authenticated admin POST with userPiecesRootDir unset also returns 503', async () => {
|
||||
const app = makeAppWithAuthButNoUserPiecesRoot({ id: 'admin1', role: 'admin' });
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'admin-fallback-hole',
|
||||
description: 'admin hole test',
|
||||
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(503);
|
||||
expect(res.body.ok).toBe(false);
|
||||
// Must NOT have written anything to the builtin dir.
|
||||
expect(existsSync(join(piecesDir, 'admin-fallback-hole.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('unauthenticated POST with no userPiecesRootDir falls back to piecesDir (legacy no-auth mode)', async () => {
|
||||
// No user: genuine no-auth legacy mode — should still work (existing behavior).
|
||||
const app = makeAppWithAuthButNoUserPiecesRoot(null);
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'legacy-fallback',
|
||||
description: 'legacy',
|
||||
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, 'legacy-fallback.yaml'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 1: invalid ?source param → 400 (no fallback to priority resolution)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Pieces API (Fix 1: invalid ?source → 400, no destructive fallback)', () => {
|
||||
let piecesDir: string;
|
||||
let userPiecesRootDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix1-'));
|
||||
piecesDir = join(tempDir, 'pieces');
|
||||
userPiecesRootDir = join(tempDir, 'users');
|
||||
mkdirSync(piecesDir);
|
||||
mkdirSync(userPiecesRootDir);
|
||||
// A builtin and a user-custom with the same name
|
||||
writeFileSync(join(piecesDir, 'chat.yaml'), makeMinimalPieceYaml('chat', 'builtin chat'));
|
||||
// Alice has a user-custom 'chat'
|
||||
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
||||
});
|
||||
|
||||
function makeApp(user: UserShape | null) {
|
||||
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;
|
||||
}
|
||||
|
||||
it('GET /api/pieces/:name?source=builtinn (typo) → 400 (not priority-fallback)', async () => {
|
||||
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
||||
.get('/api/pieces/chat?source=builtinn');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.ok).toBe(false);
|
||||
expect(res.body.error).toMatch(/invalid source/i);
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/chat?source=builtinn (typo) → 400, does NOT delete user-custom', async () => {
|
||||
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
||||
.delete('/api/pieces/chat?source=builtinn');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.ok).toBe(false);
|
||||
expect(res.body.error).toMatch(/invalid source/i);
|
||||
// User-custom must be untouched
|
||||
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('PUT /api/pieces/chat?source=user_custom (underscore typo) → 400, does NOT mutate user-custom', async () => {
|
||||
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
||||
.put('/api/pieces/chat?source=user_custom')
|
||||
.send({
|
||||
name: 'chat',
|
||||
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(400);
|
||||
expect(res.body.ok).toBe(false);
|
||||
expect(res.body.error).toMatch(/invalid source/i);
|
||||
// User-custom description must be unchanged
|
||||
const content = readFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), 'utf-8');
|
||||
expect(content).toContain('alice custom chat');
|
||||
expect(content).not.toContain('hijacked');
|
||||
});
|
||||
|
||||
it('valid ?source=builtin still works (not rejected)', async () => {
|
||||
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
||||
.get('/api/pieces/chat?source=builtin');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.source).toBe('builtin');
|
||||
});
|
||||
|
||||
it('absent ?source still does priority resolution (not rejected)', async () => {
|
||||
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
||||
.get('/api/pieces/chat');
|
||||
expect(res.status).toBe(200);
|
||||
// priority: user-custom wins
|
||||
expect(res.body.source).toBe('user-custom');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 2: POST /api/pieces returns actual source in response body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Pieces API (Fix 2: POST returns actual source)', () => {
|
||||
let piecesDir: string;
|
||||
let userPiecesRootDir: string;
|
||||
|
||||
function minimalPieceBody(name: string) {
|
||||
return {
|
||||
name,
|
||||
description: 'test',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix2-'));
|
||||
piecesDir = join(tempDir, 'pieces');
|
||||
userPiecesRootDir = join(tempDir, 'users');
|
||||
mkdirSync(piecesDir);
|
||||
mkdirSync(userPiecesRootDir);
|
||||
});
|
||||
|
||||
it('authenticated POST returns source: user-custom', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { (req as any).user = { id: 'alice', role: 'user' }; next(); });
|
||||
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
||||
const res = await request(app).post('/api/pieces').send(minimalPieceBody('my-piece'));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.source).toBe('user-custom');
|
||||
});
|
||||
|
||||
it('legacy (no-auth) POST with customPiecesDir returns source: global-custom', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix2-gc-'));
|
||||
const gcDir = join(tempDir, 'custom');
|
||||
mkdirSync(join(tempDir, 'pieces'));
|
||||
mkdirSync(gcDir);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// No user set — legacy no-auth mode
|
||||
mountPiecesApi(app, { piecesDir: join(tempDir, 'pieces'), customPiecesDir: gcDir });
|
||||
const res = await request(app).post('/api/pieces').send(minimalPieceBody('gc-piece'));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.source).toBe('global-custom');
|
||||
});
|
||||
|
||||
it('legacy (no-auth) POST without customPiecesDir returns source: builtin', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// No user, no customPiecesDir
|
||||
mountPiecesApi(app, { piecesDir });
|
||||
const res = await request(app).post('/api/pieces').send(minimalPieceBody('legacy-piece'));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.source).toBe('builtin');
|
||||
});
|
||||
|
||||
it('no-auth POST with userPiecesRootDir returns source: user-custom (regression fix)', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// No user, but userPiecesRootDir is configured (the fixed path)
|
||||
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
||||
const res = await request(app).post('/api/pieces').send(minimalPieceBody('noauth-custom'));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.source).toBe('user-custom');
|
||||
// Piece is in data/users/local/pieces, NOT in piecesDir
|
||||
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'noauth-custom.yaml'))).toBe(true);
|
||||
expect(existsSync(join(piecesDir, 'noauth-custom.yaml'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression fix: no-auth + userPiecesRootDir → 'local' user-custom namespace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Pieces API (regression: no-auth uses local user-custom, not piecesDir)', () => {
|
||||
let piecesDir: string;
|
||||
let userPiecesRootDir: string;
|
||||
|
||||
function minimalPieceBody(name: string) {
|
||||
return {
|
||||
name,
|
||||
description: 'test',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-noauth-local-'));
|
||||
piecesDir = join(tempDir, 'pieces');
|
||||
userPiecesRootDir = join(tempDir, 'users');
|
||||
mkdirSync(piecesDir);
|
||||
mkdirSync(userPiecesRootDir);
|
||||
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
|
||||
});
|
||||
|
||||
function makeNoAuthApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// No user middleware — simulates no-auth mode
|
||||
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
||||
return app;
|
||||
}
|
||||
|
||||
it('no-auth POST creates under local user-custom dir, NOT in piecesDir', async () => {
|
||||
const app = makeNoAuthApp();
|
||||
const res = await request(app).post('/api/pieces').send(minimalPieceBody('my-noauth-piece'));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.source).toBe('user-custom');
|
||||
// Must be in the 'local' user-custom dir
|
||||
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'my-noauth-piece.yaml'))).toBe(true);
|
||||
// Must NOT be in bundled piecesDir
|
||||
expect(existsSync(join(piecesDir, 'my-noauth-piece.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('no-auth created piece (with userPiecesRootDir) is DELETABLE — DELETE returns 200', async () => {
|
||||
const app = makeNoAuthApp();
|
||||
// Create it first
|
||||
await request(app).post('/api/pieces').send(minimalPieceBody('deletable-noauth'));
|
||||
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'deletable-noauth.yaml'))).toBe(true);
|
||||
|
||||
// Now delete — must succeed (not 403)
|
||||
const delRes = await request(app).delete('/api/pieces/deletable-noauth');
|
||||
expect(delRes.status).toBe(200);
|
||||
expect(delRes.body.ok).toBe(true);
|
||||
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'deletable-noauth.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('bundled built-in piece is still non-deletable (403) for no-auth callers', async () => {
|
||||
const app = makeNoAuthApp();
|
||||
const res = await request(app).delete('/api/pieces/general');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/cannot delete a built-in/i);
|
||||
expect(existsSync(join(piecesDir, 'general.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('no-auth LIST includes the local user-custom piece (source=user-custom)', async () => {
|
||||
// Pre-create a piece in the 'local' user-custom dir
|
||||
mkdirSync(join(userPiecesRootDir, 'local', 'pieces'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(userPiecesRootDir, 'local', 'pieces', 'local-custom.yaml'),
|
||||
makeMinimalPieceYaml('local-custom', 'no-auth local piece'),
|
||||
);
|
||||
|
||||
const app = makeNoAuthApp();
|
||||
const res = await request(app).get('/api/pieces');
|
||||
expect(res.status).toBe(200);
|
||||
const byName = Object.fromEntries(res.body.pieces.map((p: any) => [p.name, p]));
|
||||
expect(byName['local-custom']).toBeDefined();
|
||||
expect(byName['local-custom'].source).toBe('user-custom');
|
||||
expect(byName['local-custom'].ownerId).toBe('local');
|
||||
// Built-in also visible
|
||||
expect(byName['general']).toBeDefined();
|
||||
expect(byName['general'].source).toBe('builtin');
|
||||
});
|
||||
|
||||
it('no-auth GET resolves user-custom piece from local dir by priority', async () => {
|
||||
mkdirSync(join(userPiecesRootDir, 'local', 'pieces'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(userPiecesRootDir, 'local', 'pieces', 'my-piece.yaml'),
|
||||
makeMinimalPieceYaml('my-piece', 'local custom'),
|
||||
);
|
||||
|
||||
const app = makeNoAuthApp();
|
||||
const res = await request(app).get('/api/pieces/my-piece');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.source).toBe('user-custom');
|
||||
expect(res.body.ownerId).toBe('local');
|
||||
});
|
||||
});
|
||||
|
||||
+183
-47
@@ -100,6 +100,19 @@ function validateName(name: string): boolean {
|
||||
return VALID_PIECE_NAME.test(name);
|
||||
}
|
||||
|
||||
const VALID_SOURCES = new Set<string>(['builtin', 'user-custom', 'global-custom']);
|
||||
|
||||
/**
|
||||
* Returns true when `source` is absent (caller wants priority resolution).
|
||||
* Returns false when `source` is a known valid value.
|
||||
* Returns a 400 error string when `source` is present but unrecognised.
|
||||
*/
|
||||
function parseSourceParam(source: string | undefined): { valid: true; value: PieceSource | undefined } | { valid: false; error: string } {
|
||||
if (source === undefined) return { valid: true, value: undefined };
|
||||
if (VALID_SOURCES.has(source)) return { valid: true, value: source as PieceSource };
|
||||
return { valid: false, error: 'Invalid source' };
|
||||
}
|
||||
|
||||
export function findPieceFile(name: string, piecesDir: string, customPiecesDir?: string): { path: string; custom: boolean } | null {
|
||||
if (customPiecesDir) {
|
||||
const customPath = join(customPiecesDir, `${name}.yaml`);
|
||||
@@ -122,6 +135,9 @@ export interface PiecesApiOptions {
|
||||
userPiecesRootDir?: string;
|
||||
}
|
||||
|
||||
/** Canonical owner id for no-auth / legacy mode. Mirrors worker-bootstrap and piece-catalog default. */
|
||||
const LOCAL_OWNER = 'local';
|
||||
|
||||
type AuthedUser = { id: string; role?: string };
|
||||
|
||||
function getUser(req: Request): AuthedUser | undefined {
|
||||
@@ -137,6 +153,7 @@ function isAdminOrLegacy(user: AuthedUser | undefined): boolean {
|
||||
/**
|
||||
* Lookup priority for a given caller:
|
||||
* 1. Caller's own user-custom dir (overrides everything below).
|
||||
* No-auth callers use the 'local' owner id.
|
||||
* 2. Global custom dir (admin-managed, all users see).
|
||||
* 3. Built-in dir.
|
||||
*/
|
||||
@@ -145,9 +162,10 @@ function findPieceForCaller(
|
||||
user: AuthedUser | undefined,
|
||||
name: string,
|
||||
): { path: string; source: PieceSource; ownerId?: string } | null {
|
||||
if (opts.userPiecesRootDir && user) {
|
||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, user.id), `${name}.yaml`);
|
||||
if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId: user.id };
|
||||
if (opts.userPiecesRootDir) {
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${name}.yaml`);
|
||||
if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId };
|
||||
}
|
||||
if (opts.customPiecesDir) {
|
||||
const gcPath = join(opts.customPiecesDir, `${name}.yaml`);
|
||||
@@ -178,30 +196,31 @@ export function mountPiecesApi(
|
||||
app.get('/api/pieces', (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = getUser(req);
|
||||
const seen = new Set<string>();
|
||||
const pieces: PieceSummary[] = [];
|
||||
|
||||
// Order matters: user-custom overrides global-custom, which overrides built-in.
|
||||
const sources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
|
||||
if (opts.userPiecesRootDir && user) {
|
||||
const ucDir = userPiecesDir(opts.userPiecesRootDir, user.id);
|
||||
if (existsSync(ucDir)) sources.push({ dir: ucDir, source: 'user-custom', ownerId: user.id });
|
||||
// Build custom sources (user-custom first, then global-custom).
|
||||
// Within custom umbrella: dedup by name so user-custom wins over global-custom.
|
||||
// Built-ins are ALWAYS emitted separately — they are never hidden by a same-named custom.
|
||||
const customSources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
|
||||
if (opts.userPiecesRootDir) {
|
||||
// No-auth callers use the 'local' owner id, mirroring worker and piece-catalog defaults.
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
const ucDir = userPiecesDir(opts.userPiecesRootDir, ownerId);
|
||||
if (existsSync(ucDir)) customSources.push({ dir: ucDir, source: 'user-custom', ownerId });
|
||||
}
|
||||
if (opts.customPiecesDir && existsSync(opts.customPiecesDir)) {
|
||||
sources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
|
||||
}
|
||||
if (existsSync(opts.piecesDir)) {
|
||||
sources.push({ dir: opts.piecesDir, source: 'builtin' });
|
||||
customSources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
|
||||
}
|
||||
|
||||
for (const { dir, source, ownerId } of sources) {
|
||||
// Emit custom pieces (dedup within custom umbrella only).
|
||||
const seenCustom = new Set<string>();
|
||||
for (const { dir, source, ownerId } of customSources) {
|
||||
for (const f of listPieceFiles(dir)) {
|
||||
try {
|
||||
const p = loadPieceFile(f);
|
||||
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
// Drift is meaningful only for global-custom that shadows a built-in.
|
||||
if (seenCustom.has(name)) continue;
|
||||
seenCustom.add(name);
|
||||
let drift: DriftStatus | undefined;
|
||||
if (source === 'global-custom' && existsSync(opts.piecesDir)) {
|
||||
const builtinPath = join(opts.piecesDir, `${name}.yaml`);
|
||||
@@ -212,7 +231,7 @@ export function mountPiecesApi(
|
||||
description: p.description,
|
||||
triggers: p.triggers,
|
||||
requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
|
||||
custom: source !== 'builtin',
|
||||
custom: true,
|
||||
source,
|
||||
ownerId,
|
||||
drift,
|
||||
@@ -222,6 +241,27 @@ export function mountPiecesApi(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always emit ALL built-ins (never hidden by custom pieces of the same name).
|
||||
if (existsSync(opts.piecesDir)) {
|
||||
for (const f of listPieceFiles(opts.piecesDir)) {
|
||||
try {
|
||||
const p = loadPieceFile(f);
|
||||
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
|
||||
pieces.push({
|
||||
name,
|
||||
description: p.description,
|
||||
triggers: p.triggers,
|
||||
requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
|
||||
custom: false,
|
||||
source: 'builtin',
|
||||
});
|
||||
} catch {
|
||||
// skip malformed piece files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ pieces });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: `Failed to list pieces: ${e}` });
|
||||
@@ -232,7 +272,31 @@ export function mountPiecesApi(
|
||||
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
||||
try {
|
||||
const user = getUser(req);
|
||||
const found = findPieceForCaller(opts, user, req.params.name);
|
||||
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
|
||||
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
|
||||
const requestedSource = sourceParsed.value;
|
||||
|
||||
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
|
||||
|
||||
if (requestedSource === 'builtin') {
|
||||
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
|
||||
} else if (requestedSource === 'user-custom') {
|
||||
if (opts.userPiecesRootDir) {
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
|
||||
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
|
||||
}
|
||||
} else if (requestedSource === 'global-custom') {
|
||||
if (opts.customPiecesDir) {
|
||||
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
|
||||
}
|
||||
} else {
|
||||
// No source param: priority resolution (user-custom > global-custom > builtin).
|
||||
found = findPieceForCaller(opts, user, req.params.name);
|
||||
}
|
||||
|
||||
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
||||
const piece = loadPieceFile(found.path);
|
||||
res.json({
|
||||
@@ -253,7 +317,29 @@ export function mountPiecesApi(
|
||||
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
||||
try {
|
||||
const user = getUser(req);
|
||||
const found = findPieceForCaller(opts, user, req.params.name);
|
||||
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
|
||||
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
|
||||
const requestedSource = sourceParsed.value;
|
||||
|
||||
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
|
||||
if (requestedSource === 'builtin') {
|
||||
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
|
||||
} else if (requestedSource === 'user-custom') {
|
||||
if (opts.userPiecesRootDir) {
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
|
||||
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
|
||||
}
|
||||
} else if (requestedSource === 'global-custom') {
|
||||
if (opts.customPiecesDir) {
|
||||
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
|
||||
}
|
||||
} else {
|
||||
found = findPieceForCaller(opts, user, req.params.name);
|
||||
}
|
||||
|
||||
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
||||
|
||||
// Authz: built-in / global-custom → admin (or legacy no-auth); user-custom → owner (or admin).
|
||||
@@ -262,7 +348,7 @@ export function mountPiecesApi(
|
||||
res.status(403).json({ ok: false, error: 'Only admins can modify built-in or global-custom pieces' });
|
||||
return;
|
||||
}
|
||||
} else if (found.ownerId !== user?.id && !isAdminOrLegacy(user)) {
|
||||
} else if (found.ownerId !== (user?.id ?? LOCAL_OWNER) && !isAdminOrLegacy(user)) {
|
||||
// Different user's user-custom — and not admin. Should be unreachable since
|
||||
// findPieceForCaller scopes user-custom to the caller, but guard anyway.
|
||||
res.status(403).json({ ok: false, error: "Cannot modify another user's custom piece" });
|
||||
@@ -293,33 +379,55 @@ export function mountPiecesApi(
|
||||
if (error) { res.status(400).json({ ok: false, error }); return; }
|
||||
|
||||
const user = getUser(req);
|
||||
const adminOrLegacy = isAdminOrLegacy(user);
|
||||
|
||||
// Determine destination dir:
|
||||
// - admin / legacy → preserve existing behavior (write to piecesDir).
|
||||
// - non-admin user → write to their user-custom dir.
|
||||
// POST always creates in user-custom dir. "+" is always Create Custom.
|
||||
// Admins edit built-ins via PUT on existing ones, not by POST-creating new built-ins.
|
||||
// No-auth / legacy with userPiecesRootDir: use the 'local' owner id so pieces are
|
||||
// user-custom (deletable) and never pollute piecesDir.
|
||||
// No-auth / legacy WITHOUT userPiecesRootDir: fall back to global customPiecesDir or piecesDir.
|
||||
let destDir: string;
|
||||
if (adminOrLegacy) {
|
||||
destDir = opts.piecesDir;
|
||||
} else {
|
||||
if (!opts.userPiecesRootDir) {
|
||||
res.status(503).json({ ok: false, error: 'User pieces directory not configured on this server' });
|
||||
return;
|
||||
}
|
||||
destDir = userPiecesDir(opts.userPiecesRootDir, user!.id);
|
||||
let createdSource: PieceSource;
|
||||
if (opts.userPiecesRootDir) {
|
||||
// Both authenticated and no-auth go to user-custom dir when userPiecesRootDir is set.
|
||||
// Authenticated users: 503 guard below is only needed when root is NOT set.
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
destDir = userPiecesDir(opts.userPiecesRootDir, ownerId);
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
createdSource = 'user-custom';
|
||||
} else if (user) {
|
||||
// Authenticated caller but userPiecesRootDir is not configured — cannot safely write.
|
||||
res.status(503).json({ ok: false, error: 'User piece storage not configured' });
|
||||
return;
|
||||
} else if (opts.customPiecesDir) {
|
||||
// Legacy (no-auth) with global custom dir configured.
|
||||
destDir = opts.customPiecesDir;
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
createdSource = 'global-custom';
|
||||
} else {
|
||||
// Pure legacy single-user: fall back to piecesDir (existing behavior).
|
||||
destDir = opts.piecesDir;
|
||||
createdSource = 'builtin';
|
||||
}
|
||||
|
||||
// Reject if any visible-to-caller piece with this name already exists
|
||||
// (built-in, global-custom, or caller's user-custom).
|
||||
// Always reject if the name collides with a built-in — Custom and Default are
|
||||
// separate namespaces. (This also covers the admin case, since admins should
|
||||
// use PUT to update an existing built-in, not POST to create a duplicate.)
|
||||
const builtinPath = join(opts.piecesDir, `${req.body.name}.yaml`);
|
||||
if (existsSync(builtinPath) && destDir !== opts.piecesDir) {
|
||||
res.status(409).json({ ok: false, error: `"${req.body.name}" は組み込み (built-in) Piece と同名です。Custom Piece には別名を付けてください。` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject if the caller's visible piece with this name already exists
|
||||
// (global-custom or caller's own user-custom).
|
||||
if (findPieceForCaller(opts, user, req.body.name)) {
|
||||
res.status(409).json({ ok: false, error: 'Piece already exists' }); return;
|
||||
}
|
||||
|
||||
const filePath = join(destDir, `${req.body.name}.yaml`);
|
||||
writeFileSync(filePath, stringify(req.body, { lineWidth: 120 }), 'utf-8');
|
||||
logger.info(`[pieces-api] created piece=${req.body.name} dest=${destDir} actor=${user?.id ?? 'legacy'}`);
|
||||
res.status(201).json({ ok: true });
|
||||
logger.info(`[pieces-api] created piece=${req.body.name} dest=${destDir} source=${createdSource} actor=${user?.id ?? 'legacy'}`);
|
||||
res.status(201).json({ ok: true, source: createdSource });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: `Failed to create piece: ${e}` });
|
||||
}
|
||||
@@ -327,23 +435,51 @@ export function mountPiecesApi(
|
||||
|
||||
app.delete('/api/pieces/:name', (req: Request, res: Response) => {
|
||||
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
||||
if (req.params.name === 'general' || req.params.name === 'chat') {
|
||||
res.status(403).json({ ok: false, error: 'Cannot delete general piece' }); return;
|
||||
}
|
||||
try {
|
||||
const user = getUser(req);
|
||||
const found = findPieceForCaller(opts, user, req.params.name);
|
||||
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
|
||||
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
|
||||
const requestedSource = sourceParsed.value;
|
||||
|
||||
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
|
||||
if (requestedSource === 'builtin') {
|
||||
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
|
||||
} else if (requestedSource === 'user-custom') {
|
||||
if (opts.userPiecesRootDir) {
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
|
||||
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
|
||||
}
|
||||
} else if (requestedSource === 'global-custom') {
|
||||
if (opts.customPiecesDir) {
|
||||
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
|
||||
}
|
||||
} else {
|
||||
found = findPieceForCaller(opts, user, req.params.name);
|
||||
}
|
||||
|
||||
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
||||
|
||||
// Authz mirrors PUT: built-in / global-custom → admin; user-custom → owner.
|
||||
if (found.source !== 'user-custom') {
|
||||
// Built-in (Default) pieces are non-deletable for everyone — including admins.
|
||||
// This covers general/chat and all other built-ins. Admins may still EDIT
|
||||
// built-ins via PUT; only deletion is prohibited.
|
||||
if (found.source === 'builtin') {
|
||||
res.status(403).json({ ok: false, error: 'Cannot delete a built-in (Default) piece' }); return;
|
||||
}
|
||||
|
||||
// Authz for non-builtin sources: global-custom → admin; user-custom → owner.
|
||||
if (found.source === 'global-custom') {
|
||||
if (!isAdminOrLegacy(user)) {
|
||||
res.status(403).json({ ok: false, error: 'Only admins can delete built-in or global-custom pieces' });
|
||||
res.status(403).json({ ok: false, error: 'Only admins can delete global-custom pieces' });
|
||||
return;
|
||||
}
|
||||
} else if (found.source === 'user-custom') {
|
||||
if (found.ownerId !== (user?.id ?? LOCAL_OWNER) && !isAdminOrLegacy(user)) {
|
||||
res.status(403).json({ ok: false, error: "Cannot delete another user's custom piece" });
|
||||
return;
|
||||
}
|
||||
} else if (found.ownerId !== user?.id && !isAdminOrLegacy(user)) {
|
||||
res.status(403).json({ ok: false, error: "Cannot delete another user's custom piece" });
|
||||
return;
|
||||
}
|
||||
|
||||
unlinkSync(found.path);
|
||||
|
||||
+11
-1
@@ -43,6 +43,7 @@ import { createWorkerMetrics, type WorkerMetrics } from '../metrics/worker-metri
|
||||
import { createMetricsHandler } from '../metrics/http-handler.js';
|
||||
import { buildDirectProbe, buildProxyProbe } from '../engine/backend-probes.js';
|
||||
import { startTrashCleanup } from '../user-folder/trash-cleanup.js';
|
||||
import { userPiecesDir } from '../user-folder/paths.js';
|
||||
import { startReflectionRetentionSweep } from '../engine/reflection/retention.js';
|
||||
import type { AuthConfig } from '../config.js';
|
||||
import { isKeyConfigured } from '../mcp/crypto.js';
|
||||
@@ -777,7 +778,16 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
generateTitle: opts.generateTitle,
|
||||
selectPiece: opts.selectPiece,
|
||||
pieceExists: opts.piecesDir
|
||||
? (name: string) => findPieceFile(name, opts.piecesDir!, opts.customPiecesDir) !== null
|
||||
? (name: string, ownerId?: string) => {
|
||||
// Mirror the worker's per-user → global-custom → builtin resolution order.
|
||||
// 1. Owner's per-user dir (matches what worker uses at job run time).
|
||||
// No-auth tasks (ownerId null) fall back to 'local', mirroring the worker.
|
||||
const ufl = loadConfig().userFolderRoot ?? './data/users';
|
||||
const ownerForPieces = ownerId ?? 'local';
|
||||
if (existsSync(join(userPiecesDir(ufl, ownerForPieces), `${name}.yaml`))) return true;
|
||||
// 2. Global-custom + builtin via existing helper.
|
||||
return findPieceFile(name, opts.piecesDir!, opts.customPiecesDir) !== null;
|
||||
}
|
||||
: undefined,
|
||||
sessRepo,
|
||||
getMaxUploadMb: opts.configManager
|
||||
|
||||
Reference in New Issue
Block a user