Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
47 changes: 47 additions & 0 deletions ms_agent/agent_hub/_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import difflib
import fnmatch
import re
from dataclasses import dataclass, field

Expand Down Expand Up @@ -446,6 +447,36 @@ def merge(
'heartbeat': 'HEARTBEAT.md',
}

# Framework-private files: same NAME across frameworks but incompatible FORMAT
# (e.g. hermes vs ms-agent ``config.yaml``, ms-agent vs qwenpaw ``skill.json``).
# They have no cross-framework semantics, so on a convert they must be dropped
# -- NOT carried over verbatim. The normal safety net (a file with no target
# pattern is filtered out downstream) fails exactly here, because the target
# framework happens to declare an identically-named pattern and would load a
# file it cannot parse. Same-framework sync is unaffected (that path keeps
# every file verbatim by design).
PRODUCT_PRIVATE_FILES = {
'hermes': frozenset(['config.yaml', 'hooks/*']),
'ms-agent':
frozenset(
['config.yaml', 'settings.json', 'agent.yaml', 'facts.json',
'skill.json']),
'qwenpaw': frozenset(['agent.json', 'skill.json']),
'openhuman': frozenset(['config.toml']),
}


def _is_private_file(product: str, path: str) -> bool:
"""Whether *path* is a framework-private (non-portable) file of *product*.

Matches by fnmatch so glob entries like ``hooks/*`` cover their whole tree.
"""
for pat in PRODUCT_PRIVATE_FILES.get(product, ()):
if path == pat or fnmatch.fnmatch(path, pat):
return True
return False


_section_merger = SectionMerger()
_heartbeat_merger = HeartbeatMerger()

Expand Down Expand Up @@ -736,6 +767,22 @@ def merge_resources(
))
continue

# Framework-private config/manifest with no cross-framework meaning
# (e.g. hermes vs ms-agent ``config.yaml``): on a convert it must be
# dropped rather than carried over, since the target framework may
# declare an identically-named file it cannot parse. Same-framework
# sync keeps everything, so only guard the cross-product path.
if is_cross_product and _is_private_file(source_product, path):
result.actions.append(
MergeAction(
path=path,
action='skip',
detail=(f'{path} is {source_product}-private '
f'(incompatible format on {target_product}), '
f'dropped'),
))
continue

# The source's per-agent persona file (dynamic name, absent from the
# static path map): force the no-equivalent route so it folds into the
# target's persona file instead of being carried over verbatim and
Expand Down
50 changes: 48 additions & 2 deletions ms_agent/agent_hub/_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,43 @@ def _retry_on_master_missing(fn, *, retries: int = 6, delay: float = 1.0):
raise


def _verify_visibility_or_abort(client: 'AgentApi', username: str,
name: str, requested: str) -> None:
"""Read back a freshly created repo's visibility; abort on a leak.

The visibility flag is set at creation time (not subject to the file-tree
eventual-consistency lag), so a private request that reads back as public
means the server did not honor it and any push would leak. Rather than
report success with a ``private`` label the server never applied, raise so
the caller aborts before uploading a single byte.

Conservative on purpose: only a POSITIVE ``public`` reading fails. If the
metadata cannot be read (transient error, anonymous-probe fallback with no
fields, or an unrecognized value), proceed rather than block a legitimate
upload on an unverifiable state.
"""
if requested != 'private':
return
from modelscope_hub.agent import agent_visibility_label
try:
info = client.repo_info(username, name)
except Exception as exc:
logger.warning(
'Could not verify visibility for %s/%s (%s); proceeding.',
username, name, exc)
return
if not info:
logger.warning(
'Could not read back visibility for %s/%s; proceeding.', username,
name)
return
if agent_visibility_label(info) == 'public':
raise RuntimeError(
f'requested private but {username}/{name} was created PUBLIC on '
f'the server; aborting before upload to avoid leaking content. '
f'Delete the repo and retry, or check server-side permissions.')


def push_resources(
client: 'AgentApi',
username: str,
Expand Down Expand Up @@ -214,16 +251,25 @@ def push_resources(
return

# Ensure repo exists (idempotent create).
created = False
try:
if not client.check_repo(username, name):
client.create_repo(
username, name, framework=framework, visibility=visibility)
logger.info('Created empty agent repo %s/%s (framework=%s, %s).',
username, name, framework, visibility)
created = True
logger.info(
'Created empty agent repo %s/%s (framework=%s, requested '
'visibility=%s).', username, name, framework, visibility)
except Exception as exc:
logger.warning('create_repo check failed (%s), proceeding anyway.',
exc)

# Before pushing any content, confirm the server actually honored a
# private request -- a mismatch here used to be reported as a successful
# "private" upload while the repo was in fact public (silent leak).
if created:
_verify_visibility_or_abort(client, username, name, visibility)

# Idempotent upsert: skip files whose content already matches the remote.
# A repeated full upload of unchanged content would otherwise issue
# zero-delta commits; for an unchanged LFS pointer the server rejects the
Expand Down
52 changes: 49 additions & 3 deletions ms_agent/agent_hub/_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,14 +315,60 @@ def join_all_path(self, agent_name: str, bare_path: str) -> str:
# Core helpers
# ------------------------------------------------------------------

# Sub-paths (in priority order) that may hold the real data root when
# ``local_dir`` is given one level too high -- e.g. nanobot's install root
# ``.nanobot`` versus its data root ``.nanobot/workspace``. Empty (the
# default) means the install root IS the data root, so no probing happens.
_ROOT_SUBDIRS: tuple[str, ...] = ()

def _holds_own_files(self, base: Path) -> bool:
"""Whether *base* directly holds this framework's files.

Markers are the leading segments of every pattern that carry no
wildcard or ``{name}`` placeholder, i.e. the fixed top-level entries
a populated workspace of this framework must have.
"""
for pattern in self.patterns:
head = pattern.split('/')[0]
if any(c in head for c in '*?{'):
continue
if (base / head).exists():
return True
return False

def _probe_root(self, base: Path) -> Path:
"""Resolve *base* to the data root, descending one known sub-path.

Users naturally pass the install root (``--local_dir ~/.nanobot``)
while the files live a level down (``~/.nanobot/workspace``), which
used to fail with a bare 'no files found'. Descend only into a
declared sub-path that already EXISTS, and only when *base* holds none
of this framework's own files -- so an explicit data root still wins,
and a fresh/empty output dir is never redirected (that would silently
relocate written files). Deliberately not a recursive search: this
path is also the WRITE target for download/convert, where guessing a
nested directory (a backup copy, say) could clobber unrelated files.
"""
if not self._ROOT_SUBDIRS or self._holds_own_files(base):
return base
for sub in self._ROOT_SUBDIRS:
candidate = base / sub
if candidate.is_dir():
return candidate
return base

@property
def root(self) -> Path:
"""Effective framework data root: ``local_dir`` override, else the default.
"""Effective framework data root: ``local_dir`` override, else default.

``local_dir`` ALWAYS means the data root, uniformly across every
framework; per-agent subdirectories (if any) are derived from it by
``workspace_root``."""
return self._local_dir if self._local_dir is not None else self.default_root
``workspace_root``. An override that points at the install root
instead is normalized by :meth:`_probe_root`.
"""
if self._local_dir is not None:
return self._probe_root(self._local_dir)
return self.default_root

@property
def workspace_root(self) -> Path:
Expand Down
8 changes: 8 additions & 0 deletions ms_agent/agent_hub/frameworks/nanobot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ class NanobotWorkspace(WorkspaceSpec):
append-only event log ``memory/history.jsonl`` (the legacy ``HISTORY.md``
was replaced by the JSONL log). Sub-agents run as background sessions
(no on-disk per-agent files), so this is single-agent.

Note that the install root and the data root differ by one level: the
files sit in ``workspace/``, not directly under ``~/.nanobot``. Declaring
``_ROOT_SUBDIRS`` lets a ``--local_dir`` pointing at the install root be
accepted too, which otherwise failed with "no nanobot files found".
"""

# ``.nanobot`` (install root) -> ``.nanobot/workspace`` (data root).
_ROOT_SUBDIRS = ('workspace', )

@property
def product_name(self) -> str:
return 'nanobot'
Expand Down
13 changes: 5 additions & 8 deletions ms_agent/cli/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,14 +385,13 @@ def execute(self) -> None:
raise SystemExit(1)
try:
openapi = OpenAPIClient(config=config)
user_data = openapi.get_current_user()
if not user_data:
username = openapi.get_current_username()
if not username:
print(
'Error: failed to resolve current user: empty response from server.',
'Error: failed to resolve current user: server did not '
'return a username.',
file=sys.stderr)
raise SystemExit(1)
username = user_data.get('username') or user_data.get(
'Username') or ''
except SystemExit:
raise
except Exception as e:
Expand All @@ -414,9 +413,7 @@ def execute(self) -> None:
from modelscope_hub._openapi import OpenAPIClient
try:
openapi = OpenAPIClient(config=config)
user_data = openapi.get_current_user()
username = user_data.get('username') or user_data.get(
'Username') or ''
username = openapi.get_current_username()
except Exception:
pass

Expand Down
23 changes: 23 additions & 0 deletions tests/agent_hub/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
# Copyright (c) Alibaba, Inc. and its affiliates.
# Copyright (c) Alibaba, Inc. and its affiliates.
"""Shared helpers for agent tests."""
import unittest


def skip_if_server_rejects(*frameworks):
"""Skip the current test when the server will not create repos for *frameworks*.

Repo creation is gated server-side to the same set the CLI exposes by
default; anything else comes back as ``invalid framework, must be one of:
ms-agent, qwenpaw`` and no repo is created, so the test would go on to fail
on an unrelated 404 ("project not found") that says nothing about the real
cause. ``TRY_EXP_FRAMEWORKS`` only lifts the client-side gate, never this
one, so these cases cannot pass online no matter how they are configured.

The gated frameworks stay fully covered offline, and these skips disappear
on their own once the server accepts more frameworks.
"""
from ms_agent.agent_hub._commands import STABLE_FRAMEWORKS
rejected = sorted(set(frameworks) - set(STABLE_FRAMEWORKS))
if rejected:
raise unittest.SkipTest(
f"server only creates agent repos for "
f"{', '.join(sorted(STABLE_FRAMEWORKS))}; "
f"cannot exercise {', '.join(rejected)} online")


def delete_matching_repos(client, owner, substrings, *, page_size=100, max_pages=50):
Expand Down
Loading
Loading