@m6d/cortex-cli 0.16.0 → 0.17.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/README.md CHANGED
@@ -135,7 +135,7 @@ after; without it the command says so and names the command. Fields the API
135
135
  marks live (`x-live-fields`) take effect without a publish, from agents'
136
136
  next turn, and the command names them: a trigger's
137
137
  `credentialName`, `fireCredentialName` and model, say, or a dataset host's
138
- `url`, `credentials` and `timeoutMs`. `cortex datasetHosts sync <id>` reads a host's manifest, and a
138
+ `url`, `headers` and `timeoutMs`. `cortex datasetHosts sync <id>` reads a host's manifest, and a
139
139
  draft host publishes only after `datasetHosts test` reached it once.
140
140
 
141
141
  A procedure the API marks destructive (`x-destructive`: delete, publish, rotate, toggle and the
@@ -200,7 +200,7 @@ openapi: [
200
200
  {
201
201
  name: "shop",
202
202
  url: "https://shop.example.com/openapi.json",
203
- credentials: [{ header: "Authorization", prefix: "Bearer ", credential: null }],
203
+ headers: [{ header: "Authorization", prefix: "Bearer ", from: { kind: "runToken" } }],
204
204
  // For an API that wraps every answer: where each one keeps its result.
205
205
  // resultPath: "/data",
206
206
  pinned: ["findOrder"],
@@ -211,10 +211,11 @@ openapi: [
211
211
  ],
212
212
  ```
213
213
 
214
- Every apply reads the document first, without the source's credentials. An
214
+ Every apply reads the document first, without the source's headers. An
215
215
  operation it adds is created, a changed one updated, and a dropped one
216
- deleted. A source with credentials is read only over https, or from a host
217
- the deployment allows, since the document says where they go. A source needs the `tools` write permission even when nothing
216
+ deleted. A source whose headers carry a credential or the run token is read
217
+ only over https, or from a host the deployment allows, since the document
218
+ says where they go. A source needs the `tools` write permission even when nothing
218
219
  changes. Each tool is named after its `operationId`, made an identifier of at
219
220
  most 64 characters; an operation Cortex cannot import is left
220
221
  out. Two operations that come out with one name are told apart by a number
@@ -240,7 +241,7 @@ question, and is required with no terminal to ask on. `--dry-run` never asks.
240
241
  References between entries (mentions, service scopes, which role a
241
242
  credential or secret plays) are judged on the state the apply leaves, so
242
243
  the order the manifest lists them in does not matter. A dataset host's
243
- credentials are the exception: its catalog is read with them before the
244
+ headers are the exception: its catalog is read with them before the
244
245
  apply, so they are judged on the deployment as it stands. A credential a
245
246
  trigger fires with cannot go to a host in the same apply that drops
246
247
  it; drop it first, then apply again.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Scaffold Cortex projects and manage a Cortex deployment",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,7 +12,7 @@ import {
12
12
  normalizeHost,
13
13
  scrubCredentialStrings,
14
14
  } from "@cortex/contracts/egress-policy";
15
- import { MODEL_PROVIDER_LIMITS, NAME_MAX_LENGTH } from "@cortex/contracts/manifest";
15
+ import { MODEL_PROVIDER_LIMITS, NAME_MAX_LENGTH, SETTING_BOUNDS } from "@cortex/contracts/manifest";
16
16
  import { z } from "zod";
17
17
 
18
18
  import {
@@ -91,6 +91,8 @@ const serverSchema = namedSchema.extend({
91
91
  modelProvider: z.string().nullable(),
92
92
  modelName: z.string().nullable(),
93
93
  modelEffort: z.string().nullable(),
94
+ signInChecksPerMinute: z.number(),
95
+ triggerChecksPerMinute: z.number(),
94
96
  });
95
97
  const chatSchema = z.object({ choices: z.array(z.object({ message: z.object({}) })).min(1) });
96
98
  const embeddingsSchema = z.object({
@@ -388,6 +390,8 @@ async function ensureServer(
388
390
  url,
389
391
  keySecretName,
390
392
  ...model,
393
+ signInChecksPerMinute: SETTING_BOUNDS.server.signInChecksPerMinute.default,
394
+ triggerChecksPerMinute: SETTING_BOUNDS.server.triggerChecksPerMinute.default,
391
395
  }),
392
396
  );
393
397
  report.input("Server", `${name} · created`);
@@ -413,6 +417,8 @@ async function ensureServer(
413
417
  modelName: server.modelName,
414
418
  modelEffort: server.modelEffort,
415
419
  }),
420
+ signInChecksPerMinute: server.signInChecksPerMinute,
421
+ triggerChecksPerMinute: server.triggerChecksPerMinute,
416
422
  }),
417
423
  );
418
424
  report.input("Server", `${name} · updated`);
@@ -70,13 +70,6 @@ export const SETTING_BOUNDS = {
70
70
  tool: {
71
71
  timeoutMs: { min: 100, max: 120_000, default: 30_000 },
72
72
  maxResponseBytes: { min: 1_024, max: 1_048_576, default: 262_144 },
73
- rateLimitRequests: { min: 1, max: 10_000, default: 60 },
74
- rateLimitWindowSeconds: { min: 1, max: 86_400, default: 60 },
75
- },
76
- agent: {
77
- rateLimitRequests: { min: 1, max: 10_000, default: 60 },
78
- rateLimitWindowSeconds: { min: 1, max: 86_400, default: 60 },
79
- dailyExecutionQuota: { min: 1, max: 1_000_000, default: 10_000 },
80
73
  },
81
74
  /** How much of the conversation the agent's model sees. */
82
75
  agentContext: {
@@ -95,6 +88,18 @@ export const SETTING_BOUNDS = {
95
88
  trigger: { disableAfterFailures: { min: 1, max: 1_000, default: 5 } },
96
89
  datasetHost: { timeoutMs: { min: 100, max: 120_000, default: 30_000 } },
97
90
  webhook: { timeoutMs: { min: 1_000, max: 30_000, default: 10_000 } },
91
+ /** The score, a fraction, a new visitor of an anonymous agent needs to get an id. */
92
+ recaptcha: { minScore: { min: 0.1, max: 0.9, default: 0.5 } },
93
+ /**
94
+ * How often Cortex checks a caller for the server, per minute: a sign-in
95
+ * (a new end-user token it asks the server to verify, or a visitor one
96
+ * of its anonymous agents gives an id), or a trigger's secret. Anyone
97
+ * can try either, so each bounds what strangers cost it.
98
+ */
99
+ server: {
100
+ signInChecksPerMinute: { min: 1, max: 100_000, default: 600 },
101
+ triggerChecksPerMinute: { min: 1, max: 10_000, default: 60 },
102
+ },
98
103
  } as const satisfies Record<string, Record<string, Bounds>>;
99
104
 
100
105
  /** An integer within `bounds`, their default when left out. */
@@ -112,14 +117,68 @@ export function settingDefaults<TBounds extends Record<string, Bounds>>(bounds:
112
117
  ) as SettingValues<TBounds>;
113
118
  }
114
119
 
120
+ type RateLimitBounds = { max: number; allUsers: number | null; perUser: number | null };
121
+
122
+ /**
123
+ * Every rate limit an author sets, in the console and in a manifest alike,
124
+ * per minute except an agent's tokens, per UTC day. Each holds for all of an
125
+ * entry's users together and for each user alone, from 1 to `max`, or not
126
+ * at all (null); a manifest that leaves one out gets the default.
127
+ */
128
+ export const RATE_LIMITS = {
129
+ agent: {
130
+ messagesPerMinute: { max: 10_000, allUsers: 600, perUser: 20 },
131
+ tokensPerDay: { max: 1_000_000_000_000, allUsers: null, perUser: 1_000_000 },
132
+ },
133
+ tool: { callsPerMinute: { max: 10_000, allUsers: 60, perUser: 20 } },
134
+ datasetHost: { queriesPerMinute: { max: 10_000, allUsers: 60, perUser: 20 } },
135
+ } as const satisfies Record<string, Record<string, RateLimitBounds>>;
136
+
137
+ /** Whom a rate limit holds for: all of an entry's users together, or each user alone. */
138
+ export const RATE_LIMIT_USERS = ["allUsers", "perUser"] as const;
139
+ export type RateLimitUsers = (typeof RATE_LIMIT_USERS)[number];
140
+
141
+ /** A group of `RATE_LIMITS`, one value per limit for all users and for each user. */
142
+ export function mapRateLimits<TBounds extends Record<string, RateLimitBounds>, TValue>(
143
+ bounds: TBounds,
144
+ value: (bound: RateLimitBounds, users: RateLimitUsers) => TValue,
145
+ ) {
146
+ return Object.fromEntries(
147
+ RATE_LIMIT_USERS.map((users) => [
148
+ users,
149
+ Object.fromEntries(
150
+ Object.entries(bounds).map(([name, bound]) => [name, value(bound, users)]),
151
+ ),
152
+ ]),
153
+ ) as Record<RateLimitUsers, { [Name in keyof TBounds]: TValue }>;
154
+ }
155
+
156
+ /** An entry's rate limits as its row holds them; null lifts one. */
157
+ export type RateLimits<TBounds> = Record<
158
+ RateLimitUsers,
159
+ { [Name in keyof TBounds]: number | null }
160
+ >;
161
+
162
+ /** Every limit of a group at its default, as a row that sets none stores them. */
163
+ export function rateLimitDefaults<TBounds extends Record<string, RateLimitBounds>>(
164
+ bounds: TBounds,
165
+ ) {
166
+ return mapRateLimits(bounds, (bound, users) => bound[users]) satisfies RateLimits<TBounds>;
167
+ }
168
+
169
+ /** One rate limit as a manifest writes it: null lifts it, and left out it takes its default. */
170
+ function rateLimit(bound: RateLimitBounds, users: RateLimitUsers) {
171
+ return z.number().int().min(1).max(bound.max).nullable().default(bound[users]);
172
+ }
173
+
115
174
  /**
116
175
  * The fields of an entry users may change in Cortex after an apply, each
117
176
  * once, from the ones `fields` allows; none when left out. Every other field
118
177
  * is the manifest's alone. An apply keeps a live change to a listed field
119
178
  * unless the manifest changed that field too (a conflict, see
120
179
  * `manifestApplyInputSchema`). Identity, what is fixed at creation, the
121
- * audience, credentials, and whatever an apply checks against the
122
- * manifest's own value are never offered.
180
+ * audience, and whatever an apply checks against the manifest's own value
181
+ * are never offered.
123
182
  */
124
183
  function overridable<const TField extends string>(fields: readonly [TField, ...TField[]]) {
125
184
  return z
@@ -359,19 +418,25 @@ export function confinesEndpointParams(url: string) {
359
418
  const upstreamUrlSchema = z.url({ protocol: /^https?$/ });
360
419
 
361
420
  /**
362
- * What a call presents: a header name and the prefix before the value in
363
- * characters, and how many headers at most (a real upstream wants one or
364
- * two). The console caps its credential rows at the same.
421
+ * The headers a call carries: a header name and the prefix before the value
422
+ * in characters, and how many headers at most. The console caps its header
423
+ * rows at the same.
365
424
  */
366
- export const CREDENTIAL_LIMITS = { headerLength: 120, prefixLength: 40, presentations: 8 } as const;
425
+ export const HEADER_LIMITS = { headerLength: 120, prefixLength: 40, headers: 8 } as const;
367
426
 
368
427
  /** An HTTP header field name (RFC 9110 token). */
369
428
  export const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
429
+
430
+ /**
431
+ * A header value fetch sends: visible ASCII, tab, space and obs-text.
432
+ * Control characters, and any character past one byte, make fetch throw.
433
+ */
434
+ export const HEADER_VALUE_PATTERN = /^[\t\x20-\x7e\x80-\xff]*$/;
370
435
  const headerNameSchema = z
371
436
  .string()
372
437
  .trim()
373
438
  .min(1)
374
- .max(CREDENTIAL_LIMITS.headerLength)
439
+ .max(HEADER_LIMITS.headerLength)
375
440
  .regex(HEADER_NAME_PATTERN);
376
441
 
377
442
  /** Headers Cortex or fetch fills on every request. Lower-cased. */
@@ -388,20 +453,20 @@ export const transportForbiddenHeaders = [
388
453
  "proxy-authorization",
389
454
  ] as const;
390
455
 
391
- /** Headers the MCP transport sets on its own requests: no MCP server's credential travels under one. Lower-cased. */
456
+ /** Headers the MCP transport sets on its own requests: no header an MCP server's entry declares travels under one. Lower-cased. */
392
457
  export const mcpTransportHeaders = new Set<string>([
393
458
  "mcp-session-id",
394
459
  "mcp-protocol-version",
395
460
  "last-event-id",
396
461
  ]);
397
462
 
398
- /** Headers no credential or field may travel under; test lower-cased names. */
463
+ /** Headers no declared header or field may travel under; test lower-cased names. */
399
464
  export const transportOwnedHeaders = new Set<string>([
400
465
  ...transportSetHeaders,
401
466
  ...transportForbiddenHeaders,
402
467
  ]);
403
468
 
404
- /** The index of each entry presenting a header an earlier entry already presents, compared case-insensitively. */
469
+ /** The index of each entry declaring a header an earlier entry already declares, compared case-insensitively. */
405
470
  function repeatedHeaders(entries: ReadonlyArray<{ header: string }>) {
406
471
  const seen = new Set<string>();
407
472
  return entries.flatMap((entry, index) => {
@@ -423,18 +488,18 @@ const presentedHeaderSchema = headerNameSchema.refine(
423
488
  );
424
489
 
425
490
  /**
426
- * What every call carries: each entry a header, what precedes the value in
427
- * it (`"Bearer "`, say), and the credential by name, as `entry` validates
428
- * it, at most `CREDENTIAL_LIMITS.presentations` of them. A header is
429
- * presented once; `repeated` words the refusal of a later one.
491
+ * The headers every call carries: each entry a header, what precedes the
492
+ * value in it (`"Bearer "`, say), and where the value comes from, as `entry`
493
+ * validates it, at most `HEADER_LIMITS.headers` of them. A header is
494
+ * declared once; `repeated` words the refusal of a later one.
430
495
  */
431
- export function presentationsSchema<TEntry extends z.ZodType<{ header: string }>>(
496
+ export function headerListSchema<TEntry extends z.ZodType<{ header: string }>>(
432
497
  entry: TEntry,
433
498
  repeated: () => string,
434
499
  ) {
435
500
  return z
436
501
  .array(entry)
437
- .max(CREDENTIAL_LIMITS.presentations)
502
+ .max(HEADER_LIMITS.headers)
438
503
  .superRefine((entries, context) => {
439
504
  for (const index of repeatedHeaders(entries)) {
440
505
  context.addIssue({
@@ -446,23 +511,71 @@ export function presentationsSchema<TEntry extends z.ZodType<{ header: string }>
446
511
  });
447
512
  }
448
513
 
449
- /** The presentations a manifest entry declares: no unknown key, an omitted prefix or list empty. */
450
- function declaredPresentationsSchema<TCredential extends string | null>(
514
+ /** A variable by name: one the agent's server declares, or a built-in (`timezone`, `locale`, `utcTime`). */
515
+ const variableSourceSchema = z.object({
516
+ kind: z.literal("variable"),
517
+ name: z.string().trim().min(1).max(64),
518
+ });
519
+
520
+ /** A credential Cortex holds or acquires, by name. */
521
+ const credentialSourceSchema = z.object({ kind: z.literal("credential"), name: nameSchema });
522
+
523
+ /** A value written as is. */
524
+ const constantHeaderSourceSchema = z.object({
525
+ kind: z.literal("constant"),
526
+ value: z.string().max(4_000),
527
+ });
528
+
529
+ /**
530
+ * Where a header's value comes from: a credential, the run's own token (the
531
+ * end user's in a chat, the trigger's in a firing), a variable, or a
532
+ * constant. A run token or a variable needs a run; a call without one (a
533
+ * webhook delivery, a catalog read) presents only credentials and constants.
534
+ */
535
+ export const headerSourceSchema = z.discriminatedUnion("kind", [
536
+ credentialSourceSchema,
537
+ z.object({ kind: z.literal("runToken") }),
538
+ variableSourceSchema,
539
+ constantHeaderSourceSchema,
540
+ ]);
541
+ export type HeaderSource = z.infer<typeof headerSourceSchema>;
542
+
543
+ /** The sources a call without a run can present. */
544
+ export const runlessHeaderSourceSchema = z.discriminatedUnion("kind", [
545
+ credentialSourceSchema,
546
+ constantHeaderSourceSchema,
547
+ ]);
548
+
549
+ /**
550
+ * The headers a manifest entry declares: no unknown key, an omitted prefix
551
+ * or list empty, and a prefix or constant only of what a header can carry
552
+ * (`HEADER_VALUE_PATTERN`).
553
+ */
554
+ function declaredHeadersSchema<TSource extends HeaderSource>(
451
555
  header: z.ZodString,
452
- credential: z.ZodType<TCredential, TCredential>,
556
+ from: z.ZodType<TSource, TSource>,
453
557
  ) {
454
- return presentationsSchema(
455
- z.strictObject({
456
- header,
457
- prefix: z.string().max(CREDENTIAL_LIMITS.prefixLength).default(""),
458
- credential,
459
- }),
460
- () => "a header presented twice",
558
+ return headerListSchema(
559
+ z
560
+ .strictObject({
561
+ header,
562
+ prefix: z
563
+ .string()
564
+ .max(HEADER_LIMITS.prefixLength)
565
+ .regex(HEADER_VALUE_PATTERN, "a prefix a header cannot carry")
566
+ .default(""),
567
+ from,
568
+ })
569
+ .refine(
570
+ (entry) =>
571
+ entry.from.kind !== "constant" || HEADER_VALUE_PATTERN.test(entry.from.value),
572
+ { path: ["from", "value"], message: "a value a header cannot carry" },
573
+ ),
574
+ () => "a header declared twice",
461
575
  ).default([]);
462
576
  }
463
577
 
464
- /** A credential, or `null` for the run's own token (the end user's in a chat, the trigger's in a firing). */
465
- const credentialsSchema = declaredPresentationsSchema(presentedHeaderSchema, nameSchema.nullable());
578
+ const headersSchema = declaredHeadersSchema(presentedHeaderSchema, headerSourceSchema);
466
579
 
467
580
  /**
468
581
  * Where Cortex fills a top-level scalar input field from, out of the model's
@@ -470,7 +583,7 @@ const credentialsSchema = declaredPresentationsSchema(presentedHeaderSchema, nam
470
583
  * a constant, or the end user's bearer.
471
584
  */
472
585
  export const fieldSourceSchema = z.discriminatedUnion("kind", [
473
- z.object({ kind: z.literal("variable"), name: z.string().trim().min(1).max(64) }),
586
+ variableSourceSchema,
474
587
  z.object({ kind: z.literal("secret"), name: nameSchema }),
475
588
  z.object({
476
589
  kind: z.literal("constant"),
@@ -806,6 +919,7 @@ const localizedTextSchema = z.record(
806
919
  );
807
920
 
808
921
  const { tool: toolBounds } = SETTING_BOUNDS;
922
+ const { tool: toolRateLimits, datasetHost: datasetHostRateLimits } = RATE_LIMITS;
809
923
 
810
924
  /** A `type` tool's input schema, its fields bound only as `TOOL_TYPES` lets the type: filled from a source, placed in the request. */
811
925
  function toolInputSchemaOf(type: (typeof TOOL_TYPE_NAMES)[number]) {
@@ -816,30 +930,47 @@ function toolInputSchemaOf(type: (typeof TOOL_TYPE_NAMES)[number]) {
816
930
  ]).default(EMPTY_OBJECT);
817
931
  }
818
932
 
819
- /** Shared by every tool type; a tool that calls nothing (`client`) has no endpoint, credentials, approval or bound fields. */
933
+ /** What a live write may change on a manifest's tool, and keep through the next apply. */
934
+ const TOOL_OVERRIDABLES = [
935
+ "description",
936
+ "timeoutMs",
937
+ "maxResponseBytes",
938
+ "rateLimits",
939
+ "pinned",
940
+ "tags",
941
+ ] as const;
942
+
943
+ /** The same for a tool that calls an endpoint, whose headers are its own. */
944
+ const CALLED_TOOL_OVERRIDABLES = [...TOOL_OVERRIDABLES, "headers"] as const;
945
+
946
+ /** Shared by every tool type; a tool that calls nothing (`client`) has no endpoint, headers, approval or bound fields. */
820
947
  const toolBaseSchema = z.strictObject({
821
948
  ...serverToolSchema.pick({ name: true, description: true }).shape,
822
949
  inputSchema: toolInputSchemaOf("client"),
823
950
  outputSchema: objectSchema.default(EMPTY_OBJECT),
824
951
  timeoutMs: setting(toolBounds.timeoutMs),
825
952
  maxResponseBytes: setting(toolBounds.maxResponseBytes),
826
- rateLimitRequests: setting(toolBounds.rateLimitRequests),
827
- rateLimitWindowSeconds: setting(toolBounds.rateLimitWindowSeconds),
953
+ rateLimits: z
954
+ .strictObject({
955
+ allUsers: z
956
+ .strictObject({
957
+ callsPerMinute: rateLimit(toolRateLimits.callsPerMinute, "allUsers"),
958
+ })
959
+ .prefault({}),
960
+ perUser: z
961
+ .strictObject({
962
+ callsPerMinute: rateLimit(toolRateLimits.callsPerMinute, "perUser"),
963
+ })
964
+ .prefault({}),
965
+ })
966
+ .prefault({}),
828
967
  /** Trims the upstream's answer to what the output schema declares. */
829
968
  projectOutput: z.boolean().default(false),
830
969
  /** Listed in every turn of every agent the tool reaches, whatever the question. */
831
970
  pinned: z.boolean().default(false),
832
971
  tags: toolTagsSchema.default([]),
833
972
  audience: audienceSchema,
834
- overridable: overridable([
835
- "description",
836
- "timeoutMs",
837
- "maxResponseBytes",
838
- "rateLimitRequests",
839
- "rateLimitWindowSeconds",
840
- "pinned",
841
- "tags",
842
- ]),
973
+ overridable: overridable(TOOL_OVERRIDABLES),
843
974
  });
844
975
 
845
976
  /** A tool that calls an endpoint; with `requiresApproval`, every call waits for the end user to approve it. */
@@ -848,8 +979,9 @@ const calledToolSchema = toolBaseSchema.extend({
848
979
  confinesEndpointParams,
849
980
  "a {parameter} outside the path or query",
850
981
  ),
851
- credentials: credentialsSchema,
982
+ headers: headersSchema,
852
983
  requiresApproval: z.boolean().default(false),
984
+ overridable: overridable(CALLED_TOOL_OVERRIDABLES),
853
985
  });
854
986
 
855
987
  const manifestToolSchema = z
@@ -891,16 +1023,16 @@ const toolNamesSchema = z.array(serverToolSchema.shape.name).max(500).default([]
891
1023
  * An OpenAPI document whose operations become HTTP tools this manifest owns.
892
1024
  * Each apply reads it, imports every operation Cortex supports, keeps those
893
1025
  * tools in step with the document and deletes the ones it no longer has.
894
- * Every tool presents `credentials`. A GET call runs at once; any other
1026
+ * Every tool carries the source's `headers`. A GET call runs at once; any other
895
1027
  * method waits for the end user's approval, unless `approval` names the
896
1028
  * tool the other way.
897
1029
  */
898
1030
  const manifestOpenApiSchema = z.strictObject({
899
1031
  /** Names the source in what an apply reports. */
900
1032
  name: nameSchema,
901
- /** Where the document is read, with no credentials: OpenAPI 3 or Swagger 2, JSON or YAML. */
1033
+ /** Where the document is read, with no headers: OpenAPI 3 or Swagger 2, JSON or YAML. */
902
1034
  url: upstreamUrlSchema,
903
- credentials: credentialsSchema,
1035
+ headers: headersSchema,
904
1036
  /** Where every operation's successful response keeps its result, when the API wraps each answer. */
905
1037
  resultPath: jsonPointerSchema.optional(),
906
1038
  /** Tools listed in every turn of every agent they reach, whatever the question. */
@@ -913,6 +1045,8 @@ const manifestOpenApiSchema = z.strictObject({
913
1045
  "a tool both never and always needs approval",
914
1046
  ),
915
1047
  audience: audienceSchema,
1048
+ /** What a live write may change on each of the source's tools, and keep through the next apply. */
1049
+ overridable: overridable(CALLED_TOOL_OVERRIDABLES),
916
1050
  });
917
1051
  export type ManifestOpenApi = z.input<typeof manifestOpenApiSchema>;
918
1052
 
@@ -1014,14 +1148,34 @@ const manifestDatasetHostSchema = z
1014
1148
  .strictObject({
1015
1149
  name: datasetIdentifierSchema,
1016
1150
  url: upstreamUrlSchema,
1017
- credentials: credentialsSchema,
1151
+ headers: headersSchema,
1018
1152
  timeoutMs: setting(SETTING_BOUNDS.datasetHost.timeoutMs),
1153
+ rateLimits: z
1154
+ .strictObject({
1155
+ allUsers: z
1156
+ .strictObject({
1157
+ queriesPerMinute: rateLimit(
1158
+ datasetHostRateLimits.queriesPerMinute,
1159
+ "allUsers",
1160
+ ),
1161
+ })
1162
+ .prefault({}),
1163
+ perUser: z
1164
+ .strictObject({
1165
+ queriesPerMinute: rateLimit(
1166
+ datasetHostRateLimits.queriesPerMinute,
1167
+ "perUser",
1168
+ ),
1169
+ })
1170
+ .prefault({}),
1171
+ })
1172
+ .prefault({}),
1019
1173
  /** The datasets the host serves that agents may query. */
1020
1174
  expose: z.array(datasetIdentifierSchema).min(1).max(500),
1021
1175
  /** Among those, the ones listed in every turn, whatever the question. */
1022
1176
  pinned: z.array(datasetIdentifierSchema).max(50).default([]),
1023
1177
  audience: audienceSchema,
1024
- overridable: overridable(["timeoutMs"]),
1178
+ overridable: overridable(["timeoutMs", "rateLimits", "headers"]),
1025
1179
  })
1026
1180
  .superRefine((host, context) => {
1027
1181
  const unexposed = unexposedPins(host);
@@ -1051,41 +1205,41 @@ const manifestMcpServerSchema = z.strictObject({
1051
1205
  /** Prefixes every tool the server contributes: scripts call `tools.<name>_<tool>()`. */
1052
1206
  name: identifierSchema,
1053
1207
  url: upstreamUrlSchema,
1054
- credentials: declaredPresentationsSchema(
1208
+ headers: declaredHeadersSchema(
1055
1209
  presentedHeaderSchema.refine(
1056
1210
  (name) => !mcpTransportHeaders.has(name.toLowerCase()),
1057
1211
  "a header the MCP transport sets",
1058
1212
  ),
1059
- nameSchema.nullable(),
1213
+ headerSourceSchema,
1060
1214
  ),
1061
1215
  /** The server's own tool names to offer; empty offers every tool. */
1062
1216
  include: mcpToolNamesSchema.default([]),
1063
1217
  exclude: mcpToolNamesSchema.default([]),
1064
1218
  audience: audienceSchema,
1065
- overridable: overridable(["include", "exclude"]),
1219
+ overridable: overridable(["include", "exclude", "headers"]),
1066
1220
  });
1067
1221
  export type ManifestMcpServer = z.input<typeof manifestMcpServerSchema>;
1068
1222
 
1069
1223
  /**
1070
1224
  * Delivers its event to a receiver while it is declared. A delivery carries
1071
- * no run, so every credential it presents is named, and none under the
1072
- * header that names the event.
1225
+ * no run, so its headers come from credentials and constants, and none
1226
+ * travels under the header that names the event.
1073
1227
  */
1074
1228
  const manifestWebhookSchema = z.strictObject({
1075
1229
  name: slugSchema,
1076
1230
  description: z.string().trim().max(500).default(""),
1077
1231
  event: z.enum(SERVER_EVENTS),
1078
1232
  url: upstreamUrlSchema,
1079
- credentials: declaredPresentationsSchema(
1233
+ headers: declaredHeadersSchema(
1080
1234
  presentedHeaderSchema.refine(
1081
1235
  (name) => name.toLowerCase() !== EVENT_HEADER.toLowerCase(),
1082
1236
  "the header that names the event",
1083
1237
  ),
1084
- nameSchema,
1238
+ runlessHeaderSourceSchema,
1085
1239
  ),
1086
1240
  timeoutMs: setting(SETTING_BOUNDS.webhook.timeoutMs),
1087
1241
  audience: audienceSchema,
1088
- overridable: overridable(["description", "timeoutMs"]),
1242
+ overridable: overridable(["description", "timeoutMs", "headers"]),
1089
1243
  });
1090
1244
  export type ManifestWebhook = z.input<typeof manifestWebhookSchema>;
1091
1245
 
@@ -1131,11 +1285,32 @@ export const agentAuthSchema = z.discriminatedUnion("kind", [
1131
1285
  issuer: issuerSchema,
1132
1286
  audience: tokenAudienceSchema,
1133
1287
  }),
1134
- z.strictObject({ kind: z.literal("anonymous") }),
1288
+ /**
1289
+ * Each visitor gets an id of their own from Cortex, kept in their
1290
+ * browser. With `recaptcha`, reCAPTCHA Enterprise scores each new
1291
+ * visitor first, and one below `minScore` gets none.
1292
+ */
1293
+ z.strictObject({
1294
+ kind: z.literal("anonymous"),
1295
+ recaptcha: z
1296
+ .strictObject({
1297
+ siteKey: z.string().trim().min(1).max(200),
1298
+ projectId: z.string().trim().min(1).max(200),
1299
+ /** The secret holding a Google Cloud API key allowed to create assessments. */
1300
+ secret: nameSchema,
1301
+ minScore: z
1302
+ .number()
1303
+ .min(SETTING_BOUNDS.recaptcha.minScore.min)
1304
+ .max(SETTING_BOUNDS.recaptcha.minScore.max)
1305
+ .default(SETTING_BOUNDS.recaptcha.minScore.default),
1306
+ })
1307
+ .optional(),
1308
+ }),
1135
1309
  z.strictObject({ kind: z.literal("server") }),
1136
1310
  ]);
1137
1311
 
1138
- const { agent: agentBounds, agentContext, agentLimits } = SETTING_BOUNDS;
1312
+ const { agentContext, agentLimits } = SETTING_BOUNDS;
1313
+ const { agent: agentRateLimits } = RATE_LIMITS;
1139
1314
 
1140
1315
  /**
1141
1316
  * How the agent runs; every setting left out keeps its default. The
@@ -1160,9 +1335,22 @@ export const agentSettingsSchema = z.strictObject({
1160
1335
  /** Off, end users cannot message the agent: only its triggers reach it. */
1161
1336
  messageTrigger: z.boolean().default(true),
1162
1337
  defaultLocale: localeSchema.default(BASE_LOCALE),
1163
- rateLimitRequests: setting(agentBounds.rateLimitRequests),
1164
- rateLimitWindowSeconds: setting(agentBounds.rateLimitWindowSeconds),
1165
- dailyExecutionQuota: setting(agentBounds.dailyExecutionQuota),
1338
+ rateLimits: z
1339
+ .strictObject({
1340
+ allUsers: z
1341
+ .strictObject({
1342
+ messagesPerMinute: rateLimit(agentRateLimits.messagesPerMinute, "allUsers"),
1343
+ tokensPerDay: rateLimit(agentRateLimits.tokensPerDay, "allUsers"),
1344
+ })
1345
+ .prefault({}),
1346
+ perUser: z
1347
+ .strictObject({
1348
+ messagesPerMinute: rateLimit(agentRateLimits.messagesPerMinute, "perUser"),
1349
+ tokensPerDay: rateLimit(agentRateLimits.tokensPerDay, "perUser"),
1350
+ })
1351
+ .prefault({}),
1352
+ })
1353
+ .prefault({}),
1166
1354
  context: z
1167
1355
  .strictObject({
1168
1356
  maxContextTokens: setting(agentContext.maxContextTokens),
@@ -85,8 +85,31 @@ export type AttachmentSummary = {
85
85
  description: string | null;
86
86
  };
87
87
 
88
- /** The user id every caller shares on an agent that verifies no end user. */
89
- export const ANONYMOUS_USER_ID = "00000000-0000-0000-0000-000000000000";
88
+ /**
89
+ * How an anonymous agent's visitors' user ids begin: Cortex issues each
90
+ * visitor an id of its own, which their browser keeps. No issuer ever mints
91
+ * a subject shaped like this.
92
+ */
93
+ export const VISITOR_USER_PREFIX = "visitor:";
94
+
95
+ /** The reCAPTCHA action a visitor's sign-in runs, which Cortex checks the token was made for. */
96
+ export const VISITOR_RECAPTCHA_ACTION = "cortex_visitor";
97
+
98
+ /** `POST /visitors`: a visitor's sign-in to an anonymous agent, with a reCAPTCHA token when the agent checks visitors. */
99
+ export type VisitorRequest = { recaptcha?: string };
100
+
101
+ /** A visitor's token, the bearer of every request after the sign-in. */
102
+ export type VisitorToken = { token: string };
103
+
104
+ /**
105
+ * Why a sign-in got no token: the agent checks visitors with reCAPTCHA and
106
+ * the request carried no token, or one that failed. `siteKey` is the key the
107
+ * widget runs reCAPTCHA with.
108
+ */
109
+ export type VisitorRefusal = {
110
+ error: "recaptcha_required" | "recaptcha_failed";
111
+ siteKey: string;
112
+ };
90
113
 
91
114
  export type ThreadSummary = {
92
115
  id: string;
@@ -139,8 +162,6 @@ export type ClientToolBinding = {
139
162
  toolId: string;
140
163
  surface: ToolSurface;
141
164
  embedOrigin: string;
142
- /** Variables the initiate call's input reads. */
143
- variables: string[];
144
165
  };
145
166
 
146
167
  /**
@@ -29,7 +29,7 @@ import { applyPorts, type Ports } from "@/scaffold/ports";
29
29
  * `@m6d/cortex` range below are stamped by scripts/sync-package-versions.ts
30
30
  * before the CLI publishes, so a scaffold pins what was current then.
31
31
  */
32
- const CORTEX_VERSION = "0.16.0";
32
+ const CORTEX_VERSION = "0.17.0";
33
33
 
34
34
  /** This machine as Cortex's containers name it: the one private host the stack's egress policy admits. */
35
35
  export const DOCKER_HOST = "host.docker.internal";
@@ -150,7 +150,7 @@ function packageJson({ name, datasets, setup }: Project) {
150
150
  verify: "bun run format:check && bun run lint && bun run spellcheck && bun run check && bun run audit",
151
151
  },
152
152
  dependencies: {
153
- "@m6d/cortex": "~0.4.0",
153
+ "@m6d/cortex": "~0.5.0",
154
154
  // drizzle 1.0 is in beta; this is the build `@m6d/cortex/drizzle` is tested against.
155
155
  ...(datasets ? { "drizzle-orm": "1.0.0-beta.15-859cf75" } : {}),
156
156
  hono: "^4.12.3",
@@ -198,7 +198,7 @@ if (!server) throw new Error("Apply from this project: its .env names the server
198
198
  {
199
199
  name: "store",
200
200
  url: \`\${serverUrl}/datasets\`,
201
- credentials: [],
201
+ headers: [],
202
202
  expose: ["orders"],
203
203
  pinned: ["orders"],
204
204
  audience: { server },
@@ -223,7 +223,7 @@ export default defineManifest({
223
223
  name: "Helper",
224
224
  server,
225
225
  prompt: "You are a helpful assistant. Address the user as {{userName}}.",
226
- // No identity provider yet: every caller is one shared identity.
226
+ // No identity provider yet: each browser is a visitor of its own.
227
227
  auth: { kind: "anonymous" },
228
228
  },
229
229
  ],${datasets ? datasetHosts : ""}