clyops-api 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // clyops-api --root DIR: serve a directory of clyops tools over HTTP.
3
+ import { Cli, die, info } from 'clyops';
4
+ import { auditLog } from 'clyops-tools';
5
+ import { readFileSync } from 'node:fs';
6
+ import { createApi, type ApiKey } from './server.js';
7
+
8
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string };
9
+
10
+ const cli = new Cli({ name: 'clyops-api' });
11
+ cli.setDescription('Serve a directory of clyops tools as an HTTP API: one POST endpoint per tool, validated and documented (OpenAPI at /openapi.json) from each tool\'s --help-json-schema.');
12
+ cli.setEpilog('Every option can also be set in the environment as CLYOPS_API_<OPTION> (CLYOPS_API_API_KEY for the key).\n\nExamples:\n clyops-api --root ~/mytool/scripts\n curl -X POST localhost:8080/tools/media/to-pcm -H content-type:application/json -d \'{"input":"a.wav"}\'');
13
+ cli.opt('CLYOPS_API_ROOT', 'root', 'r', '', 'Tools directory or dispatcher definition file', 'Tools', 'path');
14
+ cli.opt('CLYOPS_API_NAME', 'name', 'n', 'optional', 'API title (default: the directory name)', 'Tools');
15
+ cli.opt('CLYOPS_API_CWD', 'cwd', '', 'optional', 'Working directory for the tools (default: the current one)', 'Tools', 'dir:exists');
16
+ cli.opt('CLYOPS_API_TIMEOUT', 'timeout', 't', '0', 'Kill a tool after this many seconds (0: never)', 'Tools', 'int:0-');
17
+ cli.opt('CLYOPS_API_CONCURRENCY', 'concurrency', 'j', 'optional', 'Async jobs run at the same time (default: CPUs)', 'Tools', 'int:1-');
18
+ cli.opt('CLYOPS_API_MCP', 'mcp', '', 'true', 'Also serve the tools over MCP (streamable HTTP) at /mcp', 'Server', 'bool');
19
+ cli.opt('CLYOPS_API_WATCH', 'watch', 'w', 'true', 'Pick up added, changed and removed tools without a restart', 'Tools', 'bool');
20
+ cli.opt('CLYOPS_API_HOST', 'host', 'H', '127.0.0.1', 'Address to listen on', 'Server');
21
+ cli.opt('CLYOPS_API_PORT', 'port', 'p', '8080', 'Port to listen on', 'Server', 'port');
22
+ cli.opt('CLYOPS_API_API_KEY', 'api-key', 'k', 'optional', 'Require this key (Authorization: Bearer KEY or X-API-Key); it may run every tool', 'Server', 'secret');
23
+ cli.opt('CLYOPS_API_KEYS', 'keys', 'K', 'optional', 'JSON file of named keys, each with its own scope: {"ci": {"key": "...", "allow": ["media/*"]}}', 'Security', 'file:readable');
24
+ cli.optArray('CLYOPS_API_ALLOW', 'allow', 'a', 'Serve only tools matching this glob over their words (media/*, media/**)', 'Security');
25
+ cli.optArray('CLYOPS_API_DENY', 'deny', 'D', 'Leave out tools matching this glob', 'Security');
26
+ cli.opt('CLYOPS_API_READ_ONLY', 'read-only', '', 'flag', 'Serve only tools that declare the read-only effect', 'Security');
27
+ cli.optArray('CLYOPS_API_PATHS_WITHIN', 'paths-within', '', 'Path inputs must resolve inside this directory', 'Security', 'dir:exists');
28
+ cli.opt('CLYOPS_API_MAX_BODY', 'max-body', '', '10485760', 'Largest request body in bytes: JSON, multipart or spooled for an async job', 'Security', 'int:1-');
29
+ cli.opt('CLYOPS_API_MAX_OUTPUT', 'max-output', '', '16777216', 'Keep at most this many bytes of a tool\'s stdout and stderr (0: all)', 'Security', 'int:0-');
30
+ cli.opt('CLYOPS_API_AUDIT', 'audit', '', 'optional', 'Append a JSON line per run to this file (-: stderr)', 'Security', 'path');
31
+ const args = cli.run();
32
+
33
+ let keys: Record<string, ApiKey> | undefined;
34
+ if (args.CLYOPS_API_KEYS) {
35
+ try {
36
+ keys = JSON.parse(readFileSync(args.CLYOPS_API_KEYS as string, 'utf8')) as Record<string, ApiKey>;
37
+ } catch (err) {
38
+ die(1, 'cannot read --keys: %s', (err as Error).message);
39
+ }
40
+ }
41
+ const within = args.CLYOPS_API_PATHS_WITHIN as string[];
42
+
43
+ const { app, current } = await createApi({
44
+ root: args.CLYOPS_API_ROOT as string,
45
+ name: (args.CLYOPS_API_NAME as string | null) ?? undefined,
46
+ cwd: (args.CLYOPS_API_CWD as string | null) ?? undefined,
47
+ timeoutMs: (args.CLYOPS_API_TIMEOUT as number) * 1000,
48
+ concurrency: (args.CLYOPS_API_CONCURRENCY as number | null) ?? undefined,
49
+ apiKey: (args.CLYOPS_API_API_KEY as string | null) ?? undefined,
50
+ keys,
51
+ filter: { allow: args.CLYOPS_API_ALLOW as string[], deny: args.CLYOPS_API_DENY as string[], readOnly: args.CLYOPS_API_READ_ONLY as boolean },
52
+ within: within.length ? within : undefined,
53
+ maxBody: args.CLYOPS_API_MAX_BODY as number,
54
+ maxOutput: args.CLYOPS_API_MAX_OUTPUT as number,
55
+ audit: args.CLYOPS_API_AUDIT ? auditLog(args.CLYOPS_API_AUDIT as string) : undefined,
56
+ mcp: args.CLYOPS_API_MCP as boolean,
57
+ watch: args.CLYOPS_API_WATCH as boolean,
58
+ onReload: ({ tools }) => info('reloaded: %d tool(s)', tools.length),
59
+ version,
60
+ });
61
+ const server = app.listen(args.CLYOPS_API_PORT as number, args.CLYOPS_API_HOST as string, () => {
62
+ info('serving %d tool(s) on http://%s:%d (OpenAPI: /openapi.json%s)', current().tools.length, args.CLYOPS_API_HOST, args.CLYOPS_API_PORT, args.CLYOPS_API_MCP ? ', MCP: /mcp' : '');
63
+ });
64
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => server.close(() => process.exit(0)));
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // clyops-api: an HTTP API over a directory of clyops tools.
2
+ export * from './server.js';
3
+ export { toZod } from './zod.js';
4
+ export { loadTools, runTool, type Tool, type ToolResult } from 'clyops-tools';
package/src/server.ts ADDED
@@ -0,0 +1,590 @@
1
+ // An HTTP API over a directory of clyops tools: one POST endpoint per tool,
2
+ // validated and documented from its --help-json-schema.
3
+ import {
4
+ allowed, InputError, isTextType, loadTools, runTool, startTool, tail, toJsonSchema, watchTools,
5
+ type AuditEntry, type Command, type Group, type RunResult, type Tool, type ToolFilter, type ToolResult, type ToolRunOptions,
6
+ } from 'clyops-tools';
7
+ import { JobQueue, type JobRecord } from 'clyops-jobs';
8
+ import { mcpHttpHandler } from 'clyops-mcp';
9
+ import busboy from 'busboy';
10
+ import express, { type NextFunction, type Request, type Response } from 'express';
11
+ import { createReadStream, createWriteStream, existsSync, mkdtempSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { basename, join } from 'node:path';
14
+ import { Readable, Transform } from 'node:stream';
15
+ import { finished, pipeline } from 'node:stream/promises';
16
+ import { plus, z } from 'plus-express';
17
+ import { toZod } from './zod.js';
18
+
19
+ /** A named API key and what it may run: globs over tool words (see clyops-tools' ToolFilter). */
20
+ export interface ApiKey {
21
+ key: string;
22
+ allow?: string[];
23
+ deny?: string[];
24
+ }
25
+
26
+ export interface ApiOptions {
27
+ /** Tools directory or dispatcher definition file. */
28
+ root: string;
29
+ /** API title (default: the root's name). */
30
+ name?: string;
31
+ /** Working directory tools run in (default: the server's). */
32
+ cwd?: string;
33
+ /** Required as `Authorization: Bearer KEY` or `X-API-Key: KEY` when set; it may run every tool. */
34
+ apiKey?: string;
35
+ /** Named keys, each limited to the tools its allow/deny globs let through. */
36
+ keys?: Record<string, ApiKey>;
37
+ /** Which tools to serve at all (with the root's `allow`/`deny` settings). */
38
+ filter?: ToolFilter;
39
+ /** Path-valued inputs must resolve inside these directories. */
40
+ within?: string[];
41
+ /** Kill a tool after this long (0: never). */
42
+ timeoutMs?: number;
43
+ /** Largest request body accepted: JSON, multipart, or spooled for an async job (default 10 MiB). */
44
+ maxBody?: number;
45
+ /** Keep at most this many bytes of a tool's stdout and stderr (0 or unset: all). Streamed stdout is not kept. */
46
+ maxOutput?: number;
47
+ /** Called after every run, for an audit log. */
48
+ audit?: (entry: Omit<AuditEntry, 'time'>) => void;
49
+ /** Async jobs run at the same time (default: the number of CPUs). */
50
+ concurrency?: number;
51
+ /** Also serve the tools over MCP (streamable HTTP) at /mcp (default: true). */
52
+ mcp?: boolean;
53
+ /** Pick up added, changed and removed tools without a restart (default: false; call close() to stop). */
54
+ watch?: boolean;
55
+ /** Called after each reload when watching. */
56
+ onReload?: (loaded: { tree: Group; tools: ApiTool[] }) => void;
57
+ version?: string;
58
+ }
59
+
60
+ /** A tool the API serves, with the URL path of its endpoint, `/tools/<group>/.../<name>`. */
61
+ export type ApiTool = Tool & { path: string };
62
+
63
+ /** Who is calling: the key's name and what it may run. */
64
+ interface Caller {
65
+ name?: string;
66
+ filter?: ToolFilter;
67
+ }
68
+
69
+ const RunResponseSchema = z.object({
70
+ ok: z.boolean(),
71
+ exitCode: z.number().int().nullable(),
72
+ signal: z.string().nullable(),
73
+ timedOut: z.boolean(),
74
+ stdout: z.string().openapi({ description: 'Base64 when `stdoutEncoding` is `base64` (binary output)' }),
75
+ stdoutEncoding: z.literal('base64').optional(),
76
+ stdoutUrl: z.string().optional().openapi({ description: 'Where an async job\'s binary output is served' }),
77
+ stderr: z.string(),
78
+ truncated: z.boolean().optional(),
79
+ durationMs: z.number(),
80
+ command: z.array(z.string()),
81
+ json: z.unknown().optional(),
82
+ });
83
+
84
+ const JobSchema = z.object({
85
+ job_id: z.string(),
86
+ function: z.string(),
87
+ status: z.enum(['pending', 'processing', 'done', 'error']),
88
+ stage: z.string().nullable(),
89
+ started_at: z.string(),
90
+ updated_at: z.string(),
91
+ completed_at: z.string().nullable(),
92
+ error: z.string().nullable(),
93
+ result: RunResponseSchema.optional(),
94
+ }).passthrough();
95
+
96
+ const ErrorSchema = z.object({ error: z.string(), issues: z.unknown().optional() });
97
+ const json = (schema: z.ZodType, description: string) => ({ description, content: { 'application/json': { schema } } });
98
+
99
+ type ApiResult = ToolResult & { stdoutEncoding?: 'base64'; stdoutUrl?: string };
100
+
101
+ /** A tool's declared binary stdout type, if any (spec section 1.3). */
102
+ function binaryStdout(tool: Tool): string | undefined {
103
+ const type = tool.schema.stdout?.contentType;
104
+ return type && !isTextType(type) ? type.split(';')[0].trim() : undefined;
105
+ }
106
+
107
+ /**
108
+ * Whether to answer with the raw stdout stream rather than the JSON envelope:
109
+ * declared binary output streams unless the client asks for JSON, and other
110
+ * output streams when it asks for application/octet-stream.
111
+ */
112
+ function streams(tool: Tool, req: Request): boolean {
113
+ const accept = req.get('accept') ?? '';
114
+ return binaryStdout(tool) ? !accept.includes('application/json') : accept.includes('application/octet-stream');
115
+ }
116
+
117
+ /** The envelope as JSON: binary stdout base64-encoded. */
118
+ function envelope(result: ToolResult): ApiResult {
119
+ const { stdoutBuffer, ...rest } = result;
120
+ return stdoutBuffer ? { ...rest, stdout: stdoutBuffer.toString('base64'), stdoutEncoding: 'base64' } : rest;
121
+ }
122
+
123
+ /** A header-safe one-line summary of stderr, for the trailer. */
124
+ function stderrTrailer(stderr: string): string {
125
+ return tail(stderr, 3).join(' | ').replace(/[^\x20-\x7e]/g, '?').slice(-500);
126
+ }
127
+
128
+ /** Query values (strings, arrays of strings) typed by the input's JSON Schema, for a non-JSON body. */
129
+ function queryInput(query: Request['query'], schema: { properties?: Record<string, { type?: string; items?: { type?: string } }> }): Record<string, unknown> {
130
+ const typed = (value: string, type?: string): unknown => {
131
+ if ((type === 'integer' || type === 'number') && /^-?[0-9]*\.?[0-9]+$/.test(value)) return Number(value);
132
+ if (type === 'boolean' && /^(true|1|yes|on)$/i.test(value)) return true;
133
+ if (type === 'boolean' && /^(false|0|no|off)$/i.test(value)) return false;
134
+ return value;
135
+ };
136
+ const out: Record<string, unknown> = {};
137
+ for (const [key, raw] of Object.entries(query)) {
138
+ if (key === 'async') continue;
139
+ const values = (Array.isArray(raw) ? raw : [raw]).map(String);
140
+ const prop = schema.properties?.[key];
141
+ out[key] = prop?.type === 'array' ? values.map((v) => typed(v, prop.items?.type)) : typed(values[values.length - 1], prop?.type);
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /** Fails a stream with a 413 once more than `limit` bytes have passed. */
147
+ function limited(limit: number): Transform {
148
+ let size = 0;
149
+ return new Transform({
150
+ transform(chunk: Buffer, _enc, done) {
151
+ size += chunk.length;
152
+ if (size > limit) return done(Object.assign(new Error(`request body is larger than ${limit} bytes`), { status: 413 }));
153
+ done(null, chunk);
154
+ },
155
+ });
156
+ }
157
+
158
+ /**
159
+ * A multipart body: the `args` part is the JSON input, the `stdin` part is
160
+ * saved as the tool's stdin, and a part named after a path-valued input is
161
+ * saved and its path given as that input (repeated for array inputs). Files
162
+ * go to `dir`; at most `limit` bytes in all.
163
+ */
164
+ function readMultipart(req: Request, tool: Tool, dir: string, limit: number): Promise<{ input: Record<string, unknown>; stdin?: string }> {
165
+ const schema = toJsonSchema(tool.schema) as { properties: Record<string, { type?: string }> };
166
+ return new Promise((resolve, reject) => {
167
+ const input: Record<string, unknown> = {};
168
+ let stdin: string | undefined;
169
+ let size = 0;
170
+ let n = 0;
171
+ const writes: Promise<void>[] = [];
172
+ const bb = busboy({ headers: req.headers });
173
+ // Stop reading at the first problem; the rest of the body is discarded.
174
+ const fail = (err: Error) => {
175
+ req.unpipe(bb);
176
+ req.resume();
177
+ reject(err);
178
+ };
179
+ const count = (bytes: number) => {
180
+ size += bytes;
181
+ if (size > limit) fail(Object.assign(new Error(`request body is larger than ${limit} bytes`), { status: 413 }));
182
+ return size <= limit;
183
+ };
184
+ const save = (from: Readable, file: string) => {
185
+ const write = pipeline(from, createWriteStream(file));
186
+ write.catch(() => {}); // reported through fail or the close handler
187
+ writes.push(write);
188
+ };
189
+ bb.on('field', (name, value) => {
190
+ if (!count(Buffer.byteLength(value))) return;
191
+ if (name === 'args') {
192
+ try {
193
+ Object.assign(input, JSON.parse(value));
194
+ } catch {
195
+ fail(new InputError('the args part is not JSON'));
196
+ }
197
+ } else if (name === 'stdin') {
198
+ stdin = join(dir, 'stdin');
199
+ save(Readable.from([value]), stdin);
200
+ } else input[name] = value;
201
+ });
202
+ bb.on('file', (name, stream, info) => {
203
+ const file = name === 'stdin' ? join(dir, 'stdin') : join(dir, `${n++}-${basename(info.filename || name)}`);
204
+ if (name === 'stdin') stdin = file;
205
+ else if (schema.properties[name]?.type === 'array') input[name] = [...((input[name] as string[]) ?? []), file];
206
+ else input[name] = file;
207
+ stream.on('data', (chunk: Buffer) => void count(chunk.length));
208
+ save(stream, file);
209
+ });
210
+ bb.on('error', (err) => fail(err as Error));
211
+ bb.on('close', () => Promise.all(writes).then(() => resolve({ input, stdin }), reject));
212
+ req.pipe(bb);
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Build the Express app. With `watch`, the tool endpoints, the OpenAPI
218
+ * document and /mcp follow the tools directory as it changes; otherwise they
219
+ * are fixed when the app is built.
220
+ */
221
+ export async function createApi(opts: ApiOptions) {
222
+ const maxBody = opts.maxBody ?? 10 << 20;
223
+ // A job's files (spooled stdin, uploads, binary stdout) live until it is dropped.
224
+ const jobDirs = new Map<string, string>();
225
+ const queue = new JobQueue<ApiResult>({
226
+ concurrency: opts.concurrency,
227
+ onDrop: (record) => {
228
+ const dir = jobDirs.get(record.job_id);
229
+ if (dir) rmSync(dir, { recursive: true, force: true });
230
+ jobDirs.delete(record.job_id);
231
+ },
232
+ });
233
+ const onError = (cmd: Command | null, err: Error) => process.emitWarning(cmd ? `skipping ${cmd.words.join(' ')}: ${err.message}` : err.message);
234
+ const withPaths = (tools: Tool[]): ApiTool[] => tools.map((t) => ({ ...t, path: `/tools/${t.words.join('/')}` }));
235
+
236
+ // Everything documented in OpenAPI lives on a router rebuilt for each set of tools.
237
+ let api: ReturnType<typeof buildRoutes>;
238
+ const swap = (loaded: { tree: Group; tools: Tool[] }) => (api = buildRoutes(opts, ctx, loaded.tree, withPaths(loaded.tools)));
239
+ const ctx = { queue, jobDirs, maxBody };
240
+ const watcher = opts.watch
241
+ ? await watchTools(opts.root, { name: opts.name, filter: opts.filter, onError, onChange: (loaded) => opts.onReload?.(swap(loaded)) })
242
+ : undefined;
243
+ swap(watcher ? watcher.current() : await loadTools(opts.root, { name: opts.name, filter: opts.filter, onError }));
244
+
245
+ const app = express();
246
+ app.use(express.json({ limit: maxBody }));
247
+ const keys = Object.entries(opts.keys ?? {});
248
+ if (opts.apiKey || keys.length) {
249
+ app.use((req: Request, res: Response, next: NextFunction) => {
250
+ const given = req.get('x-api-key') ?? req.get('authorization')?.replace(/^Bearer\s+/i, '');
251
+ if (given && given === opts.apiKey) res.locals.caller = { name: keys.length ? 'default' : undefined } satisfies Caller;
252
+ else {
253
+ const found = keys.find(([, k]) => given && k.key === given);
254
+ if (!found) return void res.status(401).json({ error: 'missing or wrong API key' });
255
+ res.locals.caller = { name: found[0], filter: { allow: found[1].allow, deny: found[1].deny } } satisfies Caller;
256
+ }
257
+ next();
258
+ });
259
+ }
260
+ app.get('/openapi.json', (_req: Request, res: Response) => {
261
+ res.json(documentStreams(api.registry.generateOpenAPIDocument(opts.apiKey || keys.length ? { security: [{ apiKey: [] }] } : {}), api.tools));
262
+ });
263
+ // A key only sees and runs the tools it may.
264
+ app.use((req: Request, res: Response, next: NextFunction) => {
265
+ const caller = res.locals.caller as Caller | undefined;
266
+ const tool = api.tools.find((t) => t.path === req.path);
267
+ if (tool && caller?.filter && !allowed(tool, caller.filter)) return void res.status(403).json({ error: `this key may not run ${tool.words.join(' ')}` });
268
+ next();
269
+ });
270
+ // A non-JSON body is the tool's stdin (the arguments come from the query
271
+ // string), or multipart with the arguments, stdin and files.
272
+ app.use((req: Request, res: Response, next: NextFunction) => {
273
+ const tool = req.method === 'POST' && req.headers['content-type'] && !req.is('application/json') && api.tools.find((t) => t.path === req.path);
274
+ if (!tool) return next();
275
+ void runBody(opts, ctx, tool, req, res).catch(next);
276
+ });
277
+ // RouterPlus's type drops Router's call signature; it is still a Router.
278
+ app.use((req: Request, res: Response, next: NextFunction) => (api.router as unknown as express.Router)(req, res, next));
279
+ if (opts.mcp !== false) {
280
+ app.post('/mcp', mcpHttpHandler((req: Request) => {
281
+ const caller = req.res?.locals.caller as Caller | undefined;
282
+ return {
283
+ name: api.tree.name,
284
+ version: opts.version,
285
+ instructions: api.tree.description || undefined,
286
+ tools: caller?.filter ? api.tools.filter((t) => allowed(t, caller.filter as ToolFilter)) : api.tools,
287
+ cwd: opts.cwd,
288
+ timeoutMs: opts.timeoutMs,
289
+ within: opts.within,
290
+ maxOutput: opts.maxOutput,
291
+ audit: opts.audit && ((entry) => opts.audit?.({ ...entry, key: caller?.name })),
292
+ };
293
+ }));
294
+ }
295
+ app.use(errorHandler);
296
+
297
+ return {
298
+ app,
299
+ queue,
300
+ /** The tree and tools being served now. */
301
+ current: () => ({ tree: api.tree, tools: api.tools }),
302
+ /** Stop watching (the app keeps serving the last set). */
303
+ close: () => watcher?.close(),
304
+ };
305
+ }
306
+
307
+ type Ctx = { queue: JobQueue<ApiResult>; jobDirs: Map<string, string>; maxBody: number };
308
+
309
+ /** A POST with a non-JSON body: validate the query (or the args part), then run with the body as stdin. */
310
+ async function runBody(opts: ApiOptions, ctx: Ctx, tool: ApiTool, req: Request, res: Response): Promise<void> {
311
+ const inputSchema = toJsonSchema(tool.schema);
312
+ const dir = mkdtempSync(join(tmpdir(), 'clyops-api-'));
313
+ let keep = false;
314
+ try {
315
+ let input: Record<string, unknown>;
316
+ let stdin: string | Readable | undefined;
317
+ if (req.is('multipart/form-data')) {
318
+ const parts = await readMultipart(req, tool, dir, ctx.maxBody);
319
+ input = parts.input;
320
+ stdin = parts.stdin;
321
+ } else {
322
+ input = queryInput(req.query, inputSchema as never);
323
+ stdin = req;
324
+ }
325
+ const checked = toZod(inputSchema).safeParse(input);
326
+ if (!checked.success) return void res.status(400).json({ error: 'Validation failed', issues: checked.error.issues });
327
+ if (req.query.async === 'true' && stdin === req) {
328
+ // A job runs later: keep the body until then.
329
+ stdin = join(dir, 'stdin');
330
+ await pipeline(req, limited(ctx.maxBody), createWriteStream(stdin));
331
+ }
332
+ keep = req.query.async === 'true';
333
+ await respond(opts, ctx, tool, input, req, res, stdin, keep ? dir : undefined);
334
+ } finally {
335
+ if (!keep) rmSync(dir, { recursive: true, force: true });
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Run a tool for a request and answer: a job (`?async=true`), the raw
341
+ * stdout stream, or the JSON envelope. `stdin` is a file path (opened when
342
+ * the tool starts) or a stream. A job owns `dir` and removes it when dropped.
343
+ */
344
+ async function respond(opts: ApiOptions, ctx: Ctx, tool: ApiTool, input: Record<string, unknown>, req: Request, res: Response,
345
+ stdin?: string | Readable, dir?: string): Promise<void> {
346
+ const caller = res.locals.caller as Caller | undefined;
347
+ const base: ToolRunOptions = { cwd: opts.cwd, timeoutMs: opts.timeoutMs, within: opts.within, maxOutput: opts.maxOutput };
348
+ const open = () => (typeof stdin === 'string' ? createReadStream(stdin) : stdin);
349
+ const audit = (result: RunResult) => opts.audit?.({
350
+ key: caller?.name, tool: tool.words.join(' '), command: result.command, exitCode: result.exitCode, signal: result.signal,
351
+ timedOut: result.timedOut, durationMs: result.durationMs, via: 'api',
352
+ });
353
+
354
+ if (req.query.async === 'true') {
355
+ const jobDir = dir ?? mkdtempSync(join(tmpdir(), 'clyops-api-'));
356
+ const toFile = streams(tool, req);
357
+ const job = ctx.queue.add(tool.words.join(' '), async ({ signal }) => {
358
+ let result: ApiResult;
359
+ if (toFile) {
360
+ const started = startTool(tool, input, { ...base, signal, stdin: open(), stdout: 'stream' });
361
+ const [r] = await Promise.all([started.result, pipeline(started.stdout, createWriteStream(join(jobDir, 'stdout')))]);
362
+ result = { ...r, ok: r.exitCode === 0, stdoutUrl: `/jobs/${job.record.job_id}/stdout` };
363
+ } else {
364
+ result = envelope(await runTool(tool, input, { ...base, signal, stdin: open(), stdout: binaryStdout(tool) ? 'buffer' : 'text' }));
365
+ }
366
+ audit(result);
367
+ return result;
368
+ }, caller?.name ? { key: caller.name } : {});
369
+ ctx.jobDirs.set(job.record.job_id, jobDir);
370
+ res.status(202).location(`/jobs/${job.record.job_id}`).json(job.record);
371
+ return;
372
+ }
373
+
374
+ // A client that goes away stops the tool.
375
+ const abort = new AbortController();
376
+ res.on('close', () => !res.writableEnded && abort.abort());
377
+ if (!streams(tool, req)) {
378
+ const result = await runTool(tool, input, { ...base, signal: abort.signal, stdin: open(), stdout: binaryStdout(tool) ? 'buffer' : 'text' });
379
+ audit(result);
380
+ res.json(envelope(result));
381
+ return;
382
+ }
383
+
384
+ // Stream stdout as it comes. The exit status isn't known when the headers
385
+ // go out, so it follows as a trailer; a failure before any output is still
386
+ // a JSON error.
387
+ const started = startTool(tool, input, { ...base, signal: abort.signal, stdin: open(), stdout: 'stream' });
388
+ const out = started.stdout;
389
+ const first = await new Promise<Buffer | null>((resolve) => {
390
+ out.once('data', (chunk: Buffer) => {
391
+ out.pause();
392
+ resolve(chunk);
393
+ });
394
+ out.once('end', () => resolve(null));
395
+ });
396
+ if (!first) {
397
+ const result = await started.result;
398
+ audit(result);
399
+ if (result.exitCode !== 0) return void res.status(500).json(envelope({ ...result, ok: false }));
400
+ res.status(200).type(binaryStdout(tool) ?? 'application/octet-stream').end();
401
+ return;
402
+ }
403
+ res.status(200);
404
+ res.setHeader('Content-Type', binaryStdout(tool) ?? 'application/octet-stream');
405
+ res.setHeader('Trailer', 'X-Clyops-Exit-Code, X-Clyops-Stderr');
406
+ res.write(first);
407
+ out.pipe(res, { end: false });
408
+ const [result] = await Promise.all([started.result, finished(out)]);
409
+ audit(result);
410
+ res.addTrailers({ 'X-Clyops-Exit-Code': String(result.exitCode ?? ''), 'X-Clyops-Stderr': stderrTrailer(result.stderr) });
411
+ res.end();
412
+ }
413
+
414
+ /**
415
+ * The OpenAPI document with what plus-express can't express: the other
416
+ * request bodies (a tool's stdin, multipart), the query parameters they take,
417
+ * declared binary stdout and the tools' effects.
418
+ */
419
+ function documentStreams(doc: { paths?: Record<string, Record<string, Record<string, unknown>>> }, tools: ApiTool[]) {
420
+ const binary = { type: 'string', format: 'binary' };
421
+ for (const tool of tools) {
422
+ const op = doc.paths?.[tool.path]?.post as {
423
+ requestBody?: { content: Record<string, unknown> }; parameters?: unknown[]; responses?: Record<string, { content?: Record<string, unknown> }>;
424
+ } & Record<string, unknown> | undefined;
425
+ if (!op) continue;
426
+ if (tool.schema.effects?.length) op['x-clyops-effects'] = tool.schema.effects;
427
+ const content = (op.requestBody ??= { content: {} }).content;
428
+ for (const type of tool.schema.stdin?.contentType ? tool.schema.stdin.contentType.split(',').map((t) => t.trim()) : ['application/octet-stream']) {
429
+ content[type] = { schema: { ...binary, description: `Standard input${tool.schema.stdin?.description ? `: ${tool.schema.stdin.description}` : ''}; the arguments come from the query string` } };
430
+ }
431
+ content['multipart/form-data'] = {
432
+ schema: {
433
+ type: 'object',
434
+ properties: { args: { type: 'string', description: 'The input, as JSON' }, stdin: { ...binary, description: 'Standard input' } },
435
+ additionalProperties: { ...binary, description: 'A file for the path input of the same name' },
436
+ },
437
+ };
438
+ const properties = (toJsonSchema(tool.schema).properties ?? {}) as Record<string, Record<string, unknown>>;
439
+ op.parameters = [
440
+ ...(op.parameters ?? []),
441
+ ...Object.entries(properties).map(([name, schema]) => ({
442
+ in: 'query', name, required: false, schema, ...(schema.type === 'array' ? { style: 'form', explode: true } : {}),
443
+ description: `${schema.description ?? ''} (with a non-JSON body)`.trim(),
444
+ })),
445
+ ];
446
+ const type = binaryStdout(tool);
447
+ const ok = op.responses?.['200'];
448
+ if (type && ok) ok.content = { [type]: { schema: { ...binary, description: tool.schema.stdout?.description ?? '' } }, ...ok.content };
449
+ }
450
+ return doc;
451
+ }
452
+
453
+ /** The documented routes for one set of tools, on their own router and registry. */
454
+ function buildRoutes(opts: ApiOptions, ctx: Ctx, tree: Group, tools: ApiTool[]) {
455
+ const { queue } = ctx;
456
+ const { router, registry } = plus(express.Router(), {
457
+ openApiConfig: {
458
+ openapi: '3.0.0',
459
+ info: { title: tree.name, version: opts.version ?? '0.0.0', description: tree.description || `clyops tools in ${tree.dir}` },
460
+ },
461
+ });
462
+ if (opts.apiKey || Object.keys(opts.keys ?? {}).length) registry.registerSecurityScheme('apiKey', { type: 'http', scheme: 'bearer' });
463
+ const jobView = (record: JobRecord, result?: ApiResult) => ({ ...record, ...(result ? { result } : {}) });
464
+ const visible = (res: Response) => {
465
+ const caller = res.locals.caller as Caller | undefined;
466
+ return {
467
+ tool: (t: Tool) => !caller?.filter || allowed(t, caller.filter),
468
+ // With named keys, a key sees only its own jobs.
469
+ job: (r: JobRecord) => !caller?.name || r.key === caller.name,
470
+ };
471
+ };
472
+
473
+ router.get(
474
+ {
475
+ path: '/tools',
476
+ summary: 'List the tools',
477
+ tags: ['tools'],
478
+ responses: { 200: json(z.array(z.object({ name: z.string(), words: z.array(z.string()), path: z.string(), description: z.string() })), 'The tools') },
479
+ },
480
+ (_req: Request, res: Response) => {
481
+ res.json(tools.filter(visible(res).tool).map((t) => ({ name: t.name, words: t.words, path: t.path, description: t.description })));
482
+ },
483
+ );
484
+
485
+ for (const tool of tools) {
486
+ const inputSchema = toJsonSchema(tool.schema);
487
+ const tag = tool.words.length > 1 ? tool.words.slice(0, -1).join(' ') : 'tools';
488
+ router.get(
489
+ {
490
+ path: tool.path,
491
+ summary: `Describe ${tool.words.join(' ')}`,
492
+ tags: [tag],
493
+ responses: { 200: json(z.object({ schema: z.unknown(), input: z.unknown() }), 'The tool\'s clyops schema and the JSON Schema of its input') },
494
+ },
495
+ (_req: Request, res: Response) => {
496
+ res.json({ schema: tool.schema, input: inputSchema });
497
+ },
498
+ );
499
+ router.post(
500
+ {
501
+ path: tool.path,
502
+ operationId: tool.words.join('_').replace(/[^A-Za-z0-9_]/g, '_'),
503
+ summary: tool.description || tool.words.join(' '),
504
+ description: [tool.schema.description, tool.schema.epilog].filter(Boolean).join('\n\n'),
505
+ tags: [tag],
506
+ body: toZod(inputSchema),
507
+ query: z.object({ async: z.enum(['true', 'false']).optional().openapi({ description: 'Return a job id at once instead of waiting' }) }),
508
+ responses: {
509
+ 200: json(RunResponseSchema, 'The tool ran; `ok` is false when it exited non-zero'),
510
+ 202: json(JobSchema, 'Queued (with `?async=true`); poll `GET /jobs/{id}`'),
511
+ 400: json(ErrorSchema, 'Invalid input'),
512
+ 403: json(ErrorSchema, 'The API key may not run this tool'),
513
+ 500: json(RunResponseSchema, 'Streaming output: the tool failed before writing any'),
514
+ },
515
+ },
516
+ async (req: Request, res: Response, next: NextFunction) => {
517
+ try {
518
+ await respond(opts, ctx, tool, (req.body ?? {}) as Record<string, unknown>, req, res);
519
+ } catch (err) {
520
+ next(err);
521
+ }
522
+ },
523
+ );
524
+ }
525
+
526
+ const JobParams = z.object({ id: z.string() });
527
+ const find = (req: Request, res: Response) => {
528
+ const job = queue.get(String(req.params.id));
529
+ return job && visible(res).job(job.record) ? job : undefined;
530
+ };
531
+ router.get(
532
+ { path: '/jobs', summary: 'List jobs', tags: ['jobs'], responses: { 200: json(z.array(JobSchema), 'Recent jobs, without results') } },
533
+ (_req: Request, res: Response) => {
534
+ res.json(queue.list().filter(visible(res).job));
535
+ },
536
+ );
537
+ router.get(
538
+ { path: '/jobs/:id', summary: 'A job, with its result once done', tags: ['jobs'], params: JobParams, responses: { 200: json(JobSchema, 'The job'), 404: json(ErrorSchema, 'No such job') } },
539
+ (req: Request, res: Response) => {
540
+ const job = find(req, res);
541
+ if (!job) return void res.status(404).json({ error: `no job ${req.params.id}` });
542
+ res.json(jobView(job.record, job.result));
543
+ },
544
+ );
545
+ router.get(
546
+ {
547
+ path: '/jobs/:id/stdout',
548
+ summary: 'A finished job\'s binary output',
549
+ tags: ['jobs'],
550
+ params: JobParams,
551
+ responses: {
552
+ 200: { description: 'The output, with the tool\'s declared content type', content: { 'application/octet-stream': { schema: z.string().openapi({ format: 'binary' }) } } },
553
+ 404: json(ErrorSchema, 'No such job, or no output (yet)'),
554
+ },
555
+ },
556
+ (req: Request, res: Response) => {
557
+ const job = find(req, res);
558
+ const file = job && ctx.jobDirs.get(job.record.job_id) && join(ctx.jobDirs.get(job.record.job_id) as string, 'stdout');
559
+ if (!job || !file || job.record.status !== 'done' || !existsSync(file)) return void res.status(404).json({ error: `no output for job ${req.params.id}` });
560
+ const tool = tools.find((t) => t.words.join(' ') === job.record.function);
561
+ res.type((tool && binaryStdout(tool)) ?? 'application/octet-stream').sendFile(file);
562
+ },
563
+ );
564
+ router.delete(
565
+ { path: '/jobs/:id', summary: 'Cancel a job', tags: ['jobs'], params: JobParams, responses: { 202: json(JobSchema, 'Cancelling'), 404: json(ErrorSchema, 'No such job') } },
566
+ (req: Request, res: Response) => {
567
+ const job = find(req, res);
568
+ if (!job) return void res.status(404).json({ error: `no job ${req.params.id}` });
569
+ job.cancel();
570
+ res.status(202).json(jobView(job.record));
571
+ },
572
+ );
573
+
574
+ return { router, registry, tree, tools };
575
+ }
576
+
577
+ /** Errors as JSON: validation failures (400) with zod's issues, anything else 500. */
578
+ export function errorHandler(err: Error & { status?: number; errors?: unknown }, _req: Request, res: Response, _next: NextFunction): void {
579
+ const status = err.status ?? 500;
580
+ let issues = err.errors;
581
+ if (typeof issues === 'string') {
582
+ try {
583
+ issues = JSON.parse(issues);
584
+ } catch {
585
+ // plain message
586
+ }
587
+ }
588
+ if (res.headersSent) return void res.end();
589
+ res.status(status).json({ error: err.message, ...(issues !== undefined ? { issues } : {}) });
590
+ }