@agentage/extension 0.2.0 → 0.3.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/dist/agcid/scope.d.ts +0 -1
- package/dist/agcid/scope.js +0 -1
- package/dist/auth/part-auth.d.ts +18 -0
- package/dist/auth/part-auth.js +61 -0
- package/dist/auth/revoked.d.ts +9 -0
- package/dist/auth/revoked.js +34 -0
- package/dist/auth/route-access.d.ts +2 -0
- package/dist/auth/route-access.js +21 -0
- package/dist/auth/token-check.d.ts +13 -0
- package/dist/auth/token-check.js +26 -0
- package/dist/auth/verify-token.d.ts +18 -0
- package/dist/auth/verify-token.js +68 -0
- package/dist/command/check-cli.js +2 -7
- package/dist/command/run-command.d.ts +1 -0
- package/dist/command/run-command.js +5 -15
- package/dist/context/context.d.ts +4 -0
- package/dist/context/context.js +3 -1
- package/dist/context/test-context.js +23 -17
- package/dist/discovery/discovery.d.ts +8 -2
- package/dist/discovery/discovery.schema.d.ts +14 -2
- package/dist/discovery/discovery.schema.js +7 -3
- package/dist/envelope/envelope.d.ts +2 -0
- package/dist/envelope/envelope.js +1 -0
- package/dist/manifest/entry.d.ts +5 -0
- package/dist/manifest/entry.js +29 -0
- package/dist/manifest/parts.d.ts +1 -0
- package/dist/manifest/parts.js +1 -0
- package/dist/mcp/mcp-app.js +31 -7
- package/dist/mcp/server.js +20 -6
- package/dist/mcp/tools.d.ts +2 -0
- package/dist/mcp/tools.js +4 -0
- package/dist/runtime/part-runners.d.ts +6 -5
- package/dist/runtime/part-runners.js +20 -27
- package/dist/runtime/run-part.d.ts +2 -2
- package/dist/runtime/run-part.js +55 -28
- package/dist/server/app.d.ts +3 -0
- package/dist/server/app.js +15 -1
- package/dist/server/errors.js +2 -0
- package/dist/store/migrate.js +7 -3
- package/dist/store/migrations.d.ts +2 -0
- package/dist/store/migrations.js +32 -6
- package/dist/store/store.d.ts +7 -1
- package/dist/store/store.js +37 -7
- package/dist/tool/build.d.ts +0 -2
- package/dist/tool/build.js +5 -3
- package/dist/tool/check.d.ts +16 -0
- package/dist/tool/check.js +30 -0
- package/dist/tool/docker.js +3 -2
- package/dist/tool/exec.d.ts +1 -0
- package/dist/tool/exec.js +43 -16
- package/dist/tool/main.js +23 -24
- package/dist/tool/record-extras.d.ts +4 -1
- package/dist/tool/record-extras.js +16 -15
- package/dist/tool/stage.d.ts +1 -0
- package/dist/tool/stage.js +8 -10
- package/dist/tool/timeouts.d.ts +10 -0
- package/dist/tool/timeouts.js +19 -0
- package/dist/tool/up/compose-run.d.ts +2 -1
- package/dist/tool/up/compose-run.js +3 -2
- package/dist/tool/up/compose.d.ts +2 -1
- package/dist/tool/up/compose.js +32 -23
- package/dist/tool/up/dev-issuer-app.d.ts +9 -3
- package/dist/tool/up/dev-issuer-app.js +53 -24
- package/dist/tool/up/dev-issuer.js +2 -1
- package/dist/tool/up/down.d.ts +1 -0
- package/dist/tool/up/down.js +39 -30
- package/dist/tool/up/gateway.d.ts +9 -0
- package/dist/tool/up/gateway.js +11 -17
- package/dist/tool/up/lines.js +1 -1
- package/dist/tool/up/local-record.d.ts +4 -1
- package/dist/tool/up/ports.d.ts +2 -0
- package/dist/tool/up/ports.js +22 -0
- package/dist/tool/up/project.d.ts +4 -0
- package/dist/tool/up/project.js +20 -0
- package/dist/tool/up/store-line.d.ts +11 -0
- package/dist/tool/up/store-line.js +36 -0
- package/dist/tool/up/token.js +9 -2
- package/dist/tool/up/up.d.ts +3 -6
- package/dist/tool/up/up.js +49 -46
- package/dist/tool/up/wait.js +4 -2
- package/dist/ui/ui-app.d.ts +2 -0
- package/dist/ui/ui-app.js +18 -5
- package/dist/worker/runs.js +11 -11
- package/dist/worker/scheduler.js +14 -5
- package/package.json +2 -1
- package/schemas/extension-discovery.schema.json +17 -3
- package/schemas/extension-record.schema.json +17 -3
package/dist/agcid/scope.d.ts
CHANGED
package/dist/agcid/scope.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {} from 'express';
|
|
2
2
|
import { AGCID_HEADER, agcidOf } from './agcid.js';
|
|
3
3
|
import { requestScope } from './current.js';
|
|
4
|
-
export { currentAgcid } from './current.js';
|
|
5
4
|
// Keeps a valid incoming agcid, else mints one; echoed on every response and tags every log line.
|
|
6
5
|
export const agcidMiddleware = (req, res, next) => {
|
|
7
6
|
const agcid = agcidOf(req.get(AGCID_HEADER));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Log } from '../log/log.js';
|
|
2
|
+
import { type Env } from '../runtime/build-info.js';
|
|
3
|
+
export declare const covers: (audience: string, host: string) => boolean;
|
|
4
|
+
export declare const partAuth: (env: Env, id: string, log: Log, scopes: string[]) => Promise<{
|
|
5
|
+
tokenCheck: import("./token-check.js").TokenCheck;
|
|
6
|
+
resource: {
|
|
7
|
+
resource: string;
|
|
8
|
+
authorization_servers: string[];
|
|
9
|
+
scopes_supported: string[];
|
|
10
|
+
bearer_methods_supported: string[];
|
|
11
|
+
};
|
|
12
|
+
checks: {
|
|
13
|
+
auth: import("../health/health.js").HealthCheck;
|
|
14
|
+
};
|
|
15
|
+
}>;
|
|
16
|
+
export declare const declaredScopes: (id: string, openapi: Record<string, unknown> | null, tools?: readonly {
|
|
17
|
+
scope: string;
|
|
18
|
+
}[]) => string[];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { OriginSchema } from '../discovery/discovery.schema.js';
|
|
3
|
+
import { messageOf } from '../log/log.js';
|
|
4
|
+
import { formatIssues } from '../manifest/read-manifest.js';
|
|
5
|
+
import {} from '../runtime/build-info.js';
|
|
6
|
+
import { createRevokedList, REVOKED_POLL_MS } from './revoked.js';
|
|
7
|
+
import { accessOfSecurity, scopeOf } from './token-check.js';
|
|
8
|
+
import { createTokenCheck, remoteKeys } from './verify-token.js';
|
|
9
|
+
// *.{a domain of two labels or more}, or one host, as a deployment's origin may name it.
|
|
10
|
+
const isAudience = (audience) => {
|
|
11
|
+
const host = audience.startsWith('*.') ? audience.slice(2) : audience;
|
|
12
|
+
return ['https', 'http'].some((scheme) => OriginSchema.safeParse(`${scheme}://${host}`).success);
|
|
13
|
+
};
|
|
14
|
+
const AuthEnvSchema = z.object({
|
|
15
|
+
AGENTAGE_ISSUER: z
|
|
16
|
+
.url()
|
|
17
|
+
.refine((url) => URL.canParse(url) && new URL(url).origin === url, 'an origin, no path'),
|
|
18
|
+
AGENTAGE_AUDIENCE: z.string().refine(isAudience, '*.{domain} or one host'),
|
|
19
|
+
AGENTAGE_ORIGIN: z.url(),
|
|
20
|
+
});
|
|
21
|
+
// *.agentage.io covers the domain and every host under it at any depth; else one exact host.
|
|
22
|
+
export const covers = (audience, host) => audience.startsWith('*.')
|
|
23
|
+
? host === audience.slice(2) || host.endsWith(audience.slice(1))
|
|
24
|
+
: host === audience;
|
|
25
|
+
const settingsOf = (env) => {
|
|
26
|
+
const parsed = AuthEnvSchema.safeParse(env);
|
|
27
|
+
if (!parsed.success)
|
|
28
|
+
throw new Error(`no token check, no start: ${formatIssues(parsed.error.issues)}`);
|
|
29
|
+
const { AGENTAGE_ISSUER: issuer, AGENTAGE_AUDIENCE: audience } = parsed.data;
|
|
30
|
+
const { origin, hostname } = new URL(parsed.data.AGENTAGE_ORIGIN);
|
|
31
|
+
if (!covers(audience, hostname))
|
|
32
|
+
throw new Error(`the host ${hostname} is not under the audience ${audience}`);
|
|
33
|
+
return { issuer, audience, origin };
|
|
34
|
+
};
|
|
35
|
+
// Keys and revoked list read once before the part serves; an issuer down at start is no stop.
|
|
36
|
+
export const partAuth = async (env, id, log, scopes) => {
|
|
37
|
+
const { issuer, audience, origin } = settingsOf(env);
|
|
38
|
+
const { keys, read } = remoteKeys(issuer);
|
|
39
|
+
const revoked = createRevokedList(issuer, log);
|
|
40
|
+
const unread = (err) => log.warn('key set not read', { reason: messageOf(err) });
|
|
41
|
+
await Promise.all([read().catch(unread), revoked.refresh()]);
|
|
42
|
+
setInterval(() => void revoked.refresh(), REVOKED_POLL_MS).unref();
|
|
43
|
+
return {
|
|
44
|
+
tokenCheck: createTokenCheck({ issuer, audience, id, origin, keys, revoked }),
|
|
45
|
+
resource: {
|
|
46
|
+
resource: origin,
|
|
47
|
+
authorization_servers: [issuer],
|
|
48
|
+
scopes_supported: scopes,
|
|
49
|
+
bearer_methods_supported: ['header'],
|
|
50
|
+
},
|
|
51
|
+
checks: { auth: revoked.check },
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
export const declaredScopes = (id, openapi, tools = []) => {
|
|
55
|
+
const routes = Object.values(openapi?.['paths'] ?? {})
|
|
56
|
+
.flatMap((item) => Object.values(item))
|
|
57
|
+
.filter((op) => typeof op === 'object' && !Array.isArray(op))
|
|
58
|
+
.map((op) => scopeOf(id, accessOfSecurity(op.security ?? openapi?.['security'])));
|
|
59
|
+
const scopes = new Set([...routes, ...tools.map((tool) => tool.scope)]);
|
|
60
|
+
return [...scopes].filter((scope) => scope !== 'public').sort();
|
|
61
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type HealthCheck } from '../health/health.js';
|
|
2
|
+
import { type Log } from '../log/log.js';
|
|
3
|
+
export declare const REVOKED_POLL_MS = 30000;
|
|
4
|
+
export declare const createRevokedList: (issuer: string, log: Log, now?: () => number) => {
|
|
5
|
+
has: (jti: string) => boolean;
|
|
6
|
+
loaded: () => boolean;
|
|
7
|
+
refresh: () => Promise<void>;
|
|
8
|
+
check: HealthCheck;
|
|
9
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {} from '../health/health.js';
|
|
3
|
+
import { messageOf } from '../log/log.js';
|
|
4
|
+
import { retryOnce } from './token-check.js';
|
|
5
|
+
export const REVOKED_POLL_MS = 30_000;
|
|
6
|
+
const STALE_MS = 5 * 60_000;
|
|
7
|
+
const RevokedSchema = z.object({
|
|
8
|
+
revoked: z.array(z.object({ jti: z.string(), exp: z.number() })),
|
|
9
|
+
generatedAt: z.string(),
|
|
10
|
+
});
|
|
11
|
+
const readList = async (url) => {
|
|
12
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
13
|
+
if (!res.ok)
|
|
14
|
+
throw new Error(`the issuer answered ${res.status}`);
|
|
15
|
+
return new Set(RevokedSchema.parse(await res.json()).revoked.map((entry) => entry.jti));
|
|
16
|
+
};
|
|
17
|
+
// The issuer's revoked ids, read every 30 s so a revoke holds within 60 s (api-standards 5).
|
|
18
|
+
export const createRevokedList = (issuer, log, now = Date.now) => {
|
|
19
|
+
let ids = new Set();
|
|
20
|
+
let readAt;
|
|
21
|
+
const refresh = async () => {
|
|
22
|
+
try {
|
|
23
|
+
ids = await retryOnce(() => readList(`${issuer}/.well-known/agentage-revoked`));
|
|
24
|
+
readAt = now();
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
log.warn('revoked list not read', { reason: messageOf(err) });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const loaded = () => readAt !== undefined;
|
|
31
|
+
const fresh = () => readAt !== undefined && now() - readAt <= STALE_MS;
|
|
32
|
+
const check = async () => (fresh() ? 'ok' : 'degraded');
|
|
33
|
+
return { has: (jti) => ids.has(jti), loaded, refresh, check };
|
|
34
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { accessOfSecurity, strongest } from './token-check.js';
|
|
2
|
+
const PARAM = /^\{.+\}$/;
|
|
3
|
+
// As Express routes: without case, and one trailing slash ignored.
|
|
4
|
+
const sameShape = (template, path) => {
|
|
5
|
+
const want = template.toLowerCase().split('/');
|
|
6
|
+
const got = path
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/(.)\/$/, '$1')
|
|
9
|
+
.split('/');
|
|
10
|
+
return want.length === got.length && want.every((seg, i) => PARAM.test(seg) || seg === got[i]);
|
|
11
|
+
};
|
|
12
|
+
// The strongest access any matching operation declares: Express may run another path's handler.
|
|
13
|
+
export const routeAccess = (doc, method, path) => {
|
|
14
|
+
const paths = (doc?.['paths'] ?? {});
|
|
15
|
+
const operations = Object.keys(paths)
|
|
16
|
+
.filter((template) => sameShape(template, path))
|
|
17
|
+
.flatMap((template) => paths[template]?.[method.toLowerCase()] ?? []);
|
|
18
|
+
if (operations.length === 0)
|
|
19
|
+
return 'read';
|
|
20
|
+
return strongest(operations.map((op) => accessOfSecurity(op.security ?? doc?.['security'])));
|
|
21
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type Access = 'public' | 'read' | 'write' | 'admin';
|
|
2
|
+
export interface Caller {
|
|
3
|
+
sub: string;
|
|
4
|
+
scopes: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
export type TokenCheck = (authorization: string | undefined, access: Access) => Promise<Caller | null>;
|
|
7
|
+
export declare const setCaller: (request: object, caller: Caller | null) => void;
|
|
8
|
+
export declare const callerOf: (request: object) => Caller | null;
|
|
9
|
+
export declare const RANK: Record<Access, number>;
|
|
10
|
+
export declare const scopeOf: (id: string, access?: Access) => string;
|
|
11
|
+
export declare const strongest: (needed: readonly Access[]) => Access;
|
|
12
|
+
export declare const accessOfSecurity: (security: unknown) => Access;
|
|
13
|
+
export declare const retryOnce: <T>(read: () => Promise<T>, retries?: (_err: unknown) => boolean) => Promise<T>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
const callers = new WeakMap();
|
|
3
|
+
export const setCaller = (request, caller) => void callers.set(request, caller);
|
|
4
|
+
export const callerOf = (request) => callers.get(request) ?? null;
|
|
5
|
+
export const RANK = { public: 0, read: 1, write: 2, admin: 3 };
|
|
6
|
+
export const scopeOf = (id, access = 'read') => access === 'public' ? 'public' : `${id}:${access}`;
|
|
7
|
+
export const strongest = (needed) => needed.reduce((top, access) => (RANK[access] > RANK[top] ? access : top), 'public');
|
|
8
|
+
const VERB = /:(read|write|admin)$/;
|
|
9
|
+
// OpenAPI security: [] is public, a scope names the verb; a route that declares nothing needs read.
|
|
10
|
+
export const accessOfSecurity = (security) => {
|
|
11
|
+
if (Array.isArray(security) && security.length === 0)
|
|
12
|
+
return 'public';
|
|
13
|
+
const scopes = (Array.isArray(security) ? security : [])
|
|
14
|
+
.flatMap((requirement) => Object.values(requirement ?? {}))
|
|
15
|
+
.flat()
|
|
16
|
+
.filter((scope) => typeof scope === 'string');
|
|
17
|
+
const verbs = scopes.map((scope) => VERB.exec(scope)?.[1] ?? 'read');
|
|
18
|
+
return verbs.length === 0 ? 'read' : strongest(verbs);
|
|
19
|
+
};
|
|
20
|
+
// security.md: a failed read of the issuer is tried once more before it counts as an outage.
|
|
21
|
+
export const retryOnce = (read, retries = (_err) => true) => read().catch(async (err) => {
|
|
22
|
+
if (!retries(err))
|
|
23
|
+
throw err;
|
|
24
|
+
await sleep(1000);
|
|
25
|
+
return read();
|
|
26
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type JWTVerifyGetKey } from 'jose';
|
|
2
|
+
import { type TokenCheck } from './token-check.js';
|
|
3
|
+
export interface TokenCheckDeps {
|
|
4
|
+
issuer: string;
|
|
5
|
+
audience: string;
|
|
6
|
+
id: string;
|
|
7
|
+
origin: string;
|
|
8
|
+
keys: JWTVerifyGetKey;
|
|
9
|
+
revoked: {
|
|
10
|
+
has(jti: string): boolean;
|
|
11
|
+
loaded(): boolean;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export declare const remoteKeys: (issuer: string) => {
|
|
15
|
+
keys: JWTVerifyGetKey<Uint8Array<ArrayBufferLike> | import("node:crypto").webcrypto.CryptoKey>;
|
|
16
|
+
read: () => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
export declare const createTokenCheck: (deps: TokenCheckDeps) => TokenCheck;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createRemoteJWKSet, errors, jwtVerify } from 'jose';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { fail, isFailure } from '../envelope/envelope.js';
|
|
4
|
+
import { RANK, retryOnce, scopeOf } from './token-check.js';
|
|
5
|
+
const VERIFY = { algorithms: ['EdDSA'], requiredClaims: ['exp'] };
|
|
6
|
+
const ClaimsSchema = z.object({
|
|
7
|
+
sub: z.string().min(1),
|
|
8
|
+
aud: z.string(),
|
|
9
|
+
scope: z.string(),
|
|
10
|
+
jti: z.string().optional(),
|
|
11
|
+
});
|
|
12
|
+
const unavailable = (message) => fail('UNAVAILABLE', message, { retryAfter: 5 });
|
|
13
|
+
const invalid = (origin, message = 'Invalid token') => fail('UNAUTHORIZED', message, {
|
|
14
|
+
headers: {
|
|
15
|
+
'WWW-Authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
const forbidden = (scope) => fail('FORBIDDEN', `Needs ${scope}`, {
|
|
19
|
+
headers: { 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${scope}"` },
|
|
20
|
+
});
|
|
21
|
+
const outage = (err) => !(err instanceof errors.JWKSNoMatchingKey);
|
|
22
|
+
// A key set that cannot be read is an outage, never a 401; a kid it lacks is a bad token.
|
|
23
|
+
const keysOrOutage = (keys) => async (header, token) => {
|
|
24
|
+
if (!header.kid)
|
|
25
|
+
throw new errors.JWKSNoMatchingKey();
|
|
26
|
+
return retryOnce(async () => keys(header, token), outage).catch((err) => {
|
|
27
|
+
throw outage(err) ? unavailable('Token keys unavailable') : err;
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
// Read at start and again only for an unknown kid, so a known key outlives an issuer outage.
|
|
31
|
+
export const remoteKeys = (issuer) => {
|
|
32
|
+
const set = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`), {
|
|
33
|
+
timeoutDuration: 3000,
|
|
34
|
+
cacheMaxAge: Infinity,
|
|
35
|
+
});
|
|
36
|
+
return { keys: keysOrOutage(set), read: () => retryOnce(set.reload) };
|
|
37
|
+
};
|
|
38
|
+
const claimsOf = async (authorization, deps) => {
|
|
39
|
+
const [scheme = '', credential = '', ...rest] = authorization.split(' ');
|
|
40
|
+
const bearer = scheme.toLowerCase() === 'bearer' && rest.length === 0;
|
|
41
|
+
const jwt = bearer && credential.startsWith('aga_') ? credential : '';
|
|
42
|
+
const options = { ...VERIFY, issuer: deps.issuer, audience: deps.audience };
|
|
43
|
+
const verified = await jwtVerify(jwt.slice(4), deps.keys, options).catch((err) => {
|
|
44
|
+
throw isFailure(err) ? err : invalid(deps.origin);
|
|
45
|
+
});
|
|
46
|
+
const claims = ClaimsSchema.safeParse(verified.payload);
|
|
47
|
+
if (!claims.success || verified.protectedHeader.typ !== 'at+jwt')
|
|
48
|
+
throw invalid(deps.origin);
|
|
49
|
+
return claims.data;
|
|
50
|
+
};
|
|
51
|
+
const grants = (scope, id, access) => {
|
|
52
|
+
const held = new Set(scope.split(' '));
|
|
53
|
+
return ['read', 'write', 'admin'].some((verb) => RANK[verb] >= RANK[access] && held.has(`${id}:${verb}`));
|
|
54
|
+
};
|
|
55
|
+
export const createTokenCheck = (deps) => async (authorization, access) => {
|
|
56
|
+
if (access === 'public')
|
|
57
|
+
return null;
|
|
58
|
+
if (!authorization)
|
|
59
|
+
throw invalid(deps.origin, 'Missing token');
|
|
60
|
+
const { sub, scope, jti } = await claimsOf(authorization, deps);
|
|
61
|
+
if (jti !== undefined && !deps.revoked.loaded())
|
|
62
|
+
throw unavailable('Revoked list unavailable');
|
|
63
|
+
if (jti !== undefined && deps.revoked.has(jti))
|
|
64
|
+
throw invalid(deps.origin, 'Token revoked');
|
|
65
|
+
if (!grants(scope, deps.id, access))
|
|
66
|
+
throw forbidden(scopeOf(deps.id, access));
|
|
67
|
+
return { sub, scopes: scope.split(' ') };
|
|
68
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readManifest } from '../manifest/read-manifest.js';
|
|
2
|
-
import { loadHandlers } from './run-command.js';
|
|
3
2
|
import { createOut } from './out.js';
|
|
3
|
+
import { contextFor, loadHandlers } from './run-command.js';
|
|
4
4
|
const quiet = () => undefined;
|
|
5
5
|
// null when the cli entry exports create and its handlers are exactly the declared commands.
|
|
6
6
|
export const checkCli = async (dir) => {
|
|
@@ -8,12 +8,7 @@ export const checkCli = async (dir) => {
|
|
|
8
8
|
const pkg = await readManifest(dir);
|
|
9
9
|
const declared = (pkg.agentage.commands ?? []).map((command) => command.name).sort();
|
|
10
10
|
const out = createOut({ json: false, stdout: quiet, stderr: quiet });
|
|
11
|
-
const
|
|
12
|
-
extension: { id: pkg.agentage.id, version: pkg.version, part: 'cli' },
|
|
13
|
-
args: {},
|
|
14
|
-
out,
|
|
15
|
-
};
|
|
16
|
-
const handlers = Object.keys(await loadHandlers(dir, pkg, ctx)).sort();
|
|
11
|
+
const handlers = Object.keys(await loadHandlers(dir, pkg, contextFor(pkg, {}, out))).sort();
|
|
17
12
|
if (JSON.stringify(handlers) === JSON.stringify(declared))
|
|
18
13
|
return null;
|
|
19
14
|
return `cli handlers [${handlers.join(', ')}] differ from commands [${declared.join(', ')}]`;
|
|
@@ -12,4 +12,5 @@ export declare const EXIT_OK = 0;
|
|
|
12
12
|
export declare const EXIT_FAILED = 1;
|
|
13
13
|
export declare const EXIT_USAGE = 2;
|
|
14
14
|
export declare const loadHandlers: (dir: string, pkg: ExtensionPackage, ctx: CliContext) => Promise<Record<string, CommandHandler>>;
|
|
15
|
+
export declare const contextFor: (pkg: ExtensionPackage, args: CliContext["args"], out: CliContext["out"]) => CliContext;
|
|
15
16
|
export declare const runCommand: (o: RunCommandOptions) => Promise<number>;
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { pathToFileURL } from 'node:url';
|
|
1
|
+
import { loadCreate } from '../manifest/entry.js';
|
|
3
2
|
import {} from '../manifest/manifest.schema.js';
|
|
4
|
-
import { entryOf } from '../manifest/parts.js';
|
|
5
3
|
import { readManifest } from '../manifest/read-manifest.js';
|
|
6
4
|
import { parseCommandArgs, usage, UsageError } from './args.js';
|
|
7
5
|
import {} from './command.types.js';
|
|
@@ -11,14 +9,8 @@ export const EXIT_FAILED = 1;
|
|
|
11
9
|
export const EXIT_USAGE = 2;
|
|
12
10
|
const writeOut = (text) => void process.stdout.write(text);
|
|
13
11
|
const writeErr = (text) => void process.stderr.write(text);
|
|
14
|
-
export const loadHandlers = async (dir, pkg, ctx) =>
|
|
15
|
-
|
|
16
|
-
const mod = (await import(pathToFileURL(file).href));
|
|
17
|
-
if (typeof mod.create !== 'function')
|
|
18
|
-
throw new Error(`${file} does not export create(ctx)`);
|
|
19
|
-
return mod.create(ctx);
|
|
20
|
-
};
|
|
21
|
-
const contextFor = (pkg, args, out) => ({
|
|
12
|
+
export const loadHandlers = async (dir, pkg, ctx) => (await loadCreate(dir, pkg, 'cli'))(ctx);
|
|
13
|
+
export const contextFor = (pkg, args, out) => ({
|
|
22
14
|
extension: Object.freeze({ id: pkg.agentage.id, version: pkg.version, part: 'cli' }),
|
|
23
15
|
args: Object.freeze(args),
|
|
24
16
|
out,
|
|
@@ -28,10 +20,8 @@ const execute = async (o, pkg, stdout, stderr) => {
|
|
|
28
20
|
if (!spec)
|
|
29
21
|
throw new UsageError(`${pkg.agentage.id} has no command ${o.command}`);
|
|
30
22
|
const parsed = parseCommandArgs(spec, o.argv);
|
|
31
|
-
if (parsed.help)
|
|
32
|
-
stdout(`${usage(spec)}\n`);
|
|
33
|
-
return EXIT_OK;
|
|
34
|
-
}
|
|
23
|
+
if (parsed.help)
|
|
24
|
+
return (stdout(`${usage(spec)}\n`), EXIT_OK);
|
|
35
25
|
const out = createOut({ json: parsed.json, stdout, stderr });
|
|
36
26
|
const handler = (await loadHandlers(o.dir, pkg, contextFor(pkg, parsed.args, out)))[o.command];
|
|
37
27
|
if (typeof handler !== 'function')
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Caller } from '../auth/token-check.js';
|
|
1
2
|
import { type ExtensionPackage } from '../manifest/manifest.schema.js';
|
|
2
3
|
import { type Log } from '../log/log.js';
|
|
3
4
|
import { type Store } from '../store/store.types.js';
|
|
@@ -12,6 +13,9 @@ export interface ExtensionInfo {
|
|
|
12
13
|
}
|
|
13
14
|
export interface PartContext {
|
|
14
15
|
readonly extension: Readonly<ExtensionInfo>;
|
|
16
|
+
readonly auth: {
|
|
17
|
+
caller(request: object): Caller | null;
|
|
18
|
+
};
|
|
15
19
|
readonly log: Log;
|
|
16
20
|
readonly settings: Readonly<Record<string, SettingValue>>;
|
|
17
21
|
readonly store: Store;
|
package/dist/context/context.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { callerOf } from '../auth/token-check.js';
|
|
1
2
|
import {} from '../manifest/manifest.schema.js';
|
|
2
3
|
import { createLog } from '../log/log.js';
|
|
3
4
|
import {} from '../store/store.types.js';
|
|
@@ -19,5 +20,6 @@ export const createContext = (input) => {
|
|
|
19
20
|
});
|
|
20
21
|
const log = input.log ?? createLog({ extension: pkg.agentage.id, part });
|
|
21
22
|
const settings = Object.freeze({ ...settingDefaults(pkg), ...input.settings });
|
|
22
|
-
|
|
23
|
+
const auth = Object.freeze({ caller: callerOf });
|
|
24
|
+
return Object.freeze({ extension, auth, log, settings, store: store ?? NO_STORE });
|
|
23
25
|
};
|
|
@@ -24,33 +24,39 @@ const testStore = (option, log) => {
|
|
|
24
24
|
const dir = option === true ? process.cwd() : option.dir;
|
|
25
25
|
return createTestStore((store) => migrate(store, dir, log));
|
|
26
26
|
};
|
|
27
|
+
const TEST_EXTENSION = {
|
|
28
|
+
id: 'test',
|
|
29
|
+
version: '0.0.0',
|
|
30
|
+
part: 'api',
|
|
31
|
+
origin: 'http://127.0.0.1',
|
|
32
|
+
commit: '0'.repeat(40),
|
|
33
|
+
};
|
|
34
|
+
// Jobs run by hand with runJob, tools recorded as the mcp part registers them.
|
|
35
|
+
const recorders = (log) => {
|
|
36
|
+
const scheduler = createScheduler({ runs: memoryRuns(), log });
|
|
37
|
+
return { ...createToolRegistry(), schedule: scheduler.schedule, runJob: scheduler.run };
|
|
38
|
+
};
|
|
27
39
|
const serverContext = (options) => {
|
|
28
40
|
const logs = [];
|
|
29
41
|
const log = runLog(recordingLog(logs));
|
|
30
|
-
const extension = {
|
|
31
|
-
id: 'test',
|
|
32
|
-
version: '0.0.0',
|
|
33
|
-
part: 'api',
|
|
34
|
-
origin: 'http://127.0.0.1',
|
|
35
|
-
commit: '0'.repeat(40),
|
|
36
|
-
...options.extension,
|
|
37
|
-
};
|
|
38
42
|
const store = testStore(options.store, recordingLog([]));
|
|
39
|
-
const
|
|
40
|
-
const {
|
|
41
|
-
const base = { extension, log, settings: { ...options.settings }, logs, tool, tools };
|
|
42
|
-
const close = async () => store?.close();
|
|
43
|
+
const extension = { ...TEST_EXTENSION, ...options.extension };
|
|
44
|
+
const caller = { sub: 'usr_test', scopes: [`${extension.id}:read`] };
|
|
43
45
|
return {
|
|
44
|
-
|
|
46
|
+
extension,
|
|
47
|
+
auth: { caller: () => caller },
|
|
48
|
+
log,
|
|
49
|
+
logs,
|
|
50
|
+
settings: { ...options.settings },
|
|
45
51
|
store: store ?? NO_STORE,
|
|
46
|
-
close,
|
|
47
|
-
|
|
48
|
-
runJob: scheduler.run,
|
|
52
|
+
close: async () => store?.close(),
|
|
53
|
+
...recorders(log),
|
|
49
54
|
};
|
|
50
55
|
};
|
|
56
|
+
// One recorded line per line written, the trailing newline dropped.
|
|
57
|
+
const lines = (into) => (text) => void into.push(...text.replace(/\n$/, '').split('\n'));
|
|
51
58
|
const commandContext = (options) => {
|
|
52
59
|
const output = { stdout: [], stderr: [] };
|
|
53
|
-
const lines = (into) => (text) => void into.push(...text.replace(/\n$/, '').split('\n'));
|
|
54
60
|
const out = createOut({
|
|
55
61
|
json: options.json ?? false,
|
|
56
62
|
stdout: lines(output.stdout),
|
|
@@ -9,7 +9,10 @@ export type PartImages = Partial<Record<ImagePartKind, string>>;
|
|
|
9
9
|
export declare const DEFAULT_RECORD_FILE = "/run/agentage/agentage-extension.json";
|
|
10
10
|
export interface RecordExtras {
|
|
11
11
|
openapi?: string;
|
|
12
|
-
tools?:
|
|
12
|
+
tools?: {
|
|
13
|
+
name: string;
|
|
14
|
+
scope: string;
|
|
15
|
+
}[];
|
|
13
16
|
widgets?: {
|
|
14
17
|
name: string;
|
|
15
18
|
element: string;
|
|
@@ -43,7 +46,10 @@ export declare const discoveryDocument: (record: ExtensionRecord, host: string)
|
|
|
43
46
|
} | undefined;
|
|
44
47
|
};
|
|
45
48
|
openapi?: string | undefined;
|
|
46
|
-
tools?:
|
|
49
|
+
tools?: {
|
|
50
|
+
name: string;
|
|
51
|
+
scope: string;
|
|
52
|
+
}[] | undefined;
|
|
47
53
|
widgets?: {
|
|
48
54
|
name: string;
|
|
49
55
|
element: string;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
export declare const OriginSchema: z.ZodString;
|
|
3
|
+
export declare const WidgetSchema: z.ZodObject<{
|
|
4
|
+
name: z.ZodString;
|
|
5
|
+
element: z.ZodString;
|
|
6
|
+
module: z.ZodString;
|
|
7
|
+
}, z.core.$strict>;
|
|
2
8
|
export declare const ExtensionRecordSchema: z.ZodObject<{
|
|
3
9
|
id: z.ZodString;
|
|
4
10
|
version: z.ZodString;
|
|
@@ -30,7 +36,10 @@ export declare const ExtensionRecordSchema: z.ZodObject<{
|
|
|
30
36
|
}, z.core.$strict>>;
|
|
31
37
|
}, z.core.$strict>;
|
|
32
38
|
openapi: z.ZodOptional<z.ZodString>;
|
|
33
|
-
tools: z.ZodOptional<z.ZodArray<z.
|
|
39
|
+
tools: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
40
|
+
name: z.ZodString;
|
|
41
|
+
scope: z.ZodString;
|
|
42
|
+
}, z.core.$strict>>>;
|
|
34
43
|
widgets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
35
44
|
name: z.ZodString;
|
|
36
45
|
element: z.ZodString;
|
|
@@ -70,7 +79,10 @@ export declare const DiscoveryDocumentSchema: z.ZodObject<{
|
|
|
70
79
|
}, z.core.$strict>>;
|
|
71
80
|
}, z.core.$strict>;
|
|
72
81
|
openapi: z.ZodOptional<z.ZodString>;
|
|
73
|
-
tools: z.ZodOptional<z.ZodArray<z.
|
|
82
|
+
tools: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
83
|
+
name: z.ZodString;
|
|
84
|
+
scope: z.ZodString;
|
|
85
|
+
}, z.core.$strict>>>;
|
|
74
86
|
widgets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
75
87
|
name: z.ZodString;
|
|
76
88
|
element: z.ZodString;
|
|
@@ -20,15 +20,19 @@ const PartsRecordSchema = z
|
|
|
20
20
|
.refine((parts) => IMAGE_PART_KINDS.some((kind) => parts[kind] !== undefined), {
|
|
21
21
|
message: 'at least one part with an image',
|
|
22
22
|
});
|
|
23
|
-
const OriginSchema = z
|
|
23
|
+
export const OriginSchema = z
|
|
24
24
|
.string()
|
|
25
25
|
.max(270)
|
|
26
26
|
.regex(/^(https:\/\/[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+|http:\/\/(127\.0\.0\.1|localhost))(:[0-9]{1,5})?$/);
|
|
27
|
-
const WidgetSchema = z.strictObject({
|
|
27
|
+
export const WidgetSchema = z.strictObject({
|
|
28
28
|
name: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
29
29
|
element: z.string().regex(/^[a-z][a-z0-9]*-[a-z0-9-]+$/),
|
|
30
30
|
module: z.string().regex(/^\/ui\/[^/]+\/[^/]+\.m?js$/),
|
|
31
31
|
});
|
|
32
|
+
const ToolRecordSchema = z.strictObject({
|
|
33
|
+
name: PrefixedToolNameSchema,
|
|
34
|
+
scope: z.string().regex(/^(public|[a-z][a-z0-9-]*:(read|write|admin))$/),
|
|
35
|
+
});
|
|
32
36
|
const recordShape = {
|
|
33
37
|
id: ExtensionIdSchema,
|
|
34
38
|
version: VersionSchema,
|
|
@@ -39,7 +43,7 @@ const recordShape = {
|
|
|
39
43
|
.string()
|
|
40
44
|
.regex(/^\/api\/v[1-9][0-9]*\/openapi\.json$/)
|
|
41
45
|
.optional(),
|
|
42
|
-
tools: z.array(
|
|
46
|
+
tools: z.array(ToolRecordSchema).min(1).optional(),
|
|
43
47
|
widgets: z.array(WidgetSchema).min(1).optional(),
|
|
44
48
|
commands: z.array(CommandNameSchema).min(1).optional(),
|
|
45
49
|
};
|
|
@@ -23,10 +23,12 @@ export interface Failure extends Error {
|
|
|
23
23
|
readonly status: number;
|
|
24
24
|
readonly details?: ErrorDetail[];
|
|
25
25
|
readonly retryAfter?: number;
|
|
26
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
26
27
|
}
|
|
27
28
|
export interface FailOptions {
|
|
28
29
|
details?: ErrorDetail[];
|
|
29
30
|
retryAfter?: number;
|
|
31
|
+
headers?: Record<string, string>;
|
|
30
32
|
}
|
|
31
33
|
export declare const ok: <T>(data: T, meta?: Record<string, unknown>) => OkEnvelope<T>;
|
|
32
34
|
export declare const fail: (code: ErrorCode, message: string, options?: FailOptions) => Failure;
|
|
@@ -14,6 +14,7 @@ export const fail = (code, message, options = {}) => Object.assign(new Error(mes
|
|
|
14
14
|
status: ERROR_STATUS[code],
|
|
15
15
|
details: options.details,
|
|
16
16
|
retryAfter: retryAfterOf(code, options.retryAfter),
|
|
17
|
+
headers: options.headers,
|
|
17
18
|
});
|
|
18
19
|
export const isFailure = (err) => err instanceof Error && err[FAILURE] === true;
|
|
19
20
|
export const errorEnvelope = (failure, agcid) => {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ExtensionPackage } from './manifest.schema.js';
|
|
2
|
+
import { type ImagePartKind } from './primitives.schema.js';
|
|
3
|
+
export type ImportModule = (file: string) => Promise<unknown>;
|
|
4
|
+
export declare const createOf: (mod: unknown, file: string) => ((ctx: never) => unknown);
|
|
5
|
+
export declare const loadCreate: <C>(dir: string, pkg: ExtensionPackage, part: ImagePartKind | "cli", importModule?: ImportModule) => Promise<(ctx: C) => unknown>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import {} from './manifest.schema.js';
|
|
4
|
+
import { entryOf } from './parts.js';
|
|
5
|
+
import {} from './primitives.schema.js';
|
|
6
|
+
const importFile = (file) => import(pathToFileURL(file).href);
|
|
7
|
+
export const createOf = (mod, file) => {
|
|
8
|
+
const create = mod?.create;
|
|
9
|
+
if (typeof create !== 'function')
|
|
10
|
+
throw new Error(`${file} does not export create(ctx)`);
|
|
11
|
+
return create;
|
|
12
|
+
};
|
|
13
|
+
// The entry itself missing (not a module it imports) means the package was never built.
|
|
14
|
+
const notBuilt = (err, file) => {
|
|
15
|
+
const missing = err?.code === 'ERR_MODULE_NOT_FOUND';
|
|
16
|
+
const message = String(err?.message);
|
|
17
|
+
const named = [file, pathToFileURL(file).href].some((name) => message.includes(`'${name}'`));
|
|
18
|
+
if (!missing || !named)
|
|
19
|
+
return err;
|
|
20
|
+
return new Error(`${file} not found: build the package first (npm run build)`);
|
|
21
|
+
};
|
|
22
|
+
// The one way the kit reaches part code: import the part's entry, take its create(ctx).
|
|
23
|
+
export const loadCreate = async (dir, pkg, part, importModule = importFile) => {
|
|
24
|
+
const file = join(dir, entryOf(pkg, part));
|
|
25
|
+
const mod = await importModule(file).catch((err) => {
|
|
26
|
+
throw notBuilt(err, file);
|
|
27
|
+
});
|
|
28
|
+
return createOf(mod, file);
|
|
29
|
+
};
|
package/dist/manifest/parts.d.ts
CHANGED
|
@@ -9,3 +9,4 @@ export interface NormalizedParts {
|
|
|
9
9
|
export declare const normalizeParts: (parts: Parts) => NormalizedParts;
|
|
10
10
|
export declare const imageParts: (pkg: ExtensionPackage) => ImagePartKind[];
|
|
11
11
|
export declare const entryOf: (pkg: ExtensionPackage, part: ImagePartKind | "cli") => string;
|
|
12
|
+
export declare const hasPostgres: (pkg: ExtensionPackage) => boolean;
|
package/dist/manifest/parts.js
CHANGED
|
@@ -22,3 +22,4 @@ export const imageParts = (pkg) => {
|
|
|
22
22
|
return IMAGE_PART_KINDS.filter((kind) => code[kind] !== undefined);
|
|
23
23
|
};
|
|
24
24
|
export const entryOf = (pkg, part) => normalizeParts(pkg.agentage.parts).code[part]?.entry ?? `./dist/${part}/index.js`;
|
|
25
|
+
export const hasPostgres = (pkg) => normalizeParts(pkg.agentage.parts).store === 'postgres';
|