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
87 changes: 87 additions & 0 deletions prd-spec/bugfixes/2026-11-06-signed-tos-url-skill-download.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Signed TOS URL Skill Download

## Background and Evidence

`VeSkillRegistry.get_skill()` refreshes Skill Space metadata with `load_skills_from_cloud()`, selects a remote skill, and materializes it with `materialize_remote_skill()`. For legacy AgentKit Skill Spaces, `_build_skill_from_space_item()` maps `TosPath` to `Skill.path` and `BucketName` to `Skill.bucket_name`.

Today `_download_legacy_skill_space_skill()` assumes `Skill.path` is a TOS object key such as `skills/<skill_id>/<version>/<name>.zip`. If the cloud API returns a complete HTTPS pre-signed TOS URL in `TosPath`, the non-Vestack branch passes that URL to `VeTOS.download()` as an object key, while the Vestack branch tries to parse it as a legacy object path. Real validation against `ss-yey3lbgruoq7vcs7xlp4` also showed the object-key path can return `403 AccessDenied` for sharing-bucket skills, while `GenTempTosObjectDownloadUrlSharing` returns a usable signed URL for the same skill.

## Goals

- Load a normal Skill Space skill when `TosPath` is a complete pre-signed HTTPS TOS ZIP URL.
- Preserve direct object-key downloads when they work, and fall back to a sharing signed URL when they are denied.
- Reuse the existing archive safety checks: streaming size limit, zip validation, safe extraction, and ADK `SKILL.md` load validation.
- Keep signed URL content out of error messages and source fixtures except redacted, synthetic test URLs.

## Non-Goals

- Do not change Harness `tos_public_url` validation. User-provided Harness public URLs must continue to reject TOS credential query parameters.
- Do not change private TOS directory loading through `tos://bucket/prefix/`.
- Do not change `ListSkillsBySpaceId`.

## Scenarios

1. `ListSkillsBySpaceId` returns `BucketName` plus object-key `TosPath`; direct SDK TOS download still succeeds for readable objects.
2. `ListSkillsBySpaceId` returns object-key `TosPath`, but direct SDK TOS download is denied; the materializer requests `GenTempTosObjectDownloadUrlSharing`, downloads `Result.SignedUrl`, and loads the Skill.
3. `ListSkillsBySpaceId` returns `TosPath=https://bucket.tos-cn-beijing.volces.com/...zip?X-Tos-Signature=...`; the materializer downloads that URL directly and loads the Skill.
4. A signed URL redirects to a non-TOS host or returns a non-ZIP payload; materialization fails closed.

## Requirements

- Detect signed HTTPS TOS ZIP URLs only for cloud-returned Skill Space metadata.
- Accept TOS signing query keys for this cloud-returned path.
- Require HTTPS, a TOS endpoint host, a `.zip` path, and no username/password in the URL.
- Stream downloads with the same 64 MiB archive limit used by public TOS ZIPs.
- Preserve cache key behavior by using `version_id` when present; otherwise the metadata digest includes the URL.
- Do not log raw signed URLs or credential-bearing query strings during fallback.

## Design

Add a materializer branch for cloud-returned signed TOS ZIP URLs. `_build_skill_from_space_item()` will classify a `TosPath` that is an HTTPS TOS ZIP URL as a signed URL source. `materialize_remote_skill()` will then route that source through a signed-URL downloader instead of the legacy TOS object-key downloader.

The signed-URL downloader should share the public TOS ZIP streaming implementation, but use a validator that allows TOS signature query keys. Harness `tos_public_url` continues to use the stricter public validator that rejects credential query keys.

For legacy object-key metadata, keep the current direct `VeTOS.download()` attempt first. If that attempt fails or raises, request `GenTempTosObjectDownloadUrlSharing` with the skill id and version, read `Result.SignedUrl`, and pass it through the same signed-URL downloader.

## Affected Files

- `veadk/skills/utils.py`
- `veadk/skills/materializer.py`
- `tests/skills/test_adk_skill_materializer.py`

## Component Contract Assessment

No component spec update is required. The external Skill Space metadata contract already allows `TosPath` to carry the location returned by AgentKit; this change only fixes local materialization of one returned shape. Public Python APIs, Harness request schema, and user-facing configuration remain unchanged.

## Tasks

- Add a failing regression test for a cloud-returned pre-signed TOS URL in `TosPath`.
- Add a failing regression test for object-key `TosPath` falling back to a sharing signed URL after direct TOS download denial.
- Classify signed TOS URL metadata during Skill construction.
- Add a materializer download path that allows signed query keys only for cloud-returned TOS URLs.
- Reuse that signed URL download path for sharing signed URL fallback.
- Run targeted Skill tests and whitespace checks.

## Tests

- `uv run --extra dev pytest tests/skills/test_adk_skill_materializer.py`
- `uv run --extra dev pytest tests/skills/test_adk_skill_registry.py tests/skills/test_skill_space_policy.py`
- `git diff --check`

## Risks

- Signed URLs contain credentials in query parameters; logs and error messages must not echo raw URLs.
- URL cache keys may change when a refreshed signed URL changes. This is acceptable when no stable version is provided; `version_id` remains authoritative when available.
- Sharing signed URL generation may fail for skills with incomplete backend metadata; such skills still fail closed and should be diagnosed separately.

## Acceptance Criteria

- A Skill Space skill whose `TosPath` is a pre-signed HTTPS TOS ZIP URL downloads and loads successfully.
- Existing readable object-key Skill Space downloads still pass.
- Object-key Skill Space downloads that are denied by direct TOS access can load through the sharing signed URL fallback.
- Harness `tos_public_url` tests that reject signed query credentials still pass.
- No real credentials or production signed URLs are committed.

## Design Review

Direct review completed because no repository-local `review-spec` skill is available in this checkout. The design is bounded to cloud-returned metadata, preserves Harness user-input validation, fails closed on unsafe URL shapes, and has targeted regression coverage.
87 changes: 87 additions & 0 deletions prd-spec/bugfixes/2026-11-06-signed-tos-url-skill-download.zh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 预签名 TOS URL Skill 下载修复

## 背景和证据

`VeSkillRegistry.get_skill()` 会通过 `load_skills_from_cloud()` 刷新 Skill Space 元数据,选中远端 Skill 后调用 `materialize_remote_skill()` 本地物化。对于 legacy AgentKit Skill Space,`_build_skill_from_space_item()` 会把 `TosPath` 映射到 `Skill.path`,把 `BucketName` 映射到 `Skill.bucket_name`。

当前 `_download_legacy_skill_space_skill()` 假设 `Skill.path` 是 `skills/<skill_id>/<version>/<name>.zip` 这类 TOS 对象 key。如果云端 API 在 `TosPath` 中返回完整 HTTPS 预签名 TOS URL,非 Vestack 分支会把这个 URL 当作 object key 传给 `VeTOS.download()`,Vestack 分支则会把它误拆成 legacy 对象路径。针对 `ss-yey3lbgruoq7vcs7xlp4` 的真实验证还显示,sharing bucket 中的 object-key Skill 可能返回 `403 AccessDenied`,但同一 Skill 通过 `GenTempTosObjectDownloadUrlSharing` 可以拿到可用 signed URL。

## 目标

- 当 `TosPath` 是完整预签名 HTTPS TOS ZIP URL 时,普通 Skill Space Skill 可以正常加载。
- 保留可直接读取的 object-key 下载路径,并在直连被拒绝时 fallback 到 sharing signed URL。
- 复用现有归档安全检查:流式大小限制、ZIP 校验、安全解压和 ADK `SKILL.md` 加载校验。
- 除脱敏的合成测试 URL 外,不在错误信息和源码 fixture 中保留 signed URL 内容。

## 非目标

- 不修改 Harness `tos_public_url` 校验。用户传入的 Harness public URL 仍然必须拒绝 TOS 凭证 query 参数。
- 不修改通过 `tos://bucket/prefix/` 加载私有 TOS 目录的逻辑。
- 不修改 `ListSkillsBySpaceId`。

## 场景

1. `ListSkillsBySpaceId` 返回 `BucketName` 加 object-key `TosPath`;可读对象仍然通过 SDK TOS 直连下载。
2. `ListSkillsBySpaceId` 返回 object-key `TosPath`,但 SDK TOS 直连下载被拒绝;materializer 调用 `GenTempTosObjectDownloadUrlSharing`,下载 `Result.SignedUrl` 并加载 Skill。
3. `ListSkillsBySpaceId` 返回 `TosPath=https://bucket.tos-cn-beijing.volces.com/...zip?X-Tos-Signature=...`;materializer 直接下载该 URL 并加载 Skill。
4. signed URL 重定向到非 TOS host,或返回非 ZIP 内容;物化失败关闭。

## 需求

- 只对云端返回的 Skill Space 元数据识别 signed HTTPS TOS ZIP URL。
- 对这条云端返回路径允许 TOS 签名 query key。
- 要求 URL 使用 HTTPS、TOS endpoint host、`.zip` 路径,并且不包含 username/password。
- 下载必须流式执行,并沿用 public TOS ZIP 的 64 MiB 归档大小限制。
- 缓存 key 优先使用 `version_id`;没有版本时,metadata digest 可以包含 URL。
- fallback 过程中不得记录原始 signed URL 或携带凭证的 query string。

## 设计

为云端返回的 signed TOS ZIP URL 增加 materializer 分支。`_build_skill_from_space_item()` 会把 HTTPS TOS ZIP URL 形式的 `TosPath` 识别为 signed URL source。`materialize_remote_skill()` 随后走 signed URL 下载器,而不是 legacy TOS object-key 下载器。

signed URL 下载器应复用 public TOS ZIP 的流式下载实现,但使用允许 TOS 签名 query key 的校验器。Harness `tos_public_url` 继续使用更严格的 public 校验器,仍然拒绝凭证 query key。

对于 legacy object-key 元数据,先保留当前 `VeTOS.download()` 直连尝试。如果直连失败或抛错,则使用 skill id 和 version 调用 `GenTempTosObjectDownloadUrlSharing`,读取 `Result.SignedUrl`,并交给同一个 signed URL 下载器处理。

## 影响文件

- `veadk/skills/utils.py`
- `veadk/skills/materializer.py`
- `tests/skills/test_adk_skill_materializer.py`

## 组件契约评估

不需要更新组件 spec。外部 Skill Space 元数据契约已经允许 `TosPath` 承载 AgentKit 返回的位置;本次只修复本地物化对其中一种返回形态的处理。公开 Python API、Harness 请求 schema 和用户配置均不变化。

## 任务

- 为 `TosPath` 中云端返回的预签名 TOS URL 增加失败回归测试。
- 为 object-key `TosPath` 在 TOS 直连被拒绝后 fallback 到 sharing signed URL 增加失败回归测试。
- 在 Skill 构造时识别 signed TOS URL 元数据。
- 增加 materializer 下载路径,仅对云端返回的 TOS URL 允许签名 query key。
- 复用 signed URL 下载路径处理 sharing signed URL fallback。
- 运行目标 Skill 测试和空白检查。

## 测试

- `uv run --extra dev pytest tests/skills/test_adk_skill_materializer.py`
- `uv run --extra dev pytest tests/skills/test_adk_skill_registry.py tests/skills/test_skill_space_policy.py`
- `git diff --check`

## 风险

- signed URL 的 query 参数包含凭证;日志和错误信息不能回显原始 URL。
- 如果刷新后的 signed URL 改变且没有稳定版本号,URL 可能影响缓存 key。存在 `version_id` 时仍以版本号为准,这一点保持权威。
- sharing signed URL 生成可能因 Skill 后端元数据不完整而失败;这类 Skill 仍然失败关闭,需要单独诊断。

## 验收标准

- `TosPath` 为预签名 HTTPS TOS ZIP URL 的 Skill Space Skill 可以成功下载并加载。
- 现有可读 object-key Skill Space 下载测试仍然通过。
- TOS 直连被拒绝的 object-key Skill Space 下载可以通过 sharing signed URL fallback 加载。
- Harness `tos_public_url` 拒绝签名 query 凭证的测试仍然通过。
- 不提交真实凭证或生产 signed URL。

## 设计评审

当前 checkout 没有仓库本地 `review-spec` skill,因此已执行直接评审。设计边界限定在云端返回元数据,保留 Harness 用户输入校验,对不安全 URL 形态失败关闭,并具备目标回归覆盖。
123 changes: 123 additions & 0 deletions tests/skills/test_adk_skill_materializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,129 @@ def download_legacy_skill(skill: VeADKSkill, zip_path: Path) -> bool:
)


def test_legacy_skillspace_signed_tos_url_downloads_archive(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
signed_url = (
"https://bucket.tos-cn-beijing.volces.com/skills/s-123/v1/"
"signed-skill.zip?X-Tos-Algorithm=TOS4-HMAC-SHA256&"
"X-Tos-Signature=synthetic"
)
remote_skill = VeADKSkill(
name="signed-skill",
description="Signed skill.",
path=signed_url,
skill_space_id="space-1",
bucket_name="bucket",
id="s-123",
source_type="tos-signed-url",
version_id="v1",
)
fake_client = _FakeHttpClient(
[
_FakeStreamResponse(
signed_url,
_zip_bytes(
{
"signed-skill/SKILL.md": (
"---\nname: signed-skill\ndescription: Signed skill.\n---\n"
"Signed body.\n"
)
}
),
)
]
)
monkeypatch.setattr(materializer.httpx, "Client", lambda **kwargs: fake_client)

skill_dir = materialize_remote_skill(remote_skill, cache_dir=tmp_path)

assert fake_client.requested == [signed_url]
skill = load_skill_from_dir(skill_dir)
assert skill.name == "signed-skill"
assert skill.instructions == "Signed body."
assert skill_dir == (
tmp_path / "tos" / "space-1" / "signed-skill" / "v1" / "signed-skill"
)


def test_legacy_skillspace_object_key_falls_back_to_sharing_signed_url(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
remote_skill = VeADKSkill(
name="pdf",
description="PDF skill.",
path="skills/s-123/v1/pdf.zip",
skill_space_id="space-1",
bucket_name="sharing-bucket",
id="s-123",
version_id="v1",
)
signed_url = (
"https://sharing-bucket.tos-cn-beijing.volces.com/skills/s-123/v1/"
"pdf.zip?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Signature=synthetic"
)
tos_calls: list[dict[str, object]] = []
top_calls: list[dict[str, object]] = []

class FakeVeTOS:
def __init__(self, **kwargs: object) -> None:
tos_calls.append(kwargs)

def download(self, **kwargs: object) -> bool:
tos_calls.append(kwargs)
return False

def fake_ve_request(**kwargs: object) -> dict[str, object]:
top_calls.append(kwargs)
return {"Result": {"SignedUrl": signed_url}}

fake_client = _FakeHttpClient(
[
_FakeStreamResponse(
signed_url,
_zip_bytes(
{
"pdf/SKILL.md": (
"---\nname: pdf\ndescription: PDF skill.\n---\nPDF body.\n"
)
}
),
)
]
)

monkeypatch.setattr(
materializer,
"_get_cloud_credentials",
lambda: ("ak", "sk", "sts"),
)
monkeypatch.setattr(
materializer,
"_get_agentkit_endpoint",
lambda region=None: (
"agentkit",
"cn-beijing",
"agentkit.cn-beijing.volcengineapi.com",
),
)
monkeypatch.setattr("veadk.integrations.ve_tos.ve_tos.VeTOS", FakeVeTOS)
monkeypatch.setattr(materializer, "ve_request", fake_ve_request, raising=False)
monkeypatch.setattr(materializer.httpx, "Client", lambda **kwargs: fake_client)

skill_dir = materialize_remote_skill(remote_skill, cache_dir=tmp_path)
skill = load_skill_from_dir(skill_dir)

assert skill.name == "pdf"
assert skill.instructions == "PDF body."
assert fake_client.requested == [signed_url]
assert top_calls[0]["action"] == "GenTempTosObjectDownloadUrlSharing"
assert top_calls[0]["request_body"] == {"SkillId": "s-123", "SkillVersion": "v1"}
assert tos_calls[1]["raise_on_error"] is False


def test_legacy_skillspace_download_uses_explicit_region(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
Expand Down
25 changes: 25 additions & 0 deletions tests/skills/test_skill_space_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,31 @@ def test_space_policy_json_is_compact_and_deduplicated():
)


def test_space_item_signed_tos_path_is_classified_as_signed_url_source():
signed_url = (
"https://bucket.tos-cn-beijing.volces.com/skills/s-123/v1/"
"reporting.zip?X-Tos-Signature=synthetic"
)

skill = utils._build_skill_from_space_item(
{
"Name": "reporting",
"Description": "Reporting skill.",
"BucketName": "bucket",
"TosPath": signed_url,
"SkillId": "s-123",
"Version": "v1",
},
"ss-test",
)

assert skill is not None
assert skill.path == signed_url
assert skill.public_url == signed_url
assert skill.source_type == "tos-signed-url"
assert skill.bucket_name == "bucket"


@pytest.mark.parametrize(
("mode", "ids", "expected"),
[
Expand Down
Loading
Loading