@foldrun/core 2.14.2 → 3.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/src/runner.ts CHANGED
@@ -3036,6 +3036,8 @@ async function runStep(
3036
3036
  tenant,
3037
3037
  input: {
3038
3038
  agentRel: path.relative(workspaceRoot, agentDir).replaceAll("\\", "/"),
3039
+ // Its file tools inside the sandbox get the same allowance (FOL-96).
3040
+ configWrites: step.approve === true && Boolean(step.approvedAt),
3039
3041
  prompt,
3040
3042
  model,
3041
3043
  effort,
@@ -3127,6 +3129,11 @@ async function runStep(
3127
3129
  network: { level: network.level, enforced: forward?.enforce === true },
3128
3130
  // A test run's state/ and storage/ changes come out under the run.
3129
3131
  divert: testRun && runId ? divertFor(runId) : undefined,
3132
+ // Read at copy-out: what the host handed back into the sandbox.
3133
+ handedBack: () => step.handedBack,
3134
+ // A person approved this step before it ran: configuration and code
3135
+ // it changes are kept, listed on the trail (FOL-96).
3136
+ configWrites: step.approve === true && Boolean(step.approvedAt),
3130
3137
  test: testRun,
3131
3138
  // Resume: a sandbox a previous driver left running, recorded on the
3132
3139
  // step. Consumed once — a fresh attempt after this one (a retry, a
@@ -3332,6 +3339,11 @@ async function runStep(
3332
3339
  step.image = outcome.image ?? null;
3333
3340
  step.browserPod = outcome.browserPod ? { ...outcome.browserPod } : null;
3334
3341
  if (step.subagentOf && outcome.applied?.length) step.wrote = [...new Set([...(step.wrote ?? []), ...outcome.applied])];
3342
+ if (outcome.refused?.length) {
3343
+ const seen = new Map((step.refused ?? []).map((r) => [r.path, r]));
3344
+ for (const r of outcome.refused) seen.set(r.path, r);
3345
+ step.refused = [...seen.values()];
3346
+ }
3335
3347
  if (outcome.status === "failed" && outcome.browserPod?.lost) {
3336
3348
  const decision = podLossDecision(outcome.browserPod);
3337
3349
  if (decision.rerun) {
@@ -3503,6 +3515,9 @@ async function runStep(
3503
3515
  const attemptInProcess = (modelEnv: Record<string, string | undefined>) => executeStep({
3504
3516
  agentDir,
3505
3517
  workspaceRoot,
3518
+ // A person approved this step (its gate) before it ran: it may change
3519
+ // configuration and code (FOL-96) — the desks' retro applies fixes so.
3520
+ configWrites: step.approve === true && Boolean(step.approvedAt),
3506
3521
  libraryRoot: libraryDir(tenant),
3507
3522
  ...(subagents.length ? { subagents } : {}),
3508
3523
  // The colleagues' inbox, read after every tool call — the same hook
@@ -5241,6 +5256,21 @@ function driveRunInner(
5241
5256
  // Its files go back with the reply, so the delegating step's
5242
5257
  // sandbox has them too (delegate-tools.ts copyBackFiles).
5243
5258
  const back = readCopyBack(pDir, child.wrote ?? []);
5259
+ // What goes back into the parent's sandbox, as the host sends it:
5260
+ // its copy-out reads this, not the sandbox's own note (FOL-96).
5261
+ if (back.files.length) {
5262
+ parent.handedBack = { ...(parent.handedBack ?? {}) };
5263
+ for (const f of back.files) {
5264
+ const h = crypto.createHash("sha256").update(Buffer.from(f.base64, "base64")).digest("hex");
5265
+ // Kept beside any earlier job's for the same path, never over
5266
+ // it: either may be what the sandbox ends up holding.
5267
+ const prev = parent.handedBack[f.path];
5268
+ const all = prev === undefined ? [] : Array.isArray(prev) ? prev : [prev];
5269
+ if (!all.includes(h)) all.push(h);
5270
+ parent.handedBack[f.path] = all.length === 1 ? all[0] : all;
5271
+ }
5272
+ save();
5273
+ }
5244
5274
  return {
5245
5275
  status,
5246
5276
  result: child.result ?? (errors || "(no reply)"),
package/src/step-exec.ts CHANGED
@@ -87,6 +87,9 @@ export interface ExecOutcome {
87
87
  }
88
88
 
89
89
  export interface ExecOptions {
90
+ /** A person approved this step before it ran: its file tools may write
91
+ * the workspace's configuration and code (confine.ts, FOL-96). */
92
+ configWrites?: boolean;
90
93
  /** What the step reports beyond its events (FOL-54 step 4): `metadata`
91
94
  * sends each model call (`turn`); `full` also sends each tool call's input
92
95
  * and output (`io`). Unset sends neither — the consult, the probe and any
@@ -520,7 +523,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
520
523
  }
521
524
  }
522
525
  const verdict = isFilesystemTool(hookInput.tool_name)
523
- ? checkPaths(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, { agentDir, workspaceRoot, libraryRoot })
526
+ ? checkPaths(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, { agentDir, workspaceRoot, libraryRoot, configWrites: opts.configWrites })
524
527
  : null;
525
528
  if (verdict && !verdict.ok) {
526
529
  emit("error", verdict.reason!, via ? { subagent: via } : undefined);
@@ -560,7 +563,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
560
563
  const again = hookInput.tool_name === "Bash"
561
564
  ? checkBash(String(v.input.command ?? ""))
562
565
  : isFilesystemTool(hookInput.tool_name)
563
- ? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
566
+ ? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot, configWrites: opts.configWrites })
564
567
  : { ok: true as const };
565
568
  const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
566
569
  // The sub-agent guard read the ORIGINAL input: a rewrite of a
@@ -628,7 +631,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
628
631
  toolName === "Bash"
629
632
  ? checkBash(String(input.command ?? ""))
630
633
  : isFilesystemTool(toolName)
631
- ? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot })
634
+ ? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot, configWrites: opts.configWrites })
632
635
  : { ok: true as const };
633
636
  if (!verdict.ok) {
634
637
  refund();
package/src/store.ts CHANGED
@@ -2107,7 +2107,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
2107
2107
  // A header name is case-insensitive on the wire; a JSON field is not.
2108
2108
  // `body:eventId` lower-cased to `body:eventid` matched nothing, and a
2109
2109
  // dedupe that matches nothing admits everything.
2110
- idempotency: typeof data.idempotency === "string" ? idempotencyName(data.idempotency) : null,
2110
+ idempotency: idempotencyOf(data.idempotency, data.trigger, data.signature),
2111
2111
  debounce: durationOf(data.debounce),
2112
2112
  throttle: durationOf(data.throttle),
2113
2113
  catchup: data.catchup === "none" || data.catchup === "last" ? data.catchup : null,
@@ -2144,6 +2144,19 @@ export function parseSchemaText(text: string): { schema: Record<string, unknown>
2144
2144
  return { schema: value as Record<string, unknown> };
2145
2145
  }
2146
2146
 
2147
+ /**
2148
+ * The flow's `idempotency:`, or its default. A GitHub-signed webhook dedupes
2149
+ * on `x-github-delivery` unless it says otherwise: GitHub's signature has no
2150
+ * timestamp, so a delivery captured once could be replayed for ever, and
2151
+ * every GitHub delivery carries that id (FOL-94). Any `idempotency:` the
2152
+ * file sets wins; `none` (or `false`, `off`) turns dedupe off.
2153
+ */
2154
+ function idempotencyOf(raw: unknown, trigger: unknown, signature: unknown): string | null {
2155
+ if (raw === false || (typeof raw === "string" && /^(none|off|false)$/i.test(raw.trim()))) return null;
2156
+ if (typeof raw === "string") return idempotencyName(raw);
2157
+ return trigger === "webhook" && String(signature) === "github" ? "x-github-delivery" : null;
2158
+ }
2159
+
2147
2160
  function idempotencyName(raw: string): string | null {
2148
2161
  const v = raw.trim();
2149
2162
  if (!v) return null;
@@ -3520,6 +3533,14 @@ export interface StepRecord {
3520
3533
  /** On an isolated sub-agent's run: the workspace paths its sandbox's
3521
3534
  * changes were applied to — what its delegating step is told it wrote. */
3522
3535
  wrote?: string[];
3536
+ /** What this step's sandbox changed that was not taken back to the
3537
+ * workspace: configuration and code, which a step proposes and a person
3538
+ * applies (copy-back.ts, FOL-96). */
3539
+ refused?: { path: string; kind: "config" | "code" }[];
3540
+ /** What the host handed back into this step's sandbox mid-step — its
3541
+ * `sandbox: own` jobs' files, path → sha256 — recorded HERE, on the host,
3542
+ * so the copy-out never has to take the sandbox's word for it (FOL-96). */
3543
+ handedBack?: Record<string, string | string[]>;
3523
3544
  /**
3524
3545
  * Sandbox seconds this step rented, and how many of them were cold start.
3525
3546
  * Set only on the isolated path — an in-process step rents nothing, so