@m6d/cortex-cli 0.13.0 → 0.14.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.
@@ -7,6 +7,7 @@ import {
7
7
  IDENTIFIER_PATTERN,
8
8
  localeSchema,
9
9
  SERVER_EVENTS,
10
+ refuseBlockedApprovalTitle,
10
11
  serverToolSchema,
11
12
  } from "../server/index";
12
13
  import { MESSAGE_TRIGGER_NAME } from "../triggers/index";
@@ -573,7 +574,7 @@ const toolBaseSchema = z.strictObject({
573
574
  audience: audienceSchema,
574
575
  });
575
576
 
576
- /** A tool that calls an endpoint; `approval` set, every call waits for the end user to approve it. */
577
+ /** A tool that calls an endpoint; with `requiresApproval`, every call waits for the end user to approve it. */
577
578
  const calledToolSchema = toolBaseSchema.extend({
578
579
  inputSchema: objectSchemaOf(true).default(EMPTY_OBJECT),
579
580
  url: upstreamUrlSchema.refine(
@@ -581,33 +582,66 @@ const calledToolSchema = toolBaseSchema.extend({
581
582
  "a {parameter} outside the path or query",
582
583
  ),
583
584
  credentials: credentialsSchema,
584
- approval: serverToolSchema.shape.approval,
585
+ requiresApproval: z.boolean().default(false),
585
586
  });
586
587
 
587
- const manifestToolSchema = z.discriminatedUnion("type", [
588
- calledToolSchema.extend({
589
- type: z.literal("http"),
590
- method: z.enum(TOOL_METHODS),
591
- readOnly: z.boolean(),
592
- ...toolConfigByType.http.shape,
593
- }),
594
- /** The endpoint is the initiate call that opens the page; never read-only, so a reload rejoins it. */
595
- calledToolSchema.extend({
596
- type: z.literal("embedded"),
597
- method: z.enum(TOOL_METHODS),
598
- ...toolConfigByType.embedded.shape,
599
- }),
600
- /** Always POSTed. */
601
- calledToolSchema.extend({
602
- type: z.literal("soap"),
603
- readOnly: z.boolean(),
604
- ...toolConfigByType.soap.shape,
605
- }),
606
- /** Answered by your app: the model calls it and the widget hands the call over. */
607
- toolBaseSchema.extend({ type: z.literal("client") }),
608
- ]);
588
+ const manifestToolSchema = z
589
+ .discriminatedUnion("type", [
590
+ calledToolSchema.extend({
591
+ type: z.literal("http"),
592
+ method: z.enum(TOOL_METHODS),
593
+ readOnly: z.boolean(),
594
+ ...toolConfigByType.http.shape,
595
+ }),
596
+ /** The endpoint is the initiate call that opens the page; never read-only, so a reload rejoins it. */
597
+ calledToolSchema.extend({
598
+ type: z.literal("embedded"),
599
+ method: z.enum(TOOL_METHODS),
600
+ ...toolConfigByType.embedded.shape,
601
+ }),
602
+ /** Always POSTed. */
603
+ calledToolSchema.extend({
604
+ type: z.literal("soap"),
605
+ readOnly: z.boolean(),
606
+ ...toolConfigByType.soap.shape,
607
+ }),
608
+ /** Answered by your app: the model calls it and the widget hands the call over. */
609
+ toolBaseSchema.extend({ type: z.literal("client") }),
610
+ ])
611
+ .superRefine((tool, context) => {
612
+ if (tool.type !== "client") refuseBlockedApprovalTitle(tool, context, []);
613
+ });
609
614
  export type ManifestTool = z.input<typeof manifestToolSchema>;
610
615
 
616
+ const toolNamesSchema = z.array(serverToolSchema.shape.name).max(500).default([]);
617
+
618
+ /**
619
+ * An OpenAPI document whose operations become HTTP tools this manifest owns.
620
+ * Each apply reads it, imports every operation Cortex supports, keeps those
621
+ * tools in step with the document and deletes the ones it no longer has.
622
+ * Every tool presents `credentials`. A GET call runs at once; any other
623
+ * method waits for the end user's approval, unless `approval` names the
624
+ * tool the other way.
625
+ */
626
+ const manifestOpenApiSchema = z.strictObject({
627
+ /** Names the source in what an apply reports. */
628
+ name: nameSchema,
629
+ /** Where the document is read, with no credentials: OpenAPI 3 or Swagger 2, JSON or YAML. */
630
+ url: upstreamUrlSchema,
631
+ credentials: credentialsSchema,
632
+ /** Tools listed in every turn of every agent they reach, whatever the question. */
633
+ pinned: toolNamesSchema,
634
+ approval: z
635
+ .strictObject({ never: toolNamesSchema, always: toolNamesSchema })
636
+ .prefault({})
637
+ .refine(
638
+ ({ never, always }) => !never.some((name) => always.includes(name)),
639
+ "a tool both never and always needs approval",
640
+ ),
641
+ audience: audienceSchema,
642
+ });
643
+ export type ManifestOpenApi = z.input<typeof manifestOpenApiSchema>;
644
+
611
645
  /** Identified by its `key`, which skills and collections scope themselves by; `name` is its label. */
612
646
  const manifestServiceSchema = z.strictObject({
613
647
  /** What consumers parameterize their tools with; fixed once published. */
@@ -896,6 +930,7 @@ export const manifestSchema = z.strictObject({
896
930
  id: slugSchema,
897
931
  agents: listOf(manifestAgentSchema, (agent) => agent.slug),
898
932
  tools: listOf(manifestToolSchema, byName),
933
+ openapi: listOf(manifestOpenApiSchema, byName),
899
934
  services: listOf(manifestServiceSchema, (service) => service.key),
900
935
  skills: listOf(manifestSkillSchema, byName),
901
936
  triggers: listOf(manifestTriggerSchema, byName),
@@ -907,8 +942,14 @@ export const manifestSchema = z.strictObject({
907
942
  /** A manifest as written: every field with a default may be left out. */
908
943
  export type Manifest = z.input<typeof manifestSchema>;
909
944
 
910
- /** Entry names per kind: agents by slug, services by key, everything else by name. */
911
- const namesByKindSchema = z.record(manifestSchema.keyof().exclude(["id"]), z.array(z.string()));
945
+ /**
946
+ * Entry names per kind: agents by slug, services by key, everything else by
947
+ * name. An OpenAPI source's operations are counted among `tools`.
948
+ */
949
+ const namesByKindSchema = z.record(
950
+ manifestSchema.keyof().exclude(["id", "openapi"]),
951
+ z.array(z.string()),
952
+ );
912
953
 
913
954
  /** What an apply creates, updates and deletes, kind by kind; a preview answers the same without doing it. */
914
955
  export const manifestChangesSchema = z.object({
@@ -2,7 +2,8 @@ import { z } from "zod";
2
2
  import { REQUEST_LOCATION_TOOL } from "../client-tools/index";
3
3
  import { backendErrorSchema } from "../errors/index";
4
4
  import { pendingApprovalSchema, TRIGGER_ANSWER_TOOL_NAME } from "../triggers/index";
5
- import { LOCALES } from "../wire/index";
5
+ import { isRecord, unescapePointerToken } from "../json/index";
6
+ import { APPROVAL_TITLE_FIELD, LOCALES } from "../wire/index";
6
7
 
7
8
  /*
8
9
  * The server protocol: what Cortex calls on a customer's backend. `GET /`
@@ -108,11 +109,14 @@ export function isBuiltInToolName(name: string) {
108
109
  return BUILT_IN_TOOL_NAMES.some((builtIn) => builtIn === name);
109
110
  }
110
111
 
112
+ /** The longest name a tool takes, a server's or one defined in Cortex. */
113
+ export const TOOL_NAME_MAX_LENGTH = 64;
114
+
111
115
  /** A tool name, a server's or one defined in Cortex: an identifier no built-in tool holds. */
112
116
  const declaredToolNameSchema = z
113
117
  .string()
114
118
  .regex(IDENTIFIER_PATTERN)
115
- .max(64)
119
+ .max(TOOL_NAME_MAX_LENGTH)
116
120
  .refine((name) => !isBuiltInToolName(name), "the name of a built-in tool");
117
121
 
118
122
  const jsonSchemaSchema = z.record(z.string(), z.unknown());
@@ -135,8 +139,9 @@ export type ServerVariable = z.infer<typeof serverVariableSchema>;
135
139
 
136
140
  /**
137
141
  * A tool the server runs. Its input is an object, the `input` its calls
138
- * carry. An `approval` sentence per locale parks every call for the end
139
- * user's approval.
142
+ * carry. `requiresApproval` parks every call for the end user's approval;
143
+ * such a tool's input cannot declare `APPROVAL_TITLE_FIELD`, which the
144
+ * model fills for the approval card.
140
145
  */
141
146
  export const serverToolSchema = z.object({
142
147
  name: declaredToolNameSchema,
@@ -147,7 +152,7 @@ export const serverToolSchema = z.object({
147
152
  ),
148
153
  outputSchema: jsonSchemaSchema.optional(),
149
154
  readOnly: z.boolean(),
150
- approval: z.record(localeSchema, z.string().trim().min(1).max(500)).optional(),
155
+ requiresApproval: z.boolean().optional(),
151
156
  });
152
157
  export type ServerTool = z.infer<typeof serverToolSchema>;
153
158
 
@@ -179,6 +184,97 @@ export const approvalRequestedEventSchema = z.object({
179
184
  });
180
185
  export type ApprovalRequestedEvent = z.infer<typeof approvalRequestedEventSchema>;
181
186
 
187
+ /** A JSON Pointer token as a key: percent-decoded, then unescaped; undefined when its encoding is broken. */
188
+ function pointerKey(token: string) {
189
+ try {
190
+ return unescapePointerToken(decodeURIComponent(token));
191
+ } catch {
192
+ return undefined;
193
+ }
194
+ }
195
+
196
+ /** What a local `$ref` (`#/$defs/order`) points at in `root`, read as the input compiler reads it; undefined for any other. */
197
+ function pointedAt(root: unknown, ref: string) {
198
+ if (!ref.startsWith("#")) return undefined;
199
+ let node = root;
200
+ for (const token of ref.slice(1).split("/").slice(1)) {
201
+ const key = pointerKey(token);
202
+ node =
203
+ key !== undefined && (isRecord(node) || Array.isArray(node))
204
+ ? new Map<string, unknown>(Object.entries(node)).get(key)
205
+ : undefined;
206
+ }
207
+ return node;
208
+ }
209
+
210
+ /** Whether one schema node names `APPROVAL_TITLE_FIELD` as a property or a required field. */
211
+ function namesApprovalTitle(node: Record<string, unknown>) {
212
+ const required: unknown = node.required;
213
+ return (
214
+ (isRecord(node.properties) && Object.hasOwn(node.properties, APPROVAL_TITLE_FIELD)) ||
215
+ (Array.isArray(required) && required.includes(APPROVAL_TITLE_FIELD))
216
+ );
217
+ }
218
+
219
+ /** What describes the same object as `node`: its `allOf`, `anyOf` and `oneOf` members, and what its local `$ref` points at in `root`. */
220
+ function sameObjectSchemas(node: Record<string, unknown>, root: unknown) {
221
+ const schemas: unknown[] = typeof node.$ref === "string" ? [pointedAt(root, node.$ref)] : [];
222
+ for (const keyword of ["allOf", "anyOf", "oneOf"]) {
223
+ const members: unknown = node[keyword];
224
+ if (!Array.isArray(members)) continue;
225
+ const listed: unknown[] = members;
226
+ schemas.push(...listed);
227
+ }
228
+ return schemas;
229
+ }
230
+
231
+ /**
232
+ * Every node that describes the same object as an input schema's root: the
233
+ * root, and what `sameObjectSchemas` reaches from it at any depth, each once.
234
+ */
235
+ function sameObjectNodes(inputSchema: unknown) {
236
+ const nodes = new Array<Record<string, unknown>>();
237
+ const pending = [inputSchema];
238
+ while (pending.length > 0) {
239
+ const node = pending.pop();
240
+ if (!isRecord(node) || nodes.includes(node)) continue;
241
+ nodes.push(node);
242
+ pending.push(...sameObjectSchemas(node, inputSchema));
243
+ }
244
+ return nodes;
245
+ }
246
+
247
+ /** Whether one schema node is closed to properties it does not declare. */
248
+ function closed(node: Record<string, unknown>) {
249
+ return node.additionalProperties === false || node.unevaluatedProperties === false;
250
+ }
251
+
252
+ /**
253
+ * Whether an input schema leaves no room for `APPROVAL_TITLE_FIELD`, which
254
+ * Cortex adds to the root of a tool that needs approval: a node describing
255
+ * the input names it, or one besides the root is closed to it, so a strict
256
+ * reading of the schema could not take the title.
257
+ */
258
+ export function blocksApprovalTitle(inputSchema: unknown) {
259
+ return sameObjectNodes(inputSchema).some(
260
+ (node) => namesApprovalTitle(node) || (node !== inputSchema && closed(node)),
261
+ );
262
+ }
263
+
264
+ /** Refuses, under `path`, a tool that needs approval whose input `blocksApprovalTitle`. */
265
+ export function refuseBlockedApprovalTitle(
266
+ tool: { requiresApproval?: boolean; inputSchema: unknown },
267
+ context: z.RefinementCtx,
268
+ path: Array<string | number>,
269
+ ) {
270
+ if (!tool.requiresApproval || !blocksApprovalTitle(tool.inputSchema)) return;
271
+ context.addIssue({
272
+ code: "custom",
273
+ path: [...path, "inputSchema"],
274
+ message: `a tool that needs approval cannot take an input named ${APPROVAL_TITLE_FIELD}, nor one a closed sub-schema describes: Cortex adds the approval card's title to it`,
275
+ });
276
+ }
277
+
182
278
  function uniqueNames(entries: ReadonlyArray<{ name: string }>) {
183
279
  return new Set(entries.map((entry) => entry.name)).size === entries.length;
184
280
  }
@@ -187,7 +283,13 @@ function uniqueNames(entries: ReadonlyArray<{ name: string }>) {
187
283
  export const serverManifestSchema = z.object({
188
284
  version: z.string().min(1).max(128),
189
285
  variables: z.array(serverVariableSchema).max(100).refine(uniqueNames, "variable names repeat"),
190
- tools: z.array(serverToolSchema).max(500).refine(uniqueNames, "tool names repeat"),
286
+ tools: z
287
+ .array(serverToolSchema)
288
+ .max(500)
289
+ .refine(uniqueNames, "tool names repeat")
290
+ .superRefine((tools, context) => {
291
+ tools.forEach((tool, index) => refuseBlockedApprovalTitle(tool, context, [index]));
292
+ }),
191
293
  verify: z.boolean(),
192
294
  events: z.array(z.enum(SERVER_EVENTS)),
193
295
  });
@@ -171,13 +171,18 @@ export type LocalizedText = Record<Locale, string>;
171
171
  */
172
172
  export const STATEMENT_TOOLS = ["executeCode", "queryDataset", "describeDataset"] as const;
173
173
 
174
+ /**
175
+ * The input Cortex adds to every tool that waits for the end user's approval:
176
+ * the model writes what that one call will do, and the approval card shows it
177
+ * as its title. Cortex removes it before the call runs.
178
+ */
179
+ export const APPROVAL_TITLE_FIELD = "approvalTitle";
180
+
174
181
  /**
175
182
  * What Cortex stamps on a tool call's `metadata.cortex` from its first chunk,
176
183
  * so the live part and the stored one agree.
177
184
  */
178
185
  export type ToolCallMetadata = {
179
- /** The action the approval card names, in the author's words rather than the function's name. */
180
- approval?: LocalizedText;
181
186
  /**
182
187
  * Set on every call the client answers: a client tool's, the consumer's own, or a built-in
183
188
  * input's. Cortex runs every other call, so only these take an output from a request.
@@ -225,6 +225,8 @@ export async function callManagement(
225
225
  ...(method === "post" ? { "Content-Type": "application/json" } : {}),
226
226
  },
227
227
  body: method === "post" && input !== undefined ? JSON.stringify(input) : undefined,
228
+ }).catch((error: unknown) => {
229
+ throw new UnreachableError(error);
228
230
  });
229
231
  const body: unknown = await response.json().catch(() => null);
230
232
  if (!response.ok) {
@@ -261,6 +263,18 @@ function queryString(input: Record<string, unknown>) {
261
263
  return encoded === "" ? "" : `?${encoded}`;
262
264
  }
263
265
 
266
+ /**
267
+ * Cortex did not answer at all: refused, reset, unresolvable. `fetch` rejects
268
+ * only for these, with whatever its runtime throws (Bun's is a plain `Error`
269
+ * with a `code`), so `callManagement` names them once here.
270
+ */
271
+ export class UnreachableError extends Error {
272
+ public constructor(cause: unknown) {
273
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
274
+ this.name = "UnreachableError";
275
+ }
276
+ }
277
+
264
278
  /** A refusal; `detail` is what the refusal names, when it names something (an entry, the row holding a name). */
265
279
  export class ManagementError extends Error {
266
280
  public constructor(
@@ -87,14 +87,16 @@ export function removeProfile(name: string) {
87
87
  }
88
88
 
89
89
  /**
90
- * The profile a management command runs against: `CORTEX_URL` + `CORTEX_KEY`
91
- * when both are set (CI, containers), else the named or current entry from
92
- * the file. `undefined` means nobody has logged in.
90
+ * The profile a management command runs against: the entry `name` picks from
91
+ * the file, else `CORTEX_URL` + `CORTEX_KEY` when both are set (CI, containers,
92
+ * a project's .env), else the current entry. A name always wins, so `-p prod`
93
+ * inside a project whose .env points at its own Cortex still reaches prod.
94
+ * `undefined` means nobody has logged in.
93
95
  */
94
96
  export function resolveProfile(name?: string) {
95
97
  const url = process.env["CORTEX_URL"];
96
98
  const key = process.env["CORTEX_KEY"];
97
- if (url && key) return { name: "env", url: url.replace(/\/+$/, ""), key };
99
+ if (name === undefined && url && key) return { name: "env", url: url.replace(/\/+$/, ""), key };
98
100
  const store = readStore();
99
101
  const chosen = name ?? store.current;
100
102
  const profile = lookup(store, chosen);