feat(workflow): support --input on workflow resume (swamp-club#467)#1475
Conversation
Operators can now pass --input/--input-file/--stdin to `swamp workflow resume` to supply or override input values that change between the suspend and resume phases (e.g. a freshly minted auth key issued during a manual_approval gate). Behaviour: - A run's effective inputs are persisted to the run record when it *suspends* (not at run start), so post-gate steps can resolve `inputs.*` on resume. Runs that never suspend write no input values. - Resume inputs deep-merge over the persisted inputs: existing keys are preserved and a resume `--input` wins on a key collision. The merge base is the full original input set, so a plain `swamp workflow resume` needs no inputs re-passed. - The run record records the *key names* of resume-time inputs for audit (never their values, so secrets are not written to the persisted run). - Workflow evaluation remains strict: a workflow must declare the inputs it references at run time. The pattern is declare-at-run, supply/override-at-resume. Mechanics: - WorkflowRun aggregate gains optional `inputs` and `resumeInputs` fields (backward compatible; legacy runs round-trip unchanged). - deepMerge relocated from the cli input_parser to a domain module (src/domain/inputs/input_merge.ts), re-exported for existing callers. - resume() merges inputs and records resume key names; the resume CLI command parses inputs and rejects multi-item stdin (single run). Tests: unit (input_merge, WorkflowRun inputs round-trip), service-level resume merge, and CLI integration (resume --input, multi-item stdin guard). Docs updated in design/workflow.md and the swamp-workflow skill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When resuming with --input, log which input keys collide with the run's existing inputs (override → warning) versus which are new (additive → info). Key names only — values may be secrets and are never logged. Routes through the CLI logger so log/json output modes are respected. Adds a CLI integration test asserting the override and additive warnings and that no input values leak into the output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`echo` separates tokens with newlines rather than spaces on Windows, so the shell step output was "RESOLVED\nregion=us-west\nauthKey=..." instead of one space-separated line. Assert the resolved values individually rather than matching the whole line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Adversarial Review
Critical / High
No critical or high severity findings.
Medium
-
Prototype pollution in
deepMerge—src/domain/inputs/input_merge.ts:30-43deepMergeiteratesObject.entries(overrides)without filtering dangerous keys like__proto__,constructor, orprototype. If an attacker supplies--input '__proto__.polluted=true'via CLI (which usessetNestedValuewith dot-notation), the merge itself won't pollute prototypes because{ ...base }creates a fresh object. However, the concern is that theisPlainObjectcheck at line 33 would returntrueforObject.prototype(the value at__proto__), potentially causing the recursive merge to walk into the shared prototype object when the base already has such a key from a prior context.In practice, this is mitigated because: (a) CLI inputs go through
parseKeyValueInputswhich usessetNestedValueto create nested objects (not__proto__injection), (b) YAML/JSON parsed inputs would need explicit__proto__keys, and (c) the spread operator on the result creates plain objects. The risk is theoretical but worth a guard if this function is ever used on less-trusted data.Suggested fix: Add a key filter:
if (key === "__proto__" || key === "constructor" || key === "prototype") continue; -
Shallow copy of nested inputs in
toData()—src/domain/workflows/workflow_run.ts:714-715data.inputs = { ...this._inputs }is a shallow copy. If inputs contain nested objects (whichdeepMergeexplicitly supports and the round-trip test at line 1046 exercises withnested: { a: 1 }), the serializeddata.inputsshares references to those nested objects with the liveWorkflowRun. A downstream mutation of the serialized data would affect the in-memory run.In practice, the data goes directly to YAML serialization (which clones implicitly), so this is unlikely to cause real corruption. But it's inconsistent with the stated
Readonly<Record<string, unknown>>return type of theinputsgetter that signals immutability.Suggested fix: Use
structuredClone(this._inputs)or a recursive copy, matching the pattern used elsewhere for data artifacts.
Low
-
stepExprContext?.inputsmay be undefined at suspend —src/domain/workflows/execution_service.ts:2259run.suspend(stepExprContext?.inputs)passesundefinedwhenstepExprContextis nullish or whenexpressionContext.inputswas never set (e.g., a workflow run with no--inputflags). Thesuspend()method guards against this (if (inputs) { this._inputs = inputs; }), so_inputsstays{}. On resume,deepMerge({}, resumeInputs)produces just the resume inputs — which is the correct behavior. No bug here, but the flow relies on theundefined-means-empty convention across three call sites. -
recordResumeInputsusesArray.includesfor dedup —src/domain/workflows/workflow_run.ts:663-667Linear scan on each key. For any realistic number of resume input keys this is negligible, but
Setwould be more idiomatic. Not a bug. -
parseStdinContentdoes not guard against YAML alias bombs —src/cli/input_parser.ts:361@std/yaml'sparseis called on untrusted stdin content. Most YAML parsers in Deno/Node limit alias expansion, but a billion-laughs YAML payload on stdin could theoretically consume memory. This is a pre-existing concern not introduced by this PR.
Verdict
PASS — The implementation is clean and well-tested. The input merging logic is correct: deepMerge produces the right result for scalar overrides, additive keys, nested object merging, and array replacement. The audit trail correctly records only key names. Backward compatibility with legacy run records is explicitly tested. The toData shallow-copy issue (Medium #2) is worth addressing in a follow-up but does not produce wrong behavior in the current call paths.
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
--inputdescription verbosity (src/cli/commands/workflow_resume.ts:78-81): The description"Override/additional input for the resumed run (key=value or JSON); merged over the original run inputs"is notably longer than the equivalent onworkflow run("Input values (key=value or JSON)"). The semicolon-joined clause andOverride/additionalslash construction are a bit awkward. A tighter alternative:"Input override for the resumed run (key=value or JSON), merged over original inputs"— or just matchworkflow run's phrasing and let the example/help text carry the merge semantics. -
--input-filedescription inconsistency (workflow_resume.ts:83): Resume says"Override inputs from a YAML file"whileworkflow runsays"Input values from YAML file". The leading word differs. Minor, but if these flags should feel like the same option in different contexts, matching the phrasing helps.
Verdict
PASS — New flags mirror the workflow run pattern exactly, error messages are clear and actionable, the override/addition warnings are security-conscious (key names only, no values), and JSON-mode behavior is consistent with existing commands.
There was a problem hiding this comment.
Code Review
Clean, well-designed feature. The architecture follows DDD principles correctly, test coverage is thorough, and the security-conscious decision to audit only key names (never values) is sound.
Blocking Issues
None.
Suggestions
-
Prototype pollution guard in
deepMerge(src/domain/inputs/input_merge.ts): The function doesn't filter__proto__orconstructorkeys. In practice this is safe here — V8'sJSON.parsecreates own properties (not prototype links), and the spread{ ...base }produces plain objects — but adding ahasOwnPropertyguard or key blocklist would be a defensive improvement for a function that merges untrusted user input recursively. -
Shallow copy in
suspend(inputs)(src/domain/workflows/workflow_run.ts:635-639): The method assigns the reference directly (this._inputs = inputs). A defensive copy (this._inputs = { ...inputs }) would prevent the caller from accidentally mutating the run's captured inputs. The current call site (execution_service.ts:2259, passingstepExprContext?.inputs) is safe in practice since the context is scoped per execution, but future callers might not be. -
Minor: redundant "backward compatibility" comment (
src/cli/input_parser.ts:27): The comment// Re-export deepMerge from domain layer for backward compatibilityimplies this re-export preserves an existing API surface, butdeepMergewas only just moved in this PR. The comment oncoerceInputTypes(line 25) makes sense since that was moved earlier; fordeepMergethe comment could just say// Re-export deepMerge from domain layersince there are no external consumers to be backward-compatible with yet.
Overall: solid DDD layering (domain function used by both domain service and CLI layer via re-export), comprehensive test pyramid (unit → service → integration), correct libswamp import boundary compliance, and clean backward compatibility for legacy run records.
Summary
Adds
--input/--input-file/--stdinsupport toswamp workflow resume(follow-up to swamp-club#369, manual_approval gates). Operators can supply or override input values that change between the suspend and resume phases — e.g. a freshly minted auth key issued during a verification gate.Behaviour
inputs.*on resume. Runs that never suspend write no input values to disk.--inputwins on a key collision. The merge base is the full original input set, so a plainswamp workflow resumeneeds no inputs re-passed — you only type the deltas.Mechanics
WorkflowRunaggregate gains optionalinputsandresumeInputsfields (backward compatible — legacy run records round-trip unchanged).deepMergerelocated fromsrc/cli/input_parser.tsto a domain module (src/domain/inputs/input_merge.ts), re-exported so existing callers (workflow run,model method run) are unaffected.resume()merges inputs onto the expression context and records resume key names; the resume CLI command parses inputs and rejects multi-item stdin (resume targets a single run).Notes for reviewers
--input(rather than a vault expression), that value lands in the suspended run YAML. Bounded to suspended runs; the audit field is key-names-only; vault expressions sidestep it.Test Plan
input_merge(merge/override/additive/nesting),WorkflowRuninputs + resumeInputs round-trip incl. legacy records lacking the fields.execution_servicesuspend → approve → resume merge (override wins, original preserved); no inputs persisted for non-suspended runs.workflow resume --inputend-to-end (resolved through CEL), multi-item stdin guard.deno check/lint/fmtclean.--inputrestores the original inputs; an undeclared referenced input fails fast at evaluation (strict, unchanged).systeminit/swamp-uatworkflow suite 12/12 against the compiled binary; fulluat:cliserial shows no regressions (the only failures reproduce identically on the unmodifiedmainbinary).content/manual/reference/workflows.md.🤖 Generated with Claude Code