@kybernesis/dispatch 0.1.0 → 0.2.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/dist/channel.d.ts CHANGED
@@ -26,14 +26,26 @@ export declare function peerSubject(peer: PeerRef): string;
26
26
  export interface DispatchChannelOptions {
27
27
  /**
28
28
  * The deployments allowed to call this agent AND assert a forwarded
29
- * end-user principal. Must be non-empty — an agent with no peers should
30
- * simply not author a dispatch channel. There is deliberately no predicate
31
- * form: enumerating concrete peers is the whole point.
29
+ * end-user principal. There is deliberately no predicate form: enumerating
30
+ * concrete peers is the whole point. Optional only when `governed` is set;
31
+ * an agent with neither should simply not author a dispatch channel.
32
32
  */
33
- trustedPeers: readonly PeerRef[];
33
+ trustedPeers?: readonly PeerRef[];
34
+ /**
35
+ * GOVERNED mode: verify A2A session tokens minted by the Kybernesis control
36
+ * plane for granted caller→callee edges (requires @kybernesis/enterprise
37
+ * ≥0.2.0 installed). `agent` is THIS agent's registered name; a verified
38
+ * a2a caller may call and assert forwarded principals — no peer enumeration
39
+ * needed, and revoking the edge in the admin locks it out within the token
40
+ * TTL (default 300 s). Composes with `trustedPeers` (union).
41
+ */
42
+ governed?: {
43
+ issuer: string;
44
+ agent: string;
45
+ };
34
46
  /**
35
47
  * Extra `AuthFn`s appended to the walk (e.g. your web app's auth for a
36
- * browser frontend). The Vercel OIDC verifier for peers always runs first.
48
+ * browser frontend). The peer verifiers always run first.
37
49
  */
38
50
  extraAuth?: Parameters<typeof eveChannel>[0] extends {
39
51
  auth?: infer A;
package/dist/channel.js CHANGED
@@ -35,18 +35,35 @@ export function peerSubject(peer) {
35
35
  * via `placeholderAuth()` unless you pass real auth in `extraAuth`.
36
36
  */
37
37
  export function dispatchChannel(options) {
38
- const peers = options.trustedPeers;
39
- if (!Array.isArray(peers) || peers.length === 0) {
40
- throw new Error("dispatchChannel: trustedPeers must name at least one peer deployment — an agent with no peers should not author a dispatch channel.");
38
+ const peers = options.trustedPeers ?? [];
39
+ const governed = options.governed;
40
+ if (peers.length === 0 && !governed) {
41
+ throw new Error("dispatchChannel: name at least one trustedPeers entry or set governed — an agent with no peers should not author a dispatch channel.");
41
42
  }
42
43
  const subjects = peers.map(peerSubject);
43
44
  const subjectSet = new Set(subjects);
44
45
  const extra = (options.extraAuth ?? []);
46
+ let governedAuth;
47
+ const governedAuthFn = async (request) => {
48
+ if (governedAuth === undefined) {
49
+ try {
50
+ const mod = (await import("@kybernesis/enterprise"));
51
+ governedAuth = mod.kybernesisAuth({ issuer: governed.issuer, agent: governed.agent });
52
+ }
53
+ catch {
54
+ console.error("[dispatch] governed mode requires @kybernesis/enterprise >=0.2.0 — install it or remove `governed`.");
55
+ governedAuth = null;
56
+ }
57
+ }
58
+ return governedAuth ? governedAuth(request) : null;
59
+ };
45
60
  return eveChannel({
46
61
  auth: [
47
62
  // Verifies which deployment is calling: this project's own tokens are
48
63
  // always accepted; other projects only when enumerated as peers.
49
- vercelOidc({ subjects }),
64
+ ...(peers.length > 0 ? [vercelOidc({ subjects })] : []),
65
+ // Governed callers present a control-plane A2A session token instead.
66
+ ...(governed ? [governedAuthFn] : []),
50
67
  // Open on localhost for `eve dev` and the REPL; ignored in production.
51
68
  localDev(),
52
69
  ...extra,
@@ -54,8 +71,12 @@ export function dispatchChannel(options) {
54
71
  // caller passed real auth above.
55
72
  placeholderAuth(),
56
73
  ],
57
- // Only enumerated peers may assert a forwarded end-user principal.
58
- trustedForwarders: (forwarder) => typeof forwarder.subject === "string" &&
59
- subjectSet.has(forwarder.subject),
74
+ // Enumerated peers OR a verified a2a agent principal (whose edge grant was
75
+ // checked at mint, ≤TTL ago) may assert a forwarded end-user principal.
76
+ trustedForwarders: (forwarder) => (typeof forwarder.subject === "string" && subjectSet.has(forwarder.subject)) ||
77
+ (Boolean(governed) &&
78
+ forwarder.principalType === "agent" &&
79
+ forwarder.authenticator === "kybernesis" &&
80
+ forwarder.attributes?.kind === "a2a"),
60
81
  });
61
82
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Governed mode: edges resolved from the Kybernesis control plane instead of
3
+ * hand-enumerated peers. The caller authenticates to the control plane with its
4
+ * agent credential (KYBERNESIS_AGENT_CREDENTIAL env), receives a short-TTL A2A
5
+ * session token for a GRANTED caller→callee edge plus the callee's deployment
6
+ * URL — so the registry doubles as discovery, and revoking the edge in the
7
+ * admin takes effect at the next mint (≤ the token TTL, default 300 s).
8
+ */
9
+ export interface GovernedOptions {
10
+ /** Control-plane issuer URL (e.g. https://agent.kybernesis.ai). */
11
+ issuer: string;
12
+ }
13
+ interface MintedSession {
14
+ token: string;
15
+ callee: {
16
+ name: string;
17
+ deploymentUrl: string | null;
18
+ };
19
+ expiresAt: number;
20
+ }
21
+ /**
22
+ * Per-edge mint cache. url() and auth() both need the mint result and are
23
+ * invoked independently by eve — a shared in-flight promise keeps them to one
24
+ * control-plane call per TTL window.
25
+ */
26
+ export declare function createA2ASessionSource(issuer: string, callee: string): () => Promise<MintedSession>;
27
+ export {};
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Governed mode: edges resolved from the Kybernesis control plane instead of
3
+ * hand-enumerated peers. The caller authenticates to the control plane with its
4
+ * agent credential (KYBERNESIS_AGENT_CREDENTIAL env), receives a short-TTL A2A
5
+ * session token for a GRANTED caller→callee edge plus the callee's deployment
6
+ * URL — so the registry doubles as discovery, and revoking the edge in the
7
+ * admin takes effect at the next mint (≤ the token TTL, default 300 s).
8
+ */
9
+ /** Re-mint 30 s before expiry so an in-flight dispatch never carries a stale token. */
10
+ const EXPIRY_BUFFER_SEC = 30;
11
+ /**
12
+ * Per-edge mint cache. url() and auth() both need the mint result and are
13
+ * invoked independently by eve — a shared in-flight promise keeps them to one
14
+ * control-plane call per TTL window.
15
+ */
16
+ export function createA2ASessionSource(issuer, callee) {
17
+ let cached = null;
18
+ let inflight = null;
19
+ const mint = async () => {
20
+ const credential = process.env.KYBERNESIS_AGENT_CREDENTIAL;
21
+ if (!credential) {
22
+ throw new Error("governed remotePeer: KYBERNESIS_AGENT_CREDENTIAL is not set — mint it in the control plane (agent panel) and add it to this deployment's env.");
23
+ }
24
+ const res = await fetch(`${issuer.replace(/\/$/, "")}/api/agent/session`, {
25
+ method: "POST",
26
+ headers: {
27
+ authorization: `Bearer ${credential}`,
28
+ "content-type": "application/json",
29
+ },
30
+ body: JSON.stringify({ callee }),
31
+ });
32
+ if (!res.ok) {
33
+ const body = (await res.json().catch(() => ({})));
34
+ throw new Error(`governed remotePeer: A2A mint for callee "${callee}" failed (${res.status} ${body.error ?? "unknown"}) — check the edge grant and both agents' status in the control plane.`);
35
+ }
36
+ return (await res.json());
37
+ };
38
+ return async () => {
39
+ const nowSec = Math.floor(Date.now() / 1000);
40
+ if (cached && cached.expiresAt - EXPIRY_BUFFER_SEC > nowSec)
41
+ return cached;
42
+ if (!inflight) {
43
+ inflight = mint().finally(() => {
44
+ inflight = null;
45
+ });
46
+ }
47
+ cached = await inflight;
48
+ return cached;
49
+ };
50
+ }
package/dist/index.d.ts CHANGED
@@ -22,4 +22,5 @@
22
22
  * app's service identity. Upgrade both ends of an edge together.
23
23
  */
24
24
  export { remotePeer, type RemotePeerOptions } from "./remote-peer.js";
25
+ export { type GovernedOptions } from "./governed.js";
25
26
  export { dispatchChannel, peerSubject, type DispatchChannelOptions, type PeerRef, } from "./channel.js";
@@ -1,18 +1,30 @@
1
1
  import { defineRemoteAgent } from "eve";
2
+ import { type GovernedOptions } from "./governed.js";
2
3
  /** Options for {@link remotePeer}. */
3
4
  export interface RemotePeerOptions {
4
5
  /**
5
6
  * Environment variable holding the peer deployment's URL (e.g.
6
7
  * `"GTM_AGENT_URL"`). Read at runtime on every dispatch, so repointing the
7
- * edge is an env change, not a rebuild.
8
+ * edge is an env change, not a rebuild. Required in ungoverned mode; in
9
+ * governed mode it becomes an OVERRIDE on top of registry discovery.
8
10
  */
9
- envVar: string;
11
+ envVar?: string;
10
12
  /**
11
13
  * Fallback URL when the env var is unset. Optional — without it, a missing
12
14
  * env var fails the dispatch loudly, which is usually what you want in a
13
15
  * fresh environment.
14
16
  */
15
17
  fallbackUrl?: string;
18
+ /**
19
+ * GOVERNED mode: the callee's registered name in the control plane. The edge
20
+ * must be granted (caller→callee) in the admin; outbound auth becomes a
21
+ * short-TTL A2A session token minted from `governed.issuer` using this
22
+ * deployment's `KYBERNESIS_AGENT_CREDENTIAL`, and the callee's URL is
23
+ * resolved from the registry (env var still wins as an override).
24
+ */
25
+ callee?: string;
26
+ /** Control-plane settings for governed mode. Requires `callee`. */
27
+ governed?: GovernedOptions;
16
28
  /**
17
29
  * The routing hint. The calling agent's model reads this to decide when to
18
30
  * delegate here — write it like the specialist's job description, with the
@@ -22,9 +34,9 @@ export interface RemotePeerOptions {
22
34
  description: string;
23
35
  /**
24
36
  * Forward the verified end-user principal across the hop (default `true`).
25
- * The receiver must name this deployment in its `dispatchChannel`
26
- * `trustedPeers` for the assertion to be accepted — otherwise the dispatch
27
- * fails with a 403 rather than silently downgrading.
37
+ * The receiver must trust this deployment (trustedPeers, or the verified
38
+ * a2a caller principal in governed mode) for the assertion to be accepted —
39
+ * otherwise the dispatch fails with a 403 rather than silently downgrading.
28
40
  *
29
41
  * Set `false` only when the peer should treat calls as coming from this
30
42
  * app itself (service identity) rather than from the human behind it.
@@ -44,17 +56,26 @@ export interface RemotePeerOptions {
44
56
  * subagent, with eve's durable dispatch underneath: the calling turn parks
45
57
  * without holding compute until the peer posts its terminal callback.
46
58
  *
59
+ * Ungoverned (hand-wired):
47
60
  * ```ts title="agent/subagents/gtm.ts"
48
61
  * import { remotePeer } from "@kybernesis/dispatch";
49
- *
50
62
  * export default remotePeer({
51
63
  * envVar: "GTM_AGENT_URL",
52
- * description:
53
- * "The company's GTM operator: posting cadence, open plays, outreach targets, content drafting.",
64
+ * description: "The company's GTM operator: cadence, plays, targets, drafting.",
54
65
  * });
55
66
  * ```
67
+ * Outbound auth is Vercel OIDC — the peer verifies which deployment is calling.
56
68
  *
57
- * Outbound auth is always Vercel OIDC — the peer verifies which deployment is
58
- * calling before any principal assertion is considered.
69
+ * Governed (control-plane edges):
70
+ * ```ts title="agent/subagents/gtm.ts"
71
+ * export default remotePeer({
72
+ * callee: "eve-gtm",
73
+ * governed: { issuer: "https://agent.kybernesis.ai" },
74
+ * description: "The company's GTM operator: cadence, plays, targets, drafting.",
75
+ * });
76
+ * ```
77
+ * Outbound auth is a 5-minute A2A token minted per granted edge; the callee's
78
+ * URL comes from the registry. Revoke the edge in the admin → the next mint
79
+ * refuses → dispatches 403 within the token TTL.
59
80
  */
60
81
  export declare function remotePeer(options: RemotePeerOptions): import("eve").RemoteAgentDefinition;
@@ -1,5 +1,6 @@
1
1
  import { defineRemoteAgent } from "eve";
2
2
  import { vercelOidc } from "eve/agents/auth";
3
+ import { createA2ASessionSource } from "./governed.js";
3
4
  /**
4
5
  * Declares a separately deployed eve agent as a callable peer of this agent.
5
6
  *
@@ -8,31 +9,70 @@ import { vercelOidc } from "eve/agents/auth";
8
9
  * subagent, with eve's durable dispatch underneath: the calling turn parks
9
10
  * without holding compute until the peer posts its terminal callback.
10
11
  *
12
+ * Ungoverned (hand-wired):
11
13
  * ```ts title="agent/subagents/gtm.ts"
12
14
  * import { remotePeer } from "@kybernesis/dispatch";
13
- *
14
15
  * export default remotePeer({
15
16
  * envVar: "GTM_AGENT_URL",
16
- * description:
17
- * "The company's GTM operator: posting cadence, open plays, outreach targets, content drafting.",
17
+ * description: "The company's GTM operator: cadence, plays, targets, drafting.",
18
18
  * });
19
19
  * ```
20
+ * Outbound auth is Vercel OIDC — the peer verifies which deployment is calling.
20
21
  *
21
- * Outbound auth is always Vercel OIDC — the peer verifies which deployment is
22
- * calling before any principal assertion is considered.
22
+ * Governed (control-plane edges):
23
+ * ```ts title="agent/subagents/gtm.ts"
24
+ * export default remotePeer({
25
+ * callee: "eve-gtm",
26
+ * governed: { issuer: "https://agent.kybernesis.ai" },
27
+ * description: "The company's GTM operator: cadence, plays, targets, drafting.",
28
+ * });
29
+ * ```
30
+ * Outbound auth is a 5-minute A2A token minted per granted edge; the callee's
31
+ * URL comes from the registry. Revoke the edge in the admin → the next mint
32
+ * refuses → dispatches 403 within the token TTL.
23
33
  */
24
34
  export function remotePeer(options) {
25
- const { envVar, fallbackUrl, description, outputSchema } = options;
26
- return defineRemoteAgent({
27
- url: () => {
28
- const url = process.env[envVar] ?? fallbackUrl;
29
- if (!url) {
30
- throw new Error(`remotePeer: ${envVar} is not set and no fallbackUrl was provided — set the peer deployment URL in the environment.`);
35
+ const { envVar, fallbackUrl, description, outputSchema, callee, governed } = options;
36
+ if (governed && !callee) {
37
+ throw new Error("remotePeer: governed mode requires `callee` (the peer's registered control-plane name).");
38
+ }
39
+ const session = governed && callee ? createA2ASessionSource(governed.issuer, callee) : null;
40
+ // eve resolves this at agent-graph build (boot), not per dispatch — so URL
41
+ // resolution must DEGRADE, never throw for a merely-missing credential:
42
+ // a misconfigured edge should fail when dispatched, not prevent the whole
43
+ // agent (and its evals) from booting.
44
+ const url = async () => {
45
+ const override = envVar ? process.env[envVar] : undefined;
46
+ if (override)
47
+ return override;
48
+ if (session && process.env.KYBERNESIS_AGENT_CREDENTIAL) {
49
+ try {
50
+ const s = await session();
51
+ if (s.callee.deploymentUrl)
52
+ return s.callee.deploymentUrl;
31
53
  }
32
- return url;
33
- },
54
+ catch {
55
+ // Mint failed (control plane down, edge revoked): fall through to the
56
+ // static fallback so boot survives; the dispatch itself will fail
57
+ // loudly in auth() with the precise mint error.
58
+ }
59
+ }
60
+ if (fallbackUrl)
61
+ return fallbackUrl;
62
+ throw new Error(`remotePeer: no URL for this edge — ${session
63
+ ? "registry discovery unavailable (no credential or no registered deploymentUrl) and no envVar/fallbackUrl override is set."
64
+ : `${envVar ?? "envVar"} is not set and no fallbackUrl was provided.`}`);
65
+ };
66
+ const auth = session
67
+ ? async () => {
68
+ const s = await session();
69
+ return { headers: { authorization: `Bearer ${s.token}` } };
70
+ }
71
+ : vercelOidc();
72
+ return defineRemoteAgent({
73
+ url,
34
74
  description,
35
- auth: vercelOidc(),
75
+ auth,
36
76
  forwardPrincipal: options.forwardPrincipal ?? true,
37
77
  ...(outputSchema !== undefined ? { outputSchema } : {}),
38
78
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/dispatch",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Agent-to-agent dispatch for eve agents: declared remote-peer edges with principal forwarding on by default, and a receiver channel that makes trusting the wrong caller hard to express.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,7 +35,8 @@
35
35
  "typescript": "7.0.2"
36
36
  },
37
37
  "peerDependencies": {
38
- "eve": ">=0.30.0 <0.31.0"
38
+ "eve": ">=0.30.0 <0.31.0",
39
+ "@kybernesis/enterprise": ">=0.2.0"
39
40
  },
40
41
  "engines": {
41
42
  "node": ">=20"
@@ -47,5 +48,10 @@
47
48
  },
48
49
  "publishConfig": {
49
50
  "access": "public"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@kybernesis/enterprise": {
54
+ "optional": true
55
+ }
50
56
  }
51
57
  }