Refactor 922 cowork cleanups#1667
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR centralizes config key removal through the update_config() write path and extends target deployment path resolution to support primitive-specific deploy_root overrides (used by skills), updating skill integration logic and tests accordingly.
Changes:
- Add
remove_keyssupport toupdate_config()and route_unset_config_key()through it. - Extend
TargetProfile.deploy_path()with an optionalprimitiveparameter to honor primitive-leveldeploy_root/subdir. - Refactor skill integrator path building to use
target.deploy_path(...)and expand tests for these behaviors.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_config.py | Adds unit tests ensuring unset helpers call the shared update_config() path. |
| tests/unit/integration/test_skill_integrator_phase3w4.py | Updates test target construction to provide a deploy_path() matching new primitive-aware behavior. |
| tests/unit/integration/test_skill_integrator_hermetic.py | Same as above for hermetic integration coverage. |
| tests/unit/integration/test_copilot_cowork_target.py | Adds coverage for primitive deploy-root override behavior on the copilot target. |
| src/apm_cli/integration/targets.py | Implements deploy_path(..., primitive=...) and removes prior Path typing workarounds. |
| src/apm_cli/integration/skill_integrator.py | Replaces bespoke “skills root” resolution with TargetProfile.deploy_path(). |
| src/apm_cli/config.py | Adds remove_keys to update_config() and routes unsets through it. |
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 2 | 1 | Clean, bounded refactor -- two recommended findings, one nit. Main concerns: test helper duplicates deploy_path verbatim (sync hazard); update_config now unconditionally writes even on absent-key unset (behavioral drift). |
| CLI Logging Expert | 0 | 0 | 1 | No logging or diagnostic regressions; one nit on _unset_config_key docstring accuracy now that it always writes the config file regardless of key presence. |
| DevX UX Expert | 0 | 1 | 2 | No user-visible CLI surface touched. One behavioral contract breaks quietly: _unset_config_key documented No-op now incurs unconditional disk write. Two nits: test helper duplicates production logic, deploy_path/primitive silent-ignore needs a doc note. |
| Supply Chain Security | 0 | 1 | 2 | No security regressions. Path containment fully preserved and algebraically verified. Two post-merge nits: unconditional config write (no credentials at risk) and test helper drift risk for future path hardening. |
| OSS Growth Hacker | 0 | 0 | 0 | Pure internal refactor; no conversion surface touched and no growth action required. |
| Test Coverage Expert | 0 | 1 | 1 | Two gaps: mock-only routing test for remove_keys (nit; indirect coverage exists); tests/integration/ hermetic+phase3w4 not updated for deploy_path refactor (recommended). |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Python Architect (converged: cli-logging-expert, devx-ux-expert)] Restore absent-key guard in update_config: add
if not updates and all(k not in config for k in remove_keys): returnbefore the json.dump path, and update docstrings for the three public wrappers. -- Three panelists converge on this behavioral regression. An absent key that was a silent no-op before now raises OSError; mtime stamps and indent-2 reformatting change on every absent-key unset call. - [Test Coverage Expert] Add
from tests.unit._skill_integrator_target_helpers import attach_skill_deploy_pathandattach_skill_deploy_path(fake_target)to both tests/integration/test_skill_integrator_hermetic.py and tests/integration/test_skill_integrator_phase3w4.py. -- The refactor moved inline path computation into target.deploy_path() calls; integration tests constructing bare MagicMock targets without the helper will call deploy_path() on a raw mock, trigger the is_symlink() guard, and fail with PathTraversalError. - [Python Architect (converged: devx-ux-expert, supply-chain-security-expert)] Replace the verbatim branch-tree copy in tests/unit/_skill_integrator_target_helpers.py:12-21 with lambda delegation:
target.deploy_path = lambda *a, **kw: TargetProfile.deploy_path(target, *a, **kw). -- Three panelists flag the sync hazard. Future path hardening in TargetProfile.deploy_path will silently diverge from the test helper, making unit tests validate stale behavior while production ships new logic. - [Python Architect (converged: devx-ux-expert)] Add a one-line docstring note to deploy_path stating that the primitive kwarg is silently ignored when resolved_deploy_root is set. -- Now that deploy_path is the canonical call site for all integrators, authors adding new integration targets will have no indication that their primitive argument is a no-op on cowork targets.
- [Supply Chain Security Expert] Add an explanatory comment at skill_integrator.py:929 documenting why the cowork target bypasses ensure_path_within. -- The bypass is pre-existing and correct, but future security auditors will see an ensure_path_within skip with no rationale and flag it; a single inline comment collapses that future review cost to zero.
Architecture
classDiagram
direction LR
class PrimitiveMapping {
<<ValueObject>>
+subdir str
+extension str
+format_id str
+deploy_root str
}
class TargetProfile {
<<ValueObject>>
+name str
+root_dir str
+primitives dict
+resolved_deploy_root Path
+deploy_path(project_root, parts, primitive) Path
+supports(primitive) bool
+for_scope(user_scope) TargetProfile
}
class BaseIntegrator {
<<Abstract>>
}
class SkillIntegrator {
+_integrate_native_skill()
+_integrate_skill_bundle()
}
class ConfigModule {
<<Module>>
+update_config(updates, remove_keys)
+_unset_config_key(key)
+unset_temp_dir()
+unset_copilot_cowork_skills_dir()
}
class SkillIntegratorTargetHelpers {
<<TestDouble>>
+attach_skill_deploy_path(target)
}
note for TargetProfile "primitive kwarg added; silently ignored when resolved_deploy_root is set"
note for SkillIntegratorTargetHelpers "DRY risk: mirrors TargetProfile.deploy_path branch tree verbatim"
note for ConfigModule "Single write path; always writes, no absent-key guard"
BaseIntegrator <|-- SkillIntegrator
TargetProfile *-- PrimitiveMapping : primitives
SkillIntegrator ..> TargetProfile : deploy_path(root, primitive)
SkillIntegratorTargetHelpers ..> TargetProfile : mirrors logic verbatim
classDef touched fill:#fff3b0,stroke:#d47600
class TargetProfile:::touched
class SkillIntegrator:::touched
class ConfigModule:::touched
class SkillIntegratorTargetHelpers:::touched
flowchart TD
subgraph deploy["deploy_path - targets.py:241"]
dp1["SkillIntegrator._integrate_native_skill<br/>skill_integrator.py:914-915"]
dp2["mapping = primitives.get(primitive)<br/>None when primitive is None"]
dp3{"resolved_deploy_root not None?"}
dp4["return resolved_deploy_root.joinpath(parts)<br/>primitive silently ignored here"]
dp5["deploy_root = mapping.deploy_root or None"]
dp6["base = project_root / (deploy_root or root_dir)"]
dp7{"mapping and mapping.subdir?"}
dp8["base = base / mapping.subdir"]
dp9["return base.joinpath(parts)"]
dp1 --> dp2 --> dp3
dp3 -- "YES cowork" --> dp4
dp3 -- "NO static target" --> dp5 --> dp6 --> dp7
dp7 -- "YES" --> dp8 --> dp9
dp7 -- "NO" --> dp9
end
subgraph unset["config unset - config.py:151-160"]
us1["unset_temp_dir or unset_copilot_cowork_skills_dir"]
us2["_unset_config_key(key)"]
us3["update_config remove_keys=(key,)"]
us4["_invalidate_config_cache()"]
us5["[I/O] get_config reads CONFIG_FILE"]
us6["config.pop(key, None) - no-op if key absent"]
us7["config.update - no-op for empty dict"]
us8["[FS] json.dump to CONFIG_FILE<br/>ALWAYS written even if key was absent"]
us9["_invalidate_config_cache()"]
us1 --> us2 --> us3 --> us4 --> us5 --> us6 --> us7 --> us8 --> us9
end
Recommendation
Merge is defensible: path containment is algebraically verified, API shape is correct, and no user-facing surface changes. The two convergent recommended findings (absent-key write guard, test-helper delegation) and the unconfirmed integration-test gap are all addressed by small, localized changes requiring no design discussion. Ship and open a single companion issue tracking all five followups, with the absent-key guard restoration and integration-test verification as first-to-close.
Full per-persona findings
Python Architect
- [recommended] Test helper duplicates TargetProfile.deploy_path branch tree verbatim -- sync hazard at
tests/unit/_skill_integrator_target_helpers.py
tests/unit/_skill_integrator_target_helpers.py:12-21 reproduces the exact 8-line branch tree of TargetProfile.deploy_path (targets.py:263-272) line-for-line. If the production method gains a new case -- e.g. primitive-aware routing when resolved_deploy_root is set -- the helper silently diverges and unit tests validate stale behavior while production ships the new path. Minimal fix:target.deploy_path = lambda *a, **kw: TargetProfile.deploy_path(target, *a, **kw). The mock already carries .primitives, .resolved_deploy_root, and .root_dir so the real method works unmodified.
Suggested: Use lambda delegation to real TargetProfile.deploy_path method. - [recommended] update_config({}, remove_keys=(key,)) always writes the config file -- absent-key guard was dropped at
src/apm_cli/config.py
Old _unset_config_key (pre-PR) guarded the disk write behindif key in config:, making absent-key unset calls true no-ops with zero disk I/O. The new path always reaches json.dump regardless of whether remove_keys mutated anything: _invalidate_cache + get_config (read) + pop (no-op when key absent) + update({}) (no-op) + json.dump (always). Effects: spurious FS write, mtime bump, and indent=2 reformatting on every absent-key unset call. Environments with file-system watchers or hook-triggered config reloads see unexpected mtime changes. The new TestUnsetConfigHelpers test patches update_config entirely so it verifies routing only -- it does not catch this regression. Minimal fix:if not updates and all(k not in config for k in remove_keys): return.
Suggested: Add short-circuit guard before json.dump, or document unconditional-write behavior explicitly in update_config docstring. - [nit] deploy_path docstring omits that primitive is silently ignored when resolved_deploy_root is set at
src/apm_cli/integration/targets.py
targets.py:264-267: when resolved_deploy_root is set the method returns immediately after joinpath(*parts), never consulting the primitive mapping. One sentence covers it: 'When resolved_deploy_root is set, primitive is not consulted -- the dynamic root already encodes the complete deploy directory.'
CLI Logging Expert
- [nit] _unset_config_key and its callers document 'No-op when key not present' but the new implementation always writes the config file at
src/apm_cli/config.py:151
Public wrappers (unset_temp_dir, unset_allow_protocol_fallback, unset_prefer_ssh) all carry docstrings saying 'No-op if the key is not present', which is now factually wrong at the I/O level. No log message is emitted in either path so there is zero user-visible output change, but a read-only config file combined with an absent key would have silently succeeded before and now raises an OSError.
Suggested: Either update the docstrings to say 'Logical no-op when key is not present; config file is still rewritten on every call', or add a short-circuit guard before delegating:if key not in get_config(): returnto restore the conditional-write semantics.
DevX UX Expert
- [recommended] _unset_config_key docstring says 'No-op when key is not present' but new implementation unconditionally writes to disk at
src/apm_cli/config.py:151
Old code guarded the open()+json.dump() insideif key in config:, making the absent-key path truly I/O-free. New path delegates to update_config({}, remove_keys=(key,)), which always rewrites the file after pop(key, None). In environments with filesystem watches, audited I/O, or CI setups where the config file is read-only when no keys need unsetting, callers relying on the docstring guarantee now see unexpected side effects or errors. - [nit] attach_skill_deploy_path re-implements TargetProfile.deploy_path verbatim -- drift risk if production method gains new branching condition at
tests/unit/_skill_integrator_target_helpers.py:12
TestDeployPath in test_copilot_cowork_target.py already shows the right pattern: use replace() on a real KNOWN_TARGETS profile for the path-resolution boundary, reserve MagicMock for integrator surrounding logic. - [nit] deploy_path silently ignores primitive when resolved_deploy_root is set -- warrants a docstring note at
src/apm_cli/integration/targets.py:264
With deploy_path now the canonical calling convention across all integrator callers, the silent ignore is more exposed. A single docstring note prevents future callers from assuming primitive-routed paths for cowork targets.
Supply Chain Security Expert
- [nit] update_config({}, remove_keys=(key,)) unconditionally writes config.json even when the key was absent at
src/apm_cli/config.py:160
No credentials live in config.json so there is no confidentiality risk, but restoring the short-circuit (e.g.if key in config or updates: write) eliminates the spurious disk write. - [recommended] Test helper mirrors TargetProfile.deploy_path logic; future path hardening will not be reflected in the mock at
tests/unit/_skill_integrator_target_helpers.py
Any future change to the real deploy_path (extra containment check, symlink guard, normalisation step) will not be reflected here, allowing tests to pass against an older, potentially weaker path shape. Fix: either delegate to the real method or add a convergence property test. - [nit] Pre-existing ensure_path_within bypass for cowork targets needs an explanatory comment for future auditors at
src/apm_cli/integration/skill_integrator.py:929
validate_path_segments and symlink rejection still run so there is no regression. The omission is safe (deploy_path with resolved_deploy_root always returns resolved_deploy_root.joinpath(skill_name), so the containment check would trivially pass). A one-line comment to that effect would aid future auditors.
OSS Growth Hacker
No findings.
Auth Expert -- inactive
None of the canonical auth files are changed (auth.py, token_manager.py, azure_cli.py, github_downloader.py, marketplace/client.py, github_host.py, validation.py, pipeline.py, registry_proxy.py). Fallback self-check also negative: the diff is confined to config read-modify-write consolidation and deploy-path primitive-aware routing -- no token handling, credential resolution, host classification, or AuthResolver semantics are touched.
Doc Writer -- inactive
All changes are internal implementation details with no user-facing surface. update_config(remove_keys=()) and deploy_path(primitive=...) are private Python helpers not exposed in any CLI command, flag, config key, or documented API. Doc drift check: negative.
Test Coverage Expert
- [recommended] tests/integration/ hermetic and phase3w4 not updated for deploy_path refactor; TestIntegrateSkillNameNormalization tests likely broken at
tests/integration/test_skill_integrator_phase3w4.py:568
The refactor replaces inline path computation with target.deploy_path() calls at skill_integrator.py:914-915. Pre-existing tests in tests/integration/ construct fake_target=MagicMock() without attach_skill_deploy_path. After the refactor, target.deploy_path(project_root, skill_name, primitive='skills') returns a truthy MagicMock; target_skill_dir.is_symlink() returns another truthy MagicMock; the guard at skill_integrator.py:925 fires and the test fails with PathTraversalError. The PR correctly updated tests/unit/integration/ but did not update tests/integration/ counterparts. Outcome: unknown (pytest unavailable).
Suggested: Addfrom tests.unit._skill_integrator_target_helpers import attach_skill_deploy_pathandattach_skill_deploy_path(fake_target)in both tests/integration/ test files, mirroring the change already applied to tests/unit/integration/ in this PR.
Proof (unknown):tests/integration/test_skill_integrator_phase3w4.py::TestIntegrateSkillNameNormalization::test_invalid_name_normalized_with_diagnostics-- proves: skill install correctly deploys a skill with a normalized name to the configured target directory [multi-harness-support, devx] - [nit] update_config(remove_keys=...) new API exercised by routing-only mock test; no direct round-trip with isolated_config at
tests/unit/test_config.py
Indirect coverage exists through TestAuditOnInstallConfig.test_unset_falls_back_to_default which exercises the full write path with a real temp file. Adequate for a routing refactor, but a direct test would be preferred for the new public parameter.
Proof (passed):tests/unit/test_config.py::TestAuditOnInstallConfig::test_unset_falls_back_to_default--config_mod.set_audit_on_install('block'); config_mod.unset_audit_on_install(); assert config_mod.get_audit_on_install() == 'off'-- proves: unsetting a config key removes it from the on-disk file and subsequent reads return the default value [devx]
Performance Expert -- inactive
Fast-path and fallback both miss. deploy_path() refactor adds ~70-130ns method-call overhead per integration target during the integrate phase -- noise against disk-write latency. The unconditional config write on absent-key unset is cold-path only (confirmed absent from install/, deps/, cache/** call graphs). No action required from the performance perspective.
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Generated by PR Review Panel for issue #1667 · sonnet46 16.1M · ◷
|
Addressed the review follow-ups and the lint failure. Changes:
Validation passed locally:
|
apm-spec-waiver: internal cowork path refactor only; no OpenAPM observable behavior change
Description
Cleans up the remaining accepted cowork internal refactor items from #922. This routes skill deployment path construction through
TargetProfile.deploy_path(...), preserves primitive deploy-root overrides such as.agents/skillsand makes config unset helpers use the sharedupdate_config()read-modify-write path.The
sync_remove_filescowork-root caching item from #922 was already present on currentmain, so this PR does not touchbase_integrator.py.Fixes #922
Type of change
Testing
Validation run:
Results:
Note: uv run --python 3.12 pytest -q exited 0 locally, but integration/live suites were environment-gated (26704 skipped, 173 deselected), so the meaningful broad local signal is the full unit suite above.
Spec conformance (OpenAPM v0.1)
If this PR changes behaviour that an OpenAPM v0.1 req-XXX covers,
confirm the three-step ritual (see CONTRIBUTING.md "Adding or
changing a normative requirement"):
Spec edit: docs/src/content/docs/specs/openapm-v0.1.md updated
(new/changed anchor + prose + Appendix C
row).
Manifest edit: docs/src/content/docs/specs/manifests/openapm-v0.1.requirements.yml
updated.
Test edit: a @pytest.mark.req("req-XXX") test under
tests/spec_conformance/ added or extended.
CONFORMANCE.{md,json} regenerated via
uv run --extra dev python -m tests.spec_conformance.gen_statement
and committed.
N/A -- this PR does not change OpenAPM-observable behaviour.