the allow list shipped an hour ago was a snapshot. plugin permissions
register at mount, so a plugin installed after a commander was created was
missing from its list and failed as a bare 403 with nothing saying why —
and every future thing a commander is meant to do is exactly that shape:
notify a device, send mail, spawn a sub-agent, hand a transcription to an
idle one, reach a plugin that does not exist yet.
so a credential now narrows two ways, and both only subtract:
permissions an ALLOW list. exact, a snapshot. right for a credential
with one job.
excludedKinds kinds it may never reach, resolved against the LIVE
registry on every request. right for a credential whose
job is open-ended.
a commander gets the second: every kind except admin. bounded away from
installing plugins, managing users and roles and changing server settings,
forever — and open to everything the platform learns to do, with nothing
to re-issue. still intersected with the account, so for a member it
narrows itself against their role.
verified against the running server with a commander-shaped key: tasks,
notify, file-browser and the transcribe PLUGIN all reach their routes;
/api/users, /api/plugins and /api/roles are 403. the plugin case is the
one an allow list could not have passed — it was mounted long after any
key was minted.
the commander's CLAUDE.md says what it cannot do in the rule's own terms,
and that a 403 is an answer rather than a problem to route around.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
138 lines
5.0 KiB
TypeScript
138 lines
5.0 KiB
TypeScript
import { createRouter } from '../../create-router';
|
|
import * as errors from '../../custom-errors';
|
|
import { listTasks, getTaskByDirName, createTask, deleteTask, readCategoryOrder } from './task-files';
|
|
import { runScriptTaskDirect } from './script-run';
|
|
|
|
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
|
|
|
|
export const tasksRouter = createRouter();
|
|
|
|
tasksRouter.get('/', async (ctx) => {
|
|
const summaries = await listTasks();
|
|
|
|
const tasks = summaries.map((row) => ({
|
|
dirName: row.dirName,
|
|
name: row.name,
|
|
description: row.description,
|
|
category: row.category,
|
|
triggers: (row.trigger as TriggerConfig[]) ?? [],
|
|
mode: row.mode ?? 'agentic',
|
|
plugin: row.plugin,
|
|
inline: row.inline,
|
|
inputs: row.inputs,
|
|
}));
|
|
|
|
return ctx.json(tasks);
|
|
});
|
|
|
|
// Must be declared before '/:name', which would otherwise match "categories".
|
|
tasksRouter.get('/categories', async (ctx) => {
|
|
return ctx.json(await readCategoryOrder());
|
|
});
|
|
|
|
tasksRouter.get('/:name', async (ctx) => {
|
|
const name = ctx.req.param('name');
|
|
|
|
const task = await getTaskByDirName(name);
|
|
if (!task) return ctx.text('Not found', 404);
|
|
|
|
return ctx.json({
|
|
dirName: task.dirName,
|
|
name: task.name,
|
|
description: task.description,
|
|
category: task.category,
|
|
mode: task.mode,
|
|
inline: task.inline,
|
|
language: task.language,
|
|
body: task.body,
|
|
implementation: task.implementation,
|
|
inputs: task.inputs,
|
|
args: task.args,
|
|
trigger: task.trigger,
|
|
config: task.config,
|
|
version: task.version,
|
|
filePath: task.filePath,
|
|
plugin: task.plugin,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* POST /tasks/:name/run — run a script task, wait for it, return what it did.
|
|
*
|
|
* The entry point for a caller with no browser. Both existing runners need something watching them: the
|
|
* socket needs a live connection to stream into, and a job needs somebody to open Jobs and read it. A
|
|
* commander's loop has neither — it holds a credential, makes one call, and acts on the answer.
|
|
*
|
|
* Deliberately NOT a job. Transcribing a memo takes seconds and produces a file; wrapping it in a queue,
|
|
* a row and a progress bar puts a screen nobody opened between the request and the result.
|
|
*
|
|
* Synchronous, so the caller's own timeout is the bound on how long a task may take. That is the honest
|
|
* shape for "do this and tell me what happened" — a task that needs to outlive its caller should be a
|
|
* job, and `POST /jobs` already is one.
|
|
*/
|
|
tasksRouter.post('/:name/run', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const name = ctx.req.param('name');
|
|
|
|
const task = await getTaskByDirName(name);
|
|
if (!task) throw errors.NOT_FOUND(`Task not found: ${name}`);
|
|
if (task.mode !== 'script') throw errors.BAD_REQUEST(`Task mode '${task.mode}' cannot run directly`);
|
|
|
|
type RunBody = { inputs?: Record<string, string>; cwd?: string };
|
|
// A run with no inputs is a legitimate call, so an absent or unparseable body is an empty one rather
|
|
// than a 400 — the task's own required-input check is the place that knows what it needs.
|
|
const body: RunBody = await ctx.req.json<RunBody>().catch(() => ({}) as RunBody);
|
|
|
|
try {
|
|
const { code, output, taskName } = await runScriptTaskDirect({
|
|
taskDirName: name,
|
|
inputs: body.inputs ?? {},
|
|
cwd: body.cwd,
|
|
// The script inherits the caller's narrowing, or the absence of one. Without this a narrowed
|
|
// credential could write a task and run it back at the API with full authority.
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
username: user.username ?? '',
|
|
scope: ctx.get('credentialScope'),
|
|
},
|
|
});
|
|
// A non-zero exit is reported, not thrown: the script ran, and its output is the useful part of the
|
|
// answer either way. HTTP 200 means "the run happened"; `code` means "it worked".
|
|
return ctx.json({ ok: code === 0, code, output, taskName });
|
|
} catch (err) {
|
|
throw errors.BAD_REQUEST(err instanceof Error ? err.message : 'Task failed to start');
|
|
}
|
|
});
|
|
|
|
tasksRouter.post('/', async (ctx) => {
|
|
const body = await ctx.req.json<{ name: string; description?: string; mode?: string; language?: string }>();
|
|
|
|
if (!body.name?.trim()) return ctx.text('Name is required', 400);
|
|
|
|
try {
|
|
const task = await createTask({
|
|
name: body.name,
|
|
description: body.description ?? null,
|
|
mode: body.mode ?? 'agentic',
|
|
language: body.language ?? null,
|
|
});
|
|
return ctx.json(task);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to create task';
|
|
return ctx.text(message, message === 'Task already exists' ? 409 : 400);
|
|
}
|
|
});
|
|
|
|
tasksRouter.delete('/:name', async (ctx) => {
|
|
const name = ctx.req.param('name');
|
|
|
|
const task = await getTaskByDirName(name);
|
|
if (!task) return ctx.text('Not found', 404);
|
|
// A plugin's task is its own file in its own checkout. Uninstalling the plugin is how it goes away.
|
|
if (task.plugin) return ctx.text(`${name} belongs to the ${task.plugin} plugin — uninstall it instead`, 409);
|
|
|
|
await deleteTask(task.dirName);
|
|
return ctx.json({ ok: true });
|
|
});
|