fix(stacks): update_stack_env defaults to merge-semantic to prevent variable data loss#82
fix(stacks): update_stack_env defaults to merge-semantic to prevent variable data loss#82strausmann wants to merge 1 commit into
Conversation
…ariable data loss
The Dockhand REST endpoint PUT /api/stacks/{name}/env has replace-semantics:
a partial variable list silently deletes all other variables. This caused a
production incident where a single-key update wiped 7 of 8 variables from
the hangar-print-hub stack (2026-06-01).
Changes:
- Add `mode` parameter (enum: "merge" | "replace", default: "merge")
- In merge mode: GET existing vars → merge by key (new wins) → PUT full list
- In replace mode: PUT only the provided variables (original behaviour)
- Import StackEnv and EnvVariable types for typed GET response
- Update tool description to document the replace-semantics risk and opt-in
- Update README with a dedicated section explaining merge vs replace
- Add 20 new static-analysis tests in tests/stack-env-merge.test.ts
- Update existing test in stack-env-tools.test.ts that asserted the old
direct-pass { variables } body (now correctly asserts { variables: finalVariables })
There was a problem hiding this comment.
Code Review
This pull request introduces merge-by-default semantics to the update_stack_env tool to prevent accidental data loss during partial environment variable updates, while adding an optional mode parameter to allow explicit replacements. It also documents these changes in the README and adds static analysis tests. The review feedback highlights a critical security risk where the isSecret flag could be unintentionally overwritten and lost during a merge, as well as a potential runtime error if the API returns malformed data. Additionally, the reviewer suggests updating the static analysis tests to accommodate the security fix and recommends implementing real functional tests to replace the fragile regex-based static analysis.
| let finalVariables: EnvVariable[]; | ||
|
|
||
| if (mode === 'merge') { | ||
| const existing = await client.get<StackEnv>( | ||
| `/api/stacks/${encodePath(name)}/env`, | ||
| { env: environmentId }, | ||
| ); | ||
| const existingVars: EnvVariable[] = existing?.variables ?? []; | ||
| const mergedByKey = new Map<string, EnvVariable>( | ||
| existingVars.map((v) => [v.key, v]), | ||
| ); | ||
| for (const v of variables) { | ||
| mergedByKey.set(v.key, v); | ||
| } | ||
| finalVariables = Array.from(mergedByKey.values()); | ||
| } else { | ||
| finalVariables = variables; | ||
| } |
There was a problem hiding this comment.
There are two key issues with the current merge implementation:
- Security / Secret Exposure Risk (High Severity): If an existing variable is flagged as a secret (
isSecret: true), and a partial update is performed on it without explicitly passingisSecret: true(since it is optional in the schema), the existingisSecretflag will be overwritten withundefined. This causes the variable to lose its secret status and potentially be written to the.envfile in plain text. Preserving the existingisSecretflag unless explicitly overridden is a crucial security safeguard. - Defensive Programming (Medium Severity): If the API returns an unexpected response or if
variablesis not an array (e.g.,null),existingVars.mapwill throw a runtimeTypeError. CheckingArray.isArray(existingVars)and filtering out any malformed entries prevents potential crashes.
let finalVariables: EnvVariable[];
if (mode === 'merge') {
const existing = await client.get<StackEnv>(
`/api/stacks/${encodePath(name)}/env`,
{ env: environmentId },
);
const existingVars = existing?.variables;
const mergedByKey = new Map<string, EnvVariable>();
if (Array.isArray(existingVars)) {
for (const v of existingVars) {
if (v && typeof v.key === 'string') {
mergedByKey.set(v.key, v);
}
}
}
for (const v of variables) {
const existingVar = mergedByKey.get(v.key);
mergedByKey.set(v.key, {
...v,
isSecret: v.isSecret !== undefined ? v.isSecret : existingVar?.isSecret,
});
}
finalVariables = Array.from(mergedByKey.values());
} else {
finalVariables = variables;
}| it('iterates over incoming variables and sets them in the Map', () => { | ||
| // mergedByKey.set(v.key, v) pattern | ||
| expect(block).toMatch(/mergedByKey\.set\s*\(\s*v\.key\s*,\s*v\s*\)/); | ||
| }); |
There was a problem hiding this comment.
Since we updated the merge implementation to safely preserve the isSecret flag, the regex in this test needs to be updated to be more flexible (matching just the key setting rather than expecting exactly v as the second argument).
Additionally, please note that static analysis tests (reading the source file and matching regexes) are highly fragile to refactoring, formatting, and variable name changes, and they do not verify actual runtime behavior (such as correct merging of properties or handling of edge cases). It is highly recommended to add real functional/unit tests using a mocked client to verify the actual runtime behavior of the tool.
| it('iterates over incoming variables and sets them in the Map', () => { | |
| // mergedByKey.set(v.key, v) pattern | |
| expect(block).toMatch(/mergedByKey\.set\s*\(\s*v\.key\s*,\s*v\s*\)/); | |
| }); | |
| it('iterates over incoming variables and sets them in the Map', () => { | |
| // mergedByKey.set(v.key, ...) pattern | |
| expect(block).toMatch(/mergedByKey\.set\s*\(\s*v\.key\s*,/); | |
| }); |
There was a problem hiding this comment.
Pull request overview
This PR makes update_stack_env safe for partial updates by defaulting to merge semantics (GET existing env vars → merge by key → PUT full list), with an explicit mode="replace" opt-in for the old replace behavior. This change reduces the risk of accidentally deleting unrelated stack environment variables when updating only a subset.
Changes:
- Added
modeparameter toupdate_stack_env(default"merge") and implemented GET→merge→PUT behavior insrc/tools/stacks.ts. - Added static-analysis tests covering merge/replace behavior and updated an existing test to reflect the new PUT body shape.
- Documented merge vs replace semantics in the README and updated the stacks tool table entry.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/tools/stacks.ts |
Adds mode param and implements merge-default logic for update_stack_env. |
tests/stack-env-merge.test.ts |
New static-analysis tests to verify merge/replace implementation and type usage. |
tests/stack-env-tools.test.ts |
Updates assertion to expect { variables: finalVariables } in the PUT body. |
README.md |
Documents merge-default behavior and adds usage examples for merge vs replace. |
| for (const v of variables) { | ||
| mergedByKey.set(v.key, v); | ||
| } |
| * | ||
| * See: https://github.com/strausmann/mcp-dockhand/issues/<issue> | ||
| * Incident: hangar-print-hub stack (2026-06-01), 7 of 8 variables deleted by | ||
| * a single-key update_stack_env call. | ||
| */ |
| # Safe partial update — only PRINTER_HUB_SSO_TRUST_TOKEN changes, all others preserved | ||
| update_stack_env(environmentId=1, name="my-stack", variables=[{key: "MY_VAR", value: "new"}]) |
Problem
The Dockhand REST endpoint
PUT /api/stacks/{name}/envhas replace-semantics: submitting a partial variable list silently deletes all other variables. Theupdate_stack_envMCP tool was passing the payload 1:1 to the API, making every partial update a potential wipe.Production incident (2026-06-01): Stack
hangar-print-hubhad 8 variables (tokens for Hangar, Hub, Spoolman, Grocy, Snipe-IT, Webhook API keys). A single-keyupdate_stack_env([{key: "PRINTER_HUB_SSO_TRUST_TOKEN", ...}])call deleted all other 7 variables → Hangar Admin login 401, all service integrations broken.Solution
Add a
modeparameter toupdate_stack_envwith default"merge":"merge"(default)"replace"Changes
src/tools/stacks.ts— addmodeparameter; implement GET→merge→PUT logic in merge path; importStackEnvandEnvVariabletypes for typed responsetests/stack-env-merge.test.ts— 20 new static-analysis tests covering mode parameter, merge path (GET + Map merge + Array.from), replace path, PUT body, description, and type safetytests/stack-env-tools.test.ts— update one existing test that asserted the old direct{ variables }PUT body (now correctly checks{ variables: finalVariables })README.md— add dedicated section documenting merge vs replace semantics; update stack tool table rowTest results
Backwards compatibility
modeget the new safe merge behaviour automatically.mode="replace"."variables"— no API contract change.