@foldrun/core 8.0.1 → 9.1.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/dist/src/approvals.d.ts +18 -10
- package/dist/src/approvals.js +51 -1
- package/dist/src/catalog.d.ts +14 -0
- package/dist/src/catalog.js +28 -0
- package/dist/src/cron.d.ts +9 -0
- package/dist/src/cron.js +20 -0
- package/dist/src/frontmatter-fields.js +1 -1
- package/dist/src/frontmatter-schema.d.ts +5 -1
- package/dist/src/frontmatter-schema.js +40 -1
- package/dist/src/history.d.ts +5 -0
- package/dist/src/history.js +31 -4
- package/dist/src/hooks.d.ts +2 -0
- package/dist/src/hooks.js +13 -0
- package/dist/src/library.js +4 -0
- package/dist/src/notify.d.ts +21 -4
- package/dist/src/notify.js +105 -22
- package/dist/src/package.d.ts +5 -1
- package/dist/src/package.js +5 -3
- package/dist/src/runner.js +17 -9
- package/dist/src/scan.js +21 -0
- package/dist/src/secrets.d.ts +92 -10
- package/dist/src/secrets.js +258 -28
- package/dist/src/storage.d.ts +76 -4
- package/dist/src/storage.js +219 -22
- package/dist/src/store.d.ts +20 -2
- package/dist/src/store.js +50 -15
- package/dist/src/triggers.js +2 -2
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +19 -6
- package/package.json +1 -1
- package/src/approvals.ts +55 -1
- package/src/catalog.ts +27 -0
- package/src/cron.ts +19 -0
- package/src/frontmatter-fields.ts +1 -1
- package/src/frontmatter-schema.ts +38 -2
- package/src/history.ts +31 -4
- package/src/hooks.ts +14 -0
- package/src/library.ts +3 -0
- package/src/notify.ts +104 -23
- package/src/package.ts +5 -3
- package/src/runner.ts +17 -8
- package/src/scan.ts +23 -0
- package/src/secrets.ts +283 -34
- package/src/storage.ts +223 -19
- package/src/store.ts +49 -14
- package/src/triggers.ts +2 -2
- package/src/webhook.ts +26 -6
package/dist/src/notify.js
CHANGED
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
import crypto from "node:crypto";
|
|
34
34
|
import { readAgentsMd } from "./runner.js";
|
|
35
35
|
import { accountDir, workspaceDir, runCost, readFlow, runVerdict } from "./store.js";
|
|
36
|
-
import { getSecret } from "./secrets.js";
|
|
36
|
+
import { getSecret, secretSites, signingSecretShown, accountSecretUsableIn } from "./secrets.js";
|
|
37
|
+
import { siteCovers } from "./network.js";
|
|
37
38
|
import { publicUrl } from "./webhook.js";
|
|
38
39
|
import { approveLinkPath, approveLinkTtlMs } from "./approvals.js";
|
|
39
40
|
import { noteSecretUse, healthKey } from "./secret-health.js";
|
|
@@ -61,12 +62,16 @@ export function platformMail(tenant) {
|
|
|
61
62
|
return { key, from: platform.setting("FOLDRUN_EMAIL_FROM") || "foldrun <hello@foldrun.io>" };
|
|
62
63
|
return own;
|
|
63
64
|
}
|
|
64
|
-
/** The account's own Resend key and sender, or null when it has none.
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
/** The account's own Resend key and sender, or null when it has none.
|
|
66
|
+
* With a workspace — mail a workspace's run causes — an account key or
|
|
67
|
+
* sender limited to other workspaces is not used (30 Oct 2026); the
|
|
68
|
+
* account's own mail (resets, invites) names none and is not limited. */
|
|
69
|
+
export function accountMail(tenant, workspace) {
|
|
70
|
+
const usable = (name) => !workspace || accountSecretUsableIn(tenant, name, workspace);
|
|
71
|
+
const own = usable("RESEND_API_KEY") ? getSecret(tenant, "RESEND_API_KEY") : null;
|
|
67
72
|
if (!own)
|
|
68
73
|
return null;
|
|
69
|
-
const from = getSecret(tenant, "EMAIL_FROM")?.value?.trim() || "foldrun <onboarding@resend.dev>";
|
|
74
|
+
const from = (usable("EMAIL_FROM") ? getSecret(tenant, "EMAIL_FROM")?.value?.trim() : "") || "foldrun <onboarding@resend.dev>";
|
|
70
75
|
return { key: own.value, from };
|
|
71
76
|
}
|
|
72
77
|
/**
|
|
@@ -79,8 +84,17 @@ export function accountMail(tenant) {
|
|
|
79
84
|
* owner's bin. The platform's key is the fallback for an account that set
|
|
80
85
|
* none, so `notify:` works before anyone has configured mail at all.
|
|
81
86
|
*/
|
|
82
|
-
export function notifyMail(tenant) {
|
|
83
|
-
|
|
87
|
+
export function notifyMail(tenant, workspace) {
|
|
88
|
+
if (!workspace)
|
|
89
|
+
return accountMail(tenant) ?? platformMail(tenant);
|
|
90
|
+
// A workspace's notification: the account's sender only where it may be
|
|
91
|
+
// used, else the platform's — never platformMail's fall back to the
|
|
92
|
+
// account's own key, which would walk around the limit.
|
|
93
|
+
const own = accountMail(tenant, workspace);
|
|
94
|
+
if (own)
|
|
95
|
+
return own;
|
|
96
|
+
const key = process.env.FOLDRUN_RESEND_API_KEY;
|
|
97
|
+
return key ? { key, from: platform.setting("FOLDRUN_EMAIL_FROM") || "foldrun <hello@foldrun.io>" } : null;
|
|
84
98
|
}
|
|
85
99
|
/** Was the mail sent on the account's own key? Only then is a refusal from
|
|
86
100
|
* Resend a fact about a secret the account holds. Recording a platform-key
|
|
@@ -145,7 +159,7 @@ export function signatureHeaders(tenant, workspace, config, body) {
|
|
|
145
159
|
return {};
|
|
146
160
|
const secret = getSecret(tenant, config.signingSecret, workspace);
|
|
147
161
|
if (!secret) {
|
|
148
|
-
console.error(`[foldrun] notify: signing_secret names ${config.signingSecret}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`);
|
|
162
|
+
console.error(`[foldrun] notify: signing_secret names ${signingSecretShown(config.signingSecret)}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`);
|
|
149
163
|
return {};
|
|
150
164
|
}
|
|
151
165
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
@@ -180,15 +194,80 @@ export function newDeliveryId() {
|
|
|
180
194
|
return `dlv_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
181
195
|
}
|
|
182
196
|
/** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
|
|
183
|
-
* URL is itself a credential, so it is never echoed resolved.
|
|
197
|
+
* URL is itself a credential, so it is never echoed resolved.
|
|
198
|
+
*
|
|
199
|
+
* A secret bound to sites (`foldrun secrets sites`) is filled only when the
|
|
200
|
+
* resolved URL's host is one of them — the rule the egress proxy and the
|
|
201
|
+
* tool tester keep (30 Oct 2026). Before, notify.url filled any secret into
|
|
202
|
+
* any host, so an editor could write `https://their.server/?k=${STRIPE_KEY}`
|
|
203
|
+
* and press Test. The host is read AFTER filling, because the usual shape is
|
|
204
|
+
* the whole URL as one secret (`${SLACK_WEBHOOK_URL}`, bound to
|
|
205
|
+
* hooks.slack.com). An unbound secret fills as it does for an agent's
|
|
206
|
+
* `secrets:` and a tool test — workspace first, then the account — since
|
|
207
|
+
* binding is how an account says where a value may go. `refused` says why
|
|
208
|
+
* `url` is null when a binding stopped it; `filled` maps each value put
|
|
209
|
+
* into the URL to its name, so an error that echoes the URL can be scrubbed. */
|
|
184
210
|
export function webhookTarget(tenant, workspace, config = notifyConfig(tenant, workspace)) {
|
|
185
211
|
const raw = config?.url ?? "";
|
|
212
|
+
const filled = {};
|
|
213
|
+
// Every name put in, kept apart from `filled` (value → name, for
|
|
214
|
+
// scrubbing): two secrets with the same value would otherwise leave only
|
|
215
|
+
// the last name there, and the first one's binding would go unchecked.
|
|
216
|
+
const used = new Set();
|
|
186
217
|
const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
|
|
187
218
|
const hit = getSecret(tenant, name, workspace);
|
|
219
|
+
if (hit) {
|
|
220
|
+
filled[hit.value] = name;
|
|
221
|
+
used.add(name);
|
|
222
|
+
}
|
|
188
223
|
return hit ? hit.value : whole;
|
|
189
224
|
});
|
|
190
225
|
const shown = raw.includes("${") ? raw : raw ? `${raw.slice(0, 40)}${raw.length > 40 ? "…" : ""}` : "none";
|
|
191
|
-
|
|
226
|
+
if (!url || url.includes("${"))
|
|
227
|
+
return { url: null, shown, filled: {} };
|
|
228
|
+
const bound = secretSites(tenant, [...used], workspace);
|
|
229
|
+
if (Object.keys(bound).length) {
|
|
230
|
+
let host = "";
|
|
231
|
+
try {
|
|
232
|
+
host = new URL(url).hostname;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Not a URL: no host can be one of its sites.
|
|
236
|
+
}
|
|
237
|
+
const offSite = Object.entries(bound).filter(([, sites]) => !host || !sites.some((site) => siteCovers(site, host))).map(([n]) => n);
|
|
238
|
+
if (offSite.length) {
|
|
239
|
+
return {
|
|
240
|
+
url: null,
|
|
241
|
+
shown,
|
|
242
|
+
filled: {},
|
|
243
|
+
// The host is not echoed: it is read from the FILLED url, so
|
|
244
|
+
// `https://${KEY}.their.server/` would print the key back.
|
|
245
|
+
refused: `${offSite.join(", ")} ${offSite.length === 1 ? "is" : "are"} bound to other sites than notify.url's host — not sent`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { url, shown, filled };
|
|
250
|
+
}
|
|
251
|
+
/** An error about a request to a resolved notify.url, with every secret value
|
|
252
|
+
* put back as its ${NAME} — a redirect refusal quotes the URL it was sent to,
|
|
253
|
+
* and the delivery log that keeps it is read by every viewer. */
|
|
254
|
+
function scrubTarget(text, filled) {
|
|
255
|
+
let out = text;
|
|
256
|
+
// Longest first, so a value inside another is not half-replaced.
|
|
257
|
+
for (const value of Object.keys(filled).sort((a, b) => b.length - a.length)) {
|
|
258
|
+
if (!value)
|
|
259
|
+
continue;
|
|
260
|
+
const variants = new Set([value]);
|
|
261
|
+
try {
|
|
262
|
+
variants.add(encodeURI(value)).add(encodeURIComponent(value));
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// A lone surrogate cannot be encoded, so it cannot appear encoded.
|
|
266
|
+
}
|
|
267
|
+
for (const v of variants)
|
|
268
|
+
out = out.split(v).join(`\${${filled[value]}}`);
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
192
271
|
}
|
|
193
272
|
/**
|
|
194
273
|
* One attempt at one delivery, against the workspace's notify: as it reads
|
|
@@ -204,10 +283,13 @@ export async function attemptWebhook(tenant, workspace, d) {
|
|
|
204
283
|
if (!config?.url) {
|
|
205
284
|
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "this workspace's notify: has no url any more" };
|
|
206
285
|
}
|
|
207
|
-
const { url } = webhookTarget(tenant, workspace, config);
|
|
286
|
+
const { url, refused, filled } = webhookTarget(tenant, workspace, config);
|
|
208
287
|
if (!url) {
|
|
209
|
-
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "the secret named in notify.url is not in this account's vault" };
|
|
288
|
+
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: refused ?? "the secret named in notify.url is not in this account's vault" };
|
|
210
289
|
}
|
|
290
|
+
// A URL that carries a secret is a credential: a redirect is followed only
|
|
291
|
+
// within the origin it was sent to, as the tool tester does.
|
|
292
|
+
const carriesSecret = Object.keys(filled).length > 0;
|
|
211
293
|
try {
|
|
212
294
|
const res = await fetchUntrusted(url, {
|
|
213
295
|
method: "POST",
|
|
@@ -222,7 +304,7 @@ export async function attemptWebhook(tenant, workspace, d) {
|
|
|
222
304
|
},
|
|
223
305
|
body: d.body,
|
|
224
306
|
signal: AbortSignal.timeout(Number(process.env.FOLDRUN_WEBHOOK_TIMEOUT_MS) || 8000),
|
|
225
|
-
});
|
|
307
|
+
}, { sameOrigin: carriesSecret });
|
|
226
308
|
const text = (await res.text().catch(() => "")).slice(0, 500);
|
|
227
309
|
return {
|
|
228
310
|
...base,
|
|
@@ -235,7 +317,7 @@ export async function attemptWebhook(tenant, workspace, d) {
|
|
|
235
317
|
}
|
|
236
318
|
catch (err) {
|
|
237
319
|
const why = err instanceof Error ? (err.name === "TimeoutError" ? "timed out" : err.message) : String(err);
|
|
238
|
-
return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: why.slice(0, 500) };
|
|
320
|
+
return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: scrubTarget(why, filled).slice(0, 500) };
|
|
239
321
|
}
|
|
240
322
|
}
|
|
241
323
|
/**
|
|
@@ -335,7 +417,7 @@ export async function sendPlainNotificationOutcome(tenant, workspace, msg) {
|
|
|
335
417
|
};
|
|
336
418
|
try {
|
|
337
419
|
if (config.email) {
|
|
338
|
-
const mail = notifyMail(tenant);
|
|
420
|
+
const mail = notifyMail(tenant, workspace);
|
|
339
421
|
if (!mail)
|
|
340
422
|
return no;
|
|
341
423
|
const r = await sendMail({
|
|
@@ -386,7 +468,7 @@ export async function sendTestNotification(tenant, workspace) {
|
|
|
386
468
|
const headline = "\u2713 test notification";
|
|
387
469
|
const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
|
|
388
470
|
if (config.email) {
|
|
389
|
-
const mail = notifyMail(tenant);
|
|
471
|
+
const mail = notifyMail(tenant, workspace);
|
|
390
472
|
if (!mail) {
|
|
391
473
|
return {
|
|
392
474
|
ok: false,
|
|
@@ -411,9 +493,9 @@ export async function sendTestNotification(tenant, workspace) {
|
|
|
411
493
|
// The destination is echoed back with the secret still unresolved: a
|
|
412
494
|
// Slack webhook URL is itself a credential, and a page that prints it is
|
|
413
495
|
// a page that leaks it into a screenshot.
|
|
414
|
-
const { url, shown } = webhookTarget(tenant, workspace, config);
|
|
496
|
+
const { url, shown, refused } = webhookTarget(tenant, workspace, config);
|
|
415
497
|
if (!url) {
|
|
416
|
-
return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
|
|
498
|
+
return { ok: false, destination: shown, detail: refused ?? `the secret named in notify.url is not in this account's vault` };
|
|
417
499
|
}
|
|
418
500
|
const payload = JSON.stringify({ text: `${headline} — ${detail}`, workspace, status: "test", summary: detail });
|
|
419
501
|
// One attempt, recorded in the delivery log like any other — and the way
|
|
@@ -424,7 +506,7 @@ export async function sendTestNotification(tenant, workspace) {
|
|
|
424
506
|
? {
|
|
425
507
|
ok: true,
|
|
426
508
|
destination: shown,
|
|
427
|
-
detail: `${config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted"} (delivery ${r.deliveryId})`,
|
|
509
|
+
detail: `${config.signingSecret ? "accepted, signed with " + signingSecretShown(config.signingSecret) : "accepted"} (delivery ${r.deliveryId})`,
|
|
428
510
|
}
|
|
429
511
|
: { ok: false, destination: shown, detail: `${r.statusCode ? `HTTP ${r.statusCode} — ` : ""}${r.detail.replace(/\s+/g, " ").slice(0, 400)}` };
|
|
430
512
|
}
|
|
@@ -510,7 +592,7 @@ export async function sendRunNotification(tenant, workspace, run) {
|
|
|
510
592
|
};
|
|
511
593
|
try {
|
|
512
594
|
if (config.email) {
|
|
513
|
-
const mail = notifyMail(tenant);
|
|
595
|
+
const mail = notifyMail(tenant, workspace);
|
|
514
596
|
if (!mail) {
|
|
515
597
|
console.error(`[foldrun] notify: email configured but no mail credential — set RESEND_API_KEY (and EMAIL_FROM) on the account ${tenant}, or FOLDRUN_RESEND_API_KEY on the platform`);
|
|
516
598
|
return false;
|
|
@@ -554,8 +636,9 @@ export async function sendRunNotification(tenant, workspace, run) {
|
|
|
554
636
|
}
|
|
555
637
|
// ${SECRET} so the Slack URL — itself a credential — can live in the
|
|
556
638
|
// vault instead of in a file that gets committed.
|
|
557
|
-
|
|
558
|
-
|
|
639
|
+
const target = webhookTarget(tenant, workspace, config);
|
|
640
|
+
if (!target.url) {
|
|
641
|
+
console.error(`[foldrun] notify: ${target.refused ?? "secret in URL not set"} for ${tenant}/${workspace}`);
|
|
559
642
|
return false;
|
|
560
643
|
}
|
|
561
644
|
const event = blocked ? "blocked" : run.status;
|
package/dist/src/package.d.ts
CHANGED
|
@@ -20,7 +20,11 @@ export interface Package {
|
|
|
20
20
|
/** An error the API layer turns into a status rather than a 500. */
|
|
21
21
|
export declare class PackageError extends Error {
|
|
22
22
|
readonly status: number;
|
|
23
|
-
|
|
23
|
+
/** `cause`: the error underneath a 500, so an HTTP caller can log it and
|
|
24
|
+
* answer without it — a Node error names the server's paths (30 Oct 2026). */
|
|
25
|
+
constructor(message: string, status: number, opts?: {
|
|
26
|
+
cause?: unknown;
|
|
27
|
+
});
|
|
24
28
|
}
|
|
25
29
|
export declare const MANIFEST_FILE = "foldrun-package.json";
|
|
26
30
|
export declare function isPackagedPath(rel: string): boolean;
|
package/dist/src/package.js
CHANGED
|
@@ -37,8 +37,10 @@ import { zip, unzip, ZipError } from "./zip.js";
|
|
|
37
37
|
/** An error the API layer turns into a status rather than a 500. */
|
|
38
38
|
export class PackageError extends Error {
|
|
39
39
|
status;
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
/** `cause`: the error underneath a 500, so an HTTP caller can log it and
|
|
41
|
+
* answer without it — a Node error names the server's paths (30 Oct 2026). */
|
|
42
|
+
constructor(message, status, opts) {
|
|
43
|
+
super(message, opts);
|
|
42
44
|
this.status = status;
|
|
43
45
|
}
|
|
44
46
|
}
|
|
@@ -371,7 +373,7 @@ export function applyImport(tenant, workspace, pkg, opts = {}) {
|
|
|
371
373
|
// best effort — the error below is the one to report
|
|
372
374
|
}
|
|
373
375
|
}
|
|
374
|
-
throw new PackageError(`the import could not be written, and nothing was kept: ${err instanceof Error ? err.message : String(err)}`, 500);
|
|
376
|
+
throw new PackageError(`the import could not be written, and nothing was kept: ${err instanceof Error ? err.message : String(err)}`, 500, { cause: err });
|
|
375
377
|
}
|
|
376
378
|
for (const c of changes) {
|
|
377
379
|
const abs = path.join(dir, c.path);
|
package/dist/src/runner.js
CHANGED
|
@@ -41,7 +41,7 @@ import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec,
|
|
|
41
41
|
import { sendPlainNotification } from "./notify.js";
|
|
42
42
|
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
|
|
43
43
|
import { refNames } from "./refs.js";
|
|
44
|
-
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
44
|
+
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, isOneSegment, } from "./store.js";
|
|
45
45
|
import { canReadBack } from "./tool-output.js";
|
|
46
46
|
import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
|
|
47
47
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
|
|
@@ -4694,8 +4694,12 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4694
4694
|
// resuming around.
|
|
4695
4695
|
if (step.status !== "pending")
|
|
4696
4696
|
continue;
|
|
4697
|
-
|
|
4698
|
-
|
|
4697
|
+
// One folder under agents/, never a path (30 Oct 2026): an ad-hoc
|
|
4698
|
+
// run's agent came from the URL, and `../../<tenant>/…` joined here
|
|
4699
|
+
// ran another account's agent. Refused as not found, before the
|
|
4700
|
+
// existence check could answer for a path outside.
|
|
4701
|
+
const aDir = isOneSegment(step.agent) ? path.join(pDir, "agents", step.agent) : null;
|
|
4702
|
+
if (!aDir || !fs.existsSync(path.join(aDir, "agent.md"))) {
|
|
4699
4703
|
step.status = "failed";
|
|
4700
4704
|
step.events.push({
|
|
4701
4705
|
t: new Date().toISOString(),
|
|
@@ -4800,15 +4804,18 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4800
4804
|
if (needsApproval.length) {
|
|
4801
4805
|
for (const step of needsApproval) {
|
|
4802
4806
|
step.status = "awaiting-approval";
|
|
4807
|
+
const t = new Date().toISOString();
|
|
4803
4808
|
step.events.push({
|
|
4804
|
-
t
|
|
4809
|
+
t,
|
|
4805
4810
|
type: "info",
|
|
4806
4811
|
// ask: is the same gate carrying a question — the answer
|
|
4807
4812
|
// arrives as the approval note and rides into the prompt.
|
|
4813
|
+
// An event URL is this step's and this park's alone (30 Oct
|
|
4814
|
+
// 2026): the index and this line's `t` are in its token.
|
|
4808
4815
|
text: step.ask
|
|
4809
4816
|
? `waiting for an answer: ${step.ask}`
|
|
4810
4817
|
: step.waitFor === "event"
|
|
4811
|
-
? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id)}`
|
|
4818
|
+
? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id, { step: run.steps.indexOf(step), at: t })}`
|
|
4812
4819
|
: "waiting for approval before running",
|
|
4813
4820
|
});
|
|
4814
4821
|
}
|
|
@@ -4918,8 +4925,9 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4918
4925
|
// step of the flow (isSplicedRecord). It gets no delegateJob of its
|
|
4919
4926
|
// own: depth one.
|
|
4920
4927
|
const delegateFor = (parent) => async (agent, job, cancelled) => {
|
|
4921
|
-
|
|
4922
|
-
|
|
4928
|
+
// One folder under agents/, never a path — as the group check above.
|
|
4929
|
+
const dir = isOneSegment(agent) ? path.join(pDir, "agents", agent) : null;
|
|
4930
|
+
if (!dir || !fs.existsSync(path.join(dir, "agent.md")))
|
|
4923
4931
|
return { status: "failed", result: `${agent} is not an agent in this workspace` };
|
|
4924
4932
|
// A stable id on both records: an index into run.steps moves when a
|
|
4925
4933
|
// rescue or a fan-out is spliced in before the child.
|
|
@@ -5335,8 +5343,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
5335
5343
|
const rescueStep = async (step) => {
|
|
5336
5344
|
if (!step.onFail)
|
|
5337
5345
|
return;
|
|
5338
|
-
const rescuerDir = path.join(pDir, "agents", step.onFail);
|
|
5339
|
-
if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
|
|
5346
|
+
const rescuerDir = isOneSegment(step.onFail) ? path.join(pDir, "agents", step.onFail) : null;
|
|
5347
|
+
if (!rescuerDir || !fs.existsSync(path.join(rescuerDir, "agent.md"))) {
|
|
5340
5348
|
step.events.push({
|
|
5341
5349
|
t: new Date().toISOString(),
|
|
5342
5350
|
type: "error",
|
package/dist/src/scan.js
CHANGED
|
@@ -202,6 +202,27 @@ export const SCAN_RULES = [
|
|
|
202
202
|
title: "A required frontmatter field is missing",
|
|
203
203
|
help: "A skill with no description is never loaded: the model chooses skills by their descriptions.",
|
|
204
204
|
},
|
|
205
|
+
{
|
|
206
|
+
// 30 Oct 2026: check and the scheduler read a schedule with the same
|
|
207
|
+
// cronCanFire (core cron.ts), so check says what the scheduler does.
|
|
208
|
+
id: "config/cron-never-fires",
|
|
209
|
+
category: "config",
|
|
210
|
+
level: "warn",
|
|
211
|
+
title: "A schedule that names a day no month has",
|
|
212
|
+
help: "`0 0 30 2 *` is a valid cron — every field is in range — but no February has a 30th, so the flow never runs and the " +
|
|
213
|
+
"scheduler never lists a next run for it. Pick a day every named month has, or add a weekday (either then matches).",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
// 30 Oct 2026: a signing secret is looked up in the vault by name.
|
|
217
|
+
id: "secret/signing-secret-value",
|
|
218
|
+
category: "secret",
|
|
219
|
+
level: "warn",
|
|
220
|
+
title: "A signing_secret: that holds a value, not a secret's name",
|
|
221
|
+
help: "A flow's `signing_secret:` and `notify: signing_secret:` name a vault entry (`WEBHOOK_SIGNING_KEY`, or `${WEBHOOK_SIGNING_KEY}`). " +
|
|
222
|
+
"Anything else is looked up as a name and never found — every signed delivery is refused, every notification goes unsigned — " +
|
|
223
|
+
"and the value itself is deployed and versioned in plain text. Put it in the vault (`foldrun secrets set NAME`), write the name, and rotate it.",
|
|
224
|
+
owasp: ["ASI03"],
|
|
225
|
+
},
|
|
205
226
|
{
|
|
206
227
|
id: "check/ignore-needs-reason",
|
|
207
228
|
category: "config",
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -31,8 +31,15 @@ interface StoredSecret {
|
|
|
31
31
|
* these sites alone. Metadata, not a value, so it is stored in the clear
|
|
32
32
|
* beside the ciphertext and survives the value being set again. */
|
|
33
33
|
sites?: string[];
|
|
34
|
+
/** An ACCOUNT secret limited to these workspaces (GitHub's "selected
|
|
35
|
+
* repositories" for an organisation secret). Absent: every workspace may
|
|
36
|
+
* use it, as before. Every other workspace is answered as if it were not
|
|
37
|
+
* set (getSecret). Metadata like `sites`: in the clear, and in the seal
|
|
38
|
+
* of a bound record. Never on a workspace's own record. */
|
|
39
|
+
workspaces?: string[];
|
|
34
40
|
/** 2: sealed with the record's place as AAD — account, scope, name, kind,
|
|
35
|
-
* key and
|
|
41
|
+
* key, sites and, when limited, workspaces (vaultAad). Absent: the older,
|
|
42
|
+
* unbound form. */
|
|
36
43
|
v?: 2;
|
|
37
44
|
}
|
|
38
45
|
type SecretsFile = Record<string, StoredSecret>;
|
|
@@ -44,7 +51,30 @@ declare const writeBound: () => boolean;
|
|
|
44
51
|
*/
|
|
45
52
|
export declare function mutateVaultFile(file: string, change: (all: SecretsFile) => SecretsFile | null): SecretsFile;
|
|
46
53
|
export declare const SECRET_NAME: RegExp;
|
|
47
|
-
|
|
54
|
+
/**
|
|
55
|
+
* How a `signing_secret:` is shown in an answer, a log line or a delivery
|
|
56
|
+
* record: the name it holds, and only a name (30 Oct 2026). It is meant to
|
|
57
|
+
* name a vault entry, but nothing stopped the value itself being written
|
|
58
|
+
* there, and notify's test send echoed it back to the dashboard as "signed
|
|
59
|
+
* with <it>". Anything that is not a secret name is described, never
|
|
60
|
+
* repeated (foldrun check flags it as secret/signing-secret-value).
|
|
61
|
+
*/
|
|
62
|
+
export declare function signingSecretShown(name: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Which workspaces an ACCOUNT secret may be used in, given with the value so
|
|
65
|
+
* both are sealed in one locked write (30 Oct 2026): a new secret created
|
|
66
|
+
* and limited in two calls was open to every workspace between them, and
|
|
67
|
+
* stayed open if the second failed. `workspaces` undefined keeps what the
|
|
68
|
+
* record has; null opens it to every workspace; a list limits it.
|
|
69
|
+
* `exists` is asked about each name under the vault lock, so a workspace
|
|
70
|
+
* deleted meanwhile is not granted (the delete takes its name off every
|
|
71
|
+
* list under the same lock — retargetSecretWorkspaces).
|
|
72
|
+
*/
|
|
73
|
+
export interface SecretLimit {
|
|
74
|
+
workspaces?: string[] | null;
|
|
75
|
+
exists?: (workspace: string) => boolean;
|
|
76
|
+
}
|
|
77
|
+
export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind, limit?: SecretLimit): void;
|
|
48
78
|
/**
|
|
49
79
|
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
50
80
|
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
@@ -71,11 +101,61 @@ export declare function decryptValue(tenant: string, rec: {
|
|
|
71
101
|
data: string;
|
|
72
102
|
k?: "tenant";
|
|
73
103
|
} | undefined): string | null;
|
|
74
|
-
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
104
|
+
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
105
|
+
* An account secret limited to some workspaces (setSecretWorkspaces) is
|
|
106
|
+
* absent for every other — the answer a secret that was never set gets, on
|
|
107
|
+
* every path that resolves one for a workspace: runs, tool tests, notify,
|
|
108
|
+
* the egress proxy, MCP servers. Without a workspace it is the account's
|
|
109
|
+
* own use (its model key, its mail sender), which no list limits. */
|
|
75
110
|
export declare function getSecret(tenant: string, name: string, workspace?: string): {
|
|
76
111
|
value: string;
|
|
77
112
|
scope: SecretScope;
|
|
78
113
|
} | null;
|
|
114
|
+
/** May the ACCOUNT's secret `name` be used for this workspace? True when it
|
|
115
|
+
* is not limited (or not set); for callers that read the account's copy on
|
|
116
|
+
* a workspace's behalf without getSecret's workspace-first lookup — the
|
|
117
|
+
* account's mail sender for a workspace's notifications (notify.ts). */
|
|
118
|
+
export declare function accountSecretUsableIn(tenant: string, name: string, workspace: string): boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Limit an account secret to some workspaces, or lift the limit (null).
|
|
121
|
+
* An empty list means no workspace may use it. Returns the list as stored,
|
|
122
|
+
* sorted, or null.
|
|
123
|
+
*
|
|
124
|
+
* `exists` is asked about each name under the vault lock (the API passes
|
|
125
|
+
* "is there such a workspace"), so it cannot grant a name a delete has just
|
|
126
|
+
* taken off every list. `ifWorkspaces`, when given, is the list the caller
|
|
127
|
+
* saw (null: every workspace): a list changed since is refused (409), so
|
|
128
|
+
* two admins editing it at once do not silently overwrite each other.
|
|
129
|
+
*
|
|
130
|
+
* The record is sealed (v2) whatever it was, whatever
|
|
131
|
+
* FOLDRUN_VAULT_WRITE_BOUND says: a list in the clear on an unbound record
|
|
132
|
+
* could be stripped on disk to open the secret to every workspace; sealed,
|
|
133
|
+
* a stripped list makes it unreadable. Every reader since 29 Oct 2026 reads v2.
|
|
134
|
+
*/
|
|
135
|
+
export declare function setSecretWorkspaces(tenant: string, name: string, workspaces: string[] | null, opts?: {
|
|
136
|
+
exists?: (workspace: string) => boolean;
|
|
137
|
+
ifWorkspaces?: string[] | null;
|
|
138
|
+
}): string[] | null;
|
|
139
|
+
/**
|
|
140
|
+
* A workspace is being renamed (to: its new name) or deleted (to: null):
|
|
141
|
+
* every account secret limited to it follows. Without this a rename quietly
|
|
142
|
+
* cut the workspace off its secrets, and a delete left the name on the list
|
|
143
|
+
* for the next workspace given it.
|
|
144
|
+
*
|
|
145
|
+
* `failed` names the records that hold the name but will not open with this
|
|
146
|
+
* install's keys: they are left exactly as they are, so the old name is
|
|
147
|
+
* STILL on their list — a key restored later would bring the grant back.
|
|
148
|
+
* The caller must not report the rename or delete as clean while any are
|
|
149
|
+
* left (the API refuses it, naming them: set them again, or delete them).
|
|
150
|
+
*
|
|
151
|
+
* Names, not ids: a grant naming a workspace's immutable id would survive a
|
|
152
|
+
* rename untouched and could never pass to a new workspace given an old
|
|
153
|
+
* name — the stronger design, not built yet (30 Oct 2026).
|
|
154
|
+
*/
|
|
155
|
+
export declare function retargetSecretWorkspaces(tenant: string, from: string, to: string | null): {
|
|
156
|
+
changed: string[];
|
|
157
|
+
failed: string[];
|
|
158
|
+
};
|
|
79
159
|
export declare function deleteSecret(tenant: string, name: string, workspace?: string): void;
|
|
80
160
|
export interface SecretEntry {
|
|
81
161
|
name: string;
|
|
@@ -89,6 +169,8 @@ export interface SecretEntry {
|
|
|
89
169
|
/** The sites it is bound to — the proxy fills it for these and no other,
|
|
90
170
|
* and it never enters a sandbox. Absent when unbound. */
|
|
91
171
|
sites?: string[];
|
|
172
|
+
/** An account secret limited to these workspaces; absent: every one. */
|
|
173
|
+
workspaces?: string[];
|
|
92
174
|
}
|
|
93
175
|
export declare function listSecrets(tenant: string, workspace?: string): SecretEntry[];
|
|
94
176
|
export declare function resolveSecrets(tenant: string, names: string[], workspace?: string): {
|
|
@@ -154,7 +236,7 @@ export interface OAuth2Config {
|
|
|
154
236
|
export declare const OAUTH_REFRESH_TRIES = 3;
|
|
155
237
|
/** Wait before try n (1-based retry): 1s, 3s — FOLDRUN_OAUTH_RETRY_MS scales it for tests. */
|
|
156
238
|
export declare function oauthRetryDelayMs(n: number): number;
|
|
157
|
-
export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string): void;
|
|
239
|
+
export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string, limit?: SecretLimit): void;
|
|
158
240
|
export declare function isOAuth2Value(value: string): boolean;
|
|
159
241
|
/**
|
|
160
242
|
* The cache key for a live token: SHA-256 over the kind, the account and the
|
|
@@ -205,8 +287,8 @@ export interface ServiceAccountConfig {
|
|
|
205
287
|
* a Workspace admin grants it. Absent, the account acts as itself. */
|
|
206
288
|
subject?: string;
|
|
207
289
|
}
|
|
208
|
-
export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string): void;
|
|
209
|
-
export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string): void;
|
|
290
|
+
export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string, limit?: SecretLimit): void;
|
|
291
|
+
export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string, limit?: SecretLimit): void;
|
|
210
292
|
export declare function isFileValue(value: string): boolean;
|
|
211
293
|
export declare function fileContent(value: string): string;
|
|
212
294
|
export interface SshConfig {
|
|
@@ -217,7 +299,7 @@ export interface SshConfig {
|
|
|
217
299
|
private_key?: string;
|
|
218
300
|
password?: string;
|
|
219
301
|
}
|
|
220
|
-
export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string): void;
|
|
302
|
+
export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string, limit?: SecretLimit): void;
|
|
221
303
|
export declare function isSshValue(value: string): boolean;
|
|
222
304
|
export declare function sshConfigOf(value: string): SshConfig;
|
|
223
305
|
/** A client certificate for mutual TLS: presented by the egress proxy, on its
|
|
@@ -232,14 +314,14 @@ export interface ClientCertConfig {
|
|
|
232
314
|
passphrase?: string;
|
|
233
315
|
export?: boolean;
|
|
234
316
|
}
|
|
235
|
-
export declare function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string): void;
|
|
317
|
+
export declare function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string, limit?: SecretLimit): void;
|
|
236
318
|
/** A keyless cloud identity: no credential at all, only the audience the
|
|
237
319
|
* platform's signed tokens are for. A step that declares it gets a token
|
|
238
320
|
* file the sandbox driver keeps fresh through the egress proxy. */
|
|
239
321
|
export interface IdentityConfig {
|
|
240
322
|
audience: string;
|
|
241
323
|
}
|
|
242
|
-
export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string): void;
|
|
324
|
+
export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string, limit?: SecretLimit): void;
|
|
243
325
|
export declare function isIdentityValue(value: string): boolean;
|
|
244
326
|
export declare function identityOf(value: string): IdentityConfig;
|
|
245
327
|
export declare function isClientCertValue(value: string): boolean;
|
|
@@ -260,7 +342,7 @@ export interface ApiConfig {
|
|
|
260
342
|
/** `Authorization: Basic …` from a username and password (RFC 7617 §2):
|
|
261
343
|
* UTF-8, no colon in the username, no control characters in either. */
|
|
262
344
|
export declare function basicAuthorization(username: string, password: string): string;
|
|
263
|
-
export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string): void;
|
|
345
|
+
export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string, limit?: SecretLimit): void;
|
|
264
346
|
export declare function isApiValue(value: string): boolean;
|
|
265
347
|
export declare function apiConfigOf(value: string): ApiConfig;
|
|
266
348
|
/**
|