@m6d/cortex-cli 0.12.1 → 0.13.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.
Files changed (74) hide show
  1. package/README.md +135 -130
  2. package/package.json +16 -5
  3. package/src/cli.ts +17 -12
  4. package/src/commands/apply.ts +169 -0
  5. package/src/commands/{cc.ts → management.ts} +150 -100
  6. package/src/commands/new.ts +50 -74
  7. package/src/contracts/charts/chart-spec.ts +1 -1
  8. package/src/contracts/client-tools/index.ts +3 -3
  9. package/src/contracts/{runtime → datasets}/datasets.ts +7 -42
  10. package/src/contracts/datasets/index.ts +3 -0
  11. package/src/contracts/egress-policy/index.ts +16 -0
  12. package/src/contracts/{egress → egress-policy}/scrub.ts +34 -5
  13. package/src/contracts/{egress → egress-policy}/url.ts +22 -9
  14. package/src/contracts/errors/index.ts +38 -0
  15. package/src/contracts/json/index.ts +5 -0
  16. package/src/contracts/manifest/index.ts +932 -0
  17. package/src/contracts/manifest-file/index.ts +16 -0
  18. package/src/contracts/markdown/index.ts +1 -1
  19. package/src/contracts/pattern/index.ts +276 -0
  20. package/src/contracts/server/index.ts +247 -0
  21. package/src/contracts/server/reserved-names.fixtures.json +18 -0
  22. package/src/contracts/triggers/index.ts +112 -0
  23. package/src/contracts/wire/index.ts +61 -32
  24. package/src/{cc → management}/client.ts +19 -7
  25. package/src/{cc → management}/flags.ts +2 -1
  26. package/src/{cc → management}/profiles.ts +5 -5
  27. package/src/scaffold/files.ts +362 -237
  28. package/src/scaffold/ports.ts +13 -24
  29. package/src/scaffold/templates/datasets.ts.txt +52 -0
  30. package/src/scaffold/templates/dockerignore.txt +7 -0
  31. package/src/scaffold/templates/fallowrc.jsonc.txt +8 -12
  32. package/src/scaffold/templates/gitignore.txt +2 -1
  33. package/src/scaffold/templates/server.ts.txt +40 -0
  34. package/src/scaffold/templates/setup.ts.txt +52 -0
  35. package/src/ui/prompts.ts +4 -31
  36. package/src/ui/report.ts +5 -15
  37. package/tsconfig.json +2 -3
  38. package/src/commands/graph.ts +0 -228
  39. package/src/commands/swagger.ts +0 -247
  40. package/src/config/load.ts +0 -122
  41. package/src/config/validate.ts +0 -46
  42. package/src/contracts/credentials/index.ts +0 -183
  43. package/src/contracts/egress/index.ts +0 -120
  44. package/src/contracts/graph/clients/embed.ts +0 -62
  45. package/src/contracts/graph/clients/neo4j.ts +0 -85
  46. package/src/contracts/graph/helpers.ts +0 -55
  47. package/src/contracts/graph/index.ts +0 -30
  48. package/src/contracts/graph/schema.ts +0 -65
  49. package/src/contracts/graph/types.ts +0 -125
  50. package/src/contracts/rich-text/index.ts +0 -220
  51. package/src/contracts/runtime/api.ts +0 -345
  52. package/src/contracts/runtime/auth.ts +0 -41
  53. package/src/contracts/runtime/grammar.ts +0 -169
  54. package/src/contracts/runtime/index.ts +0 -15
  55. package/src/contracts/runtime/models.ts +0 -50
  56. package/src/contracts/runtime/signature.ts +0 -255
  57. package/src/contracts/runtime/sync.ts +0 -196
  58. package/src/contracts/runtime/triggers.ts +0 -267
  59. package/src/graph/expand-domains.ts +0 -286
  60. package/src/graph/generate-cypher.ts +0 -201
  61. package/src/graph/seed.ts +0 -199
  62. package/src/graph/validate.ts +0 -78
  63. package/src/scaffold/features.ts +0 -190
  64. package/src/scaffold/templates/domains-README.md.txt +0 -16
  65. package/src/scaffold/templates/index.ts.txt +0 -18
  66. package/src/swagger/extract-endpoints.ts +0 -269
  67. package/src/swagger/generated-block.ts +0 -74
  68. package/src/swagger/openapi-schema.ts +0 -168
  69. package/src/swagger/parse-response.ts +0 -88
  70. package/src/swagger/types.ts +0 -25
  71. /package/src/contracts/{runtime → datasets}/dataset-sql-guide.ts +0 -0
  72. /package/src/contracts/{runtime → datasets}/dataset-sql.ts +0 -0
  73. /package/src/contracts/{runtime → datasets}/datasets.fixtures.json +0 -0
  74. /package/src/{cc → management}/output.ts +0 -0
@@ -2,13 +2,13 @@ import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
 
5
- import { Option, type Command } from "@commander-js/extra-typings";
5
+ import type { Command } from "@commander-js/extra-typings";
6
+ import { CORTEX_MANIFEST_ID } from "@cortex/contracts/manifest";
6
7
 
7
8
  import pkg from "../../package.json";
8
- import { FEATURE_NAMES, FEATURES, type Feature } from "@/scaffold/features";
9
- import { scaffoldFiles, type Database } from "@/scaffold/files";
10
- import { allocatePorts } from "@/scaffold/ports";
11
- import { askDatabase, askFeatures, askName, intro, log, outro, runSteps } from "@/ui/prompts";
9
+ import { manifestIdOf, scaffoldFiles } from "@/scaffold/files";
10
+ import { allocatePorts, type Ports } from "@/scaffold/ports";
11
+ import { askConfirm, askName, intro, log, outro, runSteps } from "@/ui/prompts";
12
12
  import * as report from "@/ui/report";
13
13
 
14
14
  /** npm's rule for an unscoped package name. The name is the directory too. */
@@ -18,55 +18,31 @@ const MAX_NAME = 214;
18
18
  /** The two names npm rejects outright, however well-formed they otherwise are. */
19
19
  const RESERVED_NAMES = ["node_modules", "favicon.ico"];
20
20
 
21
- export function registerNew(program: Command) {
21
+ export function registerNew(program: Command<[], { verbose?: true }>) {
22
22
  const command = program
23
23
  .command("new")
24
24
  .helpGroup("Project:")
25
- .description("Scaffold a new Cortex server")
25
+ .description("Scaffold a backend, its agent, and Cortex to run it on")
26
26
  .argument("[name]", "Directory to create, and the generated package name")
27
- .addOption(
28
- new Option("--database <type>", "Database the project is configured for")
29
- .choices(["postgres", "mssql"] as const)
30
- .default("postgres" as const),
31
- )
32
- // Both halves of every toggle, so a flag can answer its question either
27
+ // Both halves of the toggle, so the flag can answer its question either
33
28
  // way and `--yes` is not the only way to say "no". Declaring the positive
34
29
  // first leaves the value `undefined` when neither is passed, which is
35
30
  // what marks the question as still unanswered.
36
- .option("--attachments", FEATURES.attachments.hint)
37
- .option("--no-attachments", "Leave attachments out")
38
- .option("--redis", FEATURES.redis.hint)
39
- .option("--no-redis", "Leave Redis out")
40
- .option("--auth", FEATURES.auth.hint)
41
- .option("--no-auth", "Leave auth out")
42
- .option("--knowledge", FEATURES.knowledge.hint)
43
- .option("--no-knowledge", "Leave the knowledge graph out")
44
- .option("--control-center", FEATURES.controlCenter.hint)
45
- .option("--no-control-center", "Leave the Control Center out")
31
+ .option("--datasets", "Serve an example table as a dataset host")
32
+ .option("--no-datasets", "Leave the dataset host out")
46
33
  .option("--yes", "Accept the defaults instead of asking");
47
34
 
48
35
  command.action(async function (name, options) {
49
- // The globals live on the root command, which reaches this file untyped —
50
- // `Command` here is the parameter type, not cli.ts's inferred one.
51
- const { verbose = false } = program.opts() as { verbose?: boolean };
52
-
53
- await scaffold(name, {
54
- ...options,
55
- verbose,
56
- databasePassed: command.getOptionValueSource("database") !== "default",
57
- });
36
+ const { verbose = false } = program.opts();
37
+
38
+ await scaffold(name, { ...options, verbose });
58
39
  });
59
40
  }
60
41
 
61
- type Options = {
62
- database: Database;
63
- yes?: boolean;
64
- verbose: boolean;
65
- databasePassed: boolean;
66
- } & Partial<Record<Feature, boolean>>;
42
+ type Options = { datasets?: boolean; yes?: boolean; verbose: boolean };
67
43
 
68
44
  async function scaffold(name: string | undefined, options: Options) {
69
- const { verbose, databasePassed } = options;
45
+ const { verbose } = options;
70
46
 
71
47
  // No TTY ⇒ never prompt and behave as `--yes`. On Bun a piped stdin reports
72
48
  // `undefined` rather than `false`, which is why this compares against `true`.
@@ -77,6 +53,7 @@ async function scaffold(name: string | undefined, options: Options) {
77
53
  // A name that came from a flag is checked before anything prints; one that
78
54
  // is prompted for is checked by the prompt, which can ask again.
79
55
  if (name !== undefined && !isValidName(name)) failInvalidName(name);
56
+ if (name !== undefined && manifestIdOf(name) === CORTEX_MANIFEST_ID) failCortexId(name);
80
57
  if (name !== undefined && isTaken(resolve(process.cwd(), name))) failTaken(name);
81
58
 
82
59
  const started = Date.now();
@@ -84,15 +61,17 @@ async function scaffold(name: string | undefined, options: Options) {
84
61
 
85
62
  const project = name ?? (await askName(checkName)).trim();
86
63
 
87
- const database = interactive && !databasePassed ? await askDatabase() : options.database;
88
- const features = await chooseFeatures(options, interactive);
64
+ // A flag settles the question; otherwise it is asked, or under `--yes` left out.
65
+ const datasets =
66
+ options.datasets ??
67
+ (interactive && (await askConfirm("Serve an example table as a dataset host?")));
89
68
 
90
69
  const target = resolve(process.cwd(), project);
91
70
 
92
- // Allocated here rather than inside the generators, which stay pure: picking a
71
+ // Drawn here rather than inside the generators, which stay pure: picking a
93
72
  // port means probing the machine for a free one.
94
73
  const ports = allocatePorts();
95
- const files = scaffoldFiles(project, database, features, ports);
74
+ const files = scaffoldFiles({ name: project, datasets, ports });
96
75
  let failedInstall: string | undefined;
97
76
 
98
77
  await runSteps([
@@ -105,8 +84,13 @@ async function scaffold(name: string | undefined, options: Options) {
105
84
  writeFileSync(file, contents);
106
85
  }
107
86
 
108
- const summary = [database, ...features, "model"].join(" · ");
109
- return `Wrote ${Object.keys(files).length} files to ./${project}\n${summary}`;
87
+ const summary = [
88
+ "server",
89
+ ...(datasets ? ["dataset host"] : []),
90
+ "manifest",
91
+ "Cortex",
92
+ ];
93
+ return `Wrote ${Object.keys(files).length} files to ./${project}\n${summary.join(" · ")}`;
110
94
  },
111
95
  },
112
96
  {
@@ -152,7 +136,7 @@ async function scaffold(name: string | undefined, options: Options) {
152
136
  if (failedInstall !== undefined) warnInstall(project, failedInstall, verbose);
153
137
 
154
138
  outro(`Done in ${seconds(started)}`);
155
- process.stdout.write(nextSteps(project, features, files));
139
+ process.stdout.write(nextSteps(project, ports));
156
140
  }
157
141
 
158
142
  /**
@@ -172,41 +156,19 @@ function warnInstall(project: string, output: string, verbose: boolean) {
172
156
  );
173
157
  }
174
158
 
175
- function nextSteps(project: string, features: Feature[], files: ReturnType<typeof scaffoldFiles>) {
176
- const keys = [
177
- "CORTEX_DATABASE_URL",
178
- "CORTEX_MODEL_*",
179
- ...features.map((feature) => FEATURES[feature].secrets).filter((key) => key !== undefined),
180
- ];
181
-
159
+ function nextSteps(project: string, ports: Ports) {
182
160
  return (
183
161
  `\nNext steps:\n\n` +
184
162
  step(`cd ${project}`) +
185
- step("edit .env", `fill in ${keys.length === 2 ? keys.join(" and ") : keys.join(", ")}`) +
186
- (files["docker-compose.yml"] === undefined ? "" : step("docker compose up -d")) +
187
- // The `port` the generated cortex.config.ts sets, which is the server's
188
- // own default. Service ports move at scaffold time; this one does not.
163
+ step("edit cortex.env", "fill in EMBEDDING_API_KEY") +
164
+ step("docker compose up -d", `→ http://localhost:${ports.cortex}`) +
165
+ // The port the generated index.ts listens on; only Cortex's ports move.
189
166
  step("bun run dev", "→ http://localhost:3331") +
167
+ step("README.md", "register the server, then cortex apply") +
190
168
  `\n`
191
169
  );
192
170
  }
193
171
 
194
- /**
195
- * One multi-select for the whole catalogue, minus whatever the flags already
196
- * settled: a passed flag suppresses its question, the rest are still asked.
197
- */
198
- async function chooseFeatures(options: Options, interactive: boolean) {
199
- const chosen = new Set(FEATURE_NAMES.filter((feature) => options[feature] === true));
200
- const undecided = FEATURE_NAMES.filter((feature) => options[feature] === undefined);
201
-
202
- if (interactive && undecided.length > 0) {
203
- for (const feature of await askFeatures(undecided)) chosen.add(feature);
204
- }
205
-
206
- // Catalogue order, not the order the flags or the checkboxes came in.
207
- return FEATURE_NAMES.filter((feature) => chosen.has(feature));
208
- }
209
-
210
172
  async function install(cwd: string) {
211
173
  try {
212
174
  // Piped rather than inherited: the spinner owns the screen while this runs.
@@ -245,11 +207,14 @@ function isValidName(name: string) {
245
207
  return name.length <= MAX_NAME && VALID_NAME.test(name) && !RESERVED_NAMES.includes(name);
246
208
  }
247
209
 
248
- /** The prompt's rules, which are the flag path's two failures asked one at a time. */
210
+ /** The prompt's rules, which are the flag path's failures asked one at a time. */
249
211
  function checkName(candidate: string) {
250
212
  // One line each: clack prints them inside the prompt, which will not wrap.
251
213
  if (RESERVED_NAMES.includes(candidate)) return `npm does not allow the name ${candidate}.`;
252
214
  if (!isValidName(candidate)) return "Lowercase letters, digits, and . _ - only.";
215
+ if (manifestIdOf(candidate) === CORTEX_MANIFEST_ID) {
216
+ return `The manifest would take Cortex's own id, ${CORTEX_MANIFEST_ID}.`;
217
+ }
253
218
  if (isTaken(resolve(process.cwd(), candidate))) {
254
219
  return `./${candidate} already exists and is not an empty directory.`;
255
220
  }
@@ -307,6 +272,17 @@ function missingName(): never {
307
272
  });
308
273
  }
309
274
 
275
+ /** The manifest takes its id from the name, and Cortex refuses any manifest under its own. */
276
+ function failCortexId(name: string): never {
277
+ report.fail({
278
+ what: `"${name}" would give the manifest Cortex's own id, ${CORTEX_MANIFEST_ID}`,
279
+ blocks: [
280
+ "cortex.manifest.ts takes its id from the project name, and Cortex keeps that\nid for its own manifest. Pick another name:",
281
+ ` cortex new ${CORTEX_MANIFEST_ID}-app`,
282
+ ],
283
+ });
284
+ }
285
+
310
286
  function failTaken(name: string): never {
311
287
  report.fail({
312
288
  what: `./${name} already exists and is not an empty directory.`,
@@ -1,6 +1,6 @@
1
1
  /*
2
2
  * The boundary on a Vega-Lite spec the model wrote, shared by the widget
3
- * (which draws it live) and the server (which describes it to the model):
3
+ * (which draws it live) and Cortex (which describes it to the model):
4
4
  * inline data only, no fetches, no interactivity, bounded work.
5
5
  */
6
6
 
@@ -43,9 +43,9 @@ export type ClientToolHandshake = ClientToolOutcome & {
43
43
  };
44
44
 
45
45
  /*
46
- * Built-in input tools: static client tools the server package exports and
47
- * the widget answers with a component of its own, keyed by tool name. A
48
- * consumer's `toolComponents` entry under the same name replaces the built-in.
46
+ * Built-in input tools: client tools Cortex declares and the widget answers
47
+ * with a component of its own, keyed by tool name. A consumer's
48
+ * `toolComponents` entry under the same name replaces the built-in.
49
49
  */
50
50
 
51
51
  export const REQUEST_LOCATION_TOOL = "requestLocation";
@@ -11,10 +11,15 @@ import { isRecord } from "../json";
11
11
  */
12
12
 
13
13
  export const DATASET_IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
14
+ /** The longest dataset or host name. */
15
+ export const DATASET_IDENTIFIER_MAX = 64;
14
16
  /** `column` on the dataset queried, or `relation.relation.column` along declared relations. */
15
17
  export const DATASET_COLUMN_REF_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)*$/;
16
18
 
17
- export const datasetIdentifierSchema = z.string().regex(DATASET_IDENTIFIER_PATTERN).max(64);
19
+ export const datasetIdentifierSchema = z
20
+ .string()
21
+ .regex(DATASET_IDENTIFIER_PATTERN)
22
+ .max(DATASET_IDENTIFIER_MAX);
18
23
  export const datasetColumnRefSchema = z.string().regex(DATASET_COLUMN_REF_PATTERN).max(256);
19
24
 
20
25
  export const DATASET_COLUMN_TYPES = ["string", "number", "boolean", "date"] as const;
@@ -355,46 +360,6 @@ export const datasetResultSchema = z.object({
355
360
  });
356
361
  export type DatasetResult = z.infer<typeof datasetResultSchema>;
357
362
 
358
- export const DATASET_ERROR_KINDS = [
359
- "invalid_query",
360
- "unsupported",
361
- "forbidden",
362
- "timeout",
363
- "internal",
364
- ] as const;
365
- export type DatasetErrorKind = (typeof DATASET_ERROR_KINDS)[number];
366
-
367
- /** The HTTP status each error kind travels under, both from a host and from the server's own refusals. */
368
- export const DATASET_ERROR_STATUS = {
369
- invalid_query: 400,
370
- unsupported: 400,
371
- forbidden: 403,
372
- timeout: 504,
373
- internal: 500,
374
- } as const satisfies Record<DatasetErrorKind, number>;
375
-
376
- export const datasetErrorSchema = z.object({
377
- error: z.object({
378
- kind: z.enum(DATASET_ERROR_KINDS),
379
- detail: z.string().max(512),
380
- }),
381
- });
382
- export type DatasetError = z.infer<typeof datasetErrorSchema>;
383
-
384
- /**
385
- * A dataset host as an agent's published configuration carries it: the
386
- * datasets the agent exposes, already the published manifest's definitions,
387
- * and which of them ride in every turn. The Control Center runs the queries
388
- * for a host it registered, so the server needs no URL or credential here.
389
- */
390
- export const datasetHostDeclarationSchema = z.object({
391
- name: datasetIdentifierSchema,
392
- manifestVersion: datasetManifestSchema.shape.version,
393
- datasets: z.array(datasetDefinitionSchema).max(500),
394
- pinned: z.array(datasetIdentifierSchema).max(50).default([]),
395
- });
396
- export type DatasetHostDeclaration = z.infer<typeof datasetHostDeclarationSchema>;
397
-
398
363
  /** The definitions a query may reach, by dataset name. */
399
364
  export type DatasetCatalog = ReadonlyMap<string, DatasetDefinition>;
400
365
 
@@ -659,7 +624,7 @@ export function boundDatasetResult(
659
624
  /**
660
625
  * Every way a query can be wrong given the catalog, as messages the model
661
626
  * can act on; an empty list means the host will understand it. Checked
662
- * before anything leaves the server, so a host can trust the shape and only
627
+ * before anything leaves Cortex, so a host can trust the shape and only
663
628
  * has to translate.
664
629
  */
665
630
  export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuery) {
@@ -0,0 +1,3 @@
1
+ export * from "./datasets";
2
+ export * from "./dataset-sql";
3
+ export * from "./dataset-sql-guide";
@@ -0,0 +1,16 @@
1
+ /*
2
+ * The egress policy as far as no socket is needed: which addresses are
3
+ * private, which hosts the deployment admits, which URLs a secret may travel
4
+ * to, and the redaction of the credentials an answer echoes back. Free of
5
+ * node modules, so a browser bundle judges a URL the same way Cortex does.
6
+ * Cortex's `guardedFetch` enforces the rest at the socket.
7
+ */
8
+
9
+ export {
10
+ canCarrySecret,
11
+ egressAllowedHosts,
12
+ isEgressAllowedHost,
13
+ isPrivateAddress,
14
+ normalizeHost,
15
+ } from "./url";
16
+ export { credentialForms, redactedUrl, scrubAnswer, scrubCredentialStrings } from "./scrub";
@@ -25,11 +25,11 @@ export function credentialForms(value: string) {
25
25
  }
26
26
 
27
27
  /**
28
- * The one boundary every upstream body crosses before it is stored or sent
29
- * on, in cc and the server alike: U+0000 is dropped from keys and values
30
- * because Postgres refuses it in text and jsonb on both cc's execution log
31
- * and the server's transcript, then the credentials the request presented
32
- * are redacted. Dropping first, on the patterns as well, means a credential
28
+ * The one boundary every upstream body crosses before Cortex stores it or
29
+ * sends it on: U+0000 is dropped from keys and values because Postgres
30
+ * refuses it in text and jsonb, in the tool execution log and the thread's
31
+ * transcript alike, then the credentials the request presented are
32
+ * redacted. Dropping first, on the patterns as well, means a credential
33
33
  * an upstream spelled with U+0000 inside it still matches; redacting first
34
34
  * would let the strip reassemble it.
35
35
  */
@@ -59,3 +59,32 @@ export function scrubCredentialStrings(value: unknown, credentials: string[]): u
59
59
  ]),
60
60
  );
61
61
  }
62
+
63
+ /**
64
+ * What a party answered (a manifest, a tool it lists, the values it fills),
65
+ * fit to store or show a model: the credentials it was sent redacted from
66
+ * every string in it, or null when one of its `names` carries one, since a
67
+ * redacted identifier would name something else. Functions in it, such as a
68
+ * tool's executor, stay as they are.
69
+ */
70
+ export function scrubAnswer<T>(answer: T, credentials: string[], names: readonly string[] = []) {
71
+ const carries = names.some((name) => {
72
+ const plain = name.replaceAll("\u0000", "");
73
+ return scrubCredentialStrings(plain, credentials) !== plain;
74
+ });
75
+ return carries ? null : (scrubCredentialStrings(answer, credentials) as T);
76
+ }
77
+
78
+ /**
79
+ * A URL as it may be logged or shown: its origin and path, without the
80
+ * userinfo, query or fragment a secret can ride in. Text that does not
81
+ * parse as a URL keeps what comes before its query or fragment.
82
+ */
83
+ export function redactedUrl(raw: string) {
84
+ try {
85
+ const url = new URL(raw);
86
+ return `${url.origin}${url.pathname}`;
87
+ } catch {
88
+ return raw.split(/[?#]/)[0] ?? raw;
89
+ }
90
+ }
@@ -2,7 +2,7 @@
2
2
  * The egress policy as far as a URL alone can tell: which addresses are
3
3
  * private, which hosts the deployment admits, and where a secret may be
4
4
  * sent. Pure and free of node modules, so a browser bundle judges a URL the
5
- * same way the server does.
5
+ * same way Cortex does.
6
6
  */
7
7
 
8
8
  type Range = { first: number; last: number };
@@ -33,8 +33,8 @@ function isBlockedV4(address: string) {
33
33
  }
34
34
 
35
35
  /**
36
- * The IPv4 address inside an IPv4-mapped IPv6 one, which arrives dotted from a
37
- * DNS answer (`::ffff:8.8.8.8`) and as two hex groups from a parsed URL
36
+ * The IPv4 address in the last 32 bits of an IPv6 one, which arrives dotted
37
+ * from a DNS answer (`::ffff:8.8.8.8`) and as two hex groups from a parsed URL
38
38
  * (`::ffff:808:808`). `null` when the tail is neither.
39
39
  */
40
40
  function mappedV4(mapped: string) {
@@ -45,15 +45,28 @@ function mappedV4(mapped: string) {
45
45
  return [high >> 8, high & 255, low >> 8, low & 255].join(".");
46
46
  }
47
47
 
48
- function isBlockedV6(address: string) {
49
- const normalized = address.toLowerCase();
50
- if (normalized.startsWith("::ffff:")) {
51
- const mapped = mappedV4(normalized.slice("::ffff:".length));
52
- return mapped === null || isBlockedV4(mapped);
48
+ /**
49
+ * The IPv4 address an IPv6 one reaches, for the prefixes where only the
50
+ * address may follow: IPv4-mapped (`::ffff:0:0/96`) and NAT64's well-known
51
+ * `64:ff9b::/96`. `undefined` for any other IPv6 address.
52
+ */
53
+ function embeddedV4(address: string) {
54
+ for (const prefix of ["::ffff:", "64:ff9b::"]) {
55
+ if (address.startsWith(prefix)) return mappedV4(address.slice(prefix.length));
53
56
  }
57
+ return undefined;
58
+ }
59
+
60
+ function isBlockedV6(address: string) {
61
+ const normalized = normalizeHost(address);
62
+ const embedded = embeddedV4(normalized);
63
+ if (embedded !== undefined) return embedded === null || isBlockedV4(embedded);
54
64
  return (
55
65
  normalized === "::" ||
56
66
  normalized === "::1" ||
67
+ // NAT64's local-use /48: the operator may embed the IPv4 address at
68
+ // any RFC 6052 prefix length, so no position can be read reliably.
69
+ normalized.startsWith("64:ff9b:1:") ||
57
70
  normalized.startsWith("fc") ||
58
71
  normalized.startsWith("fd") ||
59
72
  /^fe[89ab]/.test(normalized)
@@ -115,7 +128,7 @@ export function isEgressAllowedHost(hostname: string, allowed = egressAllowedHos
115
128
  * http to loopback, or over plain http to a host the policy admits. Pass
116
129
  * `allowed` explicitly where the policy is known from elsewhere (the browser
117
130
  * form reads it from the session) or must stay strict regardless of it (a
118
- * URL the browser or the CLI dials, not the server): `[]` admits nothing.
131
+ * URL the browser or the CLI dials, not Cortex): `[]` admits nothing.
119
132
  */
120
133
  export function canCarrySecret(url: string, allowed = egressAllowedHosts()) {
121
134
  try {
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+
3
+ /*
4
+ * The one error envelope a backend answers Cortex with, in the dataset
5
+ * protocol and the server protocol alike: `{ error: { kind, detail } }`
6
+ * under the kind's HTTP status, or a closer one HTTP defines (401 for a
7
+ * wrong server key, 404 for an unknown tool, 413 for a body too large). The
8
+ * detail reaches the model.
9
+ */
10
+
11
+ export const BACKEND_ERROR_KINDS = [
12
+ "invalid_request",
13
+ "unsupported",
14
+ "forbidden",
15
+ "timeout",
16
+ "internal",
17
+ ] as const;
18
+ export type BackendErrorKind = (typeof BACKEND_ERROR_KINDS)[number];
19
+
20
+ /** The HTTP status each kind travels under. */
21
+ export const BACKEND_ERROR_STATUS = {
22
+ invalid_request: 400,
23
+ unsupported: 400,
24
+ forbidden: 403,
25
+ timeout: 504,
26
+ internal: 500,
27
+ } as const satisfies Record<BackendErrorKind, number>;
28
+
29
+ /** The longest detail Cortex accepts; a backend cuts a longer one down to it. */
30
+ export const BACKEND_ERROR_DETAIL_MAX = 512;
31
+
32
+ export const backendErrorSchema = z.object({
33
+ error: z.object({
34
+ kind: z.enum(BACKEND_ERROR_KINDS),
35
+ detail: z.string().max(BACKEND_ERROR_DETAIL_MAX),
36
+ }),
37
+ });
38
+ export type BackendError = z.infer<typeof backendErrorSchema>;
@@ -7,6 +7,11 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
7
7
  return typeof value === "object" && value !== null && !Array.isArray(value);
8
8
  }
9
9
 
10
+ /** `value` when it is a record, else undefined. */
11
+ export function asRecord(value: unknown) {
12
+ return isRecord(value) ? value : undefined;
13
+ }
14
+
10
15
  /**
11
16
  * One reference token of a JSON Pointer (RFC 6901): `~` is written `~0` and `/` is written
12
17
  * `~1`. Escaping replaces `~` first and unescaping `~1` first, so a literal `~1` survives the