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:
2026-08-24 13:40:42 +00:00
co-authored by Claude Opus 5
parent 247291eeb9
commit e29a4ee380
17 changed files with 631 additions and 190 deletions
+169
View File
@@ -0,0 +1,169 @@
import { join, isAbsolute } from 'node:path';
import { mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { getTaskByDirName, type TaskRecord } from './task-files';
import { getOwnerHomeDir } from '../../data-path';
import { buildTaskApiEnv } from './task-api-env';
/**
* Everything a script-mode task run needs before something spawns it.
*
* ── Why this is its own file ──
*
* Three runners spawn a script task and they must agree about HOW: the ephemeral socket
* (`task-executor.ts`), the background job (`execute-script.ts`) and the direct REST call below. Each
* carried its own copy of the same twenty lines — materialize the implementation into a temp dir, build
* `INPUT_<NAME>` env vars, build positional args in `task.args` order, resolve a home-relative cwd, mint
* the API env. Identical line for line, and nothing said they had to stay that way.
*
* They differ in how they REPORT — streaming to a socket, appending to a durable log, or returning a
* string — and that difference is real, so it stays with each of them. What must not differ is what the
* script sees: a task that behaves one way from the context menu and another from a commander is a bug
* nobody can reproduce, because both callers are "running the task".
*/
const getRunner = (language: string): string[] =>
language === 'python'
? ['python3']
: language === 'typescript'
? ['bun', 'run']
: language === 'javascript'
? ['node']
: ['bash'];
const getFileName = (language: string): string =>
language === 'python'
? 'run.py'
: language === 'typescript'
? 'index.ts'
: language === 'javascript'
? 'index.js'
: 'run.sh';
/**
* Only `index.ts` (or its sibling by language) is written out — a script task is materialized ALONE, so
* it has to be self-contained. That is a contract, not an oversight: see `platform/CLAUDE.md`.
*/
function materializeScript(language: string, implementation: string): string {
const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
const filePath = join(dir, getFileName(language));
writeFileSync(filePath, implementation);
chmodSync(filePath, 0o755);
return filePath;
}
export type ScriptRunUser = {
id: number;
email: string;
username?: string | null;
/** The caller's credential narrowing, propagated to the script's own token. See `task-api-env.ts`. */
permissions?: string[] | null;
};
export type PreparedScriptRun = {
taskName: string;
cmd: string[];
cwd: string;
env: Record<string, string>;
/** Removes the temp directory. Every caller must call it, on every path out. */
cleanup: () => void;
};
type PrepareParams = {
task: TaskRecord;
inputs: Record<string, string>;
/** Home-relative or absolute. Absent means the owner's home. */
cwd?: string;
user: ScriptRunUser;
};
export async function prepareScriptRun({ task, inputs, cwd, user }: PrepareParams): Promise<PreparedScriptRun> {
if (task.mode !== 'script') throw new Error('Task is not a script-mode task');
if (!task.implementation) throw new Error(`Task ${task.dirName} has no implementation`);
const language = task.language ?? 'bash';
const scriptPath = materializeScript(language, task.implementation);
// Inputs reach the script two ways, and both are load-bearing: named, as `INPUT_<NAME>`, and
// positionally in the order `task.args` declares.
const inputEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(inputs)) inputEnv[`INPUT_${key.toUpperCase()}`] = value;
const positionalArgs = task.args?.length ? task.args.map((name) => inputs[name] ?? '') : [];
// A relative cwd is relative to the OWNER's real home, not the managed one — that is where task runs
// execute. A missing cwd surfaces as ENOENT naming the binary rather than the directory, which is why
// this resolves rather than passing it through.
const homeDir = getOwnerHomeDir(user.email);
return {
taskName: task.name,
cmd: [...getRunner(language), scriptPath, ...positionalArgs],
cwd: cwd ? (isAbsolute(cwd) ? cwd : join(homeDir, cwd)) : homeDir,
env: {
...(process.env as Record<string, string>),
...(await buildTaskApiEnv(user)),
...inputEnv,
},
cleanup: () => {
try {
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
} catch {
/* best effort */
}
},
};
}
export type DirectRunResult = { code: number; output: string; taskName: string };
/**
* Run a script task and wait for it. No job row, no socket, no streaming.
*
* This is the shape a caller with no browser needs — a commander's loop, a cron entry, anything holding a
* credential rather than a session. The two existing runners both require something to watch them: the
* socket needs a live connection, and the job needs somebody to open Jobs to see what happened.
*
* stdout and stderr are interleaved into one string in arrival order, which is what a caller reading it
* as "what happened" wants — the script's own narration and its errors in the order it produced them.
*/
export async function runScriptTaskDirect(params: {
taskDirName: string;
inputs: Record<string, string>;
cwd?: string;
user: ScriptRunUser;
}): Promise<DirectRunResult> {
const task = await getTaskByDirName(params.taskDirName);
if (!task) throw new Error(`Task not found: ${params.taskDirName}`);
const prepared = await prepareScriptRun({ task, inputs: params.inputs, cwd: params.cwd, user: params.user });
try {
const proc = Bun.spawn(prepared.cmd, {
cwd: prepared.cwd,
env: prepared.env,
stdout: 'pipe',
stderr: 'pipe',
});
let output = '';
const decoder = new TextDecoder();
const drain = async (stream: ReadableStream<Uint8Array>) => {
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
output += decoder.decode(value);
}
};
// Both streams are drained CONCURRENTLY with the wait. Reading them in sequence deadlocks the moment
// a script writes more to stderr than the pipe buffer holds while nothing is reading it.
await Promise.all([drain(proc.stdout), drain(proc.stderr)]);
const code = await proc.exited;
return { code, output, taskName: prepared.taskName };
} finally {
prepared.cleanup();
}
}