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,10 +1,9 @@
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, chmodSync, rmSync, createWriteStream } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync, createWriteStream } from 'node:fs';
|
||||
import { getTaskByDirName } from './task-files';
|
||||
import { getOwnerHomeDir, DATA_PATH } from '../../data-path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { killTree } from './process-tree';
|
||||
import { buildTaskApiEnv } from './task-api-env';
|
||||
import { prepareScriptRun } from './script-run';
|
||||
|
||||
// Script-job event stream. `stdout`/`stderr` are high-frequency (live-only + persisted to the log
|
||||
// file); `started`/`exit`/`error` are structural and get buffered for viewer replay.
|
||||
@@ -33,41 +32,6 @@ export type ExecuteScriptParams = {
|
||||
emit: (event: ScriptEvent) => void;
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const buildInputEnv = (inputs: Record<string, string>): Record<string, string> => {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(inputs)) env[`INPUT_${key.toUpperCase()}`] = value;
|
||||
return env;
|
||||
};
|
||||
|
||||
const buildArgs = (inputs: Record<string, string>, argsOrder?: string[] | null): string[] =>
|
||||
!argsOrder || argsOrder.length === 0 ? [] : argsOrder.map((name) => inputs[name] ?? '');
|
||||
|
||||
// The persisted per-job output log — served by GET /jobs/:id/log for backfill / unattended viewing.
|
||||
export const jobLogPath = (jobId: string) => join(DATA_PATH, 'jobs', `${jobId}.log`);
|
||||
|
||||
@@ -79,22 +43,16 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
|
||||
|
||||
const task = await getTaskByDirName(params.taskDirName);
|
||||
if (!task) throw new Error(`Task not found: ${params.taskDirName}`);
|
||||
if (task.mode !== 'script') throw new Error('Task is not a script-mode task');
|
||||
if (!task.implementation) throw new Error(`Task ${params.taskDirName} has no implementation`);
|
||||
|
||||
const language = task.language ?? 'bash';
|
||||
const scriptPath = materializeScript(language, task.implementation);
|
||||
const inputEnv = buildInputEnv(inputs);
|
||||
const positionalArgs = buildArgs(inputs, task.args);
|
||||
const cmd = [...getRunner(language), scriptPath, ...positionalArgs];
|
||||
|
||||
const homeDir = getOwnerHomeDir(email);
|
||||
const cwd = params.cwd ? (isAbsolute(params.cwd) ? params.cwd : join(homeDir, params.cwd)) : homeDir;
|
||||
|
||||
const spawnCmd = cmd;
|
||||
const apiEnv = await buildTaskApiEnv({ id: userId, email, username });
|
||||
const spawnEnv = { ...(process.env as Record<string, string>), ...apiEnv, ...inputEnv };
|
||||
const spawnCwd = cwd;
|
||||
// What the script sees is `script-run.ts`'s to decide, for all three runners. This one owns only how
|
||||
// the output is reported: streamed to viewers and appended to a durable log.
|
||||
const prepared = await prepareScriptRun({
|
||||
task,
|
||||
inputs,
|
||||
cwd: params.cwd,
|
||||
user: { id: userId, email, username },
|
||||
});
|
||||
const { cmd: spawnCmd, env: spawnEnv, cwd: spawnCwd, cleanup } = prepared;
|
||||
|
||||
mkdirSync(join(DATA_PATH, 'jobs'), { recursive: true });
|
||||
const log = createWriteStream(jobLogPath(jobId), { flags: 'w' });
|
||||
@@ -115,15 +73,7 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
|
||||
/* a log-stream error must never kill the pump */
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
try {
|
||||
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
emit({ type: 'started', taskName: task.name });
|
||||
emit({ type: 'started', taskName: prepared.taskName });
|
||||
|
||||
const proc = Bun.spawn(spawnCmd, { cwd: spawnCwd, env: spawnEnv, stdout: 'pipe', stderr: 'pipe' });
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,21 @@ const PUBLIC_HOST = (() => {
|
||||
}
|
||||
})();
|
||||
|
||||
type TaskApiUser = { id: number; email: string; username?: string | null };
|
||||
type TaskApiUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
username?: string | null;
|
||||
/**
|
||||
* The narrowing carried by the credential that ASKED for this run, or null/absent for a full-authority
|
||||
* caller (a browser session, the context menu, a job started by hand).
|
||||
*
|
||||
* It has to propagate or the narrowing is decorative. A commander holding a key limited to `tasks` can
|
||||
* write a task and run it; if the token handed to that script were unnarrowed, the script would come
|
||||
* back through the API with the owner's full authority and the limit would have bought nothing. The
|
||||
* script gets exactly what its caller had — never more.
|
||||
*/
|
||||
permissions?: string[] | null;
|
||||
};
|
||||
|
||||
// Environment that lets a script task call Officer's own API instead of reimplementing server-side
|
||||
// work (transcription, for one — `plugins/transcribe/tasks/transcribe-audio-file/index.ts` uses exactly
|
||||
@@ -24,7 +38,17 @@ type TaskApiUser = { id: number; email: string; username?: string | null };
|
||||
// curl -H "Authorization: Bearer $OFFICER_AUTH_TOKEN" -H "Host: $OFFICER_API_HOST" \
|
||||
// "$OFFICER_API_URL/api/transcribe/transcribe"
|
||||
export async function buildTaskApiEnv(user: TaskApiUser): Promise<Record<string, string>> {
|
||||
const token = await sign({ id: user.id, email: user.email, username: user.username ?? '' }, '12h');
|
||||
const token = await sign(
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username ?? '',
|
||||
// Only when narrowed. An absent claim is what every ordinary run already signs, and `resolveAuthToken`
|
||||
// spreads the payload onto the identity, so this reaches both doors with no further wiring.
|
||||
...(user.permissions ? { permissions: user.permissions } : {}),
|
||||
},
|
||||
'12h',
|
||||
);
|
||||
return {
|
||||
OFFICER_API_URL,
|
||||
OFFICER_API_HOST: PUBLIC_HOST ?? `127.0.0.1:${PORT}`,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { getTaskByDirName } from './task-files';
|
||||
import { getOwnerHomeDir } from '../../data-path';
|
||||
import { buildTaskApiEnv } from './task-api-env';
|
||||
import { prepareScriptRun } from './script-run';
|
||||
|
||||
type WSData = {
|
||||
userId: number;
|
||||
@@ -94,123 +92,38 @@ function killTree(root: number) {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
function send(ws: ServerWebSocket<WSData>, msg: OutMessage) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function getRunner(language: string): string[] {
|
||||
switch (language) {
|
||||
case 'bash':
|
||||
return ['bash'];
|
||||
case 'python':
|
||||
return ['python3'];
|
||||
case 'typescript':
|
||||
return ['bun', 'run'];
|
||||
case 'javascript':
|
||||
return ['node'];
|
||||
default:
|
||||
return ['bash'];
|
||||
}
|
||||
}
|
||||
|
||||
function getFileName(language: string): string {
|
||||
switch (language) {
|
||||
case 'bash':
|
||||
return 'run.sh';
|
||||
case 'python':
|
||||
return 'run.py';
|
||||
case 'typescript':
|
||||
return 'index.ts';
|
||||
case 'javascript':
|
||||
return 'index.js';
|
||||
default:
|
||||
return 'run.sh';
|
||||
}
|
||||
}
|
||||
|
||||
// Write implementation to a temp file for execution, cleaned up after
|
||||
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 fileName = getFileName(language);
|
||||
const filePath = join(dir, fileName);
|
||||
|
||||
writeFileSync(filePath, implementation);
|
||||
chmodSync(filePath, 0o755);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function buildInputEnv(inputs: Record<string, string>): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
env[`INPUT_${key.toUpperCase()}`] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function buildArgs(inputs: Record<string, string>, argsOrder?: string[] | null): string[] {
|
||||
if (!argsOrder || argsOrder.length === 0) return [];
|
||||
return argsOrder.map((name) => inputs[name] ?? '');
|
||||
}
|
||||
|
||||
async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
||||
const { userId, email, username } = ws.data;
|
||||
|
||||
// Resolve task from the file-backed store
|
||||
const task = await getTaskByDirName(msg.taskDirName);
|
||||
if (!task) {
|
||||
send(ws, { type: 'error', message: `Task not found: ${msg.taskDirName}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.mode !== 'script') {
|
||||
send(ws, { type: 'error', message: 'Task is not a script-mode task' });
|
||||
// What the script sees comes from `script-run.ts`, shared with the job runner and the direct REST call
|
||||
// — a task must not behave differently depending on which door ran it. This file owns only the
|
||||
// streaming: output to the socket, a keep-alive ping, and a kill that reaches the whole tree.
|
||||
let prepared;
|
||||
try {
|
||||
prepared = await prepareScriptRun({
|
||||
task,
|
||||
inputs: msg.inputs,
|
||||
cwd: msg.cwd,
|
||||
user: { id: userId, email, username },
|
||||
});
|
||||
} catch (err) {
|
||||
send(ws, { type: 'error', message: err instanceof Error ? err.message : 'Task cannot run' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!task.implementation) {
|
||||
send(ws, { type: 'error', message: `Task ${msg.taskDirName} has no implementation` });
|
||||
return;
|
||||
}
|
||||
const { cmd: spawnCmd, env: spawnEnv, cwd: spawnCwd, cleanup } = prepared;
|
||||
|
||||
const language = task.language ?? 'bash';
|
||||
|
||||
// Write script to temp dir for execution
|
||||
const scriptPath = materializeScript(language, task.implementation);
|
||||
|
||||
// Build env vars from inputs
|
||||
const inputEnv = buildInputEnv(msg.inputs);
|
||||
|
||||
// Build positional args
|
||||
const positionalArgs = buildArgs(msg.inputs, task.args);
|
||||
|
||||
// Build the command
|
||||
const runner = getRunner(language);
|
||||
const cmd = [...runner, scriptPath, ...positionalArgs];
|
||||
|
||||
// msg.cwd arrives from the file browser relative to the user's home; Bun.spawn needs it absolute
|
||||
// (a missing cwd surfaces as ENOENT naming the binary, not the directory)
|
||||
const homeDir = getOwnerHomeDir(email);
|
||||
const cwd = msg.cwd ? (isAbsolute(msg.cwd) ? msg.cwd : join(homeDir, msg.cwd)) : homeDir;
|
||||
|
||||
const spawnCmd = cmd;
|
||||
const apiEnv = await buildTaskApiEnv({ id: userId, email, username });
|
||||
const spawnEnv = { ...(process.env as Record<string, string>), ...apiEnv, ...inputEnv };
|
||||
const spawnCwd = cwd;
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
send(ws, { type: 'started', taskName: task.name });
|
||||
send(ws, { type: 'started', taskName: prepared.taskName });
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(spawnCmd, {
|
||||
|
||||
@@ -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