Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/goodeye_cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -1907,13 +1907,21 @@ class PushResult(_SyncBase):
items: list[PushItem] = Field(default_factory=list)


def _verifier_payload(entry: SyncEntry) -> list[dict[str, Any]]:
def _verifier_payload(entry: SyncEntry) -> list[dict[str, Any]] | None:
"""Build the save-payload verifier bindings from an index entry.

Mirrors the publish path's binding shape (``name`` + ``verifier_id``) and
additionally preserves a pinned ``version`` when the recorded binding has
one, so a push never drops a version pin the workflow carried.

Returns ``None`` for a skill someone else owns, which leaves the field out
of the payload entirely. Only an owner may rewire the refs, and the
registry carries the stored ones forward when the field is absent, so a
grantee has nothing to gain by sending them and older servers reject the
attempt outright.
"""
if entry.effective_role != "owner":
return None
payload: list[dict[str, Any]] = []
for binding in entry.verifier_bindings:
row: dict[str, Any] = {"name": binding.name, "verifier_id": binding.verifier_id}
Expand Down
51 changes: 51 additions & 0 deletions tests/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -1905,6 +1905,57 @@ def test_push_omits_version_for_unpinned_binding(
assert sent["verifiers"] == [{"name": "tone", "verifier_id": "vrf_1"}]


@respx.mock
def test_push_omits_verifiers_for_shared_skill(
tmp_path: Path, tmp_config_paths: ConfigPaths
) -> None:
"""A push of someone else's skill leaves the verifier refs out of the payload.

Rewiring the refs is reserved to the skill's owner, so an edit grantee has
nothing to say about them: the server carries the stored refs forward when
the field is absent. Sending them back would be asking to set a field this
caller may not set, which older servers refuse outright.
"""
_me_route()
target_dir = tmp_path / "skills"
config = SyncConfig(targets=[SyncTarget(path=str(target_dir), scope="all")])
body = _push_body(slug="shared-runbook")
_write_skill(target_dir, "shared-runbook", body)
state = SyncState(
entries=[
_modified_entry(
target_dir,
id_="skl_shared",
slug="shared-runbook",
role="edit",
verifiers=[SyncVerifierBinding(name="tone", verifier_id="vrf_1", version=3)],
)
]
)
save_route = respx.post(f"{SERVER}/v1/skills").mock(
return_value=_save_response(
workflow_id="skl_shared",
name="shared-runbook",
verifiers=[{"name": "tone", "verifier_id": "vrf_1", "version": 3}],
)
)

with GoodeyeClient(SERVER, api_key="good_live_EXAMPLE") as client:
result = push(client, config, state, slugs=[], target_path=None, paths=tmp_config_paths)

sent = json.loads(save_route.calls[0].request.content)
assert "verifiers" not in sent
assert sent["skill_id"] == "skl_shared"
assert [(i.slug, i.action) for i in result.items] == [("shared-runbook", "pushed")]

# The bindings the server reports back are still recorded locally, so the
# index keeps tracking refs the caller cannot edit.
reloaded = load_sync_state(tmp_config_paths)
assert reloaded.entries[0].verifier_bindings == [
SyncVerifierBinding(name="tone", verifier_id="vrf_1", version=3)
]


@respx.mock
def test_push_edited_metadata_reaches_request(
tmp_path: Path, tmp_config_paths: ConfigPaths
Expand Down