@truefoundry/trueforge-core 0.2.0-rc.2 → 0.2.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-session/TurnResourceResolver.d.ts +13 -10
- package/dist/agent-session/TurnResourceResolver.d.ts.map +1 -1
- package/dist/agent-session/TurnResourceResolver.js +15 -1
- package/dist/agent-session/TurnResourceResolver.js.map +1 -1
- package/dist/agent-session/TurnResourceResolver.mjs +15 -1
- package/dist/agent-session/TurnResourceResolver.mjs.map +1 -1
- package/dist/agent-session/index.d.ts +1 -1
- package/dist/agent-session/index.d.ts.map +1 -1
- package/dist/agent-session/index.js.map +1 -1
- package/dist/agent-session/index.mjs.map +1 -1
- package/dist/agent-session/schemas/agentSpec.d.ts +4 -2
- package/dist/agent-session/schemas/agentSpec.d.ts.map +1 -1
- package/dist/agent-session/schemas/agentSpec.js +5 -3
- package/dist/agent-session/schemas/agentSpec.js.map +1 -1
- package/dist/agent-session/schemas/agentSpec.mjs +5 -3
- package/dist/agent-session/schemas/agentSpec.mjs.map +1 -1
- package/dist/agent-session/schemas/session.d.ts +3 -0
- package/dist/agent-session/schemas/session.d.ts.map +1 -1
- package/dist/agent-session/store/ISessionStore.d.ts +10 -0
- package/dist/agent-session/store/ISessionStore.d.ts.map +1 -1
- package/dist/agent-session/store/ISessionStore.js.map +1 -1
- package/dist/agent-session/store/InMemorySessionStore.d.ts +2 -1
- package/dist/agent-session/store/InMemorySessionStore.d.ts.map +1 -1
- package/dist/agent-session/store/InMemorySessionStore.js +13 -0
- package/dist/agent-session/store/InMemorySessionStore.js.map +1 -1
- package/dist/agent-session/store/InMemorySessionStore.mjs +13 -0
- package/dist/agent-session/store/InMemorySessionStore.mjs.map +1 -1
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +5 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +3 -0
- package/dist/core/index.mjs.map +1 -1
- package/dist/core/llm/VercelAILLM.d.ts.map +1 -1
- package/dist/core/llm/VercelAILLM.js +4 -0
- package/dist/core/llm/VercelAILLM.js.map +1 -1
- package/dist/core/llm/VercelAILLM.mjs +4 -0
- package/dist/core/llm/VercelAILLM.mjs.map +1 -1
- package/dist/core/redisKeys.d.ts +7 -0
- package/dist/core/redisKeys.d.ts.map +1 -0
- package/dist/core/redisKeys.js +36 -0
- package/dist/core/redisKeys.js.map +1 -0
- package/dist/core/redisKeys.mjs +10 -0
- package/dist/core/redisKeys.mjs.map +1 -0
- package/dist/core/sandbox/Sandbox.d.ts.map +1 -1
- package/dist/core/sandbox/Sandbox.js +8 -1
- package/dist/core/sandbox/Sandbox.js.map +1 -1
- package/dist/core/sandbox/Sandbox.mjs +8 -1
- package/dist/core/sandbox/Sandbox.mjs.map +1 -1
- package/dist/core/sandbox/provider/DaytonaProvider.d.ts +1 -1
- package/dist/core/sandbox/provider/DaytonaProvider.d.ts.map +1 -1
- package/dist/core/sandbox/provider/DaytonaProvider.js +3 -3
- package/dist/core/sandbox/provider/DaytonaProvider.js.map +1 -1
- package/dist/core/sandbox/provider/DaytonaProvider.mjs +3 -3
- package/dist/core/sandbox/provider/DaytonaProvider.mjs.map +1 -1
- package/dist/core/sandbox/provider/Provider.d.ts +20 -4
- package/dist/core/sandbox/provider/Provider.d.ts.map +1 -1
- package/dist/core/sandbox/provider/Provider.js.map +1 -1
- package/dist/core/sandbox/provider/Provider.mjs.map +1 -1
- package/dist/core/sandbox/provider/TFYSandboxProvider.d.ts +2 -2
- package/dist/core/sandbox/provider/TFYSandboxProvider.d.ts.map +1 -1
- package/dist/core/sandbox/provider/TFYSandboxProvider.js +4 -4
- package/dist/core/sandbox/provider/TFYSandboxProvider.js.map +1 -1
- package/dist/core/sandbox/provider/TFYSandboxProvider.mjs +4 -4
- package/dist/core/sandbox/provider/TFYSandboxProvider.mjs.map +1 -1
- package/dist/core/sandbox/sandboxScripts.gen.d.ts +1 -1
- package/dist/core/sandbox/sandboxScripts.gen.d.ts.map +1 -1
- package/dist/core/sandbox/sandboxScripts.gen.js +1 -1
- package/dist/core/sandbox/sandboxScripts.gen.js.map +1 -1
- package/dist/core/sandbox/sandboxScripts.gen.mjs +1 -1
- package/dist/core/sandbox/sandboxScripts.gen.mjs.map +1 -1
- package/dist/core/sandbox/skills/ISkillMounter.d.ts +1 -1
- package/dist/core/sandbox/skills/ISkillMounter.d.ts.map +1 -1
- package/dist/core/sandbox/skills/ISkillMounter.js.map +1 -1
- package/dist/core/sandbox/skills/SkillMounter.d.ts +20 -2
- package/dist/core/sandbox/skills/SkillMounter.d.ts.map +1 -1
- package/dist/core/sandbox/skills/SkillMounter.js +59 -23
- package/dist/core/sandbox/skills/SkillMounter.js.map +1 -1
- package/dist/core/sandbox/skills/SkillMounter.mjs +58 -23
- package/dist/core/sandbox/skills/SkillMounter.mjs.map +1 -1
- package/dist/core/sandbox/skills/index.d.ts +1 -1
- package/dist/core/sandbox/skills/index.d.ts.map +1 -1
- package/dist/core/sandbox/skills/index.js.map +1 -1
- package/dist/core/sandbox/skills/index.mjs.map +1 -1
- package/dist/request-reply/executor.d.ts +1 -1
- package/dist/request-reply/executor.d.ts.map +1 -1
- package/dist/request-reply/executor.js +1 -1
- package/dist/request-reply/executor.js.map +1 -1
- package/dist/request-reply/executor.mjs +1 -1
- package/dist/request-reply/executor.mjs.map +1 -1
- package/dist/request-reply/utils.d.ts +0 -1
- package/dist/request-reply/utils.d.ts.map +1 -1
- package/dist/request-reply/utils.js +4 -6
- package/dist/request-reply/utils.js.map +1 -1
- package/dist/request-reply/utils.mjs +4 -5
- package/dist/request-reply/utils.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/core/sandbox/sandboxScripts.gen.ts"],"sourcesContent":["// AUTO-GENERATED by scripts/generate-sandbox-scripts.mjs — do not edit.\nexport const sandboxScripts = {\n mcpClient: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"mcp==1.29.0\\\", \\\"pydantic==2.12.5\\\", \\\"nats-py==2.15.0\\\"]\\n# ///\\n\\nimport os\\nimport sys\\nimport json\\nimport time\\nimport base64\\nimport asyncio\\nimport logging\\nimport argparse\\nfrom pathlib import Path\\nfrom typing import Any, TypedDict\\n\\nfrom pydantic import BaseModel, ValidationError\\n\\nfrom mcp.types import CallToolResult, TextContent, Tool\\n\\nlogger = logging.getLogger(__name__)\\n\\n_NATS_REQUEST_MAX_ATTEMPTS = 3\\n_NATS_RETRY_BACKOFF_MS = 250\\n\\n_nats_url = os.environ.get(\\\"TFY_NATS_URL\\\")\\n_nats_subject_prefix = os.environ.get(\\\"TFY_NATS_SUBJECT_PREFIX\\\")\\n_NATS_SUBJECT = f\\\"{_nats_subject_prefix}.mcp\\\" if _nats_url and _nats_subject_prefix else None\\nif bool(_nats_url) != bool(_nats_subject_prefix):\\n raise RuntimeError(\\n \\\"TFY_NATS_URL and TFY_NATS_SUBJECT_PREFIX must be set together; \\\"\\n f\\\"got TFY_NATS_URL={'set' if _nats_url else 'unset'}, \\\"\\n f\\\"TFY_NATS_SUBJECT_PREFIX={'set' if _nats_subject_prefix else 'unset'}\\\"\\n ) from None\\n\\n_nats_trace_headers: dict[str, str] = {}\\nif _traceparent := os.environ.get(\\\"TFY_TRACEPARENT\\\"):\\n _nats_trace_headers[\\\"traceparent\\\"] = _traceparent\\nif _tracestate := os.environ.get(\\\"TFY_TRACESTATE\\\"):\\n _nats_trace_headers[\\\"tracestate\\\"] = _tracestate\\n\\n\\nclass _ToolsCache(BaseModel):\\n fetched_at: float\\n tools: list[Tool]\\n\\n\\nclass _BridgeReply(BaseModel):\\n \\\"\\\"\\\"NATS bridge reply envelope. `result` is left untyped — operation-specific callers\\n (Tool / CallToolResult) validate the payload; this model only validates the envelope.\\\"\\\"\\\"\\n\\n ok: bool\\n result: Any = None\\n error: str = \\\"unknown error\\\"\\n source: str = \\\"internal\\\"\\n\\n\\nclass _ServerConfig(TypedDict):\\n allowed_tools: list[str]\\n\\n\\n_TOOLS_CACHE_TTL_SECONDS = 600\\n\\n_inflight_list_tools: dict[str, asyncio.Task[list[Tool]]] = {}\\n\\n_raw_servers = os.environ.get(\\\"TFY_MCP_SERVERS\\\")\\n_servers_map: dict[str, _ServerConfig] = json.loads(base64.b64decode(_raw_servers).decode()) if _raw_servers else {}\\n# Approvals are always enabled (Sandbox always injects TFY_ENABLE_AGENT_APPROVALS=true).\\n_enable_agent_approvals = os.environ.get(\\\"TFY_ENABLE_AGENT_APPROVALS\\\", \\\"true\\\").lower() == \\\"true\\\"\\n\\n\\ndef _check_tool_allowed(server: str, tool_name: str) -> None:\\n server_config = _servers_map.get(server)\\n if server_config is None:\\n raise RuntimeError(f\\\"Access denied: MCP server '{server}' is not available for this agent\\\") from None\\n server_tools = server_config.get(\\\"allowed_tools\\\") or []\\n if len(server_tools) > 0 and tool_name not in server_tools:\\n raise RuntimeError(f\\\"Access denied: tool '{tool_name}' is not enabled on server '{server}'\\\") from None\\n\\n\\ndef _cache_path(server: str) -> Path:\\n return Path(__file__).parent / f\\\"{server}.tools.json\\\"\\n\\n\\ndef _read_tools_cache(server: str) -> _ToolsCache | None:\\n p = _cache_path(server)\\n if not p.exists():\\n return None\\n try:\\n cache = _ToolsCache.model_validate_json(p.read_text(encoding=\\\"utf-8\\\"))\\n except Exception:\\n logger.exception(\\\"_ToolsCache.model_validate_json\\\")\\n p.unlink(missing_ok=True)\\n return None\\n if time.time() - cache.fetched_at > _TOOLS_CACHE_TTL_SECONDS:\\n p.unlink(missing_ok=True)\\n return None\\n return cache\\n\\n\\ndef _write_tools_cache(server: str, tools: list[Tool]) -> None:\\n try:\\n cache = _ToolsCache(fetched_at=time.time(), tools=tools)\\n _cache_path(server).write_text(cache.model_dump_json(), encoding=\\\"utf-8\\\")\\n except Exception:\\n logger.exception(\\\"_write_tools_cache\\\")\\n\\n\\ndef _require_nats_subject() -> str:\\n if not _NATS_SUBJECT or not _nats_url:\\n raise RuntimeError(\\n \\\"NATS bridge setup failed; NATS bridge is required for MCP from Code Mode. \\\"\\n \\\"Try again.\\\"\\n ) from None\\n return _NATS_SUBJECT\\n\\n\\nasync def _fetch_tools(server: str) -> list[Tool]:\\n result = await _nats_request(_require_nats_subject(), {\\\"op\\\": \\\"list_tools\\\", \\\"server\\\": server})\\n if not isinstance(result, dict):\\n raise RuntimeError(f\\\"NATS list_tools '{server}' returned unexpected shape: {result!r}\\\") from None\\n return [Tool.model_validate(t) for t in result.get(\\\"tools\\\", [])]\\n\\n\\nasync def _fetch_and_cache_tools(server: str) -> list[Tool]:\\n tools = await _fetch_tools(server)\\n _write_tools_cache(server, tools)\\n return tools\\n\\n\\nasync def _nats_request(subject: str, payload: dict[str, Any]) -> Any:\\n \\\"\\\"\\\"Send one request over a short-lived bridge connection, unwrap { ok, result|error }.\\n\\n Connection is per-call (closed in `finally`) to avoid leaking nats-py's aiohttp session.\\n Connect and request are retried in separate loops so a successful connection isn't torn\\n down and rebuilt on a request-level retry. Retries only failures that prove the request\\n never reached a handler (connect failure, NoRespondersError); timeouts and post-delivery\\n failures are not retried so a non-idempotent tool can't double-execute.\\n \\\"\\\"\\\"\\n import nats\\n from nats.errors import NoRespondersError\\n\\n data = json.dumps(payload).encode()\\n request_timeout = float(os.environ[\\\"TFY_CM_REQUEST_TIMEOUT_SECONDS\\\"])\\n\\n # Phase 1: establish a connection, retrying only connect failures.\\n nc = None\\n last_connect_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n nc = await nats.connect(_nats_url)\\n break\\n except Exception as e:\\n last_connect_error = f\\\"connect to NATS at {_nats_url} failed: {e}\\\"\\n if nc is None:\\n raise RuntimeError(\\n f\\\"NATS connect failed for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_connect_error}\\\"\\n ) from None\\n\\n # Phase 2: request over the established connection, retrying only NoRespondersError.\\n try:\\n last_transport_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n msg = await nc.request(\\n subject,\\n data,\\n timeout=request_timeout,\\n headers=_nats_trace_headers or None,\\n )\\n except NoRespondersError as e:\\n # Server confirms no subscriber — never delivered, safe to retry.\\n last_transport_error = f\\\"no NATS responder for '{subject}': {e}\\\"\\n continue\\n except Exception as e:\\n # Timeout / other: the tool may already have run, so we must not retry.\\n raise RuntimeError(f\\\"NATS request '{subject}' failed: {e}\\\") from None\\n try:\\n reply = _BridgeReply.model_validate_json(msg.data.decode())\\n except ValidationError as e:\\n raise RuntimeError(f\\\"NATS reply on '{subject}' is malformed: {e}\\\") from None\\n if not reply.ok:\\n # A well-formed reply means the transport worked; `source` says who's at fault.\\n if reply.source == \\\"caller\\\":\\n raise RuntimeError(f\\\"Invalid MCP request on '{subject}': {reply.error}\\\") from None\\n if reply.source == \\\"transport\\\":\\n raise RuntimeError(f\\\"Code Mode transport error on '{subject}': {reply.error}\\\") from None\\n raise RuntimeError(f\\\"Internal MCP error on '{subject}': {reply.error}\\\") from None\\n return reply.result\\n raise RuntimeError(\\n f\\\"NATS bridge has no responder for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_transport_error}\\\"\\n ) from None\\n finally:\\n try:\\n await nc.close()\\n except Exception:\\n logger.debug(\\\"Error closing NATS connection\\\", exc_info=True)\\n\\n\\nasync def _get_tools(server: str) -> list[Tool]:\\n cache = _read_tools_cache(server)\\n if cache is not None:\\n return cache.tools\\n task = _inflight_list_tools.get(server)\\n if task is None:\\n task = asyncio.create_task(_fetch_and_cache_tools(server))\\n _inflight_list_tools[server] = task\\n task.add_done_callback(lambda _t: _inflight_list_tools.pop(server, None))\\n return await task\\n\\n\\nasync def _get_tool(server: str, tool_name: str) -> Tool | None:\\n for t in await _get_tools(server):\\n if t.name == tool_name:\\n return t\\n return None\\n\\n\\ndef _is_destructive(tool: Tool) -> bool:\\n annotations = tool.annotations\\n if annotations is None:\\n return False\\n\\n destructive = getattr(\\n annotations, \\\"destructive_hint\\\", getattr(annotations, \\\"destructiveHint\\\", None)\\n )\\n read_only = getattr(\\n annotations, \\\"read_only_hint\\\", getattr(annotations, \\\"readOnlyHint\\\", None)\\n )\\n return bool(destructive) or (not read_only and read_only is not None)\\n\\nasync def _ensure_non_destructive(server: str, tool_name: str) -> None:\\n tool = await _get_tool(server, tool_name)\\n if tool is None:\\n raise RuntimeError(f\\\"Tool '{tool_name}' not found on MCP server '{server}'\\\") from None\\n if _is_destructive(tool):\\n raise RuntimeError(\\n f\\\"Tool '{tool_name}' on MCP server '{server}' is destructive and cannot be called in Code Mode; \\\"\\n f\\\"call it directly so it can go through the user approval flow\\\"\\n ) from None\\n\\n\\ndef _project_call_tool_result(server: str, tool: str, result: CallToolResult) -> Any:\\n \\\"\\\"\\\"Project an MCP-wire CallToolResult into the user-facing Python value.\\\"\\\"\\\"\\n if result.isError:\\n text_parts = [c.text for c in result.content if isinstance(c, TextContent) and c.text]\\n msg = \\\"; \\\".join(text_parts) if text_parts else \\\"tool returned an error\\\"\\n raise RuntimeError(f\\\"MCP tool error (server={server}, tool={tool}): {msg}\\\") from None\\n\\n if result.structuredContent is not None:\\n return result.structuredContent\\n\\n # Fallback to content blocks (tools without outputSchema / plain text responses)\\n if len(result.content) == 1:\\n first = result.content[0]\\n if isinstance(first, TextContent) and first.text:\\n try:\\n return json.loads(first.text)\\n except Exception:\\n pass\\n if result.content:\\n return result.content\\n return None\\n\\n\\nasync def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any:\\n _check_tool_allowed(server, tool)\\n if _enable_agent_approvals:\\n await _ensure_non_destructive(server, tool)\\n\\n raw = await _nats_request(\\n _require_nats_subject(),\\n {\\\"op\\\": \\\"call_tool\\\", \\\"server\\\": server, \\\"tool\\\": tool, \\\"arguments\\\": body},\\n )\\n try:\\n result = CallToolResult.model_validate(raw)\\n except Exception as e:\\n raise RuntimeError(f\\\"NATS call_tool reply for '{server}/{tool}' is malformed: {e}\\\") from None\\n\\n return _project_call_tool_result(server, tool, result)\\n\\n\\n_USAGE = \\\"mcp_client.py call-tool <server> <tool> <args-json>\\\"\\n\\n\\ndef _build_arg_parser() -> argparse.ArgumentParser:\\n parser = argparse.ArgumentParser(prog=\\\"mcp_client.py\\\", usage=_USAGE)\\n sub = parser.add_subparsers(dest=\\\"cmd\\\", required=True)\\n\\n call_tool_p = sub.add_parser(\\\"call-tool\\\", help=\\\"Invoke an MCP tool\\\")\\n call_tool_p.add_argument(\\\"server\\\")\\n call_tool_p.add_argument(\\\"tool\\\")\\n # Parsing JSON at the argparse boundary surfaces malformed input as a clean\\n # argparse error (with usage) instead of an opaque traceback from inside the handler.\\n call_tool_p.add_argument(\\\"args_json\\\", metavar=\\\"args-json\\\", type=json.loads)\\n\\n return parser\\n\\n\\nasync def _main() -> None:\\n args = _build_arg_parser().parse_args()\\n try:\\n if args.cmd == \\\"call-tool\\\":\\n result = await call_tool(args.server, args.tool, args.args_json)\\n print(json.dumps(result, default=str))\\n except RuntimeError as e:\\n sys.exit(str(e))\\n\\n\\nif __name__ == \\\"__main__\\\":\\n asyncio.run(_main())\\n\",\n gitDownloader: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"pydantic==2.12.5\\\"]\\n# ///\\n\\n\\\"\\\"\\\"Materialize git skill directories into TFY_SKILLS_DIR (default /opt/tfy/skills). Requires `git`.\\n\\nEach git skill (AGENT_GIT_SKILLS) is materialized with a blob-filtered sparse `git` clone that\\nfetches only the requested subdir at a resolved object id, keyed by name. Mount `ref` values\\n(branch, tag, or full object id) are resolved here via `git ls-remote` — the host never spawns\\ngit. A single on-disk state file records the installed object id + subdir per name so a skill\\nwhose tip hasn't moved is skipped instead of re-cloning every run, and skills no longer desired\\nare pruned from disk. A sparse clone is used instead of a full repo tarball because its cost is\\n~constant in the subdir size rather than the whole-repo size, which is dramatically faster for a\\nsmall skill living in a large monorepo.\\n\\nOptional env:\\n - AGENT_GIT_SKILLS (base64-encoded JSON list of {name, url, path, ref}; empty\\n clears git downloads). `ref` is a branch, tag, or full object id.\\n - TFY_SKILLS_DIR: override the skills directory (default /opt/tfy/skills).\\n\\\"\\\"\\\"\\n\\nfrom __future__ import annotations\\n\\nimport base64\\nimport json\\nimport os\\nimport re\\nimport shutil\\nimport subprocess\\nimport sys\\nimport tempfile\\nfrom pathlib import Path\\n\\nfrom pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator\\n\\nDEFAULT_SKILLS_DIR = \\\"/opt/tfy/skills\\\"\\nSTATE_FILE_NAME = \\\".tfy-skill-downloader-state.json\\\"\\n# Per-git-invocation wall-clock cap so a hung/slow fetch can't stall sandbox init indefinitely.\\nGIT_CLONE_TIMEOUT_SECONDS = 120\\n# Cap the installed skill size so a huge repo-root skill can't fill the persistent skills dir. The\\n# sparse clone already bounds a subdir skill to its subdir; this also guards the whole-repo (root\\n# subdir) case where checkout hydrates every file.\\nGIT_SKILL_MAX_BYTES = 200 * 1024 * 1024 # 200MB installed\\n# Resolved object id used for fetch + skip state (sha1 / sha256).\\nOBJECT_ID_RE = re.compile(r\\\"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$\\\")\\n# Mount ref (branch/tag/SHA) charset — must match host AgentSpec SkillMount.ref validation.\\n# Validated before the value is passed to `git ls-remote` / used as a fetch arg fallback.\\nGIT_REF_RE = re.compile(r\\\"^[A-Za-z0-9._\\\\-/]+$\\\")\\nSKILLS_ROOT = Path(os.environ.get(\\\"TFY_SKILLS_DIR\\\", DEFAULT_SKILLS_DIR))\\nSTATE_PATH = SKILLS_ROOT / STATE_FILE_NAME\\n\\n\\nclass GitSkillError(Exception):\\n \\\"\\\"\\\"Recoverable per-repo/per-skill git failure. Raised (not sys.exit) so a single bad repo or\\n subdirectory doesn't abort the remaining repository groups (best-effort failure isolation).\\\"\\\"\\\"\\n\\n\\ndef skill_dir(name: str) -> Path | None:\\n \\\"\\\"\\\"Resolve a skill's on-disk directory. This is the ONLY place a skill name becomes a path, so\\n the safety check here is what guarantees that no name — freshly requested or read back from the\\n (possibly corrupt) state file — can escape SKILLS_ROOT during delete/copy.\\n\\n Returns None for an unsafe name (empty, absolute, containing a path separator, \\\".\\\" / \\\"..\\\", or the\\n reserved state-file name) so each caller can skip it per its own failure contract instead of\\n touching an out-of-tree path. The state-file name is reserved because a skill whose name equals it\\n would resolve to STATE_PATH and let a skill install clobber the reconciliation state.\\n \\\"\\\"\\\"\\n if not name or name in (\\\".\\\", \\\"..\\\") or \\\"/\\\" in name or \\\"\\\\\\\\\\\" in name or name == STATE_FILE_NAME:\\n return None\\n return SKILLS_ROOT / name\\n\\n\\nclass GitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill to materialize: `name`, `url`, `path`, and mount `ref` (branch, tag, or object id).\\n This script resolves `ref` via `git ls-remote` in the sandbox, then sparse-fetches the resolved\\n object id. The url and ref are format-validated here (defense in depth) so an unexpected value\\n can't be interpreted as a git option or reach a non-public host.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n url: str\\n path: str = \\\"\\\"\\n # Branch, tag, or full object id from the agent_spec mount (resolved in-sandbox before fetch).\\n ref: str\\n\\n @field_validator(\\\"url\\\")\\n @classmethod\\n def _validate_url(cls, v: str) -> str:\\n # Only the two public hosts the gateway supports. Anchoring on the \\\"https://<host>/\\\" prefix\\n # blocks non-https schemes (file://, ext::, ssh) and any other host, so this URL can't be\\n # turned into an SSRF/local-file/command vector once it reaches `git`.\\n if not (v.startswith(\\\"https://github.com/\\\") or v.startswith(\\\"https://gitlab.com/\\\")):\\n raise ValueError(f\\\"git skill url must be a github.com/gitlab.com https URL: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"ref\\\")\\n @classmethod\\n def _validate_ref(cls, v: str) -> str:\\n if not v or \\\"..\\\" in v.split(\\\"/\\\") or not GIT_REF_RE.match(v):\\n raise ValueError(f\\\"git skill ref is invalid: {v!r}\\\")\\n if v.replace(\\\"/\\\", \\\"\\\") == \\\"\\\":\\n raise ValueError(f\\\"git skill ref must not consist only of slashes: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"path\\\")\\n @classmethod\\n def _validate_path(cls, v: str) -> str:\\n # Relative subpath within the repo. Reject absolute paths and \\\"..\\\" segments so it can't\\n # escape the repo root. Leading/trailing slashes are stripped (repo root == \\\"\\\"). A leading\\n # \\\"-\\\" is safe because subdirs are fed to `git sparse-checkout set` via stdin, never as args.\\n if v.startswith(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must be relative: {v!r}\\\")\\n stripped = v.strip(\\\"/\\\")\\n if stripped and \\\"..\\\" in stripped.split(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must not contain '..': {v!r}\\\")\\n return stripped\\n\\n\\nclass DownloadedGitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill recorded on disk: its dir name, the object id it was installed at, and the repo\\n path it was installed from. The ref lets a later run skip re-downloading when the requested\\n pin is unchanged; the path is part of the identity so that changing the path at the same\\n pin still triggers a re-download instead of serving the previously-installed layout.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n # Empty when migrated from a pre-pin-tracking state file — treated as \\\"unknown\\\", forcing a\\n # one-time re-download while still keeping the name tracked for pruning.\\n ref: str = \\\"\\\"\\n # Repo path the skill was installed from (repo root == \\\"\\\"). Empty on entries written before\\n # path tracking; a mismatch against the requested path forces a one-time re-download.\\n path: str = \\\"\\\"\\n\\n\\nclass SkillDownloaderState(BaseModel):\\n \\\"\\\"\\\"Persisted next to skills; tracks what was downloaded so undesired skills can be pruned.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n downloaded_git_skills: list[DownloadedGitSkill] = Field(\\n default_factory=list,\\n description=\\\"Git skills successfully downloaded (name + installed object id).\\\",\\n )\\n\\n\\ndef _migrate_downloaded_git_skills(entries: list[object]) -> list[dict[str, object]]:\\n \\\"\\\"\\\"Normalize state entries: rename legacy `commit_sha` → `ref` and `subdir` → `path`.\\\"\\\"\\\"\\n migrated: list[dict[str, object]] = []\\n for item in entries:\\n if not isinstance(item, dict):\\n continue\\n entry = dict(item)\\n if \\\"ref\\\" not in entry and isinstance(entry.get(\\\"commit_sha\\\"), str):\\n entry[\\\"ref\\\"] = entry[\\\"commit_sha\\\"]\\n entry.pop(\\\"commit_sha\\\", None)\\n if \\\"path\\\" not in entry and isinstance(entry.get(\\\"subdir\\\"), str):\\n entry[\\\"path\\\"] = entry[\\\"subdir\\\"]\\n entry.pop(\\\"subdir\\\", None)\\n migrated.append(entry)\\n return migrated\\n\\n\\ndef load_state() -> SkillDownloaderState:\\n if not STATE_PATH.is_file():\\n return SkillDownloaderState()\\n try:\\n raw = json.loads(STATE_PATH.read_text(encoding=\\\"utf-8\\\"))\\n except (OSError, ValueError):\\n return SkillDownloaderState()\\n if not isinstance(raw, dict):\\n return SkillDownloaderState()\\n # Migrate a pre-pin-tracking state file: its `downloaded_git_names` (list of names, no pin)\\n # becomes entries with an empty ref. That keeps the names tracked so undesired skills are\\n # still pruned, and forces a one-time re-download (empty != any requested pin). Skipped when\\n # the new field is already present so we never clobber real pins.\\n if \\\"downloaded_git_skills\\\" not in raw and isinstance(raw.get(\\\"downloaded_git_names\\\"), list):\\n raw[\\\"downloaded_git_skills\\\"] = [\\n {\\\"name\\\": n, \\\"ref\\\": \\\"\\\"} for n in raw[\\\"downloaded_git_names\\\"] if isinstance(n, str)\\n ]\\n if isinstance(raw.get(\\\"downloaded_git_skills\\\"), list):\\n raw[\\\"downloaded_git_skills\\\"] = _migrate_downloaded_git_skills(raw[\\\"downloaded_git_skills\\\"])\\n try:\\n return SkillDownloaderState.model_validate(raw)\\n except ValidationError:\\n return SkillDownloaderState()\\n\\n\\ndef save_state(state: SkillDownloaderState) -> None:\\n STATE_PATH.parent.mkdir(parents=True, exist_ok=True)\\n tmp = STATE_PATH.with_name(STATE_PATH.name + \\\".tmp\\\")\\n tmp.write_text(state.model_dump_json(indent=2), encoding=\\\"utf-8\\\")\\n os.replace(tmp, STATE_PATH)\\n\\n\\ndef _rmtree(path: Path, ignore_errors: bool = True) -> None:\\n try:\\n shutil.rmtree(path, ignore_errors=ignore_errors)\\n except OSError:\\n pass\\n\\n\\ndef _delete_skill_dir_by_name(name: str, label: str) -> None:\\n \\\"\\\"\\\"Fail-closed prune of a no-longer-desired skill's directory: abort the whole run if removal\\n fails so we never silently leave a stale skill on disk. skill_dir() returns None for a\\n corrupt/tampered name, so rmtree never runs on an out-of-tree path.\\\"\\\"\\\"\\n dir_ = skill_dir(name)\\n if dir_ is None or not dir_.is_dir():\\n return\\n try:\\n shutil.rmtree(dir_)\\n except OSError as e:\\n sys.exit(f\\\"Could not remove skill directory {dir_} ({label}): {e}\\\")\\n\\n\\ndef git_skills_from_env(raw: str | None) -> list[GitSkill]:\\n \\\"\\\"\\\"Empty or missing env means \\\"no desired git skills\\\" (prune all git downloads).\\\"\\\"\\\"\\n if raw is None or not str(raw).strip():\\n return []\\n try:\\n data = json.loads(base64.b64decode(raw))\\n except (ValueError, json.JSONDecodeError) as e:\\n sys.exit(f\\\"AGENT_GIT_SKILLS is not valid base64-encoded JSON: {e}\\\")\\n if not isinstance(data, list):\\n sys.exit(\\\"AGENT_GIT_SKILLS must be a JSON list.\\\")\\n try:\\n return [GitSkill.model_validate(item) for item in data]\\n except ValidationError as e:\\n sys.exit(f\\\"Invalid AGENT_GIT_SKILLS entry: {e}\\\")\\n\\n\\ndef reconcile_git_skills(git_skills: list[GitSkill], state: SkillDownloaderState) -> list[str]:\\n \\\"\\\"\\\"Delete dirs for git skills no longer desired; prune them from state. Returns removed names.\\\"\\\"\\\"\\n desired_names = {g.name for g in git_skills}\\n removed: list[str] = []\\n for entry in state.downloaded_git_skills:\\n if entry.name in desired_names:\\n continue\\n # Fail-closed prune: a failed delete aborts the run rather than leaving a stale skill on disk.\\n # The name is dropped from tracking either way.\\n _delete_skill_dir_by_name(entry.name, entry.name)\\n removed.append(entry.name)\\n state.downloaded_git_skills = [\\n e for e in state.downloaded_git_skills if e.name in desired_names\\n ]\\n return removed\\n\\n\\ndef _run_git(\\n args: list[str], cwd: Path, input_text: str | None = None\\n) -> subprocess.CompletedProcess[str]:\\n \\\"\\\"\\\"Run a git subcommand non-interactively and fail-closed on error.\\n\\n Args are passed as a list (never a shell string) and git runs with a scrubbed environment:\\n ambient global/system config and credential helpers are ignored (so nothing can rewrite the URL\\n via insteadOf or inject creds) and prompting is disabled (so a private/typo repo fails fast\\n instead of hanging on a username prompt).\\\"\\\"\\\"\\n env = {\\n **os.environ,\\n \\\"GIT_TERMINAL_PROMPT\\\": \\\"0\\\", # never prompt for credentials — fail fast on a non-public repo\\n \\\"GIT_CONFIG_NOSYSTEM\\\": \\\"1\\\", # ignore /etc/gitconfig\\n \\\"GIT_CONFIG_GLOBAL\\\": \\\"/dev/null\\\", # ignore ~/.gitconfig (insteadOf rewrites, cred helpers)\\n \\\"GCM_INTERACTIVE\\\": \\\"never\\\",\\n }\\n try:\\n proc = subprocess.run(\\n [\\\"git\\\", *args],\\n cwd=os.fspath(cwd),\\n env=env,\\n input=input_text,\\n capture_output=True,\\n text=True,\\n timeout=GIT_CLONE_TIMEOUT_SECONDS,\\n )\\n except subprocess.TimeoutExpired:\\n raise GitSkillError(f\\\"git {args[0]} timed out after {GIT_CLONE_TIMEOUT_SECONDS}s\\\")\\n except OSError as e:\\n raise GitSkillError(f\\\"could not run git (is it installed in the sandbox?): {e}\\\")\\n if proc.returncode != 0:\\n detail = (proc.stderr or proc.stdout or \\\"\\\").strip()[-500:]\\n raise GitSkillError(f\\\"git {args[0]} failed (exit {proc.returncode}): {detail}\\\")\\n return proc\\n\\n\\ndef _parse_ls_remote_object_id(stdout: str) -> str | None:\\n \\\"\\\"\\\"Prefer a peeled `ref^{}` line (annotated tags), else the first object id.\\\"\\\"\\\"\\n first: str | None = None\\n for line in stdout.splitlines():\\n trimmed = line.strip()\\n if not trimmed:\\n continue\\n parts = trimmed.split(None, 1)\\n sha = parts[0]\\n name = parts[1] if len(parts) > 1 else \\\"\\\"\\n if name.endswith(\\\"^{}\\\"):\\n return sha\\n if first is None:\\n first = sha\\n return first\\n\\n\\ndef _resolve_object_id(url: str, ref: str, cwd: Path) -> str:\\n \\\"\\\"\\\"Resolve a mount ref (branch/tag/SHA) to an object id inside the sandbox.\\n\\n Full object ids are used as-is (`git ls-remote` does not advertise bare SHAs on\\n GitHub/GitLab). Branches and tags are resolved via `git ls-remote` so a branch tip\\n advances on later sandbox inits.\\n \\\"\\\"\\\"\\n if OBJECT_ID_RE.match(ref):\\n return ref\\n proc = _run_git([\\\"ls-remote\\\", url, ref], cwd=cwd)\\n oid = _parse_ls_remote_object_id(proc.stdout or \\\"\\\")\\n if oid is None:\\n raise GitSkillError(f\\\"git ls-remote returned no commit for {url} ref {ref}\\\")\\n return oid\\n\\n\\ndef _git_fetch_repo(\\n url: str, object_id: str, paths: list[str], parent_dir: Path\\n) -> Path:\\n \\\"\\\"\\\"Object-id-pinned, shallow, blob-filtered sparse checkout of `paths` into a fresh repo under\\n `parent_dir`, returning the repo working directory.\\n\\n When every requested path is the repo root (\\\"\\\") we can't sparse-restrict, so we do a full\\n shallow checkout (all blobs). Otherwise we cone-sparse to just the requested paths and use\\n --filter=blob:none so only those paths' blobs are fetched — the whole point of the approach.\\n Paths are fed to `git sparse-checkout set` via stdin so a path value can never be read as an\\n option/argument. Fetching by object id relies on the server allowing it, which github.com and\\n gitlab.com both do.\\\"\\\"\\\"\\n repo = parent_dir / \\\"repo\\\"\\n _run_git([\\\"init\\\", \\\"-q\\\", os.fspath(repo)], cwd=parent_dir)\\n _run_git([\\\"remote\\\", \\\"add\\\", \\\"origin\\\", url], cwd=repo)\\n\\n cone_dirs = [s for s in dict.fromkeys(paths) if s] # de-duped, root (\\\"\\\") dropped\\n want_full_tree = any(s == \\\"\\\" for s in paths)\\n if not want_full_tree and cone_dirs:\\n _run_git([\\\"sparse-checkout\\\", \\\"init\\\", \\\"--cone\\\"], cwd=repo)\\n _run_git(\\n [\\\"sparse-checkout\\\", \\\"set\\\", \\\"--stdin\\\"],\\n cwd=repo,\\n input_text=\\\"\\\".join(f\\\"{d}\\\\n\\\" for d in cone_dirs),\\n )\\n\\n fetch_args = [\\\"-c\\\", \\\"protocol.file.allow=never\\\", \\\"fetch\\\", \\\"-q\\\", \\\"--depth\\\", \\\"1\\\"]\\n if not want_full_tree:\\n fetch_args.append(\\\"--filter=blob:none\\\")\\n fetch_args += [\\\"origin\\\", object_id]\\n _run_git(fetch_args, cwd=repo)\\n\\n _run_git(\\n [\\\"-c\\\", \\\"advice.detachedHead=false\\\", \\\"checkout\\\", \\\"-q\\\", \\\"--detach\\\", \\\"FETCH_HEAD\\\"], cwd=repo\\n )\\n return repo\\n\\n\\ndef _installed_size_bytes(path: Path) -> int:\\n \\\"\\\"\\\"Total size of regular files under `path`, excluding the repo's .git dir and without following\\n symlinks (they're copied as links, not their targets), used to enforce GIT_SKILL_MAX_BYTES.\\\"\\\"\\\"\\n total = 0\\n for root, dirs, files in os.walk(path, followlinks=False):\\n if \\\".git\\\" in dirs:\\n dirs.remove(\\\".git\\\")\\n for name in files:\\n try:\\n total += (Path(root) / name).lstat().st_size\\n except OSError:\\n continue\\n return total\\n\\n\\ndef _install_git_skill(repo_root: Path, skill: GitSkill) -> None:\\n dest = skill_dir(skill.name)\\n if dest is None:\\n raise GitSkillError(f\\\"Git skill: unsafe directory name {skill.name!r}\\\")\\n repo_root_resolved = repo_root.resolve()\\n src = (repo_root / skill.path).resolve() if skill.path else repo_root_resolved\\n # Defense in depth: even though path is validated, ensure the resolved source stays within the\\n # cloned repo (guards against a symlinked directory component pointing outside the checkout).\\n if src != repo_root_resolved and not src.is_relative_to(repo_root_resolved):\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' escapes the repository\\\"\\n )\\n if not src.is_dir():\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' not found in repository\\\"\\n )\\n if _installed_size_bytes(src) > GIT_SKILL_MAX_BYTES:\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: content exceeds {GIT_SKILL_MAX_BYTES} bytes\\\"\\n )\\n # Stage the fresh copy into a temp sibling dir, then swap it in with an atomic rename. `dest` is\\n # only touched once the copy has fully succeeded, so a failed/partial copytree can never leave\\n # the skill missing (which would contradict the path the gateway advertises in the prompt) — the\\n # previously installed copy stays intact on failure. `.git` is excluded (only present for a repo-\\n # root skill) and symlinks are copied as links, never followed, so a link inside the repo can't\\n # pull external file contents into the skill dir at copy time.\\n staging = dest.with_name(f\\\".{dest.name}.new-{os.getpid()}\\\")\\n _rmtree(staging) # clear any leftover staging dir from an earlier aborted run\\n try:\\n shutil.copytree(src, staging, symlinks=True, ignore=shutil.ignore_patterns(\\\".git\\\"))\\n except (OSError, shutil.Error) as e:\\n # shutil.Error (a multi-file copy error, NOT an OSError subclass) must be caught here too so a\\n # bad copy is reported as a per-skill GitSkillError (WARNING + counted failure) instead of an\\n # uncaught traceback that aborts the whole run and skips save_state for the group.\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to stage git skill {skill.name}: {e}\\\")\\n try:\\n if dest.exists():\\n shutil.rmtree(dest)\\n # Same filesystem (both under SKILLS_ROOT), so this is an atomic rename; the window where the\\n # skill dir is absent shrinks from a full copy to a single near-instant rename.\\n os.replace(staging, dest)\\n except OSError as e:\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to install git skill {skill.name}: {e}\\\")\\n\\n\\ndef _mark_git_downloaded(\\n state: SkillDownloaderState, skill: GitSkill, object_id: str\\n) -> None:\\n \\\"\\\"\\\"Upsert a git skill's installed object id + path so the next run can skip when both match.\\\"\\\"\\\"\\n for entry in state.downloaded_git_skills:\\n if entry.name == skill.name:\\n entry.ref = object_id\\n entry.path = skill.path\\n return\\n state.downloaded_git_skills.append(\\n DownloadedGitSkill(name=skill.name, ref=object_id, path=skill.path)\\n )\\n\\n\\ndef _git_skill_already_installed(\\n state: SkillDownloaderState, skill: GitSkill, object_id: str\\n) -> bool:\\n \\\"\\\"\\\"True only when the skill is recorded at the resolved object id AND path AND its directory is\\n still present. Re-download otherwise (branch tip moved, path changed, first install, or the\\n dir was removed out-of-band) so the prompt never points at a missing/stale skill dir.\\\"\\\"\\\"\\n dir_ = skill_dir(skill.name)\\n if dir_ is None or not dir_.is_dir():\\n return False\\n return any(\\n e.name == skill.name and e.ref == object_id and e.path == skill.path\\n for e in state.downloaded_git_skills\\n )\\n\\n\\ndef download_git_skills(\\n git_skills: list[GitSkill], state: SkillDownloaderState\\n) -> int:\\n \\\"\\\"\\\"Ensure desired git skills are installed at their resolved object id, skipping any already\\n present at that pin so an unchanged tip no longer re-clones every run.\\n\\n Ref resolution (`git ls-remote`) runs here in the sandbox — the host never spawns git.\\n Failures are isolated per repo group and per skill so one bad repo/subdir doesn't prevent the\\n rest from being attempted; each failure is logged to stderr. Returns the number of skills that are\\n satisfied this run — freshly installed OR already present at the resolved pin (never raises).\\n The caller (run_git_download) compares this to the number requested and exits non-zero if any are\\n missing, so the overall behaviour is fail-closed.\\\"\\\"\\\"\\n SKILLS_ROOT.mkdir(parents=True, exist_ok=True)\\n\\n # Resolve mount refs → object ids, then skip skills already installed at that pin.\\n pending: list[tuple[GitSkill, str]] = []\\n satisfied = 0\\n for skill in git_skills:\\n try:\\n object_id = _resolve_object_id(skill.url, skill.ref, SKILLS_ROOT)\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e} (skill: {skill.name})\\\", file=sys.stderr)\\n continue\\n if _git_skill_already_installed(state, skill, object_id):\\n satisfied += 1\\n else:\\n pending.append((skill, object_id))\\n\\n # Group pending by (repo, object id) so a repo shared by several skills is cloned once and its\\n # needed paths fetched together in a single sparse checkout.\\n skills_by_repo: dict[tuple[str, str], list[tuple[GitSkill, str]]] = {}\\n for skill, object_id in pending:\\n skills_by_repo.setdefault((skill.url, object_id), []).append((skill, object_id))\\n\\n for (url, object_id), group in skills_by_repo.items():\\n try:\\n with tempfile.TemporaryDirectory(\\n prefix=\\\".git-skill-dl-\\\",\\n dir=os.fspath(SKILLS_ROOT),\\n ignore_cleanup_errors=True,\\n ) as tmp_str:\\n repo_root = _git_fetch_repo(\\n url,\\n object_id,\\n [s.path for s, _ in group],\\n Path(tmp_str),\\n )\\n # Install each skill independently so a bad path only skips that one skill.\\n for skill, oid in group:\\n try:\\n _install_git_skill(repo_root, skill)\\n _mark_git_downloaded(state, skill, oid)\\n satisfied += 1\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e}\\\", file=sys.stderr)\\n except GitSkillError as e:\\n # Collect (don't abort-on-first) so EVERY failing skill is reported before the run exits;\\n # run_git_download turns a non-zero failed count into a non-zero exit (fail-closed), which\\n # fails the whole agent request. We still don't purge a previously installed copy here, so\\n # a reused sandbox keeps the old files on disk even though this run is about to fail.\\n names = \\\", \\\".join(s.name for s, _ in group)\\n print(f\\\"WARNING: {e} (skills: {names})\\\", file=sys.stderr)\\n save_state(state)\\n return satisfied\\n\\n\\ndef run_git_download() -> None:\\n git_skills = git_skills_from_env(os.environ.get(\\\"AGENT_GIT_SKILLS\\\"))\\n state = load_state()\\n removed = reconcile_git_skills(git_skills, state)\\n save_state(state) # persist pruning even when there is nothing to download\\n\\n if not git_skills:\\n if removed:\\n print(f\\\"Removed {len(removed)} git skill(s) from disk. (AGENT_GIT_SKILLS empty.)\\\")\\n return\\n\\n satisfied = download_git_skills(git_skills, state)\\n failed = len(git_skills) - satisfied\\n suffix = f\\\" Removed {len(removed)} stale git skill(s).\\\" if removed else \\\"\\\"\\n if failed:\\n # Fail-closed: a requested skill that couldn't be installed exits non-zero so the gateway\\n # (ensureExecSuccess) fails the whole agent request, matching the fail-closed SKILL.md fetch.\\n # Per-skill WARNINGs above list exactly what failed.\\n sys.exit(\\n f\\\"Failed to install {failed}/{len(git_skills)} git skill(s); see warnings above.{suffix}\\\"\\n )\\n print(f\\\"Ensured {satisfied} git skill(s) (downloaded or already up to date).{suffix}\\\")\\n\\n\\ndef main() -> None:\\n run_git_download()\\n\\n\\nif __name__ == \\\"__main__\\\":\\n main()\\n\",\n} as const;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AACO,IAAM,iBAAiB;AAAA,EAC5B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EACX,eAAe;AACjB;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/core/sandbox/sandboxScripts.gen.ts"],"sourcesContent":["// AUTO-GENERATED by scripts/generate-sandbox-scripts.mjs — do not edit.\nexport const sandboxScripts = {\n mcpClient: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"mcp==1.29.0\\\", \\\"pydantic==2.12.5\\\", \\\"nats-py==2.15.0\\\"]\\n# ///\\n\\nimport os\\nimport sys\\nimport json\\nimport time\\nimport base64\\nimport asyncio\\nimport logging\\nimport argparse\\nfrom pathlib import Path\\nfrom typing import Any, TypedDict\\n\\nfrom pydantic import BaseModel, ValidationError\\n\\nfrom mcp.types import CallToolResult, TextContent, Tool\\n\\nlogger = logging.getLogger(__name__)\\n\\n_NATS_REQUEST_MAX_ATTEMPTS = 3\\n_NATS_RETRY_BACKOFF_MS = 250\\n\\n_nats_url = os.environ.get(\\\"TFY_NATS_URL\\\")\\n_nats_subject_prefix = os.environ.get(\\\"TFY_NATS_SUBJECT_PREFIX\\\")\\n_NATS_SUBJECT = f\\\"{_nats_subject_prefix}.mcp\\\" if _nats_url and _nats_subject_prefix else None\\nif bool(_nats_url) != bool(_nats_subject_prefix):\\n raise RuntimeError(\\n \\\"TFY_NATS_URL and TFY_NATS_SUBJECT_PREFIX must be set together; \\\"\\n f\\\"got TFY_NATS_URL={'set' if _nats_url else 'unset'}, \\\"\\n f\\\"TFY_NATS_SUBJECT_PREFIX={'set' if _nats_subject_prefix else 'unset'}\\\"\\n ) from None\\n\\n_nats_trace_headers: dict[str, str] = {}\\nif _traceparent := os.environ.get(\\\"TFY_TRACEPARENT\\\"):\\n _nats_trace_headers[\\\"traceparent\\\"] = _traceparent\\nif _tracestate := os.environ.get(\\\"TFY_TRACESTATE\\\"):\\n _nats_trace_headers[\\\"tracestate\\\"] = _tracestate\\n\\n\\nclass _ToolsCache(BaseModel):\\n fetched_at: float\\n tools: list[Tool]\\n\\n\\nclass _BridgeReply(BaseModel):\\n \\\"\\\"\\\"NATS bridge reply envelope. `result` is left untyped — operation-specific callers\\n (Tool / CallToolResult) validate the payload; this model only validates the envelope.\\\"\\\"\\\"\\n\\n ok: bool\\n result: Any = None\\n error: str = \\\"unknown error\\\"\\n source: str = \\\"internal\\\"\\n\\n\\nclass _ServerConfig(TypedDict):\\n allowed_tools: list[str]\\n\\n\\n_TOOLS_CACHE_TTL_SECONDS = 600\\n\\n_inflight_list_tools: dict[str, asyncio.Task[list[Tool]]] = {}\\n\\n_raw_servers = os.environ.get(\\\"TFY_MCP_SERVERS\\\")\\n_servers_map: dict[str, _ServerConfig] = json.loads(base64.b64decode(_raw_servers).decode()) if _raw_servers else {}\\n# Approvals are always enabled (Sandbox always injects TFY_ENABLE_AGENT_APPROVALS=true).\\n_enable_agent_approvals = os.environ.get(\\\"TFY_ENABLE_AGENT_APPROVALS\\\", \\\"true\\\").lower() == \\\"true\\\"\\n\\n\\ndef _check_tool_allowed(server: str, tool_name: str) -> None:\\n server_config = _servers_map.get(server)\\n if server_config is None:\\n raise RuntimeError(f\\\"Access denied: MCP server '{server}' is not available for this agent\\\") from None\\n server_tools = server_config.get(\\\"allowed_tools\\\") or []\\n if len(server_tools) > 0 and tool_name not in server_tools:\\n raise RuntimeError(f\\\"Access denied: tool '{tool_name}' is not enabled on server '{server}'\\\") from None\\n\\n\\ndef _cache_path(server: str) -> Path:\\n return Path(__file__).parent / f\\\"{server}.tools.json\\\"\\n\\n\\ndef _read_tools_cache(server: str) -> _ToolsCache | None:\\n p = _cache_path(server)\\n if not p.exists():\\n return None\\n try:\\n cache = _ToolsCache.model_validate_json(p.read_text(encoding=\\\"utf-8\\\"))\\n except Exception:\\n logger.exception(\\\"_ToolsCache.model_validate_json\\\")\\n p.unlink(missing_ok=True)\\n return None\\n if time.time() - cache.fetched_at > _TOOLS_CACHE_TTL_SECONDS:\\n p.unlink(missing_ok=True)\\n return None\\n return cache\\n\\n\\ndef _write_tools_cache(server: str, tools: list[Tool]) -> None:\\n try:\\n cache = _ToolsCache(fetched_at=time.time(), tools=tools)\\n _cache_path(server).write_text(cache.model_dump_json(), encoding=\\\"utf-8\\\")\\n except Exception:\\n logger.exception(\\\"_write_tools_cache\\\")\\n\\n\\ndef _require_nats_subject() -> str:\\n if not _NATS_SUBJECT or not _nats_url:\\n raise RuntimeError(\\n \\\"NATS bridge setup failed; NATS bridge is required for MCP from Code Mode. \\\"\\n \\\"Try again.\\\"\\n ) from None\\n return _NATS_SUBJECT\\n\\n\\nasync def _fetch_tools(server: str) -> list[Tool]:\\n result = await _nats_request(_require_nats_subject(), {\\\"op\\\": \\\"list_tools\\\", \\\"server\\\": server})\\n if not isinstance(result, dict):\\n raise RuntimeError(f\\\"NATS list_tools '{server}' returned unexpected shape: {result!r}\\\") from None\\n return [Tool.model_validate(t) for t in result.get(\\\"tools\\\", [])]\\n\\n\\nasync def _fetch_and_cache_tools(server: str) -> list[Tool]:\\n tools = await _fetch_tools(server)\\n _write_tools_cache(server, tools)\\n return tools\\n\\n\\nasync def _nats_request(subject: str, payload: dict[str, Any]) -> Any:\\n \\\"\\\"\\\"Send one request over a short-lived bridge connection, unwrap { ok, result|error }.\\n\\n Connection is per-call (closed in `finally`) to avoid leaking nats-py's aiohttp session.\\n Connect and request are retried in separate loops so a successful connection isn't torn\\n down and rebuilt on a request-level retry. Retries only failures that prove the request\\n never reached a handler (connect failure, NoRespondersError); timeouts and post-delivery\\n failures are not retried so a non-idempotent tool can't double-execute.\\n \\\"\\\"\\\"\\n import nats\\n from nats.errors import NoRespondersError\\n\\n data = json.dumps(payload).encode()\\n request_timeout = float(os.environ[\\\"TFY_CM_REQUEST_TIMEOUT_SECONDS\\\"])\\n\\n # Phase 1: establish a connection, retrying only connect failures.\\n nc = None\\n last_connect_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n nc = await nats.connect(_nats_url)\\n break\\n except Exception as e:\\n last_connect_error = f\\\"connect to NATS at {_nats_url} failed: {e}\\\"\\n if nc is None:\\n raise RuntimeError(\\n f\\\"NATS connect failed for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_connect_error}\\\"\\n ) from None\\n\\n # Phase 2: request over the established connection, retrying only NoRespondersError.\\n try:\\n last_transport_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n msg = await nc.request(\\n subject,\\n data,\\n timeout=request_timeout,\\n headers=_nats_trace_headers or None,\\n )\\n except NoRespondersError as e:\\n # Server confirms no subscriber — never delivered, safe to retry.\\n last_transport_error = f\\\"no NATS responder for '{subject}': {e}\\\"\\n continue\\n except Exception as e:\\n # Timeout / other: the tool may already have run, so we must not retry.\\n raise RuntimeError(f\\\"NATS request '{subject}' failed: {e}\\\") from None\\n try:\\n reply = _BridgeReply.model_validate_json(msg.data.decode())\\n except ValidationError as e:\\n raise RuntimeError(f\\\"NATS reply on '{subject}' is malformed: {e}\\\") from None\\n if not reply.ok:\\n # A well-formed reply means the transport worked; `source` says who's at fault.\\n if reply.source == \\\"caller\\\":\\n raise RuntimeError(f\\\"Invalid MCP request on '{subject}': {reply.error}\\\") from None\\n if reply.source == \\\"transport\\\":\\n raise RuntimeError(f\\\"Code Mode transport error on '{subject}': {reply.error}\\\") from None\\n raise RuntimeError(f\\\"Internal MCP error on '{subject}': {reply.error}\\\") from None\\n return reply.result\\n raise RuntimeError(\\n f\\\"NATS bridge has no responder for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_transport_error}\\\"\\n ) from None\\n finally:\\n try:\\n await nc.close()\\n except Exception:\\n logger.debug(\\\"Error closing NATS connection\\\", exc_info=True)\\n\\n\\nasync def _get_tools(server: str) -> list[Tool]:\\n cache = _read_tools_cache(server)\\n if cache is not None:\\n return cache.tools\\n task = _inflight_list_tools.get(server)\\n if task is None:\\n task = asyncio.create_task(_fetch_and_cache_tools(server))\\n _inflight_list_tools[server] = task\\n task.add_done_callback(lambda _t: _inflight_list_tools.pop(server, None))\\n return await task\\n\\n\\nasync def _get_tool(server: str, tool_name: str) -> Tool | None:\\n for t in await _get_tools(server):\\n if t.name == tool_name:\\n return t\\n return None\\n\\n\\ndef _is_destructive(tool: Tool) -> bool:\\n annotations = tool.annotations\\n if annotations is None:\\n return False\\n\\n destructive = getattr(\\n annotations, \\\"destructive_hint\\\", getattr(annotations, \\\"destructiveHint\\\", None)\\n )\\n read_only = getattr(\\n annotations, \\\"read_only_hint\\\", getattr(annotations, \\\"readOnlyHint\\\", None)\\n )\\n return bool(destructive) or (not read_only and read_only is not None)\\n\\nasync def _ensure_non_destructive(server: str, tool_name: str) -> None:\\n tool = await _get_tool(server, tool_name)\\n if tool is None:\\n raise RuntimeError(f\\\"Tool '{tool_name}' not found on MCP server '{server}'\\\") from None\\n if _is_destructive(tool):\\n raise RuntimeError(\\n f\\\"Tool '{tool_name}' on MCP server '{server}' is destructive and cannot be called in Code Mode; \\\"\\n f\\\"call it directly so it can go through the user approval flow\\\"\\n ) from None\\n\\n\\ndef _project_call_tool_result(server: str, tool: str, result: CallToolResult) -> Any:\\n \\\"\\\"\\\"Project an MCP-wire CallToolResult into the user-facing Python value.\\\"\\\"\\\"\\n if result.isError:\\n text_parts = [c.text for c in result.content if isinstance(c, TextContent) and c.text]\\n msg = \\\"; \\\".join(text_parts) if text_parts else \\\"tool returned an error\\\"\\n raise RuntimeError(f\\\"MCP tool error (server={server}, tool={tool}): {msg}\\\") from None\\n\\n if result.structuredContent is not None:\\n return result.structuredContent\\n\\n # Fallback to content blocks (tools without outputSchema / plain text responses)\\n if len(result.content) == 1:\\n first = result.content[0]\\n if isinstance(first, TextContent) and first.text:\\n try:\\n return json.loads(first.text)\\n except Exception:\\n pass\\n if result.content:\\n return result.content\\n return None\\n\\n\\nasync def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any:\\n _check_tool_allowed(server, tool)\\n if _enable_agent_approvals:\\n await _ensure_non_destructive(server, tool)\\n\\n raw = await _nats_request(\\n _require_nats_subject(),\\n {\\\"op\\\": \\\"call_tool\\\", \\\"server\\\": server, \\\"tool\\\": tool, \\\"arguments\\\": body},\\n )\\n try:\\n result = CallToolResult.model_validate(raw)\\n except Exception as e:\\n raise RuntimeError(f\\\"NATS call_tool reply for '{server}/{tool}' is malformed: {e}\\\") from None\\n\\n return _project_call_tool_result(server, tool, result)\\n\\n\\n_USAGE = \\\"mcp_client.py call-tool <server> <tool> <args-json>\\\"\\n\\n\\ndef _build_arg_parser() -> argparse.ArgumentParser:\\n parser = argparse.ArgumentParser(prog=\\\"mcp_client.py\\\", usage=_USAGE)\\n sub = parser.add_subparsers(dest=\\\"cmd\\\", required=True)\\n\\n call_tool_p = sub.add_parser(\\\"call-tool\\\", help=\\\"Invoke an MCP tool\\\")\\n call_tool_p.add_argument(\\\"server\\\")\\n call_tool_p.add_argument(\\\"tool\\\")\\n # Parsing JSON at the argparse boundary surfaces malformed input as a clean\\n # argparse error (with usage) instead of an opaque traceback from inside the handler.\\n call_tool_p.add_argument(\\\"args_json\\\", metavar=\\\"args-json\\\", type=json.loads)\\n\\n return parser\\n\\n\\nasync def _main() -> None:\\n args = _build_arg_parser().parse_args()\\n try:\\n if args.cmd == \\\"call-tool\\\":\\n result = await call_tool(args.server, args.tool, args.args_json)\\n print(json.dumps(result, default=str))\\n except RuntimeError as e:\\n sys.exit(str(e))\\n\\n\\nif __name__ == \\\"__main__\\\":\\n asyncio.run(_main())\\n\",\n skillDownloader: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"pydantic==2.12.5\\\"]\\n# ///\\n\\n\\\"\\\"\\\"Materialize skill directories into TFY_SKILLS_DIR (default /opt/tfy/skills).\\n\\nReads `.tfy-requested-skills.json` (uploaded by the host), installs each entry under\\nTFY_SKILLS_DIR, then deletes the requested file. Empty `\\\"skills\\\": []` clears installs.\\nOn-disk state skips unchanged skills and removes ones no longer requested.\\n\\nGit: each skill is a blob-filtered sparse `git` clone of only the requested subdir at\\na resolved object id (keyed by name). Mount `ref` (branch, tag, or full object id) is\\nresolved here via `git ls-remote` — the host never spawns git. Sparse clone is used\\ninstead of a full-repo tarball so cost tracks subdir size, not monorepo size.\\n\\nRegistry: download a presigned tar of the skill artifact and extract it under the\\nskill name. State records installed FQNs for skip/remove.\\n\\nRequested file example:\\n {\\n \\\"skills\\\": [\\n {\\n \\\"type\\\": \\\"git\\\",\\n \\\"name\\\": \\\"echo\\\",\\n \\\"url\\\": \\\"https://github.com/acme/skills.git\\\",\\n \\\"path\\\": \\\"echo\\\",\\n \\\"ref\\\": \\\"main\\\"\\n },\\n {\\n \\\"type\\\": \\\"registry\\\",\\n \\\"name\\\": \\\"echo\\\",\\n \\\"fqn\\\": \\\"agent-skill:acme/team-a/echo:3\\\",\\n \\\"presigned_url\\\": \\\"https://...\\\"\\n }\\n ]\\n }\\n\\nState file example (git `ref` is the resolved object id used for skip/reinstall, not the\\nrequested pin like `\\\"main\\\"`):\\n {\\n \\\"downloaded_git_skills\\\": [\\n {\\n \\\"name\\\": \\\"echo\\\",\\n \\\"ref\\\": \\\"f1d2d2f924e986ac86fdf7b36c94bcdf32beec15\\\",\\n \\\"path\\\": \\\"echo\\\"\\n }\\n ],\\n \\\"downloaded_registry_skills\\\": [\\n {\\n \\\"fqn\\\": \\\"agent-skill:acme/team-a/echo:3\\\",\\n \\\"name\\\": \\\"echo\\\"\\n }\\n ]\\n }\\n\\nGit skill fields (requested file):\\n - name: install directory name under TFY_SKILLS_DIR\\n - url: https://github.com/... or https://gitlab.com/...\\n - path: subdir in the repo (empty = repo root)\\n - ref: branch, tag, or full object id (resolved to an oid before fetch; state stores that oid)\\n\\nRegistry skill fields (requested file):\\n - name: install directory name under TFY_SKILLS_DIR\\n - fqn: version-pinned registry FQN\\n - presigned_url: temporary download URL for the skill tar\\n\\nEnv:\\n - TFY_SKILLS_DIR: skills directory (default /opt/tfy/skills).\\n\\\"\\\"\\\"\\n\\nfrom __future__ import annotations\\n\\nimport errno\\nimport os\\nimport re\\nimport shutil\\nimport subprocess\\nimport sys\\nimport tarfile\\nimport tempfile\\nimport urllib.error\\nimport urllib.request\\nfrom pathlib import Path\\nfrom typing import Annotated, Any, ClassVar, Literal\\nfrom abc import ABC, abstractmethod\\n\\nfrom pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator\\n\\nDEFAULT_SKILLS_DIR = \\\"/opt/tfy/skills\\\"\\nSTATE_FILE_NAME = \\\".tfy-skill-downloader-state.json\\\"\\nREQUESTED_FILE_NAME = \\\".tfy-requested-skills.json\\\"\\n# Per-git-invocation wall-clock cap so a hung/slow fetch can't stall sandbox init indefinitely.\\nGIT_CLONE_TIMEOUT_SECONDS = 120\\n# Per-registry-download wall-clock cap so a hung/slow download can't stall sandbox init indefinitely.\\nREGISTRY_DOWNLOAD_TIMEOUT_SECONDS = 120\\n# Cap registry download bytes and post-extract installed size (same bound as git installs).\\nREGISTRY_SKILL_MAX_BYTES = 100 * 1024 * 1024 # 100MB\\n# Cap the installed skill size so a huge repo-root skill can't fill the persistent skills dir. The\\n# sparse clone already bounds a subdir skill to its subdir; this also guards the whole-repo (root\\n# subdir) case where checkout hydrates every file.\\nGIT_SKILL_MAX_BYTES = 100 * 1024 * 1024 # 100MB installed\\n# Resolved object id used for fetch + skip state (sha1 / sha256).\\nOBJECT_ID_RE = re.compile(r\\\"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$\\\")\\n# Mount ref (branch/tag/SHA) charset — must match host AgentSpec SkillMount.ref validation.\\n# Validated before the value is passed to `git ls-remote` / used as a fetch arg fallback.\\nGIT_REF_RE = re.compile(r\\\"^[A-Za-z0-9._\\\\-/]+$\\\")\\nSKILLS_ROOT = Path(os.environ.get(\\\"TFY_SKILLS_DIR\\\", DEFAULT_SKILLS_DIR))\\nSTATE_PATH = SKILLS_ROOT / STATE_FILE_NAME\\n\\n\\nclass GitSkillError(Exception):\\n \\\"\\\"\\\"Recoverable per-repo/per-skill git failure. Raised (not sys.exit) so a single bad repo or\\n subdirectory doesn't abort the remaining repository groups (best-effort failure isolation).\\\"\\\"\\\"\\n\\n\\ndef skill_dir(name: str) -> Path | None:\\n \\\"\\\"\\\"Map skill name → path under SKILLS_ROOT, or None if unsafe (incl. state/requested filenames).\\\"\\\"\\\"\\n if not name or name in (\\\".\\\", \\\"..\\\") or \\\"/\\\" in name or \\\"\\\\\\\\\\\" in name or name in (\\n STATE_FILE_NAME,\\n REQUESTED_FILE_NAME,\\n ):\\n return None\\n return SKILLS_ROOT / name\\n\\n\\nclass GitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill to materialize: `name`, `url`, `path`, and mount `ref` (branch, tag, or object id).\\n This script resolves `ref` via `git ls-remote` in the sandbox, then sparse-fetches the resolved\\n object id. The url and ref are format-validated here (defense in depth) so an unexpected value\\n can't be interpreted as a git option or reach a non-public host.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n url: str\\n path: str = \\\"\\\"\\n # Branch, tag, or full object id from the agent_spec mount (resolved in-sandbox before fetch).\\n ref: str\\n\\n @field_validator(\\\"url\\\")\\n @classmethod\\n def _validate_url(cls, v: str) -> str:\\n # Only the two public hosts the gateway supports. Anchoring on the \\\"https://<host>/\\\" prefix\\n # blocks non-https schemes (file://, ext::, ssh) and any other host, so this URL can't be\\n # turned into an SSRF/local-file/command vector once it reaches `git`.\\n if not (v.startswith(\\\"https://github.com/\\\") or v.startswith(\\\"https://gitlab.com/\\\")):\\n raise ValueError(f\\\"git skill url must be a github.com/gitlab.com https URL: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"ref\\\")\\n @classmethod\\n def _validate_ref(cls, v: str) -> str:\\n if not v or \\\"..\\\" in v.split(\\\"/\\\") or not GIT_REF_RE.match(v):\\n raise ValueError(f\\\"git skill ref is invalid: {v!r}\\\")\\n if v.replace(\\\"/\\\", \\\"\\\") == \\\"\\\":\\n raise ValueError(f\\\"git skill ref must not consist only of slashes: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"path\\\")\\n @classmethod\\n def _validate_path(cls, v: str) -> str:\\n # Relative subpath within the repo. Reject absolute paths and \\\"..\\\" segments so it can't\\n # escape the repo root. Leading/trailing slashes are stripped (repo root == \\\"\\\"). A leading\\n # \\\"-\\\" is safe because subdirs are fed to `git sparse-checkout set` via stdin, never as args.\\n if v.startswith(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must be relative: {v!r}\\\")\\n stripped = v.strip(\\\"/\\\")\\n if stripped and \\\"..\\\" in stripped.split(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must not contain '..': {v!r}\\\")\\n return stripped\\n\\n\\nclass DownloadedGitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill recorded on disk: its dir name, the object id it was installed at, and the repo\\n path it was installed from. The ref lets a later run skip re-downloading when the requested\\n pin is unchanged; the path is part of the identity so that changing the path at the same\\n pin still triggers a re-download instead of serving the previously-installed layout.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n # Installed object id; empty = unknown → reinstall. Name still tracked for cleanup.\\n ref: str = \\\"\\\"\\n # Installed repo path (\\\"\\\" = root); mismatch vs requested forces reinstall.\\n path: str = \\\"\\\"\\n\\n\\nclass DownloadedRegistrySkill(BaseModel):\\n \\\"\\\"\\\"A registry skill recorded on disk: its FQN and name.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n fqn: str\\n name: str\\n\\n\\nclass SkillDownloaderState(BaseModel):\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n # Keep git and registry installs in separate lists (do not merge into one discriminated\\n # array). Each SkillSource already reads/writes only its own rows, and the persisted\\n # identities differ: git skip/reinstall keys on name + resolved object id + path;\\n # registry keys on fqn + name. A shared list would add type filters with no load-path win.\\n downloaded_git_skills: list[DownloadedGitSkill] = Field(default_factory=list)\\n downloaded_registry_skills: list[DownloadedRegistrySkill] = Field(default_factory=list)\\n\\n\\nclass RequestedGitSkill(BaseModel):\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n type: Literal[\\\"git\\\"] = \\\"git\\\"\\n name: str\\n url: str\\n path: str = \\\"\\\"\\n ref: str\\n\\n def resolve(self) -> GitSkill:\\n try:\\n return GitSkill(name=self.name, url=self.url, path=self.path, ref=self.ref)\\n except ValidationError as e:\\n sys.exit(f\\\"Invalid git entry in {REQUESTED_FILE_NAME}: {e}\\\")\\n\\n\\nclass RequestedRegistrySkill(BaseModel):\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n type: Literal[\\\"registry\\\"] = \\\"registry\\\"\\n name: str\\n fqn: str = Field(min_length=1)\\n presigned_url: str = Field(min_length=1)\\n\\n def resolve(self) -> RequestedRegistrySkill:\\n if skill_dir(self.name) is None:\\n sys.exit(f\\\"Invalid registry skill name: {self.name!r}\\\")\\n return self\\n\\n\\nRequestedSkill = RequestedGitSkill | RequestedRegistrySkill\\n\\n\\nclass RequestedSkillsFileContent(BaseModel):\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n skills: list[Annotated[RequestedSkill, Field(discriminator=\\\"type\\\")]] = Field(\\n default_factory=list\\n )\\n\\n\\ndef load_state() -> SkillDownloaderState:\\n if not STATE_PATH.is_file():\\n return SkillDownloaderState()\\n try:\\n return SkillDownloaderState.model_validate_json(STATE_PATH.read_text(encoding=\\\"utf-8\\\"))\\n except (OSError, ValueError, ValidationError):\\n # ValueError covers UnicodeDecodeError from corrupt/non-UTF-8 state in agent-writable skills dir.\\n return SkillDownloaderState()\\n\\n\\ndef save_state(state: SkillDownloaderState) -> None:\\n STATE_PATH.parent.mkdir(parents=True, exist_ok=True)\\n tmp = STATE_PATH.with_name(STATE_PATH.name + \\\".tmp\\\")\\n tmp.write_text(state.model_dump_json(indent=2), encoding=\\\"utf-8\\\")\\n os.replace(tmp, STATE_PATH)\\n\\n\\ndef _rmtree(path: Path, ignore_errors: bool = True) -> None:\\n try:\\n shutil.rmtree(path, ignore_errors=ignore_errors)\\n except OSError:\\n pass\\n\\n\\ndef _format_os_error(e: BaseException) -> str:\\n \\\"\\\"\\\"Prefer a clear sandbox-disk-full message when errno is ENOSPC.\\\"\\\"\\\"\\n if isinstance(e, OSError) and e.errno == errno.ENOSPC:\\n return f\\\"sandbox disk full: {e}\\\"\\n return str(e)\\n\\n\\ndef _delete_skill_dir_by_name(name: str, label: str) -> None:\\n \\\"\\\"\\\"Remove a no-longer-requested skill directory; abort the whole run if removal\\n fails so we never silently leave a stale skill on disk. skill_dir() returns None for a\\n corrupt/tampered name, so rmtree never runs on an out-of-tree path.\\\"\\\"\\\"\\n dir_ = skill_dir(name)\\n if dir_ is None or not dir_.is_dir():\\n return\\n try:\\n shutil.rmtree(dir_)\\n except OSError as e:\\n sys.exit(f\\\"Could not remove skill directory {dir_} ({label}): {e}\\\")\\n\\n\\n\\ndef _installed_size_bytes(path: Path) -> int:\\n \\\"\\\"\\\"Total size of regular files under `path`, excluding `.git`, without following symlinks.\\\"\\\"\\\"\\n total = 0\\n for root, dirs, files in os.walk(path, followlinks=False):\\n if \\\".git\\\" in dirs:\\n dirs.remove(\\\".git\\\")\\n for name in files:\\n try:\\n total += (Path(root) / name).lstat().st_size\\n except OSError:\\n continue\\n return total\\n\\n\\ndef _delete_requested_file() -> None:\\n path = SKILLS_ROOT / REQUESTED_FILE_NAME\\n try:\\n if path.is_file():\\n path.unlink()\\n except OSError as e:\\n print(f\\\"WARNING: could not delete {REQUESTED_FILE_NAME}: {e}\\\", file=sys.stderr)\\n\\n\\n\\nclass SkillSource(ABC):\\n \\\"\\\"\\\"One install source (git clone, registry tar, …). Helpers live on subclasses.\\\"\\\"\\\"\\n\\n type: ClassVar[str]\\n\\n @abstractmethod\\n def reconcile(self, skills: list[Any], state: SkillDownloaderState) -> list[str]:\\n \\\"\\\"\\\"Remove installs no longer requested. Returns removed ids/names.\\\"\\\"\\\"\\n\\n @abstractmethod\\n def download(self, skills: list[Any], state: SkillDownloaderState) -> tuple[int, int]:\\n \\\"\\\"\\\"Install/skip requested skills. If already present, skip. Returns (installed, skipped).\\\"\\\"\\\"\\n\\n\\nclass RegistrySkillSource(SkillSource):\\n type = \\\"registry\\\"\\n\\n def reconcile(self, skills: list[Any], state: SkillDownloaderState) -> list[str]:\\n requested_fqns = {s.fqn for s in skills}\\n removed: list[str] = []\\n for entry in list(state.downloaded_registry_skills):\\n if entry.fqn in requested_fqns:\\n continue\\n _delete_skill_dir_by_name(entry.name, label=f\\\"stale registry fqn {entry.fqn}\\\")\\n removed.append(entry.fqn)\\n state.downloaded_registry_skills = [e for e in state.downloaded_registry_skills if e.fqn in requested_fqns]\\n return removed\\n\\n def download(self, skills: list[Any], state: SkillDownloaderState) -> tuple[int, int]:\\n installed = 0\\n skipped = 0\\n for skill in skills:\\n if self._already_installed(state, skill):\\n skipped += 1\\n continue\\n try:\\n self._install_tar(skill)\\n self._mark_downloaded(state, skill)\\n installed += 1\\n except RuntimeError as e:\\n print(f\\\"WARNING: {e}\\\", file=sys.stderr)\\n return installed, skipped\\n\\n def _already_installed(self, state: SkillDownloaderState, skill: RequestedRegistrySkill) -> bool:\\n dir_ = skill_dir(skill.name)\\n if dir_ is None or not dir_.is_dir():\\n return False\\n return any(e.fqn == skill.fqn and e.name == skill.name for e in state.downloaded_registry_skills)\\n\\n def _mark_downloaded(self, state: SkillDownloaderState, skill: RequestedRegistrySkill) -> None:\\n for entry in state.downloaded_registry_skills:\\n if entry.fqn == skill.fqn:\\n if entry.name != skill.name:\\n _delete_skill_dir_by_name(\\n entry.name, label=f\\\"renamed registry fqn {entry.fqn}\\\"\\n )\\n entry.name = skill.name\\n return\\n state.downloaded_registry_skills.append(DownloadedRegistrySkill(fqn=skill.fqn, name=skill.name))\\n\\n def _install_tar(self, skill: RequestedRegistrySkill) -> None:\\n \\\"\\\"\\\"Stage extract under SKILLS_ROOT, then swap over the prior dir.\\\"\\\"\\\"\\n dir_ = skill_dir(skill.name)\\n if dir_ is None:\\n raise RuntimeError(f\\\"invalid skill name {skill.name!r}\\\")\\n try:\\n with urllib.request.urlopen(\\n skill.presigned_url, timeout=REGISTRY_DOWNLOAD_TIMEOUT_SECONDS\\n ) as resp:\\n data = resp.read(REGISTRY_SKILL_MAX_BYTES + 1)\\n except (urllib.error.URLError, TimeoutError, OSError) as e:\\n raise RuntimeError(f\\\"download failed for {skill.fqn}: {_format_os_error(e)}\\\") from e\\n if len(data) > REGISTRY_SKILL_MAX_BYTES:\\n raise RuntimeError(f\\\"tar for {skill.fqn} exceeds {REGISTRY_SKILL_MAX_BYTES} bytes\\\")\\n\\n staging = dir_.with_name(f\\\".{dir_.name}.new-{os.getpid()}\\\")\\n _rmtree(staging)\\n try:\\n staging.mkdir(parents=True, exist_ok=True)\\n with tempfile.TemporaryDirectory(\\n prefix=\\\".registry-skill-tar-\\\",\\n dir=os.fspath(SKILLS_ROOT),\\n ignore_cleanup_errors=True,\\n ) as tmp:\\n tar_path = Path(tmp) / \\\"skill.tar\\\"\\n tar_path.write_bytes(data)\\n with tarfile.open(tar_path, mode=\\\"r:*\\\") as tar:\\n # The download cap only limits compressed bytes. Member `.size` is the\\n # uncompressed file length from the tar headers — sum it and refuse before\\n # extractall so a small archive cannot expand past REGISTRY_SKILL_MAX_BYTES on disk.\\n claimed = sum(m.size for m in tar.getmembers() if m.isreg())\\n if claimed > REGISTRY_SKILL_MAX_BYTES:\\n raise RuntimeError(\\n f\\\"extracted skill {skill.fqn} exceeds {REGISTRY_SKILL_MAX_BYTES} bytes\\\"\\n )\\n # Registry skill tars are system-generated (not user-uploaded), so extractall is fine.\\n # filter= is 3.12+; local TFY provider often runs host 3.10/3.11.\\n if sys.version_info >= (3, 12):\\n tar.extractall(path=staging, filter=\\\"data\\\")\\n else:\\n tar.extractall(path=staging)\\n if dir_.exists():\\n shutil.rmtree(dir_)\\n os.replace(staging, dir_)\\n except RuntimeError:\\n _rmtree(staging)\\n raise\\n except (tarfile.TarError, OSError) as e:\\n _rmtree(staging)\\n raise RuntimeError(f\\\"extract failed for {skill.fqn}: {_format_os_error(e)}\\\") from e\\n\\n\\nclass GitSkillSource(SkillSource):\\n type = \\\"git\\\"\\n\\n def reconcile(self, skills: list[Any], state: SkillDownloaderState) -> list[str]:\\n requested_names = {g.name for g in skills}\\n removed: list[str] = []\\n for entry in state.downloaded_git_skills:\\n if entry.name in requested_names:\\n continue\\n # Fail closed: a failed delete aborts the run rather than leaving a stale skill on disk.\\n _delete_skill_dir_by_name(entry.name, entry.name)\\n removed.append(entry.name)\\n state.downloaded_git_skills = [\\n e for e in state.downloaded_git_skills if e.name in requested_names\\n ]\\n return removed\\n\\n def download(self, skills: list[Any], state: SkillDownloaderState) -> tuple[int, int]:\\n \\\"\\\"\\\"Install requested git skills at resolved object ids; skip unchanged pins.\\n\\n Failures are isolated per repo/skill (stderr warnings). Returns (installed, skipped);\\n caller exits non-zero if any requested skill is missing.\\n \\\"\\\"\\\"\\n SKILLS_ROOT.mkdir(parents=True, exist_ok=True)\\n\\n pending: list[tuple[GitSkill, str]] = []\\n installed = 0\\n skipped = 0\\n for skill in skills:\\n try:\\n object_id = self._resolve_object_id(skill.url, skill.ref, SKILLS_ROOT)\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e} (skill: {skill.name})\\\", file=sys.stderr)\\n continue\\n if self._already_installed(state, skill, object_id):\\n skipped += 1\\n else:\\n pending.append((skill, object_id))\\n\\n skills_by_repo: dict[tuple[str, str], list[tuple[GitSkill, str]]] = {}\\n for skill, object_id in pending:\\n skills_by_repo.setdefault((skill.url, object_id), []).append((skill, object_id))\\n\\n for (url, object_id), group in skills_by_repo.items():\\n try:\\n with tempfile.TemporaryDirectory(\\n prefix=\\\".git-skill-dl-\\\",\\n dir=os.fspath(SKILLS_ROOT),\\n ignore_cleanup_errors=True,\\n ) as tmp_str:\\n repo_root = self._fetch_repo(\\n url,\\n object_id,\\n [s.path for s, _ in group],\\n Path(tmp_str),\\n )\\n for skill, oid in group:\\n try:\\n self._install_skill(repo_root, skill)\\n self._mark_downloaded(state, skill, oid)\\n installed += 1\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e}\\\", file=sys.stderr)\\n except GitSkillError as e:\\n # Don't abort-on-first: report every failing group; keep prior installs on disk.\\n names = \\\", \\\".join(s.name for s, _ in group)\\n print(f\\\"WARNING: {e} (skills: {names})\\\", file=sys.stderr)\\n return installed, skipped\\n\\n def _run_git(\\n self, args: list[str], cwd: Path, input_text: str | None = None\\n ) -> subprocess.CompletedProcess[str]:\\n \\\"\\\"\\\"Run a git subcommand non-interactively and fail-closed on error.\\\"\\\"\\\"\\n env = {\\n **os.environ,\\n \\\"GIT_TERMINAL_PROMPT\\\": \\\"0\\\",\\n \\\"GIT_CONFIG_NOSYSTEM\\\": \\\"1\\\",\\n \\\"GIT_CONFIG_GLOBAL\\\": \\\"/dev/null\\\",\\n \\\"GCM_INTERACTIVE\\\": \\\"never\\\",\\n }\\n try:\\n proc = subprocess.run(\\n [\\\"git\\\", *args],\\n cwd=os.fspath(cwd),\\n env=env,\\n input=input_text,\\n capture_output=True,\\n text=True,\\n timeout=GIT_CLONE_TIMEOUT_SECONDS,\\n )\\n except subprocess.TimeoutExpired:\\n raise GitSkillError(f\\\"git {args[0]} timed out after {GIT_CLONE_TIMEOUT_SECONDS}s\\\")\\n except OSError as e:\\n raise GitSkillError(f\\\"could not run git (is it installed in the sandbox?): {e}\\\")\\n if proc.returncode != 0:\\n detail = (proc.stderr or proc.stdout or \\\"\\\").strip()[-500:]\\n raise GitSkillError(f\\\"git {args[0]} failed (exit {proc.returncode}): {detail}\\\")\\n return proc\\n\\n def _parse_ls_remote_object_id(self, stdout: str) -> str | None:\\n \\\"\\\"\\\"Prefer a peeled `ref^{}` line (annotated tags), else the first object id.\\\"\\\"\\\"\\n first: str | None = None\\n for line in stdout.splitlines():\\n trimmed = line.strip()\\n if not trimmed:\\n continue\\n parts = trimmed.split(None, 1)\\n sha = parts[0]\\n name = parts[1] if len(parts) > 1 else \\\"\\\"\\n if name.endswith(\\\"^{}\\\"):\\n return sha\\n if first is None:\\n first = sha\\n return first\\n\\n def _resolve_object_id(self, url: str, ref: str, cwd: Path) -> str:\\n \\\"\\\"\\\"Resolve a mount ref (branch/tag/SHA) to an object id inside the sandbox.\\\"\\\"\\\"\\n if OBJECT_ID_RE.match(ref):\\n return ref\\n proc = self._run_git([\\\"ls-remote\\\", url, ref], cwd=cwd)\\n oid = self._parse_ls_remote_object_id(proc.stdout or \\\"\\\")\\n if oid is None:\\n raise GitSkillError(f\\\"git ls-remote returned no commit for {url} ref {ref}\\\")\\n return oid\\n\\n def _fetch_repo(\\n self, url: str, object_id: str, paths: list[str], parent_dir: Path\\n ) -> Path:\\n \\\"\\\"\\\"Object-id-pinned, shallow, blob-filtered sparse checkout of `paths`.\\\"\\\"\\\"\\n repo = parent_dir / \\\"repo\\\"\\n self._run_git([\\\"init\\\", \\\"-q\\\", os.fspath(repo)], cwd=parent_dir)\\n self._run_git([\\\"remote\\\", \\\"add\\\", \\\"origin\\\", url], cwd=repo)\\n\\n cone_dirs = [s for s in dict.fromkeys(paths) if s]\\n want_full_tree = any(s == \\\"\\\" for s in paths)\\n if not want_full_tree and cone_dirs:\\n self._run_git([\\\"sparse-checkout\\\", \\\"init\\\", \\\"--cone\\\"], cwd=repo)\\n self._run_git(\\n [\\\"sparse-checkout\\\", \\\"set\\\", \\\"--stdin\\\"],\\n cwd=repo,\\n input_text=\\\"\\\".join(f\\\"{d}\\\\n\\\" for d in cone_dirs),\\n )\\n\\n fetch_args = [\\\"-c\\\", \\\"protocol.file.allow=never\\\", \\\"fetch\\\", \\\"-q\\\", \\\"--depth\\\", \\\"1\\\"]\\n if not want_full_tree:\\n fetch_args.append(\\\"--filter=blob:none\\\")\\n fetch_args += [\\\"origin\\\", object_id]\\n self._run_git(fetch_args, cwd=repo)\\n\\n self._run_git(\\n [\\\"-c\\\", \\\"advice.detachedHead=false\\\", \\\"checkout\\\", \\\"-q\\\", \\\"--detach\\\", \\\"FETCH_HEAD\\\"],\\n cwd=repo,\\n )\\n return repo\\n\\n def _install_skill(self, repo_root: Path, skill: GitSkill) -> None:\\n dest = skill_dir(skill.name)\\n if dest is None:\\n raise GitSkillError(f\\\"Git skill: unsafe directory name {skill.name!r}\\\")\\n repo_root_resolved = repo_root.resolve()\\n src = (repo_root / skill.path).resolve() if skill.path else repo_root_resolved\\n if src != repo_root_resolved and not src.is_relative_to(repo_root_resolved):\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' escapes the repository\\\"\\n )\\n if not src.is_dir():\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' not found in repository\\\"\\n )\\n if _installed_size_bytes(src) > GIT_SKILL_MAX_BYTES:\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: content exceeds {GIT_SKILL_MAX_BYTES} bytes\\\"\\n )\\n staging = dest.with_name(f\\\".{dest.name}.new-{os.getpid()}\\\")\\n _rmtree(staging)\\n try:\\n shutil.copytree(src, staging, symlinks=True, ignore=shutil.ignore_patterns(\\\".git\\\"))\\n except (OSError, shutil.Error) as e:\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to stage git skill {skill.name}: {_format_os_error(e)}\\\")\\n try:\\n if dest.exists():\\n shutil.rmtree(dest)\\n os.replace(staging, dest)\\n except OSError as e:\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to install git skill {skill.name}: {_format_os_error(e)}\\\")\\n\\n def _mark_downloaded(\\n self, state: SkillDownloaderState, skill: GitSkill, object_id: str\\n ) -> None:\\n for entry in state.downloaded_git_skills:\\n if entry.name == skill.name:\\n entry.ref = object_id\\n entry.path = skill.path\\n return\\n state.downloaded_git_skills.append(\\n DownloadedGitSkill(name=skill.name, ref=object_id, path=skill.path)\\n )\\n\\n def _already_installed(\\n self, state: SkillDownloaderState, skill: GitSkill, object_id: str\\n ) -> bool:\\n dir_ = skill_dir(skill.name)\\n if dir_ is None or not dir_.is_dir():\\n return False\\n return any(\\n e.name == skill.name and e.ref == object_id and e.path == skill.path\\n for e in state.downloaded_git_skills\\n )\\n\\n\\n\\n# Add a third skill type:\\n# 1. RequestedFooSkill + resolve() + union member (RequestedSkill)\\n# 2. FooSkillSource with reconcile / download\\n# 3. Append FooSkillSource() to SKILL_SOURCES (order = install order)\\n# 4. Wire type: 'foo' on the host (SkillMounter)\\n# Registry first: presigned URLs expire; git sparse clones can take much longer.\\nSKILL_SOURCES: tuple[SkillSource, ...] = (RegistrySkillSource(), GitSkillSource())\\nSKILL_SOURCE_ORDER: tuple[str, ...] = tuple(source.type for source in SKILL_SOURCES)\\n\\n\\ndef load_requested_skills() -> dict[str, list[Any]]:\\n \\\"\\\"\\\"Read requested file into per-source buckets. Missing → empty (clean up all sources).\\\"\\\"\\\"\\n buckets: dict[str, list[Any]] = {skill_type: [] for skill_type in SKILL_SOURCE_ORDER}\\n path = SKILLS_ROOT / REQUESTED_FILE_NAME\\n if not path.is_file():\\n return buckets\\n try:\\n requested = RequestedSkillsFileContent.model_validate_json(path.read_text(encoding=\\\"utf-8\\\"))\\n except OSError as e:\\n sys.exit(f\\\"Could not read {REQUESTED_FILE_NAME}: {e}\\\")\\n except ValidationError as e:\\n sys.exit(f\\\"Invalid {REQUESTED_FILE_NAME}: {e}\\\")\\n\\n for item in requested.skills:\\n buckets[item.type].append(item.resolve())\\n return buckets\\n\\n\\ndef run_skill_download() -> None:\\n loaded = load_requested_skills()\\n state = load_state()\\n removed_by_source = {source.type: source.reconcile(loaded[source.type], state) for source in SKILL_SOURCES}\\n\\n if not any(loaded.values()):\\n bits = [\\n f\\\"Removed {len(removed_by_source[skill_type])} {skill_type} skill(s)\\\"\\n for skill_type in SKILL_SOURCE_ORDER\\n if removed_by_source[skill_type]\\n ]\\n if bits:\\n print(f\\\"{'; '.join(bits)}. (requested empty.)\\\")\\n save_state(state)\\n _delete_requested_file()\\n return\\n\\n counts_by_source = {\\n source.type: (\\n source.download(loaded[source.type], state) if loaded[source.type] else (0, 0)\\n )\\n for source in SKILL_SOURCES\\n }\\n save_state(state)\\n _delete_requested_file()\\n\\n suffix_parts = [\\n f\\\"Removed {len(removed_by_source[skill_type])} stale {skill_type} skill(s)\\\"\\n for skill_type in SKILL_SOURCE_ORDER\\n if removed_by_source[skill_type]\\n ]\\n suffix = f\\\" {' '.join(suffix_parts)}.\\\" if suffix_parts else \\\"\\\"\\n\\n failed_parts = [\\n f\\\"{len(loaded[skill_type]) - sum(counts_by_source[skill_type])}/{len(loaded[skill_type])} {skill_type}\\\"\\n for skill_type in SKILL_SOURCE_ORDER\\n if loaded[skill_type] and sum(counts_by_source[skill_type]) < len(loaded[skill_type])\\n ]\\n if failed_parts:\\n sys.exit(\\n f\\\"Failed to install {' and '.join(failed_parts)} skill(s); see warnings above.{suffix}\\\"\\n )\\n\\n ensured = []\\n for skill_type in SKILL_SOURCE_ORDER:\\n if not loaded[skill_type]:\\n continue\\n installed, skipped = counts_by_source[skill_type]\\n ensured.append(f\\\"{installed} installed/{skipped} skipped {skill_type}\\\")\\n print(f\\\"Ensured {' and '.join(ensured)} skill(s).{suffix}\\\")\\n\\n\\ndef main() -> None:\\n run_skill_download()\\n\\n\\nif __name__ == \\\"__main__\\\":\\n main()\\n\",\n} as const;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AACO,IAAM,iBAAiB;AAAA,EAC5B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EACX,iBAAiB;AACnB;","names":[]}
|
|
@@ -317,7 +317,7 @@ async def _main() -> None:
|
|
|
317
317
|
if __name__ == "__main__":
|
|
318
318
|
asyncio.run(_main())
|
|
319
319
|
`,
|
|
320
|
-
gitDownloader: '#!/usr/bin/env python3\n\n# /// script\n# dependencies = ["pydantic==2.12.5"]\n# ///\n\n"""Materialize git skill directories into TFY_SKILLS_DIR (default /opt/tfy/skills). Requires `git`.\n\nEach git skill (AGENT_GIT_SKILLS) is materialized with a blob-filtered sparse `git` clone that\nfetches only the requested subdir at a resolved object id, keyed by name. Mount `ref` values\n(branch, tag, or full object id) are resolved here via `git ls-remote` \u2014 the host never spawns\ngit. A single on-disk state file records the installed object id + subdir per name so a skill\nwhose tip hasn\'t moved is skipped instead of re-cloning every run, and skills no longer desired\nare pruned from disk. A sparse clone is used instead of a full repo tarball because its cost is\n~constant in the subdir size rather than the whole-repo size, which is dramatically faster for a\nsmall skill living in a large monorepo.\n\nOptional env:\n - AGENT_GIT_SKILLS (base64-encoded JSON list of {name, url, path, ref}; empty\n clears git downloads). `ref` is a branch, tag, or full object id.\n - TFY_SKILLS_DIR: override the skills directory (default /opt/tfy/skills).\n"""\n\nfrom __future__ import annotations\n\nimport base64\nimport json\nimport os\nimport re\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\nfrom pathlib import Path\n\nfrom pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator\n\nDEFAULT_SKILLS_DIR = "/opt/tfy/skills"\nSTATE_FILE_NAME = ".tfy-skill-downloader-state.json"\n# Per-git-invocation wall-clock cap so a hung/slow fetch can\'t stall sandbox init indefinitely.\nGIT_CLONE_TIMEOUT_SECONDS = 120\n# Cap the installed skill size so a huge repo-root skill can\'t fill the persistent skills dir. The\n# sparse clone already bounds a subdir skill to its subdir; this also guards the whole-repo (root\n# subdir) case where checkout hydrates every file.\nGIT_SKILL_MAX_BYTES = 200 * 1024 * 1024 # 200MB installed\n# Resolved object id used for fetch + skip state (sha1 / sha256).\nOBJECT_ID_RE = re.compile(r"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$")\n# Mount ref (branch/tag/SHA) charset \u2014 must match host AgentSpec SkillMount.ref validation.\n# Validated before the value is passed to `git ls-remote` / used as a fetch arg fallback.\nGIT_REF_RE = re.compile(r"^[A-Za-z0-9._\\-/]+$")\nSKILLS_ROOT = Path(os.environ.get("TFY_SKILLS_DIR", DEFAULT_SKILLS_DIR))\nSTATE_PATH = SKILLS_ROOT / STATE_FILE_NAME\n\n\nclass GitSkillError(Exception):\n """Recoverable per-repo/per-skill git failure. Raised (not sys.exit) so a single bad repo or\n subdirectory doesn\'t abort the remaining repository groups (best-effort failure isolation)."""\n\n\ndef skill_dir(name: str) -> Path | None:\n """Resolve a skill\'s on-disk directory. This is the ONLY place a skill name becomes a path, so\n the safety check here is what guarantees that no name \u2014 freshly requested or read back from the\n (possibly corrupt) state file \u2014 can escape SKILLS_ROOT during delete/copy.\n\n Returns None for an unsafe name (empty, absolute, containing a path separator, "." / "..", or the\n reserved state-file name) so each caller can skip it per its own failure contract instead of\n touching an out-of-tree path. The state-file name is reserved because a skill whose name equals it\n would resolve to STATE_PATH and let a skill install clobber the reconciliation state.\n """\n if not name or name in (".", "..") or "/" in name or "\\\\" in name or name == STATE_FILE_NAME:\n return None\n return SKILLS_ROOT / name\n\n\nclass GitSkill(BaseModel):\n """A git skill to materialize: `name`, `url`, `path`, and mount `ref` (branch, tag, or object id).\n This script resolves `ref` via `git ls-remote` in the sandbox, then sparse-fetches the resolved\n object id. The url and ref are format-validated here (defense in depth) so an unexpected value\n can\'t be interpreted as a git option or reach a non-public host."""\n\n model_config = ConfigDict(extra="ignore")\n\n name: str\n url: str\n path: str = ""\n # Branch, tag, or full object id from the agent_spec mount (resolved in-sandbox before fetch).\n ref: str\n\n @field_validator("url")\n @classmethod\n def _validate_url(cls, v: str) -> str:\n # Only the two public hosts the gateway supports. Anchoring on the "https://<host>/" prefix\n # blocks non-https schemes (file://, ext::, ssh) and any other host, so this URL can\'t be\n # turned into an SSRF/local-file/command vector once it reaches `git`.\n if not (v.startswith("https://github.com/") or v.startswith("https://gitlab.com/")):\n raise ValueError(f"git skill url must be a github.com/gitlab.com https URL: {v!r}")\n return v\n\n @field_validator("ref")\n @classmethod\n def _validate_ref(cls, v: str) -> str:\n if not v or ".." in v.split("/") or not GIT_REF_RE.match(v):\n raise ValueError(f"git skill ref is invalid: {v!r}")\n if v.replace("/", "") == "":\n raise ValueError(f"git skill ref must not consist only of slashes: {v!r}")\n return v\n\n @field_validator("path")\n @classmethod\n def _validate_path(cls, v: str) -> str:\n # Relative subpath within the repo. Reject absolute paths and ".." segments so it can\'t\n # escape the repo root. Leading/trailing slashes are stripped (repo root == ""). A leading\n # "-" is safe because subdirs are fed to `git sparse-checkout set` via stdin, never as args.\n if v.startswith("/"):\n raise ValueError(f"git skill path must be relative: {v!r}")\n stripped = v.strip("/")\n if stripped and ".." in stripped.split("/"):\n raise ValueError(f"git skill path must not contain \'..\': {v!r}")\n return stripped\n\n\nclass DownloadedGitSkill(BaseModel):\n """A git skill recorded on disk: its dir name, the object id it was installed at, and the repo\n path it was installed from. The ref lets a later run skip re-downloading when the requested\n pin is unchanged; the path is part of the identity so that changing the path at the same\n pin still triggers a re-download instead of serving the previously-installed layout."""\n\n model_config = ConfigDict(extra="ignore")\n\n name: str\n # Empty when migrated from a pre-pin-tracking state file \u2014 treated as "unknown", forcing a\n # one-time re-download while still keeping the name tracked for pruning.\n ref: str = ""\n # Repo path the skill was installed from (repo root == ""). Empty on entries written before\n # path tracking; a mismatch against the requested path forces a one-time re-download.\n path: str = ""\n\n\nclass SkillDownloaderState(BaseModel):\n """Persisted next to skills; tracks what was downloaded so undesired skills can be pruned."""\n\n model_config = ConfigDict(extra="ignore")\n\n downloaded_git_skills: list[DownloadedGitSkill] = Field(\n default_factory=list,\n description="Git skills successfully downloaded (name + installed object id).",\n )\n\n\ndef _migrate_downloaded_git_skills(entries: list[object]) -> list[dict[str, object]]:\n """Normalize state entries: rename legacy `commit_sha` \u2192 `ref` and `subdir` \u2192 `path`."""\n migrated: list[dict[str, object]] = []\n for item in entries:\n if not isinstance(item, dict):\n continue\n entry = dict(item)\n if "ref" not in entry and isinstance(entry.get("commit_sha"), str):\n entry["ref"] = entry["commit_sha"]\n entry.pop("commit_sha", None)\n if "path" not in entry and isinstance(entry.get("subdir"), str):\n entry["path"] = entry["subdir"]\n entry.pop("subdir", None)\n migrated.append(entry)\n return migrated\n\n\ndef load_state() -> SkillDownloaderState:\n if not STATE_PATH.is_file():\n return SkillDownloaderState()\n try:\n raw = json.loads(STATE_PATH.read_text(encoding="utf-8"))\n except (OSError, ValueError):\n return SkillDownloaderState()\n if not isinstance(raw, dict):\n return SkillDownloaderState()\n # Migrate a pre-pin-tracking state file: its `downloaded_git_names` (list of names, no pin)\n # becomes entries with an empty ref. That keeps the names tracked so undesired skills are\n # still pruned, and forces a one-time re-download (empty != any requested pin). Skipped when\n # the new field is already present so we never clobber real pins.\n if "downloaded_git_skills" not in raw and isinstance(raw.get("downloaded_git_names"), list):\n raw["downloaded_git_skills"] = [\n {"name": n, "ref": ""} for n in raw["downloaded_git_names"] if isinstance(n, str)\n ]\n if isinstance(raw.get("downloaded_git_skills"), list):\n raw["downloaded_git_skills"] = _migrate_downloaded_git_skills(raw["downloaded_git_skills"])\n try:\n return SkillDownloaderState.model_validate(raw)\n except ValidationError:\n return SkillDownloaderState()\n\n\ndef save_state(state: SkillDownloaderState) -> None:\n STATE_PATH.parent.mkdir(parents=True, exist_ok=True)\n tmp = STATE_PATH.with_name(STATE_PATH.name + ".tmp")\n tmp.write_text(state.model_dump_json(indent=2), encoding="utf-8")\n os.replace(tmp, STATE_PATH)\n\n\ndef _rmtree(path: Path, ignore_errors: bool = True) -> None:\n try:\n shutil.rmtree(path, ignore_errors=ignore_errors)\n except OSError:\n pass\n\n\ndef _delete_skill_dir_by_name(name: str, label: str) -> None:\n """Fail-closed prune of a no-longer-desired skill\'s directory: abort the whole run if removal\n fails so we never silently leave a stale skill on disk. skill_dir() returns None for a\n corrupt/tampered name, so rmtree never runs on an out-of-tree path."""\n dir_ = skill_dir(name)\n if dir_ is None or not dir_.is_dir():\n return\n try:\n shutil.rmtree(dir_)\n except OSError as e:\n sys.exit(f"Could not remove skill directory {dir_} ({label}): {e}")\n\n\ndef git_skills_from_env(raw: str | None) -> list[GitSkill]:\n """Empty or missing env means "no desired git skills" (prune all git downloads)."""\n if raw is None or not str(raw).strip():\n return []\n try:\n data = json.loads(base64.b64decode(raw))\n except (ValueError, json.JSONDecodeError) as e:\n sys.exit(f"AGENT_GIT_SKILLS is not valid base64-encoded JSON: {e}")\n if not isinstance(data, list):\n sys.exit("AGENT_GIT_SKILLS must be a JSON list.")\n try:\n return [GitSkill.model_validate(item) for item in data]\n except ValidationError as e:\n sys.exit(f"Invalid AGENT_GIT_SKILLS entry: {e}")\n\n\ndef reconcile_git_skills(git_skills: list[GitSkill], state: SkillDownloaderState) -> list[str]:\n """Delete dirs for git skills no longer desired; prune them from state. Returns removed names."""\n desired_names = {g.name for g in git_skills}\n removed: list[str] = []\n for entry in state.downloaded_git_skills:\n if entry.name in desired_names:\n continue\n # Fail-closed prune: a failed delete aborts the run rather than leaving a stale skill on disk.\n # The name is dropped from tracking either way.\n _delete_skill_dir_by_name(entry.name, entry.name)\n removed.append(entry.name)\n state.downloaded_git_skills = [\n e for e in state.downloaded_git_skills if e.name in desired_names\n ]\n return removed\n\n\ndef _run_git(\n args: list[str], cwd: Path, input_text: str | None = None\n) -> subprocess.CompletedProcess[str]:\n """Run a git subcommand non-interactively and fail-closed on error.\n\n Args are passed as a list (never a shell string) and git runs with a scrubbed environment:\n ambient global/system config and credential helpers are ignored (so nothing can rewrite the URL\n via insteadOf or inject creds) and prompting is disabled (so a private/typo repo fails fast\n instead of hanging on a username prompt)."""\n env = {\n **os.environ,\n "GIT_TERMINAL_PROMPT": "0", # never prompt for credentials \u2014 fail fast on a non-public repo\n "GIT_CONFIG_NOSYSTEM": "1", # ignore /etc/gitconfig\n "GIT_CONFIG_GLOBAL": "/dev/null", # ignore ~/.gitconfig (insteadOf rewrites, cred helpers)\n "GCM_INTERACTIVE": "never",\n }\n try:\n proc = subprocess.run(\n ["git", *args],\n cwd=os.fspath(cwd),\n env=env,\n input=input_text,\n capture_output=True,\n text=True,\n timeout=GIT_CLONE_TIMEOUT_SECONDS,\n )\n except subprocess.TimeoutExpired:\n raise GitSkillError(f"git {args[0]} timed out after {GIT_CLONE_TIMEOUT_SECONDS}s")\n except OSError as e:\n raise GitSkillError(f"could not run git (is it installed in the sandbox?): {e}")\n if proc.returncode != 0:\n detail = (proc.stderr or proc.stdout or "").strip()[-500:]\n raise GitSkillError(f"git {args[0]} failed (exit {proc.returncode}): {detail}")\n return proc\n\n\ndef _parse_ls_remote_object_id(stdout: str) -> str | None:\n """Prefer a peeled `ref^{}` line (annotated tags), else the first object id."""\n first: str | None = None\n for line in stdout.splitlines():\n trimmed = line.strip()\n if not trimmed:\n continue\n parts = trimmed.split(None, 1)\n sha = parts[0]\n name = parts[1] if len(parts) > 1 else ""\n if name.endswith("^{}"):\n return sha\n if first is None:\n first = sha\n return first\n\n\ndef _resolve_object_id(url: str, ref: str, cwd: Path) -> str:\n """Resolve a mount ref (branch/tag/SHA) to an object id inside the sandbox.\n\n Full object ids are used as-is (`git ls-remote` does not advertise bare SHAs on\n GitHub/GitLab). Branches and tags are resolved via `git ls-remote` so a branch tip\n advances on later sandbox inits.\n """\n if OBJECT_ID_RE.match(ref):\n return ref\n proc = _run_git(["ls-remote", url, ref], cwd=cwd)\n oid = _parse_ls_remote_object_id(proc.stdout or "")\n if oid is None:\n raise GitSkillError(f"git ls-remote returned no commit for {url} ref {ref}")\n return oid\n\n\ndef _git_fetch_repo(\n url: str, object_id: str, paths: list[str], parent_dir: Path\n) -> Path:\n """Object-id-pinned, shallow, blob-filtered sparse checkout of `paths` into a fresh repo under\n `parent_dir`, returning the repo working directory.\n\n When every requested path is the repo root ("") we can\'t sparse-restrict, so we do a full\n shallow checkout (all blobs). Otherwise we cone-sparse to just the requested paths and use\n --filter=blob:none so only those paths\' blobs are fetched \u2014 the whole point of the approach.\n Paths are fed to `git sparse-checkout set` via stdin so a path value can never be read as an\n option/argument. Fetching by object id relies on the server allowing it, which github.com and\n gitlab.com both do."""\n repo = parent_dir / "repo"\n _run_git(["init", "-q", os.fspath(repo)], cwd=parent_dir)\n _run_git(["remote", "add", "origin", url], cwd=repo)\n\n cone_dirs = [s for s in dict.fromkeys(paths) if s] # de-duped, root ("") dropped\n want_full_tree = any(s == "" for s in paths)\n if not want_full_tree and cone_dirs:\n _run_git(["sparse-checkout", "init", "--cone"], cwd=repo)\n _run_git(\n ["sparse-checkout", "set", "--stdin"],\n cwd=repo,\n input_text="".join(f"{d}\\n" for d in cone_dirs),\n )\n\n fetch_args = ["-c", "protocol.file.allow=never", "fetch", "-q", "--depth", "1"]\n if not want_full_tree:\n fetch_args.append("--filter=blob:none")\n fetch_args += ["origin", object_id]\n _run_git(fetch_args, cwd=repo)\n\n _run_git(\n ["-c", "advice.detachedHead=false", "checkout", "-q", "--detach", "FETCH_HEAD"], cwd=repo\n )\n return repo\n\n\ndef _installed_size_bytes(path: Path) -> int:\n """Total size of regular files under `path`, excluding the repo\'s .git dir and without following\n symlinks (they\'re copied as links, not their targets), used to enforce GIT_SKILL_MAX_BYTES."""\n total = 0\n for root, dirs, files in os.walk(path, followlinks=False):\n if ".git" in dirs:\n dirs.remove(".git")\n for name in files:\n try:\n total += (Path(root) / name).lstat().st_size\n except OSError:\n continue\n return total\n\n\ndef _install_git_skill(repo_root: Path, skill: GitSkill) -> None:\n dest = skill_dir(skill.name)\n if dest is None:\n raise GitSkillError(f"Git skill: unsafe directory name {skill.name!r}")\n repo_root_resolved = repo_root.resolve()\n src = (repo_root / skill.path).resolve() if skill.path else repo_root_resolved\n # Defense in depth: even though path is validated, ensure the resolved source stays within the\n # cloned repo (guards against a symlinked directory component pointing outside the checkout).\n if src != repo_root_resolved and not src.is_relative_to(repo_root_resolved):\n raise GitSkillError(\n f"Git skill {skill.name}: path \'{skill.path}\' escapes the repository"\n )\n if not src.is_dir():\n raise GitSkillError(\n f"Git skill {skill.name}: path \'{skill.path}\' not found in repository"\n )\n if _installed_size_bytes(src) > GIT_SKILL_MAX_BYTES:\n raise GitSkillError(\n f"Git skill {skill.name}: content exceeds {GIT_SKILL_MAX_BYTES} bytes"\n )\n # Stage the fresh copy into a temp sibling dir, then swap it in with an atomic rename. `dest` is\n # only touched once the copy has fully succeeded, so a failed/partial copytree can never leave\n # the skill missing (which would contradict the path the gateway advertises in the prompt) \u2014 the\n # previously installed copy stays intact on failure. `.git` is excluded (only present for a repo-\n # root skill) and symlinks are copied as links, never followed, so a link inside the repo can\'t\n # pull external file contents into the skill dir at copy time.\n staging = dest.with_name(f".{dest.name}.new-{os.getpid()}")\n _rmtree(staging) # clear any leftover staging dir from an earlier aborted run\n try:\n shutil.copytree(src, staging, symlinks=True, ignore=shutil.ignore_patterns(".git"))\n except (OSError, shutil.Error) as e:\n # shutil.Error (a multi-file copy error, NOT an OSError subclass) must be caught here too so a\n # bad copy is reported as a per-skill GitSkillError (WARNING + counted failure) instead of an\n # uncaught traceback that aborts the whole run and skips save_state for the group.\n _rmtree(staging)\n raise GitSkillError(f"Failed to stage git skill {skill.name}: {e}")\n try:\n if dest.exists():\n shutil.rmtree(dest)\n # Same filesystem (both under SKILLS_ROOT), so this is an atomic rename; the window where the\n # skill dir is absent shrinks from a full copy to a single near-instant rename.\n os.replace(staging, dest)\n except OSError as e:\n _rmtree(staging)\n raise GitSkillError(f"Failed to install git skill {skill.name}: {e}")\n\n\ndef _mark_git_downloaded(\n state: SkillDownloaderState, skill: GitSkill, object_id: str\n) -> None:\n """Upsert a git skill\'s installed object id + path so the next run can skip when both match."""\n for entry in state.downloaded_git_skills:\n if entry.name == skill.name:\n entry.ref = object_id\n entry.path = skill.path\n return\n state.downloaded_git_skills.append(\n DownloadedGitSkill(name=skill.name, ref=object_id, path=skill.path)\n )\n\n\ndef _git_skill_already_installed(\n state: SkillDownloaderState, skill: GitSkill, object_id: str\n) -> bool:\n """True only when the skill is recorded at the resolved object id AND path AND its directory is\n still present. Re-download otherwise (branch tip moved, path changed, first install, or the\n dir was removed out-of-band) so the prompt never points at a missing/stale skill dir."""\n dir_ = skill_dir(skill.name)\n if dir_ is None or not dir_.is_dir():\n return False\n return any(\n e.name == skill.name and e.ref == object_id and e.path == skill.path\n for e in state.downloaded_git_skills\n )\n\n\ndef download_git_skills(\n git_skills: list[GitSkill], state: SkillDownloaderState\n) -> int:\n """Ensure desired git skills are installed at their resolved object id, skipping any already\n present at that pin so an unchanged tip no longer re-clones every run.\n\n Ref resolution (`git ls-remote`) runs here in the sandbox \u2014 the host never spawns git.\n Failures are isolated per repo group and per skill so one bad repo/subdir doesn\'t prevent the\n rest from being attempted; each failure is logged to stderr. Returns the number of skills that are\n satisfied this run \u2014 freshly installed OR already present at the resolved pin (never raises).\n The caller (run_git_download) compares this to the number requested and exits non-zero if any are\n missing, so the overall behaviour is fail-closed."""\n SKILLS_ROOT.mkdir(parents=True, exist_ok=True)\n\n # Resolve mount refs \u2192 object ids, then skip skills already installed at that pin.\n pending: list[tuple[GitSkill, str]] = []\n satisfied = 0\n for skill in git_skills:\n try:\n object_id = _resolve_object_id(skill.url, skill.ref, SKILLS_ROOT)\n except GitSkillError as e:\n print(f"WARNING: {e} (skill: {skill.name})", file=sys.stderr)\n continue\n if _git_skill_already_installed(state, skill, object_id):\n satisfied += 1\n else:\n pending.append((skill, object_id))\n\n # Group pending by (repo, object id) so a repo shared by several skills is cloned once and its\n # needed paths fetched together in a single sparse checkout.\n skills_by_repo: dict[tuple[str, str], list[tuple[GitSkill, str]]] = {}\n for skill, object_id in pending:\n skills_by_repo.setdefault((skill.url, object_id), []).append((skill, object_id))\n\n for (url, object_id), group in skills_by_repo.items():\n try:\n with tempfile.TemporaryDirectory(\n prefix=".git-skill-dl-",\n dir=os.fspath(SKILLS_ROOT),\n ignore_cleanup_errors=True,\n ) as tmp_str:\n repo_root = _git_fetch_repo(\n url,\n object_id,\n [s.path for s, _ in group],\n Path(tmp_str),\n )\n # Install each skill independently so a bad path only skips that one skill.\n for skill, oid in group:\n try:\n _install_git_skill(repo_root, skill)\n _mark_git_downloaded(state, skill, oid)\n satisfied += 1\n except GitSkillError as e:\n print(f"WARNING: {e}", file=sys.stderr)\n except GitSkillError as e:\n # Collect (don\'t abort-on-first) so EVERY failing skill is reported before the run exits;\n # run_git_download turns a non-zero failed count into a non-zero exit (fail-closed), which\n # fails the whole agent request. We still don\'t purge a previously installed copy here, so\n # a reused sandbox keeps the old files on disk even though this run is about to fail.\n names = ", ".join(s.name for s, _ in group)\n print(f"WARNING: {e} (skills: {names})", file=sys.stderr)\n save_state(state)\n return satisfied\n\n\ndef run_git_download() -> None:\n git_skills = git_skills_from_env(os.environ.get("AGENT_GIT_SKILLS"))\n state = load_state()\n removed = reconcile_git_skills(git_skills, state)\n save_state(state) # persist pruning even when there is nothing to download\n\n if not git_skills:\n if removed:\n print(f"Removed {len(removed)} git skill(s) from disk. (AGENT_GIT_SKILLS empty.)")\n return\n\n satisfied = download_git_skills(git_skills, state)\n failed = len(git_skills) - satisfied\n suffix = f" Removed {len(removed)} stale git skill(s)." if removed else ""\n if failed:\n # Fail-closed: a requested skill that couldn\'t be installed exits non-zero so the gateway\n # (ensureExecSuccess) fails the whole agent request, matching the fail-closed SKILL.md fetch.\n # Per-skill WARNINGs above list exactly what failed.\n sys.exit(\n f"Failed to install {failed}/{len(git_skills)} git skill(s); see warnings above.{suffix}"\n )\n print(f"Ensured {satisfied} git skill(s) (downloaded or already up to date).{suffix}")\n\n\ndef main() -> None:\n run_git_download()\n\n\nif __name__ == "__main__":\n main()\n'
|
|
320
|
+
skillDownloader: '#!/usr/bin/env python3\n\n# /// script\n# dependencies = ["pydantic==2.12.5"]\n# ///\n\n"""Materialize skill directories into TFY_SKILLS_DIR (default /opt/tfy/skills).\n\nReads `.tfy-requested-skills.json` (uploaded by the host), installs each entry under\nTFY_SKILLS_DIR, then deletes the requested file. Empty `"skills": []` clears installs.\nOn-disk state skips unchanged skills and removes ones no longer requested.\n\nGit: each skill is a blob-filtered sparse `git` clone of only the requested subdir at\na resolved object id (keyed by name). Mount `ref` (branch, tag, or full object id) is\nresolved here via `git ls-remote` \u2014 the host never spawns git. Sparse clone is used\ninstead of a full-repo tarball so cost tracks subdir size, not monorepo size.\n\nRegistry: download a presigned tar of the skill artifact and extract it under the\nskill name. State records installed FQNs for skip/remove.\n\nRequested file example:\n {\n "skills": [\n {\n "type": "git",\n "name": "echo",\n "url": "https://github.com/acme/skills.git",\n "path": "echo",\n "ref": "main"\n },\n {\n "type": "registry",\n "name": "echo",\n "fqn": "agent-skill:acme/team-a/echo:3",\n "presigned_url": "https://..."\n }\n ]\n }\n\nState file example (git `ref` is the resolved object id used for skip/reinstall, not the\nrequested pin like `"main"`):\n {\n "downloaded_git_skills": [\n {\n "name": "echo",\n "ref": "f1d2d2f924e986ac86fdf7b36c94bcdf32beec15",\n "path": "echo"\n }\n ],\n "downloaded_registry_skills": [\n {\n "fqn": "agent-skill:acme/team-a/echo:3",\n "name": "echo"\n }\n ]\n }\n\nGit skill fields (requested file):\n - name: install directory name under TFY_SKILLS_DIR\n - url: https://github.com/... or https://gitlab.com/...\n - path: subdir in the repo (empty = repo root)\n - ref: branch, tag, or full object id (resolved to an oid before fetch; state stores that oid)\n\nRegistry skill fields (requested file):\n - name: install directory name under TFY_SKILLS_DIR\n - fqn: version-pinned registry FQN\n - presigned_url: temporary download URL for the skill tar\n\nEnv:\n - TFY_SKILLS_DIR: skills directory (default /opt/tfy/skills).\n"""\n\nfrom __future__ import annotations\n\nimport errno\nimport os\nimport re\nimport shutil\nimport subprocess\nimport sys\nimport tarfile\nimport tempfile\nimport urllib.error\nimport urllib.request\nfrom pathlib import Path\nfrom typing import Annotated, Any, ClassVar, Literal\nfrom abc import ABC, abstractmethod\n\nfrom pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator\n\nDEFAULT_SKILLS_DIR = "/opt/tfy/skills"\nSTATE_FILE_NAME = ".tfy-skill-downloader-state.json"\nREQUESTED_FILE_NAME = ".tfy-requested-skills.json"\n# Per-git-invocation wall-clock cap so a hung/slow fetch can\'t stall sandbox init indefinitely.\nGIT_CLONE_TIMEOUT_SECONDS = 120\n# Per-registry-download wall-clock cap so a hung/slow download can\'t stall sandbox init indefinitely.\nREGISTRY_DOWNLOAD_TIMEOUT_SECONDS = 120\n# Cap registry download bytes and post-extract installed size (same bound as git installs).\nREGISTRY_SKILL_MAX_BYTES = 100 * 1024 * 1024 # 100MB\n# Cap the installed skill size so a huge repo-root skill can\'t fill the persistent skills dir. The\n# sparse clone already bounds a subdir skill to its subdir; this also guards the whole-repo (root\n# subdir) case where checkout hydrates every file.\nGIT_SKILL_MAX_BYTES = 100 * 1024 * 1024 # 100MB installed\n# Resolved object id used for fetch + skip state (sha1 / sha256).\nOBJECT_ID_RE = re.compile(r"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$")\n# Mount ref (branch/tag/SHA) charset \u2014 must match host AgentSpec SkillMount.ref validation.\n# Validated before the value is passed to `git ls-remote` / used as a fetch arg fallback.\nGIT_REF_RE = re.compile(r"^[A-Za-z0-9._\\-/]+$")\nSKILLS_ROOT = Path(os.environ.get("TFY_SKILLS_DIR", DEFAULT_SKILLS_DIR))\nSTATE_PATH = SKILLS_ROOT / STATE_FILE_NAME\n\n\nclass GitSkillError(Exception):\n """Recoverable per-repo/per-skill git failure. Raised (not sys.exit) so a single bad repo or\n subdirectory doesn\'t abort the remaining repository groups (best-effort failure isolation)."""\n\n\ndef skill_dir(name: str) -> Path | None:\n """Map skill name \u2192 path under SKILLS_ROOT, or None if unsafe (incl. state/requested filenames)."""\n if not name or name in (".", "..") or "/" in name or "\\\\" in name or name in (\n STATE_FILE_NAME,\n REQUESTED_FILE_NAME,\n ):\n return None\n return SKILLS_ROOT / name\n\n\nclass GitSkill(BaseModel):\n """A git skill to materialize: `name`, `url`, `path`, and mount `ref` (branch, tag, or object id).\n This script resolves `ref` via `git ls-remote` in the sandbox, then sparse-fetches the resolved\n object id. The url and ref are format-validated here (defense in depth) so an unexpected value\n can\'t be interpreted as a git option or reach a non-public host."""\n\n model_config = ConfigDict(extra="ignore")\n\n name: str\n url: str\n path: str = ""\n # Branch, tag, or full object id from the agent_spec mount (resolved in-sandbox before fetch).\n ref: str\n\n @field_validator("url")\n @classmethod\n def _validate_url(cls, v: str) -> str:\n # Only the two public hosts the gateway supports. Anchoring on the "https://<host>/" prefix\n # blocks non-https schemes (file://, ext::, ssh) and any other host, so this URL can\'t be\n # turned into an SSRF/local-file/command vector once it reaches `git`.\n if not (v.startswith("https://github.com/") or v.startswith("https://gitlab.com/")):\n raise ValueError(f"git skill url must be a github.com/gitlab.com https URL: {v!r}")\n return v\n\n @field_validator("ref")\n @classmethod\n def _validate_ref(cls, v: str) -> str:\n if not v or ".." in v.split("/") or not GIT_REF_RE.match(v):\n raise ValueError(f"git skill ref is invalid: {v!r}")\n if v.replace("/", "") == "":\n raise ValueError(f"git skill ref must not consist only of slashes: {v!r}")\n return v\n\n @field_validator("path")\n @classmethod\n def _validate_path(cls, v: str) -> str:\n # Relative subpath within the repo. Reject absolute paths and ".." segments so it can\'t\n # escape the repo root. Leading/trailing slashes are stripped (repo root == ""). A leading\n # "-" is safe because subdirs are fed to `git sparse-checkout set` via stdin, never as args.\n if v.startswith("/"):\n raise ValueError(f"git skill path must be relative: {v!r}")\n stripped = v.strip("/")\n if stripped and ".." in stripped.split("/"):\n raise ValueError(f"git skill path must not contain \'..\': {v!r}")\n return stripped\n\n\nclass DownloadedGitSkill(BaseModel):\n """A git skill recorded on disk: its dir name, the object id it was installed at, and the repo\n path it was installed from. The ref lets a later run skip re-downloading when the requested\n pin is unchanged; the path is part of the identity so that changing the path at the same\n pin still triggers a re-download instead of serving the previously-installed layout."""\n\n model_config = ConfigDict(extra="ignore")\n\n name: str\n # Installed object id; empty = unknown \u2192 reinstall. Name still tracked for cleanup.\n ref: str = ""\n # Installed repo path ("" = root); mismatch vs requested forces reinstall.\n path: str = ""\n\n\nclass DownloadedRegistrySkill(BaseModel):\n """A registry skill recorded on disk: its FQN and name."""\n\n model_config = ConfigDict(extra="ignore")\n\n fqn: str\n name: str\n\n\nclass SkillDownloaderState(BaseModel):\n model_config = ConfigDict(extra="ignore")\n\n # Keep git and registry installs in separate lists (do not merge into one discriminated\n # array). Each SkillSource already reads/writes only its own rows, and the persisted\n # identities differ: git skip/reinstall keys on name + resolved object id + path;\n # registry keys on fqn + name. A shared list would add type filters with no load-path win.\n downloaded_git_skills: list[DownloadedGitSkill] = Field(default_factory=list)\n downloaded_registry_skills: list[DownloadedRegistrySkill] = Field(default_factory=list)\n\n\nclass RequestedGitSkill(BaseModel):\n model_config = ConfigDict(extra="ignore")\n\n type: Literal["git"] = "git"\n name: str\n url: str\n path: str = ""\n ref: str\n\n def resolve(self) -> GitSkill:\n try:\n return GitSkill(name=self.name, url=self.url, path=self.path, ref=self.ref)\n except ValidationError as e:\n sys.exit(f"Invalid git entry in {REQUESTED_FILE_NAME}: {e}")\n\n\nclass RequestedRegistrySkill(BaseModel):\n model_config = ConfigDict(extra="ignore")\n\n type: Literal["registry"] = "registry"\n name: str\n fqn: str = Field(min_length=1)\n presigned_url: str = Field(min_length=1)\n\n def resolve(self) -> RequestedRegistrySkill:\n if skill_dir(self.name) is None:\n sys.exit(f"Invalid registry skill name: {self.name!r}")\n return self\n\n\nRequestedSkill = RequestedGitSkill | RequestedRegistrySkill\n\n\nclass RequestedSkillsFileContent(BaseModel):\n model_config = ConfigDict(extra="ignore")\n\n skills: list[Annotated[RequestedSkill, Field(discriminator="type")]] = Field(\n default_factory=list\n )\n\n\ndef load_state() -> SkillDownloaderState:\n if not STATE_PATH.is_file():\n return SkillDownloaderState()\n try:\n return SkillDownloaderState.model_validate_json(STATE_PATH.read_text(encoding="utf-8"))\n except (OSError, ValueError, ValidationError):\n # ValueError covers UnicodeDecodeError from corrupt/non-UTF-8 state in agent-writable skills dir.\n return SkillDownloaderState()\n\n\ndef save_state(state: SkillDownloaderState) -> None:\n STATE_PATH.parent.mkdir(parents=True, exist_ok=True)\n tmp = STATE_PATH.with_name(STATE_PATH.name + ".tmp")\n tmp.write_text(state.model_dump_json(indent=2), encoding="utf-8")\n os.replace(tmp, STATE_PATH)\n\n\ndef _rmtree(path: Path, ignore_errors: bool = True) -> None:\n try:\n shutil.rmtree(path, ignore_errors=ignore_errors)\n except OSError:\n pass\n\n\ndef _format_os_error(e: BaseException) -> str:\n """Prefer a clear sandbox-disk-full message when errno is ENOSPC."""\n if isinstance(e, OSError) and e.errno == errno.ENOSPC:\n return f"sandbox disk full: {e}"\n return str(e)\n\n\ndef _delete_skill_dir_by_name(name: str, label: str) -> None:\n """Remove a no-longer-requested skill directory; abort the whole run if removal\n fails so we never silently leave a stale skill on disk. skill_dir() returns None for a\n corrupt/tampered name, so rmtree never runs on an out-of-tree path."""\n dir_ = skill_dir(name)\n if dir_ is None or not dir_.is_dir():\n return\n try:\n shutil.rmtree(dir_)\n except OSError as e:\n sys.exit(f"Could not remove skill directory {dir_} ({label}): {e}")\n\n\n\ndef _installed_size_bytes(path: Path) -> int:\n """Total size of regular files under `path`, excluding `.git`, without following symlinks."""\n total = 0\n for root, dirs, files in os.walk(path, followlinks=False):\n if ".git" in dirs:\n dirs.remove(".git")\n for name in files:\n try:\n total += (Path(root) / name).lstat().st_size\n except OSError:\n continue\n return total\n\n\ndef _delete_requested_file() -> None:\n path = SKILLS_ROOT / REQUESTED_FILE_NAME\n try:\n if path.is_file():\n path.unlink()\n except OSError as e:\n print(f"WARNING: could not delete {REQUESTED_FILE_NAME}: {e}", file=sys.stderr)\n\n\n\nclass SkillSource(ABC):\n """One install source (git clone, registry tar, \u2026). Helpers live on subclasses."""\n\n type: ClassVar[str]\n\n @abstractmethod\n def reconcile(self, skills: list[Any], state: SkillDownloaderState) -> list[str]:\n """Remove installs no longer requested. Returns removed ids/names."""\n\n @abstractmethod\n def download(self, skills: list[Any], state: SkillDownloaderState) -> tuple[int, int]:\n """Install/skip requested skills. If already present, skip. Returns (installed, skipped)."""\n\n\nclass RegistrySkillSource(SkillSource):\n type = "registry"\n\n def reconcile(self, skills: list[Any], state: SkillDownloaderState) -> list[str]:\n requested_fqns = {s.fqn for s in skills}\n removed: list[str] = []\n for entry in list(state.downloaded_registry_skills):\n if entry.fqn in requested_fqns:\n continue\n _delete_skill_dir_by_name(entry.name, label=f"stale registry fqn {entry.fqn}")\n removed.append(entry.fqn)\n state.downloaded_registry_skills = [e for e in state.downloaded_registry_skills if e.fqn in requested_fqns]\n return removed\n\n def download(self, skills: list[Any], state: SkillDownloaderState) -> tuple[int, int]:\n installed = 0\n skipped = 0\n for skill in skills:\n if self._already_installed(state, skill):\n skipped += 1\n continue\n try:\n self._install_tar(skill)\n self._mark_downloaded(state, skill)\n installed += 1\n except RuntimeError as e:\n print(f"WARNING: {e}", file=sys.stderr)\n return installed, skipped\n\n def _already_installed(self, state: SkillDownloaderState, skill: RequestedRegistrySkill) -> bool:\n dir_ = skill_dir(skill.name)\n if dir_ is None or not dir_.is_dir():\n return False\n return any(e.fqn == skill.fqn and e.name == skill.name for e in state.downloaded_registry_skills)\n\n def _mark_downloaded(self, state: SkillDownloaderState, skill: RequestedRegistrySkill) -> None:\n for entry in state.downloaded_registry_skills:\n if entry.fqn == skill.fqn:\n if entry.name != skill.name:\n _delete_skill_dir_by_name(\n entry.name, label=f"renamed registry fqn {entry.fqn}"\n )\n entry.name = skill.name\n return\n state.downloaded_registry_skills.append(DownloadedRegistrySkill(fqn=skill.fqn, name=skill.name))\n\n def _install_tar(self, skill: RequestedRegistrySkill) -> None:\n """Stage extract under SKILLS_ROOT, then swap over the prior dir."""\n dir_ = skill_dir(skill.name)\n if dir_ is None:\n raise RuntimeError(f"invalid skill name {skill.name!r}")\n try:\n with urllib.request.urlopen(\n skill.presigned_url, timeout=REGISTRY_DOWNLOAD_TIMEOUT_SECONDS\n ) as resp:\n data = resp.read(REGISTRY_SKILL_MAX_BYTES + 1)\n except (urllib.error.URLError, TimeoutError, OSError) as e:\n raise RuntimeError(f"download failed for {skill.fqn}: {_format_os_error(e)}") from e\n if len(data) > REGISTRY_SKILL_MAX_BYTES:\n raise RuntimeError(f"tar for {skill.fqn} exceeds {REGISTRY_SKILL_MAX_BYTES} bytes")\n\n staging = dir_.with_name(f".{dir_.name}.new-{os.getpid()}")\n _rmtree(staging)\n try:\n staging.mkdir(parents=True, exist_ok=True)\n with tempfile.TemporaryDirectory(\n prefix=".registry-skill-tar-",\n dir=os.fspath(SKILLS_ROOT),\n ignore_cleanup_errors=True,\n ) as tmp:\n tar_path = Path(tmp) / "skill.tar"\n tar_path.write_bytes(data)\n with tarfile.open(tar_path, mode="r:*") as tar:\n # The download cap only limits compressed bytes. Member `.size` is the\n # uncompressed file length from the tar headers \u2014 sum it and refuse before\n # extractall so a small archive cannot expand past REGISTRY_SKILL_MAX_BYTES on disk.\n claimed = sum(m.size for m in tar.getmembers() if m.isreg())\n if claimed > REGISTRY_SKILL_MAX_BYTES:\n raise RuntimeError(\n f"extracted skill {skill.fqn} exceeds {REGISTRY_SKILL_MAX_BYTES} bytes"\n )\n # Registry skill tars are system-generated (not user-uploaded), so extractall is fine.\n # filter= is 3.12+; local TFY provider often runs host 3.10/3.11.\n if sys.version_info >= (3, 12):\n tar.extractall(path=staging, filter="data")\n else:\n tar.extractall(path=staging)\n if dir_.exists():\n shutil.rmtree(dir_)\n os.replace(staging, dir_)\n except RuntimeError:\n _rmtree(staging)\n raise\n except (tarfile.TarError, OSError) as e:\n _rmtree(staging)\n raise RuntimeError(f"extract failed for {skill.fqn}: {_format_os_error(e)}") from e\n\n\nclass GitSkillSource(SkillSource):\n type = "git"\n\n def reconcile(self, skills: list[Any], state: SkillDownloaderState) -> list[str]:\n requested_names = {g.name for g in skills}\n removed: list[str] = []\n for entry in state.downloaded_git_skills:\n if entry.name in requested_names:\n continue\n # Fail closed: a failed delete aborts the run rather than leaving a stale skill on disk.\n _delete_skill_dir_by_name(entry.name, entry.name)\n removed.append(entry.name)\n state.downloaded_git_skills = [\n e for e in state.downloaded_git_skills if e.name in requested_names\n ]\n return removed\n\n def download(self, skills: list[Any], state: SkillDownloaderState) -> tuple[int, int]:\n """Install requested git skills at resolved object ids; skip unchanged pins.\n\n Failures are isolated per repo/skill (stderr warnings). Returns (installed, skipped);\n caller exits non-zero if any requested skill is missing.\n """\n SKILLS_ROOT.mkdir(parents=True, exist_ok=True)\n\n pending: list[tuple[GitSkill, str]] = []\n installed = 0\n skipped = 0\n for skill in skills:\n try:\n object_id = self._resolve_object_id(skill.url, skill.ref, SKILLS_ROOT)\n except GitSkillError as e:\n print(f"WARNING: {e} (skill: {skill.name})", file=sys.stderr)\n continue\n if self._already_installed(state, skill, object_id):\n skipped += 1\n else:\n pending.append((skill, object_id))\n\n skills_by_repo: dict[tuple[str, str], list[tuple[GitSkill, str]]] = {}\n for skill, object_id in pending:\n skills_by_repo.setdefault((skill.url, object_id), []).append((skill, object_id))\n\n for (url, object_id), group in skills_by_repo.items():\n try:\n with tempfile.TemporaryDirectory(\n prefix=".git-skill-dl-",\n dir=os.fspath(SKILLS_ROOT),\n ignore_cleanup_errors=True,\n ) as tmp_str:\n repo_root = self._fetch_repo(\n url,\n object_id,\n [s.path for s, _ in group],\n Path(tmp_str),\n )\n for skill, oid in group:\n try:\n self._install_skill(repo_root, skill)\n self._mark_downloaded(state, skill, oid)\n installed += 1\n except GitSkillError as e:\n print(f"WARNING: {e}", file=sys.stderr)\n except GitSkillError as e:\n # Don\'t abort-on-first: report every failing group; keep prior installs on disk.\n names = ", ".join(s.name for s, _ in group)\n print(f"WARNING: {e} (skills: {names})", file=sys.stderr)\n return installed, skipped\n\n def _run_git(\n self, args: list[str], cwd: Path, input_text: str | None = None\n ) -> subprocess.CompletedProcess[str]:\n """Run a git subcommand non-interactively and fail-closed on error."""\n env = {\n **os.environ,\n "GIT_TERMINAL_PROMPT": "0",\n "GIT_CONFIG_NOSYSTEM": "1",\n "GIT_CONFIG_GLOBAL": "/dev/null",\n "GCM_INTERACTIVE": "never",\n }\n try:\n proc = subprocess.run(\n ["git", *args],\n cwd=os.fspath(cwd),\n env=env,\n input=input_text,\n capture_output=True,\n text=True,\n timeout=GIT_CLONE_TIMEOUT_SECONDS,\n )\n except subprocess.TimeoutExpired:\n raise GitSkillError(f"git {args[0]} timed out after {GIT_CLONE_TIMEOUT_SECONDS}s")\n except OSError as e:\n raise GitSkillError(f"could not run git (is it installed in the sandbox?): {e}")\n if proc.returncode != 0:\n detail = (proc.stderr or proc.stdout or "").strip()[-500:]\n raise GitSkillError(f"git {args[0]} failed (exit {proc.returncode}): {detail}")\n return proc\n\n def _parse_ls_remote_object_id(self, stdout: str) -> str | None:\n """Prefer a peeled `ref^{}` line (annotated tags), else the first object id."""\n first: str | None = None\n for line in stdout.splitlines():\n trimmed = line.strip()\n if not trimmed:\n continue\n parts = trimmed.split(None, 1)\n sha = parts[0]\n name = parts[1] if len(parts) > 1 else ""\n if name.endswith("^{}"):\n return sha\n if first is None:\n first = sha\n return first\n\n def _resolve_object_id(self, url: str, ref: str, cwd: Path) -> str:\n """Resolve a mount ref (branch/tag/SHA) to an object id inside the sandbox."""\n if OBJECT_ID_RE.match(ref):\n return ref\n proc = self._run_git(["ls-remote", url, ref], cwd=cwd)\n oid = self._parse_ls_remote_object_id(proc.stdout or "")\n if oid is None:\n raise GitSkillError(f"git ls-remote returned no commit for {url} ref {ref}")\n return oid\n\n def _fetch_repo(\n self, url: str, object_id: str, paths: list[str], parent_dir: Path\n ) -> Path:\n """Object-id-pinned, shallow, blob-filtered sparse checkout of `paths`."""\n repo = parent_dir / "repo"\n self._run_git(["init", "-q", os.fspath(repo)], cwd=parent_dir)\n self._run_git(["remote", "add", "origin", url], cwd=repo)\n\n cone_dirs = [s for s in dict.fromkeys(paths) if s]\n want_full_tree = any(s == "" for s in paths)\n if not want_full_tree and cone_dirs:\n self._run_git(["sparse-checkout", "init", "--cone"], cwd=repo)\n self._run_git(\n ["sparse-checkout", "set", "--stdin"],\n cwd=repo,\n input_text="".join(f"{d}\\n" for d in cone_dirs),\n )\n\n fetch_args = ["-c", "protocol.file.allow=never", "fetch", "-q", "--depth", "1"]\n if not want_full_tree:\n fetch_args.append("--filter=blob:none")\n fetch_args += ["origin", object_id]\n self._run_git(fetch_args, cwd=repo)\n\n self._run_git(\n ["-c", "advice.detachedHead=false", "checkout", "-q", "--detach", "FETCH_HEAD"],\n cwd=repo,\n )\n return repo\n\n def _install_skill(self, repo_root: Path, skill: GitSkill) -> None:\n dest = skill_dir(skill.name)\n if dest is None:\n raise GitSkillError(f"Git skill: unsafe directory name {skill.name!r}")\n repo_root_resolved = repo_root.resolve()\n src = (repo_root / skill.path).resolve() if skill.path else repo_root_resolved\n if src != repo_root_resolved and not src.is_relative_to(repo_root_resolved):\n raise GitSkillError(\n f"Git skill {skill.name}: path \'{skill.path}\' escapes the repository"\n )\n if not src.is_dir():\n raise GitSkillError(\n f"Git skill {skill.name}: path \'{skill.path}\' not found in repository"\n )\n if _installed_size_bytes(src) > GIT_SKILL_MAX_BYTES:\n raise GitSkillError(\n f"Git skill {skill.name}: content exceeds {GIT_SKILL_MAX_BYTES} bytes"\n )\n staging = dest.with_name(f".{dest.name}.new-{os.getpid()}")\n _rmtree(staging)\n try:\n shutil.copytree(src, staging, symlinks=True, ignore=shutil.ignore_patterns(".git"))\n except (OSError, shutil.Error) as e:\n _rmtree(staging)\n raise GitSkillError(f"Failed to stage git skill {skill.name}: {_format_os_error(e)}")\n try:\n if dest.exists():\n shutil.rmtree(dest)\n os.replace(staging, dest)\n except OSError as e:\n _rmtree(staging)\n raise GitSkillError(f"Failed to install git skill {skill.name}: {_format_os_error(e)}")\n\n def _mark_downloaded(\n self, state: SkillDownloaderState, skill: GitSkill, object_id: str\n ) -> None:\n for entry in state.downloaded_git_skills:\n if entry.name == skill.name:\n entry.ref = object_id\n entry.path = skill.path\n return\n state.downloaded_git_skills.append(\n DownloadedGitSkill(name=skill.name, ref=object_id, path=skill.path)\n )\n\n def _already_installed(\n self, state: SkillDownloaderState, skill: GitSkill, object_id: str\n ) -> bool:\n dir_ = skill_dir(skill.name)\n if dir_ is None or not dir_.is_dir():\n return False\n return any(\n e.name == skill.name and e.ref == object_id and e.path == skill.path\n for e in state.downloaded_git_skills\n )\n\n\n\n# Add a third skill type:\n# 1. RequestedFooSkill + resolve() + union member (RequestedSkill)\n# 2. FooSkillSource with reconcile / download\n# 3. Append FooSkillSource() to SKILL_SOURCES (order = install order)\n# 4. Wire type: \'foo\' on the host (SkillMounter)\n# Registry first: presigned URLs expire; git sparse clones can take much longer.\nSKILL_SOURCES: tuple[SkillSource, ...] = (RegistrySkillSource(), GitSkillSource())\nSKILL_SOURCE_ORDER: tuple[str, ...] = tuple(source.type for source in SKILL_SOURCES)\n\n\ndef load_requested_skills() -> dict[str, list[Any]]:\n """Read requested file into per-source buckets. Missing \u2192 empty (clean up all sources)."""\n buckets: dict[str, list[Any]] = {skill_type: [] for skill_type in SKILL_SOURCE_ORDER}\n path = SKILLS_ROOT / REQUESTED_FILE_NAME\n if not path.is_file():\n return buckets\n try:\n requested = RequestedSkillsFileContent.model_validate_json(path.read_text(encoding="utf-8"))\n except OSError as e:\n sys.exit(f"Could not read {REQUESTED_FILE_NAME}: {e}")\n except ValidationError as e:\n sys.exit(f"Invalid {REQUESTED_FILE_NAME}: {e}")\n\n for item in requested.skills:\n buckets[item.type].append(item.resolve())\n return buckets\n\n\ndef run_skill_download() -> None:\n loaded = load_requested_skills()\n state = load_state()\n removed_by_source = {source.type: source.reconcile(loaded[source.type], state) for source in SKILL_SOURCES}\n\n if not any(loaded.values()):\n bits = [\n f"Removed {len(removed_by_source[skill_type])} {skill_type} skill(s)"\n for skill_type in SKILL_SOURCE_ORDER\n if removed_by_source[skill_type]\n ]\n if bits:\n print(f"{\'; \'.join(bits)}. (requested empty.)")\n save_state(state)\n _delete_requested_file()\n return\n\n counts_by_source = {\n source.type: (\n source.download(loaded[source.type], state) if loaded[source.type] else (0, 0)\n )\n for source in SKILL_SOURCES\n }\n save_state(state)\n _delete_requested_file()\n\n suffix_parts = [\n f"Removed {len(removed_by_source[skill_type])} stale {skill_type} skill(s)"\n for skill_type in SKILL_SOURCE_ORDER\n if removed_by_source[skill_type]\n ]\n suffix = f" {\' \'.join(suffix_parts)}." if suffix_parts else ""\n\n failed_parts = [\n f"{len(loaded[skill_type]) - sum(counts_by_source[skill_type])}/{len(loaded[skill_type])} {skill_type}"\n for skill_type in SKILL_SOURCE_ORDER\n if loaded[skill_type] and sum(counts_by_source[skill_type]) < len(loaded[skill_type])\n ]\n if failed_parts:\n sys.exit(\n f"Failed to install {\' and \'.join(failed_parts)} skill(s); see warnings above.{suffix}"\n )\n\n ensured = []\n for skill_type in SKILL_SOURCE_ORDER:\n if not loaded[skill_type]:\n continue\n installed, skipped = counts_by_source[skill_type]\n ensured.append(f"{installed} installed/{skipped} skipped {skill_type}")\n print(f"Ensured {\' and \'.join(ensured)} skill(s).{suffix}")\n\n\ndef main() -> None:\n run_skill_download()\n\n\nif __name__ == "__main__":\n main()\n'
|
|
321
321
|
};
|
|
322
322
|
export {
|
|
323
323
|
sandboxScripts
|