@agentage/extension 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -9,5 +9,21 @@ correlation id, the response envelope, the store pool, the scheduler and argumen
9
9
  npm install @agentage/extension express zod
10
10
  ```
11
11
 
12
+ `agentage extension check [dir] --json` prints the conformance report (exit 1 when `conforms` is
13
+ false); `file` and `line` are there only when a problem has them:
14
+
15
+ ```json
16
+ {
17
+ "id": "probe",
18
+ "version": "0.4.0",
19
+ "conforms": false,
20
+ "rules": ["manifest", "entries", "surface", "stack", "store", "sealing"],
21
+ "problems": [
22
+ { "rule": "sealing", "file": "src/api/index.ts", "line": 7, "problem": "reads process.env" }
23
+ ],
24
+ "skipped": {}
25
+ }
26
+ ```
27
+
12
28
  See the [platform README](https://github.com/agentage/platform#readme) for every part kind, the
13
29
  manifest, the tool and how a part image runs.
@@ -2,7 +2,7 @@ import { parseArgs } from 'node:util';
2
2
  import {} from '../manifest/manifest.schema.js';
3
3
  export class UsageError extends Error {
4
4
  }
5
- export const isOptional = (arg) => arg.optional === true || arg.required === false;
5
+ export const isOptional = (arg) => arg.required === false;
6
6
  const coerce = (arg, raw) => {
7
7
  if (arg.type === 'boolean')
8
8
  return raw === true || raw === 'true';
@@ -44,7 +44,6 @@ declare const CommandSchema: z.ZodObject<{
44
44
  boolean: "boolean";
45
45
  integer: "integer";
46
46
  }>;
47
- optional: z.ZodOptional<z.ZodBoolean>;
48
47
  required: z.ZodOptional<z.ZodBoolean>;
49
48
  description: z.ZodOptional<z.ZodString>;
50
49
  }, z.core.$strict>>>;
@@ -99,7 +98,6 @@ export declare const PackageSchema: z.ZodObject<{
99
98
  boolean: "boolean";
100
99
  integer: "integer";
101
100
  }>;
102
- optional: z.ZodOptional<z.ZodBoolean>;
103
101
  required: z.ZodOptional<z.ZodBoolean>;
104
102
  description: z.ZodOptional<z.ZodString>;
105
103
  }, z.core.$strict>>>;
@@ -27,7 +27,6 @@ const CommandSchema = z.strictObject({
27
27
  .array(z.strictObject({
28
28
  name: CommandNameSchema,
29
29
  type: ArgTypeSchema,
30
- optional: z.boolean().optional(),
31
30
  required: z.boolean().optional(),
32
31
  description: z.string().min(1).max(200).optional(),
33
32
  }))
@@ -39,10 +39,11 @@ const guard = async (options, req, body) => {
39
39
  if (!options.tokenCheck) {
40
40
  if (listing.length > 0)
41
41
  throw fail('UNAUTHORIZED', 'tools/list needs a token');
42
- return;
42
+ return false;
43
43
  }
44
- if (needed.length > 0)
45
- await options.tokenCheck(req.get('authorization'), strongest(needed));
44
+ if (needed.length === 0)
45
+ return false;
46
+ return (await options.tokenCheck(req.get('authorization'), strongest(needed))) !== null;
46
47
  };
47
48
  const webRequest = (req, body) => {
48
49
  const headers = new Headers();
@@ -56,9 +57,12 @@ const webRequest = (req, body) => {
56
57
  body,
57
58
  });
58
59
  };
59
- const send = async (response, res) => {
60
+ const send = async (response, res, auth) => {
60
61
  res.status(response.status);
61
62
  response.headers.forEach((value, key) => void res.setHeader(key, value));
63
+ // After the SDK headers: its SSE answer sets no-cache, which a token-checked answer overrides.
64
+ if (auth)
65
+ res.setHeader('Cache-Control', 'no-store');
62
66
  if (!response.body)
63
67
  return void res.end();
64
68
  await pipeline(Readable.fromWeb(response.body), res);
@@ -69,8 +73,8 @@ const handle = (handler, options) => async (req, res) => {
69
73
  // The SDK reads a body again when given none (a BOM, say): it must only run what guard read.
70
74
  if (message === undefined)
71
75
  return void res.status(400).json(PARSE_ERROR);
72
- await guard(options, req, message);
73
- await send(await handler.fetch(webRequest(req, body), { parsedBody: message }), res);
76
+ const auth = await guard(options, req, message);
77
+ await send(await handler.fetch(webRequest(req, body), { parsedBody: message }), res, auth);
74
78
  };
75
79
  const methodNotAllowed = (_req, res) => {
76
80
  res.setHeader('Allow', 'POST');
@@ -57,11 +57,14 @@ export const createPartApp = (options) => {
57
57
  // The committed OpenAPI document, bare JSON and anonymous like discovery (api-standards section 1).
58
58
  const openapiHandler = (doc) => (_req, res) => void res.json(doc);
59
59
  // Before the body is read: each request checked for the access its OpenAPI operation declares.
60
- const routeGuard = (options) => async (req, _res, next) => {
60
+ const routeGuard = (options) => async (req, res, next) => {
61
61
  if (!options.tokenCheck)
62
62
  return next();
63
63
  const access = routeAccess(options.openapi, req.method, `/api/v1${req.path}`);
64
- setCaller(req, await options.tokenCheck(req.get('authorization'), access));
64
+ const caller = await options.tokenCheck(req.get('authorization'), access);
65
+ if (caller)
66
+ res.setHeader('Cache-Control', 'no-store');
67
+ setCaller(req, caller);
65
68
  next();
66
69
  };
67
70
  export const createApiApp = (options) => createPartApp({
@@ -19,7 +19,19 @@ const toFailure = (err) => {
19
19
  export const notFound = () => {
20
20
  throw fail('NOT_FOUND', 'Not found');
21
21
  };
22
+ const GONE_CODES = ['ERR_STREAM_PREMATURE_CLOSE', 'ECONNABORTED'];
23
+ // A client abort leaves the socket destroyed; the same code from an app-side stream does not.
24
+ const clientGone = (err, res) => GONE_CODES.includes(err?.code) && res.socket?.destroyed !== false;
22
25
  export const errorHandler = (log) => (err, _req, res, _next) => {
26
+ // A gone client or a started answer cannot take an envelope: close it, never write twice.
27
+ const gone = clientGone(err, res);
28
+ if (gone || res.headersSent) {
29
+ if (gone)
30
+ log.warn('client closed early');
31
+ else
32
+ log.error(err);
33
+ return void res.destroy();
34
+ }
23
35
  const failure = toFailure(err);
24
36
  if (failure.status >= 500)
25
37
  log.error(err);
@@ -5,16 +5,16 @@ export interface CheckOptions {
5
5
  requireDocker?: boolean;
6
6
  rules?: readonly CheckRule[];
7
7
  }
8
- export interface CheckInput extends CheckOptions {
8
+ export type CheckInput = CheckOptions & {
9
9
  dir: string;
10
10
  pkg: ExtensionPackage;
11
11
  run: Run;
12
- }
13
- export interface Finding {
12
+ };
13
+ export type Finding = {
14
14
  file?: string;
15
15
  line?: number;
16
16
  problem: string;
17
- }
17
+ };
18
18
  export type CheckProblem = Finding & {
19
19
  rule: string;
20
20
  };
@@ -33,5 +33,5 @@ export interface CheckReport {
33
33
  skipped: Record<string, string>;
34
34
  }
35
35
  export declare const RULES: readonly CheckRule[];
36
- export declare const check: (dir: string, options?: CheckOptions) => Promise<CheckReport>;
36
+ export declare const check: (at: string, options?: CheckOptions) => Promise<CheckReport>;
37
37
  export declare const assertConforms: (dir: string, options?: CheckOptions) => Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { readdir } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, normalize, resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import semver from 'semver';
5
5
  import { checkCli } from '../command/check-cli.js';
@@ -14,14 +14,15 @@ import { sealing } from './rules/sealing.js';
14
14
  import { store } from './rules/store.js';
15
15
  import { surface } from './rules/surface.js';
16
16
  import { KIT_VERSION } from './kit-version.js';
17
- import { assertBuilt } from './stage.js';
17
+ import { assertBuilt, exists } from './stage.js';
18
18
  const RECIPE = /^(?:dockerfile|containerfile)(?:\..+)?$|^(?:docker-)?compose(?:\.[\w-]+)?\.ya?ml$/i;
19
+ const NO_PACKAGE = 'is missing: run it in an extension package folder';
19
20
  const manifest = {
20
21
  name: 'manifest',
21
22
  run: async ({ dir }) => {
22
- const value = await readJsonIfExists(join(dir, 'package.json')).catch((err) => {
23
- throw new Error(messageOf(err).replace(`${dir}/`, ''));
24
- });
23
+ const value = await readJsonIfExists(join(dir, 'package.json'));
24
+ if (value === undefined)
25
+ return [{ file: 'package.json', problem: NO_PACKAGE }];
25
26
  const issues = PackageSchema.safeParse(value).error?.issues ?? [];
26
27
  const recipes = (await readdir(dir)).filter((file) => RECIPE.test(file)).sort();
27
28
  const recipe = 'is not allowed: the kit writes every image recipe';
@@ -31,32 +32,35 @@ const manifest = {
31
32
  ];
32
33
  },
33
34
  };
34
- const problemsOf = (work) => work.then((found) => (Array.isArray(found) ? found : []), (err) => [messageOf(err)]);
35
- const exportProblems = async (dir, pkg, part) => {
36
- const mod = (await import(pathToFileURL(join(dir, entryOf(pkg, part))).href));
37
- const names = Object.keys(mod);
38
- if (!names.includes('create'))
39
- return [`part ${part} does not export create`];
35
+ const exportProblem = async (dir, pkg, part) => {
36
+ const href = pathToFileURL(join(dir, entryOf(pkg, part))).href;
37
+ const mod = (await import(href).catch((err) => {
38
+ throw new Error(`part ${part} fails at import: ${messageOf(err)}`);
39
+ }));
40
+ const others = Object.keys(mod).filter((name) => name !== 'create');
40
41
  if (typeof mod.create !== 'function')
41
- return [`part ${part} create is not a function`];
42
- const others = names.filter((name) => name !== 'create');
43
- return others.length > 0 ? [`part ${part} exports ${others.join(', ')} beside create`] : [];
42
+ return `part ${part} does not export create${'create' in mod ? ' as a function' : ''}`;
43
+ if (others.length > 0)
44
+ return `part ${part} exports ${others.join(', ')} beside create`;
45
+ const cli = part === 'cli' ? await checkCli(dir) : null;
46
+ return cli && `cli part: ${cli}`;
44
47
  };
45
- const partProblems = async ({ dir, pkg }, part) => {
46
- const built = part === 'cli' ? [] : await problemsOf(assertBuilt(dir, pkg, part));
47
- if (built.length > 0 || part === 'ui')
48
- return built;
49
- const own = await problemsOf(exportProblems(dir, pkg, part));
50
- const cli = own.length === 0 && part === 'cli' ? await checkCli(dir) : null;
51
- return cli ? [`cli part: ${cli}`] : own;
48
+ const partProblem = async ({ dir, pkg }, part) => {
49
+ if (part !== 'cli')
50
+ await assertBuilt(dir, pkg, part);
51
+ else if (!(await exists(join(dir, entryOf(pkg, part)))))
52
+ return `${normalize(entryOf(pkg, part))} not found: the package build must write the cli part there`;
53
+ return part === 'ui' ? null : exportProblem(dir, pkg, part);
52
54
  };
53
55
  // Every part built where its image looks, exporting create and nothing else; cli handlers = commands.
54
56
  const entries = {
55
57
  name: 'entries',
56
58
  run: async (input) => {
59
+ if (!(await exists(join(input.dir, 'dist'))))
60
+ return [{ file: 'dist', problem: 'is missing: build the package first (npm run build)' }];
57
61
  const parts = Object.keys(normalizeParts(input.pkg.agentage.parts).code);
58
- const found = await Promise.all(parts.map((part) => partProblems(input, part)));
59
- return found.flat().map((problem) => ({ problem }));
62
+ const found = parts.map((part) => partProblem(input, part).catch(messageOf));
63
+ return (await Promise.all(found)).flatMap((problem) => (problem ? [{ problem }] : []));
60
64
  },
61
65
  };
62
66
  const MAJORS = { express: '^5', zod: '^4', '@modelcontextprotocol/server': '^2' };
@@ -87,9 +91,11 @@ const stack = {
87
91
  };
88
92
  // The closed list of extension-kit section 7, in its order; build runs it before any image.
89
93
  export const RULES = [manifest, entries, surface, stack, store, sealing];
90
- const runRule = (rule, input) => rule.run(input).catch((err) => [{ problem: messageOf(err) }]);
91
- export const check = async (dir, options = {}) => {
94
+ // A thrown message names its file relative to the package, as every problem line does.
95
+ const runRule = (rule, input) => rule.run(input).catch((err) => [{ problem: messageOf(err).replace(`${input.dir}/`, '') }]);
96
+ export const check = async (at, options = {}) => {
92
97
  const { rules = RULES, run = runCommand } = options;
98
+ const dir = resolve(at);
93
99
  const read = readJsonIfExists(join(dir, 'package.json')).catch(() => ({}));
94
100
  const pkg = ((await read) ?? {});
95
101
  const problems = [];
package/dist/tool/exec.js CHANGED
@@ -51,7 +51,8 @@ const tail = (text) => text ? `\n${text.trim().split('\n').slice(-15).join('\n')
51
51
  export const mustRun = async (run, command, args, options) => {
52
52
  const result = await run(command, args, options);
53
53
  if (result.code !== 0) {
54
- throw new Error(`${command} ${args[0] ?? ''} failed with exit code ${result.code}${tail(result.stderr)}`);
54
+ // tsc writes its diagnostics to stdout, so an empty stderr falls back to it.
55
+ throw new Error(`${command} ${args[0] ?? ''} failed with exit code ${result.code}${tail(result.stderr?.trim() ? result.stderr : result.stdout)}`);
55
56
  }
56
57
  return result.stdout;
57
58
  };
@@ -1,6 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join, resolve } from 'node:path';
3
- import { messageOf } from '../../log/log.js';
4
3
  import { hasPostgres } from '../../manifest/parts.js';
5
4
  import { sqlFiles } from '../../store/migrations.js';
6
5
  import {} from '../check.js';
@@ -18,6 +17,9 @@ const runArgs = (dir, files) => [
18
17
  ...[STORE_IMAGE, '-c', SCRIPT, 'sh', ...files],
19
18
  ];
20
19
  const failure = (stderr = '') => {
20
+ const daemon = /^docker: (.*)$/m.exec(stderr)?.[1];
21
+ if (daemon)
22
+ return { problem: `${STORE_IMAGE} does not run: ${daemon}` };
21
23
  const [, file, line, why] = /^psql:\/tmp\/m\/([^:]+):(\d+): (?:ERROR:\s+)?(.*)$/m.exec(stderr) ?? [];
22
24
  const problem = `fails on ${STORE_IMAGE}: ${why ? `ERROR: ${why}` : stderr.trim().split('\n').pop()}`;
23
25
  return file ? { file: `migrations/${file}`, line: Number(line), problem } : { problem };
@@ -36,15 +38,12 @@ const metaCommands = async (dir, files) => {
36
38
  });
37
39
  };
38
40
  const dockerAnswers = (run) => run('docker', ['version'], { quiet: true }).then(({ code }) => code === 0, () => false);
39
- const migrationFiles = (dir) => sqlFiles(join(dir, 'migrations')).catch((err) => {
40
- throw new Error(messageOf(err).replace(join(dir, 'migrations'), 'migrations'));
41
- });
42
41
  export const store = {
43
42
  name: 'store',
44
43
  run: async ({ dir, pkg, run, requireDocker }) => {
45
44
  if (!hasPostgres(pkg))
46
45
  return { skipped: 'no store' };
47
- const files = await migrationFiles(dir);
46
+ const files = await sqlFiles(join(dir, 'migrations'));
48
47
  const meta = await metaCommands(dir, files);
49
48
  if (files.length === 0 || meta.length > 0)
50
49
  return meta;
@@ -14,13 +14,15 @@ const routeKeys = ({ path, stack }) => [path].flat().flatMap((one) => {
14
14
  // router.all names no method, so it has no operation to compare.
15
15
  return stack.flatMap(({ method }) => (method ? [`${method.toUpperCase()} ${at}`] : []));
16
16
  });
17
- const stackOf = (router) => router?.stack;
18
17
  // Express routes (of a Router or an express() app) as OpenAPI keys; a sub-router hides its routes, so it never matches one.
19
- const servedRoutes = (router) => (stackOf(router) ?? stackOf(router?.router) ?? []).flatMap(({ name, route, handle }) => {
20
- if (route)
21
- return routeKeys(route);
22
- return handle?.stack ? [`${name} sub-router`] : [];
23
- });
18
+ const servedRoutes = (app) => {
19
+ const { stack, router } = (app ?? {});
20
+ return (stack ?? router?.stack ?? []).flatMap(({ name, route, handle }) => {
21
+ if (route)
22
+ return routeKeys(route);
23
+ return handle?.stack ? [`${name} sub-router`] : [];
24
+ });
25
+ };
24
26
  const METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
25
27
  const operations = (doc) => Object.entries((doc['paths'] ?? {})).flatMap(([path, ops]) => Object.entries(ops ?? {})
26
28
  .filter(([method]) => METHODS.has(method))
@@ -33,8 +35,7 @@ const apiProblems = (doc, routes, version) => {
33
35
  const served = new Set([`info.version ${version}`, ...routes]);
34
36
  const info = doc['info']?.['version'];
35
37
  const documented = new Set([`info.version ${String(info)}`, ...ops.map(([key]) => key)]);
36
- const diff = [...served].filter((k) => !documented.has(k));
37
- diff.push(...[...documented].filter((k) => !served.has(k)));
38
+ const diff = [...served, ...documented].filter((k) => !served.has(k) || !documented.has(k));
38
39
  const open = ops.filter(([, op]) => !declaresAccess(op['security'] ?? doc['security']));
39
40
  return [
40
41
  ...(diff.length > 0 ? [`differs from the generated contract (${diff.join(', ')})`] : []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentage/extension",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "The agentage extension kit: create(ctx) per part (pages, ui, api, mcp, worker, cli), the store, /health, discovery, the envelope, and the build tool with up and token",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -213,9 +213,6 @@
213
213
  "boolean"
214
214
  ]
215
215
  },
216
- "optional": {
217
- "type": "boolean"
218
- },
219
216
  "required": {
220
217
  "type": "boolean"
221
218
  },