@logic-joe/fleek 0.2.0 → 0.2.2
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/fleek.mjs +4966 -2042
- package/package.json +1 -1
- package/service-agents/install-system-service +2 -2
- package/service-agents/kit-setup +21 -1
- package/service-agents/knowledge-poll +232 -0
- package/service-agents/run-job +127 -10
- package/service-agents/run-service +7 -0
package/package.json
CHANGED
|
@@ -10,8 +10,8 @@ broker_uid=$(id -u "$broker")
|
|
|
10
10
|
broker_home=$(getent passwd "$broker" | cut -d: -f6)
|
|
11
11
|
[ "$broker_uid" -gt 0 ] && [ "$broker_home" = /home/fa-review ] || exit 77
|
|
12
12
|
install -d -m 0755 /opt/fleek-agents
|
|
13
|
-
if [ "$source_dir" != /opt/fleek-agents ]; then install -m 0755 "$source_dir/run-job" "$source_dir/unlock-keyring" "$source_dir/run-service" "$source_dir/kit-setup" "$source_dir/broker-run" /opt/fleek-agents/; fi
|
|
14
|
-
chmod 0755 /opt/fleek-agents/run-job /opt/fleek-agents/unlock-keyring /opt/fleek-agents/run-service /opt/fleek-agents/kit-setup /opt/fleek-agents/broker-run
|
|
13
|
+
if [ "$source_dir" != /opt/fleek-agents ]; then install -m 0755 "$source_dir/run-job" "$source_dir/knowledge-poll" "$source_dir/unlock-keyring" "$source_dir/run-service" "$source_dir/kit-setup" "$source_dir/broker-run" /opt/fleek-agents/; fi
|
|
14
|
+
chmod 0755 /opt/fleek-agents/knowledge-poll /opt/fleek-agents/run-job /opt/fleek-agents/unlock-keyring /opt/fleek-agents/run-service /opt/fleek-agents/kit-setup /opt/fleek-agents/broker-run
|
|
15
15
|
# Every level of ~/.config belongs to the broker (install -d sets owner/mode only on the NAMED directories, so naming
|
|
16
16
|
# just .config/systemd/user left .config root-owned and `fleek login` could not create ~/.config/fleek - SVCLOGIN).
|
|
17
17
|
# Re-running the installer repairs an existing host: install -d also re-applies owner/mode to existing directories.
|
package/service-agents/kit-setup
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
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).
|
|
8
12
|
# Usage: sudo /opt/fleek-agents/kit-setup review (then: systemctl restart fleek-agent-review.service)
|
|
9
13
|
set -euo pipefail
|
|
10
14
|
if [ "${1:-}" != --as-broker ]; then
|
|
@@ -52,8 +56,24 @@ cd "$job"
|
|
|
52
56
|
set +e
|
|
53
57
|
fleek kit pull "$project" --target claude --yes --json </dev/null >/dev/null 2>&1
|
|
54
58
|
pulled=$?
|
|
59
|
+
# VF-R2: the job's vendor skills - every plugin the approved kits declare must give the job a usable skills directory
|
|
60
|
+
launch=$(fleek kit run claude --project "$project" --dry-run --json </dev/null 2>/dev/null)
|
|
61
|
+
launched=$?
|
|
55
62
|
set -e
|
|
56
63
|
cd /
|
|
57
64
|
rm -rf "$job"
|
|
58
65
|
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:
|
|
66
|
+
if [ "$launched" -ne 0 ]; then echo "run-job is NOT ready: kit run --dry-run exits $launched" >&2; exit "$launched"; fi
|
|
67
|
+
gaps=$(printf '%s' "$launch" | python3 -I -c 'import json, re, sys
|
|
68
|
+
for gap in json.load(sys.stdin).get("vendorPluginsMissing", [])[:5]:
|
|
69
|
+
plugin, reason, installed = (str(gap.get(key) or "") for key in ("plugin", "reason", "installed"))
|
|
70
|
+
if re.fullmatch(r"[a-z0-9@._-]{1,129}", plugin) and re.fullmatch(r"[a-z_]{1,20}", reason):
|
|
71
|
+
print(f" {plugin}: {reason}" + (f" (installed {installed})" if re.fullmatch(r"[0-9A-Za-z.+-]{1,64}", installed) else ""))')
|
|
72
|
+
if [ -n "$gaps" ]; then
|
|
73
|
+
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
|
|
74
|
+
echo 'Fix: install / enable the approved version for this account (re-run this setup), or ask the admin to approve the installed version.' >&2
|
|
75
|
+
exit 13
|
|
76
|
+
fi
|
|
77
|
+
# Exit 13 of `kit setup` here only means optional host entries stayed open (e.g. MCP proposals answered N); the job needs none.
|
|
78
|
+
[ "$setup" -eq 13 ] && echo "kit setup left optional host entries open (exit 13) - not needed by run-job."
|
|
79
|
+
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"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""E10: one host-side poll, called every 15 minutes by the existing broker service.
|
|
3
|
+
The broker owns STATE_DIRECTORY/repository (an ordinary, clean clone with its own Git read login).
|
|
4
|
+
Only a changed default-branch HEAD runs fleek init; agents see the CLI's allowlisted staging, never the clone or broker keys.
|
|
5
|
+
"""
|
|
6
|
+
import datetime
|
|
7
|
+
import fcntl
|
|
8
|
+
import importlib.machinery
|
|
9
|
+
import importlib.util
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import re
|
|
14
|
+
import selectors
|
|
15
|
+
import signal
|
|
16
|
+
import stat
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
loader = importlib.machinery.SourceFileLoader("fleek_starter", str(Path(__file__).with_name("run-job")))
|
|
24
|
+
spec = importlib.util.spec_from_loader(loader.name, loader)
|
|
25
|
+
starter = importlib.util.module_from_spec(spec)
|
|
26
|
+
loader.exec_module(starter)
|
|
27
|
+
SHA = re.compile(r"^[0-9a-f]{40}(?:[0-9a-f]{24})?$")
|
|
28
|
+
FLEEK = "/opt/fleek/bin/fleek"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def git(repo, *args):
|
|
32
|
+
# No checkout hooks, fsmonitor hooks, recursive submodules or repository-selected executable protocols.
|
|
33
|
+
result = subprocess.run(["git", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.ext.allow=never",
|
|
34
|
+
"-c", "protocol.file.allow=never", *args], cwd=repo, stdin=subprocess.DEVNULL,
|
|
35
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=60, check=False)
|
|
36
|
+
if result.returncode or len(result.stdout) > 1024 * 1024:
|
|
37
|
+
raise ValueError("repository_not_readable")
|
|
38
|
+
return result.stdout.decode("utf-8", "strict")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def remote_head(repo):
|
|
42
|
+
lines = git(repo, "ls-remote", "--symref", "origin", "HEAD").splitlines()
|
|
43
|
+
branch = next((line[5:].split("\t", 1)[0] for line in lines if line.startswith("ref: ")), None)
|
|
44
|
+
commit = next((line.split("\t", 1)[0] for line in lines if line.endswith("\tHEAD") and not line.startswith("ref: ")), None)
|
|
45
|
+
if not branch or not re.fullmatch(r"refs/heads/[A-Za-z0-9_./-]{1,200}", branch) or ".." in branch or not commit or not SHA.fullmatch(commit):
|
|
46
|
+
raise ValueError("repository_not_readable")
|
|
47
|
+
return branch, commit
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def analyze(config, state, repo, profile):
|
|
51
|
+
if config.get("provider", "claude") != "claude":
|
|
52
|
+
raise ValueError("init_provider_unavailable")
|
|
53
|
+
poll_quota(state)
|
|
54
|
+
provider = starter.provider_env("claude")
|
|
55
|
+
# No Fleek/Git/keyring credential or repo customization enters this container.
|
|
56
|
+
with tempfile.TemporaryDirectory(prefix="knowledge-", dir=state) as temp:
|
|
57
|
+
job = Path(temp)
|
|
58
|
+
work = job / "work"
|
|
59
|
+
work.mkdir(mode=0o700)
|
|
60
|
+
proc = subprocess.Popen([FLEEK, "init", "--project", config["project"], "--refresh", "--maintenance", "--all-apps",
|
|
61
|
+
"--agent", "manual", "--yes", "--json", "--profile", profile, "--work-dir", str(work)], cwd=repo,
|
|
62
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, start_new_session=True)
|
|
63
|
+
containers = []
|
|
64
|
+
done = False
|
|
65
|
+
try:
|
|
66
|
+
selector = selectors.DefaultSelector()
|
|
67
|
+
selector.register(proc.stdout, selectors.EVENT_READ)
|
|
68
|
+
deadline = time.monotonic() + 3600
|
|
69
|
+
while proc.poll() is None or not done:
|
|
70
|
+
if time.monotonic() > deadline:
|
|
71
|
+
raise ValueError("analysis_timeout")
|
|
72
|
+
if not selector.select(timeout=10):
|
|
73
|
+
if proc.poll() is not None:
|
|
74
|
+
break
|
|
75
|
+
continue
|
|
76
|
+
line = proc.stdout.readline(65537)
|
|
77
|
+
if not line:
|
|
78
|
+
break
|
|
79
|
+
if len(line) > 65536 or not line.endswith(b"\n"):
|
|
80
|
+
raise ValueError("analysis_output_invalid")
|
|
81
|
+
event = json.loads(line)
|
|
82
|
+
if event.get("event") == "staged":
|
|
83
|
+
staging, output = Path(event["stagingDir"]), Path(event["outputFile"])
|
|
84
|
+
if not staging.resolve().is_relative_to(work.resolve()) or not output.parent.resolve().is_relative_to(work.resolve()):
|
|
85
|
+
raise ValueError("analysis_path_invalid")
|
|
86
|
+
if staging.is_symlink() or output.parent.is_symlink():
|
|
87
|
+
raise ValueError("analysis_path_invalid")
|
|
88
|
+
name = "fleek-init-" + uuid.uuid4().hex
|
|
89
|
+
containers.append(name)
|
|
90
|
+
argv = starter.container_args(config["image"], name, job, provider)
|
|
91
|
+
mount = f"type=bind,src={job / 'input'},dst=/job,ro=true"
|
|
92
|
+
argv[argv.index(mount)] = f"type=bind,src={staging},dst=/job,ro=true"
|
|
93
|
+
argv += ["claude", "-p", "Read fleek-input.json and the allowed files. Return ONLY the JSON object requested by FLEEK-INIT.md.",
|
|
94
|
+
"--output-format", "text", "--no-session-persistence", "--tools", "Read,Glob,Grep", "--allowedTools", "Read,Glob,Grep",
|
|
95
|
+
"--permission-mode", "dontAsk", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
|
|
96
|
+
"--settings", '{"disableAllHooks":true,"permissions":{"deny":["Read(/proc/**)","Read(/run/**)"]}}', "--setting-sources", "", "--append-system-prompt-file", "/job/FLEEK-INIT.md"]
|
|
97
|
+
# A file avoids retaining provider output/logs in memory or the journal; the service bounds files to 16 MiB.
|
|
98
|
+
target = output.with_suffix(".tmp")
|
|
99
|
+
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
100
|
+
with os.fdopen(fd, "wb") as stream:
|
|
101
|
+
agent = subprocess.run(argv, stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.DEVNULL,
|
|
102
|
+
env={**os.environ, **provider}, timeout=1800, check=False)
|
|
103
|
+
if agent.returncode or target.stat().st_size > 65536:
|
|
104
|
+
raise ValueError("analysis_failed")
|
|
105
|
+
target.rename(output)
|
|
106
|
+
if event.get("event") == "error":
|
|
107
|
+
raise ValueError("analysis_failed")
|
|
108
|
+
if event.get("event") == "done":
|
|
109
|
+
done = event.get("failed") == 0
|
|
110
|
+
break
|
|
111
|
+
if proc.wait(timeout=30) or not done:
|
|
112
|
+
raise ValueError("analysis_failed")
|
|
113
|
+
finally:
|
|
114
|
+
if proc.poll() is None:
|
|
115
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
116
|
+
try:
|
|
117
|
+
proc.wait(timeout=10)
|
|
118
|
+
except subprocess.TimeoutExpired:
|
|
119
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
120
|
+
proc.wait()
|
|
121
|
+
for name in containers:
|
|
122
|
+
subprocess.run(["podman", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30, check=False)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def write_state(path, record):
|
|
126
|
+
fd, temp = tempfile.mkstemp(prefix="knowledge-state-", dir=path.parent)
|
|
127
|
+
with os.fdopen(fd, "w") as stream:
|
|
128
|
+
json.dump(record, stream)
|
|
129
|
+
os.replace(temp, path)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def poll_quota(state):
|
|
133
|
+
# This poll never consumes the review worker's quota.json / 50 daily jobs.
|
|
134
|
+
path = state / "knowledge-quota.json"
|
|
135
|
+
today = datetime.datetime.now(datetime.timezone.utc).date().isoformat()
|
|
136
|
+
try:
|
|
137
|
+
data = json.loads(starter.private_file(path).read_text())
|
|
138
|
+
except (OSError, ValueError):
|
|
139
|
+
data = {}
|
|
140
|
+
count = data.get("count", 0) if data.get("date") == today else 0
|
|
141
|
+
if not isinstance(count, int) or count >= 8:
|
|
142
|
+
raise ValueError("knowledge_daily_limit")
|
|
143
|
+
write_state(path, {"date": today, "count": count + 1})
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def verified_profile(config):
|
|
147
|
+
# Profile files contain routing metadata only. Fix the exact configured installation before using a checkout binding.
|
|
148
|
+
directory = Path(os.environ.get("FLEEK_CONFIG_DIR") or Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "fleek")
|
|
149
|
+
profiles = json.loads(starter.private_file(directory / "profiles.json").read_text()).get("profiles", {})
|
|
150
|
+
matches = [key for key, value in profiles.items() if re.fullmatch(r"[0-9a-f]{32}", key)
|
|
151
|
+
and value.get("workspace", {}).get("id", "").lower() == config["workspaceId"]
|
|
152
|
+
and value.get("holder", {}).get("kind") == "installation"
|
|
153
|
+
and value.get("holder", {}).get("id", "").lower() == config["expectedInstallationId"] and not value.get("needsLogin")]
|
|
154
|
+
if len(matches) != 1:
|
|
155
|
+
raise ValueError("identity_unavailable")
|
|
156
|
+
profile = matches[0]
|
|
157
|
+
identity = starter.command([FLEEK, "whoami", "--profile", profile, "--json"])
|
|
158
|
+
if (identity.get("installation", {}).get("id", "").lower() != config["expectedInstallationId"]
|
|
159
|
+
or identity.get("workspace", {}).get("id", "").lower() != config["workspaceId"]
|
|
160
|
+
or identity.get("principal", {}).get("kind") != "service_account" or identity.get("grantClass") != "G4x"):
|
|
161
|
+
raise ValueError("identity_unavailable")
|
|
162
|
+
return profile
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def poll(config, state, profile=None):
|
|
166
|
+
profile = profile or verified_profile(config)
|
|
167
|
+
# Statements are independent of a merge and are drained even when Git is unchanged/unreadable.
|
|
168
|
+
statements_ok = True
|
|
169
|
+
try:
|
|
170
|
+
starter.command([FLEEK, "service", "knowledge-check", config["project"], "--profile", profile, "--json"])
|
|
171
|
+
except (SystemExit, ValueError, OSError, subprocess.SubprocessError):
|
|
172
|
+
statements_ok = False
|
|
173
|
+
repo = state / "repository"
|
|
174
|
+
info = repo.lstat()
|
|
175
|
+
if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
176
|
+
raise ValueError("repository_not_readable")
|
|
177
|
+
branch, commit = remote_head(repo)
|
|
178
|
+
previous = state / "knowledge-checked.json"
|
|
179
|
+
try:
|
|
180
|
+
last = json.loads(starter.private_file(previous).read_text())
|
|
181
|
+
except (OSError, ValueError):
|
|
182
|
+
last = {}
|
|
183
|
+
scope = {"project": config["project"], "workspaceId": config.get("workspaceId"), "installationId": config.get("expectedInstallationId")}
|
|
184
|
+
if last.get("commit") == commit and all(last.get(key) == value for key, value in scope.items()):
|
|
185
|
+
return "checked" if statements_ok else "not_checkable"
|
|
186
|
+
failure_path = state / "knowledge-failed.json"
|
|
187
|
+
try:
|
|
188
|
+
failure = json.loads(starter.private_file(failure_path).read_text())
|
|
189
|
+
except (OSError, ValueError):
|
|
190
|
+
failure = {}
|
|
191
|
+
same_failure = failure.get("commit") == commit and all(failure.get(key) == value for key, value in scope.items())
|
|
192
|
+
if same_failure and (failure.get("count", 0) >= 3 or time.time() < failure.get("retryAt", 0)):
|
|
193
|
+
return "not_checkable"
|
|
194
|
+
if git(repo, "status", "--porcelain=v1").strip():
|
|
195
|
+
raise ValueError("repository_dirty")
|
|
196
|
+
git(repo, "fetch", "--no-tags", "--recurse-submodules=no", "origin", branch)
|
|
197
|
+
# Default-branch movement during the fetch is retryable; never record a different commit as checked.
|
|
198
|
+
fetched = git(repo, "rev-parse", "FETCH_HEAD").strip()
|
|
199
|
+
if fetched != commit:
|
|
200
|
+
raise ValueError("repository_changed")
|
|
201
|
+
git(repo, "checkout", "--detach", commit)
|
|
202
|
+
try:
|
|
203
|
+
analyze(config, state, repo, profile)
|
|
204
|
+
except (ValueError, OSError, KeyError, TypeError, AttributeError, subprocess.SubprocessError):
|
|
205
|
+
count = failure.get("count", 0) + 1 if same_failure else 1
|
|
206
|
+
write_state(failure_path, {**scope, "commit": commit, "count": count, "retryAt": time.time() + 6 * 3600})
|
|
207
|
+
raise
|
|
208
|
+
record = {**scope, "commit": commit, "checkedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), "result": "checked"}
|
|
209
|
+
write_state(previous, record)
|
|
210
|
+
return "checked" if statements_ok else "not_checkable"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main():
|
|
214
|
+
state = Path(os.environ["STATE_DIRECTORY"])
|
|
215
|
+
config = starter.load_config(sys.argv[1])
|
|
216
|
+
with os.fdopen(os.open(state / "knowledge-poll.lock", os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600), "w") as lock:
|
|
217
|
+
try:
|
|
218
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
219
|
+
except BlockingIOError:
|
|
220
|
+
return 0
|
|
221
|
+
try:
|
|
222
|
+
result = poll(config, state)
|
|
223
|
+
print(json.dumps({"result": result}), flush=True)
|
|
224
|
+
return 0
|
|
225
|
+
except (SystemExit, ValueError, OSError, KeyError, TypeError, AttributeError, subprocess.SubprocessError):
|
|
226
|
+
# A fixed result only: never Git/provider stderr, code, configuration or credentials.
|
|
227
|
+
print('{"result":"not_checkable"}', flush=True)
|
|
228
|
+
return 1
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
sys.exit(main())
|
package/service-agents/run-job
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
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,114 @@ def host_report(fleek, provider_auth, kit=None, last_job_at=None, timeout=20):
|
|
|
154
157
|
pass
|
|
155
158
|
|
|
156
159
|
|
|
157
|
-
|
|
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
|
+
VENDOR_GAP_REASONS = ("other_version", "disabled", "missing", "unchecked", "unverified", "error", "no_client", "outside_cache", "no_skills")
|
|
164
|
+
VENDOR_MAX_FILES, VENDOR_MAX_BYTES, VENDOR_MAX_ENTRIES, VENDOR_MAX_FILE = 2000, 8 * 1024 * 1024, 10000, 1024 * 1024
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def inert_markdown(data, skill):
|
|
168
|
+
# Vendor text is data for the agent, never configuration: a SKILL.md keeps only name + description of its frontmatter
|
|
169
|
+
# (no allowed-tools, hooks, model, ...), and Claude Code's `!`cmd`` pre-execution syntax is broken up everywhere.
|
|
170
|
+
text = data.decode("utf-8").replace("!`", "! `")
|
|
171
|
+
if skill:
|
|
172
|
+
match = re.match(r"---\n(.*?)\n---\n?", text, re.S)
|
|
173
|
+
fields = {}
|
|
174
|
+
if match:
|
|
175
|
+
lines = match.group(1).split("\n")
|
|
176
|
+
for index, line in enumerate(lines):
|
|
177
|
+
key, _, value = line.partition(":")
|
|
178
|
+
value = value.strip()
|
|
179
|
+
if key not in ("name", "description") or not value:
|
|
180
|
+
continue
|
|
181
|
+
if value in (">", "|", ">-", "|-"): # a folded / literal block: its indented lines
|
|
182
|
+
block = []
|
|
183
|
+
for more in lines[index + 1:]:
|
|
184
|
+
if not more.startswith((" ", "\t")):
|
|
185
|
+
break
|
|
186
|
+
block.append(more.strip())
|
|
187
|
+
value = " ".join(part for part in block if part)
|
|
188
|
+
fields[key] = value.strip('"').strip("'")[:1024]
|
|
189
|
+
text = text[match.end():]
|
|
190
|
+
head = "".join(f"{key}: {json.dumps(fields[key])}\n" for key in ("name", "description") if key in fields)
|
|
191
|
+
text = f"---\n{head}---\n\n{text}"
|
|
192
|
+
return text.encode("utf-8")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def read_regular(path):
|
|
196
|
+
# no symlink is followed (O_NOFOLLOW), the size is bounded before anything is read
|
|
197
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
198
|
+
try:
|
|
199
|
+
info = os.fstat(fd)
|
|
200
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > VENDOR_MAX_FILE:
|
|
201
|
+
raise ValueError("unsafe vendor plugin")
|
|
202
|
+
with os.fdopen(fd, "rb", closefd=False) as stream:
|
|
203
|
+
data = stream.read(VENDOR_MAX_FILE + 1)
|
|
204
|
+
finally:
|
|
205
|
+
os.close(fd)
|
|
206
|
+
if len(data) > VENDOR_MAX_FILE:
|
|
207
|
+
raise ValueError("unsafe vendor plugin")
|
|
208
|
+
return data
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def vendor_plugins(launch, inputs):
|
|
212
|
+
# ONF-225: the vendor plugins of the project's pinned kits, as the CLI reports them for the verified generation (installed by
|
|
213
|
+
# `kit setup` on this host with exactly the approved version, below the client's plugin cache). Only plain Markdown below the
|
|
214
|
+
# plugin's skills/ enters the container (no hooks, scripts, MCP entries, settings); no symlink is followed, nothing is fetched.
|
|
215
|
+
# 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
|
|
216
|
+
# job's metadata line, the job runs without them.
|
|
217
|
+
entries, gaps = launch.get("vendorPlugins", []), launch.get("vendorPluginsMissing", [])
|
|
218
|
+
if not isinstance(entries, list) or len(entries) > 5 or not isinstance(gaps, list) or len(gaps) > 5:
|
|
219
|
+
raise ValueError("invalid vendor plugins")
|
|
220
|
+
missing = []
|
|
221
|
+
for gap in gaps:
|
|
222
|
+
plugin, reason = (gap.get("plugin"), gap.get("reason")) if isinstance(gap, dict) else (None, None)
|
|
223
|
+
if not isinstance(plugin, str) or not PLUGIN_ID.fullmatch(plugin) or reason not in VENDOR_GAP_REASONS:
|
|
224
|
+
raise ValueError("invalid vendor plugin")
|
|
225
|
+
missing.append({"plugin": plugin, "reason": reason})
|
|
226
|
+
names, files, total, walked = [], 0, 0, 0
|
|
227
|
+
for entry in entries:
|
|
228
|
+
name, version, source = (entry.get(key) for key in ("name", "version", "dir")) if isinstance(entry, dict) else (None, None, None)
|
|
229
|
+
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) \
|
|
230
|
+
or not isinstance(source, str) or not os.path.isabs(source):
|
|
231
|
+
raise ValueError("invalid vendor plugin")
|
|
232
|
+
plugin = entry.get("plugin") if isinstance(entry.get("plugin"), str) and PLUGIN_ID.fullmatch(entry["plugin"]) else name
|
|
233
|
+
root = Path(source)
|
|
234
|
+
skills = root / "skills"
|
|
235
|
+
if root.is_symlink() or skills.is_symlink() or not skills.is_dir():
|
|
236
|
+
missing.append({"plugin": plugin, "reason": "no_skills"})
|
|
237
|
+
continue
|
|
238
|
+
target = inputs / "plugins" / name
|
|
239
|
+
for directory, subdirs, children in os.walk(skills, followlinks=False):
|
|
240
|
+
subdirs.sort()
|
|
241
|
+
walked += len(subdirs) + len(children)
|
|
242
|
+
if walked > VENDOR_MAX_ENTRIES:
|
|
243
|
+
raise ValueError("vendor plugins too large")
|
|
244
|
+
for child in sorted(children):
|
|
245
|
+
file = Path(directory) / child
|
|
246
|
+
if not child.endswith(".md"):
|
|
247
|
+
continue
|
|
248
|
+
if not file.resolve().is_relative_to(skills.resolve()):
|
|
249
|
+
raise ValueError("unsafe vendor plugin")
|
|
250
|
+
data = inert_markdown(read_regular(file), child == "SKILL.md")
|
|
251
|
+
files, total = files + 1, total + len(data)
|
|
252
|
+
if files > VENDOR_MAX_FILES or total > VENDOR_MAX_BYTES:
|
|
253
|
+
raise ValueError("vendor plugins too large")
|
|
254
|
+
dest = target / "skills" / file.relative_to(skills)
|
|
255
|
+
dest.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
256
|
+
dest.write_bytes(data)
|
|
257
|
+
if (target / "skills").is_dir():
|
|
258
|
+
(target / ".claude-plugin").mkdir(mode=0o700)
|
|
259
|
+
(target / ".claude-plugin" / "plugin.json").write_text(json.dumps({"name": name, "version": version}))
|
|
260
|
+
names.append(name)
|
|
261
|
+
else:
|
|
262
|
+
# VF-R4: an empty or non-Markdown-only skills/ gives the job nothing
|
|
263
|
+
missing.append({"plugin": plugin, "reason": "no_skills"})
|
|
264
|
+
return names, missing
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def kit_text(fleek, project, cwd, inputs):
|
|
158
268
|
# WZ-CLI-SVC (founder 25.10.2026): new kit versions an admin released, signed and pinned to this project are taken over
|
|
159
269
|
# without a root terminal (--accept-approved: service-account logins only; kit MCP entries are only noted - the container
|
|
160
270
|
# below still gets nothing but the Fleek socket).
|
|
@@ -176,7 +286,8 @@ def kit_text(fleek, project, cwd):
|
|
|
176
286
|
text = "\n\n".join(parts)
|
|
177
287
|
if not text or len(text.encode()) > 120000:
|
|
178
288
|
raise ValueError("invalid kit")
|
|
179
|
-
|
|
289
|
+
names, missing = vendor_plugins(launch, inputs)
|
|
290
|
+
return text, names, missing
|
|
180
291
|
|
|
181
292
|
|
|
182
293
|
def run(config_path):
|
|
@@ -217,14 +328,18 @@ def run(config_path):
|
|
|
217
328
|
exit_code = code
|
|
218
329
|
return code
|
|
219
330
|
started = time.monotonic()
|
|
331
|
+
vendor_missing = []
|
|
220
332
|
provider = config.get("provider", "claude")
|
|
221
333
|
container_env = {key: os.environ[key] for key in ("PATH", "HOME", "XDG_RUNTIME_DIR") if key in os.environ}
|
|
222
334
|
try:
|
|
223
335
|
inputs = job / "input"
|
|
224
336
|
inputs.mkdir(mode=0o700)
|
|
225
|
-
|
|
337
|
+
text, plugins, vendor_missing = kit_text(fleek, config["project"], job, inputs)
|
|
338
|
+
(inputs / "kit.md").write_text(text)
|
|
226
339
|
(inputs / "mcp.json").write_text(json.dumps({"mcpServers": {"fleek": {"command": "socat", "args": ["STDIO", "UNIX-CONNECT:/job/mcp.sock"]}}}))
|
|
227
|
-
|
|
340
|
+
# absolute paths (`//`): a single leading slash would be relative to the settings file; no hook ever runs in the job
|
|
341
|
+
(inputs / "settings.json").write_text(json.dumps({"disableAllHooks": True,
|
|
342
|
+
"permissions": {"deny": ["Read(//proc/**)", "Read(//run/**)", "Read(//home/**)", "Read(//tmp/**)"]}}))
|
|
228
343
|
with (job / "proxy.log").open("wb") as proxy_log, (job / "agent.stdout").open("wb") as stdout, (job / "agent.stderr").open("wb") as stderr:
|
|
229
344
|
proxy = subprocess.Popen([fleek, "mcp", "serve", "--socket", str(inputs / "mcp.sock"), "--job", event_id], stdout=proxy_log, stderr=proxy_log)
|
|
230
345
|
deadline = time.monotonic() + 10
|
|
@@ -236,7 +351,7 @@ def run(config_path):
|
|
|
236
351
|
time.sleep(0.05)
|
|
237
352
|
container_env.update(provider_env(provider))
|
|
238
353
|
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)
|
|
354
|
+
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
355
|
while agent.poll() is None:
|
|
241
356
|
if proxy.poll() is not None:
|
|
242
357
|
return outcome(proxy.returncode if proxy.returncode in (DENIED, LOGIN) else 1)
|
|
@@ -296,7 +411,9 @@ def run(config_path):
|
|
|
296
411
|
pass
|
|
297
412
|
host_report(fleek, provider_state(state), None, stamp, timeout=5)
|
|
298
413
|
# Only validated IDs and numeric metadata ever reach journald. No agent-derived text/statistics.
|
|
299
|
-
|
|
414
|
+
# VF-R2: declared vendor plugins the job ran without (validated plugin id + fixed reason)
|
|
415
|
+
print(json.dumps({"eventId": event_id, "kind": "review", "exit": exit_code, "durationMs": int((time.monotonic() - started) * 1000), "cleanupFailed": cleanup_failed,
|
|
416
|
+
**({"vendorSkillsMissing": vendor_missing} if vendor_missing else {})}), flush=True)
|
|
300
417
|
if cleanup_failed:
|
|
301
418
|
raise SystemExit(DENIED)
|
|
302
419
|
|
|
@@ -22,4 +22,11 @@ shift
|
|
|
22
22
|
sleep 60
|
|
23
23
|
done
|
|
24
24
|
) &
|
|
25
|
+
# E10: the broker's existing private bus/keyring/cgroup; no server job or per-drift notification.
|
|
26
|
+
(
|
|
27
|
+
while :; do
|
|
28
|
+
/opt/fleek-agents/knowledge-poll /etc/fleek-agents/review.json || true
|
|
29
|
+
sleep 900
|
|
30
|
+
done
|
|
31
|
+
) &
|
|
25
32
|
exec /opt/fleek/bin/fleek listen --name review --concurrency 1 --timeout 660 --quiet-children --exec /opt/fleek-agents/run-job /etc/fleek-agents/review.json
|