Files
platform/src/servers/api/tasks/tasks.ts
T
pastilhasandClaude Opus 5 12a9605b01 a commander's bound is a rule, not a list
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
2026-08-24 13:47:46 +00:00

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 });
});