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