@m6d/cortex-cli 0.12.0 → 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.
- package/README.md +135 -130
- package/package.json +16 -5
- package/src/cli.ts +17 -12
- package/src/commands/apply.ts +169 -0
- package/src/commands/{cc.ts → management.ts} +150 -100
- package/src/commands/new.ts +50 -74
- package/src/contracts/charts/chart-spec.ts +1 -1
- package/src/contracts/client-tools/index.ts +3 -3
- package/src/contracts/{runtime → datasets}/datasets.ts +7 -42
- package/src/contracts/datasets/index.ts +3 -0
- package/src/contracts/egress-policy/index.ts +16 -0
- package/src/contracts/{egress → egress-policy}/scrub.ts +34 -5
- package/src/contracts/{egress → egress-policy}/url.ts +22 -9
- package/src/contracts/errors/index.ts +38 -0
- package/src/contracts/json/index.ts +5 -0
- package/src/contracts/manifest/index.ts +932 -0
- package/src/contracts/manifest-file/index.ts +16 -0
- package/src/contracts/markdown/index.ts +1 -1
- package/src/contracts/pattern/index.ts +276 -0
- package/src/contracts/server/index.ts +247 -0
- package/src/contracts/server/reserved-names.fixtures.json +18 -0
- package/src/contracts/triggers/index.ts +112 -0
- package/src/contracts/wire/index.ts +61 -32
- package/src/{cc → management}/client.ts +19 -7
- package/src/{cc → management}/flags.ts +2 -1
- package/src/{cc → management}/profiles.ts +5 -5
- package/src/scaffold/files.ts +362 -237
- package/src/scaffold/ports.ts +13 -24
- package/src/scaffold/templates/datasets.ts.txt +52 -0
- package/src/scaffold/templates/dockerignore.txt +7 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +8 -12
- package/src/scaffold/templates/gitignore.txt +2 -1
- package/src/scaffold/templates/server.ts.txt +40 -0
- package/src/scaffold/templates/setup.ts.txt +52 -0
- package/src/ui/prompts.ts +4 -31
- package/src/ui/report.ts +5 -15
- package/tsconfig.json +2 -3
- package/src/commands/graph.ts +0 -228
- package/src/commands/swagger.ts +0 -247
- package/src/config/load.ts +0 -122
- package/src/config/validate.ts +0 -46
- package/src/contracts/credentials/index.ts +0 -183
- package/src/contracts/egress/index.ts +0 -120
- package/src/contracts/graph/clients/embed.ts +0 -45
- package/src/contracts/graph/clients/neo4j.ts +0 -85
- package/src/contracts/graph/helpers.ts +0 -55
- package/src/contracts/graph/index.ts +0 -30
- package/src/contracts/graph/schema.ts +0 -65
- package/src/contracts/graph/types.ts +0 -125
- package/src/contracts/rich-text/index.ts +0 -220
- package/src/contracts/runtime/api.ts +0 -345
- package/src/contracts/runtime/auth.ts +0 -41
- package/src/contracts/runtime/grammar.ts +0 -169
- package/src/contracts/runtime/index.ts +0 -15
- package/src/contracts/runtime/models.ts +0 -50
- package/src/contracts/runtime/signature.ts +0 -255
- package/src/contracts/runtime/sync.ts +0 -196
- package/src/contracts/runtime/triggers.ts +0 -267
- package/src/graph/expand-domains.ts +0 -286
- package/src/graph/generate-cypher.ts +0 -201
- package/src/graph/seed.ts +0 -199
- package/src/graph/validate.ts +0 -78
- package/src/scaffold/features.ts +0 -190
- package/src/scaffold/templates/domains-README.md.txt +0 -16
- package/src/scaffold/templates/index.ts.txt +0 -18
- package/src/swagger/extract-endpoints.ts +0 -269
- package/src/swagger/generated-block.ts +0 -74
- package/src/swagger/openapi-schema.ts +0 -168
- package/src/swagger/parse-response.ts +0 -88
- package/src/swagger/types.ts +0 -25
- /package/src/contracts/{runtime → datasets}/dataset-sql-guide.ts +0 -0
- /package/src/contracts/{runtime → datasets}/dataset-sql.ts +0 -0
- /package/src/contracts/{runtime → datasets}/datasets.fixtures.json +0 -0
- /package/src/{cc → management}/output.ts +0 -0
package/src/commands/new.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
|
|
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("--
|
|
37
|
-
.option("--no-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
const
|
|
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
|
-
//
|
|
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,
|
|
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 = [
|
|
109
|
-
|
|
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,
|
|
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,
|
|
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",
|
|
186
|
-
|
|
187
|
-
// The
|
|
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
|
|
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
|
|
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:
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
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
|
|
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,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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|