@logic-joe/fleek 0.1.3 → 0.2.1
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 +7661 -2895
- package/package.json +2 -1
- package/service-agents/Containerfile +13 -0
- package/service-agents/broker-run +55 -0
- package/service-agents/container-entrypoint +10 -0
- package/service-agents/egress-allowlist +44 -0
- package/service-agents/fleek-agent@.service +32 -0
- package/service-agents/fleek-egress.service +8 -0
- package/service-agents/fleek-egress.timer +10 -0
- package/service-agents/install-system-service +68 -0
- package/service-agents/kit-setup +59 -0
- package/service-agents/knowledge-poll +232 -0
- package/service-agents/run-job +316 -0
- package/service-agents/run-service +32 -0
- package/service-agents/unlock-keyring +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logic-joe/fleek",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist/fleek.mjs",
|
|
12
|
+
"service-agents",
|
|
12
13
|
"LICENSE",
|
|
13
14
|
"THIRD_PARTY_NOTICES.txt"
|
|
14
15
|
],
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Build on fleek-agents after choosing audited base/image digests. Never build on the coordinator Mac.
|
|
2
|
+
ARG BASE_IMAGE
|
|
3
|
+
FROM ${BASE_IMAGE}
|
|
4
|
+
USER root
|
|
5
|
+
RUN apt-get update && apt-get install -y --no-install-recommends socat ca-certificates \
|
|
6
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
7
|
+
# Versions match packages/fleek-cli/src/kit-capabilities.ts; reverify tool restrictions on any upgrade.
|
|
8
|
+
RUN npm install -g --allow-scripts=@anthropic-ai/claude-code @anthropic-ai/claude-code@2.1.303 @openai/codex@0.161.0
|
|
9
|
+
COPY container-entrypoint /usr/local/bin/fleek-agent-entrypoint
|
|
10
|
+
RUN chmod 0755 /usr/local/bin/fleek-agent-entrypoint
|
|
11
|
+
USER node
|
|
12
|
+
WORKDIR /job
|
|
13
|
+
ENTRYPOINT ["/usr/local/bin/fleek-agent-entrypoint"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# WZ-CLI-SVC: runs ONE `fleek` command as the service broker - its own profile (HOME), its OS keyring unlocked from the service's
|
|
3
|
+
# encrypted credential on a private D-Bus, working directory / (outside every git checkout). Same arrangement as kit-setup and
|
|
4
|
+
# run-service; `fleek service setup|status|renew` (root) use it for the broker's login, whoami, claim, reports and kit pull.
|
|
5
|
+
# Usage (root): broker-run review pty|pipe <purpose> [FLEEK_SETUP_CODE] -- <fleek arguments>
|
|
6
|
+
# pty = a person at the terminal (device login); pipe = captured output (--json documents)
|
|
7
|
+
# FLEEK_SETUP_CODE = take the wizard's setup code from the caller's environment and hand it to the inner fleek as a unit
|
|
8
|
+
# CREDENTIAL (root-only 0600 file, LoadCredential): never argv, never a unit property (`systemctl show` is readable by every
|
|
9
|
+
# user; -E would put it there), never the journal.
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
if [ "${1:-}" != --as-broker ]; then
|
|
12
|
+
[ "$(id -u)" = 0 ] || { echo 'root required' >&2; exit 77; }
|
|
13
|
+
kind=${1:-}; mode=${2:-}; purpose=${3:-}
|
|
14
|
+
[ "$kind" = review ] || { echo 'only the review starter is enabled' >&2; exit 64; }
|
|
15
|
+
case $mode in pty|pipe) ;; *) exit 64 ;; esac
|
|
16
|
+
[[ "$purpose" =~ ^[a-z][a-z-]{0,23}$ ]] || exit 64
|
|
17
|
+
shift 3
|
|
18
|
+
credential=()
|
|
19
|
+
if [ "${1:-}" = FLEEK_SETUP_CODE ]; then
|
|
20
|
+
[[ "${FLEEK_SETUP_CODE:-}" =~ ^[BCDFGHJKLMNPQRSTVWXZ]{8}$ ]] || exit 64
|
|
21
|
+
secret_dir=$(mktemp -d /run/fleek-setup-code-XXXXXX)
|
|
22
|
+
trap 'rm -rf "$secret_dir"' EXIT
|
|
23
|
+
printf '%s' "$FLEEK_SETUP_CODE" > "$secret_dir/setup-code"
|
|
24
|
+
chmod 0600 "$secret_dir/setup-code"
|
|
25
|
+
credential=(-p "LoadCredential=setup-code:$secret_dir/setup-code")
|
|
26
|
+
shift
|
|
27
|
+
fi
|
|
28
|
+
[ "${1:-}" = -- ] || exit 64
|
|
29
|
+
shift
|
|
30
|
+
broker=fa-$kind
|
|
31
|
+
broker_uid=$(id -u "$broker")
|
|
32
|
+
broker_home=$(getent passwd "$broker" | cut -d: -f6)
|
|
33
|
+
[ "$broker_uid" -gt 0 ] && [ "$broker_home" = "/home/$broker" ] || exit 77
|
|
34
|
+
# not exec: the credential file is removed when the unit ended (EXIT trap)
|
|
35
|
+
systemd-run --quiet "--$mode" --wait --collect --uid="$broker" --gid="$broker" --working-directory=/ \
|
|
36
|
+
--description="fleek $purpose" -p UMask=0077 -p "RuntimeDirectory=fleek-$purpose-$kind-$$" -p RuntimeDirectoryMode=0700 \
|
|
37
|
+
-p "LoadCredentialEncrypted=keyring-unlock:/etc/credstore.encrypted/fleek-$kind-keyring-unlock" \
|
|
38
|
+
-E "HOME=$broker_home" -E PATH=/opt/fleek/bin:/usr/local/bin:/usr/bin:/bin -E "XDG_RUNTIME_DIR=/run/user/$broker_uid" \
|
|
39
|
+
-E "LANG=${LANG:-C.UTF-8}" "${credential[@]}" \
|
|
40
|
+
/opt/fleek-agents/broker-run --as-broker "$@"
|
|
41
|
+
exit $?
|
|
42
|
+
fi
|
|
43
|
+
shift
|
|
44
|
+
if [ "${1:-}" != --inside-private-bus ]; then
|
|
45
|
+
export GNOME_KEYRING_CONTROL="$RUNTIME_DIRECTORY/keyring"
|
|
46
|
+
mkdir -p -m 0700 "$GNOME_KEYRING_CONTROL"
|
|
47
|
+
exec dbus-run-session -- "$0" --as-broker --inside-private-bus "$@"
|
|
48
|
+
fi
|
|
49
|
+
shift
|
|
50
|
+
/opt/fleek-agents/unlock-keyring
|
|
51
|
+
if [ -r "${CREDENTIALS_DIRECTORY:-/nonexistent}/setup-code" ]; then
|
|
52
|
+
FLEEK_SETUP_CODE=$(cat "$CREDENTIALS_DIRECTORY/setup-code")
|
|
53
|
+
export FLEEK_SETUP_CODE
|
|
54
|
+
fi
|
|
55
|
+
exec /opt/fleek/bin/fleek "$@"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Root-owned broker egress: fixed approved DNS names, current public addresses, no private/link-local routes."""
|
|
3
|
+
import ipaddress
|
|
4
|
+
import os
|
|
5
|
+
import pwd
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
if os.getuid() != 0:
|
|
10
|
+
raise SystemExit(77)
|
|
11
|
+
# platform.claude.com: token exchange of `claude setup-token` (WZ-CLI-SVC provider sign-in in the agent image, pre-check 25.10.2026)
|
|
12
|
+
hosts = ("api.onfleek.app", "mcp.onfleek.app", "app.onfleek.app", "api.anthropic.com", "claude.ai", "platform.claude.com", "api.openai.com", "github.com")
|
|
13
|
+
addresses = {4: set(), 6: set()}
|
|
14
|
+
for host in hosts:
|
|
15
|
+
for family, _type, _proto, _name, endpoint in socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM):
|
|
16
|
+
address = ipaddress.ip_address(endpoint[0])
|
|
17
|
+
if not address.is_global:
|
|
18
|
+
raise SystemExit("refusing non-public egress address")
|
|
19
|
+
addresses[address.version].add(str(address))
|
|
20
|
+
uid = pwd.getpwnam("fa-review").pw_uid
|
|
21
|
+
if uid == 0 or not addresses[4]:
|
|
22
|
+
raise SystemExit(77)
|
|
23
|
+
subprocess.run(["nft", "add", "table", "inet", "fleek_agents"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
24
|
+
rules = f"""delete table inet fleek_agents
|
|
25
|
+
table inet fleek_agents {{
|
|
26
|
+
set allow4 {{ type ipv4_addr; elements = {{ {', '.join(sorted(addresses[4]))} }} }}
|
|
27
|
+
set allow6 {{ type ipv6_addr; elements = {{ {', '.join(sorted(addresses[6]))} }} }}
|
|
28
|
+
chain output {{ type filter hook output priority -10; policy accept;
|
|
29
|
+
meta skuid {uid} jump broker
|
|
30
|
+
}}
|
|
31
|
+
chain broker {{
|
|
32
|
+
ct state established,related accept
|
|
33
|
+
ip daddr 127.0.0.53 udp dport 53 accept
|
|
34
|
+
ip daddr 127.0.0.53 tcp dport 53 accept
|
|
35
|
+
ip daddr {{ 0.0.0.0/8,10.0.0.0/8,100.64.0.0/10,127.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.168.0.0/16 }} reject
|
|
36
|
+
ip6 daddr {{ ::1/128,fc00::/7,fe80::/10 }} reject
|
|
37
|
+
ip daddr @allow4 tcp dport 443 accept
|
|
38
|
+
ip6 daddr @allow6 tcp dport 443 accept
|
|
39
|
+
reject with icmpx type admin-prohibited
|
|
40
|
+
}}
|
|
41
|
+
}}
|
|
42
|
+
"""
|
|
43
|
+
subprocess.run(["nft", "-f", "-"], input=rules, text=True, check=True)
|
|
44
|
+
print(f"fleek egress updated: uid={uid} hosts={len(hosts)} IPv4={len(addresses[4])} IPv6={len(addresses[6])}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Install as a USER service under the dedicated fa-<kind> broker (loginctl enable-linger).
|
|
2
|
+
[Unit]
|
|
3
|
+
Description=Fleek service agent %i
|
|
4
|
+
StartLimitIntervalSec=300
|
|
5
|
+
StartLimitBurst=3
|
|
6
|
+
|
|
7
|
+
[Service]
|
|
8
|
+
Type=simple
|
|
9
|
+
UMask=0077
|
|
10
|
+
Environment=PATH=/opt/fleek/bin:/usr/local/bin:/usr/bin:/bin
|
|
11
|
+
StateDirectory=fleek-agent-%i
|
|
12
|
+
StateDirectoryMode=0700
|
|
13
|
+
RuntimeDirectory=fleek-agent-%i
|
|
14
|
+
RuntimeDirectoryMode=0700
|
|
15
|
+
# Encrypted files are created on this machine. Configure ONE provider (OAuth preferred for Claude).
|
|
16
|
+
LoadCredentialEncrypted=claude-oauth:%h/.config/fleek-agent/claude-oauth.cred
|
|
17
|
+
# Alternative: replace the line above with anthropic-key or openai-key (not all three).
|
|
18
|
+
LoadCredentialEncrypted=keyring-unlock:%h/.config/fleek-agent/keyring-unlock.cred
|
|
19
|
+
ExecStart=/opt/fleek-agents/run-service %i /etc/fleek-agents/%i.json
|
|
20
|
+
Restart=on-failure
|
|
21
|
+
RestartSec=30
|
|
22
|
+
RestartPreventExitStatus=4 77
|
|
23
|
+
KillMode=control-group
|
|
24
|
+
TimeoutStopSec=10
|
|
25
|
+
LimitFSIZE=16M
|
|
26
|
+
# Rootless Podman needs user namespaces/cgroup delegation; do not use PrivateUsers/NoNewPrivileges here.
|
|
27
|
+
Delegate=yes
|
|
28
|
+
StandardOutput=journal
|
|
29
|
+
StandardError=journal
|
|
30
|
+
|
|
31
|
+
[Install]
|
|
32
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Root-only installer for systemd255 hosts. Encrypted credentials remain host-managed; the process runs as fa-review.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
[ "$(id -u)" = 0 ] || { echo 'root required' >&2; exit 77; }
|
|
5
|
+
kind=${1:-review}
|
|
6
|
+
[ "$kind" = review ] || { echo 'only the review starter is enabled' >&2; exit 64; }
|
|
7
|
+
source_dir=$(cd "$(dirname "$0")" && pwd)
|
|
8
|
+
broker=fa-$kind
|
|
9
|
+
broker_uid=$(id -u "$broker")
|
|
10
|
+
broker_home=$(getent passwd "$broker" | cut -d: -f6)
|
|
11
|
+
[ "$broker_uid" -gt 0 ] && [ "$broker_home" = /home/fa-review ] || exit 77
|
|
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/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
|
+
# Every level of ~/.config belongs to the broker (install -d sets owner/mode only on the NAMED directories, so naming
|
|
16
|
+
# just .config/systemd/user left .config root-owned and `fleek login` could not create ~/.config/fleek - SVCLOGIN).
|
|
17
|
+
# Re-running the installer repairs an existing host: install -d also re-applies owner/mode to existing directories.
|
|
18
|
+
install -d -m 0700 -o "$broker" -g "$broker" "$broker_home/.config" "$broker_home/.config/systemd" "$broker_home/.config/systemd/user"
|
|
19
|
+
install -d -m 0750 -o root -g "$broker" /etc/fleek-agents
|
|
20
|
+
install -d -m 0700 /etc/credstore.encrypted
|
|
21
|
+
if [ "$source_dir" != /opt/fleek-agents ]; then install -m 0755 "$source_dir/egress-allowlist" /opt/fleek-agents/; fi
|
|
22
|
+
install -m 0644 "$source_dir/fleek-egress.service" "$source_dir/fleek-egress.timer" /etc/systemd/system/
|
|
23
|
+
cat > "/etc/systemd/system/fleek-agent-$kind.service" <<UNIT
|
|
24
|
+
[Unit]
|
|
25
|
+
Description=Fleek review service agent
|
|
26
|
+
StartLimitIntervalSec=300
|
|
27
|
+
StartLimitBurst=3
|
|
28
|
+
Requires=user@$broker_uid.service
|
|
29
|
+
After=network-online.target user@$broker_uid.service fleek-egress.service
|
|
30
|
+
Requires=fleek-egress.service
|
|
31
|
+
Wants=network-online.target
|
|
32
|
+
|
|
33
|
+
[Service]
|
|
34
|
+
Type=simple
|
|
35
|
+
User=$broker
|
|
36
|
+
Group=$broker
|
|
37
|
+
UMask=0077
|
|
38
|
+
Environment=HOME=$broker_home
|
|
39
|
+
Environment=PATH=/opt/fleek/bin:/usr/local/bin:/usr/bin:/bin
|
|
40
|
+
Environment=XDG_RUNTIME_DIR=/run/user/$broker_uid
|
|
41
|
+
RuntimeDirectory=fleek-agent-$kind
|
|
42
|
+
RuntimeDirectoryMode=0700
|
|
43
|
+
StateDirectory=fleek-agent-$kind
|
|
44
|
+
StateDirectoryMode=0700
|
|
45
|
+
LoadCredentialEncrypted=claude-oauth:/etc/credstore.encrypted/fleek-$kind-claude-oauth
|
|
46
|
+
LoadCredentialEncrypted=keyring-unlock:/etc/credstore.encrypted/fleek-$kind-keyring-unlock
|
|
47
|
+
ExecStart=/opt/fleek-agents/run-service $kind /etc/fleek-agents/$kind.json
|
|
48
|
+
Restart=on-failure
|
|
49
|
+
RestartSec=30
|
|
50
|
+
RestartPreventExitStatus=4 77
|
|
51
|
+
KillMode=control-group
|
|
52
|
+
TimeoutStopSec=10
|
|
53
|
+
LimitFSIZE=16M
|
|
54
|
+
Delegate=yes
|
|
55
|
+
StandardOutput=journal
|
|
56
|
+
StandardError=journal
|
|
57
|
+
|
|
58
|
+
[Install]
|
|
59
|
+
WantedBy=multi-user.target
|
|
60
|
+
UNIT
|
|
61
|
+
loginctl enable-linger "$broker"
|
|
62
|
+
systemctl start "user@$broker_uid.service"
|
|
63
|
+
systemctl daemon-reload
|
|
64
|
+
systemctl enable --now fleek-egress.timer
|
|
65
|
+
systemctl start fleek-egress.service
|
|
66
|
+
# Deliberately not enabled/started: identity, image, provider credential and host acceptance must be ready first
|
|
67
|
+
# (`fleek service setup` enables it once they are). Re-running this on a running host keeps the unit's state.
|
|
68
|
+
printf '%s\n' "Installed disabled fleek-agent-$kind.service for $broker (uid $broker_uid)"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# KIT-PROVISION for a service broker (root, at a terminal): the ONE interactive kit confirmation an operator gives.
|
|
3
|
+
# run-job pulls kits unattended (`fleek kit pull <project> --target claude --yes --json`, stdin /dev/null). The CLI refuses a
|
|
4
|
+
# first kit, a new kit, a new MCP entry or new kit files without a terminal (CONFIRMATION_REQUIRED, run-job exits 77) - that
|
|
5
|
+
# boundary stays. This runs `fleek kit setup` once as the broker with the service's profile (HOME), its OS keyring (private
|
|
6
|
+
# D-Bus, unlocked from the service's own encrypted credential) and a working directory outside every git checkout (like
|
|
7
|
+
# run-job's job directory), then repeats run-job's exact pull without a terminal to prove it needs no confirmation now.
|
|
8
|
+
# Usage: sudo /opt/fleek-agents/kit-setup review (then: systemctl restart fleek-agent-review.service)
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
if [ "${1:-}" != --as-broker ]; then
|
|
11
|
+
[ "$(id -u)" = 0 ] || { echo 'root required' >&2; exit 77; }
|
|
12
|
+
kind=${1:-review}
|
|
13
|
+
[ "$kind" = review ] || { echo 'only the review starter is enabled' >&2; exit 64; }
|
|
14
|
+
[ -t 0 ] && [ -t 1 ] || { echo 'an interactive terminal is required (the confirmation is a person at a terminal)' >&2; exit 3; }
|
|
15
|
+
broker=fa-$kind
|
|
16
|
+
broker_uid=$(id -u "$broker")
|
|
17
|
+
broker_home=$(getent passwd "$broker" | cut -d: -f6)
|
|
18
|
+
[ "$broker_uid" -gt 0 ] && [ "$broker_home" = "/home/$broker" ] || exit 77
|
|
19
|
+
project=$(python3 -c 'import json, re, sys
|
|
20
|
+
p = json.load(open(sys.argv[1]))["project"]
|
|
21
|
+
if not re.fullmatch(r"[A-Z0-9][A-Z0-9_]{0,19}", p): sys.exit(2)
|
|
22
|
+
print(p)' "/etc/fleek-agents/$kind.json")
|
|
23
|
+
exec systemd-run --quiet --pty --wait --collect --uid="$broker" --gid="$broker" --working-directory=/ \
|
|
24
|
+
-p UMask=0077 -p "RuntimeDirectory=fleek-kit-setup-$kind" -p RuntimeDirectoryMode=0700 \
|
|
25
|
+
-p "LoadCredentialEncrypted=keyring-unlock:/etc/credstore.encrypted/fleek-$kind-keyring-unlock" \
|
|
26
|
+
-E "HOME=$broker_home" -E PATH=/opt/fleek/bin:/usr/local/bin:/usr/bin:/bin -E "XDG_RUNTIME_DIR=/run/user/$broker_uid" \
|
|
27
|
+
-E "LANG=${LANG:-C.UTF-8}" \
|
|
28
|
+
/opt/fleek-agents/kit-setup --as-broker "$project"
|
|
29
|
+
fi
|
|
30
|
+
shift
|
|
31
|
+
# Same D-Bus + keyring arrangement as run-service (private session bus in this unit, keyring control below RuntimeDirectory)
|
|
32
|
+
if [ "${1:-}" != --inside-private-bus ]; then
|
|
33
|
+
export GNOME_KEYRING_CONTROL="$RUNTIME_DIRECTORY/keyring"
|
|
34
|
+
mkdir -p -m 0700 "$GNOME_KEYRING_CONTROL"
|
|
35
|
+
exec dbus-run-session -- "$0" --as-broker --inside-private-bus "$@"
|
|
36
|
+
fi
|
|
37
|
+
shift
|
|
38
|
+
project=${1:-}
|
|
39
|
+
[[ "$project" =~ ^[A-Z0-9][A-Z0-9_]{0,19}$ ]] || exit 2
|
|
40
|
+
/opt/fleek-agents/unlock-keyring
|
|
41
|
+
fleek whoami
|
|
42
|
+
# The confirmation (TTY): same profile, project key and target as run-job. Other MCP proposals: answer N - the job
|
|
43
|
+
# container only ever gets the Fleek MCP socket, never the host's client configuration.
|
|
44
|
+
set +e
|
|
45
|
+
fleek kit setup "$project" --target claude
|
|
46
|
+
setup=$?
|
|
47
|
+
set -e
|
|
48
|
+
case $setup in 0|13) ;; *) echo "kit setup ended with exit $setup - nothing confirmed" >&2; exit "$setup" ;; esac
|
|
49
|
+
# Proof: run-job's exact unattended call from a fresh job-like directory, no terminal
|
|
50
|
+
job=$(mktemp -d -p "$RUNTIME_DIRECTORY" job-XXXXXX)
|
|
51
|
+
cd "$job"
|
|
52
|
+
set +e
|
|
53
|
+
fleek kit pull "$project" --target claude --yes --json </dev/null >/dev/null 2>&1
|
|
54
|
+
pulled=$?
|
|
55
|
+
set -e
|
|
56
|
+
cd /
|
|
57
|
+
rm -rf "$job"
|
|
58
|
+
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"
|
|
@@ -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())
|