feat(harnesses): shared-variables/v1 context + workflow-script/v1 composite (WF-COMP-31, WV-19) (03878b12-86b5-4b88-86d5-7182ae1c471f)

This commit is contained in:
Paul O'Reilly
2026-10-01 15:31:48 +13:00
parent da23760e93
commit b549a28187
4 changed files with 419 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
kind: composite
name: workflow-script
version: 1
description: "Internal composite for the flag-on kind: script path (typed-workflows WF-COMP-31 / workflow-variables WV-19). Brings the shared-variables filesystem context AND the AU-51 CP write credential (cp-service-token/v1 sets CP_SERVICE_TOKEN_FILE in the entrypoint process env before shell_runner runs). NOT a model-bearing composite — the kind: script compiled shape is cli=\"shell\", params.cmd=<cmd>; this composite provides the harness layers that path needs and nothing else."
# Order matters only for HC-6 env last-writer-wins / HC-8 git identity. The two
# layers here have disjoint env vars (shared-variables sets WORKFLOW_VARIABLES_*
# paths, cp-service-token sets CP_SERVICE_TOKEN_FILE) and no overlapping git
# identity, so the listed order has no observable merge effect — both layers
# reach the script-node container either way.
layers:
# spec/workflow-variables.md WV-19 / spec/harness.md HD-14 — agent-facing
# /variables/ context (CLAUDE.md mounted at /opt/harness/context/shared-variables/CLAUDE.md)
# plus WORKFLOW_VARIABLES_ROOT=/variables and templated WORKFLOW_VARIABLES_SELF=<step_path>.
# Provides the [shared-variables] tag used by the dispatcher's extra_layers injection.
- context: shared-variables/v1
# spec/typed-workflows.md WF-COMP-31 "AU-51 credential env seam" — this layer's
# init.sh restages the ESO-mounted CP-service-token secret (root-owned mode 0400)
# to an agent-owned 0600 copy at /run/agent/cp-service-token/token and exports
# CP_SERVICE_TOKEN_FILE pointing at it. Verified: env.CP_SERVICE_TOKEN_FILE is set
# on the entrypoint process environment before shell_runner runs (entrypoint reads
# os.environ via env=os.environ.copy() in subprocess.run at entrypoint/runners/shell.py:73,
# so the same credential channel an agent node gets reaches a script node unchanged).
- context: cp-service-token/v1

View File

@@ -0,0 +1,146 @@
# Shared Variables Context
Your container is running with the **shared variables filesystem** mounted at `/variables/`.
This is the only way you read a step's inputs and return its outputs to the control
plane — there is no API to call, no credential to hold, no env var to substitute.
You write plain files under your own step folder; the CP picks them up.
Normative source: `spec/workflow-variables.md` (WV-1..6a). This document summarises
the contract; when in doubt, the spec wins.
## What is where
```
/variables/
workflow/ # run inputs, frozen at dispatch time (WV-8)
run-id
template
item.uuid
item.title
item.label
item.flow-state
artifacts.<key> # per WorkflowInput.required_artifacts
<step>/ # this step's OWN folder — write outputs here
<loop-id>_<n>/<step>/ # loop body node in iteration n (n is 1-based)
.self # path to your own step folder (read-only)
.done # completion marker, written by the entrypoint
```
You only write under your OWN folder. The pack sidecar walks that folder after you
exit and returns its contents to the CP. Earlier loop iterations' results and your
direct `depends_on` dependencies' outputs are also under `/variables/`, pre-filled
when your container started — you only READ those.
## Two env vars are set for you
- `WORKFLOW_VARIABLES_ROOT` — always `/variables`.
- `WORKFLOW_VARIABLES_SELF` — the path to your own step folder
(e.g. `/variables/judge` for a top-level node named `judge`, or
`/variables/review-loop_2/judge` for loop iteration 2's `judge` node).
Neither ever contains a value. Both are paths only. Treat both as read-only inputs.
## Key naming (the contract you'll be checked against)
- A variable's key is the joined, lowercased path from `/variables/` to the leaf
file, with `.` as the separator.
`/variables/judge/VERDICT` ↔ `judge.verdict`.
`/variables/review-loop_2/judge/findings/HIGH` ↔ `review-loop_2.judge.findings.high`.
- **Leaf files are UPPERCASE** on disk by convention; folders stay lowercase.
Lookup via the helpers and the CP API is case-insensitive.
- Each path segment, after lowercasing, must match `^[a-z0-9][a-z0-9_-]{0,63}$`.
Maximum depth is 8 segments. Dotfiles, symlinks, FIFOs, sockets, and devices are
skipped with a warning (never read or followed).
- Two leaf files at the same level whose names lowercase to the same key → both
rejected at pack time as `invalid_output_key`.
- **Reserved names** that cannot be used as step or loop ids (case-insensitive):
`workflow`, `while`, `for`, `foreach`, `loop`, `until`, `do`, `repeat`, `if`,
`else`, `elif`, `elseif`, `then`, `switch`, `case`, `default`, `when`, `break`,
`continue`, `return`, `goto`, `yield`, `try`, `catch`, `except`, `finally`,
`raise`, `throw`, `and`, `or`, `not`, `xor`, `in`, `is`, `true`, `false`,
`null`, `none`, `nil`, `yes`, `no`, `on`, `off`. The full list lives in
`controlplane/typed_workflows/reserved_ids.py` and may grow by spec amendment.
- Step/loop ids are restricted to lowercase letters, digits, `-` and `_`, starting
with a letter; no trailing `_<digits>` (reserved for loop-iteration suffixes).
## What you can see (visibility)
- `workflow.*` — always. These are the run inputs, frozen for the life of the run
(later item changes are not reflected here; pass them on through step outputs).
- The outputs of each of your **direct** `depends_on` nodes (the same set the
manifest DAG wires you to) — not transitive.
- Inside a loop body, **every prior iteration's** outputs for **every** step in
the loop — not just the immediately preceding one. So iteration 2 sees what
iterations 1, 2, … of every loop body node wrote. Iteration 1 sees only its
direct dependencies.
- Nothing else. Your own folder is always created, even if empty.
## Declared outputs
If your node's template declares `outputs: [<key>, ...]`, those are the keys you
are expected to write. Any file actually present under your step folder is
accepted regardless — `outputs:` is for policy and tooling, not an allowlist.
A missing declared output produces a warning by default (configurable per-project
to fail the step with `variables_missing_output`).
## Values
- **Strings only.** JSON is supported as a string value — you parse it yourself;
the CP never interprets a value's structure.
- Per-value cap is configurable (`max_value_bytes`, default 1 MiB). Per-step cap
(`max_step_bytes`) and per-step file count (`max_step_files`) also apply.
- Values are data — never substituted into prompts, commands, or environment
variables by the CP. Don't put values in shell; read them through the helpers
or the CLI.
## Helpers (use these, not shell)
A Python package is pre-installed in your image at `/opt/agent/variables/` and a
CLI at `/usr/local/bin/wfvar`. Both are stdlib-only.
```python
from variables import load, save, keys
value = load("verdict") # load from your own folder
save("verdict", "CLEAN") # write into your own folder
keys() # list keys under your own folder
keys(prefix="judge.") # narrow by prefix (read-only access)
```
```bash
wfvar get VERDICT # print the value, NO trailing newline
wfvar set VERDICT CLEAN # write into your own folder
wfvar list # list your own keys
wfvar path # print your own step folder (same as $WORKFLOW_VARIABLES_SELF)
wfvar get judge.findings.high # read another step's output (visibility permitting)
```
Exit codes: `0` ok, `1` not found (key is valid but absent), `2` invalid key or
usage, `3` value exceeds per-value limit, `4` write attempted outside your own
step. Do not wrap `wfvar get` with extra shell that strips newlines — it prints
exactly the stored bytes.
**Don't write plan-like content with `wfvar set <key> <body>`** — argv breaks on
shell quoting and truncates at ARG_MAX. For multi-line / quoting-sensitive bodies
(e.g. the decompose plan files at `/variables/<node>/items/<local_id>`), use your
file-write tool or a heredoc with a terminator that cannot occur in the body,
writing directly into `/variables/<node>/...`.
## Safety
The pack sidecar reads your step folder with strict traversal protections
(`O_NOFOLLOW`, `S_ISREG` checks, size caps, 4 KiB marker cap). It does not follow
symlinks you might drop in. A symlink at any level is silently skipped with a
warning — never assume it will be read.
## What you must NOT do
- Do not read or write outside your own step folder under `/variables/`.
- Do not call out to the CP API directly — the helpers and the pack sidecar
handle all variable transport.
- Do not assume `/variables/` exists in an unstamped container — it doesn't.
- Do not write to `/variables/.done` — the entrypoint owns that marker; it is
written for you after `run_finalize_phase` returns, whether you exited 0, non-zero,
or via SIGTERM.
- Do not store values in env vars, command lines, or prompts — values are data,
and data doesn't go there.

View File

@@ -0,0 +1,20 @@
kind: context
name: shared-variables
version: 1
description: "Workflow variables filesystem contract — /variables/ layout, key naming, helpers and CLI for CP-held step inputs/outputs (spec/workflow-variables.md WV-19; injected per-task via HD-14 extra_layers)"
requires: []
provides: [shared-variables]
# WV-20: both env vars are set by THIS harness layer (not the entrypoint, not
# the container backend). WORKFLOW_VARIABLES_ROOT is static; WORKFLOW_VARIABLES_SELF
# is templated by the composer at harness-composition time on the dispatcher,
# substituting the node's resolved step_path (the SAME value WV-15 derives at mint
# time). A task with no shared-variables/v1 layer (unstamped) has neither env var
# set, and /variables does not exist for it at all.
env:
WORKFLOW_VARIABLES_ROOT: /variables
WORKFLOW_VARIABLES_SELF: "{{ step_path }}"
context_files:
- source: ./CLAUDE.md
target: /opt/harness/context/shared-variables/CLAUDE.md