@logic-joe/fleek 0.2.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logic-joe/fleek",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "fleek - command line client for the Fleek agent hub (MCP over OAuth, OS keychain, signed role/project kits)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -5,6 +5,12 @@
5
5
  # boundary stays. This runs `fleek kit setup` once as the broker with the service's profile (HOME), its OS keyring (private
6
6
  # D-Bus, unlocked from the service's own encrypted credential) and a working directory outside every git checkout (like
7
7
  # run-job's job directory), then repeats run-job's exact pull without a terminal to prove it needs no confirmation now.
8
+ # ONF-225: the plugins of the approved kits (e.g. the FirstSpirit AI Toolkit) are installed by this setup without a question
9
+ # (service account: the admin's approval of the signed kit is the confirmation; needs the `claude` CLI on the host, used only for
10
+ # `plugin install|list`); run-job copies their skill Markdown read-only into the review container. Ready only when every declared
11
+ # plugin is installed in the approved version with a skills/ directory (VF-R2: `kit run --dry-run` names each gap with its reason).
12
+ # KIT-PLUGIN-BIND: only a plugin bound to a commit in the signed kit is installed here, only while its source serves exactly that
13
+ # commit, and used only when Claude Code records that commit; an unbound plugin is reported "unbound" (the admin binds it in the kit).
8
14
  # Usage: sudo /opt/fleek-agents/kit-setup review (then: systemctl restart fleek-agent-review.service)
9
15
  set -euo pipefail
10
16
  if [ "${1:-}" != --as-broker ]; then
@@ -52,8 +58,25 @@ cd "$job"
52
58
  set +e
53
59
  fleek kit pull "$project" --target claude --yes --json </dev/null >/dev/null 2>&1
54
60
  pulled=$?
61
+ # VF-R2: the job's vendor skills - every plugin the approved kits declare must give the job a usable skills directory
62
+ launch=$(fleek kit run claude --project "$project" --dry-run --json </dev/null 2>/dev/null)
63
+ launched=$?
55
64
  set -e
56
65
  cd /
57
66
  rm -rf "$job"
58
67
  if [ "$pulled" -ne 0 ]; then echo "run-job is NOT ready: unattended kit pull exits $pulled" >&2; exit "$pulled"; fi
59
- echo "run-job ready: the unattended kit pull needs no confirmation. Next: systemctl restart fleek-agent-review.service"
68
+ if [ "$launched" -ne 0 ]; then echo "run-job is NOT ready: kit run --dry-run exits $launched" >&2; exit "$launched"; fi
69
+ gaps=$(printf '%s' "$launch" | python3 -I -c 'import json, re, sys
70
+ for gap in json.load(sys.stdin).get("vendorPluginsMissing", [])[:5]:
71
+ plugin, reason, installed = (str(gap.get(key) or "") for key in ("plugin", "reason", "installed"))
72
+ if re.fullmatch(r"[a-z0-9@._-]{1,129}", plugin) and re.fullmatch(r"[a-z_]{1,20}", reason):
73
+ print(f" {plugin}: {reason}" + (f" (installed {installed})" if re.fullmatch(r"[0-9A-Za-z.+-]{1,64}", installed) else ""))')
74
+ if [ -n "$gaps" ]; then
75
+ printf 'run-job is NOT ready for the vendor skills of the approved kits (the job would run without them):\n%s\n' "$gaps" >&2
76
+ echo 'Fix: install / enable the approved version for this account (re-run this setup), or ask the admin to approve the installed version.' >&2
77
+ echo 'unbound / other_commit: the admin binds the plugin to the commit the source serves (new kit version); nothing unbound is installed here.' >&2
78
+ exit 13
79
+ fi
80
+ # Exit 13 of `kit setup` here only means optional host entries stayed open (e.g. MCP proposals answered N); the job needs none.
81
+ [ "$setup" -eq 13 ] && echo "kit setup left optional host entries open (exit 13) - not needed by run-job."
82
+ echo "run-job ready: the unattended kit pull needs no confirmation, every declared vendor plugin gives the job its skills. Next: systemctl restart fleek-agent-review.service"
@@ -107,13 +107,16 @@ def container_args(image, name, job, provider):
107
107
  return args
108
108
 
109
109
 
110
- def agent_args(provider, event_id, issue_id):
110
+ def agent_args(provider, event_id, issue_id, plugins=()):
111
111
  prompt = f"Job review; eventId={event_id}; issueId={issue_id}."
112
112
  if provider == "claude":
113
- return ["claude", "-p", prompt, "--output-format", "json", "--max-turns", "30", "--tools", "",
114
- "--allowedTools", "mcp__fleek__*", "--permission-mode", "dontAsk", "--strict-mcp-config",
113
+ # ONF-225: vendor plugin skills (Markdown copies below /job/plugins) need the Skill tool and reads of exactly that tree
114
+ tools = ["--tools", "Skill,Read", "--allowedTools", "mcp__fleek__*", "Skill", "Read(//job/plugins/**)"] if plugins else ["--tools", "", "--allowedTools", "mcp__fleek__*"]
115
+ dirs = [arg for name in plugins for arg in ("--plugin-dir", f"/job/plugins/{name}")]
116
+ return ["claude", "-p", prompt, "--output-format", "json", "--max-turns", "30", *tools,
117
+ "--permission-mode", "dontAsk", "--strict-mcp-config",
115
118
  "--mcp-config", "/job/mcp.json", "--settings", "/job/settings.json", "--setting-sources", "",
116
- "--append-system-prompt-file", "/job/kit.md"]
119
+ *dirs, "--append-system-prompt-file", "/job/kit.md"]
117
120
  return ["codex", "exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only", "--ephemeral",
118
121
  "-c", 'forced_login_method="api"', "-c", 'web_search="disabled"', "-c", "features.shell_tool=false",
119
122
  "-c", "features.apply_patch_freeform=false", "-c", "features.multi_agent=false", "-c", "features.multi_agent_v2=false", "-c", "features.js_repl=false",
@@ -154,7 +157,115 @@ def host_report(fleek, provider_auth, kit=None, last_job_at=None, timeout=20):
154
157
  pass
155
158
 
156
159
 
157
- def kit_text(fleek, project, cwd):
160
+ PLUGIN_NAME = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}")
161
+ PLUGIN_VERSION = re.compile(r"[0-9A-Za-z.+-]{1,64}")
162
+ PLUGIN_ID = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}@[a-z0-9][a-z0-9._-]{0,63}")
163
+ # KIT-PLUGIN-BIND: "unbound" (no commit in the signed kit: never installed here) and "other_commit" (the install is not the bound commit)
164
+ VENDOR_GAP_REASONS = ("other_version", "other_commit", "unbound", "disabled", "missing", "unchecked", "unverified", "error", "no_client", "outside_cache", "no_skills")
165
+ VENDOR_MAX_FILES, VENDOR_MAX_BYTES, VENDOR_MAX_ENTRIES, VENDOR_MAX_FILE = 2000, 8 * 1024 * 1024, 10000, 1024 * 1024
166
+
167
+
168
+ def inert_markdown(data, skill):
169
+ # Vendor text is data for the agent, never configuration: a SKILL.md keeps only name + description of its frontmatter
170
+ # (no allowed-tools, hooks, model, ...), and Claude Code's `!`cmd`` pre-execution syntax is broken up everywhere.
171
+ text = data.decode("utf-8").replace("!`", "! `")
172
+ if skill:
173
+ match = re.match(r"---\n(.*?)\n---\n?", text, re.S)
174
+ fields = {}
175
+ if match:
176
+ lines = match.group(1).split("\n")
177
+ for index, line in enumerate(lines):
178
+ key, _, value = line.partition(":")
179
+ value = value.strip()
180
+ if key not in ("name", "description") or not value:
181
+ continue
182
+ if value in (">", "|", ">-", "|-"): # a folded / literal block: its indented lines
183
+ block = []
184
+ for more in lines[index + 1:]:
185
+ if not more.startswith((" ", "\t")):
186
+ break
187
+ block.append(more.strip())
188
+ value = " ".join(part for part in block if part)
189
+ fields[key] = value.strip('"').strip("'")[:1024]
190
+ text = text[match.end():]
191
+ head = "".join(f"{key}: {json.dumps(fields[key])}\n" for key in ("name", "description") if key in fields)
192
+ text = f"---\n{head}---\n\n{text}"
193
+ return text.encode("utf-8")
194
+
195
+
196
+ def read_regular(path):
197
+ # no symlink is followed (O_NOFOLLOW), the size is bounded before anything is read
198
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
199
+ try:
200
+ info = os.fstat(fd)
201
+ if not stat.S_ISREG(info.st_mode) or info.st_size > VENDOR_MAX_FILE:
202
+ raise ValueError("unsafe vendor plugin")
203
+ with os.fdopen(fd, "rb", closefd=False) as stream:
204
+ data = stream.read(VENDOR_MAX_FILE + 1)
205
+ finally:
206
+ os.close(fd)
207
+ if len(data) > VENDOR_MAX_FILE:
208
+ raise ValueError("unsafe vendor plugin")
209
+ return data
210
+
211
+
212
+ def vendor_plugins(launch, inputs):
213
+ # ONF-225: the vendor plugins of the project's pinned kits, as the CLI reports them for the verified generation (installed by
214
+ # `kit setup` on this host with exactly the approved version, below the client's plugin cache). Only plain Markdown below the
215
+ # plugin's skills/ enters the container (no hooks, scripts, MCP entries, settings); no symlink is followed, nothing is fetched.
216
+ # VF-R2: the declared plugins the CLI could not hand over (reason only), plus the ones without a Markdown file below skills/; reported in the
217
+ # job's metadata line, the job runs without them.
218
+ entries, gaps = launch.get("vendorPlugins", []), launch.get("vendorPluginsMissing", [])
219
+ if not isinstance(entries, list) or len(entries) > 5 or not isinstance(gaps, list) or len(gaps) > 5:
220
+ raise ValueError("invalid vendor plugins")
221
+ missing = []
222
+ for gap in gaps:
223
+ plugin, reason = (gap.get("plugin"), gap.get("reason")) if isinstance(gap, dict) else (None, None)
224
+ if not isinstance(plugin, str) or not PLUGIN_ID.fullmatch(plugin) or reason not in VENDOR_GAP_REASONS:
225
+ raise ValueError("invalid vendor plugin")
226
+ missing.append({"plugin": plugin, "reason": reason})
227
+ names, files, total, walked = [], 0, 0, 0
228
+ for entry in entries:
229
+ name, version, source = (entry.get(key) for key in ("name", "version", "dir")) if isinstance(entry, dict) else (None, None, None)
230
+ if not isinstance(name, str) or not PLUGIN_NAME.fullmatch(name) or name in names or not isinstance(version, str) or not PLUGIN_VERSION.fullmatch(version) \
231
+ or not isinstance(source, str) or not os.path.isabs(source):
232
+ raise ValueError("invalid vendor plugin")
233
+ plugin = entry.get("plugin") if isinstance(entry.get("plugin"), str) and PLUGIN_ID.fullmatch(entry["plugin"]) else name
234
+ root = Path(source)
235
+ skills = root / "skills"
236
+ if root.is_symlink() or skills.is_symlink() or not skills.is_dir():
237
+ missing.append({"plugin": plugin, "reason": "no_skills"})
238
+ continue
239
+ target = inputs / "plugins" / name
240
+ for directory, subdirs, children in os.walk(skills, followlinks=False):
241
+ subdirs.sort()
242
+ walked += len(subdirs) + len(children)
243
+ if walked > VENDOR_MAX_ENTRIES:
244
+ raise ValueError("vendor plugins too large")
245
+ for child in sorted(children):
246
+ file = Path(directory) / child
247
+ if not child.endswith(".md"):
248
+ continue
249
+ if not file.resolve().is_relative_to(skills.resolve()):
250
+ raise ValueError("unsafe vendor plugin")
251
+ data = inert_markdown(read_regular(file), child == "SKILL.md")
252
+ files, total = files + 1, total + len(data)
253
+ if files > VENDOR_MAX_FILES or total > VENDOR_MAX_BYTES:
254
+ raise ValueError("vendor plugins too large")
255
+ dest = target / "skills" / file.relative_to(skills)
256
+ dest.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
257
+ dest.write_bytes(data)
258
+ if (target / "skills").is_dir():
259
+ (target / ".claude-plugin").mkdir(mode=0o700)
260
+ (target / ".claude-plugin" / "plugin.json").write_text(json.dumps({"name": name, "version": version}))
261
+ names.append(name)
262
+ else:
263
+ # VF-R4: an empty or non-Markdown-only skills/ gives the job nothing
264
+ missing.append({"plugin": plugin, "reason": "no_skills"})
265
+ return names, missing
266
+
267
+
268
+ def kit_text(fleek, project, cwd, inputs):
158
269
  # WZ-CLI-SVC (founder 25.10.2026): new kit versions an admin released, signed and pinned to this project are taken over
159
270
  # without a root terminal (--accept-approved: service-account logins only; kit MCP entries are only noted - the container
160
271
  # below still gets nothing but the Fleek socket).
@@ -176,7 +287,8 @@ def kit_text(fleek, project, cwd):
176
287
  text = "\n\n".join(parts)
177
288
  if not text or len(text.encode()) > 120000:
178
289
  raise ValueError("invalid kit")
179
- return text
290
+ names, missing = vendor_plugins(launch, inputs)
291
+ return text, names, missing
180
292
 
181
293
 
182
294
  def run(config_path):
@@ -217,14 +329,18 @@ def run(config_path):
217
329
  exit_code = code
218
330
  return code
219
331
  started = time.monotonic()
332
+ vendor_missing = []
220
333
  provider = config.get("provider", "claude")
221
334
  container_env = {key: os.environ[key] for key in ("PATH", "HOME", "XDG_RUNTIME_DIR") if key in os.environ}
222
335
  try:
223
336
  inputs = job / "input"
224
337
  inputs.mkdir(mode=0o700)
225
- (inputs / "kit.md").write_text(kit_text(fleek, config["project"], job))
338
+ text, plugins, vendor_missing = kit_text(fleek, config["project"], job, inputs)
339
+ (inputs / "kit.md").write_text(text)
226
340
  (inputs / "mcp.json").write_text(json.dumps({"mcpServers": {"fleek": {"command": "socat", "args": ["STDIO", "UNIX-CONNECT:/job/mcp.sock"]}}}))
227
- (inputs / "settings.json").write_text(json.dumps({"permissions": {"deny": ["Read(/proc/**)", "Read(/run/**)"]}}))
341
+ # absolute paths (`//`): a single leading slash would be relative to the settings file; no hook ever runs in the job
342
+ (inputs / "settings.json").write_text(json.dumps({"disableAllHooks": True,
343
+ "permissions": {"deny": ["Read(//proc/**)", "Read(//run/**)", "Read(//home/**)", "Read(//tmp/**)"]}}))
228
344
  with (job / "proxy.log").open("wb") as proxy_log, (job / "agent.stdout").open("wb") as stdout, (job / "agent.stderr").open("wb") as stderr:
229
345
  proxy = subprocess.Popen([fleek, "mcp", "serve", "--socket", str(inputs / "mcp.sock"), "--job", event_id], stdout=proxy_log, stderr=proxy_log)
230
346
  deadline = time.monotonic() + 10
@@ -236,7 +352,7 @@ def run(config_path):
236
352
  time.sleep(0.05)
237
353
  container_env.update(provider_env(provider))
238
354
  argv = container_args(config["image"], name, job, {key: value for key, value in container_env.items() if key in ("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "CODEX_API_KEY")})
239
- agent = subprocess.Popen(argv + agent_args(provider, event_id, issue_id), env=container_env, stdin=subprocess.DEVNULL, stdout=stdout, stderr=stderr)
355
+ agent = subprocess.Popen(argv + agent_args(provider, event_id, issue_id, plugins if provider == "claude" else ()), env=container_env, stdin=subprocess.DEVNULL, stdout=stdout, stderr=stderr)
240
356
  while agent.poll() is None:
241
357
  if proxy.poll() is not None:
242
358
  return outcome(proxy.returncode if proxy.returncode in (DENIED, LOGIN) else 1)
@@ -296,7 +412,9 @@ def run(config_path):
296
412
  pass
297
413
  host_report(fleek, provider_state(state), None, stamp, timeout=5)
298
414
  # Only validated IDs and numeric metadata ever reach journald. No agent-derived text/statistics.
299
- print(json.dumps({"eventId": event_id, "kind": "review", "exit": exit_code, "durationMs": int((time.monotonic() - started) * 1000), "cleanupFailed": cleanup_failed}), flush=True)
415
+ # VF-R2: declared vendor plugins the job ran without (validated plugin id + fixed reason)
416
+ print(json.dumps({"eventId": event_id, "kind": "review", "exit": exit_code, "durationMs": int((time.monotonic() - started) * 1000), "cleanupFailed": cleanup_failed,
417
+ **({"vendorSkillsMissing": vendor_missing} if vendor_missing else {})}), flush=True)
300
418
  if cleanup_failed:
301
419
  raise SystemExit(DENIED)
302
420