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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .config/scripts/e2e-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ OP_CONNECT_TOKEN="$OP_TOKEN" \
OP_VAULT_NAME="$OP_VAULT" \
OP_SECRET_ITEM_NAME="$OP_SECRET_ITEM" \
go test -tags e2e -v -count=1 -timeout 30m \
-run 'TestLifecycle|TestIronClawLifecycle|TestOpenClawLifecycle|TestPicoClawLifecycle' \
-run 'TestLifecycle|TestIronClawLifecycle|TestOpenClawLifecycle|TestOpenClawExtraSoftwareInstallsClaude|TestPicoClawLifecycle' \
./e2e/

echo "✅ Lifecycle E2E tests passed!"
24 changes: 24 additions & 0 deletions control-plane/charts/ironclaw/templates/startup-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ data:
#!/bin/sh
set -e

warn() {
echo "WARNING: $*"
}

# Load secrets from volume files into environment
SECRETS_DIR="/etc/claw/secrets"
if [ -d "$SECRETS_DIR" ]; then
Expand All @@ -19,6 +23,26 @@ data:
done
fi

{{- if .Values.extraSoftware.toolVersions }}
WORKSPACE_DIR="${HOME:-/home/ironclaw}/.ironclaw/workspace"
TOOL_VERSIONS_FILE="$WORKSPACE_DIR/.tool-versions"
mkdir -p "$WORKSPACE_DIR"

echo "Applying extra software tools from $TOOL_VERSIONS_FILE"
cat >"$TOOL_VERSIONS_FILE" <<'CLAWMACHINE_TOOL_VERSIONS'
{{ .Values.extraSoftware.toolVersions | nindent 4 }}
CLAWMACHINE_TOOL_VERSIONS
Comment on lines +32 to +34
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .tool-versions heredoc terminator ( CLAWMACHINE_TOOL_VERSIONS) is indented. In sh, heredoc terminators must match exactly at the start of the line (unless using <<- with tabs), so the heredoc won’t close and the startup script will fail when extraSoftware.toolVersions is set. Remove the extra indentation or change to <<- with tab-indented terminators.

Suggested change
cat >"$TOOL_VERSIONS_FILE" <<'CLAWMACHINE_TOOL_VERSIONS'
{{ .Values.extraSoftware.toolVersions | nindent 4 }}
CLAWMACHINE_TOOL_VERSIONS
cat >"$TOOL_VERSIONS_FILE" <<-'CLAWMACHINE_TOOL_VERSIONS'
{{ .Values.extraSoftware.toolVersions | nindent 4 }}
CLAWMACHINE_TOOL_VERSIONS

Copilot uses AI. Check for mistakes.
sed -i 's/^ //' "$TOOL_VERSIONS_FILE"

export MISE_CACHE_DIR="$WORKSPACE_DIR/.cache/mise"
mkdir -p "$MISE_CACHE_DIR"
if ! command -v mise >/dev/null 2>&1; then
warn "mise is not available in the container; skipping extra software installation"
elif ! (cd "$WORKSPACE_DIR" && mise install); then
warn "mise install failed; continuing startup"
fi
{{- end }}

{{- if .Values.postgresql.enabled }}
# Wait for PostgreSQL to accept TCP connections.
# Keep this bounded so probes do not kill the pod before app start.
Expand Down
3 changes: 3 additions & 0 deletions control-plane/charts/ironclaw/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ workspace:

env: {}

extraSoftware:
toolVersions: ""

backup:
enabled: false
restoreOnStartup: false
Expand Down
3 changes: 3 additions & 0 deletions control-plane/charts/openclaw/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ spec:
metadata:
labels:
{{- include "openclaw.labels" . | nindent 8 }}
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/startup: {{ include (print $.Template.BasePath "/startup-configmap.yaml") . | sha256sum }}
spec:
{{- if and .Values.backup.enabled .Values.backup.restoreOnStartup }}
initContainers:
Expand Down
22 changes: 21 additions & 1 deletion control-plane/charts/openclaw/templates/startup-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,33 @@ data:
echo "Applying managed configFile.content to $CONFIG_FILE"
cat >"$CONFIG_FILE" <<'CLAWMACHINE_OPENCLAW_CONFIG'
{{ .Values.configFile.content | nindent 4 }}
CLAWMACHINE_OPENCLAW_CONFIG
CLAWMACHINE_OPENCLAW_CONFIG
{{- end }}
Comment on lines 33 to 37
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The heredoc terminator is indented ( CLAWMACHINE_OPENCLAW_CONFIG). In POSIX shells the terminator must match exactly at the start of the line (unless using <<- with tabs). With the current indentation the heredoc won’t close, breaking the startup script when configFile is enabled. Align the terminator to column 0 in the rendered script (e.g., remove the extra spaces, or switch to <<- + tabs consistently).

Copilot uses AI. Check for mistakes.

warn() {
echo "WARNING: $*"
}

{{- if .Values.extraSoftware.toolVersions }}
TOOL_VERSIONS_DIR="{{ .Values.openclawConfigDir }}/workspace"
TOOL_VERSIONS_FILE="$TOOL_VERSIONS_DIR/.tool-versions"
mkdir -p "$TOOL_VERSIONS_DIR"

echo "Applying extra software tools from $TOOL_VERSIONS_FILE"
cat >"$TOOL_VERSIONS_FILE" <<'CLAWMACHINE_TOOL_VERSIONS'
{{ .Values.extraSoftware.toolVersions | nindent 4 }}
CLAWMACHINE_TOOL_VERSIONS
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .tool-versions heredoc terminator is indented ( CLAWMACHINE_TOOL_VERSIONS). Heredoc terminators must appear at the beginning of the line (unless using <<- with tabs), so this will prevent the heredoc from closing and break startup whenever extraSoftware.toolVersions is set. Remove the extra indentation (or use <<- + tabs) so the terminator matches correctly.

Suggested change
CLAWMACHINE_TOOL_VERSIONS
CLAWMACHINE_TOOL_VERSIONS

Copilot uses AI. Check for mistakes.
sed -i 's/^ //' "$TOOL_VERSIONS_FILE"

export MISE_CACHE_DIR="$TOOL_VERSIONS_DIR/.cache/mise"
mkdir -p "$MISE_CACHE_DIR"
if ! command -v mise >/dev/null 2>&1; then
warn "mise is not available in the container; skipping extra software installation"
elif ! (cd "$TOOL_VERSIONS_DIR" && mise install); then
warn "mise install failed; continuing startup"
fi
{{- end }}

set_cfg_json() {
local path="$1"
local value="$2"
Expand Down
3 changes: 3 additions & 0 deletions control-plane/charts/openclaw/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ networkPolicy:

env: {}

extraSoftware:
toolVersions: ""

backup:
enabled: false
restoreOnStartup: false
Expand Down
8 changes: 8 additions & 0 deletions control-plane/charts/picoclaw/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ spec:
- name: picoclaw
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/bin/sh", "/scripts/start-picoclaw.sh"]
args: ["gateway"]
ports:
Comment on lines +99 to 101
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

picoclaw now runs a startup script as the container command, and that script may run mise install synchronously before the HTTP server starts when extraSoftware.toolVersions is set. With the current liveness/readiness probes (no startupProbe here and a short initialDelaySeconds in values), longer installs can cause repeated probe failures and container restarts. Consider adding a startupProbe (like ironclaw) and/or increasing liveness/readiness thresholds, or running the mise install step asynchronously/with a timeout.

Copilot uses AI. Check for mistakes.
- name: http
containerPort: {{ .Values.service.port }}
Expand Down Expand Up @@ -128,9 +130,15 @@ spec:
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: startup-script
mountPath: /scripts
- name: workspace
mountPath: {{ .Values.configFile.mountPath }}
volumes:
- name: startup-script
configMap:
name: {{ include "picoclaw.fullname" . }}-startup
defaultMode: 0755
- name: workspace
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
Expand Down
28 changes: 28 additions & 0 deletions control-plane/charts/picoclaw/templates/startup-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ data:
#!/bin/sh
set -e

warn() {
echo "WARNING: $*"
}

# Load secrets from volume files into environment
SECRETS_DIR="/etc/claw/secrets"
if [ -d "$SECRETS_DIR" ]; then
Expand All @@ -19,5 +23,29 @@ data:
done
fi

{{- if .Values.extraSoftware.toolVersions }}
WORKSPACE_DIR="${HOME:-/home/picoclaw}/.picoclaw/workspace"
TOOL_VERSIONS_FILE="$WORKSPACE_DIR/.tool-versions"
mkdir -p "$WORKSPACE_DIR"

echo "Applying extra software tools from $TOOL_VERSIONS_FILE"
cat >"$TOOL_VERSIONS_FILE" <<'CLAWMACHINE_TOOL_VERSIONS'
{{ .Values.extraSoftware.toolVersions | nindent 4 }}
CLAWMACHINE_TOOL_VERSIONS
sed -i 's/^ //' "$TOOL_VERSIONS_FILE"

Comment on lines +31 to +36
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .tool-versions heredoc terminator ( CLAWMACHINE_TOOL_VERSIONS) is indented. Heredoc terminators must be at the start of the line in sh (unless using <<- with tabs), so this will prevent the heredoc from closing and break startup whenever extraSoftware.toolVersions is set. Remove the extra indentation or use <<- with tab-indented terminators.

Copilot uses AI. Check for mistakes.
export MISE_CACHE_DIR="$WORKSPACE_DIR/.cache/mise"
mkdir -p "$MISE_CACHE_DIR"
if ! command -v mise >/dev/null 2>&1; then
warn "mise is not available in the container; skipping extra software installation"
elif ! (cd "$WORKSPACE_DIR" && mise install); then
warn "mise install failed; continuing startup"
fi
{{- end }}

# Bind gateway on all interfaces for Kubernetes probes/services unless overridden.
export PICOCLAW_GATEWAY_HOST="${PICOCLAW_GATEWAY_HOST:-0.0.0.0}"
export PICOCLAW_GATEWAY_PORT="${PICOCLAW_GATEWAY_PORT:-{{ .Values.service.port }}}"

# Exec the picoclaw binary
exec /usr/local/bin/picoclaw "$@"
3 changes: 3 additions & 0 deletions control-plane/charts/picoclaw/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ networkPolicy:

env: {}

extraSoftware:
toolVersions: ""

# Config file mount — generates a Secret with bot config and mounts it as a file.
configFile:
enabled: false
Expand Down
102 changes: 102 additions & 0 deletions control-plane/e2e/openclaw_extra_software_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//go:build e2e

package e2e

import (
"context"
"fmt"
"io"
"strings"
"testing"
"time"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func waitForPodReadyBySelector(t *testing.T, namespace, labelSelector string, timeout time.Duration) string {
t.Helper()
clientset, _ := getK8sClients(t)

deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
pods, err := clientset.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{
LabelSelector: labelSelector,
})
if err == nil && len(pods.Items) > 0 {
for _, pod := range pods.Items {
for _, cond := range pod.Status.Conditions {
if cond.Type == "Ready" && cond.Status == "True" {
return pod.Name
}
}
}
}
time.Sleep(3 * time.Second)
}

t.Fatalf("timed out waiting for ready pod with selector %q", labelSelector)
return ""
}

func TestOpenClawExtraSoftwareInstallsClaude(t *testing.T) {
skipIfNoServer(t)

clientset, config := getK8sClients(t)
const namespace = "claw-machine"
const testBotName = "e2e-openclaw-mise"

doDelete(t, baseURL+"/bots/"+testBotName)
time.Sleep(2 * time.Second)

t.Cleanup(func() {
doDelete(t, baseURL+"/bots/"+testBotName)
})

body := map[string]any{
"releaseName": testBotName,
"botType": "openclaw",
"values": map[string]any{
"persistence": map[string]any{
"enabled": true,
"size": "1Gi",
},
"networkPolicy": map[string]any{
"ingress": false,
"egress": true,
},
"extraSoftware": map[string]any{
"toolVersions": "claude latest",
},
},
}

resp := doPost(t, baseURL+"/bots", body)
if resp.StatusCode != 201 && resp.StatusCode != 200 && resp.StatusCode != 204 && resp.StatusCode != 202 {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("create bot failed: %d — %s", resp.StatusCode, string(b))
}
resp.Body.Close()

selector := fmt.Sprintf("app.kubernetes.io/instance=%s", testBotName)
waitForPodRunning(t, clientset, namespace, selector, 4*time.Minute)
podName := waitForPodReadyBySelector(t, namespace, selector, 4*time.Minute)
t.Logf("openclaw pod ready: %s", podName)

stdout, stderr, err := execInPod(t, clientset, config, namespace, podName, []string{
"sh", "-lc", "cat /root/.openclaw/workspace/.tool-versions",
})
if err != nil {
t.Fatalf("read .tool-versions failed: %v\nstderr=%s", err, stderr)
}
if !strings.Contains(stdout, "claude latest") {
t.Fatalf(".tool-versions missing expected entry; got:\n%s", stdout)
}

stdout, stderr, err = execInPod(t, clientset, config, namespace, podName, []string{
"sh", "-lc", "cd /root/.openclaw/workspace && CLAUDE_BIN=\"$(mise which claude 2>/dev/null)\" && [ -n \"$CLAUDE_BIN\" ] && [ -x \"$CLAUDE_BIN\" ] && echo \"$CLAUDE_BIN\"",
})
if err != nil {
t.Fatalf("claude binary lookup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
t.Logf("claude binary found: %s", strings.TrimSpace(stdout))
}
4 changes: 2 additions & 2 deletions control-plane/e2e/openclaw_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ func TestOpenClawLifecycle(t *testing.T) {
"anthropicApiKey": "1p:e2e-anthropic-key",
"discordBotToken": "1p:e2e-discord-token",
"discordEnabled": "true",
"defaultModel": "anthropic/claude-haiku-4-5-20251001", // Changed model
"defaultModel": "anthropic/claude-haiku-4-5", // Changed model
},
}
resp := doPut(t, baseURL+"/bots/"+testBotName+"/config", body)
Expand Down Expand Up @@ -409,7 +409,7 @@ func TestOpenClawLifecycle(t *testing.T) {
})

t.Run("Step10_VerifyUpdatedRuntimeConfig", func(t *testing.T) {
assertRuntimeConfig(t, "anthropic/claude-haiku-4-5-20251001")
assertRuntimeConfig(t, "anthropic/claude-haiku-4-5")
})

// --- Step 11: Delete bot ---
Expand Down
63 changes: 63 additions & 0 deletions control-plane/internal/handler/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,23 @@ func (h *HelmHandler) NewConfigPage(w http.ResponseWriter, r *http.Request) {
h.renderInstallConfigPage(w, r, botType, allFormValues(r))
}

// NewSoftwarePage renders step 3 after validating step 2.
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment says this handler renders step 3 "after validating step 2", but the implementation only parses the form and validates releaseName (same as step 1 validation). Either update the comment to match what is actually validated, or add the missing step-2 validations if they are required before proceeding.

Suggested change
// NewSoftwarePage renders step 3 after validating step 2.
// NewSoftwarePage renders step 3 after validating the release name.

Copilot uses AI. Check for mistakes.
func (h *HelmHandler) NewSoftwarePage(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
htmxError(w, r, "Invalid form: "+err.Error(), http.StatusBadRequest)
return
}

botType := formValue(r, "botType")
releaseName := strings.TrimSpace(formValue(r, "releaseName"))
if !middleware.ValidName(releaseName) {
htmxError(w, r, "Invalid release name", http.StatusBadRequest)
return
}

h.renderInstallSoftwarePage(w, r, botType, allFormValues(r))
}

// AvailableSecret is a synced ExternalSecret for template rendering.
type AvailableSecret struct {
Name string
Expand Down Expand Up @@ -631,6 +648,45 @@ func (h *HelmHandler) renderInstallConfigPage(w http.ResponseWriter, r *http.Req
}
}

func (h *HelmHandler) renderInstallSoftwarePage(w http.ResponseWriter, r *http.Request, botType string, values map[string]string) {
allowed := slices.Contains(h.allowedBotTypes(), botType)
if !allowed {
http.NotFound(w, r)
return
}

var botConfig *botenv.BotConfig
if h.bots != nil {
botConfig = h.bots.Get(botType)
}
if botConfig == nil {
botConfig = &botenv.BotConfig{
Name: botType,
DisplayName: botType,
}
}

if values == nil {
values = make(map[string]string)
}
values["botType"] = botType
if values["onboardingVersion"] == "" {
values["onboardingVersion"] = onboarding.ProfileVersion
}

data := struct {
BotConfig *botenv.BotConfig
Values map[string]string
}{
BotConfig: botConfig,
Values: values,
}

if !renderOrError(w, r, h.tmpl, "bot-form-software", data, isHTMX(r)) {
return
}
}

func (h *HelmHandler) availableSecrets(ctx context.Context) []AvailableSecret {
return availableSecretsForTemplates(ctx, h.secrets)
}
Expand Down Expand Up @@ -1518,6 +1574,13 @@ func parseInstallForm(r *http.Request, bots *botenv.Registry) (service.InstallOp
}
}

extraToolVersions := strings.TrimSpace(formValue(r, "extraToolVersions"))
if extraToolVersions != "" {
opts.Values["extraSoftware"] = map[string]any{
"toolVersions": extraToolVersions,
}
}

// Config fields (cfg:key form names)
configFields := make(map[string]string)
for key, vals := range r.Form {
Expand Down
Loading