Files
platform/src/servers/api/tasks/script-run.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

171 lines
6.5 KiB
TypeScript

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';
import type { CredentialScope } from '../../auth-token';
/**
* 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`. */
scope?: CredentialScope | 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();
}
}