@m6d/cortex-cli 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold and operate Cortex servers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,166 @@
1
+ import { z } from "zod";
2
+ import { isTlsOrLoopback } from "../egress/url";
3
+
4
+ export { isTlsOrLoopback };
5
+
6
+ /*
7
+ * A credential is a value Cortex presents to an upstream: one it holds
8
+ * (`static`) or one it acquires (`oauth2-client-credentials`, RFC 6749 §4.4).
9
+ * The definition is the same on both sides; the Control Center stores it,
10
+ * a code config declares it, and each side resolves it to a bearer through
11
+ * the helper and cache below with its own egress-guarded fetch.
12
+ */
13
+
14
+ export const CREDENTIAL_KINDS = ["static", "oauth2-client-credentials"] as const;
15
+ export type CredentialKind = (typeof CREDENTIAL_KINDS)[number];
16
+
17
+ export const credentialDefinitionSchema = z.discriminatedUnion("kind", [
18
+ z.object({
19
+ kind: z.literal("static"),
20
+ value: z.string().min(1),
21
+ }),
22
+ z.object({
23
+ kind: z.literal("oauth2-client-credentials"),
24
+ /** The token endpoint; a client secret travels only over TLS, or to loopback. */
25
+ tokenUrl: z.url().refine(isTlsOrLoopback, "token URL must be https, or http to loopback"),
26
+ clientId: z.string().min(1),
27
+ clientSecret: z.string().min(1),
28
+ scope: z.string().min(1).optional(),
29
+ }),
30
+ ]);
31
+ export type CredentialDefinition = z.infer<typeof credentialDefinitionSchema>;
32
+ export type OAuth2ClientCredentials = Extract<
33
+ CredentialDefinition,
34
+ { kind: "oauth2-client-credentials" }
35
+ >;
36
+
37
+ /** One POST to the token endpoint through the caller's guarded fetch; `null` when the transport failed. */
38
+ export type TokenPost = (
39
+ url: string,
40
+ init: { headers: Record<string, string>; body: string },
41
+ ) => Promise<{ status: number; body: string } | null>;
42
+
43
+ /** A bearer and when it stops being one; `null` when the issuer did not say. */
44
+ export type AcquiredToken = { token: string; expiresAt: number | null };
45
+
46
+ export type AcquireResult =
47
+ | { ok: true; value: AcquiredToken }
48
+ | { ok: false; error: "token_request_failed" | "token_rejected" | "token_response_invalid" };
49
+
50
+ const tokenResponseSchema = z.object({
51
+ access_token: z.string().min(1),
52
+ token_type: z.string().optional(),
53
+ // Some issuers (Azure AD v1 among them) send the lifetime as a string.
54
+ expires_in: z
55
+ .union([z.number(), z.string().regex(/^\d+$/).transform(Number)])
56
+ .pipe(z.number().nonnegative())
57
+ .optional(),
58
+ });
59
+
60
+ /**
61
+ * The client credentials grant: a form-encoded POST with the client
62
+ * authenticated by `client_secret_basic` (RFC 6749 §2.3.1, each half
63
+ * form-encoded before base64). The endpoint's body never leaves this
64
+ * function: a failure is one of three literals, since the body may echo
65
+ * the client secret or carry the issuer's own error detail.
66
+ */
67
+ export async function acquireOAuth2ClientCredentials(
68
+ definition: OAuth2ClientCredentials,
69
+ post: TokenPost,
70
+ ) {
71
+ const basic = Buffer.from(
72
+ `${encodeURIComponent(definition.clientId)}:${encodeURIComponent(definition.clientSecret)}`,
73
+ ).toString("base64");
74
+ const answer = await post(definition.tokenUrl, {
75
+ headers: {
76
+ Authorization: `Basic ${basic}`,
77
+ "Content-Type": "application/x-www-form-urlencoded",
78
+ Accept: "application/json",
79
+ },
80
+ body: new URLSearchParams({
81
+ grant_type: "client_credentials",
82
+ ...(definition.scope ? { scope: definition.scope } : {}),
83
+ }).toString(),
84
+ });
85
+ if (!answer) return { ok: false, error: "token_request_failed" } satisfies AcquireResult;
86
+ if (answer.status < 200 || answer.status >= 300) {
87
+ return { ok: false, error: "token_rejected" } satisfies AcquireResult;
88
+ }
89
+ let parsed: z.ZodSafeParseResult<z.infer<typeof tokenResponseSchema>>;
90
+ try {
91
+ parsed = tokenResponseSchema.safeParse(JSON.parse(answer.body));
92
+ } catch {
93
+ return { ok: false, error: "token_response_invalid" } satisfies AcquireResult;
94
+ }
95
+ if (!parsed.success || (parsed.data.token_type ?? "bearer").toLowerCase() !== "bearer") {
96
+ return { ok: false, error: "token_response_invalid" } satisfies AcquireResult;
97
+ }
98
+ const issuedAt = Date.now();
99
+ return {
100
+ ok: true as const,
101
+ value: {
102
+ token: parsed.data.access_token,
103
+ expiresAt:
104
+ parsed.data.expires_in === undefined
105
+ ? null
106
+ : issuedAt + parsed.data.expires_in * 1000,
107
+ },
108
+ } satisfies AcquireResult;
109
+ }
110
+
111
+ /** A token is not reused inside this margin of its expiry: the call it is presented on still has to complete. */
112
+ const EXPIRY_MARGIN_MS = 30_000;
113
+
114
+ /** Equal definitions share one key; a rotated secret is another definition and another token. Kept in memory only, like the definitions themselves. */
115
+ function keyOf(definition: CredentialDefinition) {
116
+ return JSON.stringify(definition);
117
+ }
118
+
119
+ /**
120
+ * Acquired tokens by definition, reused until their expiry margin. Concurrent
121
+ * acquisitions of one definition share a single request; a token the issuer
122
+ * gave no expiry for is answered but not kept, since nothing says when it
123
+ * stops working. `invalidate` is for the caller that saw a 401: the next
124
+ * resolve acquires again. Pure, so the console's client bundle may import
125
+ * the schemas beside it.
126
+ */
127
+ export function createCredentialCache(now: () => number = Date.now) {
128
+ const tokens = new Map<string, AcquiredToken>();
129
+ const inFlight = new Map<string, Promise<AcquireResult>>();
130
+
131
+ return {
132
+ async get(definition: CredentialDefinition, acquire: () => Promise<AcquireResult>) {
133
+ const key = keyOf(definition);
134
+ const cached = tokens.get(key);
135
+ if (
136
+ cached &&
137
+ cached.expiresAt !== null &&
138
+ cached.expiresAt - EXPIRY_MARGIN_MS > now()
139
+ ) {
140
+ return { ok: true, value: cached } satisfies AcquireResult;
141
+ }
142
+ tokens.delete(key);
143
+ const pending = inFlight.get(key);
144
+ if (pending) return pending;
145
+ const acquiring = acquire()
146
+ .then((result) => {
147
+ if (
148
+ result.ok &&
149
+ result.value.expiresAt !== null &&
150
+ result.value.expiresAt - EXPIRY_MARGIN_MS > now()
151
+ ) {
152
+ tokens.set(key, result.value);
153
+ }
154
+ return result;
155
+ })
156
+ .finally(() => inFlight.delete(key));
157
+ inFlight.set(key, acquiring);
158
+ return acquiring;
159
+ },
160
+ invalidate(definition: CredentialDefinition) {
161
+ tokens.delete(keyOf(definition));
162
+ },
163
+ };
164
+ }
165
+
166
+ export type CredentialCache = ReturnType<typeof createCredentialCache>;
@@ -63,22 +63,7 @@ export function isBlockedAddress(address: string) {
63
63
  return isIP(address) === 6 ? isBlockedV6(address) : isBlockedV4(address);
64
64
  }
65
65
 
66
- /**
67
- * Whether a credential may travel to this URL: over TLS anywhere, or over
68
- * plain http to loopback alone, for a service on the developer's machine.
69
- */
70
- export function isTlsOrLoopback(url: string) {
71
- try {
72
- const { protocol, hostname } = new URL(url);
73
- if (protocol === "https:") return true;
74
- return (
75
- protocol === "http:" &&
76
- (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]")
77
- );
78
- } catch {
79
- return false;
80
- }
81
- }
66
+ export { isTlsOrLoopback } from "./url";
82
67
 
83
68
  export function egressError() {
84
69
  const error: NodeJS.ErrnoException = new Error("egress blocked");
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Whether a credential may travel to this URL: over TLS anywhere, or over
3
+ * plain http to loopback alone, for a service on the developer's machine.
4
+ * Pure, so a browser bundle can judge a URL the same way the server does.
5
+ */
6
+ export function isTlsOrLoopback(url: string) {
7
+ try {
8
+ const { protocol, hostname } = new URL(url);
9
+ if (protocol === "https:") return true;
10
+ return (
11
+ protocol === "http:" &&
12
+ (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]")
13
+ );
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
@@ -90,7 +90,7 @@ type Resolve = ReturnType<typeof resolver>;
90
90
 
91
91
  function literalUnion(values: unknown[]) {
92
92
  return values
93
- .filter((value) => SCALAR_TYPES.has(typeof value))
93
+ .filter((value) => value === null || SCALAR_TYPES.has(typeof value))
94
94
  .map((value) => JSON.stringify(value))
95
95
  .join(" | ");
96
96
  }
@@ -125,15 +125,32 @@ function inputType(schema: unknown, resolve: Resolve, depth = 0, indent = 0): st
125
125
  const rendered = variants.map((variant) => inputType(variant, resolve, depth, indent));
126
126
  return [...new Set(rendered)].join(" | ");
127
127
  }
128
- if (schema.type === "null") return "null";
129
- if (schema.type === "array") {
130
- return `${inputType(schema.items, resolve, depth + 1, indent)}[]`;
128
+ const type = primaryType(schema.type);
129
+ const nullable = allowsNull(schema.type) ? " | null" : "";
130
+ if (type === "null") return "null";
131
+ if (type === "array") {
132
+ const item = inputType(schema.items, resolve, depth + 1, indent);
133
+ // A union item type binds looser than `[]`, so it is parenthesized.
134
+ return `${item.includes(" | ") ? `(${item})` : item}[]${nullable}`;
131
135
  }
132
- if (schema.type === "object" || isRecord(schema.properties)) {
133
- return inputObject(schema, resolve, depth, indent);
136
+ if (type === "object" || isRecord(schema.properties)) {
137
+ return inputObject(schema, resolve, depth, indent) + nullable;
134
138
  }
135
- if (schema.type === "integer") return "number";
136
- return SCALAR_TYPES.has(String(schema.type)) ? String(schema.type) : "unknown";
139
+ if (type === "integer") return `number${nullable}`;
140
+ return SCALAR_TYPES.has(String(type)) ? `${String(type)}${nullable}` : "unknown";
141
+ }
142
+
143
+ /** The non-null member of a JSON Schema `type`, so `["string", "null"]` reads as `string`. */
144
+ function primaryType(type: unknown) {
145
+ if (!Array.isArray(type)) return type;
146
+ const members: unknown[] = type;
147
+ return members.find((member) => member !== "null");
148
+ }
149
+
150
+ function allowsNull(type: unknown) {
151
+ if (!Array.isArray(type)) return false;
152
+ const members: unknown[] = type;
153
+ return members.includes("null");
137
154
  }
138
155
 
139
156
  /** The branches of a union schema, under whichever keyword carries them. */
@@ -143,7 +160,9 @@ function variantsOf(schema: Record<string, unknown>) {
143
160
  }
144
161
 
145
162
  function isObjectSchema(schema: unknown): schema is Record<string, unknown> {
146
- return isRecord(schema) && (schema.type === "object" || isRecord(schema.properties));
163
+ return (
164
+ isRecord(schema) && (primaryType(schema.type) === "object" || isRecord(schema.properties))
165
+ );
147
166
  }
148
167
 
149
168
  /**
@@ -176,7 +195,7 @@ function responseObject(schema: Record<string, unknown>, resolve: Resolve, depth
176
195
  const fields = orderedPropertyNames(schema).map((name) => {
177
196
  const child = responseUnion(properties[name], resolve);
178
197
  if (!isRecord(child) || depth >= MAX_DEPTH) return propertyName(name);
179
- if (child.type === "array") {
198
+ if (primaryType(child.type) === "array") {
180
199
  const items = responseUnion(child.items, resolve);
181
200
  return isObjectSchema(items)
182
201
  ? `${propertyName(name)}[].${responseObject(items, resolve, depth + 1)}`
@@ -200,7 +219,7 @@ export function responseShape(schema: unknown) {
200
219
  const resolve = resolver(schema);
201
220
  const root = responseUnion(schema, resolve);
202
221
  if (!isRecord(root)) return "{}";
203
- if (root.type === "array") {
222
+ if (primaryType(root.type) === "array") {
204
223
  const items = responseUnion(root.items, resolve);
205
224
  return isObjectSchema(items) ? `[]${responseObject(items, resolve)}` : "[]";
206
225
  }
@@ -27,14 +27,25 @@ export const tokenUsageSchema = z.object({
27
27
  total: tokenCountSchema,
28
28
  }) satisfies z.ZodType<TokenUsage>;
29
29
 
30
+ const componentNamesSchema = z.array(z.string().min(1).max(128)).max(1_000);
31
+
30
32
  /**
31
33
  * What a server offers every agent the Control Center creates for it, sent
32
34
  * whole at boot in `server.declare`: the `{{variables}}` its session loader
33
- * and request context fill, SDK ones included. Agents in the server's own
34
- * code never appear here; the Control Center is blind to them.
35
+ * and request context fill, SDK ones included, and the names of the
36
+ * components its code declares at server level, which reach those agents
37
+ * too and which the Control Center therefore refuses on its own components.
38
+ * Agents in the server's own code never appear here; the Control Center is
39
+ * blind to them.
35
40
  */
36
41
  export const serverDeclarationSchema = z.object({
37
42
  variables: z.array(variableDeclarationSchema),
43
+ components: z.object({
44
+ tool: componentNamesSchema,
45
+ skill: componentNamesSchema,
46
+ trigger: componentNamesSchema,
47
+ dataset_host: componentNamesSchema,
48
+ }),
38
49
  });
39
50
  export type ServerDeclaration = z.infer<typeof serverDeclarationSchema>;
40
51
 
@@ -129,7 +129,8 @@ export type TriggerDeclaration = z.infer<typeof triggerDeclarationSchema>;
129
129
  /**
130
130
  * `POST /api/runtime/agents/:agentSlug/triggers/:name/auth`: a server holding a
131
131
  * bearer for a cc trigger asks cc whether it is the trigger's secret. Only the
132
- * hash travels; a positive answer carries the trigger's credential, if any.
132
+ * hash travels; a positive answer carries the bearer the run acts with, if
133
+ * the trigger has a credential, resolved by cc (acquired, for an OAuth2 one).
133
134
  */
134
135
  export const triggerAuthRequestSchema = z.object({
135
136
  secretHash: sha256HexSchema,
@@ -140,6 +141,8 @@ export const triggerAuthResponseSchema = z.discriminatedUnion("ok", [
140
141
  z.object({
141
142
  ok: z.literal(true),
142
143
  credential: z.string().nullable(),
144
+ /** When the bearer stops working, ISO 8601; null for one that does not expire or has no credential. */
145
+ expiresAt: z.iso.datetime().nullable(),
143
146
  /** The bearer was a single-use test secret, now spent: the server must not cache this verdict. */
144
147
  oneShot: z.boolean(),
145
148
  }),
@@ -160,9 +160,10 @@ export type TriggerFiringSummary = {
160
160
  };
161
161
 
162
162
  /**
163
- * A stored message as it is persisted and served. TanStack AI's `UIMessage`
164
- * has no metadata field, so cortex owns the envelope: the SDK's parts plus the
165
- * server-derived metadata the UI renders on settled messages.
163
+ * A stored message as it is persisted and served: the SDK's parts plus the
164
+ * server-derived envelope the UIs render, carried in the SDK message's own
165
+ * `metadata` bag so it travels with the message over the stream and the
166
+ * transcript alike.
166
167
  *
167
168
  * `TPart` stays generic so this file keeps its zero dependencies — both
168
169
  * sides substitute the SDK's `MessagePart`.
@@ -175,6 +176,15 @@ export type CortexMessage<TPart = unknown> = {
175
176
  metadata?: MessageMetadata;
176
177
  };
177
178
 
179
+ /**
180
+ * A stored message as the SDK's chat client takes it. The SDK has no `trigger`
181
+ * role, so a firing's message travels as a user turn; its true role stays on
182
+ * the envelope (`metadata.trigger`), which is what the UIs render the event from.
183
+ */
184
+ export function toUiMessage<TPart>(message: CortexMessage<TPart>) {
185
+ return { ...message, role: message.role === "trigger" ? ("user" as const) : message.role };
186
+ }
187
+
178
188
  /**
179
189
  * Key under the AG-UI request's `forwardedProps` carrying the answers to
180
190
  * parked client tool calls on a continuation request. The server owns the
@@ -27,7 +27,7 @@ export type Database = "postgres" | "mssql";
27
27
  // sync (scripts/sync-package-versions.ts) restamps it and files.test.ts holds
28
28
  // them to that.
29
29
  export const DEPENDENCIES = {
30
- "@m6d/cortex-server": "~0.1.0",
30
+ "@m6d/cortex-server": "~0.3.0",
31
31
  "@tanstack/ai": "^0.55.0",
32
32
  "@tanstack/ai-openai": "^0.22.8",
33
33
  hono: "^4.12.3",
@@ -167,9 +167,10 @@ ${on === "" ? "" : `${on}\n`} agents: {
167
167
  assistant: defineAgent({
168
168
  systemPrompt: "You are a helpful assistant.",
169
169
  // Applications that serve datasets for grouped questions (see the
170
- // README's "Dataset hosts"): the host applies its own access rules,
171
- // the agent only picks which datasets to expose and pin.
172
- // datasetHosts: [{ name: "store", url: "https://store.example/api/datasets", pinned: ["orders"] }],
170
+ // README's "Dataset hosts"): the host applies its own access rules
171
+ // under the run's token the list below forwards, the agent only
172
+ // picks which datasets to expose and pin.
173
+ // datasetHosts: [{ name: "store", url: "https://store.example/api/datasets", credentials: [{ header: "X-End-User-Token", credential: { kind: "run-token" } }], pinned: ["orders"] }],
173
174
  }),
174
175
  },
175
176