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
171 lines
6.5 KiB
TypeScript
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();
|
|
}
|
|
}
|