@foldrun/core 0.6.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/agent-import.d.ts +21 -0
- package/dist/src/agent-import.js +116 -0
- package/dist/src/api-tools.js +14 -2
- package/dist/src/approvals.d.ts +8 -1
- package/dist/src/approvals.js +10 -5
- package/dist/src/completions.js +1 -0
- package/dist/src/confine.d.ts +1 -0
- package/dist/src/confine.js +33 -4
- package/dist/src/deploy.d.ts +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +22 -5
- package/dist/src/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -0
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +26 -0
- package/dist/src/install-helper.d.ts +17 -0
- package/dist/src/install-helper.js +134 -0
- package/dist/src/install-tool.d.ts +128 -0
- package/dist/src/install-tool.js +133 -0
- package/dist/src/kinds.d.ts +1 -1
- package/dist/src/kinds.js +26 -0
- package/dist/src/library.d.ts +1 -1
- package/dist/src/library.js +17 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/model-credential.d.ts +16 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- package/dist/src/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- package/dist/src/package.d.ts +67 -0
- package/dist/src/package.js +594 -0
- package/dist/src/platform.d.ts +5 -0
- package/dist/src/platform.js +1 -0
- package/dist/src/provider-health.js +3 -0
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +10 -0
- package/dist/src/run-container.js +72 -14
- package/dist/src/runner.d.ts +205 -0
- package/dist/src/runner.js +273 -36
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +2 -0
- package/dist/src/script-tools.js +3 -1
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +37 -6
- package/dist/src/starter.d.ts +12 -0
- package/dist/src/starter.js +38 -4
- package/dist/src/step-exec.d.ts +21 -2
- package/dist/src/step-exec.js +165 -7
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +31 -1
- package/dist/src/store.js +90 -8
- package/dist/src/tool-names.d.ts +3 -1
- package/dist/src/tool-names.js +4 -2
- package/dist/src/tool-test.js +8 -1
- package/dist/src/zip.d.ts +17 -0
- package/dist/src/zip.js +184 -0
- package/index.ts +3 -0
- package/package.json +18 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/deploy.ts +28 -3
- package/src/evals.ts +21 -3
- package/src/hooks.ts +406 -0
- package/src/host-env.ts +27 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/model-credential.ts +41 -0
- package/src/model-env.ts +66 -0
- package/src/notify.ts +11 -1
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/providers.ts +27 -1
- package/src/run-container.ts +75 -14
- package/src/runner.ts +279 -36
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +5 -1
- package/src/secrets.ts +34 -6
- package/src/starter.ts +44 -4
- package/src/step-exec.ts +171 -11
- package/src/storage.ts +13 -0
- package/src/store.ts +89 -7
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
package/dist/src/step-exec.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// assembles the system prompt and builds MCP servers, then hands this the
|
|
8
8
|
// results. That is what lets the same function run somewhere the vault, the
|
|
9
9
|
// library and the account do not exist.
|
|
10
|
+
import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks } from "./hooks.js";
|
|
10
11
|
import fs from "node:fs";
|
|
11
12
|
import os from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
@@ -169,8 +170,49 @@ async function executeStepInner(opts, runQuery) {
|
|
|
169
170
|
// `limits:` — this step's counts. A new step, a retry of one, or a
|
|
170
171
|
// fan-out instance each start from zero.
|
|
171
172
|
const counter = new CallCounter(opts.limits);
|
|
173
|
+
// Hooks (hooks.ts): the person's own code at this dispatch, same on every
|
|
174
|
+
// model. Each runs in the agent's directory with a bare environment.
|
|
175
|
+
let hooks = opts.hooks ?? [];
|
|
176
|
+
if (opts.hookNames?.length) {
|
|
177
|
+
const r = resolveHooks({ workspaceRoot, libraryRoot, names: opts.hookNames });
|
|
178
|
+
const bad = [...r.missing.map((n) => `no hook named ${n} in hooks/ or the library`), ...r.errors];
|
|
179
|
+
if (bad.length) {
|
|
180
|
+
emit("error", `hooks: ${bad.join("; ")} — the step is refused rather than run without its hooks`);
|
|
181
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
182
|
+
}
|
|
183
|
+
hooks = [...hooks, ...r.hooks];
|
|
184
|
+
}
|
|
185
|
+
const hookBase = { ...(opts.hookBase ?? {}) };
|
|
186
|
+
const callHook = (hook, event) => runHook(hook, event, { cwd: agentDir, env: opts.env });
|
|
187
|
+
const record = (r, via) => emit(r.did === "error" || r.did === "deny" ? "error" : "info", hookLine(r), { hook: r, ...(via ? { subagent: via } : {}) });
|
|
188
|
+
// step.start — a gate: context it returns goes in front of the task; a
|
|
189
|
+
// hook that fails refuses the step rather than run it unguarded.
|
|
190
|
+
let prompt = opts.prompt;
|
|
191
|
+
for (const hook of hooks.filter((h) => h.on === "step.start")) {
|
|
192
|
+
const a = await callHook(hook, { ...hookBase, event: "step.start", hook: hook.name, prompt });
|
|
193
|
+
if (!a.ok) {
|
|
194
|
+
record({ hook: hook.name, event: "step.start", did: "error", reason: `${a.failure}, so the step is refused`, ms: a.ms });
|
|
195
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
196
|
+
}
|
|
197
|
+
// Exit 2 is a deny on every event (runHook). On a gate that means the
|
|
198
|
+
// step does not run — it used to come back ok with no context, and the
|
|
199
|
+
// step went ahead as if the hook had agreed.
|
|
200
|
+
if (a.decision === "deny") {
|
|
201
|
+
record({ hook: hook.name, event: "step.start", did: "deny", reason: `${a.reason}, so the step is refused`, ms: a.ms });
|
|
202
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
203
|
+
}
|
|
204
|
+
if (a.context) {
|
|
205
|
+
prompt = `${prompt}\n\n${a.context}`;
|
|
206
|
+
record({ hook: hook.name, event: "step.start", did: "context", ms: a.ms });
|
|
207
|
+
}
|
|
208
|
+
else if (a.note) {
|
|
209
|
+
record({ hook: hook.name, event: "step.start", did: "note", reason: a.note, ms: a.ms });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const toolHooks = hooks.filter((h) => h.on === "tool.call");
|
|
213
|
+
const resultHooks = hooks.filter((h) => h.on === "tool.result");
|
|
172
214
|
const q = runQuery({
|
|
173
|
-
prompt
|
|
215
|
+
prompt,
|
|
174
216
|
options: {
|
|
175
217
|
cwd: agentDir,
|
|
176
218
|
abortController: abort,
|
|
@@ -188,6 +230,13 @@ async function executeStepInner(opts, runQuery) {
|
|
|
188
230
|
// declares no tools gets none, instead of seeing the full Claude
|
|
189
231
|
// Code toolset and burning turns on denied calls.
|
|
190
232
|
tools: opts.allowed,
|
|
233
|
+
// Stated, never left to the SDK. Unset, it chose its own mode for the
|
|
234
|
+
// login it ran under, and in auto mode its classifier refused a granted
|
|
235
|
+
// script before canUseTool saw it: "denied by the Claude Code auto mode
|
|
236
|
+
// classifier. Reason: [Irreversible Local Destruction]" for backlink-
|
|
237
|
+
// desk's outreach_reset, which only marks old bodies STALE (2026-10-04
|
|
238
|
+
// and 05). The decision is ours: canUseTool and the PreToolUse hook.
|
|
239
|
+
permissionMode: "default",
|
|
191
240
|
// Deliberately NOT allowedTools: listing them there auto-approves and
|
|
192
241
|
// skips canUseTool entirely, which is where confinement happens. The
|
|
193
242
|
// toolset is still restricted by `tools` above.
|
|
@@ -265,6 +314,46 @@ async function executeStepInner(opts, runQuery) {
|
|
|
265
314
|
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal.message } };
|
|
266
315
|
}
|
|
267
316
|
}
|
|
317
|
+
// tool.call hooks — after foldrun's own checks, so a hook never
|
|
318
|
+
// sees a call that was going to be refused anyway, and handed the
|
|
319
|
+
// input as the tool would get it (the workspace/ prefix expanded).
|
|
320
|
+
if (toolHooks.length) {
|
|
321
|
+
const input = (verdict?.updatedInput ?? hookInput.tool_input ?? {});
|
|
322
|
+
// A sub-agent's call says so: a hook that guards the step's own
|
|
323
|
+
// model differently from its delegates can tell them apart.
|
|
324
|
+
const v = await runToolCallHooks(toolHooks, { tool: hookInput.tool_name, input, base: via ? { ...hookBase, subagent: via } : hookBase }, callHook);
|
|
325
|
+
for (const r of v.records)
|
|
326
|
+
record(r, via);
|
|
327
|
+
const refundCall = () => { if (counter.active)
|
|
328
|
+
counter.refund(limitKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners)); };
|
|
329
|
+
if (v.decision === "deny") {
|
|
330
|
+
refundCall();
|
|
331
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: v.reason } };
|
|
332
|
+
}
|
|
333
|
+
if (v.input) {
|
|
334
|
+
// A rewritten call is answered "allow", and an allow skips
|
|
335
|
+
// canUseTool — where the shell and path confinement live. So
|
|
336
|
+
// the rewrite goes through the same checks here: a hook can
|
|
337
|
+
// narrow what the agent does, never widen it.
|
|
338
|
+
const again = hookInput.tool_name === "Bash"
|
|
339
|
+
? checkBash(String(v.input.command ?? ""))
|
|
340
|
+
: isFilesystemTool(hookInput.tool_name)
|
|
341
|
+
? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
|
|
342
|
+
: { ok: true };
|
|
343
|
+
const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
|
|
344
|
+
// The sub-agent guard read the ORIGINAL input: a rewrite of a
|
|
345
|
+
// delegation could otherwise swap subagent_type for one this
|
|
346
|
+
// step never declared (or the SDK's general-purpose).
|
|
347
|
+
const delegation = guardSubagent ? guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: v.input }) : null;
|
|
348
|
+
if (!again.ok || !granted || delegation) {
|
|
349
|
+
refundCall();
|
|
350
|
+
const why = !granted ? `Tool ${hookInput.tool_name} is not enabled for this agent.` : delegation ? `the rewritten call: ${delegation}` : `the rewritten call: ${again.reason}`;
|
|
351
|
+
emit("error", why, via ? { subagent: via } : undefined);
|
|
352
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: why } };
|
|
353
|
+
}
|
|
354
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: again.updatedInput ?? v.input } };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
268
357
|
if (!verdict?.updatedInput)
|
|
269
358
|
return {};
|
|
270
359
|
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: verdict.updatedInput } };
|
|
@@ -274,12 +363,26 @@ async function executeStepInner(opts, runQuery) {
|
|
|
274
363
|
// after its next tool call, as context — never as a change to what it
|
|
275
364
|
// may do. A model that is only writing, calling no tools, hears it at
|
|
276
365
|
// the next call it makes.
|
|
277
|
-
...(opts.inbox
|
|
366
|
+
...(opts.inbox || resultHooks.length
|
|
278
367
|
? {
|
|
279
368
|
PostToolUse: [{
|
|
280
369
|
hooks: [async (hookInput) => {
|
|
281
370
|
if (hookInput.hook_event_name !== "PostToolUse")
|
|
282
371
|
return {};
|
|
372
|
+
// tool.result hooks watch: the call has happened, so one
|
|
373
|
+
// that fails is recorded and the step goes on.
|
|
374
|
+
for (const hook of resultHooks) {
|
|
375
|
+
if (!matchesTool(hook.tool, hookInput.tool_name))
|
|
376
|
+
continue;
|
|
377
|
+
const response = hookInput.tool_response;
|
|
378
|
+
const a = await callHook(hook, { ...hookBase, event: "tool.result", hook: hook.name, tool: { name: hookInput.tool_name, input: hookInput.tool_input ?? {} }, result: response ?? null });
|
|
379
|
+
if (!a.ok)
|
|
380
|
+
record({ hook: hook.name, event: "tool.result", did: "error", tool: hookInput.tool_name, reason: a.failure, ms: a.ms });
|
|
381
|
+
else if (a.note)
|
|
382
|
+
record({ hook: hook.name, event: "tool.result", did: "note", tool: hookInput.tool_name, reason: a.note, ms: a.ms });
|
|
383
|
+
}
|
|
384
|
+
if (!opts.inbox)
|
|
385
|
+
return {};
|
|
283
386
|
// A sub-agent's call: the message is for the step's own
|
|
284
387
|
// model, and draining it here would hand it to a context
|
|
285
388
|
// that ends with the delegation — and lose it.
|
|
@@ -511,12 +614,25 @@ async function executeStepInner(opts, runQuery) {
|
|
|
511
614
|
...(block.is_error ? { err: true } : {}),
|
|
512
615
|
...(open.subagent ? { subagent: open.subagent } : {}),
|
|
513
616
|
});
|
|
617
|
+
// Why it failed, on the trail. The span above carried only `err`,
|
|
618
|
+
// so a refused call left the agent's own guess as the only account
|
|
619
|
+
// of it: backlink-desk's outreach_reset "denied by the permission
|
|
620
|
+
// classifier" (run-muufboma-2t6l, 2026-10-05) could not be checked.
|
|
621
|
+
if (block.is_error) {
|
|
622
|
+
const why = toolResultText(block.content).replace(/\s+/g, " ").trim().slice(0, 300);
|
|
623
|
+
if (why)
|
|
624
|
+
emit("error", `${open.name}: ${why}`, { call: block.tool_use_id, ...(open.subagent ? { subagent: open.subagent } : {}) });
|
|
625
|
+
}
|
|
514
626
|
readPod(block.tool_use_id);
|
|
515
627
|
}
|
|
516
628
|
}
|
|
517
629
|
}
|
|
518
630
|
else if (message.type === "result") {
|
|
519
631
|
status = message.subtype === "success" ? "completed" : "failed";
|
|
632
|
+
// Calls the SDK itself refused, which canUseTool never saw.
|
|
633
|
+
for (const d of ("permission_denials" in message && Array.isArray(message.permission_denials) ? message.permission_denials : [])) {
|
|
634
|
+
emit("error", `refused before it ran: ${String(d.tool_name ?? "a tool")}`);
|
|
635
|
+
}
|
|
520
636
|
if (message.subtype === "error_max_turns") {
|
|
521
637
|
emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
|
|
522
638
|
}
|
|
@@ -594,6 +710,21 @@ async function executeStepInner(opts, runQuery) {
|
|
|
594
710
|
status = "failed";
|
|
595
711
|
emit("error", "the model stream ended without a result — the model process likely died (out of memory?)");
|
|
596
712
|
}
|
|
713
|
+
// step.end hooks watch the reply, and may rewrite it. One that fails is
|
|
714
|
+
// recorded and the reply stands: the work it describes is done.
|
|
715
|
+
if (status === "completed") {
|
|
716
|
+
for (const hook of hooks.filter((h) => h.on === "step.end")) {
|
|
717
|
+
const a = await callHook(hook, { ...hookBase, event: "step.end", hook: hook.name, reply: texts.join("\n").trim() });
|
|
718
|
+
if (!a.ok)
|
|
719
|
+
record({ hook: hook.name, event: "step.end", did: "error", reason: a.failure, ms: a.ms });
|
|
720
|
+
else if (a.reply !== undefined) {
|
|
721
|
+
texts.splice(0, texts.length, a.reply);
|
|
722
|
+
record({ hook: hook.name, event: "step.end", did: "reply", ms: a.ms });
|
|
723
|
+
}
|
|
724
|
+
else if (a.note)
|
|
725
|
+
record({ hook: hook.name, event: "step.end", did: "note", reason: a.note, ms: a.ms });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
597
728
|
const result = texts.join("\n").trim() || null;
|
|
598
729
|
// A step's reply is not one message: the model narrates ("Now let me read
|
|
599
730
|
// the outputs…"), calls tools, and answers last. Joining those and reading
|
|
@@ -801,7 +932,13 @@ export async function checkVerify(agentDir, verify, ctx) {
|
|
|
801
932
|
return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
|
|
802
933
|
}
|
|
803
934
|
case "judge": {
|
|
804
|
-
|
|
935
|
+
// The reply is the final turn, as everywhere else; every turn goes
|
|
936
|
+
// beside it as the work, so a claim about what the step did can still
|
|
937
|
+
// be checked. Both as one <reply> failed `judge: the reply opens with
|
|
938
|
+
// GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
|
|
939
|
+
// text opened with the narration before it.
|
|
940
|
+
const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
|
|
941
|
+
const verdict = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal);
|
|
805
942
|
const ok = /^\s*PASS\b/i.test(verdict);
|
|
806
943
|
return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
|
|
807
944
|
}
|
|
@@ -813,7 +950,18 @@ export async function checkVerify(agentDir, verify, ctx) {
|
|
|
813
950
|
* Same shape as the eval judge, and answered with one word first so the
|
|
814
951
|
* verdict is a prefix test rather than a reading.
|
|
815
952
|
*/
|
|
816
|
-
|
|
953
|
+
export function judgePrompt(rubric, reply, work) {
|
|
954
|
+
const shown = reply.slice(0, 20_000);
|
|
955
|
+
const context = work.trim() && work.trim() !== reply.trim()
|
|
956
|
+
? `Everything the step wrote while working, for context. "The reply" means only the reply block below, ` +
|
|
957
|
+
`never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
|
|
958
|
+
: "";
|
|
959
|
+
return (`You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
|
|
960
|
+
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
961
|
+
context +
|
|
962
|
+
`<reply>\n${shown}\n</reply>`);
|
|
963
|
+
}
|
|
964
|
+
async function judgeReply(rubric, reply, work, env, signal) {
|
|
817
965
|
const texts = [];
|
|
818
966
|
const abortController = new AbortController();
|
|
819
967
|
if (signal?.aborted)
|
|
@@ -821,9 +969,7 @@ async function judgeReply(rubric, output, env, signal) {
|
|
|
821
969
|
signal?.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
822
970
|
try {
|
|
823
971
|
const q = query({
|
|
824
|
-
prompt:
|
|
825
|
-
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
826
|
-
`<reply>\n${output.slice(0, 40_000)}\n</reply>`,
|
|
972
|
+
prompt: judgePrompt(rubric, reply, work),
|
|
827
973
|
options: {
|
|
828
974
|
model: "haiku",
|
|
829
975
|
systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
|
|
@@ -914,3 +1060,15 @@ function runVerify(agentDir, command, env, data, signal) {
|
|
|
914
1060
|
export function sdkEnv(base, extra = {}) {
|
|
915
1061
|
return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
|
|
916
1062
|
}
|
|
1063
|
+
/** The text of a tool result, whichever shape it came in: a string, or a
|
|
1064
|
+
* list of content blocks of which only the text ones say anything. */
|
|
1065
|
+
export function toolResultText(content) {
|
|
1066
|
+
if (typeof content === "string")
|
|
1067
|
+
return content;
|
|
1068
|
+
if (!Array.isArray(content))
|
|
1069
|
+
return "";
|
|
1070
|
+
return content
|
|
1071
|
+
.filter((b) => !!b && b.type === "text" && typeof b.text === "string")
|
|
1072
|
+
.map((b) => b.text)
|
|
1073
|
+
.join(" ");
|
|
1074
|
+
}
|
package/dist/src/storage.d.ts
CHANGED
|
@@ -128,6 +128,10 @@ export declare function uploadUrl(tenant: string, workspace: string, rel: string
|
|
|
128
128
|
* is worth writing when a workspace actually outgrows its quota, not before.
|
|
129
129
|
*/
|
|
130
130
|
export declare function materializeFiles(tenant: string, workspace: string): Promise<string[]>;
|
|
131
|
+
/** path → sha of every stored file: what `storage/` holds once
|
|
132
|
+
* materializeFiles has run. A run keeps it to tell its own writes from the
|
|
133
|
+
* files it was handed. */
|
|
134
|
+
export declare function storageBaseline(tenant: string, workspace: string): Record<string, string>;
|
|
131
135
|
/**
|
|
132
136
|
* Take what the run left in `storage/` back into the store, stamped with the
|
|
133
137
|
* run that wrote it.
|
|
@@ -136,7 +140,9 @@ export declare function materializeFiles(tenant: string, workspace: string): Pro
|
|
|
136
140
|
* derived from the index, so anything on disk that the index does not already
|
|
137
141
|
* describe with the same size and hash is, by construction, new work.
|
|
138
142
|
*/
|
|
139
|
-
export declare function harvestFiles(tenant: string, workspace: string, by: string
|
|
143
|
+
export declare function harvestFiles(tenant: string, workspace: string, by: string, opts?: {
|
|
144
|
+
changedFrom?: Record<string, string>;
|
|
145
|
+
}): Promise<{
|
|
140
146
|
saved: string[];
|
|
141
147
|
errors: string[];
|
|
142
148
|
}>;
|
package/dist/src/storage.js
CHANGED
|
@@ -686,6 +686,14 @@ export async function materializeFiles(tenant, workspace) {
|
|
|
686
686
|
fs.mkdirSync(root, { recursive: true });
|
|
687
687
|
return brought;
|
|
688
688
|
}
|
|
689
|
+
/** path → sha of every stored file: what `storage/` holds once
|
|
690
|
+
* materializeFiles has run. A run keeps it to tell its own writes from the
|
|
691
|
+
* files it was handed. */
|
|
692
|
+
export function storageBaseline(tenant, workspace) {
|
|
693
|
+
if (!fileStoreEnabled())
|
|
694
|
+
return {};
|
|
695
|
+
return Object.fromEntries(readIndex(tenant, workspace).files.map((f) => [f.path, f.sha]));
|
|
696
|
+
}
|
|
689
697
|
/**
|
|
690
698
|
* Take what the run left in `storage/` back into the store, stamped with the
|
|
691
699
|
* run that wrote it.
|
|
@@ -694,7 +702,7 @@ export async function materializeFiles(tenant, workspace) {
|
|
|
694
702
|
* derived from the index, so anything on disk that the index does not already
|
|
695
703
|
* describe with the same size and hash is, by construction, new work.
|
|
696
704
|
*/
|
|
697
|
-
export async function harvestFiles(tenant, workspace, by) {
|
|
705
|
+
export async function harvestFiles(tenant, workspace, by, opts = {}) {
|
|
698
706
|
if (!fileStoreEnabled())
|
|
699
707
|
return { saved: [], errors: [] };
|
|
700
708
|
adoptLegacyFilesDir(workspaceDir(tenant, workspace));
|
|
@@ -729,6 +737,11 @@ export async function harvestFiles(tenant, workspace, by) {
|
|
|
729
737
|
if (sha256(fs.readFileSync(abs)) === previous.sha)
|
|
730
738
|
continue;
|
|
731
739
|
}
|
|
740
|
+
// Only what the run itself changed: a file still holding the bytes it
|
|
741
|
+
// was handed is not this run's work, and the index may have moved on
|
|
742
|
+
// since (an upload while the run was going) — that must win.
|
|
743
|
+
if (opts.changedFrom && opts.changedFrom[norm] === sha256(fs.readFileSync(abs)))
|
|
744
|
+
continue;
|
|
732
745
|
await putFileQuietly(tenant, workspace, norm, fs.readFileSync(abs), by);
|
|
733
746
|
saved.push(norm);
|
|
734
747
|
}
|
package/dist/src/store.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { type WireFormat, type AuthShape } from "./providers.ts";
|
|
|
20
20
|
* core needs to know about which one it's in.
|
|
21
21
|
*/
|
|
22
22
|
export { singleWorkspace };
|
|
23
|
-
export declare const WORKSPACE_DIRS: readonly ["agents", "flows", "evals", "skills", "memory", "knowledge", "tools", "scripts", "state"];
|
|
23
|
+
export declare const WORKSPACE_DIRS: readonly ["agents", "flows", "evals", "skills", "memory", "knowledge", "tools", "scripts", "hooks", "state"];
|
|
24
24
|
export declare const STORAGE_DIR = "storage";
|
|
25
25
|
/** What this directory was called before. Read-only: nothing writes here any
|
|
26
26
|
* more, but installs created before the rename have bytes under it, and a
|
|
@@ -188,6 +188,15 @@ export declare function fencedCodeBlock(body: string): {
|
|
|
188
188
|
* failure costs something that cannot be taken back. See `lintFlow`.
|
|
189
189
|
*/
|
|
190
190
|
export declare function toolIsOutward(data: Record<string, unknown>): boolean;
|
|
191
|
+
/** How much of a tool.md body rides to the model with the tool. */
|
|
192
|
+
export declare const TOOL_GUIDE_MAX = 6000;
|
|
193
|
+
/**
|
|
194
|
+
* The prose under a tool.md's frontmatter: how to call it, what it refuses,
|
|
195
|
+
* what its output means. Only `description:` used to reach the model, so
|
|
196
|
+
* agents invented what the body documented (5 Oct 2026). The fenced program
|
|
197
|
+
* of a single-file tool is code, not guidance, and stays out.
|
|
198
|
+
*/
|
|
199
|
+
export declare function toolGuide(body: string | undefined, inlineProgram?: boolean): string | undefined;
|
|
191
200
|
export declare function parseToolDef(data: Record<string, unknown>, fallbackName: string, body?: string): ToolDef | null;
|
|
192
201
|
/**
|
|
193
202
|
* Tools in a directory, in both shapes.
|
|
@@ -234,6 +243,8 @@ export interface ApiSpec {
|
|
|
234
243
|
name: string;
|
|
235
244
|
base: string;
|
|
236
245
|
description: string;
|
|
246
|
+
/** The tool.md body, for the model (see toolGuide). */
|
|
247
|
+
guide?: string;
|
|
237
248
|
headers: Record<string, string>;
|
|
238
249
|
query: Record<string, string>;
|
|
239
250
|
methods: string[];
|
|
@@ -846,6 +857,9 @@ export declare function setWorkspaceDescription(tenant: string, workspace: strin
|
|
|
846
857
|
* this copy still said `type: Agent` after the CLI's had moved to `kind:`,
|
|
847
858
|
* so every workspace made from the dashboard was born in the old format.
|
|
848
859
|
*/
|
|
860
|
+
/** What the dashboard's "Create workspace" makes: blank (blankWorkspaceFiles),
|
|
861
|
+
* minus the laptop-only files, for the same reason as templateFiles. */
|
|
862
|
+
export declare function blankTemplateFiles(workspace: string): DeployFile[];
|
|
849
863
|
export declare function templateFiles(workspace: string): DeployFile[];
|
|
850
864
|
export declare const AGENT_TEMPLATE: (name: string) => string;
|
|
851
865
|
export declare const FLOW_TEMPLATE: (name: string, firstAgent: string) => string;
|
|
@@ -879,6 +893,16 @@ export interface RunEvent {
|
|
|
879
893
|
* detail), `output: json`, a schema. What the runner reads to decline a
|
|
880
894
|
* retry of an outward step whose tools already ran. */
|
|
881
895
|
check?: boolean;
|
|
896
|
+
/** Set on the line a hook wrote — a deny, a rewrite, context, a failure
|
|
897
|
+
* (hooks.ts). The run page and the Hooks page read these. */
|
|
898
|
+
hook?: {
|
|
899
|
+
hook: string;
|
|
900
|
+
event: string;
|
|
901
|
+
did: string;
|
|
902
|
+
tool?: string;
|
|
903
|
+
reason?: string;
|
|
904
|
+
ms: number;
|
|
905
|
+
};
|
|
882
906
|
}
|
|
883
907
|
/** A question an agent asked a person while its step ran (tools: [ask]). */
|
|
884
908
|
export interface StepQuestion {
|
|
@@ -1341,6 +1365,12 @@ export declare function runCost(run: RunRecord): number;
|
|
|
1341
1365
|
* fan-out template is a row in the trace rather than work — billing any of
|
|
1342
1366
|
* them would charge for steps the customer can see never executed, which is
|
|
1343
1367
|
* the fastest way to lose an argument about an invoice.
|
|
1368
|
+
*
|
|
1369
|
+
* Tokens count only for steps that ran on the platform's own model
|
|
1370
|
+
* credential. A step on the account's own key (`credential` "provider: …" —
|
|
1371
|
+
* a provider: block, or Settings → Model) was paid for at the provider, and
|
|
1372
|
+
* billing it again here charged a BYOK customer twice. And a carried step's
|
|
1373
|
+
* tokens were billed where it ran, like its compute.
|
|
1344
1374
|
*/
|
|
1345
1375
|
export declare function runMeter(run: RunRecord): {
|
|
1346
1376
|
tokenCostUsd: number;
|
package/dist/src/store.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// or an agent, and belongs in git.
|
|
20
20
|
//
|
|
21
21
|
// An account is a named directory (per OKF: tenancy is a hosting concern).
|
|
22
|
+
import { expandPlatformVars } from "./host-env.js";
|
|
22
23
|
import fs from "node:fs";
|
|
23
24
|
import path from "node:path";
|
|
24
25
|
import { recordRevision, registerTreeReader } from "./history.js";
|
|
@@ -50,7 +51,7 @@ import { localeProblems } from "./locale.js";
|
|
|
50
51
|
import { readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles, } from "./okf.js";
|
|
51
52
|
import { readTransport, KINDS } from "./kinds.js";
|
|
52
53
|
import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS } from "./providers.js";
|
|
53
|
-
import { starterFiles, accountFiles } from "./starter.js";
|
|
54
|
+
import { starterFiles, accountFiles, blankWorkspaceFiles } from "./starter.js";
|
|
54
55
|
import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.js";
|
|
55
56
|
// Where workspaces live. The hosted app keeps many under data/; the CLI runs
|
|
56
57
|
// against one folder, which is what `foldrun run ./my-desk` has to mean.
|
|
@@ -73,6 +74,7 @@ export const WORKSPACE_DIRS = [
|
|
|
73
74
|
"knowledge",
|
|
74
75
|
"tools",
|
|
75
76
|
"scripts",
|
|
77
|
+
"hooks",
|
|
76
78
|
"state",
|
|
77
79
|
];
|
|
78
80
|
const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
|
|
@@ -288,6 +290,18 @@ export function assertCanonicalCase(rel) {
|
|
|
288
290
|
// trigger-log.jsonl: the scheduler's record of why a flow did or did not
|
|
289
291
|
// fire (trigger-log.ts). Before it was listed here every deploy erased it.
|
|
290
292
|
export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
|
|
293
|
+
// The agent engine's own home files. Each step's sandbox runs the Claude
|
|
294
|
+
// Agent SDK — the engine, whatever model answers — with its home in the
|
|
295
|
+
// agent's folder, and the engine writes its settings (.claude.json, its
|
|
296
|
+
// backups), plugin caches and the full transcript of every session
|
|
297
|
+
// (.claude/projects/*.jsonl) there. Nothing in foldrun reads them. Copied
|
|
298
|
+
// back after every step, they had piled up to 5,274 files and 19 MB in one
|
|
299
|
+
// agent's folder on the box by 6 Oct 2026, carried every past conversation
|
|
300
|
+
// into every sandbox, and their clashing rewrites were the "write-back
|
|
301
|
+
// conflict on .claude.json" lines (and the big merges) of that day. A
|
|
302
|
+
// workspace's own .claude/agents/*.md — Claude Code subagents an author
|
|
303
|
+
// writes, imported as agents — sits at the workspace root and is untouched.
|
|
304
|
+
const ENGINE_HOME_RE = /^agents\/[^/]+\/\.claude([./]|$)/;
|
|
291
305
|
/** True for anything the platform, not the author or the agent, writes. */
|
|
292
306
|
export function isPlatformPath(rel) {
|
|
293
307
|
const norm = rel.replaceAll("\\", "/");
|
|
@@ -299,6 +313,8 @@ export function isPlatformPath(rel) {
|
|
|
299
313
|
return true;
|
|
300
314
|
if (norm === "trigger-log.jsonl.tmp")
|
|
301
315
|
return true;
|
|
316
|
+
if (ENGINE_HOME_RE.test(norm))
|
|
317
|
+
return true;
|
|
302
318
|
return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
|
|
303
319
|
}
|
|
304
320
|
/** What a deploy keeps whatever it ships — the rule above saveWorkspace's
|
|
@@ -547,6 +563,28 @@ export function fencedCodeBlock(body) {
|
|
|
547
563
|
export function toolIsOutward(data) {
|
|
548
564
|
return data.outward === true;
|
|
549
565
|
}
|
|
566
|
+
/** How much of a tool.md body rides to the model with the tool. */
|
|
567
|
+
export const TOOL_GUIDE_MAX = 6000;
|
|
568
|
+
/**
|
|
569
|
+
* The prose under a tool.md's frontmatter: how to call it, what it refuses,
|
|
570
|
+
* what its output means. Only `description:` used to reach the model, so
|
|
571
|
+
* agents invented what the body documented (5 Oct 2026). The fenced program
|
|
572
|
+
* of a single-file tool is code, not guidance, and stays out.
|
|
573
|
+
*/
|
|
574
|
+
export function toolGuide(body, inlineProgram = false) {
|
|
575
|
+
if (!body)
|
|
576
|
+
return undefined;
|
|
577
|
+
let text = body.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
|
|
578
|
+
// Only a single-file tool's program is cut; in a `run:` tool every fenced
|
|
579
|
+
// block is an example of a call, which is exactly what the model needs.
|
|
580
|
+
const inline = inlineProgram ? fencedCodeBlock(text) : null;
|
|
581
|
+
if (inline)
|
|
582
|
+
text = text.slice(0, inline.start) + text.slice(inline.end);
|
|
583
|
+
text = text.trim();
|
|
584
|
+
if (!text)
|
|
585
|
+
return undefined;
|
|
586
|
+
return text.length > TOOL_GUIDE_MAX ? `${text.slice(0, TOOL_GUIDE_MAX)}\n\n[the rest of tool.md is cut here]` : text;
|
|
587
|
+
}
|
|
550
588
|
export function parseToolDef(data, fallbackName, body) {
|
|
551
589
|
const name = typeof data.name === "string" ? data.name : fallbackName;
|
|
552
590
|
// `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
|
|
@@ -580,18 +618,28 @@ export function parseToolDef(data, fallbackName, body) {
|
|
|
580
618
|
};
|
|
581
619
|
}
|
|
582
620
|
if (kind === "script") {
|
|
621
|
+
// `guide` is the body's, never the frontmatter's: an author's `guide:`
|
|
622
|
+
// would otherwise ride to the model in place of the manual.
|
|
623
|
+
if ("guide" in data) {
|
|
624
|
+
data = { ...data };
|
|
625
|
+
delete data.guide;
|
|
626
|
+
}
|
|
583
627
|
// The program is a run: path, or the file's own fenced code block — the
|
|
584
628
|
// single-file form, which is what lets a script tool read and edit like
|
|
585
629
|
// every other markdown document.
|
|
586
|
-
if (typeof data.run === "string")
|
|
587
|
-
|
|
630
|
+
if (typeof data.run === "string") {
|
|
631
|
+
const guide = toolGuide(body);
|
|
632
|
+
return { kind: "script", name, spec: { ...data, name, ...(guide ? { guide } : {}) } };
|
|
633
|
+
}
|
|
588
634
|
const inline = body ? fencedCode(body) : null;
|
|
589
635
|
if (!inline)
|
|
590
636
|
return null;
|
|
591
|
-
|
|
637
|
+
const guide = toolGuide(body, true);
|
|
638
|
+
return { kind: "script", name, spec: { ...data, name, code: inline.code, codeExt: inline.ext, ...(guide ? { guide } : {}) } };
|
|
592
639
|
}
|
|
593
640
|
const [spec] = parseApis([{ ...data, name }]);
|
|
594
|
-
|
|
641
|
+
const guide = toolGuide(body);
|
|
642
|
+
return spec ? { kind: "http", name, spec: guide ? { ...spec, guide } : spec } : null;
|
|
595
643
|
}
|
|
596
644
|
/**
|
|
597
645
|
* Tools in a directory, in both shapes.
|
|
@@ -955,7 +1003,9 @@ export function parseApis(raw) {
|
|
|
955
1003
|
out.push({
|
|
956
1004
|
name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
|
|
957
1005
|
...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
|
|
958
|
-
|
|
1006
|
+
// `${FOLDRUN_API_URL}` and `${FOLDRUN_PUBLIC_URL}` are the install's own
|
|
1007
|
+
// addresses, filled here on the host so a desk never names one.
|
|
1008
|
+
base: trimSlashes(expandPlatformVars(base)),
|
|
959
1009
|
description: typeof e.description === "string" ? e.description : "",
|
|
960
1010
|
headers: asRecordLocal(e.headers),
|
|
961
1011
|
query: asRecordLocal(e.query),
|
|
@@ -2430,6 +2480,11 @@ export function setWorkspaceDescription(tenant, workspace, description) {
|
|
|
2430
2480
|
* this copy still said `type: Agent` after the CLI's had moved to `kind:`,
|
|
2431
2481
|
* so every workspace made from the dashboard was born in the old format.
|
|
2432
2482
|
*/
|
|
2483
|
+
/** What the dashboard's "Create workspace" makes: blank (blankWorkspaceFiles),
|
|
2484
|
+
* minus the laptop-only files, for the same reason as templateFiles. */
|
|
2485
|
+
export function blankTemplateFiles(workspace) {
|
|
2486
|
+
return blankWorkspaceFiles(workspace).filter((f) => f.path === "AGENTS.md" || IN_WORKSPACE_DIR.test(f.path));
|
|
2487
|
+
}
|
|
2433
2488
|
export function templateFiles(workspace) {
|
|
2434
2489
|
// The starter minus what only matters on a laptop's disk: .gitignore
|
|
2435
2490
|
// guards a local clone's secrets, but the hosted store never keeps
|
|
@@ -2579,7 +2634,25 @@ export function runOutputPath(tenant, workspace, runId, agent, rel) {
|
|
|
2579
2634
|
}
|
|
2580
2635
|
export function readRun(tenant, workspace, runId) {
|
|
2581
2636
|
const p = runFilePath(tenant, workspace, runId);
|
|
2582
|
-
|
|
2637
|
+
// EFS/NFS: writeRun() replaces this file by rename, so an open()+read()
|
|
2638
|
+
// here can land on the just-unlinked inode and come back ESTALE (errno
|
|
2639
|
+
// -116, surfaced by Node as "Unknown system error -116", syscall read).
|
|
2640
|
+
// That isn't corruption — the next open() by path sees the committed new
|
|
2641
|
+
// inode — so re-read a few times on ESTALE and rethrow anything else.
|
|
2642
|
+
// (On dev-aws this surfaced as a CLI `--wait` call returning HTTP 500
|
|
2643
|
+
// from waitForRun's poll while the run itself completed fine.)
|
|
2644
|
+
const attempts = 5;
|
|
2645
|
+
for (let i = 1;; i++) {
|
|
2646
|
+
try {
|
|
2647
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
|
|
2648
|
+
}
|
|
2649
|
+
catch (e) {
|
|
2650
|
+
const err = e;
|
|
2651
|
+
if ((err.code === "ESTALE" || err.errno === -116) && i < attempts)
|
|
2652
|
+
continue;
|
|
2653
|
+
throw e;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2583
2656
|
}
|
|
2584
2657
|
export function writeRun(tenant, workspace, run) {
|
|
2585
2658
|
const p = runFilePath(tenant, workspace, run.id);
|
|
@@ -2825,12 +2898,19 @@ export function runCost(run) {
|
|
|
2825
2898
|
* fan-out template is a row in the trace rather than work — billing any of
|
|
2826
2899
|
* them would charge for steps the customer can see never executed, which is
|
|
2827
2900
|
* the fastest way to lose an argument about an invoice.
|
|
2901
|
+
*
|
|
2902
|
+
* Tokens count only for steps that ran on the platform's own model
|
|
2903
|
+
* credential. A step on the account's own key (`credential` "provider: …" —
|
|
2904
|
+
* a provider: block, or Settings → Model) was paid for at the provider, and
|
|
2905
|
+
* billing it again here charged a BYOK customer twice. And a carried step's
|
|
2906
|
+
* tokens were billed where it ran, like its compute.
|
|
2828
2907
|
*/
|
|
2829
2908
|
export function runMeter(run) {
|
|
2830
2909
|
let steps = 0;
|
|
2831
2910
|
let computeSecs = 0;
|
|
2832
2911
|
let smallSecs = 0;
|
|
2833
2912
|
let netBytes = 0;
|
|
2913
|
+
let tokenCostUsd = 0;
|
|
2834
2914
|
for (const s of run.steps) {
|
|
2835
2915
|
if (s.status !== "completed" && s.status !== "failed")
|
|
2836
2916
|
continue;
|
|
@@ -2838,13 +2918,15 @@ export function runMeter(run) {
|
|
|
2838
2918
|
if (s.carriedFrom)
|
|
2839
2919
|
continue;
|
|
2840
2920
|
steps += 1;
|
|
2921
|
+
if (!(s.credential ?? "").startsWith("provider:"))
|
|
2922
|
+
tokenCostUsd += s.costUsd ?? 0;
|
|
2841
2923
|
const secs = s.computeSecs ?? 0;
|
|
2842
2924
|
computeSecs += secs;
|
|
2843
2925
|
if (s.size === "small")
|
|
2844
2926
|
smallSecs += secs;
|
|
2845
2927
|
netBytes += (s.actual?.rxBytes ?? 0) + (s.actual?.txBytes ?? 0);
|
|
2846
2928
|
}
|
|
2847
|
-
return { tokenCostUsd
|
|
2929
|
+
return { tokenCostUsd, steps, computeSecs, smallSecs, netBytes };
|
|
2848
2930
|
}
|
|
2849
2931
|
/**
|
|
2850
2932
|
* The compute meter for hold+work pricing: core-seconds and GiB-seconds
|
package/dist/src/tool-names.d.ts
CHANGED
|
@@ -12,7 +12,9 @@ export declare const RETIRED_TOOLS: Record<string, string>;
|
|
|
12
12
|
* and a model provider's own search (`web: {search: zai}`) is the runtime's
|
|
13
13
|
* to swap in, not the author's to name. */
|
|
14
14
|
export declare const BUILTIN_TOOLS: Set<string>;
|
|
15
|
-
/** The platform's own groups, served in-process rather than by the SDK.
|
|
15
|
+
/** The platform's own groups, served in-process rather than by the SDK.
|
|
16
|
+
* `install` is granted to every agent anyway (install-tool.ts); naming it is
|
|
17
|
+
* accepted so a file that says so is not flagged. */
|
|
16
18
|
export declare const PLATFORM_GROUPS: Set<string>;
|
|
17
19
|
/** Would this name resolve to something the runtime provides? Built-ins win a
|
|
18
20
|
* clash, so a tool file of the same name is shadowed rather than granted. */
|
package/dist/src/tool-names.js
CHANGED
|
@@ -44,8 +44,10 @@ export const BUILTIN_TOOLS = new Set([
|
|
|
44
44
|
"Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "Bash",
|
|
45
45
|
"NotebookEdit", "TodoWrite",
|
|
46
46
|
]);
|
|
47
|
-
/** The platform's own groups, served in-process rather than by the SDK.
|
|
48
|
-
|
|
47
|
+
/** The platform's own groups, served in-process rather than by the SDK.
|
|
48
|
+
* `install` is granted to every agent anyway (install-tool.ts); naming it is
|
|
49
|
+
* accepted so a file that says so is not flagged. */
|
|
50
|
+
export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask", "install"]);
|
|
49
51
|
/** Would this name resolve to something the runtime provides? Built-ins win a
|
|
50
52
|
* clash, so a tool file of the same name is shadowed rather than granted. */
|
|
51
53
|
export function isRuntimeTool(name) {
|
package/dist/src/tool-test.js
CHANGED
|
@@ -229,7 +229,14 @@ export async function testTool(tenant, workspace, def, input = {}) {
|
|
|
229
229
|
detail: `looked for ${file}\n\nThis is the commonest way to get a tool that silently never loads.`,
|
|
230
230
|
});
|
|
231
231
|
}
|
|
232
|
-
|
|
232
|
+
// The tool's own secrets, and the calling agent's `secrets:` — a run
|
|
233
|
+
// hands a script both. Testing gbp_read (whose agent, not the tool,
|
|
234
|
+
// declares GBP_OAUTH) said "GBP_OAUTH is not set" while every run of the
|
|
235
|
+
// same call worked (9 Oct 2026). Only an agent that grants the tool: the
|
|
236
|
+
// agents[0] stand-in above is there for its depth, not its keys.
|
|
237
|
+
const own = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
|
|
238
|
+
const granting = caller && caller.ownTools.includes(def.name) ? caller.secrets : [];
|
|
239
|
+
const names = [...new Set([...own, ...granting])];
|
|
233
240
|
const { env: stored, missing } = resolveSecrets(tenant, names, workspace);
|
|
234
241
|
const live = await liveSecrets(stored, { tenant, workspace });
|
|
235
242
|
if ("error" in live) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ZipEntry {
|
|
2
|
+
path: string;
|
|
3
|
+
data: Buffer;
|
|
4
|
+
}
|
|
5
|
+
export declare class ZipError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export interface UnzipLimits {
|
|
8
|
+
/** Most entries the archive may hold. */
|
|
9
|
+
maxEntries?: number;
|
|
10
|
+
/** Largest one file, uncompressed. */
|
|
11
|
+
maxFileBytes?: number;
|
|
12
|
+
/** All files together, uncompressed. */
|
|
13
|
+
maxTotalBytes?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function crc32(buf: Buffer): number;
|
|
16
|
+
export declare function zip(entries: ZipEntry[], now?: Date): Buffer;
|
|
17
|
+
export declare function unzip(buf: Buffer, limits?: UnzipLimits): ZipEntry[];
|