a credential can be narrower than its holder
a commander is an agent working unattended for months, and the only
credential in the tree lived in the MCP tool server's environment where
the agent's own shell cannot see it. so the first one built here curled
the whisper container directly on localhost: right answer, wrong door, and
a door the platform does not control.
api_keys.permissions. null is what every key a human minted still is —
their full authority, exactly what the password that made it could do. a
list narrows: the credential reaches only those permission keys, and still
only where the account itself could. intersection, never union.
checked BEFORE the owner bypass, which is the whole point. a Super Admin
consults no grants table, so a token carrying the owner's identity is
unbounded by construction — fine for a browser session somebody is sitting
in front of, not fine for a process that runs for months. this is the only
thing in the platform that can bound them. verified against the running
server: an owner key scoped to [account] gets 200 on /api/user/settings
and 403 on /api/users, /api/tasks and /api/plugins.
it propagates. a narrowed credential can write a task and run it, so the
token buildTaskApiEnv hands that script carries the same narrowing —
otherwise the script comes back through the API with full authority and
the limit bought nothing. the gate stashes it on the request for the one
kind of route that passes authority onward.
POST /tasks/:name/run: runs a script task, waits, returns code and output.
both existing runners need something watching them — the socket needs a
live connection, a job needs somebody to open Jobs. a commander has
neither. deliberately not a job: transcribing a memo takes seconds and
produces a file, and a queue plus a row plus a progress bar is a screen
nobody opened between the request and the result.
script-run.ts is the third thing this needed and the one worth having on
its own. the socket runner, the job runner and now this each carried the
same twenty lines — materialize, INPUT_ env, positional args, resolve cwd,
mint the api env — identical, with nothing saying they had to stay that
way. they differ in how they REPORT, which is real and stays with each of
them. what the script SEES is now decided once.
a commander gets its key at creation, in officer.env at 0600 inside its own
directory, and CLAUDE.md tells it where and says plainly that a sidecar
listening on localhost with no auth is still the wrong door. a file rather
than an env var because the detached loop a commander arms is spawned by
the agent and inherits nothing the platform chose. deleting the commander
revokes the row — the directory removal takes the secret, not the thing
that makes it work.
default scope is everything except `admin`: a commander that can reach
nothing gets widened in a hurry rather than thought about, and installing
plugins, managing users and changing server settings are never part of a
watcher's job. it is a default, stored per key, and for a member it
narrows itself against their role.
also fixed: POST /command-hq/commanders/new never awaited
createCommanderDirectory, so a turn could spawn before its CLAUDE.md
existed — the exact ordering that redesign was for — and the cwd it
returned was a Promise serialising as {}.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
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' };
|
||||
|
||||
@@ -54,6 +56,55 @@ tasksRouter.get('/:name', async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 ?? '',
|
||||
permissions: ctx.get('credentialPermissions'),
|
||||
},
|
||||
});
|
||||
// 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 }>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user