@tianacloud/agent-skills 1.0.9 → 1.0.11

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.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """Package a reviewed source snapshot and its clean build as an installable template."""
3
3
  import argparse
4
+ import base64
4
5
  import hashlib
5
6
  import io
6
7
  import json
@@ -13,6 +14,17 @@ MIB = 1 << 20
13
14
  MAX_FILE = 16 * MIB
14
15
  MAX_EXPANDED = 128 * MIB
15
16
  MAX_ENTRIES = 5000
17
+ MAX_FILE_SEEDS = 256
18
+ FILE_TYPES = {
19
+ ".webp": "image/webp", ".png": "image/png", ".jpg": "image/jpeg",
20
+ ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml",
21
+ ".avif": "image/avif", ".pdf": "application/pdf", ".txt": "text/plain",
22
+ ".json": "application/json", ".csv": "text/csv", ".md": "text/markdown",
23
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
24
+ ".mp4": "video/mp4", ".webm": "video/webm", ".woff": "font/woff",
25
+ ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf",
26
+ ".glb": "model/gltf-binary", ".gltf": "model/gltf+json",
27
+ }
16
28
 
17
29
 
18
30
  def archive(files):
@@ -58,17 +70,31 @@ def directory_count(data):
58
70
 
59
71
  def source_files(data):
60
72
  count = directory_count(data)
61
- files, total, seen = {}, 0, set()
73
+ files, directories, total, seen = {}, set(), 0, set()
62
74
  with zipfile.ZipFile(io.BytesIO(data)) as source:
63
75
  for item in source.infolist():
64
- name = item.filename.rstrip("/")
76
+ decoded = item.orig_filename
77
+ if not item.flag_bits & 0x800:
78
+ decoded = decoded.encode("cp437").decode("utf-8")
79
+ name = decoded.removesuffix("/")
65
80
  parts = name.split("/")
66
81
  mode = item.external_attr >> 16
67
82
  if (not name or "\\" in name or "\0" in name or ":" in name
68
83
  or any(part in ("", ".", "..") or part.lower() == ".git" for part in parts)
69
84
  or PurePosixPath(name).is_absolute() or name in seen
70
- or stat.S_IFMT(mode) not in (0, stat.S_IFREG, stat.S_IFDIR)):
85
+ or stat.S_IFMT(mode) not in (0, stat.S_IFREG, stat.S_IFDIR)
86
+ or item.is_dir() and stat.S_IFMT(mode) == stat.S_IFREG
87
+ or not item.is_dir() and stat.S_IFMT(mode) == stat.S_IFDIR
88
+ or name in directories and not item.is_dir()):
71
89
  raise ValueError(f"Unsafe source ZIP path or type: {name}")
90
+ for parent in PurePosixPath(name).parents:
91
+ if str(parent) == ".":
92
+ continue
93
+ if str(parent) in files:
94
+ raise ValueError(f"Source ZIP file/directory collision: {name}")
95
+ directories.add(str(parent))
96
+ if item.is_dir():
97
+ directories.add(name)
72
98
  seen.add(name)
73
99
  if item.file_size > MAX_FILE or total + item.file_size > MAX_EXPANDED:
74
100
  raise ValueError("Source ZIP exceeds expanded size budget")
@@ -77,17 +103,115 @@ def source_files(data):
77
103
  if len(value) > MAX_FILE or total + len(value) > MAX_EXPANDED:
78
104
  raise ValueError("Source ZIP exceeds actual expanded size budget")
79
105
  total += len(value)
106
+ if item.is_dir() and value:
107
+ raise ValueError(f"Source ZIP directory contains file bytes: {name}")
80
108
  if not item.is_dir():
81
109
  files[name] = value
82
110
  return files, total, count
83
111
 
84
112
 
113
+ def template_file_materials(snapshot, metadata):
114
+ names = [name for name in snapshot if name == "template-files" or name.startswith("template-files/")]
115
+ if "file_seeds" not in metadata:
116
+ if names:
117
+ raise ValueError("template-files requires a file_seeds declaration")
118
+ return None
119
+ if metadata["file_seeds"] != "template-files":
120
+ raise ValueError("file_seeds must be template-files")
121
+ if not 1 <= len(names) <= MAX_FILE_SEEDS:
122
+ raise ValueError("template-files requires 1..256 files")
123
+ entries, materials, total = [], {}, 0
124
+ for name in sorted(names, key=lambda value: value.encode("utf-8")):
125
+ relative = name[len("template-files/"):]
126
+ if (not relative or len(relative.encode("utf-8")) > 1024
127
+ or any(part in ("", ".", "..") for part in relative.split("/"))
128
+ or any(character in "\\%" or ord(character) < 32 or 127 <= ord(character) <= 159 for character in relative)):
129
+ raise ValueError(f"Invalid template file path: {relative}")
130
+ value = snapshot[name]
131
+ total += len(value)
132
+ if len(value) > MAX_FILE or total > MAX_FILE:
133
+ raise ValueError("template-files exceeds 16 MiB")
134
+ entries.append({"path": relative, "size": len(value),
135
+ "sha256": hashlib.sha256(value).hexdigest(),
136
+ "content_type": FILE_TYPES.get(PurePosixPath(relative).suffix.lower(), "application/octet-stream")})
137
+ materials["files/" + relative] = value
138
+ return {"manifest": {"version": 1, "root": "template-assets/", "entries": entries},
139
+ "materials": materials, "bytes": total}
140
+
141
+
142
+ def check_seed_build_output(name, data, seeds):
143
+ if seeds is None:
144
+ return
145
+ if any(part in ("template-files", "template-assets") for part in PurePosixPath(name).parts):
146
+ raise ValueError(f"External template files must not enter the Web build: {name}")
147
+ # The platform requires this exact root icon independently of file seeds.
148
+ if name == "icon.png":
149
+ return
150
+ for seed, content in seeds["materials"].items():
151
+ if content and (data == content or len(content) >= 16 and (content in data or base64.b64encode(content) in data)):
152
+ raise ValueError(f"External template file embedded in Web build: {seed} in {name}")
153
+
154
+
155
+ def encode_install_manifest(manifest, seeds):
156
+ if seeds is None:
157
+ return (json.dumps(manifest, ensure_ascii=False, allow_nan=False) + "\n").encode(), None
158
+ raw_files = json.dumps(seeds["manifest"], ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode()
159
+ base = json.dumps({**manifest, "format_version": 2, "requires": ["template_files_v1"]}, ensure_ascii=False, allow_nan=False).encode()
160
+ return base[:-1] + b',"files":' + raw_files + b"}\n", hashlib.sha256(raw_files).hexdigest()
161
+
162
+
163
+ def unique_json_object(pairs):
164
+ result = {}
165
+ for key, value in pairs:
166
+ if key in result:
167
+ raise ValueError(f"Duplicate metadata JSON key: {key}")
168
+ result[key] = value
169
+ return result
170
+
171
+
172
+ def validate_backend_materials(metadata, snapshot, site):
173
+ repositories = {r["key"]: r for r in metadata.get("repositories", [])}
174
+ for repo in repositories.values():
175
+ prefix = "" if repo["path"] == "." else repo["path"] + "/"
176
+ if not any(name.startswith(prefix) for name in snapshot):
177
+ raise ValueError(f"Repository source missing: {repo['key']}")
178
+ for variable in repo.get("variables", []):
179
+ for name in variable["files"]:
180
+ if variable["placeholder"].encode() not in snapshot.get(prefix + name, b""):
181
+ raise ValueError(f"Source placeholder missing: {repo['key']}/{name}")
182
+ for fn in metadata.get("functions", []):
183
+ repo = repositories.get(fn["repository"])
184
+ if repo is None:
185
+ raise ValueError(f"Function repository missing: {fn['key']}")
186
+ prefix = "" if repo["path"] == "." else repo["path"] + "/"
187
+ directory = fn.get("source_dir", "").rstrip("/")
188
+ if fn.get("source_dir") != "/" and (not directory.startswith("/") or any(part in ("", ".", "..") for part in directory[1:].split("/")) or "\\" in directory or "\0" in directory):
189
+ raise ValueError("Function source_dir must be a repository path starting with /")
190
+ prefix += directory[1:] + "/" if directory else ""
191
+ if not any(name.startswith(prefix) and "/" not in name[len(prefix):] and name.endswith(".go") for name in snapshot):
192
+ raise ValueError(f"Function entry Go source missing: {fn['key']}")
193
+ for variable in metadata.get("site_variables", []):
194
+ for name in variable["files"]:
195
+ if variable["placeholder"].encode() not in site.get("data/" + name, b""):
196
+ raise ValueError(f"Built site placeholder missing: {name}")
197
+
198
+
85
199
  def pack(source_zip, dist, entry, output, without_source=False):
86
200
  source = read_file(Path(source_zip))
87
201
  snapshot, source_size, source_count = source_files(source)
88
- metadata = json.loads(snapshot["metadata.json"])
202
+ raw_metadata = snapshot["metadata.json"]
203
+ if len(raw_metadata) > MIB:
204
+ raise ValueError("metadata.json exceeds 1 MiB")
205
+ metadata = json.loads(raw_metadata.decode("utf-8"), object_pairs_hook=unique_json_object)
206
+ if type(metadata) is not dict:
207
+ raise ValueError("metadata.json must be an object")
89
208
  if type(metadata.get("database_required")) is not bool:
90
209
  raise ValueError("metadata.json requires an explicit database_required boolean")
210
+ if without_source and metadata.get("functions"):
211
+ raise ValueError("Function templates require source.zip to compile their backends")
212
+ seeds = template_file_materials(snapshot, metadata)
213
+ if seeds is not None and without_source:
214
+ raise ValueError("File templates require source.zip for server byte verification; --without-source is only available for templates without file_seeds")
91
215
  names = ["showcase/preview.html", "showcase/cover.webp"]
92
216
  if not without_source:
93
217
  names.append("showcase/prompt.md")
@@ -104,18 +228,27 @@ def pack(source_zip, dist, entry, output, without_source=False):
104
228
  sql_names[name] = target
105
229
  site = {"web.yaml": b""}
106
230
  dist = Path(dist)
231
+ if dist.is_symlink() or not dist.is_dir():
232
+ raise ValueError("Build directory must be a real directory")
107
233
  site_size = 0
108
234
  for file in sorted(dist.rglob("*")):
109
235
  if file.is_symlink():
110
236
  raise ValueError(f"Build contains a symlink: {file.relative_to(dist)}")
237
+ relative = file.relative_to(dist).as_posix()
238
+ if seeds is not None and any(part in ("template-files", "template-assets") for part in file.relative_to(dist).parts):
239
+ raise ValueError(f"External template files must not enter the Web build: {relative}")
111
240
  if file.is_file():
112
241
  data = read_file(file)
242
+ check_seed_build_output(relative, data, seeds)
113
243
  site_size += len(data)
114
244
  if site_size > MAX_FILE or len(site) >= 4097:
115
245
  raise ValueError("Build exceeds the Web archive resource budget")
116
246
  site["data/" + file.relative_to(dist).as_posix()] = data
247
+ elif not file.is_dir():
248
+ raise ValueError(f"Build contains a nonregular entry: {relative}")
117
249
  if not entry or "data/" + entry not in site:
118
250
  raise ValueError("Build is missing its fixed entry")
251
+ validate_backend_materials(metadata, snapshot, site)
119
252
  compiled = archive(site)
120
253
  if len(compiled) > MAX_FILE:
121
254
  raise ValueError("site.tweb exceeds 16 MiB")
@@ -132,7 +265,14 @@ def pack(source_zip, dist, entry, output, without_source=False):
132
265
  files["icon.png"] = site["data/icon.png"]
133
266
  files["site.tweb"] = compiled
134
267
  manifest = {"metadata": metadata, "entry": entry, "database_required": metadata["database_required"], "variables": variables}
135
- files["install.json"] = (json.dumps(manifest, ensure_ascii=False) + "\n").encode()
268
+ for field in ("private_database", "web_repository", "repositories", "functions", "site_variables"):
269
+ if field in metadata:
270
+ manifest[field] = metadata[field]
271
+ files["install.json"], file_manifest_digest = encode_install_manifest(manifest, seeds)
272
+ if len(files["install.json"]) > MIB:
273
+ raise ValueError("install.json exceeds 1 MiB")
274
+ if seeds is not None:
275
+ files.update(seeds["materials"])
136
276
  expanded = sum(map(len, files.values())) + site_size + (0 if without_source else source_size)
137
277
  entries = len(files) + len(site) + (0 if without_source else source_count)
138
278
  if expanded > MAX_EXPANDED or entries > MAX_ENTRIES or any(len(value) > MAX_FILE for value in files.values()):
@@ -141,7 +281,10 @@ def pack(source_zip, dist, entry, output, without_source=False):
141
281
  if len(data) > 32 * MIB:
142
282
  raise ValueError("install.zip exceeds 32 MiB")
143
283
  Path(output).write_bytes(data)
144
- return {"name": metadata["name"], "file": str(Path(output).resolve()), "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), "has_source": not without_source, "database_required": metadata["database_required"], "expanded_bytes": expanded, "entries": entries}
284
+ result = {"name": metadata["name"], "file": str(Path(output).resolve()), "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), "has_source": not without_source, "database_required": metadata["database_required"], "expanded_bytes": expanded, "entries": entries}
285
+ if seeds is not None:
286
+ result.update({"format_version": 2, "file_count": len(seeds["manifest"]["entries"]), "file_bytes": seeds["bytes"], "file_manifest_sha256": file_manifest_digest})
287
+ return result
145
288
 
146
289
 
147
290
  if __name__ == "__main__":
@@ -150,6 +293,6 @@ if __name__ == "__main__":
150
293
  parser.add_argument("--dist", required=True, help="Clean build output directory")
151
294
  parser.add_argument("--entry", required=True, help="Fixed entry relative to dist")
152
295
  parser.add_argument("--output", required=True, help="Destination install.zip")
153
- parser.add_argument("--without-source", action="store_true", help="Publish installation materials without source or customization prompt")
296
+ parser.add_argument("--without-source", action="store_true", help="No-file templates only: omit source and customization prompt")
154
297
  args = parser.parse_args()
155
298
  print(json.dumps(pack(args.source, args.dist, args.entry, args.output, args.without_source), ensure_ascii=False))