fix(issue-12,8,7): keep broker password out of records, keep fresh install stamps, pack sibling workspaces

Issue #12 - MQTT_PASSWORD was copied into every job record, which any
sub-agent can read. BrokerConfig.to_registry_block no longer persists it;
broker_config_from_job layers the password back from the environment
(.mam.env) at connect time, so publish_event still connects from the record.
_atomic_write_record scrubs broker.password from any record it writes, and
`registry.py scrub-secrets [--dry-run]` migrates an existing registry.
auth_token/dispatch_token stay: the agent's own publish_event needs them.

Issue #8 - update.sh restored the pre-update asset_hashes.txt and version.txt
over the installer's fresh ones, so version.txt named the previous commit and
remove.sh backed up every pristine updated file as "locally modified". The
success path now keeps the installer's stamps and only merges install_state
keys the re-install did not write (gitignore_created); the failure path
still restores everything verbatim.

Issue #7 - the shim's new-session took the first same-cwd pane, always found
the full first workspace, judged it overflow and opened another workspace per
agent (4 + 1 + 1). It now walks every same-cwd workspace in pane order and
takes the first with an idle pane or room in its 2xK grid; a workspace is
created only when all are full (4 + 2).

Tests: test_issue7_workspace_packing.py (3; the mock herdr gains an opt-in
per-workspace 2x2 geometry), test_issue8_update_state.py (1, real
install+update cycle), test_issue12_registry_secrets.py (4); registry CLI
snapshot and help capture refreshed for the intentional scrub-secrets
subcommand; registry.md documents the broker block.

Refs #12 #8 #7

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-13 11:23:19 +09:00
1 parent 8a09e022a6
commit e629cc137f
12 files changed
+422 -52

No files matched your search

+33 -31
View File
@@ -727,33 +727,41 @@ except Exception:
print(cmd)
" "$final_cmd" "$kind" 2>/dev/null || echo "$final_cmd")
# W1: Resolve existing_ws by querying pane list for matching CWD (since WorkspaceInfo has no cwd key)
existing_ws=$(_real_herdr pane list 2>/dev/null | TARGET_CWD="${ws:-.}" python3 -c "
# W1: Resolve candidate workspaces by querying pane list for matching CWD
# (WorkspaceInfo has no cwd key). ISSUE-7: every workspace on this cwd is
# a candidate, in pane order; the first one with an idle pane or with
# room in its 2xK grid takes the agent, and a fresh workspace is created
# only when all of them are full. Taking just the first match found the
# full workspace every time and re-judged it overflow, so each agent past
# the fourth opened yet another workspace (4 + 1 + 1 instead of 4 + 2).
candidate_wss=$(_real_herdr pane list 2>/dev/null | TARGET_CWD="${ws:-.}" python3 -c "
import sys, json, os
target_ws = os.environ.get('TARGET_CWD', '')
try:
target_abs = os.path.realpath(target_ws) if target_ws else ''
d = json.loads(sys.stdin.read())
panes = d.get('result', {}).get('panes', [])
matched_ws = ''
seen = []
for p in panes:
p_cwd = os.path.realpath(p.get('cwd', '')) if p.get('cwd') else ''
if target_abs and p_cwd == target_abs and p.get('workspace_id'):
matched_ws = p.get('workspace_id')
break
print(matched_ws)
wid = p.get('workspace_id')
if target_abs and p_cwd == target_abs and wid and wid not in seen:
seen.append(wid)
print('\n'.join(seen))
except Exception:
pass
" 2>/dev/null || echo "")
ws_id=""
target_pane=""
if [ -n "$existing_ws" ]; then
existing_ws=""
for cand_ws in $candidate_wss; do
[ -n "$cand_ws" ] || continue
# BUG-4: prefer an already-idle (agent-less) pane in the existing
# workspace over splitting a new one — avoids unbounded pane
# proliferation and 2x2 grid-layout churn when a prior agent's pane
# was left behind idle.
idle_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$existing_ws" python3 -c "
idle_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$cand_ws" python3 -c "
import sys, json, os
target_ws = os.environ.get('TARGET_WS', '')
try:
@@ -768,15 +776,18 @@ except Exception:
" 2>/dev/null || echo "")
if [ -n "$idle_pane" ]; then
existing_ws="$cand_ws"
target_pane="$idle_pane"
if [ -n "$env_flags" ] && [ -n "$final_cmd" ]; then
idle_env="${env_flags//--env /}"
idle_env="${idle_env:+$idle_env }HERDR_WORKSPACE_ID=$existing_ws"
final_cmd="env $idle_env $final_cmd"
fi
else
break
fi
# W2a: Determine split direction policy via pane layout
sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$existing_ws" python3 -c "
sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$cand_ws" python3 -c "
import sys, json, os
target_ws = os.environ.get('TARGET_WS', '')
try:
@@ -798,9 +809,15 @@ except Exception:
sample_pane="${split_target:-$sample_pane}"
fi
if [ "$split_dir" = "right" ] || [ "$split_dir" = "down" ]; then
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --env HERDR_WORKSPACE_ID="$existing_ws" --no-focus 2>/dev/null || echo "")
target_pane=$(echo "$split_json" | python3 -c "
if [ "$split_dir" = "overflow" ]; then
# W2b: this workspace's grid is full — try the next one on the same cwd
# (ISSUE-7); a fresh workspace is created below only if none has room.
continue
fi
existing_ws="$cand_ws"
[ "$split_dir" = "down" ] || split_dir="right"
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --env HERDR_WORKSPACE_ID="$existing_ws" --no-focus 2>/dev/null || echo "")
target_pane=$(echo "$split_json" | python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
@@ -809,23 +826,8 @@ try:
except Exception:
pass
" 2>/dev/null || echo "")
elif [ "$split_dir" = "overflow" ]; then
# W2b: Overflow threshold reached — force create fresh workspace
existing_ws=""
else
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction right --cwd "${ws:-.}" $env_flags --env HERDR_WORKSPACE_ID="$existing_ws" --no-focus 2>/dev/null || echo "")
target_pane=$(echo "$split_json" | python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
res = d.get('result', {})
print(res.get('pane', {}).get('pane_id') or res.get('pane_id', ''))
except Exception:
pass
" 2>/dev/null || echo "")
fi
fi
fi
break
done
if [ -z "$existing_ws" ] || [ -z "$target_pane" ]; then
ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" ${MAM_WS_LABEL:+--label "$MAM_WS_LABEL"} $env_flags --no-focus 2>/dev/null || echo "")
@@ -227,13 +227,19 @@ class BrokerConfig:
return asdict(self)
def to_registry_block(self) -> Dict[str, Any]:
"""The subset that gets persisted into a job record's broker block."""
"""The subset that gets persisted into a job record's broker block.
Never the password (Issue #12): every sub-agent can read the job
record, so a broker secret written there ends up in agent transcripts.
``broker_config_from_job`` layers the block over the environment, so
the password is taken from ``MQTT_PASSWORD`` (``.mam.env``) at connect
time instead.
"""
return {
"host": self.host,
"port": self.port,
"tls": self.tls,
"username": self.username,
"password": self.password,
}
@@ -393,12 +399,23 @@ def load_job(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> Dict[str,
return json.load(fh)
def _scrub_record_secrets(record: Dict[str, Any]) -> None:
"""Issue #12: a broker password never belongs in a job record (see
``BrokerConfig.to_registry_block``). Records written before that rule are
cleaned the next time anything writes them; ``registry.py scrub-secrets``
does it for a whole registry at once."""
broker = record.get("broker")
if isinstance(broker, dict):
broker.pop("password", None)
def _atomic_write_record(job_id: str, registry_dir: str, record: Dict[str, Any]) -> None:
"""Write a record atomically: temp file in the same dir + os.replace.
The rename is atomic on POSIX, so readers never observe a half-written
file. Callers MUST already hold ``registry_lock`` for read-modify-write
correctness."""
_scrub_record_secrets(record)
Path(registry_dir).mkdir(parents=True, exist_ok=True)
path = _job_path(job_id, registry_dir)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{job_id}.", suffix=".tmp")
@@ -333,9 +333,35 @@ def _build_parser() -> argparse.ArgumentParser:
p_logs.add_argument("--json", action="store_true",
help="emit raw JSON lines / records instead of a table")
p_scrub = sub.add_parser(
"scrub-secrets",
help="remove broker.password from every job record (Issue #12 migration)",
)
p_scrub.add_argument("--dry-run", action="store_true",
help="only list the records that still carry a password")
return parser
def _cmd_scrub_secrets(args) -> int:
"""Issue #12: strip broker.password from records written before
to_registry_block stopped persisting it. Every other field is kept."""
rd = args.registry_dir
found = []
with registry_lock(rd):
for record in list(_iter_records(rd)):
broker = record.get("broker")
if not (isinstance(broker, dict) and "password" in broker):
continue
found.append(record.get("job_id", "?"))
if not args.dry_run:
broker.pop("password", None)
_atomic_write_record(record["job_id"], rd, record)
verb = "would scrub" if args.dry_run else "scrubbed"
print(f"{verb} {len(found)} record(s) under {rd}" + (": " + ", ".join(found) if found else ""))
return 0
def _operator_write(job_id: str, registry_dir: str, **fields) -> int:
"""Apply an operator-authority write and report a refusal on the exit status.
@@ -506,6 +532,9 @@ def main(argv: Optional[List[str]] = None) -> int:
if args.command == "logs":
return _cmd_logs(args)
if args.command == "scrub-secrets":
return _cmd_scrub_secrets(args)
return 1
@@ -42,8 +42,7 @@ Reference implementation: [`./scripts/registry.py`](./scripts/registry.py)
"host": "broker.hivemq.com",
"port": 1883,
"tls": false,
"username": null,
"password": null
"username": null
},
"topic_prefix": "python/mqtt/jobs/abc12345",
"timeout_sec": 3600,
@@ -55,7 +54,13 @@ Reference implementation: [`./scripts/registry.py`](./scripts/registry.py)
```
- `broker` lets `publish_event.py` connect from the record alone (env still
overrides toggles like `MQTT_TLS`).
overrides toggles like `MQTT_TLS`). It never carries the password (Issue #12):
every sub-agent can read the record, so `MQTT_PASSWORD` is layered back from
the environment (`.mam.env`) at connect time. Records written before that rule
are cleaned on their next write; `registry.py scrub-secrets [--dry-run]` cleans
a whole registry at once. `auth_token` and `dispatch_token` stay in the record
because the agent's own `publish_event` needs them to sign and to pin its
attempt.
- `topic_prefix` → the events topic is `<topic_prefix>/events`.
- `last_seq` backs the monotonic `seq` counter so it survives process restarts.
- `expected_artifacts` is the hook a user `validate.sh` checks (existence/content).