bazis 0.135.0 → 0.136.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/core/agent/AgentToolExecutor.ts +3 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/tools/StandardTools.module.ts +10 -2
- package/core/tools/contracts/Tools.config.ts +30 -0
- package/core/tools/generated/deps.ts +5 -0
- package/core/tools/http/HttpRequest.tool.ts +246 -0
- package/core/tools/http/publicAddress.ts +94 -0
- package/core/tools/index.ts +5 -2
- package/core/tools/secrets/SecretStore.ts +28 -0
- package/core/workflow/generated/deps.ts +1 -1
- package/core/workflow/runtime_module/internal/ToolStep.ts +30 -4
- package/library/redaction/index.ts +28 -0
- package/library/workflow/expressions.ts +8 -1
- package/library/workflow/index.ts +1 -1
- package/library/workflow/paths.ts +21 -1
- package/library/workflow/validateProcess.ts +5 -5
- package/package.json +1 -1
|
@@ -62,6 +62,8 @@ export interface AgentToolExecutionContext {
|
|
|
62
62
|
readonly actor?: ToolActor;
|
|
63
63
|
/** A trial: the Tool, one with `dryRun: "supported"`, changes nothing (WORKFLOW-002 §5.4). */
|
|
64
64
|
readonly dryRun?: boolean;
|
|
65
|
+
/** Who calls: `"agent"` or the caller id of `ToolRunner.as`. */
|
|
66
|
+
readonly caller?: string;
|
|
65
67
|
readonly metadata: JsonObject;
|
|
66
68
|
readonly signal: AbortSignal;
|
|
67
69
|
}
|
|
@@ -1672,6 +1674,7 @@ export class AgentToolExecutor {
|
|
|
1672
1674
|
approval,
|
|
1673
1675
|
...(options.actor !== undefined ? { actor: options.actor } : {}),
|
|
1674
1676
|
...(options.dryRun === true ? { dryRun: true } : {}),
|
|
1677
|
+
...(callerOf(options) === undefined ? {} : { caller: callerOf(options)! }),
|
|
1675
1678
|
metadata,
|
|
1676
1679
|
signal,
|
|
1677
1680
|
});
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.136.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { AccessModule } from "../access";
|
|
2
2
|
import { Module, singleton } from "../di";
|
|
3
|
+
import { toolsConfig } from "./contracts/Tools.config";
|
|
4
|
+
import { HttpRequestTool } from "./http/HttpRequest.tool";
|
|
3
5
|
import { OrmEntityReferences, OrmFieldReferences } from "./orm/OrmEntity.references";
|
|
6
|
+
import { ConfigSecretStore, SecretStore } from "./secrets/SecretStore";
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* Tools that come with the framework (WORKFLOW-002 §7): not needed until an
|
|
7
|
-
* application imports it.
|
|
10
|
+
* application imports it. Here are `http.request`, closed until `tools.http.allow`
|
|
11
|
+
* lists a host, the values of `$secrets` and the reference kinds of `ormEntityTools`.
|
|
12
|
+
* The application replaces the secrets by registering its `SecretStore`.
|
|
8
13
|
*/
|
|
9
14
|
@Module({
|
|
10
15
|
imports: [AccessModule],
|
|
16
|
+
config: [toolsConfig],
|
|
11
17
|
references: { "orm.entity": OrmEntityReferences, "orm.field": OrmFieldReferences },
|
|
12
|
-
|
|
18
|
+
tools: [HttpRequestTool],
|
|
19
|
+
providers: [singleton(OrmEntityReferences), singleton(OrmFieldReferences), singleton(SecretStore, ConfigSecretStore)],
|
|
20
|
+
exports: [SecretStore],
|
|
13
21
|
})
|
|
14
22
|
export class StandardToolsModule {}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineConfig } from "../../kernel";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Settings of the standard Tools. `http.allow` is the comma-separated list of hosts
|
|
5
|
+
* `http.request` may reach: `api.example.com`, `*.example.com`, `localhost:8080`.
|
|
6
|
+
* Empty by default: requests go nowhere. `http.allowPrivateNetworks` lets it reach
|
|
7
|
+
* private and loopback addresses of a listed host: intranet services, tests.
|
|
8
|
+
* `http.callers` says who may call it: `workflow`, `agent`.
|
|
9
|
+
*/
|
|
10
|
+
export interface ToolsSettings {
|
|
11
|
+
readonly "http.allow": string;
|
|
12
|
+
readonly "http.callers": string;
|
|
13
|
+
readonly "http.allowPrivateNetworks": boolean;
|
|
14
|
+
readonly "http.maxResponseBytes": number;
|
|
15
|
+
readonly "http.timeoutMs": number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const toolsConfig = defineConfig<ToolsSettings>("tools", {
|
|
19
|
+
default: {
|
|
20
|
+
"http.allow": "",
|
|
21
|
+
"http.callers": "workflow",
|
|
22
|
+
"http.allowPrivateNetworks": false,
|
|
23
|
+
"http.maxResponseBytes": 1_048_576,
|
|
24
|
+
"http.timeoutMs": 30_000,
|
|
25
|
+
},
|
|
26
|
+
validate: {
|
|
27
|
+
"http.maxResponseBytes": (value) => (Number.isSafeInteger(value) && value >= 1 && value <= 104_857_600 ? undefined : "must be an integer 1–104857600"),
|
|
28
|
+
"http.timeoutMs": (value) => (Number.isSafeInteger(value) && value >= 1 && value <= 600_000 ? undefined : "must be an integer 1–600000"),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
// Do not edit manually.
|
|
3
3
|
import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
|
|
4
4
|
import { ServiceProvider } from "../../di/ServiceProvider";
|
|
5
|
+
import { Configuration } from "../../kernel/config/Configuration";
|
|
6
|
+
import { HttpRequestTool } from "../http/HttpRequest.tool";
|
|
5
7
|
import { OrmEntityReferences, OrmFieldReferences } from "../orm/OrmEntity.references";
|
|
8
|
+
import { ConfigSecretStore } from "../secrets/SecretStore";
|
|
6
9
|
|
|
7
10
|
registerFrameworkClassDeps([
|
|
11
|
+
[ConfigSecretStore, [Configuration]],
|
|
12
|
+
[HttpRequestTool, ["Config:tools"]],
|
|
8
13
|
[OrmEntityReferences, [ServiceProvider]],
|
|
9
14
|
[OrmFieldReferences, [ServiceProvider]],
|
|
10
15
|
]);
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import { Tool, ToolError, type AgentToolExecutionContext } from "../../agent";
|
|
4
|
+
import type { ConfigView } from "../../kernel";
|
|
5
|
+
import type { ToolsSettings } from "../contracts/Tools.config";
|
|
6
|
+
import { hostAllowed, isPublicAddress } from "./publicAddress";
|
|
7
|
+
|
|
8
|
+
const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
|
|
9
|
+
const SAFE_METHODS = new Set(["GET", "HEAD"]);
|
|
10
|
+
const MAX_REDIRECTS = 5;
|
|
11
|
+
/** What a redirect to another host must not carry. */
|
|
12
|
+
const CREDENTIALS = new Set(["authorization", "cookie", "proxy-authorization"]);
|
|
13
|
+
|
|
14
|
+
const secretText = { type: "string", description: "Usually $secrets.NAME" };
|
|
15
|
+
|
|
16
|
+
const INPUT_SCHEMA = {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
method: { enum: [...METHODS] },
|
|
20
|
+
url: { type: "string", format: "uri" },
|
|
21
|
+
headers: { type: "object", additionalProperties: { type: "string" } },
|
|
22
|
+
body: { description: "Sent as JSON" },
|
|
23
|
+
auth: {
|
|
24
|
+
type: "object",
|
|
25
|
+
description: "none; bearer with token; basic with username and password; apiKey with header and value",
|
|
26
|
+
properties: {
|
|
27
|
+
type: { enum: ["none", "bearer", "basic", "apiKey"] },
|
|
28
|
+
token: secretText,
|
|
29
|
+
username: { type: "string" },
|
|
30
|
+
password: secretText,
|
|
31
|
+
header: { type: "string" },
|
|
32
|
+
value: secretText,
|
|
33
|
+
},
|
|
34
|
+
required: ["type"],
|
|
35
|
+
},
|
|
36
|
+
successCodes: { type: "array", items: { type: "integer", minimum: 100, maximum: 599 } },
|
|
37
|
+
timeoutMs: { type: "integer", minimum: 1 },
|
|
38
|
+
},
|
|
39
|
+
required: ["url"],
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const OUTPUT_SCHEMA = {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
status: { type: "integer" },
|
|
47
|
+
headers: { type: "object" },
|
|
48
|
+
body: { description: "JSON, or text when the response is not JSON" },
|
|
49
|
+
dryRun: { type: "boolean" },
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
interface Request {
|
|
54
|
+
readonly method: string;
|
|
55
|
+
readonly url: URL;
|
|
56
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
57
|
+
readonly body: string | undefined;
|
|
58
|
+
readonly successCodes: ReadonlySet<number> | undefined;
|
|
59
|
+
readonly timeoutMs: number;
|
|
60
|
+
/** The header `auth` of type `apiKey` puts the key in: a redirect to another host drops it. */
|
|
61
|
+
readonly apiKeyHeader: string | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A request to the outside (WORKFLOW-002 §7.1), closed by default: an empty
|
|
66
|
+
* `tools.http.allow` lets nothing through. A host must be listed, and every address
|
|
67
|
+
* it resolves to must be public, on each redirect too; `http` goes to the address
|
|
68
|
+
* that was checked, `https` is held to its certificate. A status outside the success
|
|
69
|
+
* codes is an error with the reason `http_<status>`; 5xx and 429 are plain failures,
|
|
70
|
+
* so `retry` of the step repeats them. A trial makes only `GET` and `HEAD`.
|
|
71
|
+
*/
|
|
72
|
+
@Tool({
|
|
73
|
+
name: "http.request",
|
|
74
|
+
description: "Makes an HTTP request to an allowed host.",
|
|
75
|
+
sideEffect: "external",
|
|
76
|
+
dryRun: "supported",
|
|
77
|
+
callers: ["workflow", "agent"],
|
|
78
|
+
inputSchema: INPUT_SCHEMA,
|
|
79
|
+
outputSchema: OUTPUT_SCHEMA,
|
|
80
|
+
})
|
|
81
|
+
export class HttpRequestTool {
|
|
82
|
+
constructor(private readonly config: ConfigView<ToolsSettings>) {}
|
|
83
|
+
|
|
84
|
+
async execute(raw: unknown, context: AgentToolExecutionContext): Promise<unknown> {
|
|
85
|
+
const callers = this.config.get("http.callers").split(",").map((item) => item.trim());
|
|
86
|
+
if (context.caller !== undefined && !callers.includes(context.caller)) throw new ToolError(`tools.http.callers does not list ${context.caller}`, { reason: "caller_not_allowed" });
|
|
87
|
+
const request = this.requestOf(raw);
|
|
88
|
+
if (context.dryRun === true && !SAFE_METHODS.has(request.method)) return { dryRun: true };
|
|
89
|
+
const signal = AbortSignal.any([context.signal, AbortSignal.timeout(request.timeoutMs)]);
|
|
90
|
+
const response = await this.follow(request, signal);
|
|
91
|
+
const body = await this.bodyOf(response, request.method, signal);
|
|
92
|
+
const allowed = request.successCodes === undefined ? response.ok : request.successCodes.has(response.status);
|
|
93
|
+
if (!allowed) {
|
|
94
|
+
const message = `${request.method} ${request.url.host} answered ${response.status}`;
|
|
95
|
+
if (response.status >= 500 || response.status === 429) throw new Error(message);
|
|
96
|
+
throw new ToolError(message, { reason: `http_${response.status}` });
|
|
97
|
+
}
|
|
98
|
+
const headers = Object.fromEntries([...response.headers].filter(([name]) => name !== "set-cookie"));
|
|
99
|
+
return { status: response.status, headers, body, ...(context.dryRun === true ? { dryRun: true } : {}) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private requestOf(raw: unknown): Request {
|
|
103
|
+
const input = (typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>;
|
|
104
|
+
const method = String(input.method ?? "GET").toUpperCase();
|
|
105
|
+
if (!METHODS.has(method)) throw invalid(`method must be one of ${[...METHODS].join(", ")}`);
|
|
106
|
+
if (typeof input.url !== "string") throw invalid("url is required");
|
|
107
|
+
let url: URL;
|
|
108
|
+
try {
|
|
109
|
+
url = new URL(input.url);
|
|
110
|
+
} catch {
|
|
111
|
+
throw invalid("url is not a URL");
|
|
112
|
+
}
|
|
113
|
+
const headers: Record<string, string> = {};
|
|
114
|
+
if (input.headers !== undefined && input.headers !== null) {
|
|
115
|
+
if (typeof input.headers !== "object" || Array.isArray(input.headers)) throw invalid("headers must be an object");
|
|
116
|
+
for (const [name, value] of Object.entries(input.headers)) {
|
|
117
|
+
if (typeof value !== "string") throw invalid(`header ${name} must be a string`);
|
|
118
|
+
headers[name.toLowerCase()] = value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
Object.assign(headers, authHeaders(input.auth));
|
|
122
|
+
let body: string | undefined;
|
|
123
|
+
if (input.body !== undefined && input.body !== null) {
|
|
124
|
+
if (SAFE_METHODS.has(method)) throw invalid(`${method} has no body`);
|
|
125
|
+
body = JSON.stringify(input.body);
|
|
126
|
+
headers["content-type"] ??= "application/json";
|
|
127
|
+
}
|
|
128
|
+
let successCodes: Set<number> | undefined;
|
|
129
|
+
if (input.successCodes !== undefined && input.successCodes !== null) {
|
|
130
|
+
if (!Array.isArray(input.successCodes) || input.successCodes.length === 0 || !input.successCodes.every((code) => Number.isInteger(code) && code >= 100 && code <= 599)) throw invalid("successCodes must be a list of status codes");
|
|
131
|
+
successCodes = new Set(input.successCodes as number[]);
|
|
132
|
+
}
|
|
133
|
+
const limit = this.config.get("http.timeoutMs");
|
|
134
|
+
const asked = input.timeoutMs ?? limit;
|
|
135
|
+
if (!Number.isSafeInteger(asked) || (asked as number) < 1) throw invalid("timeoutMs must be a positive integer");
|
|
136
|
+
const apiKeyHeader = (input.auth as { type?: unknown; header?: unknown } | null | undefined)?.type === "apiKey" ? String((input.auth as { header: unknown }).header).toLowerCase() : undefined;
|
|
137
|
+
return { method, url, headers, body, successCodes, timeoutMs: Math.min(asked as number, limit), apiKeyHeader };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The request and its redirects, each hop checked as the first one. */
|
|
141
|
+
private async follow(first: Request, signal: AbortSignal): Promise<Response> {
|
|
142
|
+
let { method, url, body } = first;
|
|
143
|
+
let headers: Record<string, string> = { ...first.headers };
|
|
144
|
+
const origin = first.url.host;
|
|
145
|
+
const apiKeyHeader = first.apiKeyHeader;
|
|
146
|
+
for (let hop = 0; ; hop += 1) {
|
|
147
|
+
const target = await this.checked(url);
|
|
148
|
+
const response = await fetch(target, {
|
|
149
|
+
method,
|
|
150
|
+
headers: target.href === url.href ? headers : { ...headers, host: url.host },
|
|
151
|
+
redirect: "manual",
|
|
152
|
+
signal,
|
|
153
|
+
...(body === undefined ? {} : { body }),
|
|
154
|
+
});
|
|
155
|
+
const location = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
|
|
156
|
+
if (location === null) return response;
|
|
157
|
+
await response.body?.cancel();
|
|
158
|
+
if (hop >= MAX_REDIRECTS) throw new ToolError(`More than ${MAX_REDIRECTS} redirects`, { reason: "too_many_redirects" });
|
|
159
|
+
url = new URL(location, url);
|
|
160
|
+
if (url.host !== origin) headers = Object.fromEntries(Object.entries(headers).filter(([name]) => !CREDENTIALS.has(name) && name !== apiKeyHeader));
|
|
161
|
+
// 301, 302 and 303 turn a write into a read, as browsers do.
|
|
162
|
+
if (response.status !== 307 && response.status !== 308 && !SAFE_METHODS.has(method)) {
|
|
163
|
+
method = "GET";
|
|
164
|
+
body = undefined;
|
|
165
|
+
delete headers["content-type"];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The URL to connect to, once the host is allowed and its addresses are public. Refused otherwise. */
|
|
171
|
+
private async checked(url: URL): Promise<URL> {
|
|
172
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw invalid("only http and https are supported");
|
|
173
|
+
if (url.username !== "" || url.password !== "") throw invalid("credentials go to auth, not to the url");
|
|
174
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
175
|
+
const port = url.port !== "" ? url.port : url.protocol === "https:" ? "443" : "80";
|
|
176
|
+
if (!hostAllowed(this.config.get("http.allow"), hostname, port)) throw new ToolError(`tools.http.allow does not list ${url.host}`, { reason: "host_not_allowed" });
|
|
177
|
+
if (this.config.get("http.allowPrivateNetworks")) return url;
|
|
178
|
+
const addresses = isIP(hostname) !== 0 ? [hostname] : (await lookup(hostname, { all: true })).map((item) => item.address);
|
|
179
|
+
if (addresses.length === 0 || !addresses.every(isPublicAddress)) throw new ToolError(`${url.host} is not a public address`, { reason: "address_not_allowed" });
|
|
180
|
+
if (url.protocol === "https:" || isIP(hostname) !== 0) return url;
|
|
181
|
+
// Past this check the name could resolve elsewhere: plain http goes to the address that passed it.
|
|
182
|
+
const pinned = new URL(url);
|
|
183
|
+
const address = addresses[0]!;
|
|
184
|
+
pinned.hostname = isIP(address) === 6 ? `[${address}]` : address;
|
|
185
|
+
return pinned;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The body within `http.maxResponseBytes`: JSON when it is JSON. */
|
|
189
|
+
private async bodyOf(response: Response, method: string, signal: AbortSignal): Promise<unknown> {
|
|
190
|
+
if (method === "HEAD" || response.body === null) return null;
|
|
191
|
+
const limit = this.config.get("http.maxResponseBytes");
|
|
192
|
+
const tooLarge = () => new ToolError(`The response is larger than ${limit} bytes`, { reason: "response_too_large" });
|
|
193
|
+
if (Number(response.headers.get("content-length") ?? 0) > limit) {
|
|
194
|
+
await response.body.cancel();
|
|
195
|
+
throw tooLarge();
|
|
196
|
+
}
|
|
197
|
+
const chunks: Uint8Array[] = [];
|
|
198
|
+
let size = 0;
|
|
199
|
+
const reader = response.body.getReader();
|
|
200
|
+
for (;;) {
|
|
201
|
+
signal.throwIfAborted();
|
|
202
|
+
const { done, value } = await reader.read();
|
|
203
|
+
if (done) break;
|
|
204
|
+
size += value.byteLength;
|
|
205
|
+
if (size > limit) {
|
|
206
|
+
await reader.cancel();
|
|
207
|
+
throw tooLarge();
|
|
208
|
+
}
|
|
209
|
+
chunks.push(value);
|
|
210
|
+
}
|
|
211
|
+
const text = new TextDecoder().decode(Buffer.concat(chunks));
|
|
212
|
+
if (text === "") return null;
|
|
213
|
+
if (!/[/+]json\b/i.test(response.headers.get("content-type") ?? "")) return text;
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(text) as unknown;
|
|
216
|
+
} catch {
|
|
217
|
+
return text;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function invalid(message: string): ToolError {
|
|
223
|
+
return new ToolError(message, { reason: "invalid_input" });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function authHeaders(auth: unknown): Record<string, string> {
|
|
227
|
+
if (auth === undefined || auth === null) return {};
|
|
228
|
+
if (typeof auth !== "object" || Array.isArray(auth)) throw invalid("auth must be an object");
|
|
229
|
+
const { type, token, username, password, header, value } = auth as Record<string, unknown>;
|
|
230
|
+
const text = (item: unknown, name: string): string => {
|
|
231
|
+
if (typeof item !== "string" || item === "") throw invalid(`auth.${name} is required for ${String(type)}`);
|
|
232
|
+
return item;
|
|
233
|
+
};
|
|
234
|
+
switch (type) {
|
|
235
|
+
case "none":
|
|
236
|
+
return {};
|
|
237
|
+
case "bearer":
|
|
238
|
+
return { authorization: `Bearer ${text(token, "token")}` };
|
|
239
|
+
case "basic":
|
|
240
|
+
return { authorization: `Basic ${Buffer.from(`${text(username, "username")}:${text(password, "password")}`).toString("base64")}` };
|
|
241
|
+
case "apiKey":
|
|
242
|
+
return { [text(header, "header").toLowerCase()]: text(value, "value") };
|
|
243
|
+
default:
|
|
244
|
+
throw invalid("auth.type must be none, bearer, basic or apiKey");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** A dotted IPv4 address as a number, or `undefined`. */
|
|
2
|
+
function ipv4(text: string): number | undefined {
|
|
3
|
+
const parts = text.split(".");
|
|
4
|
+
if (parts.length !== 4) return undefined;
|
|
5
|
+
let value = 0;
|
|
6
|
+
for (const part of parts) {
|
|
7
|
+
if (!/^\d{1,3}$/.test(part) || Number(part) > 255) return undefined;
|
|
8
|
+
value = value * 256 + Number(part);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** An IPv6 address as eight 16-bit groups, or `undefined`. */
|
|
14
|
+
function ipv6(text: string): number[] | undefined {
|
|
15
|
+
let source = text.split("%")[0]!;
|
|
16
|
+
// A dotted tail: ::ffff:127.0.0.1.
|
|
17
|
+
const tail = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(source);
|
|
18
|
+
if (tail) {
|
|
19
|
+
const value = ipv4(tail[2]!);
|
|
20
|
+
if (value === undefined) return undefined;
|
|
21
|
+
source = `${tail[1]}${Math.floor(value / 65536).toString(16)}:${(value % 65536).toString(16)}`;
|
|
22
|
+
}
|
|
23
|
+
const [head, rest, ...more] = source.split("::");
|
|
24
|
+
if (more.length > 0) return undefined;
|
|
25
|
+
const groups = (part: string | undefined) => (part === undefined || part === "" ? [] : part.split(":"));
|
|
26
|
+
const left = groups(head);
|
|
27
|
+
const right = groups(rest);
|
|
28
|
+
const missing = 8 - left.length - right.length;
|
|
29
|
+
if (rest === undefined ? missing !== 0 : missing < 1) return undefined;
|
|
30
|
+
const all = [...left, ...new Array<string>(rest === undefined ? 0 : missing).fill("0"), ...right];
|
|
31
|
+
return all.every((group) => /^[0-9a-f]{1,4}$/i.test(group)) ? all.map((group) => Number.parseInt(group, 16)) : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function inRange(value: number, base: string, bits: number): boolean {
|
|
35
|
+
const size = 2 ** (32 - bits);
|
|
36
|
+
return Math.floor(value / size) === Math.floor(ipv4(base)! / size);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const NOT_PUBLIC_V4: readonly (readonly [string, number])[] = [
|
|
40
|
+
["0.0.0.0", 8],
|
|
41
|
+
["10.0.0.0", 8],
|
|
42
|
+
["100.64.0.0", 10],
|
|
43
|
+
["127.0.0.0", 8],
|
|
44
|
+
["169.254.0.0", 16],
|
|
45
|
+
["172.16.0.0", 12],
|
|
46
|
+
["192.0.0.0", 24],
|
|
47
|
+
["192.0.2.0", 24],
|
|
48
|
+
["192.168.0.0", 16],
|
|
49
|
+
["198.18.0.0", 15],
|
|
50
|
+
["198.51.100.0", 24],
|
|
51
|
+
["203.0.113.0", 24],
|
|
52
|
+
["224.0.0.0", 4],
|
|
53
|
+
["240.0.0.0", 4],
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function isPublicV4(value: number): boolean {
|
|
57
|
+
return !NOT_PUBLIC_V4.some(([base, bits]) => inRange(value, base, bits));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether an address can be reached from the outside: not loopback, private,
|
|
62
|
+
* link-local, multicast or reserved. An IPv6 address that carries an IPv4 one is
|
|
63
|
+
* judged by it. What cannot be read is not public.
|
|
64
|
+
*/
|
|
65
|
+
export function isPublicAddress(address: string): boolean {
|
|
66
|
+
const v4 = ipv4(address);
|
|
67
|
+
if (v4 !== undefined) return isPublicV4(v4);
|
|
68
|
+
const v6 = ipv6(address);
|
|
69
|
+
if (v6 === undefined) return false;
|
|
70
|
+
const carried = (high: number, low: number) => isPublicV4(high * 65536 + low);
|
|
71
|
+
// ::, ::1 and the old form ::a.b.c.d.
|
|
72
|
+
if (v6.slice(0, 6).every((group) => group === 0)) return v6[6] !== 0 && carried(v6[6]!, v6[7]!);
|
|
73
|
+
// ::ffff:a.b.c.d.
|
|
74
|
+
if (v6.slice(0, 5).every((group) => group === 0) && v6[5] === 0xffff) return carried(v6[6]!, v6[7]!);
|
|
75
|
+
// 64:ff9b::a.b.c.d.
|
|
76
|
+
if (v6[0] === 0x64 && v6[1] === 0xff9b) return carried(v6[6]!, v6[7]!);
|
|
77
|
+
// 2002:a.b.c.d::.
|
|
78
|
+
if (v6[0] === 0x2002) return carried(v6[1]!, v6[2]!);
|
|
79
|
+
// fc00::/7, fe80::/10, fec0::/10, ff00::/8, 2001:db8::/32.
|
|
80
|
+
if ((v6[0]! & 0xfe00) === 0xfc00 || (v6[0]! & 0xffc0) === 0xfe80 || (v6[0]! & 0xffc0) === 0xfec0 || (v6[0]! & 0xff00) === 0xff00) return false;
|
|
81
|
+
return !(v6[0] === 0x2001 && v6[1] === 0xdb8);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether `tools.http.allow` lets this host and port through: `api.example.com`, `*.example.com`, `localhost:8080`. */
|
|
85
|
+
export function hostAllowed(allow: string, hostname: string, port: string): boolean {
|
|
86
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
87
|
+
return allow.split(",").map((item) => item.trim().toLowerCase()).filter((item) => item !== "").some((item) => {
|
|
88
|
+
// An IPv6 address goes in brackets, as in a URL: [::1]:8080.
|
|
89
|
+
const match = /^\[(.+)\](?::(\d+))?$/.exec(item) ?? /^(.*?)(?::(\d+))?$/.exec(item)!;
|
|
90
|
+
const [name, wanted] = [match[1]!, match[2]];
|
|
91
|
+
if (wanted !== undefined && wanted !== port) return false;
|
|
92
|
+
return name.startsWith("*.") ? host.endsWith(name.slice(1)) && host.length > name.length - 1 : host === name;
|
|
93
|
+
});
|
|
94
|
+
}
|
package/core/tools/index.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tools that come with the framework (WORKFLOW-002 §7): actions over
|
|
3
|
-
* application's entities for processes and agents, closed by default.
|
|
2
|
+
* Tools that come with the framework (WORKFLOW-002 §7): HTTP requests, actions over
|
|
3
|
+
* the application's entities and secrets, for processes and agents, closed by default.
|
|
4
4
|
*/
|
|
5
5
|
import "./generated/deps";
|
|
6
6
|
|
|
7
7
|
export { StandardToolsModule } from "./StandardTools.module";
|
|
8
|
+
export { toolsConfig, type ToolsSettings } from "./contracts/Tools.config";
|
|
9
|
+
export { HttpRequestTool } from "./http/HttpRequest.tool";
|
|
10
|
+
export { ConfigSecretStore, SecretStore, type SecretContext } from "./secrets/SecretStore";
|
|
8
11
|
export { ormEntityTools, type OrmEntityOperation, type OrmEntityToolsOptions } from "./orm/ormEntityTools";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Configuration } from "../../kernel";
|
|
2
|
+
|
|
3
|
+
/** Whose secret is asked for. */
|
|
4
|
+
export interface SecretContext {
|
|
5
|
+
/** The partition of the instance: a tenant may have secrets of its own. */
|
|
6
|
+
readonly partition: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Values of `$secrets.NAME` (WORKFLOW-002 §7.2): a document holds only the name.
|
|
11
|
+
* The application registers its implementation in any module; by default the
|
|
12
|
+
* values come from the configuration.
|
|
13
|
+
*/
|
|
14
|
+
export abstract class SecretStore {
|
|
15
|
+
/** The value, or `undefined` when there is no such secret. */
|
|
16
|
+
abstract get(name: string, context: SecretContext): string | undefined | Promise<string | undefined>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `secrets.<name>` of the configuration: `BAZIS_SECRETS__API_TOKEN` gives `$secrets.API_TOKEN`. */
|
|
20
|
+
export class ConfigSecretStore extends SecretStore {
|
|
21
|
+
constructor(private readonly configuration: Configuration) {
|
|
22
|
+
super();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get(name: string): string | undefined {
|
|
26
|
+
return this.configuration.get(`secrets.${name}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -65,7 +65,7 @@ registerFrameworkClassDeps([
|
|
|
65
65
|
[TasksController, [IWorkflowTasks]],
|
|
66
66
|
[TasksDbContext, [DbContextOptions]],
|
|
67
67
|
[TimerStep, [RuntimeDbContext, WorkflowEngine]],
|
|
68
|
-
[ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools, WorkflowReferences, WorkflowLanguages]],
|
|
68
|
+
[ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools, WorkflowReferences, WorkflowLanguages, ServiceProvider]],
|
|
69
69
|
[WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowReferences, ServiceProvider]],
|
|
70
70
|
[WorkflowEngine, [RuntimeDbContext, IWorkflowDefinitions, GraphCache, WorkflowEvents, WorkflowClock]],
|
|
71
71
|
[WorkflowEvents, [EventBus, ServiceProvider, "optional:Logger"]],
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { AgentSchemaContract, AgentToolResult } from "../../../agent";
|
|
2
|
-
import {
|
|
2
|
+
import { redactValues, rememberSecretValue } from "../../../../library/redaction";
|
|
3
|
+
import { durationMs, inputReferences, maskByMapping, maskBySchema, masksPersonal, personalPaths, resolveInput, secretNames, type ProcessDocument } from "../../../../library/workflow";
|
|
4
|
+
import type { ServiceProvider } from "../../../di";
|
|
5
|
+
import { SecretStore } from "../../../tools/secrets/SecretStore";
|
|
3
6
|
import type { WorkflowToolMetadata } from "../../contracts/types";
|
|
4
7
|
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
5
8
|
import type { WorkflowReferences } from "../../internal/WorkflowReferences";
|
|
@@ -27,6 +30,8 @@ export class ToolStep {
|
|
|
27
30
|
private readonly tools: WorkflowTools,
|
|
28
31
|
private readonly references: WorkflowReferences,
|
|
29
32
|
private readonly languages: WorkflowLanguages,
|
|
33
|
+
/** The scope: the application's own `SecretStore` replaces the default. */
|
|
34
|
+
private readonly services: ServiceProvider,
|
|
30
35
|
) {}
|
|
31
36
|
|
|
32
37
|
async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
@@ -39,7 +44,9 @@ export class ToolStep {
|
|
|
39
44
|
const graph = await this.engine.graphOf(instance.versionId);
|
|
40
45
|
const node = graph.document.nodes[token.nodeId];
|
|
41
46
|
const name = String(node?.config?.tool ?? "");
|
|
42
|
-
const
|
|
47
|
+
const { values: secrets, missing: noSecret } = await this.secretsOf(node?.config?.input, instance.partition);
|
|
48
|
+
const revealed = Object.values(secrets);
|
|
49
|
+
const input = resolveInput(node?.config?.input ?? {}, { ...dataOf(instance, this.engine.now(), token), secrets });
|
|
43
50
|
const timeoutMs = stepTimeoutMs(node?.config?.timeout, graph.document);
|
|
44
51
|
// A step may run longer than the lease: another worker must not take the job and call the Tool again meanwhile.
|
|
45
52
|
if (timeoutMs !== undefined && timeoutMs >= LEASE_MS) {
|
|
@@ -48,8 +55,10 @@ export class ToolStep {
|
|
|
48
55
|
const runner = this.tools.bound;
|
|
49
56
|
const trial = instance.test ? runner?.getTool(name)?.metadata.dryRun : undefined;
|
|
50
57
|
const missing = await this.references.missing(inputReferences(this.references.inputSchemaOf(name), input), { scope: instance.partition, language: this.languages.default });
|
|
51
|
-
const
|
|
58
|
+
const raw: AgentToolResult = runner === undefined
|
|
52
59
|
? { callId: token.id, name, status: "error", error: { code: "TOOL_NOT_FOUND", message: `Tool ${name} is not available to processes` }, metadata: {} } as AgentToolResult
|
|
60
|
+
: noSecret !== undefined
|
|
61
|
+
? { callId: token.id, name, status: "error", error: { code: "secret_not_found", message: `There is no secret ${noSecret}`, details: { reason: "secret_not_found" } }, metadata: {} } as AgentToolResult
|
|
53
62
|
: missing.length > 0
|
|
54
63
|
// A value computed by the process, or one that is gone since the publication (WORKFLOW-002 §6.3).
|
|
55
64
|
? { callId: token.id, name, status: "error", error: { code: "ref_not_found", message: `${missing[0]!.kind} ${JSON.stringify(missing[0]!.id)} does not exist`, details: { reason: "ref_not_found", path: missing[0]!.path } }, metadata: {} } as AgentToolResult
|
|
@@ -65,9 +74,11 @@ export class ToolStep {
|
|
|
65
74
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
66
75
|
});
|
|
67
76
|
|
|
77
|
+
// What the Tool got back from the outside may echo a secret: it goes no further than the Tool.
|
|
78
|
+
const result = redactValues(raw, revealed);
|
|
68
79
|
const timedOut = TIMEOUT_CODES.has(result.error?.code ?? "");
|
|
69
80
|
const reason = timedOut ? "timeout" : reasonOf(result);
|
|
70
|
-
const payloads = graph.document.settings?.log?.toolPayloads === true ? this.payloads(graph.document, name, node?.config?.input, input, result) : {};
|
|
81
|
+
const payloads = graph.document.settings?.log?.toolPayloads === true ? redactValues(this.payloads(graph.document, name, node?.config?.input, input, result), revealed) : {};
|
|
71
82
|
await this.engine.inStep(instance.id, async (step) => {
|
|
72
83
|
const current = step.tokens.find((t) => t.id === token.id);
|
|
73
84
|
const retryAt = current?.status === "waiting" && step.instance.status === "running" && result.status === "error" && (reason === undefined || timedOut)
|
|
@@ -103,6 +114,21 @@ export class ToolStep {
|
|
|
103
114
|
});
|
|
104
115
|
}
|
|
105
116
|
|
|
117
|
+
/** The values of `$secrets` this input names, and the first one there is not. Remembered, so that a log masks them too. */
|
|
118
|
+
private async secretsOf(input: unknown, partition: string | null): Promise<{ readonly values: Record<string, string>; readonly missing?: string }> {
|
|
119
|
+
const values: Record<string, string> = {};
|
|
120
|
+
const names = secretNames(input);
|
|
121
|
+
if (names.length === 0) return { values };
|
|
122
|
+
const store = this.services.tryResolve(SecretStore);
|
|
123
|
+
for (const name of names) {
|
|
124
|
+
const value = await store?.get(name, { partition });
|
|
125
|
+
if (value === undefined) return { values, missing: name };
|
|
126
|
+
rememberSecretValue(value);
|
|
127
|
+
values[name] = value;
|
|
128
|
+
}
|
|
129
|
+
return { values };
|
|
130
|
+
}
|
|
131
|
+
|
|
106
132
|
/** The input and output for the history under `log.toolPayloads`; personal data is masked unless `log.maskPersonal` is `false`. */
|
|
107
133
|
private payloads(document: ProcessDocument, name: string, mapping: unknown, input: unknown, result: AgentToolResult): Record<string, unknown> {
|
|
108
134
|
if (!masksPersonal(document)) return { input, output: result.output ?? null };
|
|
@@ -52,6 +52,34 @@ export const MIN_REMEMBERED_SECRET_LENGTH = 10;
|
|
|
52
52
|
const rememberedSecrets = new Set<string>();
|
|
53
53
|
let longestFirst: readonly string[] = [];
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* A copy of `value` with these exact strings cut out of every text and key: the
|
|
57
|
+
* secrets of one call. Unlike {@link rememberSecretValue} it knows nothing of the
|
|
58
|
+
* rest of the process, so it takes values of any length.
|
|
59
|
+
*/
|
|
60
|
+
export function redactValues<T>(value: T, secrets: readonly string[], replacement = DEFAULT_REPLACEMENT): T {
|
|
61
|
+
const list = secrets.filter((item) => item !== "").sort((left, right) => right.length - left.length);
|
|
62
|
+
if (list.length === 0) return value;
|
|
63
|
+
const text = (source: string) => list.reduce((current, secret) => current.split(secret).join(replacement), source);
|
|
64
|
+
const seen = new WeakMap<object, unknown>();
|
|
65
|
+
const walk = (item: unknown): unknown => {
|
|
66
|
+
if (typeof item === "string") return text(item);
|
|
67
|
+
if (typeof item !== "object" || item === null || item instanceof Date) return item;
|
|
68
|
+
if (seen.has(item)) return seen.get(item);
|
|
69
|
+
if (Array.isArray(item)) {
|
|
70
|
+
const copy: unknown[] = [];
|
|
71
|
+
seen.set(item, copy);
|
|
72
|
+
for (const element of item) copy.push(walk(element));
|
|
73
|
+
return copy;
|
|
74
|
+
}
|
|
75
|
+
const copy: Record<string, unknown> = {};
|
|
76
|
+
seen.set(item, copy);
|
|
77
|
+
for (const [key, element] of Object.entries(item)) copy[text(key)] = walk(element);
|
|
78
|
+
return copy;
|
|
79
|
+
};
|
|
80
|
+
return walk(value) as T;
|
|
81
|
+
}
|
|
82
|
+
|
|
55
83
|
/**
|
|
56
84
|
* Remembers a secret value: from now on every redaction removes it wherever it
|
|
57
85
|
* appears, also in plain text without a marker such as `password=`. `Secret`
|
|
@@ -267,6 +267,8 @@ export interface ExpressionScope {
|
|
|
267
267
|
readonly nodes?: ReadonlySet<string>;
|
|
268
268
|
/** Whether it is read inside a loop body: `$item` and `$index` exist only there. */
|
|
269
269
|
readonly loop?: boolean;
|
|
270
|
+
/** Whether it is the input of a Tool: `$secrets` exists only there, a value must go nowhere else. */
|
|
271
|
+
readonly secrets?: boolean;
|
|
270
272
|
}
|
|
271
273
|
|
|
272
274
|
/** What is wrong with an expression, or `null`: its shape, functions, argument types, paths and limits. */
|
|
@@ -333,11 +335,16 @@ function literalArgumentProblem(name: ExprFunctionName, args: readonly unknown[]
|
|
|
333
335
|
|
|
334
336
|
/** What is wrong with a `$` path, or `null`. */
|
|
335
337
|
export function checkPath(path: unknown, scope: ExpressionScope = {}): string | null {
|
|
336
|
-
if (typeof path !== "string" || !DATA_PATH.test(path)) return `path ${JSON.stringify(path)} must look like $input.field, $vars.name, $nodes.Node.field, $event.field, $trigger, $run.id, $now, $item.field or $
|
|
338
|
+
if (typeof path !== "string" || !DATA_PATH.test(path)) return `path ${JSON.stringify(path)} must look like $input.field, $vars.name, $nodes.Node.field, $event.field, $trigger, $run.id, $now, $item.field, $index or $secrets.NAME`;
|
|
337
339
|
const [root, first] = path.split(".");
|
|
338
340
|
if (root === "$now" && first !== undefined) return "$now has no fields";
|
|
339
341
|
if (root === "$index" && first !== undefined) return "$index has no fields";
|
|
340
342
|
if ((root === "$item" || root === "$index") && scope.loop === false) return `path ${path}: ${root} exists only inside a loop body`;
|
|
343
|
+
if (root === "$secrets") {
|
|
344
|
+
if (scope.secrets !== true) return `path ${path}: $secrets exists only in the input of a Tool`;
|
|
345
|
+
if (first === undefined || path.split(".").length !== 2) return "$secrets needs a name: $secrets.NAME";
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
341
348
|
if (root === "$nodes" && first === undefined) return "$nodes needs a node: $nodes.Node.field";
|
|
342
349
|
if (first === undefined) return null;
|
|
343
350
|
if (root === "$input" && scope.input && !scope.input.has(first)) return `path ${path}: the input has no field ${first}`;
|
|
@@ -22,7 +22,7 @@ export {
|
|
|
22
22
|
} from "./ProcessDocument";
|
|
23
23
|
export { NODE_KINDS, nodeKindOf, ISO_DURATION, MAX_ITERATIONS, MAX_LOOP_PARALLEL, PROCESS_KEY, ISO_MOMENT, type JsonSchema, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
|
|
24
24
|
export { PROCESS_LIMITS, validateProcess, hasErrors, type ProcessIssue, type ProcessIssueCode, type ValidateProcessOptions } from "./validateProcess";
|
|
25
|
-
export { DATA_PATH, DATA_PATHS, isPath, readPath, resolveInput, type InstanceData } from "./paths";
|
|
25
|
+
export { DATA_PATH, DATA_PATHS, isPath, readPath, resolveInput, secretNames, type InstanceData } from "./paths";
|
|
26
26
|
export {
|
|
27
27
|
EXPRESSION_FUNCTIONS,
|
|
28
28
|
EXPRESSION_LIMITS,
|
|
@@ -20,10 +20,12 @@ export interface InstanceData {
|
|
|
20
20
|
readonly item?: unknown;
|
|
21
21
|
/** Inside a loop body: the number of this iteration, from 0. */
|
|
22
22
|
readonly index?: number;
|
|
23
|
+
/** Only while the input of a Tool is resolved: the values of `$secrets.NAME`. */
|
|
24
|
+
readonly secrets?: Readonly<Record<string, string>>;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/** `$input.amount`, `$vars.payment.id`, `$nodes.Pay.paymentId`, `$event.record.id`, `$run.partition`, `$now`, `$item.id`, `$index`. */
|
|
26
|
-
export const DATA_PATH = /^\$(input|vars|nodes|event|trigger|run|now|item|index)((\.[A-Za-z_][A-Za-z0-9_]*)*)$/;
|
|
28
|
+
export const DATA_PATH = /^\$(input|vars|nodes|event|trigger|run|now|item|index|secrets)((\.[A-Za-z_][A-Za-z0-9_]*)*)$/;
|
|
27
29
|
|
|
28
30
|
/** The roots of `$` paths, for a designer. */
|
|
29
31
|
export const DATA_PATHS: readonly { readonly path: string; readonly description: string }[] = Object.freeze([
|
|
@@ -36,8 +38,26 @@ export const DATA_PATHS: readonly { readonly path: string; readonly description:
|
|
|
36
38
|
{ path: "$now", description: "The engine's time" },
|
|
37
39
|
{ path: "$item", description: "Inside a loop body: the element of this iteration" },
|
|
38
40
|
{ path: "$index", description: "Inside a loop body: the number of this iteration, from 0" },
|
|
41
|
+
{ path: "$secrets.<name>", description: "Only in the input of a Tool: the value of a secret, which goes nowhere else" },
|
|
39
42
|
]);
|
|
40
43
|
|
|
44
|
+
/** The secrets a Tool input names, in paths and expressions at any depth. */
|
|
45
|
+
export function secretNames(input: unknown): string[] {
|
|
46
|
+
const names = new Set<string>();
|
|
47
|
+
const walk = (value: unknown): void => {
|
|
48
|
+
if (typeof value === "string") {
|
|
49
|
+
const match = /^\$secrets\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
|
|
50
|
+
if (match) names.add(match[1]!);
|
|
51
|
+
} else if (Array.isArray(value)) {
|
|
52
|
+
value.forEach(walk);
|
|
53
|
+
} else if (typeof value === "object" && value !== null) {
|
|
54
|
+
Object.values(value).forEach(walk);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
walk(input);
|
|
58
|
+
return [...names];
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
/** `true` if the value is a path to instance data. */
|
|
42
62
|
export function isPath(value: unknown): value is string {
|
|
43
63
|
return typeof value === "string" && DATA_PATH.test(value);
|
|
@@ -65,8 +65,6 @@ export type ProcessIssueCode =
|
|
|
65
65
|
export const PROCESS_LIMITS = Object.freeze({ nodes: 500, flows: 1000, bytes: 1_000_000 });
|
|
66
66
|
|
|
67
67
|
const ID = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
68
|
-
/** Paths that come with later stages of WORKFLOW-002: until then they are an error, not a silent literal. */
|
|
69
|
-
const NOT_YET = /^\$secrets(\.|$)/;
|
|
70
68
|
/** Who a boundary error may hang on: the steps that fail by an error of their own. */
|
|
71
69
|
const CAN_FAIL: ReadonlySet<string | undefined> = new Set(["serviceTask", "callProcess", "loop"]);
|
|
72
70
|
|
|
@@ -447,7 +445,7 @@ function configExpressionProblems(kind: string, config: Json, scope: ExpressionS
|
|
|
447
445
|
if (problem) out.push(`config.assignments[${index}].value: ${problem}`);
|
|
448
446
|
}
|
|
449
447
|
}
|
|
450
|
-
if ((kind === "serviceTask" || kind === "callProcess") && config.input !== undefined) walkInput(config.input, "config.input", scope, out);
|
|
448
|
+
if ((kind === "serviceTask" || kind === "callProcess") && config.input !== undefined) walkInput(config.input, "config.input", kind === "serviceTask" ? { ...scope, secrets: true } : scope, out);
|
|
451
449
|
if (kind === "loop" && config.over !== undefined) {
|
|
452
450
|
const problem = checkExpression(config.over, scope);
|
|
453
451
|
if (problem) out.push(`config.over: ${problem}`);
|
|
@@ -475,8 +473,10 @@ function configExpressionProblems(kind: string, config: Json, scope: ExpressionS
|
|
|
475
473
|
|
|
476
474
|
function walkInput(value: unknown, at: string, scope: ExpressionScope, out: string[]): void {
|
|
477
475
|
if (typeof value === "string") {
|
|
478
|
-
if (
|
|
479
|
-
|
|
476
|
+
if (value === "$secrets" || value.startsWith("$secrets.")) {
|
|
477
|
+
const problem = checkPath(value, scope);
|
|
478
|
+
if (problem) out.push(`${at}: ${problem}`);
|
|
479
|
+
} else if (isPath(value)) {
|
|
480
480
|
const problem = checkPath(value, scope);
|
|
481
481
|
if (problem) out.push(`${at}: ${problem}`);
|
|
482
482
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.136.0",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|