fix(flows): reject corrupt step paths at deploy + atomic cache writes (#9751) (#9813)

* fix(flows): reject corrupt step paths at deploy + atomic cache writes (#9751)

A flow step could execute an unrelated (and in the reported case, destructive)
script at runtime even though every stored definition looked correct. A forensic
dump traced it to two issues:

- Deploy accepted absolute/local step paths. `wmill sync push` from a feature-
  branch checkout under /tmp baked an absolute path
  (`/tmp/.../ops/scripts/clean_device/...`) into a step's `value.path`. Persisted
  verbatim, it mis-resolved to an unrelated script at runtime.
- The on-disk cache write was neither truncating nor atomic. `FsBackedCache::put`
  used `write+create`, so a shorter overwrite left stale trailing bytes and
  concurrent writers could interleave into a torn file — a corrupt cached blob
  that a worker then scheduled from.

Fixes:
- Reject non-workspace flow step paths (must be u/, f/, g/ or hub/) in
  `validate_flow_value` (covers create_flow + update_flow, recursively through
  loops/branches/AI-agent tools) and early in the CLI `pushFlow`.
- Make `FsBackedCache::put` write a unique temp file (truncate + fsync) then
  atomically rename it over the target, cleaning up on error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(flows): validate failure/preprocessor module paths + sub-flow paths in CLI

Address PR review (cubic + claude):
- Backend `validate_flow_value` is the authoritative guard but only walked
  `modules`; extend it to also validate `failure_module` and `preprocessor_module`
  (which can themselves be sub-flows/loops/branches), so an absolute path there
  can't be persisted.
- CLI preflight only collected `type: "script"` paths; now collects sub-flow
  (`type: "flow"`) step paths too (recursively, incl. failure/preprocessor), so the
  comment's claim matches the behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): include AI-agent tool step paths in flow path preflight

Address Codex review: collectStepPaths skipped aiagent tools, so a bad path in
a tool fell through to the API error instead of the local fail-fast. The backend
already validates these (traverse_modules walks AIAgent tools); this aligns the
CLI early-error with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(flows): make failure/preprocessor path test key explicit

The test used `slot:` as a json! key. json! does interpolate an ident key to its
variable's value (json!({slot:1}) with slot="failure_module" => {"failure_module":1}),
so the test was correct and exercised the validation — but the behavior is subtle,
so build the key explicitly via serde_json::Map to remove ambiguity (review nit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cache): use a UUID temp name for atomic put (shared-volume safe)

Address Codex (P1): pid+counter temp names collide across container PID
namespaces on a shared cache volume (same pid, PUT_SEQ resets to 0 per process),
so two workers could truncate/clobber the same temp file before rename. Use a
random UUID suffix (matching worker.rs's atomic-write helpers) — globally unique,
so the cross-process temp-file hazard is closed. Also trims the comment to the
AGENTS.md <=4-line limit (Pi nit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-06-26 18:06:27 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 16022447c7
commit 3cda447621
3 changed files with 226 additions and 13 deletions
+40
View File
@@ -135,6 +135,31 @@ function warnAboutLocalPathScriptDivergence(
const alreadySynced: string[] = [];
// Collect every script/sub-flow step path in a flow value — recursively through loops,
// branches, and the failure/preprocessor modules — for workspace-path validation. Unlike
// `collectPathScriptPaths` this also includes `type: "flow"` sub-flow steps.
function collectStepPaths(flowValue: any): string[] {
const paths: string[] = [];
const walk = (modules: any[] | undefined) => {
for (const m of modules ?? []) {
const v = m?.value;
if (!v) continue;
if ((v.type === "script" || v.type === "flow") && typeof v.path === "string") {
paths.push(v.path);
}
walk(v.modules);
walk(v.default);
for (const b of v.branches ?? []) walk(b?.modules);
// AI-agent tools are step-like and can carry script paths too.
walk(v.tools);
}
};
walk(flowValue?.modules);
if (flowValue?.failure_module) walk([flowValue.failure_module]);
if (flowValue?.preprocessor_module) walk([flowValue.preprocessor_module]);
return paths;
}
export async function pushFlow(
workspace: string,
remotePath: string,
@@ -190,6 +215,21 @@ export async function pushFlow(
);
}
// Reject script/sub-flow steps whose path is not a workspace path (u/, f/, g/ or hub/).
// A flow.yaml generated from a feature-branch checkout can carry absolute local paths
// (e.g. /tmp/.../ops/scripts/...); pushed, they silently mis-resolve at runtime (#9751).
// The backend re-validates the same rule for every step type, so this is a fail-fast.
const badStepPaths = collectStepPaths(localFlow.value).filter(
(p) => p !== "" && !/^(u|f|g|hub)\//.test(p)
);
if (badStepPaths.length > 0) {
throw new Error(
`Cannot push flow ${remotePath}: step(s) reference non-workspace path(s): ${badStepPaths.join(", ")}. ` +
`Flow step paths must be workspace paths (u/, f/, g/ or hub/), not absolute or local filesystem paths. ` +
`This usually means flow.yaml was generated with paths from a checkout directory.`
);
}
const hasOnBehalfOf = (localFlow as any).has_on_behalf_of ?? !!localFlow.on_behalf_of_email;
delete (localFlow as any).has_on_behalf_of;