Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd416fa7ac | ||
|
|
d5dc3c0f31 | ||
|
|
a3df11b693 |
@@ -0,0 +1,18 @@
|
||||
# Non-secret staging configuration for the OpenClaw engineering backend.
|
||||
#
|
||||
# Discover the exact binary with `command -v openclaw`.
|
||||
# Do not point this at the chat/WeChat Gateway HTTP endpoint.
|
||||
AIOS_AGENT_RUNTIME_BACKEND_ARGV_JSON='["/usr/bin/python3","/usr/local/lib/aios-agent-runtime-broker/openclaw_engineering_backend.py"]'
|
||||
|
||||
# Required: exact absolute path discovered on hw.imruyi.top.
|
||||
AIOS_OPENCLAW_BIN=/REPLACE/WITH/ABSOLUTE/OPENCLAW/PATH
|
||||
|
||||
# Required: provider/model verified on the installed OpenClaw build.
|
||||
AIOS_OPENCLAW_MODEL=REPLACE_WITH_VERIFIED_PROVIDER_MODEL
|
||||
|
||||
# Non-secret map: provider environment variable -> systemd credential file name.
|
||||
# Example after separately projecting an encrypted credential:
|
||||
# AIOS_OPENCLAW_PROVIDER_CREDENTIAL_MAP_JSON='{"OPENAI_API_KEY":"openai_api_key"}'
|
||||
AIOS_OPENCLAW_PROVIDER_CREDENTIAL_MAP_JSON='{}'
|
||||
|
||||
AIOS_OPENCLAW_EXEC_TIMEOUT_SECONDS=1800
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "must run as root" >&2
|
||||
exit 20
|
||||
fi
|
||||
|
||||
SRC_ROOT="${1:-.}"
|
||||
WORKER_SRC="$SRC_ROOT/services/aios-autonomous-agent-worker"
|
||||
BROKER_SRC="$SRC_ROOT/services/aios-agent-runtime-broker"
|
||||
UNIT_SRC="$SRC_ROOT/deploy/systemd"
|
||||
|
||||
grep -Fq 'AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:19998' \
|
||||
"$UNIT_SRC/aios-autonomous-agent-worker.service" || {
|
||||
echo "staging bridge binding missing" >&2
|
||||
exit 21
|
||||
}
|
||||
|
||||
getent group aios-agent-runtime >/dev/null 2>&1 || groupadd --system aios-agent-runtime
|
||||
id aios-agent-runtime >/dev/null 2>&1 || \
|
||||
useradd --system --gid aios-agent-runtime --home-dir /nonexistent --shell /usr/sbin/nologin aios-agent-runtime
|
||||
id aios-autonomous-agent-worker >/dev/null 2>&1 || \
|
||||
useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin aios-autonomous-agent-worker
|
||||
usermod -a -G aios-agent-runtime aios-autonomous-agent-worker
|
||||
|
||||
install -d -m 0755 /usr/local/lib/aios-autonomous-agent-worker
|
||||
install -d -m 0755 /usr/local/lib/aios-agent-runtime-broker
|
||||
install -d -m 0750 -o aios-autonomous-agent-worker -g aios-autonomous-agent-worker \
|
||||
/var/lib/aios-autonomous-agent-worker
|
||||
install -d -m 0770 -o aios-autonomous-agent-worker -g aios-agent-runtime \
|
||||
/var/lib/aios-autonomous-agent-worker/workspaces
|
||||
|
||||
for file in autonomous_worker.py intent_runtime.py runtime_client.py register.py runtime_selftest.py; do
|
||||
install -m 0755 "$WORKER_SRC/$file" "/usr/local/lib/aios-autonomous-agent-worker/$file"
|
||||
done
|
||||
install -m 0755 "$BROKER_SRC/broker.py" /usr/local/lib/aios-agent-runtime-broker/broker.py
|
||||
install -m 0755 "$BROKER_SRC/openclaw_engineering_backend.py" /usr/local/lib/aios-agent-runtime-broker/openclaw_engineering_backend.py
|
||||
install -m 0644 "$BROKER_SRC/RUNTIME_CONTRACT.md" /usr/local/lib/aios-agent-runtime-broker/RUNTIME_CONTRACT.md
|
||||
|
||||
install -m 0644 "$UNIT_SRC/aios-autonomous-agent-worker.service" /etc/systemd/system/
|
||||
install -m 0644 "$UNIT_SRC/aios-autonomous-agent-register.service" /etc/systemd/system/
|
||||
install -m 0644 "$UNIT_SRC/aios-agent-runtime-broker@.service" /etc/systemd/system/
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
cat <<'EOF'
|
||||
Files installed. Services were NOT started or enabled.
|
||||
|
||||
Required before activation:
|
||||
1. Discover `openclaw` with `command -v openclaw`.
|
||||
2. Verify `openclaw agent exec --help` exposes the required headless coding
|
||||
options on the installed version.
|
||||
3. Create /etc/aios/agent-runtime-openclaw.conf from the non-secret example.
|
||||
4. Project provider/model credentials into the OpenClaw broker service with a
|
||||
separate systemd encrypted credential. Never reuse the Online Agent Task
|
||||
token or writer lease.
|
||||
5. Start only `aios-agent-runtime-broker@openclaw.service`.
|
||||
6. Run runtime_selftest.py and require a real worktree mutation with unchanged
|
||||
Git HEAD.
|
||||
7. Only after self-test PASS register the autonomous identity and enable
|
||||
continuous polling.
|
||||
|
||||
The autonomous worker is staging-only and rejects any Governed Bridge other
|
||||
than http://127.0.0.1:19998.
|
||||
EOF
|
||||
@@ -0,0 +1,42 @@
|
||||
[Unit]
|
||||
Description=AIOS isolated %i engineering runtime broker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-agent-runtime
|
||||
Group=aios-agent-runtime
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=AIOS_AGENT_RUNTIME_ADAPTER=%i
|
||||
Environment=AIOS_AGENT_RUNTIME_SOCKET=/run/aios-agent-runtime/%i.sock
|
||||
Environment=AIOS_AGENT_RUNTIME_WORKSPACE_ROOT=/var/lib/aios-autonomous-agent-worker/workspaces
|
||||
Environment=AIOS_AGENT_RUNTIME_ALLOWED_PEER_USER=aios-autonomous-agent-worker
|
||||
Environment=AIOS_AGENT_RUNTIME_CONNECTION_TIMEOUT_SECONDS=15
|
||||
Environment=AIOS_AGENT_RUNTIME_BACKEND_TIMEOUT_SECONDS=3600
|
||||
EnvironmentFile=/etc/aios/agent-runtime-%i.conf
|
||||
ExecStart=/usr/bin/python3 -B /usr/local/lib/aios-agent-runtime-broker/broker.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
RuntimeDirectory=aios-agent-runtime
|
||||
RuntimeDirectoryMode=0770
|
||||
UMask=0007
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictNamespaces=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadWritePaths=/var/lib/aios-autonomous-agent-worker/workspaces /run/aios-agent-runtime
|
||||
InaccessiblePaths=-/data/aios-gitops/repo.git -/etc/credstore.encrypted/aios-autonomous-agent-worker
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# Example only. Choose the credential name/provider used by the verified model.
|
||||
# The encrypted credential file contains secret material and must never be committed.
|
||||
[Service]
|
||||
# Example:
|
||||
# LoadCredentialEncrypted=openai_api_key:/etc/credstore.encrypted/aios-agent-runtime-openclaw/openai_api_key
|
||||
@@ -0,0 +1,38 @@
|
||||
[Unit]
|
||||
Description=AIOS Autonomous Intent Agent register/recover
|
||||
After=network-online.target aios-agent-task-api.service aios-agent-runtime-broker@openclaw.service
|
||||
Wants=network-online.target
|
||||
Requires=aios-agent-runtime-broker@openclaw.service
|
||||
Before=aios-autonomous-agent-worker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
User=root
|
||||
Group=root
|
||||
LoadCredential=bridge_control_token:/run/credentials/aios-gitops-model-api.service/agent_task_control_token
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_AGENT_ID=online-engineering-worker-cn-network-mkt-autonomous-20260815
|
||||
Environment=AIOS_AGENT_VERSION=2026-08-15.4-autonomous-intent
|
||||
Environment=AIOS_WORKER_REGISTRATION_STATE=/var/lib/aios-autonomous-agent-worker/registration
|
||||
Environment=AIOS_WORKER_ENCRYPTED_CREDENTIAL=/etc/credstore.encrypted/aios-autonomous-agent-worker/agent_token
|
||||
Environment=AIOS_HERMES_BROKER_SOCKET=/run/aios-agent-runtime/hermes.sock
|
||||
Environment=AIOS_OPENCLAW_BROKER_SOCKET=/run/aios-agent-runtime/openclaw.sock
|
||||
ExecStart=/usr/bin/python3 -B /usr/local/lib/aios-autonomous-agent-worker/register.py
|
||||
TimeoutStartSec=120
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadWritePaths=/etc/credstore.encrypted /var/lib/aios-autonomous-agent-worker
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,56 @@
|
||||
[Unit]
|
||||
Description=AIOS Autonomous Intent Engineering Worker (staging)
|
||||
After=network-online.target aios-agent-task-api.service aios-agent-runtime-broker@openclaw.service aios-autonomous-agent-register.service
|
||||
Wants=network-online.target aios-agent-runtime-broker@openclaw.service
|
||||
Requires=aios-autonomous-agent-register.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-autonomous-agent-worker
|
||||
Group=aios-autonomous-agent-worker
|
||||
SupplementaryGroups=aios-agent-runtime gitops
|
||||
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-autonomous-agent-worker/agent_token
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:19998
|
||||
Environment=AIOS_AGENT_ID=online-engineering-worker-cn-network-mkt-autonomous-20260815
|
||||
Environment=AIOS_AGENT_VERSION=2026-08-15.4-autonomous-intent
|
||||
Environment=AIOS_AUTONOMOUS_REPOSITORY=aios/aios
|
||||
Environment=AIOS_AUTONOMOUS_BASE_REF=refs/heads/main
|
||||
Environment=AIOS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_AUTONOMOUS_STATE_ROOT=/var/lib/aios-autonomous-agent-worker
|
||||
Environment=AIOS_AUTONOMOUS_WORKSPACE_ROOT=/var/lib/aios-autonomous-agent-worker/workspaces
|
||||
Environment=AIOS_AUTONOMOUS_POLL_SECONDS=5
|
||||
Environment=AIOS_AUTONOMOUS_HEARTBEAT_SECONDS=10
|
||||
Environment=AIOS_AUTONOMOUS_LEASE_SECONDS=300
|
||||
Environment=AIOS_AUTONOMOUS_RUNTIME_TIMEOUT_SECONDS=3600
|
||||
Environment=AIOS_AUTONOMOUS_DEFAULT_ADAPTER=openclaw
|
||||
Environment=AIOS_AGENT_RUNTIME_SHARED_GROUP=aios-agent-runtime
|
||||
Environment=AIOS_HERMES_BROKER_SOCKET=/run/aios-agent-runtime/hermes.sock
|
||||
Environment=AIOS_OPENCLAW_BROKER_SOCKET=/run/aios-agent-runtime/openclaw.sock
|
||||
ExecStart=/usr/bin/python3 -B /usr/local/lib/aios-autonomous-agent-worker/autonomous_worker.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
UMask=0027
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictNamespaces=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||
ReadWritePaths=/var/lib/aios-autonomous-agent-worker
|
||||
InaccessiblePaths=-/etc/credstore.encrypted
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,46 @@
|
||||
[Unit]
|
||||
Description=AIOS generic governed Online Agent Worker (2026-08-15)
|
||||
After=aios-agent-task-api.service aios-online-agent-worker-register-20260815.service
|
||||
Wants=aios-online-agent-worker-register-20260815.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-engineering-worker
|
||||
Group=aios-engineering-worker
|
||||
SupplementaryGroups=gitops
|
||||
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_AGENT_ID=online-engineering-worker-cn-network-mkt-generic-20260815
|
||||
Environment=AIOS_AGENT_TYPE=trusted-online-engineering-worker
|
||||
Environment=AIOS_AGENT_VERSION=2026-08-15.3-generic
|
||||
Environment=AIOS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_WORKER_STATE_ROOT=/var/lib/aios-online-agent-worker-20260815
|
||||
Environment=AIOS_WORKER_WORKSPACE_ROOT=/var/lib/aios-online-agent-worker-20260815/workspaces
|
||||
Environment=AIOS_WORKER_POLL_SECONDS=5
|
||||
Environment=AIOS_WORKER_HEARTBEAT_SECONDS=10
|
||||
Environment=AIOS_WORKER_LEASE_SECONDS=300
|
||||
ExecStart=/usr/bin/python3 -B /usr/local/lib/aios-online-agent-worker-20260815/online_agent_worker.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=15
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictNamespaces=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||
ReadWritePaths=/var/lib/aios-online-agent-worker-20260815
|
||||
UMask=0027
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=AIOS generic Online Agent register-or-recover (2026-08-15)
|
||||
After=network-online.target aios-gitops-model-api.service aios-agent-task-api.service
|
||||
Wants=network-online.target
|
||||
Before=aios-online-agent-worker-20260815.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
User=root
|
||||
Group=root
|
||||
LoadCredential=bridge_control_token:/run/credentials/aios-gitops-model-api.service/agent_task_control_token
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_AGENT_ID=online-engineering-worker-cn-network-mkt-generic-20260815
|
||||
Environment=AIOS_AGENT_TYPE=trusted-online-engineering-worker
|
||||
Environment=AIOS_AGENT_VERSION=2026-08-15.3-generic
|
||||
Environment=AIOS_WORKER_ENCRYPTED_CREDENTIAL=/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token
|
||||
Environment=AIOS_WORKER_REGISTRATION_STATE=/var/lib/aios-online-agent-worker-20260815/registration
|
||||
ExecStart=/usr/bin/python3 -B /usr/local/lib/aios-online-agent-worker-20260815/register.py
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadWritePaths=/etc/credstore.encrypted /var/lib/aios-online-agent-worker-20260815
|
||||
UMask=0077
|
||||
TimeoutStartSec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,110 @@
|
||||
# Autonomous Intent Worker v2
|
||||
|
||||
## Goal
|
||||
|
||||
A Custom GPT or other trusted producer creates a prose engineering task with:
|
||||
|
||||
- `goal`
|
||||
- `instructions`
|
||||
- `success_criteria`
|
||||
- `required_capabilities`
|
||||
|
||||
A long-running autonomous worker claims only tasks explicitly marked with:
|
||||
|
||||
- `intent.task`
|
||||
- `engineering.execute`
|
||||
|
||||
and only when the requested engineering runtime capability is actually
|
||||
available, e.g. `agent.runtime.hermes`.
|
||||
|
||||
The runtime edits a disposable worktree. The worker independently inspects the
|
||||
diff, scans for secrets, reruns verification, and persists through staging
|
||||
Governed GitOps.
|
||||
|
||||
## Security domains
|
||||
|
||||
The Online Agent worker owns task/lease/governed-write credentials.
|
||||
|
||||
The runtime broker is a separate service identity. It authorizes the worker
|
||||
over Unix `SO_PEERCRED`, opens the worktree using `O_PATH|O_NOFOLLOW`, and
|
||||
passes a pinned `/proc/self/fd/<fd>` path to the engineering backend. The
|
||||
backend never receives the Agent Task token or writer lease.
|
||||
|
||||
The worker rejects:
|
||||
- a runtime-created Git commit (`HEAD != base_oid`);
|
||||
- direct authoritative ref mutation/push;
|
||||
- sensitive credential/key paths;
|
||||
- obvious secret material;
|
||||
- non-staging Governed Bridge endpoints.
|
||||
|
||||
## Staging boundary
|
||||
|
||||
`autonomous_worker.py` intentionally rejects any Governed Write API base other
|
||||
than:
|
||||
|
||||
`http://127.0.0.1:19998`
|
||||
|
||||
Production/main/tag/baseline/force-push writes remain outside this worker.
|
||||
|
||||
## Migration protection
|
||||
|
||||
The worker does not infer intent from prose. Historical tasks without
|
||||
`intent.task` are skipped before claim, so they do not lose an attempt.
|
||||
|
||||
The bootstrap task
|
||||
`implement-autonomous-intent-worker-v2-20260815` currently lacks the intent
|
||||
marker and is therefore intentionally not claimable by this worker. That is a
|
||||
control-plane/bootstrap concern, not a reason to weaken the runtime rule.
|
||||
|
||||
## Runtime requirement
|
||||
|
||||
A chat/WeChat Hermes endpoint is not enough. A compliant engineering backend
|
||||
must:
|
||||
1. accept `aios.online-agent.runtime-request.v1`;
|
||||
2. inspect and mutate the supplied disposable worktree;
|
||||
3. leave Git HEAD unchanged;
|
||||
4. return `aios.online-agent.runtime-result.v1`.
|
||||
|
||||
The exact installed Hermes/Codex interface is host-specific and must be
|
||||
discovered before configuring the broker. No CLI flags are invented here.
|
||||
|
||||
## Activation gate
|
||||
|
||||
Do not register or enable the autonomous consumer until:
|
||||
1. a real engineering backend is found;
|
||||
2. broker is configured with that backend;
|
||||
3. `runtime_selftest.py` reports PASS;
|
||||
4. backend has no Agent Task token or writer lease.
|
||||
|
||||
After activation, complete D/E/F/G:
|
||||
- D: native intent task, no `metadata.execution`;
|
||||
- E: goal-only engineering task, runtime finds files/root cause;
|
||||
- F: unavailable runtime capability remains unclaimed;
|
||||
- G: restart during real runtime execution, no duplicate commit/branch.
|
||||
|
||||
|
||||
## OpenClaw backend candidate
|
||||
|
||||
A current OpenClaw installation can be used as an autonomous engineering
|
||||
runtime without making OpenClaw itself an AIOS Task API client.
|
||||
|
||||
The bundled wrapper uses the official headless coding entry point:
|
||||
|
||||
```text
|
||||
openclaw agent exec
|
||||
--message-file -
|
||||
--cwd <pinned-disposable-worktree>
|
||||
--model <verified-provider/model>
|
||||
--auth-env-only
|
||||
--timeout <seconds>
|
||||
--json
|
||||
```
|
||||
|
||||
Reference used for implementation:
|
||||
`https://docs.openclaw.ai/cli/agent`
|
||||
|
||||
The wrapper maps the stable OpenClaw JSON envelope into
|
||||
`aios.online-agent.runtime-result.v1`. The AIOS worker remains responsible for
|
||||
claim/heartbeat, independent diff verification and Governed GitOps.
|
||||
|
||||
OpenClaw is advertised as `agent.runtime.openclaw`, not as Hermes or Codex.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Autonomous Engineering Runtime Contract v1
|
||||
|
||||
The Online Agent Worker and the Hermes/Codex engineering runtime are separate
|
||||
security domains.
|
||||
|
||||
The worker owns:
|
||||
- Agent Task polling, atomic claim, task lease and heartbeat;
|
||||
- intent resolution and restart state;
|
||||
- authoritative repository/base selection;
|
||||
- independent diff inspection and secret checks;
|
||||
- verification command allowlisting and re-execution;
|
||||
- Governed GitOps task/lease/commit;
|
||||
- final result submission.
|
||||
|
||||
The runtime broker and Hermes/Codex backend do **not** receive:
|
||||
- Agent Task API token;
|
||||
- task lease token;
|
||||
- governed writer lease or Bridge credential;
|
||||
- authority repository write access.
|
||||
|
||||
## stdin request
|
||||
|
||||
The backend configured behind the broker receives one JSON object:
|
||||
|
||||
`schema = aios.online-agent.runtime-request.v1`
|
||||
|
||||
It contains task goal/instructions/success criteria and a pinned disposable
|
||||
worktree path. The backend may inspect and modify that worktree. It must not
|
||||
commit or push.
|
||||
|
||||
## stdout response
|
||||
|
||||
The backend returns one JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "aios.online-agent.runtime-result.v1",
|
||||
"status": "COMPLETED",
|
||||
"summary": "root cause and implementation summary",
|
||||
"verification": [
|
||||
{
|
||||
"label": "focused test",
|
||||
"argv": ["/usr/bin/python3", "-m", "pytest", "-q", "tests/test_x.py"],
|
||||
"cwd": ".",
|
||||
"timeout_seconds": 600
|
||||
}
|
||||
],
|
||||
"evidence": {
|
||||
"root_cause": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status` may also be `BLOCKED` or `FAILED`.
|
||||
|
||||
The worker ignores any backend claim about changed files and independently reads
|
||||
Git state. It also reruns verification requests through its local command
|
||||
allowlist.
|
||||
|
||||
## Real runtime requirement
|
||||
|
||||
A chat/router/WeChat Hermes service is not an engineering backend. A compliant
|
||||
backend must be able to read and mutate the disposable worktree and emit the
|
||||
runtime result schema above.
|
||||
|
||||
The exact Hermes/Codex CLI/API must be discovered from the installed runtime.
|
||||
Do not invent flags. `/etc/aios/agent-runtime-hermes.conf` contains only
|
||||
non-secret backend command configuration. Any model/runtime credential must be
|
||||
delivered to the broker/backend through its own systemd credential projection,
|
||||
never through the Online Agent worker token.
|
||||
|
||||
|
||||
## OpenClaw headless engineering backend
|
||||
|
||||
For hosts that have a current OpenClaw CLI, use the bundled
|
||||
`openclaw_engineering_backend.py`.
|
||||
|
||||
The wrapper invokes the documented headless coding entry point:
|
||||
|
||||
`openclaw agent exec --message-file - --cwd <pinned-worktree> --model <model> --auth-env-only --timeout <seconds> --json`
|
||||
|
||||
It deliberately does **not** use the Gateway/WeChat/chat route. `agent exec`
|
||||
runs an embedded, one-shot agent turn and scopes its workspace/tool working
|
||||
directory to `--cwd`.
|
||||
|
||||
Provider credentials, if required, are projected into the broker service under
|
||||
separate systemd credentials and mapped to provider environment variables only
|
||||
for the OpenClaw child. The Online Agent Task token and writer lease remain
|
||||
absent.
|
||||
|
||||
Before enabling the autonomous worker, run a disposable broker self-test that
|
||||
proves OpenClaw changes the requested worktree while Git HEAD remains at the
|
||||
base OID.
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import array
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
MAX_BYTES = 1024 * 1024
|
||||
ADAPTER = os.environ.get("AIOS_AGENT_RUNTIME_ADAPTER", "").strip()
|
||||
SOCKET_PATH = Path(os.environ.get("AIOS_AGENT_RUNTIME_SOCKET", ""))
|
||||
WORKSPACE_ROOT = Path(os.environ.get("AIOS_AGENT_RUNTIME_WORKSPACE_ROOT", ""))
|
||||
BACKEND_ARGV_JSON = os.environ.get("AIOS_AGENT_RUNTIME_BACKEND_ARGV_JSON", "")
|
||||
ALLOWED_PEER_UID = int(os.environ.get("AIOS_AGENT_RUNTIME_ALLOWED_PEER_UID", "-1"))
|
||||
ALLOWED_PEER_USER = os.environ.get("AIOS_AGENT_RUNTIME_ALLOWED_PEER_USER", "").strip()
|
||||
CONNECTION_TIMEOUT = float(os.environ.get("AIOS_AGENT_RUNTIME_CONNECTION_TIMEOUT_SECONDS", "15"))
|
||||
BACKEND_TIMEOUT = int(os.environ.get("AIOS_AGENT_RUNTIME_BACKEND_TIMEOUT_SECONDS", "3600"))
|
||||
BACKEND_ENV_ALLOWLIST = (
|
||||
"AIOS_OPENCLAW_BIN",
|
||||
"AIOS_OPENCLAW_MODEL",
|
||||
"AIOS_OPENCLAW_PROVIDER_CREDENTIAL_MAP_JSON",
|
||||
"AIOS_OPENCLAW_EXEC_TIMEOUT_SECONDS",
|
||||
)
|
||||
SENSITIVE_RE = re.compile(r"(?i)(authorization\s*:\s*bearer\s+\S+|(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)")
|
||||
|
||||
|
||||
class BrokerError(RuntimeError):
|
||||
def __init__(self, code: str, *, details: Mapping[str, Any] | None = None) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.details = dict(details or {})
|
||||
|
||||
|
||||
def peer_uid(connection: socket.socket) -> int:
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
raise BrokerError("SO_PEERCRED_UNAVAILABLE")
|
||||
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
|
||||
pid, uid, gid = array.array("i", raw[:12])
|
||||
return int(uid)
|
||||
|
||||
|
||||
def configured_peer_uid() -> int:
|
||||
if ALLOWED_PEER_UID >= 0:
|
||||
return ALLOWED_PEER_UID
|
||||
if not ALLOWED_PEER_USER:
|
||||
raise BrokerError("ALLOWED_PEER_UID_NOT_CONFIGURED")
|
||||
try:
|
||||
return int(pwd.getpwnam(ALLOWED_PEER_USER).pw_uid)
|
||||
except KeyError as exc:
|
||||
raise BrokerError("ALLOWED_PEER_USER_NOT_FOUND") from exc
|
||||
|
||||
|
||||
def authorize_peer(connection: socket.socket) -> None:
|
||||
uid = peer_uid(connection)
|
||||
expected = configured_peer_uid()
|
||||
if uid != expected:
|
||||
raise BrokerError("PEER_UID_REJECTED", details={"peer_uid": uid})
|
||||
|
||||
|
||||
def read_request(connection: socket.socket) -> dict[str, Any]:
|
||||
connection.settimeout(CONNECTION_TIMEOUT)
|
||||
total = 0
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
try:
|
||||
chunk = connection.recv(65536)
|
||||
except socket.timeout as exc:
|
||||
raise BrokerError("REQUEST_TIMEOUT") from exc
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_BYTES:
|
||||
raise BrokerError("REQUEST_TOO_LARGE")
|
||||
chunks.append(chunk)
|
||||
try:
|
||||
value = json.loads(b"".join(chunks).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BrokerError("REQUEST_INVALID_JSON") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise BrokerError("REQUEST_OBJECT_REQUIRED")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def backend_argv() -> list[str]:
|
||||
try:
|
||||
value = json.loads(BACKEND_ARGV_JSON)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise BrokerError("BACKEND_ARGV_JSON_INVALID") from exc
|
||||
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item for item in value):
|
||||
raise BrokerError("BACKEND_ARGV_INVALID")
|
||||
executable = Path(value[0])
|
||||
if not executable.is_absolute():
|
||||
raise BrokerError("BACKEND_EXECUTABLE_INVALID")
|
||||
try:
|
||||
resolved = executable.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise BrokerError("BACKEND_EXECUTABLE_INVALID") from exc
|
||||
if not resolved.is_file():
|
||||
raise BrokerError("BACKEND_EXECUTABLE_INVALID")
|
||||
return list(value)
|
||||
|
||||
|
||||
def validate_request(value: Mapping[str, Any]) -> tuple[dict[str, Any], int]:
|
||||
if value.get("schema") != "aios.online-agent.runtime-request.v1":
|
||||
raise BrokerError("REQUEST_SCHEMA_INVALID")
|
||||
if value.get("adapter") != ADAPTER or ADAPTER not in {"hermes", "codex", "openclaw"}:
|
||||
raise BrokerError("REQUEST_ADAPTER_INVALID")
|
||||
context = value.get("execution_context")
|
||||
policy = value.get("policy")
|
||||
if not isinstance(context, Mapping) or not isinstance(policy, Mapping):
|
||||
raise BrokerError("REQUEST_CONTEXT_INVALID")
|
||||
worktree_raw = context.get("worktree")
|
||||
if not isinstance(worktree_raw, str):
|
||||
raise BrokerError("REQUEST_WORKTREE_INVALID")
|
||||
for field in (
|
||||
"authoritative_repository_write",
|
||||
"direct_git_ref_mutation",
|
||||
"direct_git_push",
|
||||
"direct_git_commit",
|
||||
"main_write",
|
||||
"force_push",
|
||||
"production_write",
|
||||
"tag_write",
|
||||
"baseline_write",
|
||||
"task_api_credential_visible",
|
||||
"writer_lease_visible",
|
||||
):
|
||||
if policy.get(field) is not False:
|
||||
raise BrokerError("REQUEST_POLICY_REJECTED", details={"field": field})
|
||||
|
||||
root = WORKSPACE_ROOT.resolve()
|
||||
candidate = Path(worktree_raw)
|
||||
if not candidate.is_absolute():
|
||||
raise BrokerError("REQUEST_WORKTREE_INVALID")
|
||||
parent = candidate.parent.resolve()
|
||||
if root == parent or root not in parent.parents:
|
||||
raise BrokerError("REQUEST_WORKTREE_OUT_OF_SCOPE")
|
||||
|
||||
flags = getattr(os, "O_PATH", os.O_RDONLY) | getattr(os, "O_NOFOLLOW", 0) | os.O_DIRECTORY
|
||||
try:
|
||||
fd = os.open(str(candidate), flags)
|
||||
except OSError as exc:
|
||||
raise BrokerError("REQUEST_WORKTREE_OPEN_FAILED") from exc
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
os.close(fd)
|
||||
raise BrokerError("REQUEST_WORKTREE_NOT_DIRECTORY")
|
||||
pinned = Path(f"/proc/self/fd/{fd}")
|
||||
try:
|
||||
resolved = pinned.resolve(strict=True)
|
||||
except OSError:
|
||||
os.close(fd)
|
||||
raise BrokerError("REQUEST_WORKTREE_PIN_FAILED")
|
||||
if root not in resolved.parents:
|
||||
os.close(fd)
|
||||
raise BrokerError("REQUEST_WORKTREE_OUT_OF_SCOPE")
|
||||
return dict(value), fd
|
||||
|
||||
|
||||
def sanitize(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, inner in value.items():
|
||||
lowered = str(key).lower()
|
||||
if any(marker in lowered for marker in ("token", "secret", "credential", "authorization", "password", "api_key", "apikey", "access_key")):
|
||||
cleaned[str(key)] = "[REDACTED]"
|
||||
else:
|
||||
cleaned[str(key)] = sanitize(inner)
|
||||
return cleaned
|
||||
if isinstance(value, list):
|
||||
return [sanitize(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return SENSITIVE_RE.sub("[REDACTED]", value)
|
||||
return value
|
||||
|
||||
|
||||
def sanitized_failure(code: str, details: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
safe: dict[str, Any] = {}
|
||||
for key, value in dict(details or {}).items():
|
||||
lowered = str(key).lower()
|
||||
if any(marker in lowered for marker in ("token", "secret", "credential", "authorization", "password", "api_key", "apikey", "access_key")):
|
||||
safe[str(key)] = "[REDACTED]"
|
||||
else:
|
||||
safe[str(key)] = value
|
||||
return {
|
||||
"schema": "aios.online-agent.runtime-result.v1",
|
||||
"status": "FAILED",
|
||||
"summary": code,
|
||||
"verification": [],
|
||||
"evidence": safe,
|
||||
}
|
||||
|
||||
|
||||
def execute_backend(request: Mapping[str, Any], worktree_fd: int) -> dict[str, Any]:
|
||||
argv = backend_argv()
|
||||
request = dict(request)
|
||||
context = dict(request["execution_context"])
|
||||
# Pass a pinned inode-backed path, not the original user-visible pathname.
|
||||
context["worktree"] = f"/proc/self/fd/{worktree_fd}"
|
||||
request["execution_context"] = context
|
||||
payload = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
environment = {
|
||||
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"AIOS_AGENT_RUNTIME_ADAPTER": ADAPTER,
|
||||
}
|
||||
for name in BACKEND_ENV_ALLOWLIST:
|
||||
value = os.environ.get(name)
|
||||
if value is not None:
|
||||
environment[name] = value
|
||||
credentials_dir = os.environ.get("CREDENTIALS_DIRECTORY")
|
||||
if credentials_dir:
|
||||
environment["CREDENTIALS_DIRECTORY"] = credentials_dir
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
input=payload,
|
||||
cwd=f"/proc/self/fd/{worktree_fd}",
|
||||
pass_fds=(worktree_fd,),
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=BACKEND_TIMEOUT,
|
||||
env=environment,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return sanitized_failure("AGENT_BACKEND_TIMEOUT")
|
||||
except OSError as exc:
|
||||
return sanitized_failure("AGENT_BACKEND_EXEC_FAILED", {"error": type(exc).__name__})
|
||||
if completed.returncode != 0:
|
||||
return sanitized_failure("AGENT_BACKEND_FAILED", {"exit_code": completed.returncode})
|
||||
if len(completed.stdout) > MAX_BYTES:
|
||||
return sanitized_failure("AGENT_BACKEND_RESULT_TOO_LARGE")
|
||||
try:
|
||||
value = json.loads(completed.stdout.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return sanitized_failure("AGENT_BACKEND_RESULT_INVALID")
|
||||
if not isinstance(value, Mapping) or value.get("schema") != "aios.online-agent.runtime-result.v1":
|
||||
return sanitized_failure("AGENT_BACKEND_RESULT_SCHEMA_INVALID")
|
||||
sanitized = sanitize(dict(value))
|
||||
return sanitized if isinstance(sanitized, dict) else sanitized_failure("AGENT_BACKEND_RESULT_INVALID")
|
||||
|
||||
|
||||
def serve_once(listener: socket.socket) -> None:
|
||||
connection, _ = listener.accept()
|
||||
worktree_fd: int | None = None
|
||||
try:
|
||||
authorize_peer(connection)
|
||||
request = read_request(connection)
|
||||
request, worktree_fd = validate_request(request)
|
||||
response = execute_backend(request, worktree_fd)
|
||||
except BrokerError as exc:
|
||||
response = sanitized_failure(exc.code, exc.details)
|
||||
except Exception as exc:
|
||||
response = sanitized_failure("BROKER_INTERNAL_ERROR", {"error": type(exc).__name__})
|
||||
finally:
|
||||
if worktree_fd is not None:
|
||||
os.close(worktree_fd)
|
||||
raw = json.dumps(response, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
try:
|
||||
connection.settimeout(CONNECTION_TIMEOUT)
|
||||
connection.sendall(raw[:MAX_BYTES])
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if ADAPTER not in {"hermes", "codex", "openclaw"}:
|
||||
raise BrokerError("BROKER_ADAPTER_INVALID")
|
||||
if not SOCKET_PATH.is_absolute() or not WORKSPACE_ROOT.is_absolute():
|
||||
raise BrokerError("BROKER_PATH_INVALID")
|
||||
SOCKET_PATH.parent.mkdir(parents=True, mode=0o750, exist_ok=True)
|
||||
if SOCKET_PATH.exists() or SOCKET_PATH.is_symlink():
|
||||
SOCKET_PATH.unlink()
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
listener.bind(str(SOCKET_PATH))
|
||||
os.chmod(SOCKET_PATH, 0o660)
|
||||
listener.listen(8)
|
||||
try:
|
||||
while True:
|
||||
serve_once(listener)
|
||||
finally:
|
||||
listener.close()
|
||||
if SOCKET_PATH.exists():
|
||||
SOCKET_PATH.unlink()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenClaw engineering backend for the AIOS autonomous runtime broker.
|
||||
|
||||
This process is intentionally *not* an Online Agent Task worker. It receives
|
||||
one secret-free `aios.online-agent.runtime-request.v1` object on stdin, invokes
|
||||
OpenClaw's headless `agent exec` against the pinned disposable worktree, and
|
||||
returns one `aios.online-agent.runtime-result.v1` object on stdout.
|
||||
|
||||
Online Task claim/heartbeat/result and Governed GitOps remain owned by the
|
||||
AIOS autonomous worker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
REQUEST_SCHEMA = "aios.online-agent.runtime-request.v1"
|
||||
RESULT_SCHEMA = "aios.online-agent.runtime-result.v1"
|
||||
MAX_INPUT_BYTES = 1024 * 1024
|
||||
MAX_OUTPUT_BYTES = 1024 * 1024
|
||||
DEFAULT_TIMEOUT = 1800
|
||||
ALLOWED_ADAPTERS = frozenset({"hermes", "codex", "openclaw"})
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
|
||||
re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"),
|
||||
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
||||
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||||
)
|
||||
|
||||
|
||||
class BackendError(RuntimeError):
|
||||
def __init__(self, code: str, *, details: Mapping[str, Any] | None = None) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.details = dict(details or {})
|
||||
|
||||
|
||||
def redact_text(value: str, limit: int = 8192) -> str:
|
||||
value = value[:limit]
|
||||
for pattern in SECRET_PATTERNS:
|
||||
value = pattern.sub("[REDACTED]", value)
|
||||
return value
|
||||
|
||||
|
||||
def result(
|
||||
status: str,
|
||||
summary: str,
|
||||
*,
|
||||
verification: list[dict[str, Any]] | None = None,
|
||||
evidence: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": RESULT_SCHEMA,
|
||||
"status": status,
|
||||
"summary": redact_text(summary) or status,
|
||||
"verification": verification or [],
|
||||
"evidence": dict(evidence or {}),
|
||||
}
|
||||
|
||||
|
||||
def emit(value: Mapping[str, Any]) -> int:
|
||||
raw = json.dumps(dict(value), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
sys.stdout.write(raw)
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
return 0
|
||||
|
||||
|
||||
def read_request() -> dict[str, Any]:
|
||||
raw = sys.stdin.buffer.read(MAX_INPUT_BYTES + 1)
|
||||
if len(raw) > MAX_INPUT_BYTES:
|
||||
raise BackendError("REQUEST_TOO_LARGE")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BackendError("REQUEST_INVALID_JSON") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise BackendError("REQUEST_OBJECT_REQUIRED")
|
||||
if value.get("schema") != REQUEST_SCHEMA:
|
||||
raise BackendError("REQUEST_SCHEMA_INVALID")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def nonempty(value: Any, field: str, limit: int) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise BackendError(f"{field.upper()}_INVALID")
|
||||
value = value.strip()
|
||||
if not value or len(value) > limit:
|
||||
raise BackendError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def string_list(value: Any, field: str) -> list[str]:
|
||||
if not isinstance(value, list) or not value or len(value) > 64:
|
||||
raise BackendError(f"{field.upper()}_INVALID")
|
||||
result_value: list[str] = []
|
||||
for item in value:
|
||||
result_value.append(nonempty(item, field, 4096))
|
||||
return result_value
|
||||
|
||||
|
||||
def validate_request(value: Mapping[str, Any]) -> tuple[dict[str, Any], Path]:
|
||||
adapter = str(value.get("adapter") or "")
|
||||
if adapter not in ALLOWED_ADAPTERS:
|
||||
raise BackendError("ADAPTER_INVALID")
|
||||
|
||||
task = value.get("task")
|
||||
context = value.get("execution_context")
|
||||
policy = value.get("policy")
|
||||
if not isinstance(task, Mapping) or not isinstance(context, Mapping) or not isinstance(policy, Mapping):
|
||||
raise BackendError("REQUEST_CONTEXT_INVALID")
|
||||
|
||||
task_id = nonempty(task.get("task_id"), "task_id", 180)
|
||||
goal = nonempty(task.get("goal"), "goal", 16384)
|
||||
instructions = nonempty(task.get("instructions"), "instructions", 65536)
|
||||
success = string_list(task.get("success_criteria"), "success_criteria")
|
||||
|
||||
worktree_raw = context.get("worktree")
|
||||
if not isinstance(worktree_raw, str) or not worktree_raw.startswith("/proc/self/fd/"):
|
||||
raise BackendError("PINNED_WORKTREE_REQUIRED")
|
||||
worktree = Path(worktree_raw)
|
||||
try:
|
||||
resolved = worktree.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise BackendError("PINNED_WORKTREE_UNAVAILABLE") from exc
|
||||
if not resolved.is_dir():
|
||||
raise BackendError("PINNED_WORKTREE_INVALID")
|
||||
|
||||
for field in (
|
||||
"authoritative_repository_write",
|
||||
"direct_git_ref_mutation",
|
||||
"direct_git_push",
|
||||
"direct_git_commit",
|
||||
"main_write",
|
||||
"force_push",
|
||||
"production_write",
|
||||
"tag_write",
|
||||
"baseline_write",
|
||||
"task_api_credential_visible",
|
||||
"writer_lease_visible",
|
||||
):
|
||||
if policy.get(field) is not False:
|
||||
raise BackendError("REQUEST_POLICY_REJECTED", details={"field": field})
|
||||
|
||||
return {
|
||||
"adapter": adapter,
|
||||
"task_id": task_id,
|
||||
"goal": goal,
|
||||
"instructions": instructions,
|
||||
"success_criteria": success,
|
||||
"repository": str(context.get("repository") or ""),
|
||||
"base_oid": str(context.get("base_oid") or ""),
|
||||
"target_branch": str(context.get("target_branch") or ""),
|
||||
}, worktree
|
||||
|
||||
|
||||
def openclaw_binary() -> Path:
|
||||
configured = os.environ.get("AIOS_OPENCLAW_BIN", "").strip()
|
||||
if not configured:
|
||||
raise BackendError("OPENCLAW_BINARY_NOT_CONFIGURED")
|
||||
path = Path(configured)
|
||||
if not path.is_absolute():
|
||||
raise BackendError("OPENCLAW_BINARY_INVALID")
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise BackendError("OPENCLAW_BINARY_UNAVAILABLE") from exc
|
||||
if not resolved.is_file() or not os.access(resolved, os.X_OK):
|
||||
raise BackendError("OPENCLAW_BINARY_UNAVAILABLE")
|
||||
return resolved
|
||||
|
||||
|
||||
def provider_environment() -> dict[str, str]:
|
||||
"""Build an environment containing only explicitly projected provider auth.
|
||||
|
||||
`--auth-env-only` tells OpenClaw not to load ambient config/auth stores.
|
||||
The mapping is non-secret; values are read from systemd credential files.
|
||||
"""
|
||||
raw = os.environ.get("AIOS_OPENCLAW_PROVIDER_CREDENTIAL_MAP_JSON", "{}")
|
||||
try:
|
||||
mapping = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise BackendError("PROVIDER_CREDENTIAL_MAP_INVALID") from exc
|
||||
if not isinstance(mapping, Mapping):
|
||||
raise BackendError("PROVIDER_CREDENTIAL_MAP_INVALID")
|
||||
|
||||
credentials_directory = os.environ.get("CREDENTIALS_DIRECTORY", "").strip()
|
||||
environment = {
|
||||
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"NO_COLOR": "1",
|
||||
# Prevent optional login-shell env snapshot import into exec runs.
|
||||
"OPENCLAW_EXEC_SHELL_SNAPSHOT": "0",
|
||||
}
|
||||
if mapping:
|
||||
if not credentials_directory:
|
||||
raise BackendError("PROVIDER_CREDENTIAL_DIRECTORY_UNAVAILABLE")
|
||||
credential_root = Path(credentials_directory)
|
||||
for env_name, credential_name in mapping.items():
|
||||
if (
|
||||
not isinstance(env_name, str)
|
||||
or not re.fullmatch(r"[A-Z][A-Z0-9_]{2,63}", env_name)
|
||||
or not isinstance(credential_name, str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9._-]{1,128}", credential_name)
|
||||
):
|
||||
raise BackendError("PROVIDER_CREDENTIAL_MAP_INVALID")
|
||||
path = credential_root / credential_name
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise BackendError("PROVIDER_CREDENTIAL_UNAVAILABLE", details={"name": credential_name})
|
||||
secret = path.read_text(encoding="utf-8").strip()
|
||||
if not secret:
|
||||
raise BackendError("PROVIDER_CREDENTIAL_EMPTY", details={"name": credential_name})
|
||||
environment[env_name] = secret
|
||||
return environment
|
||||
|
||||
|
||||
def build_prompt(task: Mapping[str, Any]) -> str:
|
||||
criteria = "\n".join(f"- {item}" for item in task["success_criteria"])
|
||||
return f"""You are the autonomous engineering runtime for AIOS task {task['task_id']}.
|
||||
|
||||
Repository: {task['repository']}
|
||||
Base OID: {task['base_oid']}
|
||||
Target branch (owned by AIOS Governed GitOps, not by you): {task['target_branch']}
|
||||
|
||||
Goal:
|
||||
{task['goal']}
|
||||
|
||||
Instructions:
|
||||
{task['instructions']}
|
||||
|
||||
Success criteria:
|
||||
{criteria}
|
||||
|
||||
Execution boundary:
|
||||
- Work only inside the current disposable worktree.
|
||||
- Inspect the repository yourself and determine the root cause and files to change.
|
||||
- Implement the smallest correct fix.
|
||||
- Run focused tests and repair failures when feasible.
|
||||
- Do NOT run `git commit`, `git push`, `git update-ref`, create branches/tags, or modify authoritative refs.
|
||||
- Do NOT attempt to access AIOS Agent Task credentials, writer leases, or Governed GitOps credentials.
|
||||
- Leave Git HEAD unchanged; AIOS independently inspects the diff, reruns verification, and performs the governed commit.
|
||||
- In your final answer, concisely state root cause, files changed, and tests you ran.
|
||||
"""
|
||||
|
||||
|
||||
def openclaw_timeout() -> int:
|
||||
raw = os.environ.get("AIOS_OPENCLAW_EXEC_TIMEOUT_SECONDS", str(DEFAULT_TIMEOUT))
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError as exc:
|
||||
raise BackendError("OPENCLAW_TIMEOUT_INVALID") from exc
|
||||
if not 60 <= value <= 7200:
|
||||
raise BackendError("OPENCLAW_TIMEOUT_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def invoke_openclaw(task: Mapping[str, Any], worktree: Path) -> dict[str, Any]:
|
||||
binary = openclaw_binary()
|
||||
model = nonempty(os.environ.get("AIOS_OPENCLAW_MODEL"), "openclaw_model", 256)
|
||||
timeout_seconds = openclaw_timeout()
|
||||
prompt = build_prompt(task)
|
||||
|
||||
argv = [
|
||||
str(binary),
|
||||
"agent",
|
||||
"exec",
|
||||
"--message-file",
|
||||
"-",
|
||||
"--cwd",
|
||||
str(worktree),
|
||||
"--model",
|
||||
model,
|
||||
"--auth-env-only",
|
||||
"--timeout",
|
||||
str(timeout_seconds),
|
||||
"--json",
|
||||
]
|
||||
|
||||
try:
|
||||
pinned_fd = int(worktree.name)
|
||||
except ValueError as exc:
|
||||
raise BackendError("PINNED_WORKTREE_FD_INVALID") from exc
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
input=prompt.encode("utf-8"),
|
||||
cwd=str(worktree),
|
||||
pass_fds=(pinned_fd,),
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=timeout_seconds + 90,
|
||||
env=provider_environment(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return result(
|
||||
"FAILED",
|
||||
"OPENCLAW_HARD_TIMEOUT",
|
||||
evidence={"backend": "openclaw-agent-exec", "timeout_seconds": timeout_seconds},
|
||||
)
|
||||
except OSError as exc:
|
||||
return result(
|
||||
"FAILED",
|
||||
"OPENCLAW_EXEC_FAILED",
|
||||
evidence={"backend": "openclaw-agent-exec", "error": type(exc).__name__},
|
||||
)
|
||||
|
||||
stdout = completed.stdout[:MAX_OUTPUT_BYTES]
|
||||
try:
|
||||
envelope = json.loads(stdout.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return result(
|
||||
"FAILED",
|
||||
"OPENCLAW_RESULT_INVALID",
|
||||
evidence={
|
||||
"backend": "openclaw-agent-exec",
|
||||
"exit_code": completed.returncode,
|
||||
"stderr_sha256": __import__("hashlib").sha256(completed.stderr).hexdigest(),
|
||||
},
|
||||
)
|
||||
|
||||
if not isinstance(envelope, Mapping):
|
||||
return result("FAILED", "OPENCLAW_RESULT_INVALID", evidence={"backend": "openclaw-agent-exec"})
|
||||
|
||||
ok = envelope.get("ok") is True and envelope.get("status") == "ok" and completed.returncode == 0
|
||||
final_text = str(envelope.get("final") or "").strip()
|
||||
evidence = {
|
||||
"backend": "openclaw-agent-exec",
|
||||
"openclaw_status": envelope.get("status"),
|
||||
"exit_code": completed.returncode,
|
||||
"model": envelope.get("model"),
|
||||
"provider": envelope.get("provider"),
|
||||
"session_id_present": bool(envelope.get("sessionId")),
|
||||
"code_mode_engaged": envelope.get("codeModeEngaged"),
|
||||
"assistant_turns": envelope.get("assistantTurns"),
|
||||
"tool_summary": envelope.get("toolSummary") if isinstance(envelope.get("toolSummary"), Mapping) else {},
|
||||
}
|
||||
|
||||
if not ok:
|
||||
error = envelope.get("error")
|
||||
if isinstance(error, Mapping):
|
||||
evidence["error_kind"] = error.get("kind")
|
||||
summary = "OPENCLAW_" + str(envelope.get("status") or "ERROR").upper()
|
||||
return result("FAILED", summary, evidence=evidence)
|
||||
|
||||
summary = final_text or "OpenClaw completed the engineering turn."
|
||||
return result("COMPLETED", summary, evidence=evidence)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
request = read_request()
|
||||
task, worktree = validate_request(request)
|
||||
return emit(invoke_openclaw(task, worktree))
|
||||
except BackendError as exc:
|
||||
return emit(result("BLOCKED", exc.code, evidence=exc.details))
|
||||
except Exception as exc:
|
||||
return emit(result("FAILED", "OPENCLAW_BACKEND_INTERNAL_ERROR", evidence={"error": type(exc).__name__}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,822 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import grp
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from intent_runtime import IntentError, Resolution, is_claimable_intent, resolve
|
||||
from runtime_client import RuntimeErrorV2, collect_changes, invoke, request_payload, scan_secrets
|
||||
|
||||
|
||||
API_BASE = os.environ.get("AIOS_AGENT_TASK_API_URL", "http://127.0.0.1:18940").rstrip("/")
|
||||
BRIDGE_BASE = os.environ.get("AIOS_GOVERNED_WRITE_API_URL", "http://127.0.0.1:19998").rstrip("/")
|
||||
AGENT_ID = os.environ.get("AIOS_AGENT_ID", "online-engineering-worker-cn-network-mkt-autonomous-20260815")
|
||||
AGENT_VERSION = os.environ.get("AIOS_AGENT_VERSION", "2026-08-15.4-autonomous-intent")
|
||||
REPOSITORY = os.environ.get("AIOS_AUTONOMOUS_REPOSITORY", "aios/aios")
|
||||
BASE_REF = os.environ.get("AIOS_AUTONOMOUS_BASE_REF", "refs/heads/main")
|
||||
AUTHORITY_GIT_DIR = Path(os.environ.get("AIOS_AUTHORITY_GIT_DIR", "/data/aios-gitops/repo.git"))
|
||||
STATE_ROOT = Path(os.environ.get("AIOS_AUTONOMOUS_STATE_ROOT", "/var/lib/aios-autonomous-agent-worker"))
|
||||
WORKSPACE_ROOT = Path(os.environ.get("AIOS_AUTONOMOUS_WORKSPACE_ROOT", str(STATE_ROOT / "workspaces")))
|
||||
SHARED_GROUP = os.environ.get("AIOS_AGENT_RUNTIME_SHARED_GROUP", "aios-agent-runtime")
|
||||
DEFAULT_ADAPTER = os.environ.get("AIOS_AUTONOMOUS_DEFAULT_ADAPTER", "hermes")
|
||||
HERMES_SOCKET = Path(os.environ.get("AIOS_HERMES_BROKER_SOCKET", "/run/aios-agent-runtime/hermes.sock"))
|
||||
CODEX_SOCKET = Path(os.environ.get("AIOS_CODEX_BROKER_SOCKET", "/run/aios-agent-runtime/codex.sock"))
|
||||
OPENCLAW_SOCKET = Path(os.environ.get("AIOS_OPENCLAW_BROKER_SOCKET", "/run/aios-agent-runtime/openclaw.sock"))
|
||||
POLL_SECONDS = int(os.environ.get("AIOS_AUTONOMOUS_POLL_SECONDS", "5"))
|
||||
HEARTBEAT_SECONDS = int(os.environ.get("AIOS_AUTONOMOUS_HEARTBEAT_SECONDS", "10"))
|
||||
LEASE_SECONDS = int(os.environ.get("AIOS_AUTONOMOUS_LEASE_SECONDS", "300"))
|
||||
RUNTIME_TIMEOUT_SECONDS = int(os.environ.get("AIOS_AUTONOMOUS_RUNTIME_TIMEOUT_SECONDS", "3600"))
|
||||
MAX_OUTPUT_BYTES = 64 * 1024
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SENSITIVE_RE = re.compile(
|
||||
r"(?i)(authorization\s*:\s*bearer\s+\S+|(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)"
|
||||
)
|
||||
|
||||
|
||||
class WorkerError(RuntimeError):
|
||||
def __init__(self, code: str, *, phase: str, details: Mapping[str, Any] | None = None) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.phase = phase
|
||||
self.details = dict(details or {})
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def sha256_json(value: Any) -> str:
|
||||
return sha256_bytes(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8"))
|
||||
|
||||
|
||||
def sanitize(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
result: dict[str, Any] = {}
|
||||
for key, inner in value.items():
|
||||
lowered = str(key).lower()
|
||||
if any(word in lowered for word in ("token", "secret", "credential", "authorization", "password", "api_key", "apikey", "access_key")):
|
||||
result[str(key)] = "[REDACTED]"
|
||||
else:
|
||||
result[str(key)] = sanitize(inner)
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [sanitize(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [sanitize(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return SENSITIVE_RE.sub("[REDACTED]", value)
|
||||
return value
|
||||
|
||||
|
||||
def audit(event_type: str, **fields: Any) -> None:
|
||||
STATE_ROOT.mkdir(parents=True, mode=0o750, exist_ok=True)
|
||||
path = STATE_ROOT / "events.jsonl"
|
||||
record = {
|
||||
"event_type": event_type,
|
||||
"created_at": now(),
|
||||
"agent_id": AGENT_ID,
|
||||
"agent_version": AGENT_VERSION,
|
||||
"credential_material_exposed": False,
|
||||
"writer_lease_material_exposed": False,
|
||||
**sanitize(fields),
|
||||
}
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
os.chmod(path, 0o640)
|
||||
print(json.dumps(record, ensure_ascii=False, sort_keys=True), flush=True)
|
||||
|
||||
|
||||
def atomic_json(path: Path, value: Mapping[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, mode=0o750, exist_ok=True)
|
||||
if path.parent.is_symlink() or path.is_symlink():
|
||||
raise WorkerError("STATE_PATH_SYMLINK_REJECTED", phase="state")
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
||||
try:
|
||||
os.fchmod(fd, 0o640)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(sanitize(dict(value)), handle, ensure_ascii=False, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def state_path(task_id: str) -> Path:
|
||||
safe = re.sub(r"[^A-Za-z0-9._:-]+", "-", task_id)[:180]
|
||||
return STATE_ROOT / "tasks" / f"{safe}.json"
|
||||
|
||||
|
||||
def load_state(task_id: str) -> dict[str, Any] | None:
|
||||
path = state_path(task_id)
|
||||
if not path.is_file() or path.is_symlink():
|
||||
return None
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def save_state(task_id: str, value: Mapping[str, Any]) -> None:
|
||||
atomic_json(state_path(task_id), value)
|
||||
|
||||
|
||||
def api_call(method: str, route: str, token: str, body: Mapping[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
|
||||
payload = None if body is None else json.dumps(dict(body), ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
headers = {"Authorization": "Bearer " + token, "Accept": "application/json"}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(API_BASE + route, data=payload, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
value = json.loads(response.read().decode("utf-8"))
|
||||
return response.status, value if isinstance(value, dict) else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
value = json.loads(exc.read(4096).decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
value = {"error": "HTTP_ERROR"}
|
||||
return exc.code, value if isinstance(value, dict) else {"error": "HTTP_ERROR"}
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
return 599, {"error": "AGENT_TASK_API_UNAVAILABLE"}
|
||||
|
||||
|
||||
def bridge_call(route: str, body: Mapping[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||
if BRIDGE_BASE != "http://127.0.0.1:19998":
|
||||
raise WorkerError(
|
||||
"NON_STAGING_BRIDGE_REJECTED",
|
||||
phase="startup",
|
||||
details={"bridge_base": BRIDGE_BASE},
|
||||
)
|
||||
payload = json.dumps(dict(body), ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
BRIDGE_BASE + route,
|
||||
data=payload,
|
||||
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
value = json.loads(response.read().decode("utf-8"))
|
||||
return response.status, value if isinstance(value, dict) else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
value = json.loads(exc.read(4096).decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
value = {"error": "HTTP_ERROR"}
|
||||
return exc.code, value if isinstance(value, dict) else {"error": "HTTP_ERROR"}
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
return 599, {"error": "GOVERNED_WRITE_API_UNAVAILABLE"}
|
||||
|
||||
|
||||
def load_agent_token() -> str:
|
||||
directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", ""))
|
||||
path = directory / "agent_token"
|
||||
if not path.is_file():
|
||||
raise WorkerError("AGENT_RUNTIME_CREDENTIAL_UNAVAILABLE", phase="startup")
|
||||
token = path.read_text(encoding="utf-8").strip()
|
||||
if len(token) < 24:
|
||||
raise WorkerError("AGENT_RUNTIME_CREDENTIAL_INVALID", phase="startup")
|
||||
return token
|
||||
|
||||
|
||||
def available_adapters() -> tuple[str, ...]:
|
||||
values: list[str] = []
|
||||
for name, path in (("hermes", HERMES_SOCKET), ("codex", CODEX_SOCKET), ("openclaw", OPENCLAW_SOCKET)):
|
||||
try:
|
||||
if path.is_socket():
|
||||
values.append(name)
|
||||
except OSError:
|
||||
pass
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def capabilities() -> tuple[str, ...]:
|
||||
values = {
|
||||
"online_agent_task_api", "task.poll", "task.claim", "task.heartbeat", "task.result",
|
||||
"intent.task", "engineering.consumer", "engineering.execute", "engineering.autonomous",
|
||||
"workspace.worktree", "git.read", "git.diff", "git.test",
|
||||
"governed.write.branch", "governed.write.file",
|
||||
}
|
||||
values.update(f"agent.runtime.{name}" for name in available_adapters())
|
||||
return tuple(sorted(values))
|
||||
|
||||
|
||||
class LeaseHeartbeat:
|
||||
def __init__(self, task_id: str, agent_token: str, lease_token: str) -> None:
|
||||
self.task_id = task_id
|
||||
self.agent_token = agent_token
|
||||
self.lease_token = lease_token
|
||||
self.stage = "claimed"
|
||||
self.failed: str | None = None
|
||||
self.stop = threading.Event()
|
||||
self.thread = threading.Thread(target=self._run, daemon=True, name=f"heartbeat-{task_id}")
|
||||
|
||||
def set_stage(self, stage: str) -> None:
|
||||
self.stage = stage
|
||||
audit("TASK_PROGRESS_STAGE", task_id=self.task_id, stage=stage)
|
||||
|
||||
def send(self) -> None:
|
||||
status, response = api_call(
|
||||
"POST",
|
||||
f"/v1/tasks/{self.task_id}/heartbeat",
|
||||
self.agent_token,
|
||||
{
|
||||
"lease_token": self.lease_token,
|
||||
"lease_seconds": LEASE_SECONDS,
|
||||
"progress": {
|
||||
"stage": self.stage,
|
||||
"event_type": "TASK_STARTED" if self.stage == "started" else "TASK_PROGRESS",
|
||||
"execution_mode": "autonomous-intent-v2",
|
||||
"production_side_effects": False,
|
||||
"main_write": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
if 200 <= status < 300:
|
||||
audit(
|
||||
"TASK_HEARTBEAT",
|
||||
task_id=self.task_id,
|
||||
stage=self.stage,
|
||||
api_status=status,
|
||||
task_status=response.get("status"),
|
||||
lease_expires_at=response.get("lease_expires_at"),
|
||||
)
|
||||
else:
|
||||
self.failed = str(response.get("error") or status)
|
||||
audit("TASK_HEARTBEAT_FAILED", task_id=self.task_id, stage=self.stage, error=self.failed)
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self.stop.wait(HEARTBEAT_SECONDS):
|
||||
try:
|
||||
self.send()
|
||||
except Exception as exc:
|
||||
self.failed = type(exc).__name__
|
||||
audit("TASK_HEARTBEAT_FAILED", task_id=self.task_id, error=self.failed)
|
||||
|
||||
def start(self) -> None:
|
||||
self.thread.start()
|
||||
|
||||
def close(self) -> None:
|
||||
self.stop.set()
|
||||
self.thread.join(timeout=3)
|
||||
|
||||
|
||||
def ensure_roots() -> None:
|
||||
if WORKSPACE_ROOT.is_symlink() or STATE_ROOT.is_symlink() or AUTHORITY_GIT_DIR.is_symlink():
|
||||
raise WorkerError("ROOT_SYMLINK_REJECTED", phase="startup")
|
||||
if not AUTHORITY_GIT_DIR.is_dir():
|
||||
raise WorkerError("AUTHORITY_GIT_DIR_UNAVAILABLE", phase="startup")
|
||||
STATE_ROOT.mkdir(parents=True, mode=0o750, exist_ok=True)
|
||||
WORKSPACE_ROOT.mkdir(parents=True, mode=0o750, exist_ok=True)
|
||||
|
||||
|
||||
def run_git(args: Sequence[str], *, cwd: Path, timeout: int = 180) -> subprocess.CompletedProcess[bytes]:
|
||||
completed = subprocess.run(
|
||||
["/usr/bin/git", *args],
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise WorkerError(
|
||||
"GIT_COMMAND_FAILED",
|
||||
phase="workspace",
|
||||
details={"operation": args[0] if args else "", "exit_code": completed.returncode},
|
||||
)
|
||||
return completed
|
||||
|
||||
|
||||
def prepare_worktree(task_id: str, base_oid: str) -> tuple[Path, Path]:
|
||||
ensure_roots()
|
||||
run_root = Path(tempfile.mkdtemp(prefix=f"{task_id[:48]}-", dir=str(WORKSPACE_ROOT)))
|
||||
os.chmod(run_root, 0o750)
|
||||
clone = run_root / "repository"
|
||||
worktree = run_root / "worktree"
|
||||
run_git(["clone", "--no-local", "--no-hardlinks", str(AUTHORITY_GIT_DIR), str(clone)], cwd=run_root, timeout=300)
|
||||
run_git(["-C", str(clone), "rev-parse", "--verify", f"{base_oid}^{{commit}}"], cwd=run_root)
|
||||
run_git(["-C", str(clone), "worktree", "add", "--detach", str(worktree), base_oid], cwd=run_root, timeout=300)
|
||||
return run_root, worktree
|
||||
|
||||
|
||||
def share_run_root(run_root: Path) -> None:
|
||||
try:
|
||||
gid = grp.getgrnam(SHARED_GROUP).gr_gid
|
||||
except KeyError as exc:
|
||||
raise WorkerError("RUNTIME_SHARED_GROUP_UNAVAILABLE", phase="workspace") from exc
|
||||
root = run_root.resolve()
|
||||
if not root.is_dir() or run_root.is_symlink():
|
||||
raise WorkerError("RUNTIME_RUN_ROOT_INVALID", phase="workspace")
|
||||
for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
|
||||
current_path = Path(current)
|
||||
for name in dirs + files:
|
||||
candidate = current_path / name
|
||||
if candidate.is_symlink():
|
||||
raise WorkerError(
|
||||
"RUNTIME_WORKSPACE_SYMLINK_REJECTED",
|
||||
phase="workspace",
|
||||
details={"path": str(candidate.relative_to(root))},
|
||||
)
|
||||
for candidate in [current_path, *(current_path / name for name in dirs), *(current_path / name for name in files)]:
|
||||
st = candidate.stat()
|
||||
os.chown(candidate, -1, gid)
|
||||
mode = st.st_mode & 0o777
|
||||
os.chmod(candidate, mode | (0o070 if candidate.is_dir() else 0o060))
|
||||
|
||||
|
||||
def cleanup_run_root(run_root: Path) -> None:
|
||||
root = WORKSPACE_ROOT.resolve()
|
||||
candidate = run_root.resolve(strict=False)
|
||||
if candidate == root or root not in candidate.parents:
|
||||
raise WorkerError("CLEANUP_SCOPE_INVALID", phase="cleanup")
|
||||
if candidate.exists():
|
||||
shutil.rmtree(candidate)
|
||||
|
||||
|
||||
def allowed_programs() -> set[str]:
|
||||
raw = os.environ.get("AIOS_AUTONOMOUS_ALLOWED_PROGRAMS_JSON", '["/usr/bin/git","/usr/bin/python3"]')
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WorkerError("ALLOWED_PROGRAMS_CONFIG_INVALID", phase="startup") from exc
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) and item.startswith("/") for item in value):
|
||||
raise WorkerError("ALLOWED_PROGRAMS_CONFIG_INVALID", phase="startup")
|
||||
return set(value)
|
||||
|
||||
|
||||
def validate_verification_argv(value: Any) -> list[str]:
|
||||
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item for item in value):
|
||||
raise WorkerError("VERIFICATION_ARGV_INVALID", phase="verification")
|
||||
argv = list(value)
|
||||
if argv[0] not in allowed_programs():
|
||||
raise WorkerError("VERIFICATION_PROGRAM_NOT_ALLOWED", phase="verification", details={"program": Path(argv[0]).name})
|
||||
if argv[0].endswith("/git") and any(item in {"push", "update-ref", "receive-pack", "send-pack", "commit", "reset", "clean"} for item in argv[1:]):
|
||||
raise WorkerError("VERIFICATION_GIT_MUTATION_REJECTED", phase="verification")
|
||||
if argv[0].endswith("/python3") and "-c" in argv:
|
||||
raise WorkerError("VERIFICATION_INLINE_PYTHON_REJECTED", phase="verification")
|
||||
return argv
|
||||
|
||||
|
||||
def run_verification(label: str, argv: Sequence[str], *, cwd: Path, timeout: int) -> dict[str, Any]:
|
||||
started = time.monotonic()
|
||||
completed = subprocess.run(
|
||||
list(argv),
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
env={"PATH": "/usr/local/bin:/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
||||
)
|
||||
result = {
|
||||
"label": label[:120],
|
||||
"argv": [Path(item).name if idx == 0 else item for idx, item in enumerate(argv)],
|
||||
"exit_code": completed.returncode,
|
||||
"stdout_sha256": sha256_bytes(completed.stdout),
|
||||
"stderr_sha256": sha256_bytes(completed.stderr),
|
||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||
}
|
||||
audit("VERIFICATION_RESULT", **result)
|
||||
if completed.returncode != 0:
|
||||
raise WorkerError(
|
||||
"VERIFICATION_FAILED",
|
||||
phase="verification",
|
||||
details={"label": label[:120], "exit_code": completed.returncode},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def independent_verification(
|
||||
worktree: Path,
|
||||
changed_files: Sequence[str],
|
||||
runtime_verification: Sequence[Mapping[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
results = [
|
||||
run_verification(
|
||||
"git_diff_check",
|
||||
["/usr/bin/git", "-C", str(worktree), "diff", "--check"],
|
||||
cwd=worktree,
|
||||
timeout=120,
|
||||
)
|
||||
]
|
||||
py_files = [item for item in changed_files if item.endswith(".py")]
|
||||
if py_files:
|
||||
results.append(
|
||||
run_verification(
|
||||
"changed_python_compile",
|
||||
["/usr/bin/python3", "-m", "py_compile", *py_files],
|
||||
cwd=worktree,
|
||||
timeout=300,
|
||||
)
|
||||
)
|
||||
test_files = [
|
||||
item for item in py_files
|
||||
if item.startswith("tests/") or Path(item).name.startswith("test_")
|
||||
]
|
||||
if test_files:
|
||||
results.append(
|
||||
run_verification(
|
||||
"changed_pytest",
|
||||
["/usr/bin/python3", "-m", "pytest", "-q", *test_files],
|
||||
cwd=worktree,
|
||||
timeout=900,
|
||||
)
|
||||
)
|
||||
seen: set[str] = set()
|
||||
for item in runtime_verification:
|
||||
argv = validate_verification_argv(item.get("argv"))
|
||||
cwd_value = item.get("cwd", ".")
|
||||
if cwd_value in {"", "."}:
|
||||
cwd = worktree
|
||||
else:
|
||||
if not isinstance(cwd_value, str) or cwd_value.startswith(("/", "~")) or ".." in Path(cwd_value).parts:
|
||||
raise WorkerError("VERIFICATION_CWD_INVALID", phase="verification")
|
||||
cwd = (worktree / cwd_value).resolve()
|
||||
if worktree.resolve() not in cwd.parents and cwd != worktree.resolve():
|
||||
raise WorkerError("VERIFICATION_CWD_ESCAPE", phase="verification")
|
||||
timeout = item.get("timeout_seconds", 300)
|
||||
if not isinstance(timeout, int) or isinstance(timeout, bool) or not 1 <= timeout <= 1800:
|
||||
raise WorkerError("VERIFICATION_TIMEOUT_INVALID", phase="verification")
|
||||
key = sha256_json({"argv": argv, "cwd": str(cwd), "timeout": timeout})
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
results.append(
|
||||
run_verification(str(item.get("label") or "agent_requested_verification"), argv, cwd=cwd, timeout=timeout)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def governed_commit(
|
||||
task_id: str,
|
||||
resolution: Resolution,
|
||||
worktree: Path,
|
||||
changed_files: Sequence[str],
|
||||
) -> dict[str, Any]:
|
||||
files: dict[str, str] = {}
|
||||
for relative in changed_files:
|
||||
path = (worktree / relative).resolve()
|
||||
if worktree.resolve() not in path.parents or not path.is_file():
|
||||
raise WorkerError("COMMIT_FILE_INVALID", phase="committing", details={"path": relative})
|
||||
files[relative] = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
|
||||
governed_task_id = f"{task_id}-writer"
|
||||
create_key = f"{task_id}-governed-v2"
|
||||
commit_key = f"{task_id}-commit-v2"
|
||||
status, created = bridge_call(
|
||||
"/v2/governed-task",
|
||||
{
|
||||
"task_id": governed_task_id,
|
||||
"repository": resolution.repository,
|
||||
"base_oid": resolution.base_oid,
|
||||
"target_branch": resolution.target_branch,
|
||||
"risk": "engineering",
|
||||
"description": f"Autonomous intent task {task_id}",
|
||||
"idempotency_key": create_key,
|
||||
},
|
||||
)
|
||||
if not 200 <= status < 300:
|
||||
raise WorkerError(
|
||||
"GOVERNED_TASK_CREATE_FAILED",
|
||||
phase="committing",
|
||||
details={"api_status": status, "error": created.get("error")},
|
||||
)
|
||||
|
||||
generation = int(created.get("generation") or 1)
|
||||
expected = str(created.get("expected_branch_oid") or resolution.base_oid)
|
||||
branch_mode = str(created.get("branch_mode") or "CREATE_NEW")
|
||||
status, lease = bridge_call(
|
||||
"/v2/writer-lease",
|
||||
{
|
||||
"schema": "aios.task-writer-lease.v2",
|
||||
"task_id": governed_task_id,
|
||||
"repository": resolution.repository,
|
||||
"branch_mode": branch_mode,
|
||||
"base_oid": resolution.base_oid,
|
||||
"expected_branch_oid": expected,
|
||||
"target_branch": resolution.target_branch,
|
||||
"generation": generation,
|
||||
"lease_seconds": 900,
|
||||
},
|
||||
)
|
||||
if not 200 <= status < 300:
|
||||
raise WorkerError(
|
||||
"WRITER_LEASE_ACQUIRE_FAILED",
|
||||
phase="committing",
|
||||
details={"api_status": status, "error": lease.get("error")},
|
||||
)
|
||||
|
||||
status, committed = bridge_call(
|
||||
"/v2/commit-files",
|
||||
{
|
||||
"task_id": governed_task_id,
|
||||
"repository": resolution.repository,
|
||||
"expected_branch_oid": expected,
|
||||
"target_branch": resolution.target_branch,
|
||||
"generation": generation,
|
||||
"idempotency_key": commit_key,
|
||||
"files": files,
|
||||
"message": f"chore(agent): execute online intent task {task_id}"[:240],
|
||||
},
|
||||
)
|
||||
if not 200 <= status < 300:
|
||||
raise WorkerError(
|
||||
"GOVERNED_COMMIT_FAILED",
|
||||
phase="committing",
|
||||
details={"api_status": status, "error": committed.get("error")},
|
||||
)
|
||||
commit_oid = committed.get("commit_oid")
|
||||
if not isinstance(commit_oid, str) or not OID_RE.fullmatch(commit_oid):
|
||||
raise WorkerError("GOVERNED_COMMIT_OID_INVALID", phase="committing")
|
||||
return {
|
||||
"governed_task_id": governed_task_id,
|
||||
"generation": generation,
|
||||
"branch_mode": branch_mode,
|
||||
"commit_oid": commit_oid,
|
||||
"commit_visible": committed.get("commit_visible"),
|
||||
"target_branch": resolution.target_branch,
|
||||
"create_idempotency_key": create_key,
|
||||
"commit_idempotency_key": commit_key,
|
||||
}
|
||||
|
||||
|
||||
def socket_for(adapter: str) -> Path:
|
||||
if adapter == "hermes":
|
||||
return HERMES_SOCKET
|
||||
if adapter == "codex":
|
||||
return CODEX_SOCKET
|
||||
if adapter == "openclaw":
|
||||
return OPENCLAW_SOCKET
|
||||
raise WorkerError("ADAPTER_INVALID", phase="runtime")
|
||||
|
||||
|
||||
def resolve_task(task: Mapping[str, Any], prior: Mapping[str, Any] | None = None) -> Resolution:
|
||||
try:
|
||||
return resolve(
|
||||
task,
|
||||
authority_git_dir=AUTHORITY_GIT_DIR,
|
||||
repository=REPOSITORY,
|
||||
base_ref=BASE_REF,
|
||||
available_adapters=available_adapters(),
|
||||
default_adapter=DEFAULT_ADAPTER,
|
||||
prior=prior,
|
||||
)
|
||||
except IntentError as exc:
|
||||
raise WorkerError(exc.code, phase="intent_resolution", details=exc.details) from exc
|
||||
|
||||
|
||||
def execute_claim(task: Mapping[str, Any], claim: Mapping[str, Any], token: str) -> int:
|
||||
task_id = str(claim.get("task_id") or task.get("task_id") or "")
|
||||
lease_token = claim.get("lease_token")
|
||||
if not isinstance(lease_token, str) or len(lease_token) < 24:
|
||||
raise WorkerError("LEASE_TOKEN_NOT_RETURNED", phase="claim")
|
||||
attempt = int(claim.get("attempt") or 0)
|
||||
state = load_state(task_id) or {"task_id": task_id, "phase": "claimed"}
|
||||
prior = state.get("resolution") if isinstance(state.get("resolution"), Mapping) else None
|
||||
resolution = resolve_task(task, prior=prior)
|
||||
state["resolution"] = resolution.to_json()
|
||||
state["attempt"] = attempt
|
||||
save_state(task_id, state)
|
||||
|
||||
heartbeat = LeaseHeartbeat(task_id, token, lease_token)
|
||||
heartbeat.start()
|
||||
run_root: Path | None = None
|
||||
try:
|
||||
heartbeat.set_stage("started")
|
||||
heartbeat.send()
|
||||
audit("TASK_STARTED", task_id=task_id, attempt=attempt, adapter=resolution.adapter)
|
||||
|
||||
existing_commit = state.get("governed")
|
||||
if isinstance(existing_commit, Mapping) and isinstance(existing_commit.get("commit_oid"), str):
|
||||
result = {
|
||||
"status": "SUCCEEDED",
|
||||
"verification_state": "VERIFIED_BY_WORKER_READBACK_PENDING_SERVER",
|
||||
"task_id": task_id,
|
||||
"execution_mode": "autonomous-intent-v2",
|
||||
"adapter": resolution.adapter,
|
||||
"resolution_digest": resolution.resolution_digest,
|
||||
"governed": dict(existing_commit),
|
||||
"recovered_after_commit": True,
|
||||
"secret_material_exposed": False,
|
||||
"direct_main_write": False,
|
||||
"force_push": False,
|
||||
"production_write": False,
|
||||
}
|
||||
else:
|
||||
heartbeat.set_stage("workspace_preparing")
|
||||
run_root, worktree = prepare_worktree(task_id, resolution.base_oid)
|
||||
share_run_root(run_root)
|
||||
state["phase"] = "workspace_prepared"
|
||||
save_state(task_id, state)
|
||||
|
||||
heartbeat.set_stage("agent_executing")
|
||||
payload = request_payload(
|
||||
adapter=resolution.adapter,
|
||||
task_id=task_id,
|
||||
goal=resolution.goal,
|
||||
instructions=resolution.instructions,
|
||||
success_criteria=resolution.success_criteria,
|
||||
repository=resolution.repository,
|
||||
base_oid=resolution.base_oid,
|
||||
target_branch=resolution.target_branch,
|
||||
worktree=worktree,
|
||||
)
|
||||
runtime = invoke(
|
||||
socket_for(resolution.adapter),
|
||||
payload,
|
||||
timeout_seconds=RUNTIME_TIMEOUT_SECONDS,
|
||||
)
|
||||
heartbeat.set_stage("validating")
|
||||
changed_files = collect_changes(worktree, resolution.base_oid)
|
||||
scan_secrets(worktree, changed_files)
|
||||
verification = independent_verification(worktree, changed_files, runtime.verification)
|
||||
|
||||
heartbeat.set_stage("committing")
|
||||
governed = governed_commit(task_id, resolution, worktree, changed_files)
|
||||
state.update({"phase": "commit_done", "governed": governed, "changed_files": list(changed_files)})
|
||||
save_state(task_id, state)
|
||||
|
||||
result = {
|
||||
"status": "SUCCEEDED",
|
||||
"verification_state": "VERIFIED_BY_WORKER_READBACK_PENDING_SERVER",
|
||||
"task_id": task_id,
|
||||
"execution_mode": "autonomous-intent-v2",
|
||||
"adapter": resolution.adapter,
|
||||
"resolution_digest": resolution.resolution_digest,
|
||||
"base_oid": resolution.base_oid,
|
||||
"target_branch": resolution.target_branch,
|
||||
"changed_files": list(changed_files),
|
||||
"tests": verification,
|
||||
"agent_summary": runtime.summary,
|
||||
"agent_evidence": sanitize(runtime.evidence),
|
||||
"governed": governed,
|
||||
"secret_material_exposed": False,
|
||||
"direct_main_write": False,
|
||||
"force_push": False,
|
||||
"production_write": False,
|
||||
}
|
||||
|
||||
if heartbeat.failed:
|
||||
raise WorkerError("HEARTBEAT_UNHEALTHY", phase="result_submitting", details={"error": heartbeat.failed})
|
||||
heartbeat.set_stage("result_submitting")
|
||||
result_key = f"{task_id}-result-v2"
|
||||
status, response = api_call(
|
||||
"POST",
|
||||
f"/v1/tasks/{task_id}/result",
|
||||
token,
|
||||
{
|
||||
"lease_token": lease_token,
|
||||
"idempotency_key": result_key,
|
||||
"result": result,
|
||||
},
|
||||
)
|
||||
if not 200 <= status < 300:
|
||||
raise WorkerError(
|
||||
"TASK_RESULT_SUBMIT_FAILED",
|
||||
phase="result_submitting",
|
||||
details={"api_status": status, "error": response.get("error")},
|
||||
)
|
||||
audit("RESULT_SUBMITTED", task_id=task_id, task_status=response.get("status"), result_idempotency_key=result_key)
|
||||
state["phase"] = "result_submitted"
|
||||
save_state(task_id, state)
|
||||
return 0
|
||||
except (WorkerError, RuntimeErrorV2) as exc:
|
||||
code = exc.code if hasattr(exc, "code") else type(exc).__name__
|
||||
phase = exc.phase if isinstance(exc, WorkerError) else "runtime"
|
||||
details = exc.details if hasattr(exc, "details") else {}
|
||||
audit("AUTONOMOUS_EXECUTION_FAILED", task_id=task_id, error=code, phase=phase, details=details)
|
||||
failure_status = "BLOCKED" if code in {
|
||||
"AGENT_RUNTIME_UNAVAILABLE", "RUNTIME_UNAVAILABLE", "RUNTIME_BLOCKED",
|
||||
"GOVERNED_WRITE_API_UNAVAILABLE", "NON_STAGING_BRIDGE_REJECTED",
|
||||
} else "FAILED_RETRYABLE"
|
||||
failure = {
|
||||
"status": failure_status,
|
||||
"verification_state": "UNVERIFIED",
|
||||
"task_id": task_id,
|
||||
"execution_mode": "autonomous-intent-v2",
|
||||
"error": code,
|
||||
"phase": phase,
|
||||
"details": sanitize(details),
|
||||
"secret_material_exposed": False,
|
||||
"direct_main_write": False,
|
||||
"force_push": False,
|
||||
"production_write": False,
|
||||
}
|
||||
if not heartbeat.failed:
|
||||
status, response = api_call(
|
||||
"POST",
|
||||
f"/v1/tasks/{task_id}/result",
|
||||
token,
|
||||
{
|
||||
"lease_token": lease_token,
|
||||
"idempotency_key": f"{task_id}-attempt-{attempt}-failure-v2",
|
||||
"result": failure,
|
||||
},
|
||||
)
|
||||
audit("FAILURE_RESULT_SUBMITTED", task_id=task_id, api_status=status, task_status=response.get("status"))
|
||||
return 20
|
||||
finally:
|
||||
heartbeat.close()
|
||||
if run_root is not None:
|
||||
try:
|
||||
cleanup_run_root(run_root)
|
||||
audit("WORKSPACE_CLEANED", task_id=task_id, cleanup="PASS")
|
||||
except Exception as exc:
|
||||
audit("WORKSPACE_CLEANUP_FAILED", task_id=task_id, error=type(exc).__name__)
|
||||
|
||||
|
||||
def worker_once(token: str) -> int:
|
||||
status, poll = api_call("POST", "/v1/tasks/poll", token, {"limit": 100})
|
||||
if status != 200:
|
||||
audit("TASK_POLL_FAILED", api_status=status, error=poll.get("error"))
|
||||
return 10
|
||||
|
||||
worker_caps = capabilities()
|
||||
candidates: list[Mapping[str, Any]] = []
|
||||
for item in poll.get("tasks", []):
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
if not is_claimable_intent(item, worker_caps):
|
||||
continue
|
||||
try:
|
||||
# Read-only preflight. Do not consume an attempt if runtime/base is unavailable.
|
||||
resolve_task(item, prior=(load_state(str(item.get("task_id") or "")) or {}).get("resolution"))
|
||||
except WorkerError as exc:
|
||||
audit("TASK_SKIPPED_PREFLIGHT", task_id=item.get("task_id"), error=exc.code)
|
||||
continue
|
||||
candidates.append(item)
|
||||
|
||||
if not candidates:
|
||||
return 0
|
||||
|
||||
# Server priority/order remains authoritative; take the first visible compatible task.
|
||||
task = candidates[0]
|
||||
task_id = str(task.get("task_id") or "")
|
||||
status, claim = api_call("POST", f"/v1/tasks/{task_id}/claim", token, {"lease_seconds": LEASE_SECONDS})
|
||||
if status != 200:
|
||||
audit("TASK_CLAIM_FAILED", task_id=task_id, api_status=status, error=claim.get("error"))
|
||||
return 11
|
||||
audit(
|
||||
"TASK_CLAIMED",
|
||||
task_id=task_id,
|
||||
attempt=claim.get("attempt"),
|
||||
revision=claim.get("revision"),
|
||||
claim_mode="server_atomic",
|
||||
execution_mode="autonomous-intent-v2",
|
||||
)
|
||||
merged = dict(task)
|
||||
merged.update({key: value for key, value in claim.items() if key not in {"lease_token"}})
|
||||
merged["task_id"] = task_id
|
||||
return execute_claim(merged, claim, token)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ensure_roots()
|
||||
if BRIDGE_BASE != "http://127.0.0.1:19998":
|
||||
raise WorkerError("NON_STAGING_BRIDGE_REJECTED", phase="startup", details={"bridge_base": BRIDGE_BASE})
|
||||
token = load_agent_token()
|
||||
audit(
|
||||
"AUTONOMOUS_WORKER_READY",
|
||||
poll_mode="continuous",
|
||||
claim_mode="server_atomic",
|
||||
task_scope="intent-only",
|
||||
bridge_base=BRIDGE_BASE,
|
||||
available_adapters=list(available_adapters()),
|
||||
capabilities=list(capabilities()),
|
||||
main_write=False,
|
||||
production_write=False,
|
||||
force_push=False,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
result = worker_once(token)
|
||||
except Exception as exc:
|
||||
audit("WORKER_LOOP_ERROR", error=type(exc).__name__)
|
||||
result = 20
|
||||
time.sleep(POLL_SECONDS if result == 0 else max(POLL_SECONDS, 5))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,273 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
REPOSITORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{2,199}$")
|
||||
ALLOWED_ADAPTERS = frozenset({"hermes", "codex", "openclaw"})
|
||||
|
||||
|
||||
class IntentError(RuntimeError):
|
||||
def __init__(self, code: str, *, details: Mapping[str, Any] | None = None) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.details = dict(details or {})
|
||||
|
||||
|
||||
def stable_digest(value: Any) -> str:
|
||||
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _text(value: Any, field: str, limit: int) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise IntentError(f"{field.upper()}_INVALID")
|
||||
value = value.strip()
|
||||
if not value or len(value) > limit:
|
||||
raise IntentError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _string_list(value: Any, field: str, *, required: bool) -> tuple[str, ...]:
|
||||
if value is None and not required:
|
||||
return ()
|
||||
if not isinstance(value, list) or len(value) > 64 or (required and not value):
|
||||
raise IntentError(f"{field.upper()}_INVALID")
|
||||
return tuple(_text(item, field, 4096) for item in value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentTask:
|
||||
task_id: str
|
||||
goal: str
|
||||
instructions: str
|
||||
success_criteria: tuple[str, ...]
|
||||
required_capabilities: tuple[str, ...]
|
||||
digest: str
|
||||
|
||||
@classmethod
|
||||
def parse(cls, task: Mapping[str, Any]) -> "IntentTask":
|
||||
task_id = str(task.get("task_id") or "")
|
||||
if not TASK_ID_RE.fullmatch(task_id):
|
||||
raise IntentError("TASK_ID_INVALID")
|
||||
|
||||
metadata = task.get("metadata")
|
||||
if isinstance(metadata, Mapping) and isinstance(metadata.get("execution"), Mapping):
|
||||
raise IntentError("STRUCTURED_TASK_NOT_INTENT")
|
||||
|
||||
goal = _text(task.get("goal"), "goal", 16384)
|
||||
instructions = _text(
|
||||
task.get("instructions") or "Inspect the repository, implement the goal, and satisfy the success criteria.",
|
||||
"instructions",
|
||||
65536,
|
||||
)
|
||||
success = _string_list(task.get("success_criteria"), "success_criteria", required=True)
|
||||
capabilities = _string_list(task.get("required_capabilities") or [], "required_capabilities", required=False)
|
||||
|
||||
required_markers = {"intent.task", "engineering.execute"}
|
||||
missing = sorted(required_markers - set(capabilities))
|
||||
if missing:
|
||||
raise IntentError("INTENT_CAPABILITY_MARKER_REQUIRED", details={"missing": missing})
|
||||
|
||||
canonical = {
|
||||
"task_id": task_id,
|
||||
"goal": goal,
|
||||
"instructions": instructions,
|
||||
"success_criteria": success,
|
||||
"required_capabilities": sorted(capabilities),
|
||||
}
|
||||
return cls(
|
||||
task_id=task_id,
|
||||
goal=goal,
|
||||
instructions=instructions,
|
||||
success_criteria=success,
|
||||
required_capabilities=capabilities,
|
||||
digest=stable_digest(canonical),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Resolution:
|
||||
schema: str
|
||||
task_id: str
|
||||
repository: str
|
||||
base_ref: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
adapter: str
|
||||
intent_digest: str
|
||||
goal: str
|
||||
instructions: str
|
||||
success_criteria: tuple[str, ...]
|
||||
required_capabilities: tuple[str, ...]
|
||||
resolution_digest: str
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["success_criteria"] = list(self.success_criteria)
|
||||
value["required_capabilities"] = list(self.required_capabilities)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, value: Mapping[str, Any], *, intent: IntentTask) -> "Resolution":
|
||||
if value.get("schema") != "aios.autonomous-intent-resolution.v2":
|
||||
raise IntentError("RESOLUTION_SCHEMA_INVALID")
|
||||
if value.get("task_id") != intent.task_id or value.get("intent_digest") != intent.digest:
|
||||
raise IntentError("RESOLUTION_TASK_DRIFT")
|
||||
result = cls(
|
||||
schema=str(value["schema"]),
|
||||
task_id=str(value["task_id"]),
|
||||
repository=str(value["repository"]),
|
||||
base_ref=str(value["base_ref"]),
|
||||
base_oid=str(value["base_oid"]),
|
||||
target_branch=str(value["target_branch"]),
|
||||
adapter=str(value["adapter"]),
|
||||
intent_digest=str(value["intent_digest"]),
|
||||
goal=str(value["goal"]),
|
||||
instructions=str(value["instructions"]),
|
||||
success_criteria=tuple(str(item) for item in value.get("success_criteria") or []),
|
||||
required_capabilities=tuple(str(item) for item in value.get("required_capabilities") or []),
|
||||
resolution_digest=str(value["resolution_digest"]),
|
||||
)
|
||||
validate_resolution(result)
|
||||
if result.resolution_digest != resolution_digest(result):
|
||||
raise IntentError("RESOLUTION_DIGEST_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def read_base_oid(authority_git_dir: Path, base_ref: str) -> str:
|
||||
if not base_ref.startswith("refs/heads/"):
|
||||
raise IntentError("BASE_REF_INVALID")
|
||||
if authority_git_dir.is_symlink() or not authority_git_dir.is_dir():
|
||||
raise IntentError("AUTHORITY_GIT_DIR_INVALID")
|
||||
completed = subprocess.run(
|
||||
["/usr/bin/git", "--git-dir", str(authority_git_dir), "rev-parse", "--verify", f"{base_ref}^{{commit}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
||||
)
|
||||
oid = completed.stdout.strip()
|
||||
if completed.returncode != 0 or not OID_RE.fullmatch(oid):
|
||||
raise IntentError("BASE_OID_UNAVAILABLE")
|
||||
return oid
|
||||
|
||||
|
||||
def derive_branch(task_id: str, digest: str) -> str:
|
||||
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", task_id).strip(".-").lower() or "task"
|
||||
slug = slug[:100]
|
||||
branch = f"agent/intent/{slug}-{digest[:12]}"
|
||||
if len(branch) > 180:
|
||||
branch = branch[:167] + "-" + digest[:12]
|
||||
if ".." in branch or branch.endswith(("/", ".", ".lock")):
|
||||
raise IntentError("TARGET_BRANCH_INVALID")
|
||||
return branch
|
||||
|
||||
|
||||
def select_adapter(capabilities: Sequence[str], available: Sequence[str], default_adapter: str) -> str:
|
||||
advertised = set(available)
|
||||
if not advertised.issubset(ALLOWED_ADAPTERS):
|
||||
raise IntentError("ADAPTER_CONFIGURATION_INVALID")
|
||||
requested = {
|
||||
item.split(".", 2)[-1]
|
||||
for item in capabilities
|
||||
if item.startswith("agent.runtime.")
|
||||
}
|
||||
requested.discard("")
|
||||
if len(requested) > 1:
|
||||
raise IntentError("MULTIPLE_AGENT_RUNTIMES_REQUIRED")
|
||||
adapter = next(iter(requested)) if requested else default_adapter
|
||||
if adapter not in ALLOWED_ADAPTERS:
|
||||
raise IntentError("AGENT_RUNTIME_INVALID")
|
||||
if adapter not in advertised:
|
||||
raise IntentError("AGENT_RUNTIME_UNAVAILABLE", details={"adapter": adapter})
|
||||
return adapter
|
||||
|
||||
|
||||
def resolution_digest(value: Resolution) -> str:
|
||||
return stable_digest({
|
||||
"schema": value.schema,
|
||||
"task_id": value.task_id,
|
||||
"repository": value.repository,
|
||||
"base_ref": value.base_ref,
|
||||
"base_oid": value.base_oid,
|
||||
"target_branch": value.target_branch,
|
||||
"adapter": value.adapter,
|
||||
"intent_digest": value.intent_digest,
|
||||
"goal": value.goal,
|
||||
"instructions": value.instructions,
|
||||
"success_criteria": value.success_criteria,
|
||||
"required_capabilities": sorted(value.required_capabilities),
|
||||
})
|
||||
|
||||
|
||||
def validate_resolution(value: Resolution) -> None:
|
||||
if not TASK_ID_RE.fullmatch(value.task_id):
|
||||
raise IntentError("TASK_ID_INVALID")
|
||||
if not REPOSITORY_RE.fullmatch(value.repository) or value.repository.startswith(("/", ".")):
|
||||
raise IntentError("REPOSITORY_INVALID")
|
||||
if not value.base_ref.startswith("refs/heads/"):
|
||||
raise IntentError("BASE_REF_INVALID")
|
||||
if not OID_RE.fullmatch(value.base_oid):
|
||||
raise IntentError("BASE_OID_INVALID")
|
||||
if not value.target_branch.startswith("agent/intent/"):
|
||||
raise IntentError("TARGET_BRANCH_POLICY_INVALID")
|
||||
if value.adapter not in ALLOWED_ADAPTERS:
|
||||
raise IntentError("AGENT_RUNTIME_INVALID")
|
||||
|
||||
|
||||
def resolve(
|
||||
task: Mapping[str, Any],
|
||||
*,
|
||||
authority_git_dir: Path,
|
||||
repository: str,
|
||||
base_ref: str,
|
||||
available_adapters: Sequence[str],
|
||||
default_adapter: str,
|
||||
prior: Mapping[str, Any] | None = None,
|
||||
) -> Resolution:
|
||||
intent = IntentTask.parse(task)
|
||||
if prior is not None:
|
||||
return Resolution.from_json(prior, intent=intent)
|
||||
if not REPOSITORY_RE.fullmatch(repository) or repository.startswith(("/", ".")):
|
||||
raise IntentError("REPOSITORY_INVALID")
|
||||
base_oid = read_base_oid(authority_git_dir, base_ref)
|
||||
adapter = select_adapter(intent.required_capabilities, available_adapters, default_adapter)
|
||||
provisional = Resolution(
|
||||
schema="aios.autonomous-intent-resolution.v2",
|
||||
task_id=intent.task_id,
|
||||
repository=repository,
|
||||
base_ref=base_ref,
|
||||
base_oid=base_oid,
|
||||
target_branch=derive_branch(intent.task_id, intent.digest),
|
||||
adapter=adapter,
|
||||
intent_digest=intent.digest,
|
||||
goal=intent.goal,
|
||||
instructions=intent.instructions,
|
||||
success_criteria=intent.success_criteria,
|
||||
required_capabilities=intent.required_capabilities,
|
||||
resolution_digest="",
|
||||
)
|
||||
result = Resolution(
|
||||
**{**asdict(provisional), "resolution_digest": resolution_digest(provisional)}
|
||||
)
|
||||
validate_resolution(result)
|
||||
return result
|
||||
|
||||
|
||||
def is_claimable_intent(task: Mapping[str, Any], worker_capabilities: Sequence[str]) -> bool:
|
||||
try:
|
||||
intent = IntentTask.parse(task)
|
||||
except IntentError:
|
||||
return False
|
||||
required = set(intent.required_capabilities)
|
||||
return required.issubset(set(worker_capabilities))
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
BASE = os.environ.get("AIOS_AGENT_TASK_API_URL", "http://127.0.0.1:18940").rstrip("/")
|
||||
AGENT_ID = os.environ.get("AIOS_AGENT_ID", "online-engineering-worker-cn-network-mkt-autonomous-20260815")
|
||||
AGENT_TYPE = os.environ.get("AIOS_AGENT_TYPE", "trusted-online-engineering-worker")
|
||||
AGENT_VERSION = os.environ.get("AIOS_AGENT_VERSION", "2026-08-15.4-autonomous-intent")
|
||||
STATE_DIR = Path(os.environ.get("AIOS_WORKER_REGISTRATION_STATE", "/var/lib/aios-autonomous-agent-worker/registration"))
|
||||
CREDENTIAL_PATH = Path(os.environ.get("AIOS_WORKER_ENCRYPTED_CREDENTIAL", "/etc/credstore.encrypted/aios-autonomous-agent-worker/agent_token"))
|
||||
IDENTITY_PATH = STATE_DIR / "identity.json"
|
||||
PROJECTION_PROVIDER = "BridgeV2RuntimeCredentialProjection"
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def runtime_capabilities() -> list[str]:
|
||||
values = [
|
||||
"online_agent_task_api", "task.poll", "task.claim", "task.heartbeat", "task.result",
|
||||
"intent.task", "engineering.consumer", "engineering.execute", "engineering.autonomous",
|
||||
"workspace.worktree", "git.read", "git.diff", "git.test",
|
||||
"governed.write.branch", "governed.write.file",
|
||||
]
|
||||
hermes = os.environ.get("AIOS_HERMES_BROKER_SOCKET", "")
|
||||
codex = os.environ.get("AIOS_CODEX_BROKER_SOCKET", "")
|
||||
openclaw = os.environ.get("AIOS_OPENCLAW_BROKER_SOCKET", "")
|
||||
if hermes and Path(hermes).is_socket():
|
||||
values.append("agent.runtime.hermes")
|
||||
if codex and Path(codex).is_socket():
|
||||
values.append("agent.runtime.codex")
|
||||
if openclaw and Path(openclaw).is_socket():
|
||||
values.append("agent.runtime.openclaw")
|
||||
return values
|
||||
|
||||
|
||||
def api_call(method: str, route: str, token: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
|
||||
payload = None if body is None else json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
headers = {"Authorization": "Bearer " + token, "Accept": "application/json"}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(BASE + route, data=payload, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
value = json.loads(response.read().decode("utf-8"))
|
||||
return response.status, value if isinstance(value, dict) else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
value = json.loads(exc.read(2048).decode("utf-8"))
|
||||
except Exception:
|
||||
value = {"error": "HTTP_ERROR"}
|
||||
return exc.code, value if isinstance(value, dict) else {"error": "HTTP_ERROR"}
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
return 599, {"error": "AGENT_TASK_API_UNAVAILABLE"}
|
||||
|
||||
|
||||
def encrypted_credential_is_valid() -> bool:
|
||||
if not CREDENTIAL_PATH.is_file() or CREDENTIAL_PATH.is_symlink():
|
||||
return False
|
||||
result = subprocess.run(
|
||||
["systemd-creds", "decrypt", "--with-key=host", str(CREDENTIAL_PATH), "/dev/null"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, timeout=15,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def load_identity() -> dict[str, Any] | None:
|
||||
if not IDENTITY_PATH.is_file() or IDENTITY_PATH.is_symlink():
|
||||
return None
|
||||
try:
|
||||
value = json.loads(IDENTITY_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def identity_matches(value: dict[str, Any] | None) -> bool:
|
||||
return bool(
|
||||
value
|
||||
and value.get("agent_id") == AGENT_ID
|
||||
and value.get("agent_type") == AGENT_TYPE
|
||||
and value.get("agent_version") == AGENT_VERSION
|
||||
and value.get("task_scope") == "intent-only"
|
||||
and value.get("credential_projection_provider") == PROJECTION_PROVIDER
|
||||
)
|
||||
|
||||
|
||||
def encrypt_agent_token(token: str) -> None:
|
||||
CREDENTIAL_PATH.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".agent-token.", dir=str(CREDENTIAL_PATH.parent))
|
||||
os.close(fd)
|
||||
os.unlink(temporary)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemd-creds", "encrypt", "--with-key=host", "--name=agent_token", "/dev/stdin", temporary],
|
||||
input=(token + "\n").encode("utf-8"),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, timeout=20,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("AGENT_CREDENTIAL_ENCRYPT_FAILED")
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, CREDENTIAL_PATH)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def write_identity() -> None:
|
||||
value = {
|
||||
"agent_id": AGENT_ID,
|
||||
"agent_type": AGENT_TYPE,
|
||||
"agent_version": AGENT_VERSION,
|
||||
"task_scope": "intent-only",
|
||||
"credential_projection_provider": PROJECTION_PROVIDER,
|
||||
"credential_delivery": "systemd-LoadCredentialEncrypted",
|
||||
"task_api_url": BASE,
|
||||
"task_api_schema": "aios.agent-task.v1",
|
||||
"execution_backend": "autonomous-intent-worker-v2",
|
||||
"runtime_only_secret_delivery": True,
|
||||
"main_write": False,
|
||||
"production_write": False,
|
||||
"force_push": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"capabilities": runtime_capabilities(),
|
||||
"created_at": now(),
|
||||
}
|
||||
STATE_DIR.mkdir(parents=True, mode=0o750, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix="identity.", dir=str(STATE_DIR))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, ensure_ascii=False, sort_keys=True)
|
||||
handle.write("\n")
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, IDENTITY_PATH)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def register_or_recover() -> str:
|
||||
STATE_DIR.mkdir(parents=True, mode=0o750, exist_ok=True)
|
||||
existing = load_identity()
|
||||
if CREDENTIAL_PATH.exists():
|
||||
if not identity_matches(existing):
|
||||
raise RuntimeError("AGENT_IDENTITY_BINDING_CONFLICT")
|
||||
if not encrypted_credential_is_valid():
|
||||
raise RuntimeError("AGENT_ENCRYPTED_CREDENTIAL_INVALID")
|
||||
return "RECOVERED"
|
||||
if existing is not None:
|
||||
raise RuntimeError("AGENT_CREDENTIAL_MISSING_AFTER_PRIOR_REGISTRATION")
|
||||
credential_directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", ""))
|
||||
control_path = credential_directory / "bridge_control_token"
|
||||
if not control_path.is_file():
|
||||
raise RuntimeError("BRIDGE_RUNTIME_CONTROL_CREDENTIAL_UNAVAILABLE")
|
||||
control_token = control_path.read_text(encoding="utf-8").strip()
|
||||
status, response = api_call(
|
||||
"POST", "/v1/agents/register", control_token,
|
||||
{
|
||||
"agent_id": AGENT_ID,
|
||||
"agent_type": AGENT_TYPE,
|
||||
"agent_version": AGENT_VERSION,
|
||||
"capabilities": runtime_capabilities(),
|
||||
"metadata": {
|
||||
"task_scope": "intent-only",
|
||||
"credential_projection_provider": PROJECTION_PROVIDER,
|
||||
"runtime_only_secret_delivery": True,
|
||||
"execution_backend": "autonomous-intent-worker-v2",
|
||||
"main_write": False,
|
||||
"production_write": False,
|
||||
"force_push": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != 201:
|
||||
raise RuntimeError("AGENT_REGISTER_FAILED_" + str(response.get("error") or status))
|
||||
token = response.get("agent_token")
|
||||
if not isinstance(token, str) or len(token) < 24:
|
||||
raise RuntimeError("AGENT_TOKEN_NOT_RETURNED")
|
||||
encrypt_agent_token(token)
|
||||
write_identity()
|
||||
return "REGISTERED"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(0 if register_or_recover() in {"REGISTERED", "RECOVERED"} else 20)
|
||||
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
MAX_BYTES = 1024 * 1024
|
||||
MAX_CHANGED_FILES = 256
|
||||
MAX_FILE_BYTES = 4 * 1024 * 1024
|
||||
SENSITIVE_NAMES = frozenset({
|
||||
".env", ".env.local", ".env.production", "id_rsa", "id_ed25519",
|
||||
"credentials", "credentials.json",
|
||||
})
|
||||
SENSITIVE_SUFFIXES = (".key", ".p12", ".pfx")
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(rb"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||||
re.compile(rb"\bAKIA[0-9A-Z]{16}\b"),
|
||||
re.compile(rb"\bghp_[A-Za-z0-9]{30,}\b"),
|
||||
re.compile(rb"\bsk-[A-Za-z0-9_-]{24,}\b"),
|
||||
)
|
||||
|
||||
|
||||
class RuntimeErrorV2(RuntimeError):
|
||||
def __init__(self, code: str, *, details: Mapping[str, Any] | None = None) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.details = dict(details or {})
|
||||
|
||||
|
||||
def safe_relative_path(value: str) -> str:
|
||||
if (
|
||||
not value
|
||||
or value.startswith(("/", "~"))
|
||||
or "\\" in value
|
||||
or "\x00" in value
|
||||
or any(part in {"", ".", ".."} for part in value.split("/"))
|
||||
):
|
||||
raise RuntimeErrorV2("CHANGED_PATH_INVALID", details={"path": value[:160]})
|
||||
lowered = value.split("/")[-1].lower()
|
||||
if value.startswith(".git/") or value in {".git", ".gitmodules"}:
|
||||
raise RuntimeErrorV2("GIT_METADATA_CHANGE_REJECTED", details={"path": value})
|
||||
if lowered in SENSITIVE_NAMES or lowered.endswith(SENSITIVE_SUFFIXES):
|
||||
raise RuntimeErrorV2("SENSITIVE_PATH_REJECTED", details={"path": value})
|
||||
return value
|
||||
|
||||
|
||||
def git(worktree: Path, args: Sequence[str], timeout: int = 60) -> bytes:
|
||||
completed = subprocess.run(
|
||||
["/usr/bin/git", "-C", str(worktree), *args],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeErrorV2("WORKTREE_GIT_FAILED", details={"operation": args[0] if args else ""})
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def _zpaths(raw: bytes) -> list[str]:
|
||||
result: list[str] = []
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
result.append(item.decode("utf-8"))
|
||||
except UnicodeDecodeError as exc:
|
||||
raise RuntimeErrorV2("WORKTREE_PATH_ENCODING_INVALID") from exc
|
||||
return result
|
||||
|
||||
|
||||
def collect_changes(worktree: Path, base_oid: str) -> list[str]:
|
||||
if not OID_RE.fullmatch(base_oid):
|
||||
raise RuntimeErrorV2("BASE_OID_INVALID")
|
||||
head = git(worktree, ["rev-parse", "HEAD"]).decode("ascii", "strict").strip()
|
||||
if head != base_oid:
|
||||
raise RuntimeErrorV2("WORKTREE_HEAD_MUTATED", details={"expected": base_oid, "actual": head})
|
||||
tracked = _zpaths(git(worktree, ["diff", "--name-only", "--no-renames", "-z", base_oid, "--"]))
|
||||
untracked = _zpaths(git(worktree, ["ls-files", "--others", "--exclude-standard", "-z"]))
|
||||
changed = sorted({safe_relative_path(item) for item in tracked + untracked})
|
||||
if not changed:
|
||||
raise RuntimeErrorV2("AGENT_PRODUCED_NO_CHANGES")
|
||||
if len(changed) > MAX_CHANGED_FILES:
|
||||
raise RuntimeErrorV2("CHANGED_FILE_LIMIT_EXCEEDED")
|
||||
root = worktree.resolve()
|
||||
for relative in changed:
|
||||
path = (root / relative).resolve(strict=False)
|
||||
if root not in path.parents or not path.is_file() or path.is_symlink():
|
||||
raise RuntimeErrorV2("CHANGED_FILE_UNSUPPORTED", details={"path": relative})
|
||||
if path.stat().st_size > MAX_FILE_BYTES:
|
||||
raise RuntimeErrorV2("CHANGED_FILE_TOO_LARGE", details={"path": relative})
|
||||
return changed
|
||||
|
||||
|
||||
def scan_secrets(worktree: Path, changed: Sequence[str]) -> None:
|
||||
root = worktree.resolve()
|
||||
for relative in changed:
|
||||
path = (root / safe_relative_path(relative)).resolve()
|
||||
raw = path.read_bytes()
|
||||
for pattern in SECRET_PATTERNS:
|
||||
if pattern.search(raw):
|
||||
raise RuntimeErrorV2("SECRET_MATERIAL_REJECTED", details={"path": relative})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeResult:
|
||||
status: str
|
||||
summary: str
|
||||
verification: tuple[dict[str, Any], ...]
|
||||
evidence: dict[str, Any]
|
||||
|
||||
@classmethod
|
||||
def parse(cls, value: Mapping[str, Any]) -> "RuntimeResult":
|
||||
if value.get("schema") != "aios.online-agent.runtime-result.v1":
|
||||
raise RuntimeErrorV2("RUNTIME_RESULT_SCHEMA_INVALID")
|
||||
status = str(value.get("status") or "")
|
||||
if status not in {"COMPLETED", "BLOCKED", "FAILED"}:
|
||||
raise RuntimeErrorV2("RUNTIME_RESULT_STATUS_INVALID")
|
||||
summary = str(value.get("summary") or "").strip()
|
||||
if not summary or len(summary) > 8192:
|
||||
raise RuntimeErrorV2("RUNTIME_RESULT_SUMMARY_INVALID")
|
||||
verification = value.get("verification") or []
|
||||
evidence = value.get("evidence") or {}
|
||||
if not isinstance(verification, list) or len(verification) > 64:
|
||||
raise RuntimeErrorV2("RUNTIME_VERIFICATION_INVALID")
|
||||
if not isinstance(evidence, Mapping):
|
||||
raise RuntimeErrorV2("RUNTIME_EVIDENCE_INVALID")
|
||||
return cls(status, summary, tuple(dict(item) for item in verification if isinstance(item, Mapping)), dict(evidence))
|
||||
|
||||
|
||||
def request_payload(
|
||||
*,
|
||||
adapter: str,
|
||||
task_id: str,
|
||||
goal: str,
|
||||
instructions: str,
|
||||
success_criteria: Sequence[str],
|
||||
repository: str,
|
||||
base_oid: str,
|
||||
target_branch: str,
|
||||
worktree: Path,
|
||||
) -> dict[str, Any]:
|
||||
if adapter not in {"hermes", "codex", "openclaw"}:
|
||||
raise RuntimeErrorV2("ADAPTER_INVALID")
|
||||
if not OID_RE.fullmatch(base_oid):
|
||||
raise RuntimeErrorV2("BASE_OID_INVALID")
|
||||
return {
|
||||
"schema": "aios.online-agent.runtime-request.v1",
|
||||
"adapter": adapter,
|
||||
"task": {
|
||||
"task_id": task_id,
|
||||
"goal": goal,
|
||||
"instructions": instructions,
|
||||
"success_criteria": list(success_criteria),
|
||||
},
|
||||
"execution_context": {
|
||||
"repository": repository,
|
||||
"base_oid": base_oid,
|
||||
"target_branch": target_branch,
|
||||
"worktree": str(worktree),
|
||||
},
|
||||
"policy": {
|
||||
"authoritative_repository_write": False,
|
||||
"direct_git_ref_mutation": False,
|
||||
"direct_git_push": False,
|
||||
"direct_git_commit": False,
|
||||
"main_write": False,
|
||||
"force_push": False,
|
||||
"production_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"task_api_credential_visible": False,
|
||||
"writer_lease_visible": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def invoke(socket_path: Path, payload: Mapping[str, Any], timeout_seconds: int) -> RuntimeResult:
|
||||
if not socket_path.is_absolute():
|
||||
raise RuntimeErrorV2("RUNTIME_SOCKET_NOT_ABSOLUTE")
|
||||
if not 1 <= timeout_seconds <= 7200:
|
||||
raise RuntimeErrorV2("RUNTIME_TIMEOUT_INVALID")
|
||||
raw = json.dumps(dict(payload), ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
if len(raw) > MAX_BYTES:
|
||||
raise RuntimeErrorV2("RUNTIME_REQUEST_TOO_LARGE")
|
||||
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.settimeout(timeout_seconds)
|
||||
try:
|
||||
client.connect(str(socket_path))
|
||||
client.sendall(raw)
|
||||
client.shutdown(socket.SHUT_WR)
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = client.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_BYTES:
|
||||
raise RuntimeErrorV2("RUNTIME_RESPONSE_TOO_LARGE")
|
||||
chunks.append(chunk)
|
||||
except (OSError, socket.timeout) as exc:
|
||||
raise RuntimeErrorV2("RUNTIME_UNAVAILABLE", details={"socket": str(socket_path)}) from exc
|
||||
finally:
|
||||
client.close()
|
||||
try:
|
||||
value = json.loads(b"".join(chunks).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeErrorV2("RUNTIME_RESPONSE_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeErrorV2("RUNTIME_RESPONSE_INVALID")
|
||||
result = RuntimeResult.parse(value)
|
||||
if result.status == "BLOCKED":
|
||||
raise RuntimeErrorV2("RUNTIME_BLOCKED", details={"summary": result.summary})
|
||||
if result.status == "FAILED":
|
||||
raise RuntimeErrorV2("RUNTIME_FAILED", details={"summary": result.summary})
|
||||
return result
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from runtime_client import collect_changes, invoke, request_payload, scan_secrets
|
||||
|
||||
|
||||
WORKSPACE_ROOT = Path(os.environ.get(
|
||||
"AIOS_AUTONOMOUS_WORKSPACE_ROOT",
|
||||
"/var/lib/aios-autonomous-agent-worker/workspaces",
|
||||
))
|
||||
ADAPTER = os.environ.get("AIOS_AUTONOMOUS_SELFTEST_ADAPTER", "openclaw")
|
||||
SOCKET = Path(os.environ.get(
|
||||
"AIOS_AUTONOMOUS_SELFTEST_BROKER_SOCKET",
|
||||
f"/run/aios-agent-runtime/{ADAPTER}.sock",
|
||||
))
|
||||
|
||||
|
||||
def run(argv: list[str], cwd: Path) -> str:
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=60,
|
||||
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"SELFTEST_COMMAND_FAILED:{argv[0]}:{completed.returncode}")
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SOCKET.is_socket():
|
||||
raise RuntimeError("AUTONOMOUS_ENGINEERING_BACKEND_UNAVAILABLE")
|
||||
WORKSPACE_ROOT.mkdir(parents=True, mode=0o750, exist_ok=True)
|
||||
run_root = Path(tempfile.mkdtemp(prefix="runtime-selftest-", dir=str(WORKSPACE_ROOT)))
|
||||
repo = run_root / "worktree"
|
||||
repo.mkdir()
|
||||
try:
|
||||
run(["/usr/bin/git", "init", str(repo)], run_root)
|
||||
run(["/usr/bin/git", "-C", str(repo), "config", "user.email", "selftest@example.invalid"], run_root)
|
||||
run(["/usr/bin/git", "-C", str(repo), "config", "user.name", "AIOS Runtime Selftest"], run_root)
|
||||
marker = repo / "agent_runtime_selftest.txt"
|
||||
marker.write_text("VALUE=1\n", encoding="utf-8")
|
||||
run(["/usr/bin/git", "-C", str(repo), "add", "agent_runtime_selftest.txt"], run_root)
|
||||
run(["/usr/bin/git", "-C", str(repo), "commit", "-m", "selftest base"], run_root)
|
||||
base_oid = run(["/usr/bin/git", "-C", str(repo), "rev-parse", "HEAD"], run_root)
|
||||
|
||||
payload = request_payload(
|
||||
adapter=ADAPTER,
|
||||
task_id="autonomous-runtime-selftest",
|
||||
goal="Change agent_runtime_selftest.txt so it contains exactly VALUE=2 followed by a newline.",
|
||||
instructions=(
|
||||
"Modify only agent_runtime_selftest.txt in the provided disposable worktree. "
|
||||
"Do not commit, push, change Git refs, or access task/governed-write credentials."
|
||||
),
|
||||
success_criteria=[
|
||||
"agent_runtime_selftest.txt contains VALUE=2",
|
||||
"Git HEAD remains unchanged",
|
||||
],
|
||||
repository="selftest/disposable",
|
||||
base_oid=base_oid,
|
||||
target_branch="agent/intent/runtime-selftest",
|
||||
worktree=repo,
|
||||
)
|
||||
result = invoke(SOCKET, payload, timeout_seconds=300)
|
||||
changed = collect_changes(repo, base_oid)
|
||||
scan_secrets(repo, changed)
|
||||
content = marker.read_text(encoding="utf-8")
|
||||
head = run(["/usr/bin/git", "-C", str(repo), "rev-parse", "HEAD"], run_root)
|
||||
if content != "VALUE=2\n":
|
||||
raise RuntimeError("RUNTIME_SELFTEST_CONTENT_MISMATCH")
|
||||
if head != base_oid:
|
||||
raise RuntimeError("RUNTIME_SELFTEST_HEAD_MUTATED")
|
||||
if changed != ["agent_runtime_selftest.txt"]:
|
||||
raise RuntimeError("RUNTIME_SELFTEST_UNEXPECTED_DIFF")
|
||||
print(json.dumps({
|
||||
"status": "PASS",
|
||||
"adapter": ADAPTER,
|
||||
"base_oid": base_oid,
|
||||
"changed_files": changed,
|
||||
"head_unchanged": True,
|
||||
"runtime_summary": result.summary,
|
||||
"task_api_token_visible_to_runtime": False,
|
||||
"writer_lease_visible_to_runtime": False,
|
||||
}, sort_keys=True))
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(run_root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generic Governed Online Agent Worker
|
||||
|
||||
This worker is a pull-based, restartable consumer for `aios.agent-task.v1`.
|
||||
The service configuration contains only stable runtime identity, capability
|
||||
names, API endpoints, heartbeat timing, and the credential projection. It does
|
||||
not contain a task id, repository base OID, target branch, or plan-file path.
|
||||
|
||||
## Dynamic task contract
|
||||
|
||||
Engineering tasks carry `metadata.execution` with schema
|
||||
`aios.online-agent.execution.v2`. The payload supplies the repository, exact
|
||||
base OID, target branch, detached-worktree policy, adapter name, explicit file
|
||||
change set, verification commands, and governed commit idempotency keys.
|
||||
Task prose is descriptive only and is never passed to a shell.
|
||||
|
||||
The worker accepts only structured operations:
|
||||
|
||||
- `deterministic`: allow-listed file changes and test commands;
|
||||
- `codex`: fail-closed until a task supplies an explicit Codex file/command
|
||||
policy;
|
||||
- `hermes`: fail-closed because the current Hermes runtime is a user-chat
|
||||
service, not an engineering executor.
|
||||
|
||||
All command execution uses `shell=False`, fixed program allowlists, relative
|
||||
paths beneath an ephemeral detached worktree, and output redaction. Direct
|
||||
`git branch`, `update-ref`, `push`, `commit`, `reset`, `clean`, main,
|
||||
production, tag, and baseline mutations are rejected by the worker.
|
||||
|
||||
## Governed write path
|
||||
|
||||
The worker calls the Bridge in this order:
|
||||
|
||||
1. `POST /v2/governed-task`;
|
||||
2. `POST /v2/writer-lease`;
|
||||
3. `POST /v2/commit-files`;
|
||||
4. read-only authority-ref visibility verification.
|
||||
|
||||
CREATE_NEW is represented by the task payload and is normalized by the Bridge
|
||||
lease authority. The worker never creates an authoritative ref itself.
|
||||
|
||||
## Lifecycle and recovery
|
||||
|
||||
The runtime registers with `register_or_recover`, receives its agent token
|
||||
once, and stores only the encrypted systemd credential. The consumer loops over
|
||||
poll, atomically claims one task, sends the first heartbeat, emits
|
||||
`TASK_STARTED`, and continues heartbeats while executing.
|
||||
|
||||
State is atomically stored per task without agent or lease token material. If
|
||||
the process stops after a governed commit but before result submission, the
|
||||
next lease attempt reuses the stable governed idempotency key and submits the
|
||||
stored commit evidence without issuing a second commit.
|
||||
|
||||
The control plane must independently verify submitted results; worker-local
|
||||
success is not production or mainline authorization.
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Generic governed Online Agent Worker runtime."""
|
||||
|
||||
__all__ = ["worker"]
|
||||
@@ -0,0 +1,937 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generic, restartable Online Agent Worker.
|
||||
|
||||
The worker is deliberately task-agnostic. A task's signed/durable payload is
|
||||
the only source of repository, branch, change, verification, and governance
|
||||
parameters. There is no task id, base OID, branch name, or plan file in the
|
||||
runtime configuration.
|
||||
|
||||
The worker performs local work only in an ephemeral detached worktree. All
|
||||
authoritative ref updates go through the governed Bridge API; direct branch,
|
||||
update-ref, push, main, production, tag, and baseline mutations are rejected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from abc import ABC, abstractmethod
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
API_BASE = os.environ.get("AIOS_AGENT_TASK_API_URL", "http://127.0.0.1:18940").rstrip("/")
|
||||
BRIDGE_BASE = os.environ.get("AIOS_GOVERNED_WRITE_API_URL", "http://127.0.0.1:18892").rstrip("/")
|
||||
AGENT_ID = os.environ.get(
|
||||
"AIOS_AGENT_ID", "online-engineering-worker-cn-network-mkt-generic-20260815"
|
||||
)
|
||||
AGENT_TYPE = os.environ.get("AIOS_AGENT_TYPE", "trusted-online-engineering-worker")
|
||||
AGENT_VERSION = os.environ.get("AIOS_AGENT_VERSION", "2026-08-15.3-generic")
|
||||
AUTHORITY_GIT_DIR = Path(
|
||||
os.environ.get("AIOS_AUTHORITY_GIT_DIR", "/data/aios-gitops/repo.git")
|
||||
)
|
||||
STATE_ROOT = Path(
|
||||
os.environ.get("AIOS_WORKER_STATE_ROOT", "/var/lib/aios-online-agent-worker-20260815")
|
||||
)
|
||||
WORKSPACE_ROOT = Path(
|
||||
os.environ.get(
|
||||
"AIOS_WORKER_WORKSPACE_ROOT", "/var/lib/aios-online-agent-worker-20260815/workspaces"
|
||||
)
|
||||
)
|
||||
GIT_CONFIG_GLOBAL = STATE_ROOT / "gitconfig"
|
||||
POLL_SECONDS = int(os.environ.get("AIOS_WORKER_POLL_SECONDS", "5"))
|
||||
HEARTBEAT_SECONDS = int(os.environ.get("AIOS_WORKER_HEARTBEAT_SECONDS", "20"))
|
||||
LEASE_SECONDS = int(os.environ.get("AIOS_WORKER_LEASE_SECONDS", "300"))
|
||||
MAX_TASK_BYTES = 512 * 1024
|
||||
MAX_FILE_BYTES = 2 * 1024 * 1024
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
|
||||
PATH_RE = re.compile(r"^[^\\\x00-\x1f\x7f]+$")
|
||||
SENSITIVE_RE = re.compile(
|
||||
r"(?i)(authorization\s*:\s*bearer\s+\S+|(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)"
|
||||
)
|
||||
|
||||
|
||||
class WorkerError(RuntimeError):
|
||||
def __init__(self, code: str, *, phase: str, details: Mapping[str, Any] | None = None) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.phase = phase
|
||||
self.details = dict(details or {})
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def digest_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def digest_json(value: Any) -> str:
|
||||
return digest_bytes(
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def sanitize(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, inner in value.items():
|
||||
lowered = str(key).lower()
|
||||
if any(marker in lowered for marker in ("token", "secret", "credential", "password", "authorization")):
|
||||
cleaned[str(key)] = "[REDACTED]"
|
||||
else:
|
||||
cleaned[str(key)] = sanitize(inner)
|
||||
return cleaned
|
||||
if isinstance(value, list):
|
||||
return [sanitize(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [sanitize(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return SENSITIVE_RE.sub("[REDACTED]", value)
|
||||
return value
|
||||
|
||||
|
||||
def excerpt(value: str, limit: int = 800) -> str:
|
||||
value = str(sanitize(value))
|
||||
return value if len(value) <= limit else value[: limit // 2] + "...[TRUNCATED]..." + value[-limit // 2 :]
|
||||
|
||||
|
||||
def audit(event_type: str, **fields: Any) -> None:
|
||||
STATE_ROOT.mkdir(mode=0o750, parents=True, exist_ok=True)
|
||||
path = STATE_ROOT / "events.jsonl"
|
||||
record = {
|
||||
"event_type": event_type,
|
||||
"created_at": now(),
|
||||
"agent_id": AGENT_ID,
|
||||
"credential_material_exposed": False,
|
||||
"lease_material_exposed": False,
|
||||
**sanitize(fields),
|
||||
}
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
os.chmod(path, 0o640)
|
||||
print(json.dumps(record, ensure_ascii=False, sort_keys=True), flush=True)
|
||||
|
||||
|
||||
def atomic_json(path: Path, value: Mapping[str, Any], mode: int = 0o640) -> None:
|
||||
path.parent.mkdir(mode=0o750, parents=True, exist_ok=True)
|
||||
if path.parent.is_symlink() or path.is_symlink():
|
||||
raise WorkerError("STATE_PATH_SYMLINK_REJECTED", phase="state")
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
||||
try:
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(sanitize(dict(value)), handle, ensure_ascii=False, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, mode)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def safe_relative_path(value: Any, *, field: str = "path") -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > 240:
|
||||
raise WorkerError(f"{field.upper()}_INVALID", phase="validation")
|
||||
if unquote(value) != value or not PATH_RE.fullmatch(value):
|
||||
raise WorkerError(f"{field.upper()}_INVALID", phase="validation")
|
||||
if value.startswith(("/", "~")) or re.match(r"^[A-Za-z]:", value):
|
||||
raise WorkerError(f"{field.upper()}_ABSOLUTE", phase="validation")
|
||||
parts = value.replace("\\", "/").split("/")
|
||||
if any(part in {"", ".", ".."} for part in parts):
|
||||
raise WorkerError(f"{field.upper()}_TRAVERSAL", phase="validation")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def resolve_under(root: Path, relative: str, *, write: bool = False) -> Path:
|
||||
relative = safe_relative_path(relative)
|
||||
root = root.resolve()
|
||||
candidate = (root / relative).resolve(strict=False)
|
||||
if candidate != root and root not in candidate.parents:
|
||||
raise WorkerError("WORKSPACE_ESCAPE", phase="validation")
|
||||
current = root
|
||||
for part in Path(relative).parts:
|
||||
current /= part
|
||||
if current.exists() and current.is_symlink():
|
||||
raise WorkerError("WORKSPACE_SYMLINK_REJECTED", phase="validation")
|
||||
if write and candidate.exists() and candidate.is_symlink():
|
||||
raise WorkerError("WRITE_SYMLINK_REJECTED", phase="validation")
|
||||
return candidate
|
||||
|
||||
|
||||
def validate_oid(value: Any, field: str = "oid") -> str:
|
||||
if not isinstance(value, str) or not OID_RE.fullmatch(value):
|
||||
raise WorkerError(f"{field.upper()}_INVALID", phase="validation")
|
||||
return value
|
||||
|
||||
|
||||
def validate_sha(value: Any, field: str = "sha256") -> str:
|
||||
if not isinstance(value, str) or not SHA256_RE.fullmatch(value):
|
||||
raise WorkerError(f"{field.upper()}_INVALID", phase="validation")
|
||||
return value
|
||||
|
||||
|
||||
def validate_branch(value: Any) -> str:
|
||||
if not isinstance(value, str) or not 3 <= len(value) <= 180:
|
||||
raise WorkerError("TARGET_BRANCH_INVALID", phase="validation")
|
||||
if value in {"main", "master", "HEAD"} or value.startswith(
|
||||
("main/", "master/", "refs/", "tags/", "tag/", "release/", "releases/", "baseline/", "production/")
|
||||
):
|
||||
raise WorkerError("PROTECTED_BRANCH_REJECTED", phase="validation")
|
||||
if (
|
||||
value.startswith("/")
|
||||
or value.endswith(("/", ".", ".lock"))
|
||||
or ".." in value
|
||||
or "//" in value
|
||||
or "@{" in value
|
||||
or any(ch in value for ch in " ~^:?*[\\")
|
||||
or any(part in {"", ".", ".."} for part in value.split("/"))
|
||||
or not BRANCH_RE.fullmatch(value)
|
||||
):
|
||||
raise WorkerError("TARGET_BRANCH_INVALID", phase="validation")
|
||||
return value
|
||||
|
||||
|
||||
def validate_program(argv: Any, *, internal_git: bool = False) -> list[str]:
|
||||
if not isinstance(argv, list) or not argv or not all(isinstance(item, str) and item for item in argv):
|
||||
raise WorkerError("STRUCTURED_ARGV_REQUIRED", phase="validation")
|
||||
command = argv[0]
|
||||
if command not in {"/usr/bin/git", "/usr/bin/python3"}:
|
||||
raise WorkerError("PROGRAM_NOT_ALLOWLISTED", phase="validation", details={"program": Path(command).name})
|
||||
if command.endswith("python3"):
|
||||
if any(item in {"-c", "-", "--command"} for item in argv):
|
||||
raise WorkerError("PYTHON_INLINE_CODE_REJECTED", phase="validation")
|
||||
if "-m" in argv:
|
||||
index = argv.index("-m")
|
||||
if index + 1 >= len(argv) or argv[index + 1] not in {"pytest", "py_compile"}:
|
||||
raise WorkerError("PYTHON_MODULE_NOT_ALLOWLISTED", phase="validation")
|
||||
if command.endswith("git") and not internal_git:
|
||||
rejected = {"push", "update-ref", "receive-pack", "send-pack", "reset", "clean", "branch", "commit", "merge", "rebase"}
|
||||
if any(item in rejected for item in argv[1:]):
|
||||
raise WorkerError("GIT_MUTATION_NOT_ALLOWLISTED", phase="validation")
|
||||
return list(argv)
|
||||
|
||||
|
||||
def run_command(
|
||||
label: str,
|
||||
argv: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
timeout: int,
|
||||
internal_git: bool = False,
|
||||
allowed_returncodes: tuple[int, ...] = (0,),
|
||||
) -> dict[str, Any]:
|
||||
validate_program(argv, internal_git=internal_git)
|
||||
if not 1 <= timeout <= 3600:
|
||||
raise WorkerError("COMMAND_TIMEOUT_INVALID", phase=label)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
shell=False,
|
||||
timeout=timeout,
|
||||
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "GIT_CONFIG_GLOBAL": str(GIT_CONFIG_GLOBAL)},
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise WorkerError("COMMAND_TIMEOUT", phase=label, details={"timeout_seconds": timeout}) from exc
|
||||
result = {
|
||||
"label": label,
|
||||
"argv": [Path(item).name if index == 0 else item for index, item in enumerate(argv)],
|
||||
"exit_code": completed.returncode,
|
||||
"stdout_sha256": digest_bytes(completed.stdout.encode("utf-8", "replace")),
|
||||
"stderr_sha256": digest_bytes(completed.stderr.encode("utf-8", "replace")),
|
||||
"stdout_excerpt": excerpt(completed.stdout),
|
||||
"stderr_excerpt": excerpt(completed.stderr),
|
||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||
}
|
||||
audit("COMMAND_RESULT", **result)
|
||||
if completed.returncode not in allowed_returncodes:
|
||||
raise WorkerError("COMMAND_FAILED", phase=label, details={"exit_code": completed.returncode, "stderr": excerpt(completed.stderr, 400)})
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionEnvelope:
|
||||
task_id: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
adapter: str
|
||||
change_set: tuple[dict[str, Any], ...]
|
||||
verification: tuple[dict[str, Any], ...]
|
||||
governance: dict[str, Any]
|
||||
workspace: dict[str, Any]
|
||||
digest: str
|
||||
|
||||
@classmethod
|
||||
def from_task(cls, task: Mapping[str, Any]) -> "ExecutionEnvelope":
|
||||
task_id = str(task.get("task_id") or "")
|
||||
if not ID_RE.fullmatch(task_id):
|
||||
raise WorkerError("TASK_ID_INVALID", phase="validation")
|
||||
metadata = task.get("metadata")
|
||||
if not isinstance(metadata, Mapping):
|
||||
raise WorkerError("TASK_METADATA_REQUIRED", phase="validation")
|
||||
execution = metadata.get("execution")
|
||||
if not isinstance(execution, Mapping):
|
||||
raise WorkerError("DYNAMIC_EXECUTION_PAYLOAD_REQUIRED", phase="validation")
|
||||
if execution.get("schema") != "aios.online-agent.execution.v2":
|
||||
raise WorkerError("EXECUTION_SCHEMA_INVALID", phase="validation")
|
||||
repository = execution.get("repository")
|
||||
if not isinstance(repository, str) or not repository:
|
||||
raise WorkerError("REPOSITORY_INVALID", phase="validation")
|
||||
base_oid = validate_oid(execution.get("base_oid"), "base_oid")
|
||||
target_branch = validate_branch(execution.get("target_branch"))
|
||||
workspace = execution.get("workspace")
|
||||
if not isinstance(workspace, Mapping):
|
||||
raise WorkerError("WORKSPACE_POLICY_REQUIRED", phase="validation")
|
||||
workspace_value = dict(workspace)
|
||||
if workspace_value.get("authority_git_dir") != str(AUTHORITY_GIT_DIR):
|
||||
raise WorkerError("AUTHORITY_ROOT_INVALID", phase="validation")
|
||||
if workspace_value.get("read_only_repository") is not True:
|
||||
raise WorkerError("REPOSITORY_WRITE_POLICY_INVALID", phase="validation")
|
||||
if workspace_value.get("cleanup_policy") not in {"always", "cleanup_on_success"}:
|
||||
raise WorkerError("WORKSPACE_CLEANUP_POLICY_INVALID", phase="validation")
|
||||
adapter_value = execution.get("adapter")
|
||||
if isinstance(adapter_value, Mapping):
|
||||
adapter = str(adapter_value.get("name") or "")
|
||||
else:
|
||||
adapter = str(adapter_value or "")
|
||||
if adapter not in {"deterministic", "codex", "hermes"}:
|
||||
raise WorkerError("RUNTIME_ADAPTER_UNSUPPORTED", phase="validation")
|
||||
change_set = execution.get("change_set")
|
||||
if not isinstance(change_set, list) or not change_set or len(change_set) > 128:
|
||||
raise WorkerError("CHANGE_SET_INVALID", phase="validation")
|
||||
normalized_changes: list[dict[str, Any]] = []
|
||||
for item in change_set:
|
||||
if not isinstance(item, Mapping):
|
||||
raise WorkerError("CHANGE_OPERATION_INVALID", phase="validation")
|
||||
operation = str(item.get("operation") or "")
|
||||
relative = safe_relative_path(item.get("path"))
|
||||
normalized = dict(item)
|
||||
normalized["operation"] = operation
|
||||
normalized["path"] = relative
|
||||
if operation == "write_file":
|
||||
content = item.get("content")
|
||||
if not isinstance(content, str) or len(content.encode("utf-8")) > MAX_FILE_BYTES:
|
||||
raise WorkerError("WRITE_CONTENT_INVALID", phase="validation")
|
||||
elif operation == "replace_text":
|
||||
if not isinstance(item.get("old"), str) or not item.get("old") or not isinstance(item.get("new"), str):
|
||||
raise WorkerError("REPLACE_TEXT_INVALID", phase="validation")
|
||||
if len(str(item["old"]).encode("utf-8")) > MAX_FILE_BYTES:
|
||||
raise WorkerError("REPLACE_TEXT_INVALID", phase="validation")
|
||||
elif operation == "delete_file":
|
||||
pass
|
||||
else:
|
||||
raise WorkerError("CHANGE_OPERATION_UNSUPPORTED", phase="validation", details={"operation": operation})
|
||||
expected = item.get("expected_sha256")
|
||||
if expected is not None:
|
||||
validate_sha(expected, "expected_sha256")
|
||||
normalized_changes.append(normalized)
|
||||
verification = execution.get("verification") or []
|
||||
if not isinstance(verification, list) or len(verification) > 128:
|
||||
raise WorkerError("VERIFICATION_INVALID", phase="validation")
|
||||
normalized_verification: list[dict[str, Any]] = []
|
||||
for item in verification:
|
||||
if not isinstance(item, Mapping):
|
||||
raise WorkerError("VERIFICATION_COMMAND_INVALID", phase="validation")
|
||||
label = str(item.get("label") or "verification")
|
||||
argv = validate_program(item.get("argv"))
|
||||
cwd_value = item.get("cwd")
|
||||
cwd = "." if cwd_value in {None, "", "."} else safe_relative_path(cwd_value, field="cwd")
|
||||
timeout = item.get("timeout_seconds", 120)
|
||||
if not isinstance(timeout, int) or isinstance(timeout, bool) or not 1 <= timeout <= 3600:
|
||||
raise WorkerError("VERIFICATION_TIMEOUT_INVALID", phase="validation")
|
||||
normalized_verification.append({"label": label, "argv": argv, "cwd": cwd, "timeout_seconds": timeout})
|
||||
governance = execution.get("governance")
|
||||
if not isinstance(governance, Mapping):
|
||||
raise WorkerError("GOVERNANCE_POLICY_REQUIRED", phase="validation")
|
||||
governance_value = dict(governance)
|
||||
governed_task_id = str(governance_value.get("task_id") or f"{task_id}-writer")
|
||||
if not ID_RE.fullmatch(governed_task_id):
|
||||
raise WorkerError("GOVERNED_TASK_ID_INVALID", phase="validation")
|
||||
idempotency_key = str(governance_value.get("idempotency_key") or f"{task_id}-governed-v1")
|
||||
commit_key = str(governance_value.get("commit_idempotency_key") or f"{task_id}-commit-v1")
|
||||
for field, value in (("idempotency_key", idempotency_key), ("commit_idempotency_key", commit_key)):
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{7,159}", value):
|
||||
raise WorkerError(f"{field.upper()}_INVALID", phase="validation")
|
||||
governance_value.update(
|
||||
{
|
||||
"task_id": governed_task_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"commit_idempotency_key": commit_key,
|
||||
"message": str(governance_value.get("message") or f"chore(gitops): execute task {task_id}"),
|
||||
}
|
||||
)
|
||||
for field in ("main_write", "force_push", "production_write", "tag_write", "baseline_write", "protected_ref_bypass"):
|
||||
if governance_value.get(field, False) is not False:
|
||||
raise WorkerError("GOVERNANCE_POLICY_REJECTED", phase="validation", details={"field": field})
|
||||
allowed_files = governance_value.get("allowed_files")
|
||||
if allowed_files is not None:
|
||||
if not isinstance(allowed_files, list):
|
||||
raise WorkerError("ALLOWED_FILES_INVALID", phase="validation")
|
||||
allowed = {safe_relative_path(item, field="allowed_file") for item in allowed_files}
|
||||
if not {item["path"] for item in normalized_changes}.issubset(allowed):
|
||||
raise WorkerError("CHANGE_OUTSIDE_ALLOWED_FILES", phase="validation")
|
||||
governance_value["allowed_files"] = sorted(allowed)
|
||||
return cls(
|
||||
task_id=task_id,
|
||||
repository=repository,
|
||||
base_oid=base_oid,
|
||||
target_branch=target_branch,
|
||||
adapter=adapter,
|
||||
change_set=tuple(normalized_changes),
|
||||
verification=tuple(normalized_verification),
|
||||
governance=governance_value,
|
||||
workspace=workspace_value,
|
||||
digest=digest_json(execution),
|
||||
)
|
||||
|
||||
|
||||
class RuntimeAdapter(ABC):
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DeterministicExecutor(RuntimeAdapter):
|
||||
name = "deterministic"
|
||||
|
||||
@staticmethod
|
||||
def _write_atomic(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(mode=0o750, parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
||||
changed: set[str] = set()
|
||||
for change in envelope.change_set:
|
||||
relative = str(change["path"])
|
||||
path = resolve_under(worktree, relative, write=True)
|
||||
expected = change.get("expected_sha256")
|
||||
current = path.read_bytes() if path.is_file() else None
|
||||
if expected is not None and (current is None or digest_bytes(current) != expected):
|
||||
raise WorkerError("WORKTREE_SOURCE_DRIFT", phase="implementing", details={"path": relative})
|
||||
operation = change["operation"]
|
||||
if operation == "write_file":
|
||||
self._write_atomic(path, str(change["content"]))
|
||||
elif operation == "replace_text":
|
||||
if current is None:
|
||||
raise WorkerError("REPLACE_SOURCE_MISSING", phase="implementing", details={"path": relative})
|
||||
original = current.decode("utf-8")
|
||||
old = str(change["old"])
|
||||
count = original.count(old)
|
||||
expected_count = int(change.get("count", 1))
|
||||
if count != expected_count:
|
||||
raise WorkerError("REPLACE_TEXT_MATCH_COUNT_INVALID", phase="implementing", details={"path": relative, "count": count})
|
||||
self._write_atomic(path, original.replace(old, str(change["new"])))
|
||||
elif operation == "delete_file":
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
changed.add(relative)
|
||||
audit("REPOSITORY_CHANGE_APPLIED", operation=operation, path=relative, sha256=digest_bytes(path.read_bytes()) if path.exists() else None)
|
||||
commands: list[dict[str, Any]] = []
|
||||
if changed:
|
||||
commands.append(run_command("git_intent_to_add", ["/usr/bin/git", "-C", str(worktree), "add", "-N", "--", *sorted(changed)], cwd=worktree, timeout=120, internal_git=True))
|
||||
commands.append(run_command("git_diff_check", ["/usr/bin/git", "-C", str(worktree), "diff", "--check"], cwd=worktree, timeout=120))
|
||||
commands.append(run_command("git_diff_stat", ["/usr/bin/git", "-C", str(worktree), "diff", "--no-ext-diff", "--stat"], cwd=worktree, timeout=120))
|
||||
tests: list[dict[str, Any]] = []
|
||||
for item in envelope.verification:
|
||||
cwd = worktree if item["cwd"] == "." else resolve_under(worktree, item["cwd"])
|
||||
tests.append(run_command(item["label"], item["argv"], cwd=cwd, timeout=item["timeout_seconds"]))
|
||||
return {
|
||||
"changed_files": sorted(changed),
|
||||
"commands": commands,
|
||||
"tests": tests,
|
||||
"adapter": self.name,
|
||||
}
|
||||
|
||||
|
||||
class CodexExecutor(RuntimeAdapter):
|
||||
name = "codex"
|
||||
|
||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
||||
raise WorkerError(
|
||||
"CODEX_ADAPTER_REQUIRES_EXPLICIT_POLICY",
|
||||
phase="adapter",
|
||||
details={"reason": "Codex is not allowed to mutate a governed worktree without an explicit file/command policy"},
|
||||
)
|
||||
|
||||
|
||||
class HermesExecutor(RuntimeAdapter):
|
||||
name = "hermes"
|
||||
|
||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
||||
raise WorkerError(
|
||||
"HERMES_ENGINEERING_ADAPTER_UNAVAILABLE",
|
||||
phase="adapter",
|
||||
details={"reason": "The live Hermes service is a user-chat runtime, not an engineering executor"},
|
||||
)
|
||||
|
||||
|
||||
def adapter_for(name: str) -> RuntimeAdapter:
|
||||
return {"deterministic": DeterministicExecutor, "codex": CodexExecutor, "hermes": HermesExecutor}[name]()
|
||||
|
||||
|
||||
class HttpClient:
|
||||
@staticmethod
|
||||
def call(base: str, method: str, route: str, token: str | None, body: Mapping[str, Any] | None = None, timeout: int = 30) -> tuple[int, dict[str, Any]]:
|
||||
payload = None if body is None else json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
headers = {"Accept": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(base + route, data=payload, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
value = json.loads(response.read().decode("utf-8"))
|
||||
return response.status, value if isinstance(value, dict) else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read(8192).decode("utf-8", "replace")
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
value = {"error": "HTTP_ERROR"}
|
||||
return exc.code, value if isinstance(value, dict) else {"error": "HTTP_ERROR"}
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
return 599, {"error": "SERVICE_UNAVAILABLE"}
|
||||
|
||||
|
||||
class AgentTaskClient:
|
||||
def __init__(self, token: str) -> None:
|
||||
self.token = token
|
||||
|
||||
def poll(self) -> list[dict[str, Any]]:
|
||||
status, response = HttpClient.call(API_BASE, "POST", "/v1/tasks/poll", self.token, {"limit": 100})
|
||||
if status != 200:
|
||||
raise WorkerError("TASK_POLL_FAILED", phase="poll", details={"api_status": status, "error": response.get("error")})
|
||||
tasks = response.get("tasks")
|
||||
return [dict(item) for item in tasks if isinstance(item, Mapping)] if isinstance(tasks, list) else []
|
||||
|
||||
def claim(self, task_id: str) -> tuple[int, dict[str, Any]]:
|
||||
return HttpClient.call(API_BASE, "POST", f"/v1/tasks/{task_id}/claim", self.token, {"lease_seconds": LEASE_SECONDS})
|
||||
|
||||
def heartbeat(self, task_id: str, lease_token: str, stage: str) -> tuple[int, dict[str, Any]]:
|
||||
return HttpClient.call(
|
||||
API_BASE,
|
||||
"POST",
|
||||
f"/v1/tasks/{task_id}/heartbeat",
|
||||
self.token,
|
||||
{"lease_token": lease_token, "lease_seconds": LEASE_SECONDS, "progress": {"stage": stage, "worker_version": AGENT_VERSION}},
|
||||
)
|
||||
|
||||
def result(self, task_id: str, lease_token: str, idempotency_key: str, result: Mapping[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||
return HttpClient.call(
|
||||
API_BASE,
|
||||
"POST",
|
||||
f"/v1/tasks/{task_id}/result",
|
||||
self.token,
|
||||
{"lease_token": lease_token, "idempotency_key": idempotency_key, "result": dict(result)},
|
||||
)
|
||||
|
||||
|
||||
class BridgeClient:
|
||||
def call(self, route: str, body: Mapping[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||
return HttpClient.call(BRIDGE_BASE, "POST", route, None, body, timeout=60)
|
||||
|
||||
|
||||
class LeaseHeartbeat:
|
||||
def __init__(self, client: AgentTaskClient, task_id: str, lease_token: str) -> None:
|
||||
self.client = client
|
||||
self.task_id = task_id
|
||||
self.lease_token = lease_token
|
||||
self.stage = "claimed"
|
||||
self.failed: str | None = None
|
||||
self.stop_event = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
self.thread = threading.Thread(target=self._run, name="online-agent-heartbeat", daemon=True)
|
||||
|
||||
def first(self) -> None:
|
||||
status, response = self.client.heartbeat(self.task_id, self.lease_token, self.stage)
|
||||
if not 200 <= status < 300:
|
||||
self.failed = str(response.get("error") or status)
|
||||
raise WorkerError("TASK_HEARTBEAT_FAILED", phase="heartbeat", details={"api_status": status, "error": self.failed})
|
||||
audit("TASK_HEARTBEAT", task_id=self.task_id, stage=self.stage, api_status=status)
|
||||
|
||||
def start(self) -> None:
|
||||
self.thread.start()
|
||||
|
||||
def set_stage(self, stage: str) -> None:
|
||||
with self.lock:
|
||||
self.stage = stage
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self.stop_event.wait(HEARTBEAT_SECONDS):
|
||||
with self.lock:
|
||||
stage = self.stage
|
||||
try:
|
||||
status, response = self.client.heartbeat(self.task_id, self.lease_token, stage)
|
||||
if not 200 <= status < 300:
|
||||
self.failed = str(response.get("error") or status)
|
||||
audit("TASK_HEARTBEAT_FAILED", task_id=self.task_id, stage=stage, api_status=status, error=self.failed)
|
||||
else:
|
||||
audit("TASK_HEARTBEAT", task_id=self.task_id, stage=stage, api_status=status)
|
||||
except Exception as exc: # pragma: no cover - defensive runtime guard
|
||||
self.failed = type(exc).__name__
|
||||
audit("TASK_HEARTBEAT_FAILED", task_id=self.task_id, stage=stage, error=self.failed)
|
||||
|
||||
def ensure(self) -> None:
|
||||
if self.failed:
|
||||
raise WorkerError("TASK_LEASE_LOST", phase="heartbeat", details={"error": self.failed})
|
||||
|
||||
def close(self) -> None:
|
||||
self.stop_event.set()
|
||||
if self.thread.ident is not None:
|
||||
self.thread.join(timeout=2)
|
||||
|
||||
|
||||
def load_agent_token() -> str:
|
||||
directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", ""))
|
||||
path = directory / "agent_token"
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise WorkerError("AGENT_RUNTIME_CREDENTIAL_UNAVAILABLE", phase="startup")
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 24:
|
||||
raise WorkerError("AGENT_RUNTIME_CREDENTIAL_INVALID", phase="startup")
|
||||
return value
|
||||
|
||||
|
||||
def ensure_roots() -> None:
|
||||
if AUTHORITY_GIT_DIR.is_symlink() or STATE_ROOT.is_symlink() or WORKSPACE_ROOT.is_symlink():
|
||||
raise WorkerError("ROOT_SYMLINK_REJECTED", phase="startup")
|
||||
if not AUTHORITY_GIT_DIR.is_dir():
|
||||
raise WorkerError("AUTHORITY_GIT_DIR_UNAVAILABLE", phase="startup")
|
||||
STATE_ROOT.mkdir(mode=0o750, parents=True, exist_ok=True)
|
||||
WORKSPACE_ROOT.mkdir(mode=0o750, parents=True, exist_ok=True)
|
||||
if GIT_CONFIG_GLOBAL.is_symlink():
|
||||
raise WorkerError("GIT_CONFIG_SYMLINK_REJECTED", phase="startup")
|
||||
expected = f"[safe]\n\tdirectory = {AUTHORITY_GIT_DIR}\n"
|
||||
if GIT_CONFIG_GLOBAL.exists() and GIT_CONFIG_GLOBAL.read_text(encoding="utf-8") != expected:
|
||||
raise WorkerError("GIT_CONFIG_BINDING_CONFLICT", phase="startup")
|
||||
if not GIT_CONFIG_GLOBAL.exists():
|
||||
GIT_CONFIG_GLOBAL.write_text(expected, encoding="utf-8")
|
||||
os.chmod(GIT_CONFIG_GLOBAL, 0o640)
|
||||
|
||||
|
||||
def task_state_path(task_id: str) -> Path:
|
||||
if not ID_RE.fullmatch(task_id):
|
||||
raise WorkerError("TASK_ID_INVALID", phase="state")
|
||||
return STATE_ROOT / "tasks" / f"{task_id}.json"
|
||||
|
||||
|
||||
def load_task_state(task_id: str, execution_digest: str) -> dict[str, Any] | None:
|
||||
path = task_state_path(task_id)
|
||||
if not path.is_file() or path.is_symlink():
|
||||
return None
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raise WorkerError("TASK_STATE_INVALID", phase="state")
|
||||
if not isinstance(value, dict):
|
||||
raise WorkerError("TASK_STATE_INVALID", phase="state")
|
||||
if value.get("execution_digest") not in {None, execution_digest}:
|
||||
raise WorkerError("TASK_PAYLOAD_DRIFT", phase="recovery")
|
||||
return value
|
||||
|
||||
|
||||
def save_task_state(task_id: str, value: Mapping[str, Any]) -> None:
|
||||
atomic_json(task_state_path(task_id), value)
|
||||
|
||||
|
||||
def new_run_root() -> Path:
|
||||
ensure_roots()
|
||||
root = Path(tempfile.mkdtemp(prefix="task-", dir=str(WORKSPACE_ROOT)))
|
||||
os.chmod(root, 0o750)
|
||||
return root
|
||||
|
||||
|
||||
def cleanup_run_root(path: Path) -> None:
|
||||
root = WORKSPACE_ROOT.resolve()
|
||||
candidate = path.resolve(strict=False)
|
||||
if candidate == root or root not in candidate.parents:
|
||||
raise WorkerError("CLEANUP_SCOPE_INVALID", phase="cleanup")
|
||||
if candidate.exists() or candidate.is_symlink():
|
||||
shutil.rmtree(candidate)
|
||||
|
||||
|
||||
def prepare_worktree(run_root: Path, base_oid: str) -> Path:
|
||||
clone = run_root / "repository"
|
||||
worktree = run_root / "worktree"
|
||||
run_command("workspace_clone", ["/usr/bin/git", "clone", "--no-local", "--no-hardlinks", str(AUTHORITY_GIT_DIR), str(clone)], cwd=run_root, timeout=300, internal_git=True)
|
||||
run_command("workspace_base_probe", ["/usr/bin/git", "-C", str(clone), "rev-parse", "--verify", base_oid + "^{commit}"], cwd=run_root, timeout=60)
|
||||
run_command("workspace_worktree_create", ["/usr/bin/git", "-C", str(clone), "worktree", "add", "--detach", str(worktree), base_oid], cwd=run_root, timeout=300, internal_git=True)
|
||||
return worktree
|
||||
|
||||
|
||||
def governed_commit(envelope: ExecutionEnvelope, worktree: Path, bridge: BridgeClient, heartbeat: LeaseHeartbeat) -> dict[str, Any]:
|
||||
governance = envelope.governance
|
||||
create_body = {
|
||||
"task_id": governance["task_id"],
|
||||
"repository": envelope.repository,
|
||||
"base_oid": envelope.base_oid,
|
||||
"target_branch": envelope.target_branch,
|
||||
"risk": str(governance.get("risk") or "engineering"),
|
||||
"description": str(governance.get("description") or f"Governed execution for {envelope.task_id}"),
|
||||
"idempotency_key": governance["idempotency_key"],
|
||||
}
|
||||
status, response = bridge.call("/v2/governed-task", create_body)
|
||||
if not 200 <= status < 300:
|
||||
raise WorkerError("GOVERNED_TASK_CREATE_FAILED", phase="committing", details={"api_status": status, "error": response.get("error")})
|
||||
generation = int(response.get("generation") or 0)
|
||||
expected_branch_oid = str(response.get("expected_branch_oid") or envelope.base_oid)
|
||||
branch_mode = str(response.get("branch_mode") or "CREATE_NEW")
|
||||
lease_body = {
|
||||
"schema": "aios.task-writer-lease.v2",
|
||||
"task_id": governance["task_id"],
|
||||
"repository": envelope.repository,
|
||||
"branch_mode": branch_mode,
|
||||
"base_oid": envelope.base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"target_branch": envelope.target_branch,
|
||||
"generation": generation,
|
||||
"lease_seconds": int(governance.get("lease_seconds") or 900),
|
||||
}
|
||||
status, lease_response = bridge.call("/v2/writer-lease", lease_body)
|
||||
if not 200 <= status < 300:
|
||||
raise WorkerError("WRITER_LEASE_ACQUIRE_FAILED", phase="committing", details={"api_status": status, "error": lease_response.get("error")})
|
||||
heartbeat.ensure()
|
||||
files: dict[str, str] = {}
|
||||
paths = governance.get("allowed_files") or [item["path"] for item in envelope.change_set]
|
||||
for relative in sorted({safe_relative_path(item, field="allowed_file") for item in paths}):
|
||||
path = resolve_under(worktree, relative)
|
||||
if not path.is_file():
|
||||
raise WorkerError("COMMIT_FILE_MISSING", phase="committing", details={"path": relative})
|
||||
files[relative] = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
commit_body = {
|
||||
"task_id": governance["task_id"],
|
||||
"repository": envelope.repository,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"target_branch": envelope.target_branch,
|
||||
"generation": generation,
|
||||
"idempotency_key": governance["commit_idempotency_key"],
|
||||
"files": files,
|
||||
"message": governance["message"],
|
||||
}
|
||||
status, commit_response = bridge.call("/v2/commit-files", commit_body)
|
||||
if not 200 <= status < 300:
|
||||
raise WorkerError("GOVERNED_COMMIT_FAILED", phase="committing", details={"api_status": status, "error": commit_response.get("error")})
|
||||
commit_oid = commit_response.get("commit_oid")
|
||||
if not isinstance(commit_oid, str) or not OID_RE.fullmatch(commit_oid):
|
||||
raise WorkerError("GOVERNED_COMMIT_OID_INVALID", phase="committing")
|
||||
visible_oid: str | None = None
|
||||
for _ in range(5):
|
||||
probe = subprocess.run(
|
||||
["/usr/bin/git", "--git-dir", str(AUTHORITY_GIT_DIR), "rev-parse", "--verify", f"refs/heads/{envelope.target_branch}"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "GIT_CONFIG_GLOBAL": str(GIT_CONFIG_GLOBAL)},
|
||||
timeout=30,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
visible_oid = probe.stdout.strip()
|
||||
if visible_oid == commit_oid:
|
||||
break
|
||||
time.sleep(1)
|
||||
if visible_oid != commit_oid:
|
||||
raise WorkerError("GIT_VISIBILITY_MISMATCH", phase="verification", details={"commit_oid": commit_oid, "visible_oid": visible_oid})
|
||||
return {
|
||||
"governed_task_id": governance["task_id"],
|
||||
"branch_mode": branch_mode,
|
||||
"generation": generation,
|
||||
"target_branch": envelope.target_branch,
|
||||
"commit_oid": commit_oid,
|
||||
"visible_oid": visible_oid,
|
||||
"commit_idempotency_key": governance["commit_idempotency_key"],
|
||||
"api_commit_visible": commit_response.get("commit_visible"),
|
||||
"direct_main_write": False,
|
||||
"force_push": False,
|
||||
"production_write": False,
|
||||
"protected_ref_bypass": False,
|
||||
}
|
||||
|
||||
|
||||
def build_result(task_id: str, envelope: ExecutionEnvelope, execution: Mapping[str, Any], governed: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "SUCCEEDED",
|
||||
"verification_state": "VERIFIED_BY_WORKER_PENDING_SERVER_REVIEW",
|
||||
"task_id": task_id,
|
||||
"execution_backend": "generic-online-agent-worker-v2",
|
||||
"adapter": envelope.adapter,
|
||||
"execution_digest": envelope.digest,
|
||||
"repository": envelope.repository,
|
||||
"base_oid": envelope.base_oid,
|
||||
"target_branch": envelope.target_branch,
|
||||
"changed_files": execution.get("changed_files", []),
|
||||
"commands": [{k: item.get(k) for k in ("label", "exit_code", "stdout_sha256", "stderr_sha256", "duration_ms")} for item in execution.get("commands", [])],
|
||||
"tests": [{k: item.get(k) for k in ("label", "exit_code", "stdout_sha256", "stderr_sha256", "duration_ms")} for item in execution.get("tests", [])],
|
||||
"governed": dict(governed),
|
||||
"workspace_cleanup": "PASS",
|
||||
"secret_material_exposed": False,
|
||||
"direct_main_write": False,
|
||||
"force_push": False,
|
||||
"production_write": False,
|
||||
"protected_ref_bypass": False,
|
||||
}
|
||||
|
||||
|
||||
class WorkerSupervisor:
|
||||
def __init__(self, token: str) -> None:
|
||||
self.agent = AgentTaskClient(token)
|
||||
self.bridge = BridgeClient()
|
||||
|
||||
def process_claim(self, task: Mapping[str, Any], claim: Mapping[str, Any]) -> None:
|
||||
task_id = str(claim.get("task_id") or task.get("task_id") or "")
|
||||
lease_token = claim.get("lease_token")
|
||||
if not isinstance(lease_token, str) or len(lease_token) < 24:
|
||||
raise WorkerError("LEASE_TOKEN_NOT_RETURNED", phase="claim")
|
||||
audit("TASK_CLAIMED", task_id=task_id, attempt=claim.get("attempt"), revision=claim.get("revision"))
|
||||
heartbeat = LeaseHeartbeat(self.agent, task_id, lease_token)
|
||||
try:
|
||||
heartbeat.first()
|
||||
heartbeat.set_stage("started")
|
||||
heartbeat.start()
|
||||
audit("TASK_STARTED", task_id=task_id, attempt=claim.get("attempt"))
|
||||
envelope = ExecutionEnvelope.from_task(task)
|
||||
state = load_task_state(task_id, envelope.digest) or {"task_id": task_id, "execution_digest": envelope.digest, "phase": "claimed"}
|
||||
governed = state.get("governed") if isinstance(state.get("governed"), Mapping) else None
|
||||
if isinstance(governed, Mapping) and isinstance(governed.get("commit_oid"), str) and OID_RE.fullmatch(governed["commit_oid"]):
|
||||
result = state.get("result")
|
||||
if not isinstance(result, Mapping):
|
||||
result = {"status": "SUCCEEDED", "verification_state": "VERIFIED_BY_WORKER_PENDING_SERVER_REVIEW", "task_id": task_id, "execution_digest": envelope.digest, "governed": dict(governed), "recovered_without_duplicate_commit": True, "direct_main_write": False, "production_write": False, "force_push": False, "protected_ref_bypass": False}
|
||||
audit("TASK_RECOVERED_COMMIT", task_id=task_id, commit_oid=governed["commit_oid"], duplicate_commit=False)
|
||||
else:
|
||||
run_root = new_run_root()
|
||||
try:
|
||||
worktree = prepare_worktree(run_root, envelope.base_oid)
|
||||
state.update({"phase": "workspace_prepared", "execution_digest": envelope.digest, "attempt": claim.get("attempt")})
|
||||
save_task_state(task_id, state)
|
||||
adapter = adapter_for(envelope.adapter)
|
||||
execution = adapter.execute(envelope, worktree)
|
||||
state.update({"phase": "verification_passed", "execution": execution})
|
||||
save_task_state(task_id, state)
|
||||
heartbeat.ensure()
|
||||
heartbeat.set_stage("committing")
|
||||
governed = governed_commit(envelope, worktree, self.bridge, heartbeat)
|
||||
result = build_result(task_id, envelope, execution, governed)
|
||||
state.update({"phase": "commit_done", "governed": governed, "result": result})
|
||||
save_task_state(task_id, state)
|
||||
finally:
|
||||
cleanup_run_root(run_root)
|
||||
audit("WORKSPACE_CLEANED", task_id=task_id, cleanup="PASS")
|
||||
heartbeat.ensure()
|
||||
heartbeat.set_stage("result")
|
||||
result_key = str(result.get("governed", {}).get("commit_idempotency_key") or f"{task_id}-result-{envelope.digest[:16]}")
|
||||
status, response = self.agent.result(task_id, lease_token, result_key, result)
|
||||
if not 200 <= status < 300:
|
||||
raise WorkerError("TASK_RESULT_SUBMIT_FAILED", phase="result", details={"api_status": status, "error": response.get("error")})
|
||||
audit("TASK_RESULT_SUBMITTED", task_id=task_id, api_status=status, result_idempotency_key=result_key, commit_oid=result.get("governed", {}).get("commit_oid"))
|
||||
except WorkerError as exc:
|
||||
audit("TASK_FAILED", task_id=task_id, error=exc.code, phase=exc.phase, details=exc.details)
|
||||
failure_key = f"{task_id}-attempt-{claim.get('attempt', 0)}-failure"
|
||||
failure = {
|
||||
"status": "FAILED",
|
||||
"verification_state": "UNVERIFIED",
|
||||
"task_id": task_id,
|
||||
"execution_backend": "generic-online-agent-worker-v2",
|
||||
"error": exc.code,
|
||||
"phase": exc.phase,
|
||||
"details": exc.details,
|
||||
"direct_main_write": False,
|
||||
"force_push": False,
|
||||
"production_write": False,
|
||||
"protected_ref_bypass": False,
|
||||
"secret_material_exposed": False,
|
||||
}
|
||||
try:
|
||||
status, response = self.agent.result(task_id, lease_token, failure_key, failure)
|
||||
audit("TASK_FAILURE_RESULT_SUBMITTED", task_id=task_id, api_status=status, error=response.get("error"))
|
||||
except Exception as submit_exc: # pragma: no cover - defensive runtime guard
|
||||
audit("TASK_FAILURE_RESULT_SUBMIT_FAILED", task_id=task_id, error=type(submit_exc).__name__)
|
||||
finally:
|
||||
heartbeat.close()
|
||||
|
||||
def once(self) -> int:
|
||||
try:
|
||||
tasks = self.agent.poll()
|
||||
audit("TASK_POLL", visible_count=len(tasks))
|
||||
except WorkerError as exc:
|
||||
audit("TASK_POLL_FAILED", error=exc.code, phase=exc.phase, details=exc.details)
|
||||
return 10
|
||||
dynamic_tasks: list[dict[str, Any]] = []
|
||||
for task in tasks:
|
||||
metadata = task.get("metadata")
|
||||
execution = metadata.get("execution") if isinstance(metadata, Mapping) else None
|
||||
if not isinstance(execution, Mapping) or execution.get("schema") != "aios.online-agent.execution.v2":
|
||||
audit("TASK_SKIPPED_NON_DYNAMIC", task_id=task.get("task_id"), reason="DYNAMIC_EXECUTION_PAYLOAD_REQUIRED")
|
||||
continue
|
||||
dynamic_tasks.append(task)
|
||||
for task in dynamic_tasks:
|
||||
task_id = str(task.get("task_id") or "")
|
||||
status, claim = self.agent.claim(task_id)
|
||||
if status == 409:
|
||||
audit("TASK_CLAIM_SKIPPED", task_id=task_id, api_status=status, error=claim.get("error"))
|
||||
continue
|
||||
if status != 200:
|
||||
audit("TASK_CLAIM_FAILED", task_id=task_id, api_status=status, error=claim.get("error"))
|
||||
continue
|
||||
self.process_claim(task, claim)
|
||||
return 0
|
||||
return 0
|
||||
|
||||
def run_forever(self) -> int:
|
||||
ensure_roots()
|
||||
audit("WORKER_STARTED", task_agnostic=True, api_base=API_BASE, bridge_base=BRIDGE_BASE, adapter_names=["deterministic", "codex", "hermes"], runtime_only_credentials=True)
|
||||
while True:
|
||||
self.once()
|
||||
time.sleep(POLL_SECONDS)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
token = load_agent_token()
|
||||
supervisor = WorkerSupervisor(token)
|
||||
if os.environ.get("AIOS_WORKER_ONCE") == "1":
|
||||
ensure_roots()
|
||||
return supervisor.once()
|
||||
return supervisor.run_forever()
|
||||
except WorkerError as exc:
|
||||
audit("WORKER_START_FAILED", error=exc.code, phase=exc.phase, details=exc.details)
|
||||
return 20
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Register or recover the generic Online Agent using runtime-only secrets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
BASE = os.environ.get("AIOS_AGENT_TASK_API_URL", "http://127.0.0.1:18940").rstrip("/")
|
||||
AGENT_ID = os.environ.get("AIOS_AGENT_ID", "online-engineering-worker-cn-network-mkt-generic-20260815")
|
||||
AGENT_TYPE = os.environ.get("AIOS_AGENT_TYPE", "trusted-online-engineering-worker")
|
||||
AGENT_VERSION = os.environ.get("AIOS_AGENT_VERSION", "2026-08-15.3-generic")
|
||||
PROJECTION_PROVIDER = "BridgeV2RuntimeCredentialProjection"
|
||||
STATE_DIR = Path(os.environ.get("AIOS_WORKER_REGISTRATION_STATE", "/var/lib/aios-online-agent-worker-20260815/registration"))
|
||||
CREDENTIAL_PATH = Path(os.environ.get("AIOS_WORKER_ENCRYPTED_CREDENTIAL", "/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token"))
|
||||
IDENTITY_PATH = STATE_DIR / "identity.json"
|
||||
AUDIT_PATH = STATE_DIR / "events.jsonl"
|
||||
CAPABILITIES = [
|
||||
"online_agent_task_api", "task.poll", "task.claim", "task.heartbeat", "task.result",
|
||||
"engineering.consumer", "engineering.execute", "workspace.worktree", "git.read", "git.diff",
|
||||
"git.test", "governed.write.branch", "governed.write.file",
|
||||
]
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def audit(event_type: str, **fields: Any) -> None:
|
||||
STATE_DIR.mkdir(mode=0o750, parents=True, exist_ok=True)
|
||||
record = {"event_type": event_type, "created_at": now(), "agent_id": AGENT_ID, "credential_material_exposed": False, **fields}
|
||||
with AUDIT_PATH.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
os.chmod(AUDIT_PATH, 0o640)
|
||||
print(json.dumps(record, ensure_ascii=False, sort_keys=True), flush=True)
|
||||
|
||||
|
||||
def api_call(method: str, route: str, token: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
|
||||
payload = None if body is None else json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
headers = {"Authorization": "Bearer " + token, "Accept": "application/json"}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(BASE + route, data=payload, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
value = json.loads(response.read().decode("utf-8"))
|
||||
return response.status, value if isinstance(value, dict) else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
value = json.loads(exc.read(2048).decode("utf-8"))
|
||||
except Exception:
|
||||
value = {"error": "HTTP_ERROR"}
|
||||
return exc.code, value if isinstance(value, dict) else {"error": "HTTP_ERROR"}
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
return 599, {"error": "AGENT_TASK_API_UNAVAILABLE"}
|
||||
|
||||
|
||||
def read_identity() -> dict[str, Any] | None:
|
||||
if not IDENTITY_PATH.is_file() or IDENTITY_PATH.is_symlink():
|
||||
return None
|
||||
try:
|
||||
value = json.loads(IDENTITY_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def identity_matches(value: dict[str, Any] | None) -> bool:
|
||||
return bool(value and value.get("agent_id") == AGENT_ID and value.get("agent_type") == AGENT_TYPE and value.get("agent_version") == AGENT_VERSION and value.get("credential_projection_provider") == PROJECTION_PROVIDER and value.get("task_scope") == "dynamic")
|
||||
|
||||
|
||||
def encrypted_credential_is_valid() -> bool:
|
||||
if not CREDENTIAL_PATH.is_file() or CREDENTIAL_PATH.is_symlink():
|
||||
return False
|
||||
result = subprocess.run(["systemd-creds", "decrypt", "--with-key=host", str(CREDENTIAL_PATH), "/dev/null"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, timeout=15)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def write_identity() -> None:
|
||||
value = {"agent_id": AGENT_ID, "agent_type": AGENT_TYPE, "agent_version": AGENT_VERSION, "task_scope": "dynamic", "credential_projection_provider": PROJECTION_PROVIDER, "credential_delivery": "systemd-LoadCredentialEncrypted", "task_api_url": BASE, "task_api_schema": "aios.agent-task.v1", "execution_backend": "generic-online-agent-worker-v2", "runtime_only_secret_delivery": True, "production_write": False, "main_write": False, "force_push": False, "tag_write": False, "baseline_write": False, "writer_lease": True, "created_at": now()}
|
||||
STATE_DIR.mkdir(mode=0o750, parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix="identity.", dir=str(STATE_DIR))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, ensure_ascii=False, sort_keys=True)
|
||||
handle.write("\n")
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, IDENTITY_PATH)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def encrypt_agent_token(agent_token: str) -> None:
|
||||
CREDENTIAL_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".online-agent-token.", dir=str(CREDENTIAL_PATH.parent))
|
||||
os.close(fd)
|
||||
os.unlink(temporary)
|
||||
try:
|
||||
result = subprocess.run(["systemd-creds", "encrypt", "--with-key=host", "--name=agent_token", "/dev/stdin", temporary], input=(agent_token + "\n").encode("utf-8"), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, timeout=20)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("AGENT_CREDENTIAL_ENCRYPT_FAILED")
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, CREDENTIAL_PATH)
|
||||
os.chmod(CREDENTIAL_PATH, 0o600)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def register_or_recover() -> str:
|
||||
STATE_DIR.mkdir(mode=0o750, parents=True, exist_ok=True)
|
||||
existing = read_identity()
|
||||
if CREDENTIAL_PATH.exists():
|
||||
if not identity_matches(existing):
|
||||
raise RuntimeError("AGENT_IDENTITY_BINDING_CONFLICT")
|
||||
if not encrypted_credential_is_valid():
|
||||
raise RuntimeError("AGENT_ENCRYPTED_CREDENTIAL_INVALID")
|
||||
audit("AGENT_RECOVERED", credential_delivery="systemd-LoadCredentialEncrypted", credential_projection_provider=PROJECTION_PROVIDER, register_or_recover=True)
|
||||
return "RECOVERED"
|
||||
if existing is not None:
|
||||
raise RuntimeError("AGENT_CREDENTIAL_MISSING_AFTER_PRIOR_REGISTRATION")
|
||||
credential_directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", ""))
|
||||
control_path = credential_directory / "bridge_control_token"
|
||||
if not control_path.is_file():
|
||||
raise RuntimeError("BRIDGE_RUNTIME_CONTROL_CREDENTIAL_UNAVAILABLE")
|
||||
control_token = control_path.read_text(encoding="utf-8").strip()
|
||||
if len(control_token) < 32:
|
||||
raise RuntimeError("BRIDGE_RUNTIME_CONTROL_CREDENTIAL_INVALID")
|
||||
status, response = api_call("POST", "/v1/agents/register", control_token, {"agent_id": AGENT_ID, "agent_type": AGENT_TYPE, "agent_version": AGENT_VERSION, "capabilities": CAPABILITIES, "metadata": {"task_scope": "dynamic", "credential_projection_provider": PROJECTION_PROVIDER, "runtime_only_secret_delivery": True, "task_api_schema": "aios.agent-task.v1", "execution_backend": "generic-online-agent-worker-v2", "production_write": False, "main_write": False, "force_push": False, "tag_write": False, "baseline_write": False, "writer_lease": True}})
|
||||
if status == 409 and response.get("error") == "AGENT_ALREADY_EXISTS":
|
||||
raise RuntimeError("AGENT_ALREADY_EXISTS_RECOVERY_CREDENTIAL_UNAVAILABLE")
|
||||
if status != 201:
|
||||
raise RuntimeError("AGENT_REGISTER_FAILED_" + str(response.get("error") or status))
|
||||
agent_token = response.get("agent_token")
|
||||
if not isinstance(agent_token, str) or len(agent_token) < 24:
|
||||
raise RuntimeError("AGENT_TOKEN_NOT_RETURNED")
|
||||
encrypt_agent_token(agent_token)
|
||||
write_identity()
|
||||
audit("AGENT_REGISTERED", credential_delivery="systemd-LoadCredentialEncrypted", credential_projection_provider=PROJECTION_PROVIDER, register_or_recover=True)
|
||||
return "REGISTERED"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(0 if register_or_recover() in {"REGISTERED", "RECOVERED"} else 20)
|
||||
except Exception as exc:
|
||||
audit("AGENT_REGISTRATION_FAILED", error=type(exc).__name__)
|
||||
raise SystemExit(20)
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aios_online_agent_worker.worker import ExecutionEnvelope, WorkerError, sanitize # noqa: E402
|
||||
|
||||
|
||||
BASE = "a" * 40
|
||||
|
||||
|
||||
def payload(task_id: str, branch: str, *, base_oid: str = BASE) -> dict[str, object]:
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"schema": "aios.online-agent.execution.v2",
|
||||
"repository": "aios/aios",
|
||||
"base_oid": base_oid,
|
||||
"target_branch": branch,
|
||||
"adapter": "deterministic",
|
||||
"workspace": {
|
||||
"authority_git_dir": str(Path(os.environ.get("AIOS_AUTHORITY_GIT_DIR", "/data/aios-gitops/repo.git"))),
|
||||
"read_only_repository": True,
|
||||
"cleanup_policy": "always",
|
||||
},
|
||||
"change_set": [
|
||||
{
|
||||
"operation": "write_file",
|
||||
"path": "tests/generated_marker.py",
|
||||
"content": "def test_marker():\n assert True\n",
|
||||
"expected_sha256": None,
|
||||
}
|
||||
],
|
||||
"verification": [
|
||||
{"label": "compile", "argv": ["/usr/bin/python3", "-m", "py_compile", "tests/generated_marker.py"], "cwd": ".", "timeout_seconds": 60}
|
||||
],
|
||||
"governance": {
|
||||
"task_id": f"{task_id}-writer",
|
||||
"idempotency_key": f"{task_id}-governed-v1",
|
||||
"commit_idempotency_key": f"{task_id}-commit-v1",
|
||||
"allowed_files": ["tests/generated_marker.py"],
|
||||
"main_write": False,
|
||||
"force_push": False,
|
||||
"production_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"protected_ref_bypass": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_dynamic_payload_is_not_bound_to_task_or_base() -> None:
|
||||
first = ExecutionEnvelope.from_task(payload("task-alpha-20260815", "agent/alpha-20260815"))
|
||||
second = ExecutionEnvelope.from_task(payload("task-beta-20260815", "agent/beta-20260815", base_oid="b" * 40))
|
||||
assert first.task_id != second.task_id
|
||||
assert first.base_oid != second.base_oid
|
||||
assert first.target_branch != second.target_branch
|
||||
|
||||
|
||||
def test_protected_branch_is_rejected() -> None:
|
||||
try:
|
||||
ExecutionEnvelope.from_task(payload("task-main-20260815", "main"))
|
||||
except WorkerError as exc:
|
||||
assert exc.code == "PROTECTED_BRANCH_REJECTED"
|
||||
else:
|
||||
raise AssertionError("protected branch accepted")
|
||||
|
||||
|
||||
def test_task_payload_does_not_persist_secret_values() -> None:
|
||||
value = sanitize({"agent_token": "agt-secret", "nested": {"authorization": "Bearer secret"}})
|
||||
assert value == {"agent_token": "[REDACTED]", "nested": {"authorization": "[REDACTED]"}}
|
||||
|
||||
|
||||
def test_execution_digest_is_stable() -> None:
|
||||
value = payload("task-digest-20260815", "agent/digest-20260815")
|
||||
assert ExecutionEnvelope.from_task(value).digest == ExecutionEnvelope.from_task(value).digest
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
BROKER_PATH = Path(__file__).parents[1] / "services" / "aios-agent-runtime-broker" / "broker.py"
|
||||
|
||||
|
||||
def load_broker():
|
||||
spec = importlib.util.spec_from_file_location("broker_under_test", BROKER_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_peer_credentials_accept_configured_uid(monkeypatch) -> None:
|
||||
broker = load_broker()
|
||||
left, right = socket.socketpair()
|
||||
try:
|
||||
monkeypatch.setattr(broker, "ALLOWED_PEER_UID", os.getuid())
|
||||
monkeypatch.setattr(broker, "ALLOWED_PEER_USER", "")
|
||||
broker.authorize_peer(left)
|
||||
finally:
|
||||
left.close()
|
||||
right.close()
|
||||
|
||||
|
||||
def test_peer_credentials_reject_other_uid(monkeypatch) -> None:
|
||||
broker = load_broker()
|
||||
left, right = socket.socketpair()
|
||||
try:
|
||||
monkeypatch.setattr(broker, "ALLOWED_PEER_UID", os.getuid() + 100000)
|
||||
monkeypatch.setattr(broker, "ALLOWED_PEER_USER", "")
|
||||
with pytest.raises(broker.BrokerError) as caught:
|
||||
broker.authorize_peer(left)
|
||||
assert caught.value.code == "PEER_UID_REJECTED"
|
||||
finally:
|
||||
left.close()
|
||||
right.close()
|
||||
|
||||
|
||||
def test_request_read_timeout(monkeypatch) -> None:
|
||||
broker = load_broker()
|
||||
left, right = socket.socketpair()
|
||||
try:
|
||||
monkeypatch.setattr(broker, "CONNECTION_TIMEOUT", 0.05)
|
||||
with pytest.raises(broker.BrokerError) as caught:
|
||||
broker.read_request(left)
|
||||
assert caught.value.code == "REQUEST_TIMEOUT"
|
||||
finally:
|
||||
left.close()
|
||||
right.close()
|
||||
|
||||
|
||||
def test_worktree_fd_remains_pinned_after_path_replacement(tmp_path: Path, monkeypatch) -> None:
|
||||
broker = load_broker()
|
||||
root = tmp_path / "workspaces"
|
||||
run_root = root / "run"
|
||||
original = run_root / "worktree"
|
||||
original.mkdir(parents=True)
|
||||
(original / "marker.txt").write_text("original\n", encoding="utf-8")
|
||||
|
||||
request = {
|
||||
"schema": "aios.online-agent.runtime-request.v1",
|
||||
"adapter": "hermes",
|
||||
"execution_context": {
|
||||
"repository": "aios/aios",
|
||||
"base_oid": "a" * 40,
|
||||
"target_branch": "agent/intent/test",
|
||||
"worktree": str(original),
|
||||
},
|
||||
"policy": {
|
||||
"authoritative_repository_write": False,
|
||||
"direct_git_ref_mutation": False,
|
||||
"direct_git_push": False,
|
||||
"direct_git_commit": False,
|
||||
"main_write": False,
|
||||
"force_push": False,
|
||||
"production_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"task_api_credential_visible": False,
|
||||
"writer_lease_visible": False,
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(broker, "ADAPTER", "hermes")
|
||||
monkeypatch.setattr(broker, "WORKSPACE_ROOT", root)
|
||||
_, fd = broker.validate_request(request)
|
||||
try:
|
||||
moved = run_root / "original-moved"
|
||||
original.rename(moved)
|
||||
original.mkdir()
|
||||
(original / "marker.txt").write_text("replacement\n", encoding="utf-8")
|
||||
|
||||
pinned = Path(f"/proc/self/fd/{fd}")
|
||||
(pinned / "through-fd.txt").write_text("pinned\n", encoding="utf-8")
|
||||
assert (moved / "through-fd.txt").read_text(encoding="utf-8") == "pinned\n"
|
||||
assert not (original / "through-fd.txt").exists()
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def test_backend_receives_no_task_api_token(tmp_path: Path, monkeypatch) -> None:
|
||||
broker = load_broker()
|
||||
worktree = tmp_path / "worktree"
|
||||
worktree.mkdir()
|
||||
backend = tmp_path / "backend.py"
|
||||
backend.write_text(
|
||||
"""#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys
|
||||
request = json.load(sys.stdin)
|
||||
assert 'AIOS_AGENT_TASK_TOKEN' not in os.environ
|
||||
worktree = pathlib.Path(request['execution_context']['worktree'])
|
||||
(worktree / 'changed.txt').write_text('ok\\n', encoding='utf-8')
|
||||
json.dump({
|
||||
'schema':'aios.online-agent.runtime-result.v1',
|
||||
'status':'COMPLETED',
|
||||
'summary':'done',
|
||||
'verification':[],
|
||||
'evidence':{'token_visible':False, 'api_key':'runtime-secret-value'}
|
||||
}, sys.stdout)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
backend.chmod(0o755)
|
||||
|
||||
monkeypatch.setattr(broker, "ADAPTER", "hermes")
|
||||
monkeypatch.setattr(broker, "BACKEND_ARGV_JSON", json.dumps(["/usr/bin/python3", str(backend)]))
|
||||
monkeypatch.setattr(broker, "BACKEND_TIMEOUT", 5)
|
||||
monkeypatch.delenv("CREDENTIALS_DIRECTORY", raising=False)
|
||||
|
||||
fd = os.open(str(worktree), getattr(os, "O_PATH", os.O_RDONLY) | os.O_DIRECTORY)
|
||||
try:
|
||||
request = {
|
||||
"schema": "aios.online-agent.runtime-request.v1",
|
||||
"adapter": "hermes",
|
||||
"execution_context": {"worktree": str(worktree)},
|
||||
}
|
||||
result = broker.execute_backend(request, fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
assert result["status"] == "COMPLETED"
|
||||
assert result["evidence"]["api_key"] == "[REDACTED]"
|
||||
assert (worktree / "changed.txt").read_text(encoding="utf-8") == "ok\n"
|
||||
|
||||
|
||||
def test_broker_backend_env_does_not_inherit_task_token(tmp_path: Path, monkeypatch) -> None:
|
||||
broker = load_broker()
|
||||
worktree = tmp_path / "worktree-env"
|
||||
worktree.mkdir()
|
||||
backend = tmp_path / "backend-env.py"
|
||||
backend.write_text(
|
||||
"""#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
assert 'AIOS_AGENT_TASK_TOKEN' not in os.environ
|
||||
assert os.environ.get('AIOS_OPENCLAW_MODEL') == 'test/model'
|
||||
assert os.environ.get('AIOS_OPENCLAW_BIN') == '/test/openclaw'
|
||||
json.dump({
|
||||
'schema':'aios.online-agent.runtime-result.v1',
|
||||
'status':'COMPLETED',
|
||||
'summary':'environment isolated',
|
||||
'verification':[],
|
||||
'evidence':{'task_token_visible':False}
|
||||
}, sys.stdout)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
backend.chmod(0o755)
|
||||
|
||||
monkeypatch.setattr(broker, "ADAPTER", "openclaw")
|
||||
monkeypatch.setattr(
|
||||
broker,
|
||||
"BACKEND_ARGV_JSON",
|
||||
json.dumps(["/usr/bin/python3", str(backend)]),
|
||||
)
|
||||
monkeypatch.setenv("AIOS_AGENT_TASK_TOKEN", "must-not-cross")
|
||||
monkeypatch.setenv("AIOS_OPENCLAW_MODEL", "test/model")
|
||||
monkeypatch.setenv("AIOS_OPENCLAW_BIN", "/test/openclaw")
|
||||
monkeypatch.delenv("CREDENTIALS_DIRECTORY", raising=False)
|
||||
|
||||
fd = os.open(str(worktree), getattr(os, "O_PATH", os.O_RDONLY) | os.O_DIRECTORY)
|
||||
try:
|
||||
request = {
|
||||
"schema": "aios.online-agent.runtime-request.v1",
|
||||
"adapter": "openclaw",
|
||||
"execution_context": {"worktree": str(worktree)},
|
||||
}
|
||||
result = broker.execute_backend(request, fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
assert result["status"] == "COMPLETED"
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from intent_runtime import IntentError, IntentTask, is_claimable_intent, resolve
|
||||
|
||||
|
||||
def make_bare(tmp_path: Path) -> tuple[Path, str]:
|
||||
work = tmp_path / "work"
|
||||
bare = tmp_path / "repo.git"
|
||||
subprocess.run(["git", "init", str(work)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.email", "test@example.invalid"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.name", "AIOS Test"], check=True)
|
||||
(work / "README.md").write_text("base\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(work), "add", "README.md"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-m", "base"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(work), "branch", "-M", "main"], check=True)
|
||||
oid = subprocess.check_output(["git", "-C", str(work), "rev-parse", "HEAD"], text=True).strip()
|
||||
subprocess.run(["git", "clone", "--bare", str(work), str(bare)], check=True, capture_output=True)
|
||||
return bare, oid
|
||||
|
||||
|
||||
def intent_task(task_id: str = "custom-gpt-intent-20260815") -> dict[str, object]:
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"goal": "Fix the repository behavior described by the task.",
|
||||
"instructions": "Inspect the code, find the root cause, implement and test the smallest correct fix.",
|
||||
"success_criteria": ["Relevant tests pass", "git diff --check passes"],
|
||||
"required_capabilities": [
|
||||
"intent.task",
|
||||
"engineering.execute",
|
||||
"engineering.autonomous",
|
||||
"git.read",
|
||||
"git.diff",
|
||||
"git.test",
|
||||
"governed.write.file",
|
||||
"governed.write.branch",
|
||||
"workspace.worktree",
|
||||
"agent.runtime.hermes",
|
||||
],
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
|
||||
def fixed_implementation_task() -> dict[str, object]:
|
||||
return {
|
||||
"task_id": "implement-autonomous-intent-worker-v2-20260815",
|
||||
"goal": "Implement autonomous intent worker v2.",
|
||||
"instructions": "Implement the worker.",
|
||||
"success_criteria": ["Implementation succeeds"],
|
||||
"required_capabilities": [
|
||||
"engineering.execute",
|
||||
"git.diff",
|
||||
"git.read",
|
||||
"git.test",
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
"workspace.worktree",
|
||||
],
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
|
||||
def test_fixed_bootstrap_task_is_not_claimable_by_intent_worker() -> None:
|
||||
worker_caps = intent_task()["required_capabilities"]
|
||||
assert is_claimable_intent(fixed_implementation_task(), worker_caps) is False
|
||||
with pytest.raises(IntentError) as caught:
|
||||
IntentTask.parse(fixed_implementation_task())
|
||||
assert caught.value.code == "INTENT_CAPABILITY_MARKER_REQUIRED"
|
||||
|
||||
|
||||
def test_native_custom_gpt_intent_task_is_claimable() -> None:
|
||||
task = intent_task()
|
||||
assert is_claimable_intent(task, task["required_capabilities"]) is True
|
||||
|
||||
|
||||
def test_missing_runtime_capability_fails_closed() -> None:
|
||||
task = intent_task()
|
||||
caps = [item for item in task["required_capabilities"] if item != "agent.runtime.hermes"]
|
||||
assert is_claimable_intent(task, caps) is False
|
||||
|
||||
|
||||
def test_resolution_is_stable_across_restart(tmp_path: Path) -> None:
|
||||
bare, oid = make_bare(tmp_path)
|
||||
task = intent_task()
|
||||
first = resolve(
|
||||
task,
|
||||
authority_git_dir=bare,
|
||||
repository="aios/aios",
|
||||
base_ref="refs/heads/main",
|
||||
available_adapters=["hermes"],
|
||||
default_adapter="hermes",
|
||||
)
|
||||
recovered = resolve(
|
||||
task,
|
||||
authority_git_dir=bare,
|
||||
repository="aios/aios",
|
||||
base_ref="refs/heads/main",
|
||||
available_adapters=["hermes"],
|
||||
default_adapter="hermes",
|
||||
prior=first.to_json(),
|
||||
)
|
||||
assert first == recovered
|
||||
assert recovered.base_oid == oid
|
||||
assert recovered.target_branch.startswith("agent/intent/")
|
||||
|
||||
|
||||
def test_requested_codex_is_rejected_when_unavailable(tmp_path: Path) -> None:
|
||||
bare, _ = make_bare(tmp_path)
|
||||
task = intent_task("codex-only-20260815")
|
||||
task["required_capabilities"] = [
|
||||
item for item in task["required_capabilities"] if item != "agent.runtime.hermes"
|
||||
] + ["agent.runtime.codex"]
|
||||
with pytest.raises(IntentError) as caught:
|
||||
resolve(
|
||||
task,
|
||||
authority_git_dir=bare,
|
||||
repository="aios/aios",
|
||||
base_ref="refs/heads/main",
|
||||
available_adapters=["hermes"],
|
||||
default_adapter="hermes",
|
||||
)
|
||||
assert caught.value.code == "AGENT_RUNTIME_UNAVAILABLE"
|
||||
|
||||
|
||||
def test_openclaw_is_a_first_class_runtime_adapter(tmp_path: Path) -> None:
|
||||
bare, _ = make_bare(tmp_path)
|
||||
task = intent_task("openclaw-intent-20260815")
|
||||
task["required_capabilities"] = [
|
||||
item for item in task["required_capabilities"] if item != "agent.runtime.hermes"
|
||||
] + ["agent.runtime.openclaw"]
|
||||
resolved = resolve(
|
||||
task,
|
||||
authority_git_dir=bare,
|
||||
repository="aios/aios",
|
||||
base_ref="refs/heads/main",
|
||||
available_adapters=["openclaw"],
|
||||
default_adapter="openclaw",
|
||||
)
|
||||
assert resolved.adapter == "openclaw"
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from runtime_client import RuntimeErrorV2, collect_changes, invoke, request_payload, scan_secrets
|
||||
|
||||
|
||||
def repo(tmp_path: Path) -> tuple[Path, str]:
|
||||
work = tmp_path / "repo"
|
||||
subprocess.run(["git", "init", str(work)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.email", "test@example.invalid"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "config", "user.name", "AIOS Test"], check=True)
|
||||
(work / "a.py").write_text("VALUE = 1\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(work), "add", "a.py"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-m", "base"], check=True, capture_output=True)
|
||||
oid = subprocess.check_output(["git", "-C", str(work), "rev-parse", "HEAD"], text=True).strip()
|
||||
return work, oid
|
||||
|
||||
|
||||
def test_collect_changes_reads_actual_git_state(tmp_path: Path) -> None:
|
||||
work, oid = repo(tmp_path)
|
||||
(work / "a.py").write_text("VALUE = 2\n", encoding="utf-8")
|
||||
(work / "new.py").write_text("NEW = True\n", encoding="utf-8")
|
||||
assert collect_changes(work, oid) == ["a.py", "new.py"]
|
||||
|
||||
|
||||
def test_agent_commit_is_rejected(tmp_path: Path) -> None:
|
||||
work, oid = repo(tmp_path)
|
||||
(work / "a.py").write_text("VALUE = 2\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(work), "add", "a.py"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "commit", "-m", "agent commit"], check=True, capture_output=True)
|
||||
with pytest.raises(RuntimeErrorV2) as caught:
|
||||
collect_changes(work, oid)
|
||||
assert caught.value.code == "WORKTREE_HEAD_MUTATED"
|
||||
|
||||
|
||||
def test_secret_material_is_rejected(tmp_path: Path) -> None:
|
||||
work, oid = repo(tmp_path)
|
||||
private_key_marker = "-----BEGIN PRIVATE " + "KEY-----"
|
||||
(work / "new.txt").write_text(
|
||||
private_key_marker + "\nabc\n", encoding="utf-8")
|
||||
changed = collect_changes(work, oid)
|
||||
with pytest.raises(RuntimeErrorV2) as caught:
|
||||
scan_secrets(work, changed)
|
||||
assert caught.value.code == "SECRET_MATERIAL_REJECTED"
|
||||
|
||||
|
||||
def test_runtime_protocol_round_trip(tmp_path: Path) -> None:
|
||||
sock = tmp_path / "runtime.sock"
|
||||
response = {
|
||||
"schema": "aios.online-agent.runtime-result.v1",
|
||||
"status": "COMPLETED",
|
||||
"summary": "Implemented the requested change.",
|
||||
"verification": [],
|
||||
"evidence": {"runtime": "fake-hermes"},
|
||||
}
|
||||
|
||||
def server() -> None:
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
listener.bind(str(sock))
|
||||
listener.listen(1)
|
||||
conn, _ = listener.accept()
|
||||
raw = b""
|
||||
while True:
|
||||
chunk = conn.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
raw += chunk
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
assert value["schema"] == "aios.online-agent.runtime-request.v1"
|
||||
conn.sendall(json.dumps(response).encode("utf-8"))
|
||||
conn.close()
|
||||
listener.close()
|
||||
|
||||
thread = threading.Thread(target=server)
|
||||
thread.start()
|
||||
for _ in range(100):
|
||||
if sock.exists():
|
||||
break
|
||||
time.sleep(0.01)
|
||||
payload = request_payload(
|
||||
adapter="hermes",
|
||||
task_id="intent-e2e-20260815",
|
||||
goal="Change VALUE",
|
||||
instructions="Inspect and implement",
|
||||
success_criteria=["Tests pass"],
|
||||
repository="aios/aios",
|
||||
base_oid="a" * 40,
|
||||
target_branch="agent/intent/example",
|
||||
worktree=tmp_path,
|
||||
)
|
||||
result = invoke(sock, payload, 5)
|
||||
thread.join(timeout=2)
|
||||
assert result.status == "COMPLETED"
|
||||
assert result.evidence["runtime"] == "fake-hermes"
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
BACKEND_PATH = Path(__file__).parents[1] / "services" / "aios-agent-runtime-broker" / "openclaw_engineering_backend.py"
|
||||
|
||||
|
||||
def load_backend():
|
||||
spec = importlib.util.spec_from_file_location("openclaw_backend_under_test", BACKEND_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def fake_openclaw(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "openclaw"
|
||||
path.write_text(
|
||||
"""#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys
|
||||
args = sys.argv[1:]
|
||||
assert args[:2] == ['agent', 'exec']
|
||||
def value(flag):
|
||||
return args[args.index(flag) + 1]
|
||||
assert value('--message-file') == '-'
|
||||
cwd = pathlib.Path(value('--cwd'))
|
||||
assert cwd.resolve() == pathlib.Path.cwd().resolve()
|
||||
assert '--auth-env-only' in args
|
||||
assert '--json' in args
|
||||
assert os.environ.get('OPENCLAW_EXEC_SHELL_SNAPSHOT') == '0'
|
||||
assert 'AIOS_AGENT_TASK_TOKEN' not in os.environ
|
||||
prompt = sys.stdin.read()
|
||||
assert 'Do NOT run `git commit`' in prompt
|
||||
(cwd / 'changed_by_openclaw.txt').write_text('changed\\n', encoding='utf-8')
|
||||
json.dump({
|
||||
'ok': True,
|
||||
'status': 'ok',
|
||||
'final': 'Changed the requested file and verified the result.',
|
||||
'payloads': [{'text':'done'}],
|
||||
'model': value('--model'),
|
||||
'provider': 'test-provider',
|
||||
'sessionId': 'test-session',
|
||||
'assistantTurns': 2,
|
||||
'codeModeEngaged': True,
|
||||
'toolSummary': {'calls': 3, 'tools': ['read','write','exec'], 'totalToolTimeMs': 10}
|
||||
}, sys.stdout)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
path.chmod(0o755)
|
||||
return path
|
||||
|
||||
|
||||
def request(worktree: Path) -> dict[str, object]:
|
||||
return {
|
||||
"schema": "aios.online-agent.runtime-request.v1",
|
||||
"adapter": "hermes",
|
||||
"task": {
|
||||
"task_id": "openclaw-backend-selftest",
|
||||
"goal": "Make the requested change.",
|
||||
"instructions": "Inspect, implement, and test.",
|
||||
"success_criteria": ["The change is correct"],
|
||||
},
|
||||
"execution_context": {
|
||||
"repository": "aios/aios",
|
||||
"base_oid": "a" * 40,
|
||||
"target_branch": "agent/intent/openclaw-backend-selftest",
|
||||
"worktree": str(worktree),
|
||||
},
|
||||
"policy": {
|
||||
"authoritative_repository_write": False,
|
||||
"direct_git_ref_mutation": False,
|
||||
"direct_git_push": False,
|
||||
"direct_git_commit": False,
|
||||
"main_write": False,
|
||||
"force_push": False,
|
||||
"production_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"task_api_credential_visible": False,
|
||||
"writer_lease_visible": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_openclaw_agent_exec_contract(tmp_path: Path, monkeypatch) -> None:
|
||||
backend = load_backend()
|
||||
worktree = tmp_path / "worktree"
|
||||
worktree.mkdir()
|
||||
fake = fake_openclaw(tmp_path)
|
||||
monkeypatch.setenv("AIOS_OPENCLAW_BIN", str(fake))
|
||||
monkeypatch.setenv("AIOS_OPENCLAW_MODEL", "test/model")
|
||||
monkeypatch.setenv("AIOS_OPENCLAW_PROVIDER_CREDENTIAL_MAP_JSON", "{}")
|
||||
fd = os.open(str(worktree), getattr(os, "O_PATH", os.O_RDONLY) | os.O_DIRECTORY)
|
||||
try:
|
||||
value = request(worktree)
|
||||
value["execution_context"]["worktree"] = f"/proc/self/fd/{fd}"
|
||||
task, pinned = backend.validate_request(value)
|
||||
result = backend.invoke_openclaw(task, pinned)
|
||||
finally:
|
||||
os.close(fd)
|
||||
assert result["status"] == "COMPLETED"
|
||||
assert result["evidence"]["backend"] == "openclaw-agent-exec"
|
||||
assert result["evidence"]["model"] == "test/model"
|
||||
assert (worktree / "changed_by_openclaw.txt").read_text(encoding="utf-8") == "changed\n"
|
||||
|
||||
|
||||
def test_provider_credential_is_projected_only_to_openclaw_child(tmp_path: Path, monkeypatch) -> None:
|
||||
backend = load_backend()
|
||||
credentials = tmp_path / "credentials"
|
||||
credentials.mkdir()
|
||||
(credentials / "openai_api_key").write_text("provider-secret\n", encoding="utf-8")
|
||||
monkeypatch.setenv("CREDENTIALS_DIRECTORY", str(credentials))
|
||||
monkeypatch.setenv(
|
||||
"AIOS_OPENCLAW_PROVIDER_CREDENTIAL_MAP_JSON",
|
||||
json.dumps({"OPENAI_API_KEY": "openai_api_key"}),
|
||||
)
|
||||
env = backend.provider_environment()
|
||||
assert env["OPENAI_API_KEY"] == "provider-secret"
|
||||
assert "AIOS_AGENT_TASK_TOKEN" not in env
|
||||
assert env["OPENCLAW_EXEC_SHELL_SNAPSHOT"] == "0"
|
||||
|
||||
|
||||
def test_missing_model_fails_closed(tmp_path: Path, monkeypatch) -> None:
|
||||
backend = load_backend()
|
||||
worktree = tmp_path / "worktree"
|
||||
worktree.mkdir()
|
||||
monkeypatch.setenv("AIOS_OPENCLAW_BIN", str(fake_openclaw(tmp_path)))
|
||||
monkeypatch.delenv("AIOS_OPENCLAW_MODEL", raising=False)
|
||||
fd = os.open(str(worktree), getattr(os, "O_PATH", os.O_RDONLY) | os.O_DIRECTORY)
|
||||
try:
|
||||
value = request(worktree)
|
||||
value["execution_context"]["worktree"] = f"/proc/self/fd/{fd}"
|
||||
task, pinned = backend.validate_request(value)
|
||||
with pytest.raises(backend.BackendError) as caught:
|
||||
backend.invoke_openclaw(task, pinned)
|
||||
finally:
|
||||
os.close(fd)
|
||||
assert caught.value.code == "OPENCLAW_MODEL_INVALID"
|
||||
|
||||
|
||||
def test_pinned_worktree_is_required(tmp_path: Path) -> None:
|
||||
backend = load_backend()
|
||||
value = request(tmp_path)
|
||||
value["execution_context"]["worktree"] = str(tmp_path)
|
||||
with pytest.raises(backend.BackendError) as caught:
|
||||
backend.validate_request(value)
|
||||
assert caught.value.code == "PINNED_WORKTREE_REQUIRED"
|
||||
Reference in New Issue
Block a user