Skip to content

Commit c041e38

Browse files
feat(workspace): operator Co-authored-by trailer (#158)
Workspace-shipped prepare-commit-msg hook for operator attribution. NOT a Claude Code plugin — the hook lives with the claude-cli provider itself, gets baked into the image, and is installed by the entrypoint. When SYN_OPERATOR_NAME and SYN_OPERATOR_EMAIL are both set, every workspace commit gets a 'Co-authored-by: NAME <EMAIL>' trailer so PRs the agent opens are attributed to the operator who sponsored the workspace. No-op when either env var is unset (backward compatible). Layout: providers/workspaces/claude-cli/ scripts/ entrypoint.sh git-hooks/ prepare-commit-msg <- new Changes: - New scripts/git-hooks/prepare-commit-msg. Skips merge/squash/template message sources to avoid polluting auto-generated messages. Does NOT skip 'commit' source so amend / cherry-pick / rebase still pick up the trailer when missing; idempotency check handles re-adds. Strips CR/LF from operator inputs (defense in depth). - Dockerfile: COPY scripts/git-hooks/ -> /opt/agentic/git-hooks/. - entrypoint.sh: compose hooks from /opt/agentic/git-hooks/ AND plugins/observability/hooks/git/ into $HOME/.git-hooks, then point core.hooksPath there. Two contributing sources, well-documented. Also lists SYN_OPERATOR_* in the env var header. - scripts/build-provider.py: stage_scripts now copies the entire scripts/ tree (was top-level files only) so subdirs like git-hooks/ reach the build context. - New tests/integration/test_workspace_operator_attribution.py with 8 tests covering: no-env / with-env / one-env-only / idempotency / template-skip / merge-skip / amend-still-gets-trailer / newline- injection-sanitized. Resolves #158. Verification (rebuilt 2.1.126 image): - 8/8 new operator-attribution tests pass. - 27/27 pre-existing integration tests pass. - Composed hooks layout in container: $HOME/.git-hooks/ post-checkout -> plugins/observability/hooks/git/post-checkout post-commit -> plugins/observability/hooks/git/post-commit post-merge -> plugins/observability/hooks/git/post-merge post-rewrite -> plugins/observability/hooks/git/post-rewrite pre-push -> plugins/observability/hooks/git/pre-push prepare-commit-msg -> /opt/agentic/git-hooks/prepare-commit-msg - Observability git_commit events still emit on stderr.
1 parent a15a3ee commit c041e38

5 files changed

Lines changed: 319 additions & 21 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""Integration tests for the workspace's prepare-commit-msg hook.
2+
3+
The hook lives at:
4+
providers/workspaces/claude-cli/scripts/git-hooks/prepare-commit-msg
5+
6+
It is shipped *with the workspace image* (not as a Claude Code plugin).
7+
The entrypoint composes it into the runtime git hooks directory at
8+
container startup. When SYN_OPERATOR_NAME and SYN_OPERATOR_EMAIL are
9+
both set, the hook appends a `Co-authored-by:` trailer to commit messages.
10+
11+
These tests run real git commits inside the rebuilt workspace image and
12+
assert the hook's behavior end-to-end (entrypoint composition + hook
13+
script + git plumbing all working together).
14+
15+
Requirements:
16+
- Docker available
17+
- agentic-workspace-claude-cli:latest built locally
18+
19+
Run with: pytest tests/integration/test_workspace_operator_attribution.py -v
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import subprocess
25+
import textwrap
26+
27+
import pytest
28+
29+
WORKSPACE_IMAGE = "agentic-workspace-claude-cli:latest"
30+
OPERATOR_NAME = "TestOperator"
31+
OPERATOR_EMAIL = "operator@example.test"
32+
33+
34+
def docker_available() -> bool:
35+
try:
36+
result = subprocess.run(
37+
["docker", "image", "inspect", WORKSPACE_IMAGE],
38+
capture_output=True,
39+
check=False,
40+
)
41+
return result.returncode == 0
42+
except FileNotFoundError:
43+
return False
44+
45+
46+
pytestmark = [
47+
pytest.mark.integration,
48+
pytest.mark.skipif(
49+
not docker_available(),
50+
reason=f"Docker or {WORKSPACE_IMAGE} not available — run `just build-workspace-claude-cli`",
51+
),
52+
]
53+
54+
55+
def run_in_workspace(script: str, env: dict[str, str] | None = None) -> str:
56+
"""Run `script` (bash) inside a fresh workspace container, return stdout.
57+
58+
The default entrypoint runs first (so git hooks get composed); then
59+
`script` runs as the CMD argument. GIT_AUTHOR_* are always set so
60+
git commits succeed inside the container.
61+
"""
62+
env = env or {}
63+
cmd = [
64+
"docker", "run", "--rm",
65+
"-e", "GIT_AUTHOR_NAME=workspace-agent",
66+
"-e", "GIT_AUTHOR_EMAIL=agent@workspace.test",
67+
]
68+
for key, value in env.items():
69+
cmd.extend(["-e", f"{key}={value}"])
70+
cmd.extend([WORKSPACE_IMAGE, "bash", "-c", script])
71+
72+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
73+
if result.returncode != 0:
74+
pytest.fail(
75+
f"Command failed (exit {result.returncode}):\n"
76+
f"--- stdout ---\n{result.stdout}\n"
77+
f"--- stderr ---\n{result.stderr}"
78+
)
79+
return result.stdout
80+
81+
82+
def commit_and_read_message(extra_setup: str = "", env: dict[str, str] | None = None) -> str:
83+
"""Init a repo, commit, return the commit message body."""
84+
script = textwrap.dedent(f"""
85+
set -e
86+
cd /tmp && rm -rf repo && git init -q repo && cd repo
87+
echo file > file
88+
git add file
89+
{extra_setup}
90+
git commit -q -m "test commit"
91+
git log -1 --format=%B
92+
""")
93+
return run_in_workspace(script, env=env)
94+
95+
96+
class TestOperatorAttribution:
97+
"""End-to-end tests for the prepare-commit-msg hook."""
98+
99+
def test_no_env_vars_leaves_message_untouched(self) -> None:
100+
"""Without SYN_OPERATOR_* set, the hook should be a no-op."""
101+
msg = commit_and_read_message()
102+
assert "Co-authored-by:" not in msg, f"unexpected trailer in:\n{msg}"
103+
104+
def test_with_operator_env_appends_trailer(self) -> None:
105+
"""Both env vars set → a single Co-authored-by trailer is appended."""
106+
msg = commit_and_read_message(
107+
env={"SYN_OPERATOR_NAME": OPERATOR_NAME, "SYN_OPERATOR_EMAIL": OPERATOR_EMAIL},
108+
)
109+
expected = f"Co-authored-by: {OPERATOR_NAME} <{OPERATOR_EMAIL}>"
110+
assert expected in msg, f"missing trailer in:\n{msg}"
111+
assert msg.count("Co-authored-by:") == 1
112+
113+
def test_only_name_set_is_no_op(self) -> None:
114+
"""Either env var alone → no trailer (both are required)."""
115+
msg = commit_and_read_message(env={"SYN_OPERATOR_NAME": OPERATOR_NAME})
116+
assert "Co-authored-by:" not in msg
117+
118+
def test_idempotent_when_trailer_already_present(self) -> None:
119+
"""If the user's commit message already has the same trailer, don't double-append."""
120+
# Use -m with newlines via printf so the trailer is in the message from the start
121+
existing = f"Co-authored-by: {OPERATOR_NAME} <{OPERATOR_EMAIL}>"
122+
script = textwrap.dedent(f"""
123+
set -e
124+
cd /tmp && rm -rf repo && git init -q repo && cd repo
125+
echo file > file && git add file
126+
git commit -q -m "test commit" -m "{existing}"
127+
git log -1 --format=%B
128+
""")
129+
msg = run_in_workspace(
130+
script,
131+
env={"SYN_OPERATOR_NAME": OPERATOR_NAME, "SYN_OPERATOR_EMAIL": OPERATOR_EMAIL},
132+
)
133+
assert msg.count("Co-authored-by:") == 1, f"trailer duplicated:\n{msg}"
134+
135+
def test_template_source_skipped(self) -> None:
136+
"""Direct hook invocation with COMMIT_SOURCE=template must leave the message unchanged."""
137+
script = textwrap.dedent("""
138+
set -e
139+
HOOK=/opt/agentic/git-hooks/prepare-commit-msg
140+
test -x "$HOOK" || { echo "HOOK_MISSING"; exit 1; }
141+
echo "from-template" > /tmp/msg
142+
"$HOOK" /tmp/msg template
143+
cat /tmp/msg
144+
""")
145+
out = run_in_workspace(
146+
script,
147+
env={"SYN_OPERATOR_NAME": OPERATOR_NAME, "SYN_OPERATOR_EMAIL": OPERATOR_EMAIL},
148+
)
149+
assert "Co-authored-by:" not in out, f"trailer should be skipped on template source:\n{out}"
150+
151+
def test_merge_source_skipped(self) -> None:
152+
"""Direct hook invocation with COMMIT_SOURCE=merge must leave the message unchanged."""
153+
script = textwrap.dedent("""
154+
set -e
155+
HOOK=/opt/agentic/git-hooks/prepare-commit-msg
156+
echo "merge msg" > /tmp/msg
157+
"$HOOK" /tmp/msg merge
158+
cat /tmp/msg
159+
""")
160+
out = run_in_workspace(
161+
script,
162+
env={"SYN_OPERATOR_NAME": OPERATOR_NAME, "SYN_OPERATOR_EMAIL": OPERATOR_EMAIL},
163+
)
164+
assert "Co-authored-by:" not in out
165+
166+
def test_amend_commit_does_get_trailer(self) -> None:
167+
"""`git commit --amend` (COMMIT_SOURCE=commit) should still get the trailer if missing.
168+
169+
This guards the explicit decision NOT to skip COMMIT_SOURCE=commit.
170+
"""
171+
script = textwrap.dedent("""
172+
set -e
173+
cd /tmp && rm -rf repo && git init -q repo && cd repo
174+
echo file > file && git add file
175+
# First commit WITHOUT operator env to produce a trailer-less HEAD
176+
unset SYN_OPERATOR_NAME SYN_OPERATOR_EMAIL
177+
git commit -q -m "initial"
178+
# Now amend WITH operator env — the hook should add the trailer
179+
export SYN_OPERATOR_NAME="$NAME" SYN_OPERATOR_EMAIL="$EMAIL"
180+
git commit -q --amend --no-edit
181+
git log -1 --format=%B
182+
""")
183+
msg = run_in_workspace(
184+
script,
185+
env={"NAME": OPERATOR_NAME, "EMAIL": OPERATOR_EMAIL},
186+
)
187+
assert f"Co-authored-by: {OPERATOR_NAME} <{OPERATOR_EMAIL}>" in msg, (
188+
f"amend should pick up trailer when missing:\n{msg}"
189+
)
190+
191+
def test_newline_in_env_var_does_not_inject_extra_trailer(self) -> None:
192+
"""A newline in SYN_OPERATOR_NAME must not split into two trailer lines."""
193+
# Pass a literal newline via $'...' inside the docker -c shell
194+
script = textwrap.dedent("""
195+
set -e
196+
cd /tmp && rm -rf repo && git init -q repo && cd repo
197+
echo file > file && git add file
198+
export SYN_OPERATOR_NAME=$'evil\\nCo-authored-by: attacker <bad@bad.test>'
199+
export SYN_OPERATOR_EMAIL='ne@example.com'
200+
git commit -q -m "newline test"
201+
git log -1 --format=%B
202+
""")
203+
msg = run_in_workspace(script)
204+
trailer_lines = [line for line in msg.splitlines() if line.startswith("Co-authored-by:")]
205+
assert len(trailer_lines) == 1, (
206+
f"newline injection should be sanitized to a single trailer line; got {len(trailer_lines)}:\n{msg}"
207+
)
208+
# The single trailer's email field (last <...> on the line) must be the
209+
# operator email, not the attacker's. The attacker substring may still
210+
# appear inside the (now malformed) name field — that's cosmetic; the
211+
# security goal is that no separate trailer line was injected.
212+
assert trailer_lines[0].rstrip().endswith("<ne@example.com>"), (
213+
f"trailer's email field should be the operator email, got:\n{trailer_lines[0]}"
214+
)

providers/workspaces/claude-cli/Dockerfile

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,16 +171,21 @@ RUN mkdir -p /workspace/artifacts/input \
171171
# NOTE: ~/.claude/settings.json is created by entrypoint.sh at runtime
172172
# because /home/agent is a tmpfs mount that wipes anything baked into the image.
173173

174-
# Copy entrypoint script
174+
# Copy entrypoint script and workspace-shipped git hooks.
175+
# git-hooks/ holds non-observability git hooks owned by the workspace itself
176+
# (e.g. prepare-commit-msg for operator Co-authored-by attribution). These
177+
# are NOT Claude Code plugins — the entrypoint installs them into the
178+
# runtime git hooks directory alongside hooks contributed by plugins.
175179
COPY scripts/entrypoint.sh /opt/agentic/entrypoint.sh
180+
COPY scripts/git-hooks/ /opt/agentic/git-hooks/
176181

177182
# Copy pre-staged plugins from build context (ADR-033)
178183
# Each plugin is a self-contained directory with .claude-plugin/plugin.json
179184
# and hooks/hooks.json using ${CLAUDE_PLUGIN_ROOT} for path resolution.
180185
COPY plugins/ /opt/agentic/plugins/
181186

182-
# Set permissions on plugins and entrypoint
183-
RUN chmod -R 755 /opt/agentic/plugins \
187+
# Set permissions on plugins, entrypoint, and workspace-shipped hooks
188+
RUN chmod -R 755 /opt/agentic/plugins /opt/agentic/git-hooks \
184189
&& find /opt/agentic/plugins -name "*.py" -exec chmod 755 {} \; \
185190
&& chmod 755 /opt/agentic/entrypoint.sh \
186191
&& chown -R agent:agent /opt/agentic

providers/workspaces/claude-cli/scripts/entrypoint.sh

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
# GIT_AUTHOR_NAME - Git commit author name (required for git ops)
1919
# GIT_AUTHOR_EMAIL - Git commit author email (required for git ops)
2020
# GITHUB_TOKEN - GitHub token for git push (optional)
21+
# SYN_OPERATOR_NAME - Operator display name for Co-authored-by trailer (optional)
22+
# SYN_OPERATOR_EMAIL - Operator email matching a verified GitHub account (optional)
2123
#
2224
# This script is the SINGLE SOURCE OF TRUTH for workspace configuration.
2325
# Orchestrators should NOT have hardcoded setup scripts.
@@ -100,23 +102,46 @@ if [ -n "${GIT_AUTHOR_NAME}" ]; then
100102
git config --global init.defaultBranch main
101103
fi
102104

103-
# Install observability git hooks globally (ADR-043).
105+
# Install workspace git hooks globally (ADR-043).
104106
#
105107
# core.hooksPath is a git global config that overrides the per-repo .git/hooks/
106-
# directory. Setting it here (at container startup) means post-commit, pre-push,
107-
# post-merge, and post-rewrite hooks fire for EVERY repo cloned or initialized
108-
# inside this container — including repos cloned by the agent mid-task.
108+
# directory. Setting it here (at container startup) means our hooks fire for
109+
# EVERY repo cloned or initialized inside this container — including repos
110+
# cloned by the agent mid-task.
109111
#
110-
# The hooks emit JSONL observability events to stderr. The docker exec stream in
111-
# AgenticEventStreamAdapter uses stderr=STDOUT to capture them alongside Claude's
112-
# stdout, and WorkflowExecutionEngine stores them in TimescaleDB.
112+
# Two contributing sources, composed into a single runtime directory:
113+
# 1. /opt/agentic/git-hooks/ — workspace-shipped
114+
# hooks. Owned by the claude-cli provider itself (this dir is baked in
115+
# by the Dockerfile from providers/workspaces/claude-cli/scripts/git-hooks/).
116+
# Currently: prepare-commit-msg for operator Co-authored-by attribution
117+
# (driven by SYN_OPERATOR_NAME / SYN_OPERATOR_EMAIL env vars; no-op when
118+
# either is unset).
119+
# 2. /opt/agentic/plugins/observability/hooks/git/ — event-emission hooks
120+
# from the observability plugin (post-commit, pre-push, post-merge,
121+
# post-rewrite, post-checkout). These emit JSONL to stderr; the docker
122+
# exec stream in AgenticEventStreamAdapter merges stderr→stdout, and
123+
# WorkflowExecutionEngine stores the events in TimescaleDB.
113124
#
114-
# Without this line, git hooks only fire in repos that have their own .git/hooks/
115-
# scripts, which is almost never the case for freshly cloned repos.
116-
GIT_HOOKS_DIR="${AGENTIC_PLUGINS_DIR:-/opt/agentic/plugins}/observability/hooks/git"
117-
if [ -d "${GIT_HOOKS_DIR}" ]; then
125+
# Workspace-shipped hooks are linked first so any future name collision
126+
# resolves in favor of the observability event emitters (rare, but explicit).
127+
GIT_HOOKS_DIR="${HOME}/.git-hooks"
128+
mkdir -p "${GIT_HOOKS_DIR}"
129+
130+
for src_dir in \
131+
/opt/agentic/git-hooks \
132+
"${AGENTIC_PLUGINS_DIR:-/opt/agentic/plugins}/observability/hooks/git"; do
133+
[ -d "${src_dir}" ] || continue
134+
for src in "${src_dir}"/*; do
135+
[ -f "${src}" ] || continue
136+
name=$(basename "${src}")
137+
case "${name}" in install.py|*.md|*.txt) continue ;; esac
138+
ln -sf "${src}" "${GIT_HOOKS_DIR}/${name}"
139+
done
140+
done
141+
142+
if [ -n "$(ls -A "${GIT_HOOKS_DIR}" 2>/dev/null)" ]; then
118143
git config --global core.hooksPath "${GIT_HOOKS_DIR}"
119-
echo "[entrypoint] Git observability hooks installed from ${GIT_HOOKS_DIR}"
144+
echo "[entrypoint] Workspace git hooks composed at ${GIT_HOOKS_DIR}"
120145
fi
121146

122147
# Also set committer identity (git uses both for commits)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/bin/sh
2+
# prepare-commit-msg — append operator Co-authored-by trailer.
3+
#
4+
# When the workspace is run by an operator (e.g. via Syntropic137 selfhost),
5+
# SYN_OPERATOR_NAME and SYN_OPERATOR_EMAIL identify the human who sponsored
6+
# the workspace. This hook adds them as a co-author on every agent commit so
7+
# the operator gets attribution on PRs the workspace opens.
8+
#
9+
# This hook is shipped as part of the claude-cli workspace provider (it is
10+
# NOT a Claude Code plugin). The entrypoint installs it into the runtime
11+
# git hooks directory at container start.
12+
#
13+
# Behavior:
14+
# - No-op when either env var is unset (backward compatible).
15+
# - Skipped on auto-generated message sources (merge / squash / template)
16+
# so we don't pollute them. NOT skipped on `commit` source so
17+
# `--amend`, cherry-pick, and rebase commits also get the trailer if
18+
# they're missing it; idempotency below handles re-adds.
19+
# - Idempotent — won't append if the same trailer already exists.
20+
# - Always exits 0 — never blocks a commit on attribution failure.
21+
22+
COMMIT_MSG_FILE="$1"
23+
COMMIT_SOURCE="$2"
24+
25+
case "$COMMIT_SOURCE" in
26+
merge|squash|template) exit 0 ;;
27+
esac
28+
29+
if [ -z "$SYN_OPERATOR_NAME" ] || [ -z "$SYN_OPERATOR_EMAIL" ]; then
30+
exit 0
31+
fi
32+
33+
# Strip CR/LF from operator inputs — env vars are normally well-formed but we
34+
# don't want a stray newline in either to inject additional trailers.
35+
NAME=$(printf '%s' "$SYN_OPERATOR_NAME" | tr -d '\r\n')
36+
EMAIL=$(printf '%s' "$SYN_OPERATOR_EMAIL" | tr -d '\r\n')
37+
38+
if [ -z "$NAME" ] || [ -z "$EMAIL" ]; then
39+
exit 0
40+
fi
41+
42+
TRAILER="Co-authored-by: ${NAME} <${EMAIL}>"
43+
44+
if grep -qF "$TRAILER" "$COMMIT_MSG_FILE" 2>/dev/null; then
45+
exit 0
46+
fi
47+
48+
printf '\n%s\n' "$TRAILER" >> "$COMMIT_MSG_FILE"
49+
exit 0

scripts/build-provider.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,18 +95,23 @@ def stage_plugins(manifest: dict, build_context: Path) -> None:
9595

9696

9797
def stage_scripts(provider: str, build_context: Path) -> None:
98-
"""Copy scripts directory (e.g., entrypoint.sh) to build context."""
98+
"""Copy scripts directory (e.g., entrypoint.sh, git-hooks/) to build context.
99+
100+
Copies the entire scripts/ tree so subdirectories like git-hooks/ are
101+
available to the Dockerfile via `COPY scripts/git-hooks/ ...`.
102+
"""
99103
scripts_src = PROVIDERS_DIR / provider / "scripts"
100104
if not scripts_src.exists():
101105
return # No scripts directory
102106

103107
scripts_dst = build_context / "scripts"
104-
scripts_dst.mkdir(parents=True, exist_ok=True)
108+
if scripts_dst.exists():
109+
shutil.rmtree(scripts_dst)
110+
shutil.copytree(scripts_src, scripts_dst)
105111

106-
for script in scripts_src.iterdir():
107-
if script.is_file():
108-
shutil.copy2(script, scripts_dst / script.name)
109-
print(f" ✓ Script: {script.name}")
112+
for path in sorted(scripts_dst.rglob("*")):
113+
if path.is_file():
114+
print(f" ✓ Script: {path.relative_to(scripts_dst)}")
110115

111116

112117
def build_wheels(build_context: Path) -> None:

0 commit comments

Comments
 (0)