Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ jobs:
- 'scripts/tests/test-agentic-engineer-schedule.sh'
- 'scripts/tests/test-agentic-engineer-lane-store.sh'
- 'scripts/tests/test-agentic-engineer-lane-store-lease.sh'
- 'scripts/tests/test-agentic-engineer-lane-store-gateway.sh'
- 'scripts/tests/test-kagent-claude-harness-wrapper.sh'
- 'scripts/kagent-claude-harness/**'
- 'scripts/tests/test-anthropic-proxy-envoy-config.sh'
Expand Down Expand Up @@ -2899,6 +2900,15 @@ jobs:
shellcheck scripts/tests/test-agentic-engineer-lane-store-lease.sh
bash scripts/tests/test-agentic-engineer-lane-store-lease.sh

- name: 🚪 Validate the lane store through Substrate's egress gateway
if: needs.changes.outputs.k8s == 'true'
# Runs the gateway image Substrate deploys in front of the store's pinned
# rclone: a WebDAV method that opens a connection gets no reply, and the
# run message's own helper reaches PROPFIND, MKCOL, LOCK and UNLOCK.
run: |
shellcheck scripts/tests/test-agentic-engineer-lane-store-gateway.sh
bash scripts/tests/test-agentic-engineer-lane-store-gateway.sh

- name: 🧠 Validate the kagent harness plugin wrapper
if: needs.changes.outputs.k8s == 'true'
# Runs the image's real `claude` wrapper against a stub Claude Code: a
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# The lane store for the agent (deployment-lane-store.yaml). The gateway
# replaces the Authorization header with the store credential, so the agent
# sends only `Authorization: Basic kagent-credential-injected`.
#
# The gateway takes a plaintext connection for HTTP only when its first
# request uses one of nine standard methods; any other first method is
# dropped as opaque TCP. So the agent sends a WebDAV method (PROPFIND, MKCOL,
# LOCK, UNLOCK) only on a connection an OPTIONS opened, through the run
# message's `dav` helper (scripts/tests/test-agentic-engineer-lane-store-gateway.sh).
apiVersion: api.kagent.dev/v1alpha3
kind: EgressPolicy
metadata:
Expand Down
81 changes: 46 additions & 35 deletions k8s/providers/hetzner/apps/agentic-engineer/job-schedule.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,29 @@ spec:
Scheduled run of the devantler-tech Agentic Engineer on its kagent lane:
instance `kagent-cluster`, writer namespace `kagent/*`, one run a day.

The lane store is a WebDAV server. Its gateway treats a connection as
HTTP only when the connection's first request uses a standard method,
so a PROPFIND, MKCOL, LOCK or UNLOCK sent first gets an empty reply.
Send each of those through this helper, which opens its connection
with an OPTIONS and then prints the reply's status, headers and body:

S=http://lane-store.agentic-engineer.svc.cluster.local:8080
A='Authorization: Basic kagent-credential-injected'
dav() { m=$1 p=$2; shift 2; curl -sS -o /dev/null -X OPTIONS -H "$A" "$S/" --next -sS -D - -X "$m" -H "$A" "$@" "$S/$p"; }

Send a GET or PUT with curl directly, with the same header. Read every
status with `-sS -D -`, never -f, which hides it.

1. Take the lane store's single-writer lease: an exclusive WebDAV lock
on memory/ that also fences every write under it, so that overlapping
runs never overwrite each other's memory (devantler-tech/monorepo#4910).
Read every status with `curl -sS -D -`, not -f, which hides it.
a. Send a PROPFIND to memory/ with the store header and `Depth: 0`.
A 404 means a first run: create memory/ with a MKCOL. Any reply
but 207 or that 404 stops the run.
b. Send a LOCK to memory/ with the store header, `Timeout: Infinite`
and `Content-Type: application/xml`, and this body with OWNER
replaced by `agentic-engineer`, your UTC start time and a random
16-hex-digit nonce, which is your owner:
a. Run `dav PROPFIND memory/ -H 'Depth: 0'`. A 404 means a first run:
create memory/ with `dav MKCOL memory/`. Any reply but 207 or that
404 stops the run.
b. Take the lease with `dav LOCK memory/ -H 'Timeout: Infinite'`,
plus `-H 'Content-Type: application/xml'` and `--data-binary`
with this body, OWNER replaced by `agentic-engineer`, your UTC
start time and a random 16-hex-digit nonce, which is your owner:
`<?xml version="1.0"?><D:lockinfo xmlns:D="DAV:"><D:lockscope><D:exclusive/></D:lockscope><D:locktype><D:write/></D:locktype><D:owner>OWNER</D:owner></D:lockinfo>`
A 200 means you hold the lease: keep the reply's Lock-Token value,
angle brackets included. A 423 means another run holds it, or a
Expand All @@ -126,39 +138,38 @@ spec:
step 4 and check again. If it is still not safe, or the refresh
fails or names another owner, upload nothing, leave the lease
alone and report it.
2. Restore your native memory from the lane store: list memory/ with a
PROPFIND (`-X PROPFIND -H 'Depth: 1'` plus the store header), list
each folder it shows the same way, and download every file into
~/lane-memory/, which stays empty on a first run. A listing or
download you cannot complete stops the run: release the lease with
the UNLOCK of step 4, upload nothing and report it.
2. Restore your native memory from the lane store: list memory/ with
`dav PROPFIND memory/ -H 'Depth: 1'`, list each folder it shows the
same way, and download every file into ~/lane-memory/ with a GET;
~/lane-memory/ stays empty on a first run. A listing or download you
cannot complete stops the run: release the lease with the UNLOCK of
step 4, upload nothing and report it.
3. Shallow-clone devantler-tech/monorepo, read its AGENTS.md, load the
agentic-engineering plugin and invoke its agentic-engineer entrypoint
as this scheduled run. ~/lane-memory/ is your native memory (the
legacy layout), as the durable-memory guide says for this lane.
Follow the consumer contract and fail closed on missing deployment
facts. Do not duplicate the role here.
4. Before you stop, however the run ended, refresh the lease: a LOCK to
memory/ with the store header, `Timeout: Infinite`, no body, and your
Lock-Token value in parentheses as the If header (`If: (<t>)` for
`Lock-Token: <t>`). You still hold the lease only if the reply is 200
and its owner is yours. On any other reply, upload nothing, leave the
lease alone and report it. Otherwise upload ~/lane-memory/ back to
memory/ one file at a time, with that same If header on every PUT and
on the MKCOL of any folder inside memory/: the store refuses a write
under memory/ that lacks the lease's token, and a second write with
one token while the first is still running. A 412 or 423 means you
lost the lease: stop uploading, leave the lease alone and report it.
Then upload this session's transcript (the newest
Claude Code session JSONL under your Claude config directory) and your
run report, with no If header, to runs/<UTC date>/transcript.jsonl and
runs/<UTC date>/report.md. If an earlier run that day already wrote
them, never replace them: write yours beside them as
transcript-<UTC time HHMMSS>.jsonl and report-<UTC time HHMMSS>.md.
Create a missing folder with MKCOL before writing into it, and check
that every upload succeeded. Last, when every write under memory/
succeeded, release the lease: an UNLOCK to memory/ with the store
header and your Lock-Token value as the Lock-Token header.
4. Before you stop, however the run ended, refresh the lease with
`dav LOCK memory/ -H 'Timeout: Infinite' -H 'If: (<t>)'`, no body,
where `<t>` is your Lock-Token value. You still hold the lease only
if the reply is 200 and its owner is yours. On any other reply,
upload nothing, leave the lease alone and report it. Otherwise upload
~/lane-memory/ back to memory/ one file at a time, with that same If
header on every PUT and on the `dav MKCOL` of any folder inside
memory/: the store refuses a write under memory/ that lacks the
lease's token, and a second write with one token while the first is
still running. A 412 or 423 means you lost the lease: stop uploading,
leave the lease alone and report it. Then upload this session's
transcript (the newest Claude Code session JSONL under your Claude
config directory) and your run report, with no If header, to
runs/<UTC date>/transcript.jsonl and runs/<UTC date>/report.md. If an
earlier run that day already wrote them, never replace them: write
yours beside them as transcript-<UTC time HHMMSS>.jsonl and
report-<UTC time HHMMSS>.md. Create a missing folder with `dav MKCOL`
before writing into it, and check that every upload succeeded. Last,
when every write under memory/ succeeded, release the lease with
`dav UNLOCK memory/ -H 'Lock-Token: <t>'`.

This run may open, update and review devantler-tech pull requests and
issues, per AGENTS.md. It never merges. Only when genuinely blocked on a
Expand Down
176 changes: 176 additions & 0 deletions scripts/tests/test-agentic-engineer-lane-store-gateway.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# Prove the kagent lane reaches its store's WebDAV methods through Substrate's egress gateway.
#
# A run reaches the lane store only through Substrate's egress gateway, agentgateway in AUTO mode.
# It decides whether a plaintext connection is HTTP from the connection's first bytes, and knows
# only nine standard methods there (crates/agentgateway/src/proxy/gateway.rs, `METHODS`). A
# PROPFIND, MKCOL, LOCK or UNLOCK that opens a connection is taken for opaque TCP and dropped, so the
# lane's first full run stopped at its first store request with an empty reply. After that first
# request the gateway parses every request on the connection as HTTP, whatever its method. The run
# message therefore sends each WebDAV method through a `dav` helper that opens its connection with
# an OPTIONS.
#
# This runs the gateway image Substrate deploys, with the AUTO bind Substrate configures for its
# plaintext egress, in front of the store's own pinned rclone, and proves:
# - a WebDAV method that opens a connection gets no reply at all, which is why the helper exists;
# - the run message's own helper reaches PROPFIND, MKCOL, LOCK and UNLOCK through the gateway, with
# their real statuses, headers and bodies, and a standard PUT that carries the lease's token lands.
set -euo pipefail

root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
dir="${root}/k8s/providers/hetzner/apps/agentic-engineer"
# The gateway image Substrate's control-plane chart runs as atenet-egress. The chart, not this
# repository, chooses it, so the pin below is the one chart 1.0.0 deploys; moving the chart fails
# this test until the pin follows it.
gateway_image='cr.agentgateway.dev/agentgateway:v1.7.0@sha256:60216aafb03d37f94e952ff3d51d2f630a65b10ecaebdc0a864882f92610c45b'
gateway_chart='1.0.0'
work="$(mktemp -d)"
network=''
containers=()
completed=0
# Remove the containers, their network and the work directory on exit, and fail any exit that did
# not reach the PASS.
cleanup() {
local name
for name in "${containers[@]}"; do
docker rm -f "${name}" >/dev/null 2>&1 || true
done
if [[ -n "${network}" ]]; then
docker network rm "${network}" >/dev/null 2>&1 || true
fi
rm -rf "${work}"
if [[ "${completed}" != 1 ]]; then
printf 'FAIL: agentic-engineer lane store gateway: aborted before completion\n' >&2
exit 1
fi
}
trap cleanup EXIT
# Print one failure reason and stop.
fail() {
printf 'FAIL: agentic-engineer lane store gateway: %s\n' "$1" >&2
exit 1
}
for tool in curl docker yq; do
command -v "${tool}" >/dev/null || fail "${tool} is required"
done

chart="$(yq '.spec.ref.tag' "${root}/k8s/bases/infrastructure/substrate/oci-repository-control-plane.yaml")"
[[ "${chart}" == "${gateway_chart}" ]] ||
fail "Substrate's chart is ${chart}, not ${gateway_chart}: pin the gateway image that chart deploys as atenet-egress here"

# The run message's store address, header and helper, exactly as a run defines them.
prompt="$(yq '.spec.template.spec.containers[] | select(.name == "schedule") | .env[] |
select(.name == "PROMPT") | .value' "${dir}/job-schedule.yaml")"
helper="$(grep -E '^ *(S=|A=|dav\(\) )' <<<"${prompt}" || true)"
[[ "$(grep -c . <<<"${helper}")" == 3 ]] || fail "the run message does not define S, A and dav exactly once: ${helper}"
[[ "${helper}" == *'S=http://lane-store.agentic-engineer.svc.cluster.local:8080'* ]] ||
fail 'the helper does not address the lane store'
[[ "${helper}" == *"A='Authorization: Basic kagent-credential-injected'"* ]] ||
fail "the helper does not send the placeholder the store's egress policy replaces"
[[ "${helper}" == *'-X OPTIONS'*'--next'* ]] || fail 'the helper does not open its connection with an OPTIONS'
pattern='(<[?]xml [^`]*</D:lockinfo>)'
[[ "${prompt}" =~ ${pattern} ]] || fail 'the run message gives no lock body'
lockinfo="${BASH_REMATCH[1]}"

# The store's image and arguments, without the credential: the gateway's egress policy adds that,
# and it has no part in which methods pass.
deployment="${dir}/deployment-lane-store.yaml"
store_image="$(yq '.spec.template.spec.containers[] | select(.name == "webdav") | .image' "${deployment}")"
[[ "${store_image}" =~ ^docker\.io/rclone/rclone:[^@]+@sha256:[0-9a-f]{64}$ ]] ||
fail "the store does not run a digest-pinned rclone: ${store_image}"
args=()
while IFS= read -r arg; do
case "${arg}" in
--htpasswd=*) ;;
*) args+=("${arg}") ;;
esac
done < <(yq '.spec.template.spec.containers[] | select(.name == "webdav") | .args[]' "${deployment}")
[[ "${args[*]:0:4}" == 'serve webdav /data --addr=:8080' ]] ||
fail "the store no longer serves WebDAV from /data on :8080: ${args[*]}"

network="lane-store-gateway-$$-${RANDOM}"
docker network create "${network}" >/dev/null
store="${network}-store"
containers+=("${store}")
mkdir -p "${work}/data" "${work}/gateway"
docker run -d --name "${store}" --network "${network}" --user "$(id -u):$(id -g)" --read-only --tmpfs /tmp \
--cap-drop ALL --security-opt no-new-privileges --mount "type=bind,src=${work}/data,dst=/data" \
"${store_image}" "${args[@]}" >/dev/null
store_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${store}")"
[[ -n "${store_ip}" ]] || fail 'the store got no address on the test network'
# Substrate's egress bind is AUTO; its HTTP listener carries every plaintext request a run sends.
# The HTTPS and TLS listeners beside it take only TLS, so they play no part in what follows.
cat >"${work}/gateway/config.yaml" <<YAML
binds:
- port: 3000
protocol: AUTO
listeners:
- protocol: HTTP
routes:
- backends:
- host: ${store_ip}:8080
YAML
chmod -R a+rX "${work}/gateway"
gateway="${network}-gateway"
containers+=("${gateway}")
docker run -d --name "${gateway}" --network "${network}" --read-only --tmpfs /tmp --cap-drop ALL \
--security-opt no-new-privileges --mount "type=bind,src=${work}/gateway,dst=/etc/agentgateway,readonly" \
-p 127.0.0.1::3000 "${gateway_image}" -f /etc/agentgateway/config.yaml >/dev/null

# Wait until a standard request crosses the gateway to the store.
url=''
for attempt in $(seq 1 30); do
ports="$(docker port "${gateway}" 3000/tcp 2>/dev/null || true)"
address="${ports%%$'\n'*}"
if [[ -n "${address}" ]] &&
[[ "$(curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X OPTIONS "http://${address}/" 2>/dev/null || true)" == 200 ]]; then
url="http://${address}"
break
fi
sleep 1
done
[[ -n "${url}" ]] || fail "the gateway did not reach the store after ${attempt} tries: $(docker logs "${gateway}" 2>&1 | tail -c 800)"

# A WebDAV method that opens a connection is taken for opaque TCP and dropped: curl gets no reply.
status=0
curl -sS --max-time 20 -o /dev/null -X PROPFIND -H 'Depth: 0' "${url}/" 2>"${work}/bare.err" || status=$?
[[ "${status}" == 52 ]] ||
fail "a PROPFIND that opens a connection now gets a reply (curl ${status}): the gateway may pass WebDAV methods, so revisit the helper"

# The run message's helper, pointed at this gateway in place of the cluster's. The helper reads S.
eval "${helper}"
# shellcheck disable=SC2034
S="${url}"
# reply <name> <dav arguments...>: runs the helper, keeps its output as <name>, prints the status.
reply() {
local name="$1"
shift
dav "$@" | tr -d '\r' >"${work}/${name}" || fail "the helper failed for $*: $(cat "${work}/${name}")"
sed -n '1s/^HTTP\/[0-9.]* \([0-9][0-9][0-9]\).*/\1/p' "${work}/${name}"
}
# header <name> <header>: one header of a kept reply.
header() { awk -v h="$2" 'BEGIN {h = tolower(h) ":"} tolower(substr($0, 1, length(h))) == h {
sub(/^[^:]*:[[:space:]]*/, ""); print; exit}' "${work}/$1"; }
owner='agentic-engineer 2026-11-11T05:15:00Z 0123456789abcdef'

[[ "$(reply missing PROPFIND memory/ -H 'Depth: 0')" == 404 ]] || fail "PROPFIND of a missing memory/ did not answer 404: $(head -c 300 "${work}/missing")"
[[ "$(reply created MKCOL memory/)" == 201 ]] || fail "MKCOL did not create memory/: $(head -c 300 "${work}/created")"
[[ "$(reply listed PROPFIND memory/ -H 'Depth: 1')" == 207 ]] || fail "PROPFIND did not list memory/: $(head -c 300 "${work}/listed")"
grep -q 'multistatus' "${work}/listed" || fail 'the listing has no multistatus body'
[[ "$(reply locked LOCK memory/ -H 'Timeout: Infinite' -H 'Content-Type: application/xml' --data-binary "${lockinfo/OWNER/${owner}}")" == 200 ]] ||
fail "LOCK did not take the lease: $(head -c 300 "${work}/locked")"
token="$(header locked Lock-Token)"
[[ "${token}" =~ ^\<[0-9]+\>$ ]] || fail "the lease reply carried no Lock-Token: ${token}"
grep -Fq ">${owner}<" "${work}/locked" || fail 'the lease reply does not name its holder'
[[ "$(reply refreshed LOCK memory/ -H 'Timeout: Infinite' -H "If: (${token})")" == 200 ]] || fail 'the refresh did not hold the lease'
grep -Fq ">${owner}<" "${work}/refreshed" || fail 'the refresh does not name the holder'
put="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT -H "${A}" -H "If: (${token})" --data-binary memory "${url}/memory/MEMORY.md")"
[[ "${put}" == 201 ]] || fail "a PUT with the lease's token did not land: ${put}"
[[ "$(reply folder MKCOL memory/archive/ -H "If: (${token})")" == 201 ]] || fail 'MKCOL with the lease token did not create a folder'
[[ "$(reply released UNLOCK memory/ -H "Lock-Token: ${token}")" == 204 ]] || fail "UNLOCK did not release the lease: $(head -c 300 "${work}/released")"

grep -Fq 'bash scripts/tests/test-agentic-engineer-lane-store-gateway.sh' "${root}/.github/workflows/ci.yaml" ||
fail 'CI does not run this test'

completed=1
printf 'agentic-engineer lane store gateway: PASS\n'
Loading
Loading