@agentage/extension 0.3.1 → 0.4.1
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 +16 -0
- package/dist/tool/build.js +1 -1
- package/dist/tool/check.d.ts +29 -8
- package/dist/tool/check.js +116 -24
- package/dist/tool/format.d.ts +3 -0
- package/dist/tool/format.js +16 -0
- package/dist/tool/index.d.ts +2 -1
- package/dist/tool/index.js +2 -1
- package/dist/tool/record-extras.d.ts +3 -0
- package/dist/tool/record-extras.js +18 -14
- package/dist/tool/rules/sealing.d.ts +2 -0
- package/dist/tool/rules/sealing.js +71 -0
- package/dist/tool/rules/store.d.ts +2 -0
- package/dist/tool/rules/store.js +60 -0
- package/dist/tool/rules/surface.d.ts +2 -0
- package/dist/tool/rules/surface.js +89 -0
- package/package.json +4 -2
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.
|
package/dist/tool/build.js
CHANGED
|
@@ -68,7 +68,7 @@ export const build = async (options = {}, deps = { run: runCommand }) => {
|
|
|
68
68
|
const pkg = await readManifest(dir);
|
|
69
69
|
const info = await resolveBuildInfo(dir, options.env ?? process.env, deps.run);
|
|
70
70
|
await compile(dir, pkg, deps.run);
|
|
71
|
-
await assertConforms(
|
|
71
|
+
await assertConforms(dir, { run: deps.run });
|
|
72
72
|
const images = await buildParts({ dir, pkg, info }, options, deps);
|
|
73
73
|
const result = { id: pkg.agentage.id, version: pkg.version, ...info, images };
|
|
74
74
|
if (!options.push)
|
package/dist/tool/check.d.ts
CHANGED
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
import { type ExtensionPackage } from '../manifest/manifest.schema.js';
|
|
2
|
-
|
|
2
|
+
import { type Run } from './exec.js';
|
|
3
|
+
export interface CheckOptions {
|
|
4
|
+
run?: Run;
|
|
5
|
+
requireDocker?: boolean;
|
|
6
|
+
rules?: readonly CheckRule[];
|
|
7
|
+
}
|
|
8
|
+
export type CheckInput = CheckOptions & {
|
|
3
9
|
dir: string;
|
|
4
10
|
pkg: ExtensionPackage;
|
|
5
|
-
|
|
11
|
+
run: Run;
|
|
12
|
+
};
|
|
13
|
+
export type Finding = {
|
|
14
|
+
file?: string;
|
|
15
|
+
line?: number;
|
|
16
|
+
problem: string;
|
|
17
|
+
};
|
|
18
|
+
export type CheckProblem = Finding & {
|
|
19
|
+
rule: string;
|
|
20
|
+
};
|
|
6
21
|
export interface CheckRule {
|
|
7
22
|
name: string;
|
|
8
|
-
run(input: CheckInput): Promise<
|
|
23
|
+
run(input: CheckInput): Promise<Finding[] | {
|
|
24
|
+
skipped: string;
|
|
25
|
+
}>;
|
|
9
26
|
}
|
|
10
|
-
export interface
|
|
11
|
-
|
|
12
|
-
|
|
27
|
+
export interface CheckReport {
|
|
28
|
+
id: string;
|
|
29
|
+
version: string;
|
|
30
|
+
conforms: boolean;
|
|
31
|
+
rules: string[];
|
|
32
|
+
problems: CheckProblem[];
|
|
33
|
+
skipped: Record<string, string>;
|
|
13
34
|
}
|
|
14
35
|
export declare const RULES: readonly CheckRule[];
|
|
15
|
-
export declare const check: (
|
|
16
|
-
export declare const assertConforms: (
|
|
36
|
+
export declare const check: (at: string, options?: CheckOptions) => Promise<CheckReport>;
|
|
37
|
+
export declare const assertConforms: (dir: string, options?: CheckOptions) => Promise<void>;
|
package/dist/tool/check.js
CHANGED
|
@@ -1,30 +1,122 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, normalize, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import semver from 'semver';
|
|
1
5
|
import { checkCli } from '../command/check-cli.js';
|
|
2
|
-
import {} from '../
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
import { messageOf } from '../log/log.js';
|
|
7
|
+
import { PackageSchema } from '../manifest/manifest.schema.js';
|
|
8
|
+
import { entryOf, normalizeParts } from '../manifest/parts.js';
|
|
9
|
+
import {} from '../manifest/primitives.schema.js';
|
|
10
|
+
import { formatIssues, readJsonIfExists } from '../manifest/read-manifest.js';
|
|
11
|
+
import { runCommand } from './exec.js';
|
|
12
|
+
import { problemLines } from './format.js';
|
|
13
|
+
import { sealing } from './rules/sealing.js';
|
|
14
|
+
import { store } from './rules/store.js';
|
|
15
|
+
import { surface } from './rules/surface.js';
|
|
16
|
+
import { KIT_VERSION } from './kit-version.js';
|
|
17
|
+
import { assertBuilt, exists } from './stage.js';
|
|
18
|
+
const RECIPE = /^(?:dockerfile|containerfile)(?:\..+)?$|^(?:docker-)?compose(?:\.[\w-]+)?\.ya?ml$/i;
|
|
19
|
+
const NO_PACKAGE = 'is missing: run it in an extension package folder';
|
|
20
|
+
const manifest = {
|
|
21
|
+
name: 'manifest',
|
|
22
|
+
run: async ({ dir }) => {
|
|
23
|
+
const value = await readJsonIfExists(join(dir, 'package.json'));
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
return [{ file: 'package.json', problem: NO_PACKAGE }];
|
|
26
|
+
const issues = PackageSchema.safeParse(value).error?.issues ?? [];
|
|
27
|
+
const recipes = (await readdir(dir)).filter((file) => RECIPE.test(file)).sort();
|
|
28
|
+
const recipe = 'is not allowed: the kit writes every image recipe';
|
|
29
|
+
return [
|
|
30
|
+
...issues.map((issue) => ({ problem: formatIssues([issue]) })),
|
|
31
|
+
...recipes.map((file) => ({ file, problem: recipe })),
|
|
32
|
+
];
|
|
33
|
+
},
|
|
34
|
+
};
|
|
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');
|
|
41
|
+
if (typeof mod.create !== 'function')
|
|
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}`;
|
|
47
|
+
};
|
|
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);
|
|
54
|
+
};
|
|
55
|
+
// Every part built where its image looks, exporting create and nothing else; cli handlers = commands.
|
|
7
56
|
const entries = {
|
|
8
57
|
name: 'entries',
|
|
9
|
-
run: async (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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)' }];
|
|
61
|
+
const parts = Object.keys(normalizeParts(input.pkg.agentage.parts).code);
|
|
62
|
+
const found = parts.map((part) => partProblem(input, part).catch(messageOf));
|
|
63
|
+
return (await Promise.all(found)).flatMap((problem) => (problem ? [{ problem }] : []));
|
|
13
64
|
},
|
|
14
65
|
};
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
66
|
+
const MAJORS = { express: '^5', zod: '^4', '@modelcontextprotocol/server': '^2' };
|
|
67
|
+
const KIT = '@agentage/extension';
|
|
68
|
+
const specProblem = (name, spec, needed) => {
|
|
69
|
+
if (!spec.trim() || !semver.validRange(spec))
|
|
70
|
+
return [`${name} ${spec || '""'} needs a version range`];
|
|
71
|
+
return semver.subset(spec, needed) ? [] : [`${name} ${spec}, needs ${needed}`];
|
|
72
|
+
};
|
|
73
|
+
const stack = {
|
|
74
|
+
name: 'stack',
|
|
75
|
+
run: async ({ pkg }) => {
|
|
76
|
+
const deps = pkg.dependencies ?? {};
|
|
77
|
+
const range = pkg.engines.agentage;
|
|
78
|
+
const found = Object.entries({ ...MAJORS, [KIT]: range }).flatMap(([name, needed]) => {
|
|
79
|
+
const spec = deps[name];
|
|
80
|
+
// This repo's CI installs the packed kit into a probe clone as a file: tarball.
|
|
81
|
+
if (spec === undefined || (name === KIT && spec.startsWith('file:')))
|
|
82
|
+
return [];
|
|
83
|
+
return specProblem(name, spec, needed);
|
|
84
|
+
});
|
|
85
|
+
if ('@modelcontextprotocol/sdk' in deps)
|
|
86
|
+
found.push('@modelcontextprotocol/sdk is MCP SDK 1: use @modelcontextprotocol/server ^2');
|
|
87
|
+
if (!semver.satisfies(KIT_VERSION, range))
|
|
88
|
+
found.push(`kit ${KIT_VERSION} is outside engines.agentage ${range}`);
|
|
89
|
+
return found.map((problem) => ({ problem }));
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
// The closed list of extension-kit section 7, in its order; build runs it before any image.
|
|
93
|
+
export const RULES = [manifest, entries, surface, stack, store, sealing];
|
|
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 = {}) => {
|
|
97
|
+
const { rules = RULES, run = runCommand } = options;
|
|
98
|
+
const dir = resolve(at);
|
|
99
|
+
const read = readJsonIfExists(join(dir, 'package.json')).catch(() => ({}));
|
|
100
|
+
const pkg = ((await read) ?? {});
|
|
101
|
+
const problems = [];
|
|
102
|
+
const skipped = {};
|
|
103
|
+
for (const rule of rules) {
|
|
104
|
+
const result = await runRule(rule, { ...options, dir, pkg, run });
|
|
105
|
+
if ('skipped' in result)
|
|
106
|
+
skipped[rule.name] = result.skipped;
|
|
107
|
+
else
|
|
108
|
+
problems.push(...result.map((found) => ({ rule: rule.name, ...found })));
|
|
109
|
+
if (rule.name === 'manifest' && problems.length > 0)
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
const names = rules.map((rule) => rule.name);
|
|
113
|
+
const conforms = problems.length === 0;
|
|
114
|
+
return { id: pkg.agentage?.id, version: pkg.version, conforms, rules: names, problems, skipped };
|
|
115
|
+
};
|
|
116
|
+
// What build runs after the compile and before any image: any problem stops everything.
|
|
117
|
+
export const assertConforms = async (dir, options = {}) => {
|
|
118
|
+
const report = await check(dir, { ...options, requireDocker: true });
|
|
119
|
+
const lines = problemLines(report).map((line) => `check failed: ${line}`);
|
|
120
|
+
if (!report.conforms)
|
|
121
|
+
throw new Error(lines.join('\n'));
|
|
30
122
|
};
|
package/dist/tool/format.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
import { type BuildResult } from './build.js';
|
|
2
|
+
import { type CheckReport } from './check.js';
|
|
2
3
|
export declare const buildLines: (result: BuildResult, cwd?: string) => string[];
|
|
4
|
+
export declare const problemLines: (report: CheckReport) => string[];
|
|
5
|
+
export declare const checkLines: (report: CheckReport) => string[];
|
package/dist/tool/format.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { relative } from 'node:path';
|
|
2
2
|
import {} from './build.js';
|
|
3
|
+
import {} from './check.js';
|
|
4
|
+
import { plural } from './up/lines.js';
|
|
3
5
|
const shortDigest = (digest) => digest.slice(0, 'sha256:'.length + 8);
|
|
4
6
|
// One line per image, the record path last: what `agentage extension build` prints.
|
|
5
7
|
export const buildLines = (result, cwd = process.cwd()) => {
|
|
@@ -12,3 +14,17 @@ export const buildLines = (result, cwd = process.cwd()) => {
|
|
|
12
14
|
lines.push(`${'record'.padEnd(8)}${relative(cwd, result.recordFile)}`);
|
|
13
15
|
return lines;
|
|
14
16
|
};
|
|
17
|
+
const where = ({ file, line }) => file ? `${file}${line ? `:${line}` : ''} ` : '';
|
|
18
|
+
export const problemLines = (report) => report.problems.map((found) => `${found.rule}: ${where(found)}${found.problem}`);
|
|
19
|
+
// What `agentage extension check` prints (sheet C2): the rules line and PASS, or each problem and FAIL.
|
|
20
|
+
export const checkLines = (report) => {
|
|
21
|
+
const { rules, skipped, problems } = report;
|
|
22
|
+
if (report.conforms) {
|
|
23
|
+
const states = rules.map((name) => `${name} ${skipped[name] ? `skipped: ${skipped[name]}` : 'ok'}`);
|
|
24
|
+
const pass = `PASS ${report.id} ${report.version} conforms (${plural(rules.length, 'rule')})`;
|
|
25
|
+
return [states.join(' · '), pass];
|
|
26
|
+
}
|
|
27
|
+
const failed = new Set(problems.map((found) => found.rule)).size;
|
|
28
|
+
const fail = `FAIL ${plural(problems.length, 'problem')} in ${plural(failed, 'rule')}`;
|
|
29
|
+
return [...problemLines(report), fail];
|
|
30
|
+
};
|
package/dist/tool/index.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ export { runCommand, type RunCommandOptions } from '../command/run-command.js';
|
|
|
3
3
|
export { type ExtensionPackage, type CommandSpec } from '../manifest/manifest.schema.js';
|
|
4
4
|
export { readManifest, validateManifest } from '../manifest/read-manifest.js';
|
|
5
5
|
export { build, type BuildDeps, type BuildOptions, type BuildResult, type BuiltImage, } from './build.js';
|
|
6
|
-
export {
|
|
6
|
+
export { check, type CheckProblem, type CheckReport } from './check.js';
|
|
7
|
+
export { buildLines, checkLines } from './format.js';
|
|
7
8
|
export { KIT_VERSION } from './kit-version.js';
|
|
8
9
|
export { down, type DownOptions, type DownResult } from './up/down.js';
|
|
9
10
|
export { token, type TokenOptions } from './up/token.js';
|
package/dist/tool/index.js
CHANGED
|
@@ -3,7 +3,8 @@ export { runCommand } from '../command/run-command.js';
|
|
|
3
3
|
export {} from '../manifest/manifest.schema.js';
|
|
4
4
|
export { readManifest, validateManifest } from '../manifest/read-manifest.js';
|
|
5
5
|
export { build, } from './build.js';
|
|
6
|
-
export {
|
|
6
|
+
export { check } from './check.js';
|
|
7
|
+
export { buildLines, checkLines } from './format.js';
|
|
7
8
|
export { KIT_VERSION } from './kit-version.js';
|
|
8
9
|
export { down } from './up/down.js';
|
|
9
10
|
export { token } from './up/token.js';
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { type RecordExtras } from '../discovery/discovery.js';
|
|
2
2
|
import { type ExtensionPackage } from '../manifest/manifest.schema.js';
|
|
3
|
+
import { type McpContext } from '../mcp/tools.js';
|
|
4
|
+
export declare const recordingContext: (pkg: ExtensionPackage, part: "api" | "mcp") => import("../context/context.js").PartContext;
|
|
5
|
+
export declare const registeredTools: (create: (ctx: McpContext) => unknown, pkg: ExtensionPackage) => Promise<import("../mcp/tools.js").ToolEntry[]>;
|
|
3
6
|
export declare const toolsOf: (dir: string, pkg: ExtensionPackage) => Promise<{
|
|
4
7
|
name: string;
|
|
5
8
|
scope: string;
|
|
@@ -16,24 +16,28 @@ import {} from '../store/store.types.js';
|
|
|
16
16
|
const WidgetsSchema = z.array(WidgetSchema).min(1);
|
|
17
17
|
const noStore = {
|
|
18
18
|
query: async () => {
|
|
19
|
-
throw new Error('the store is not open while the
|
|
19
|
+
throw new Error('the store is not open while the tool reads a part');
|
|
20
20
|
},
|
|
21
21
|
};
|
|
22
|
-
const silent = createLog({ extension: 'build', part: '
|
|
23
|
-
// The
|
|
24
|
-
export const
|
|
22
|
+
const silent = createLog({ extension: 'build', part: 'check' }, () => undefined);
|
|
23
|
+
// The ctx part code gets when the tool runs its create(ctx) to read what it registers or routes.
|
|
24
|
+
export const recordingContext = (pkg, part) => createContext({
|
|
25
|
+
pkg,
|
|
26
|
+
part,
|
|
27
|
+
origin: 'http://127.0.0.1',
|
|
28
|
+
commit: '0'.repeat(40),
|
|
29
|
+
log: silent,
|
|
30
|
+
store: noStore,
|
|
31
|
+
});
|
|
32
|
+
export const registeredTools = async (create, pkg) => {
|
|
25
33
|
const registry = createToolRegistry();
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
log: silent,
|
|
32
|
-
store: noStore,
|
|
33
|
-
});
|
|
34
|
-
await (await loadCreate(dir, pkg, 'mcp'))({ ...base, tool: registry.tool });
|
|
34
|
+
await create({ ...recordingContext(pkg, 'mcp'), tool: registry.tool });
|
|
35
|
+
return registry.tools;
|
|
36
|
+
};
|
|
37
|
+
export const toolsOf = async (dir, pkg) => {
|
|
38
|
+
const tools = await registeredTools(await loadCreate(dir, pkg, 'mcp'), pkg);
|
|
35
39
|
const { id } = pkg.agentage;
|
|
36
|
-
return
|
|
40
|
+
return tools.map((tool) => ({
|
|
37
41
|
name: prefixed(id, tool.name),
|
|
38
42
|
scope: scopeOf(id, tool.spec.scope),
|
|
39
43
|
}));
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { lstat, readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { readJsonIfExists } from '../../manifest/read-manifest.js';
|
|
4
|
+
import {} from '../check.js';
|
|
5
|
+
const CODE = /\.[cm]?[jt]sx?$/;
|
|
6
|
+
const TEST = /\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
7
|
+
// Strings first, so a // inside one is no comment; a comment keeps its newlines for the line count.
|
|
8
|
+
const TOKENS = /'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
|
|
9
|
+
const QUOTED = /'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"/g;
|
|
10
|
+
const QUOTE = '[\'"`]';
|
|
11
|
+
const GLOBAL = String.raw `\b(?:globalThis|global|self)\s*`;
|
|
12
|
+
// A value: read, passed, assigned or listed; not a key, a parameter or a name being declared.
|
|
13
|
+
const VALUE = String.raw `(?=[ \t]*(?:[.[),;\]}]|\?\.|\n|$))(?![ \t]*\}?\s*\)\s*(?:=>|\{|:)|\s*\}\s*[:=](?![=>]))`;
|
|
14
|
+
const PROCESS = String.raw `(?:${GLOBAL}\??\.\s*|(?<![.\w$:/-]|${QUOTE}))process\b${VALUE}`;
|
|
15
|
+
// extension-model section 9: ctx gives a part all it may read, so part code never uses process.
|
|
16
|
+
const ON_CODE = [
|
|
17
|
+
[
|
|
18
|
+
new RegExp(String.raw `${PROCESS}(?:\s*(?:\?\.|\.|\[\s*${QUOTE})\s*(env|argv)\b)?`, 'g'),
|
|
19
|
+
(m) => (m[1] ? `reads process.${m[1]}` : 'names process'),
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
new RegExp(String.raw `${GLOBAL}(?:\?\.)?\s*\[\s*${QUOTE}process${QUOTE}`, 'g'),
|
|
23
|
+
() => 'names process',
|
|
24
|
+
],
|
|
25
|
+
[/\blisten\s*(?:\?\.\s*)?\(|\[\s*['"`]listen['"`]\s*\]/g, () => 'calls listen()'],
|
|
26
|
+
];
|
|
27
|
+
const ON_STRINGS = [
|
|
28
|
+
[/(?:from|import|require)\s*\(?\s*['"`](?:node:)?process['"`]/g, () => 'imports process'],
|
|
29
|
+
[/(?:https?:)?\/\/[^'"`\s]*agentage\.io/gi, () => 'names an absolute agentage URL'],
|
|
30
|
+
];
|
|
31
|
+
const matches = (file, text, patterns) => patterns.flatMap(([pattern, problem]) => [...text.matchAll(pattern)].map((m) => {
|
|
32
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
33
|
+
return { file, line, problem: problem(m) };
|
|
34
|
+
}));
|
|
35
|
+
// Prose strings (with a space) are blanked; a name such as 'env' stays, as dynamic access spells it.
|
|
36
|
+
const fileFindings = (file, text) => {
|
|
37
|
+
const kept = text.replace(TOKENS, (t) => (t.startsWith('/') ? t.replace(/[^\n]/g, '') : t));
|
|
38
|
+
const code = kept.replace(QUOTED, (s) => (/\s/.test(s) ? "''" : s));
|
|
39
|
+
return [...matches(file, code, ON_CODE), ...matches(file, kept, ON_STRINGS)];
|
|
40
|
+
};
|
|
41
|
+
const LINKED = 'is a symlink: part code is plain files';
|
|
42
|
+
// Every non-test source under src/; any symlink met is refused, since it could point anywhere.
|
|
43
|
+
const sourceFindings = async (dir) => {
|
|
44
|
+
const src = join(dir, 'src');
|
|
45
|
+
const top = await lstat(src).catch(() => null);
|
|
46
|
+
if (top?.isSymbolicLink())
|
|
47
|
+
return [{ file: 'src', problem: LINKED }];
|
|
48
|
+
const found = top ? await readdir(src, { recursive: true, withFileTypes: true }) : [];
|
|
49
|
+
const scanned = found.map(async (entry) => {
|
|
50
|
+
const file = relative(dir, join(entry.parentPath, entry.name));
|
|
51
|
+
if (entry.isSymbolicLink())
|
|
52
|
+
return [{ file, problem: LINKED }];
|
|
53
|
+
if (!entry.isFile() || !CODE.test(entry.name) || TEST.test(entry.name))
|
|
54
|
+
return [];
|
|
55
|
+
return fileFindings(file, await readFile(join(dir, file), 'utf8'));
|
|
56
|
+
});
|
|
57
|
+
return (await Promise.all(scanned)).flat();
|
|
58
|
+
};
|
|
59
|
+
const extensionDeps = async (dir, pkg) => {
|
|
60
|
+
const { dependencies, optionalDependencies, peerDependencies } = pkg;
|
|
61
|
+
const names = Object.keys({ ...dependencies, ...optionalDependencies, ...peerDependencies });
|
|
62
|
+
const read = (name) => readJsonIfExists(join(dir, 'node_modules', name, 'package.json'));
|
|
63
|
+
const manifests = await Promise.all(names.map(read));
|
|
64
|
+
return names
|
|
65
|
+
.filter((_, i) => Object.hasOwn(Object(manifests[i]), 'agentage'))
|
|
66
|
+
.map((name) => ({ problem: `depends on another extension, ${name}` }));
|
|
67
|
+
};
|
|
68
|
+
export const sealing = {
|
|
69
|
+
name: 'sealing',
|
|
70
|
+
run: async ({ dir, pkg }) => [...(await sourceFindings(dir)), ...(await extensionDeps(dir, pkg))],
|
|
71
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { hasPostgres } from '../../manifest/parts.js';
|
|
4
|
+
import { sqlFiles } from '../../store/migrations.js';
|
|
5
|
+
import {} from '../check.js';
|
|
6
|
+
import {} from '../exec.js';
|
|
7
|
+
import { TIMEOUTS } from '../timeouts.js';
|
|
8
|
+
import { STORE_IMAGE } from '../up/compose.js';
|
|
9
|
+
const PSQL = 'psql -v ON_ERROR_STOP=1 -1 -q -U postgres -f "/tmp/m/$f" || exit 1';
|
|
10
|
+
const AS_POSTGRES = `initdb -D /tmp/pg -U postgres -A trust >/dev/null && pg_ctl -D /tmp/pg -w -l /tmp/pg.log start >/dev/null && for f in "$@"; do ${PSQL}; done`;
|
|
11
|
+
// Root copies the files where postgres can read them (a 0600 checkout); the names, held to NNN-name.sql, are its arguments.
|
|
12
|
+
const SCRIPT = `cp -r /migrations /tmp/m && chmod -R a+rX /tmp/m && exec su postgres -s /bin/sh -c '${AS_POSTGRES}' sh "$@"`;
|
|
13
|
+
// Attached with --rm and --init: the container goes with this process, Ctrl-C included.
|
|
14
|
+
const runArgs = (dir, files) => [
|
|
15
|
+
...['run', '--rm', '--init', '-e', 'TINI_KILL_PROCESS_GROUP=1', '--network', 'none'],
|
|
16
|
+
...['-v', `${resolve(dir, 'migrations')}:/migrations:ro`, '--entrypoint', 'sh'],
|
|
17
|
+
...[STORE_IMAGE, '-c', SCRIPT, 'sh', ...files],
|
|
18
|
+
];
|
|
19
|
+
const failure = (stderr = '') => {
|
|
20
|
+
const daemon = /^docker: (.*)$/m.exec(stderr)?.[1];
|
|
21
|
+
if (daemon)
|
|
22
|
+
return { problem: `${STORE_IMAGE} does not run: ${daemon}` };
|
|
23
|
+
const [, file, line, why] = /^psql:\/tmp\/m\/([^:]+):(\d+): (?:ERROR:\s+)?(.*)$/m.exec(stderr) ?? [];
|
|
24
|
+
const problem = `fails on ${STORE_IMAGE}: ${why ? `ERROR: ${why}` : stderr.trim().split('\n').pop()}`;
|
|
25
|
+
return file ? { file: `migrations/${file}`, line: Number(line), problem } : { problem };
|
|
26
|
+
};
|
|
27
|
+
// Quoted text, dollar-quoted bodies and comments, blanked to their newlines before looking for a backslash.
|
|
28
|
+
const SQL_TEXT = /'(?:[^']|'')*'|"(?:[^"]|"")*"|\$(\w*)\$[\s\S]*?\$\1\$|--[^\n]*|\/\*[\s\S]*?\*\//g;
|
|
29
|
+
// The migrate job sends each file through pg as plain SQL, so a psql meta-command would fail there.
|
|
30
|
+
const metaCommands = async (dir, files) => {
|
|
31
|
+
const texts = await Promise.all(files.map((f) => readFile(join(dir, 'migrations', f), 'utf8')));
|
|
32
|
+
return files.flatMap((f, i) => {
|
|
33
|
+
const code = (texts[i] ?? '').replace(SQL_TEXT, (t) => t.replace(/[^\n]/g, ''));
|
|
34
|
+
const at = code.indexOf('\\');
|
|
35
|
+
const problem = 'has a psql meta-command: the migrate job sends plain SQL';
|
|
36
|
+
const line = code.slice(0, at).split('\n').length;
|
|
37
|
+
return at < 0 ? [] : [{ file: `migrations/${f}`, line, problem }];
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
const dockerAnswers = (run) => run('docker', ['version'], { quiet: true }).then(({ code }) => code === 0, () => false);
|
|
41
|
+
export const store = {
|
|
42
|
+
name: 'store',
|
|
43
|
+
run: async ({ dir, pkg, run, requireDocker }) => {
|
|
44
|
+
if (!hasPostgres(pkg))
|
|
45
|
+
return { skipped: 'no store' };
|
|
46
|
+
const files = await sqlFiles(join(dir, 'migrations'));
|
|
47
|
+
const meta = await metaCommands(dir, files);
|
|
48
|
+
if (files.length === 0 || meta.length > 0)
|
|
49
|
+
return meta;
|
|
50
|
+
if (!(await dockerAnswers(run)))
|
|
51
|
+
return requireDocker
|
|
52
|
+
? [{ problem: 'docker does not answer: build needs it' }]
|
|
53
|
+
: { skipped: 'no docker' };
|
|
54
|
+
const result = await run('docker', runArgs(dir, files), {
|
|
55
|
+
quiet: true,
|
|
56
|
+
timeoutMs: TIMEOUTS.start,
|
|
57
|
+
});
|
|
58
|
+
return result.code === 0 ? [] : [failure(result.stderr)];
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import {} from '../../context/context.js';
|
|
3
|
+
import { OPENAPI_FILE, readOpenapi } from '../../discovery/openapi.js';
|
|
4
|
+
import { loadCreate } from '../../manifest/entry.js';
|
|
5
|
+
import { normalizeParts } from '../../manifest/parts.js';
|
|
6
|
+
import { readJsonIfExists } from '../../manifest/read-manifest.js';
|
|
7
|
+
import { canonical } from '../../mcp/snapshot.js';
|
|
8
|
+
import { prefixed } from '../../mcp/tools.js';
|
|
9
|
+
import {} from '../check.js';
|
|
10
|
+
import { recordingContext, registeredTools } from '../record-extras.js';
|
|
11
|
+
const differing = (generated, committed) => [...new Set([...Object.keys(generated), ...Object.keys(committed)])].filter((key) => JSON.stringify(canonical(generated[key])) !== JSON.stringify(canonical(committed[key])));
|
|
12
|
+
const routeKeys = ({ path, stack }) => [path].flat().flatMap((one) => {
|
|
13
|
+
const at = `/api/v1${String(one).replace(/:(\w+)/g, '{$1}')}`;
|
|
14
|
+
// router.all names no method, so it has no operation to compare.
|
|
15
|
+
return stack.flatMap(({ method }) => (method ? [`${method.toUpperCase()} ${at}`] : []));
|
|
16
|
+
});
|
|
17
|
+
// Express routes (of a Router or an express() app) as OpenAPI keys; a sub-router hides its routes, so it never matches one.
|
|
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
|
+
};
|
|
26
|
+
const METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
|
|
27
|
+
const operations = (doc) => Object.entries((doc['paths'] ?? {})).flatMap(([path, ops]) => Object.entries(ops ?? {})
|
|
28
|
+
.filter(([method]) => METHODS.has(method))
|
|
29
|
+
.map(([method, op]) => [`${method.toUpperCase()} ${path}`, op]));
|
|
30
|
+
const declaresAccess = (security) => Array.isArray(security) &&
|
|
31
|
+
(security.length === 0 ||
|
|
32
|
+
security.some((need) => Object.values(need ?? {}).some((s) => s.length > 0)));
|
|
33
|
+
const apiProblems = (doc, routes, version) => {
|
|
34
|
+
const ops = operations(doc);
|
|
35
|
+
const served = new Set([`info.version ${version}`, ...routes]);
|
|
36
|
+
const info = doc['info']?.['version'];
|
|
37
|
+
const documented = new Set([`info.version ${String(info)}`, ...ops.map(([key]) => key)]);
|
|
38
|
+
const diff = [...served, ...documented].filter((k) => !served.has(k) || !documented.has(k));
|
|
39
|
+
const open = ops.filter(([, op]) => !declaresAccess(op['security'] ?? doc['security']));
|
|
40
|
+
return [
|
|
41
|
+
...(diff.length > 0 ? [`differs from the generated contract (${diff.join(', ')})`] : []),
|
|
42
|
+
...open.map(([key]) => `${key} declares neither a scope nor security: []`),
|
|
43
|
+
];
|
|
44
|
+
};
|
|
45
|
+
const apiSurface = async ({ dir, pkg }) => {
|
|
46
|
+
const create = await loadCreate(dir, pkg, 'api').catch(() => null);
|
|
47
|
+
if (!create)
|
|
48
|
+
return [];
|
|
49
|
+
const doc = await readOpenapi(dir);
|
|
50
|
+
const found = doc
|
|
51
|
+
? apiProblems(doc, servedRoutes(await create(recordingContext(pkg, 'api'))), pkg.version)
|
|
52
|
+
: ['is missing: the api part commits its contract'];
|
|
53
|
+
return found.map((problem) => ({ file: OPENAPI_FILE, problem }));
|
|
54
|
+
};
|
|
55
|
+
const schemaOf = (zod, io) => zod['~standard'].jsonSchema[io]({ target: 'draft-2020-12' });
|
|
56
|
+
const listed = (id, { name, spec }) => ({
|
|
57
|
+
name: prefixed(id, name),
|
|
58
|
+
description: spec.description,
|
|
59
|
+
inputSchema: schemaOf(spec.input, 'input'),
|
|
60
|
+
outputSchema: schemaOf(spec.output, 'output'),
|
|
61
|
+
});
|
|
62
|
+
const byName = ({ serverInfo, tools }) => ({
|
|
63
|
+
serverInfo,
|
|
64
|
+
...Object.fromEntries(tools.map((tool) => [String(tool['name']), tool])),
|
|
65
|
+
});
|
|
66
|
+
const mcpSurface = async ({ dir, pkg }) => {
|
|
67
|
+
const create = await loadCreate(dir, pkg, 'mcp').catch(() => null);
|
|
68
|
+
if (!create)
|
|
69
|
+
return [];
|
|
70
|
+
const { id } = pkg.agentage;
|
|
71
|
+
const file = `${id}.mcpc.json`;
|
|
72
|
+
const committed = (await readJsonIfExists(join(dir, file)));
|
|
73
|
+
if (!committed)
|
|
74
|
+
return [{ file, problem: 'is missing: the mcp part commits its tool list' }];
|
|
75
|
+
const tools = (await registeredTools(create, pkg)).map((tool) => listed(id, tool));
|
|
76
|
+
const serverInfo = { name: `agentage-${id}`, version: pkg.version };
|
|
77
|
+
const diff = differing(byName({ serverInfo, tools }), byName(committed));
|
|
78
|
+
const problem = `differs from the generated tool list (${diff.join(', ')})`;
|
|
79
|
+
return diff.length > 0 ? [{ file, problem }] : [];
|
|
80
|
+
};
|
|
81
|
+
// The committed contracts equal what the code serves; entries names a part that does not load.
|
|
82
|
+
export const surface = {
|
|
83
|
+
name: 'surface',
|
|
84
|
+
run: async (input) => {
|
|
85
|
+
const { code } = normalizeParts(input.pkg.agentage.parts);
|
|
86
|
+
const api = code.api ? await apiSurface(input) : [];
|
|
87
|
+
return [...api, ...(code.mcp ? await mcpSurface(input) : [])];
|
|
88
|
+
},
|
|
89
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentage/extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"build": "tsc -b",
|
|
45
45
|
"schemas": "node dist/schemas/write-schemas.js schemas",
|
|
46
46
|
"verify": "npm run verify --prefix ../..",
|
|
47
|
-
"prepublishOnly": "npm run verify"
|
|
47
|
+
"prepublishOnly": "npm run verify",
|
|
48
|
+
"check:fixtures": "vitest run --root ../.. --reporter=tree packages/extension/src/tool/check.fixtures.test.ts"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
51
|
"@modelcontextprotocol/server": "^2.4.0",
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"helmet": "^8.1.0",
|
|
53
54
|
"jose": "^6.2.15",
|
|
54
55
|
"pg": "^8.23.1",
|
|
56
|
+
"semver": "^7.8.6",
|
|
55
57
|
"zod": "^4.2.0"
|
|
56
58
|
},
|
|
57
59
|
"peerDependencies": {
|