@zerwiz/ymir 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +291 -0
- package/.agents/agents/bragi-marketer.md +111 -0
- package/.agents/agents/brokk.md +352 -0
- package/.agents/agents/forseti-reviewer.md +93 -0
- package/.agents/agents/frigg-consent.md +77 -0
- package/.agents/agents/groa-update.md +77 -0
- package/.agents/agents/hnoss-designer.md +69 -0
- package/.agents/agents/huginn-researcher.md +99 -0
- package/.agents/agents/jord-projects.md +78 -0
- package/.agents/agents/kvasir-scout.md +91 -0
- package/.agents/agents/mimir-planner.md +92 -0
- package/.agents/agents/muninn-stow.md +77 -0
- package/.agents/agents/saga-bearings.md +78 -0
- package/.agents/agents/sindri-developer.md +114 -0
- package/.agents/agents/snotra-documenter.md +105 -0
- package/.agents/agents/syn-recovery.md +78 -0
- package/.agents/agents/tyr-check.md +78 -0
- package/.agents/agents/urdh-hold.md +77 -0
- package/.agents/agents/volundr-smith.md +80 -0
- package/.agents/agents/vor-diagnostics.md +77 -0
- package/.agents/assets/agents/naming.md +50 -0
- package/.agents/assets/agents/registry.md +93 -0
- package/.agents/assets/agents/runtime.md +36 -0
- package/.agents/assets/agents/toon-tasks-cli.md +42 -0
- package/.agents/assets/templates/company_entity.template.md +37 -0
- package/.agents/backend/README.md +74 -0
- package/.agents/backend/backends/cmux.sh +649 -0
- package/.agents/backend/backends/herdr-eventwait.py +157 -0
- package/.agents/backend/backends/herdr-workspace-move.py +114 -0
- package/.agents/backend/backends/herdr.sh +3386 -0
- package/.agents/backend/backends/orca.sh +290 -0
- package/.agents/backend/backends/tmux.sh +357 -0
- package/.agents/backend/backends/zellij.sh +700 -0
- package/.agents/backend/fm-afk-launch.sh +654 -0
- package/.agents/backend/fm-afk-return.sh +241 -0
- package/.agents/backend/fm-afk-start.sh +172 -0
- package/.agents/backend/fm-arm-command-policy.mjs +960 -0
- package/.agents/backend/fm-arm-pretool-check.sh +200 -0
- package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
- package/.agents/backend/fm-backend.sh +981 -0
- package/.agents/backend/fm-backlog-handoff.sh +909 -0
- package/.agents/backend/fm-backlog-receive.sh +187 -0
- package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
- package/.agents/backend/fm-bearings-board.sh +204 -0
- package/.agents/backend/fm-bearings-snapshot.sh +546 -0
- package/.agents/backend/fm-bootstrap.sh +1568 -0
- package/.agents/backend/fm-branch-outcome.sh +203 -0
- package/.agents/backend/fm-branch-prompt.sh +101 -0
- package/.agents/backend/fm-brief.sh +452 -0
- package/.agents/backend/fm-busy-event.sh +234 -0
- package/.agents/backend/fm-busy-lib.sh +986 -0
- package/.agents/backend/fm-captain-hold.sh +1001 -0
- package/.agents/backend/fm-cd-command-policy.mjs +153 -0
- package/.agents/backend/fm-cd-pretool-check.sh +190 -0
- package/.agents/backend/fm-check-lib.sh +74 -0
- package/.agents/backend/fm-check-register.sh +42 -0
- package/.agents/backend/fm-check-unregister.sh +52 -0
- package/.agents/backend/fm-classify-lib.sh +1770 -0
- package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
- package/.agents/backend/fm-composer-lib.sh +1416 -0
- package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
- package/.agents/backend/fm-config-push.sh +254 -0
- package/.agents/backend/fm-control-lib.sh +251 -0
- package/.agents/backend/fm-control.sh +878 -0
- package/.agents/backend/fm-crew-state.sh +625 -0
- package/.agents/backend/fm-cursor-lib.sh +243 -0
- package/.agents/backend/fm-decision-hold.sh +232 -0
- package/.agents/backend/fm-doc-audience-check.sh +269 -0
- package/.agents/backend/fm-dod-lib.sh +67 -0
- package/.agents/backend/fm-ensure-agents-md.sh +253 -0
- package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
- package/.agents/backend/fm-extension.mjs +2577 -0
- package/.agents/backend/fm-extension.sh +16 -0
- package/.agents/backend/fm-ff-lib.sh +420 -0
- package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
- package/.agents/backend/fm-fleet-sync.sh +458 -0
- package/.agents/backend/fm-fleet-view.sh +96 -0
- package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
- package/.agents/backend/fm-guard.sh +242 -0
- package/.agents/backend/fm-harness.sh +195 -0
- package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
- package/.agents/backend/fm-herdr-lab.sh +346 -0
- package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
- package/.agents/backend/fm-home-seed.sh +984 -0
- package/.agents/backend/fm-home-summary-refresh.sh +255 -0
- package/.agents/backend/fm-hook-host-lib.sh +36 -0
- package/.agents/backend/fm-inactive-reconcile.sh +531 -0
- package/.agents/backend/fm-inbox.sh +399 -0
- package/.agents/backend/fm-install-actionlint.sh +84 -0
- package/.agents/backend/fm-install-herdr.sh +96 -0
- package/.agents/backend/fm-install-shellcheck.sh +84 -0
- package/.agents/backend/fm-install-treehouse.sh +95 -0
- package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
- package/.agents/backend/fm-lease-lib.sh +218 -0
- package/.agents/backend/fm-lease.sh +189 -0
- package/.agents/backend/fm-line-cap-lib.sh +51 -0
- package/.agents/backend/fm-lint-workflows.sh +137 -0
- package/.agents/backend/fm-lint.sh +591 -0
- package/.agents/backend/fm-lock-lib.sh +104 -0
- package/.agents/backend/fm-lock.sh +107 -0
- package/.agents/backend/fm-marker-lib.sh +12 -0
- package/.agents/backend/fm-merge-local.sh +74 -0
- package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
- package/.agents/backend/fm-nm-run-lib.sh +124 -0
- package/.agents/backend/fm-on.sh +125 -0
- package/.agents/backend/fm-operational-input.sh +253 -0
- package/.agents/backend/fm-peek.sh +46 -0
- package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
- package/.agents/backend/fm-pr-check.sh +135 -0
- package/.agents/backend/fm-pr-lib.sh +1016 -0
- package/.agents/backend/fm-pr-merge.sh +707 -0
- package/.agents/backend/fm-pr-poll.sh +110 -0
- package/.agents/backend/fm-primary-scope-lib.sh +33 -0
- package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
- package/.agents/backend/fm-procevent-lavish.sh +628 -0
- package/.agents/backend/fm-procevent-lib.sh +892 -0
- package/.agents/backend/fm-procevent-quota.sh +290 -0
- package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
- package/.agents/backend/fm-procevent-when.sh +504 -0
- package/.agents/backend/fm-procevent.sh +1466 -0
- package/.agents/backend/fm-project-mode.sh +92 -0
- package/.agents/backend/fm-project-origin-lib.sh +180 -0
- package/.agents/backend/fm-promote.sh +282 -0
- package/.agents/backend/fm-public-followup-emit.sh +260 -0
- package/.agents/backend/fm-public-followup-lib.sh +398 -0
- package/.agents/backend/fm-public-followup.sh +1330 -0
- package/.agents/backend/fm-push-transition-lib.sh +168 -0
- package/.agents/backend/fm-quota-axi-lib.sh +93 -0
- package/.agents/backend/fm-quota-choose.sh +384 -0
- package/.agents/backend/fm-remote-delta-read.sh +189 -0
- package/.agents/backend/fm-remote-doctor.sh +800 -0
- package/.agents/backend/fm-remote-entrypoint.sh +193 -0
- package/.agents/backend/fm-remote-file.sh +234 -0
- package/.agents/backend/fm-remote-home-provision.sh +259 -0
- package/.agents/backend/fm-remote-home-seed.sh +259 -0
- package/.agents/backend/fm-remote-inherit-push.sh +90 -0
- package/.agents/backend/fm-remote-inherit.sh +179 -0
- package/.agents/backend/fm-remote-job-lib.sh +1241 -0
- package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
- package/.agents/backend/fm-remote-job-worker.sh +1124 -0
- package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
- package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
- package/.agents/backend/fm-review-diff.sh +158 -0
- package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
- package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
- package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
- package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
- package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
- package/.agents/backend/fm-secondmate-report.sh +88 -0
- package/.agents/backend/fm-send.sh +1059 -0
- package/.agents/backend/fm-session-lock-lib.sh +176 -0
- package/.agents/backend/fm-session-start.sh +970 -0
- package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
- package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
- package/.agents/backend/fm-sessionstart-run.sh +145 -0
- package/.agents/backend/fm-spawn.sh +3153 -0
- package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
- package/.agents/backend/fm-startup-memory-budget.sh +94 -0
- package/.agents/backend/fm-startup-network.sh +642 -0
- package/.agents/backend/fm-stow-cascade.sh +251 -0
- package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
- package/.agents/backend/fm-supervise-daemon.sh +1732 -0
- package/.agents/backend/fm-supervision-instructions.sh +215 -0
- package/.agents/backend/fm-supervision-lib.sh +90 -0
- package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
- package/.agents/backend/fm-tangle-lib.sh +53 -0
- package/.agents/backend/fm-task-inbox-lib.sh +402 -0
- package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
- package/.agents/backend/fm-teardown.sh +2922 -0
- package/.agents/backend/fm-test-isolation-proof.sh +588 -0
- package/.agents/backend/fm-test-run.sh +2191 -0
- package/.agents/backend/fm-timeout-lib.sh +141 -0
- package/.agents/backend/fm-timing-lib.sh +179 -0
- package/.agents/backend/fm-tmux-lib.sh +291 -0
- package/.agents/backend/fm-tool-update-check.sh +898 -0
- package/.agents/backend/fm-trace-context-lib.sh +227 -0
- package/.agents/backend/fm-transition-lib.sh +103 -0
- package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
- package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
- package/.agents/backend/fm-turnend-guard.sh +432 -0
- package/.agents/backend/fm-update.sh +110 -0
- package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
- package/.agents/backend/fm-voice-client.py +1373 -0
- package/.agents/backend/fm-voice-relay.py +1256 -0
- package/.agents/backend/fm-wake-drain.sh +592 -0
- package/.agents/backend/fm-wake-grant.sh +127 -0
- package/.agents/backend/fm-wake-lib.sh +1849 -0
- package/.agents/backend/fm-watch-arm.sh +602 -0
- package/.agents/backend/fm-watch-checkpoint.sh +109 -0
- package/.agents/backend/fm-watch.sh +1962 -0
- package/.agents/backend/fm-x-dismiss.sh +116 -0
- package/.agents/backend/fm-x-followup.sh +286 -0
- package/.agents/backend/fm-x-lib.sh +1000 -0
- package/.agents/backend/fm-x-link.sh +234 -0
- package/.agents/backend/fm-x-poll.sh +185 -0
- package/.agents/backend/fm-x-reply.sh +385 -0
- package/.agents/backend/fm_voice_frame.py +166 -0
- package/.agents/backend/fm_voice_records.py +574 -0
- package/.agents/backend/model-bridge.py +360 -0
- package/.agents/backend/opencode-go-bridge.py +30 -0
- package/.agents/bus/README.md +12 -0
- package/.agents/bus/messages.json +1 -0
- package/.agents/config/agents.machine.example.yaml +16 -0
- package/.agents/config/agents.yaml.example +72 -0
- package/.agents/config/crew-dispatch.json +22 -0
- package/.agents/config/cron.yaml +11 -0
- package/.agents/config/eindri-dispatch.json +67 -0
- package/.agents/config/eindri-harness +1 -0
- package/.agents/config/startup-memory-budget +1 -0
- package/.agents/filebrowser/.filebrowser.json +6 -0
- package/.agents/filebrowser/README.md +11 -0
- package/.agents/gateway/README.md +12 -0
- package/.agents/github/README.md +15 -0
- package/.agents/github/webhooks/.gitkeep +0 -0
- package/.agents/github/workflows/.gitkeep +0 -0
- package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
- package/.agents/harness/opencode/plugins/package.json +4 -0
- package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
- package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
- package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
- package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
- package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
- package/.agents/memory/README.md +46 -0
- package/.agents/memory/runes_audit.md +5 -0
- package/.agents/migrations/0001-hodd-layout.sh +38 -0
- package/.agents/migrations/0002-a2a-mcp.sh +27 -0
- package/.agents/migrations/0003-private-data-separation.sh +121 -0
- package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
- package/.agents/migrations/0005-roots-out-of-tree.sh +158 -0
- package/.agents/sandbox/Dockerfile.utgard +19 -0
- package/.agents/sandbox/README.md +14 -0
- package/.agents/sandbox/utgard.config.json +14 -0
- package/.agents/skills/README.md +59 -0
- package/.agents/skills/bragi-marketing/README.md +3 -0
- package/.agents/skills/bragi-marketing/SKILL.md +82 -0
- package/.agents/skills/eindri-homes/README.md +3 -0
- package/.agents/skills/eindri-homes/SKILL.md +291 -0
- package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
- package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
- package/.agents/skills/frigg-consent/README.md +3 -0
- package/.agents/skills/frigg-consent/SKILL.md +56 -0
- package/.agents/skills/galdr-ymirsystem/README.md +3 -0
- package/.agents/skills/galdr-ymirsystem/SKILL.md +159 -0
- package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
- package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +736 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
- package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +539 -0
- package/.agents/skills/galdr-ymirsystem/assets/installation.md +763 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
- package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +163 -0
- package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +415 -0
- package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
- package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +133 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +101 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
- package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
- package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
- package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
- package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
- package/.agents/skills/galdr-ymirsystem/assets/smidja.md +180 -0
- package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
- package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
- package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
- package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +503 -0
- package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
- package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
- package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
- package/.agents/skills/gjallarhorn-relay/README.md +3 -0
- package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
- package/.agents/skills/groa-update/SKILL.md +90 -0
- package/.agents/skills/hamr-adapters/README.md +3 -0
- package/.agents/skills/hamr-adapters/SKILL.md +123 -0
- package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
- package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
- package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
- package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
- package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
- package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
- package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
- package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
- package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
- package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
- package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
- package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
- package/.agents/skills/herdr-panes/README.md +3 -0
- package/.agents/skills/herdr-panes/SKILL.md +92 -0
- package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
- package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
- package/.agents/skills/hnoss-design/DESIGN.md +19 -0
- package/.agents/skills/hnoss-design/README.md +3 -0
- package/.agents/skills/hnoss-design/SKILL.md +83 -0
- package/.agents/skills/hvild-afk/README.md +3 -0
- package/.agents/skills/hvild-afk/SKILL.md +241 -0
- package/.agents/skills/jord-projects/README.md +3 -0
- package/.agents/skills/jord-projects/SKILL.md +92 -0
- package/.agents/skills/lifecycle/SKILL.md +56 -0
- package/.agents/skills/lifecycle/smoke_test.sh +57 -0
- package/.agents/skills/lifecycle/start.sh +21 -0
- package/.agents/skills/lifecycle/status.sh +35 -0
- package/.agents/skills/lifecycle/stop.sh +16 -0
- package/.agents/skills/muninn-stow/README.md +3 -0
- package/.agents/skills/muninn-stow/SKILL.md +309 -0
- package/.agents/skills/no-mistakes/SKILL.md +379 -0
- package/.agents/skills/nornir-schedule/README.md +3 -0
- package/.agents/skills/nornir-schedule/SKILL.md +25 -0
- package/.agents/skills/nornir-schedule/assets/events.md +137 -0
- package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
- package/.agents/skills/nsr-compliance/README.md +3 -0
- package/.agents/skills/nsr-compliance/SKILL.md +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/pr-ops/README.md +3 -0
- package/.agents/skills/pr-ops/SKILL.md +109 -0
- package/.agents/skills/ratatoskr-a2a/README.md +3 -0
- package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
- package/.agents/skills/rules-check-drift/SKILL.md +81 -0
- package/.agents/skills/saga-bearings/README.md +3 -0
- package/.agents/skills/saga-bearings/SKILL.md +26 -0
- package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
- package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
- package/.agents/skills/saga-bearings/assets/recap.md +56 -0
- package/.agents/skills/syn-recovery/README.md +3 -0
- package/.agents/skills/syn-recovery/SKILL.md +54 -0
- package/.agents/skills/tyr-check/README.md +3 -0
- package/.agents/skills/tyr-check/SKILL.md +218 -0
- package/.agents/skills/urdh-hold/README.md +3 -0
- package/.agents/skills/urdh-hold/SKILL.md +25 -0
- package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
- package/.agents/skills/urdh-hold/assets/hold.md +54 -0
- package/.agents/skills/vor-diagnostics/README.md +3 -0
- package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
- package/.agents/skills/ymir-host/README.md +3 -0
- package/.agents/skills/ymir-host/SKILL.md +35 -0
- package/.agents/skills/ymir-host/assets/desktop.md +36 -0
- package/.agents/skills/ymir-host/assets/install.md +69 -0
- package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
- package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
- package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
- package/.agents/skills/ymir-host/assets/update.md +64 -0
- package/.agents/tests/assets/board-render-harness.mjs +117 -0
- package/.agents/tests/cmux-test-safety.sh +51 -0
- package/.agents/tests/fixtures.sh +291 -0
- package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
- package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
- package/.agents/tests/fm-afk-launch.test.sh +961 -0
- package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
- package/.agents/tests/fm-afk-return.test.sh +283 -0
- package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
- package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
- package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
- package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
- package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
- package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
- package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
- package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
- package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
- package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
- package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
- package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
- package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
- package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
- package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
- package/.agents/tests/fm-backend-orca.test.sh +1357 -0
- package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
- package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
- package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
- package/.agents/tests/fm-backend.test.sh +1142 -0
- package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
- package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
- package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
- package/.agents/tests/fm-bearings-board.test.sh +414 -0
- package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
- package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
- package/.agents/tests/fm-bootstrap.test.sh +1178 -0
- package/.agents/tests/fm-branch-supervision.test.sh +552 -0
- package/.agents/tests/fm-brief.test.sh +785 -0
- package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
- package/.agents/tests/fm-busy-state.test.sh +465 -0
- package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
- package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
- package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
- package/.agents/tests/fm-check-unregister.test.sh +198 -0
- package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
- package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
- package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
- package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
- package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
- package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
- package/.agents/tests/fm-composer-ghost.test.sh +716 -0
- package/.agents/tests/fm-composer-lib.test.sh +666 -0
- package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
- package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
- package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
- package/.agents/tests/fm-control.test.sh +910 -0
- package/.agents/tests/fm-crew-state.test.sh +1610 -0
- package/.agents/tests/fm-cursor-harness.test.sh +404 -0
- package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
- package/.agents/tests/fm-cursor-primary.test.sh +708 -0
- package/.agents/tests/fm-daemon.test.sh +2740 -0
- package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
- package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
- package/.agents/tests/fm-extension-binding.test.sh +2187 -0
- package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
- package/.agents/tests/fm-fleet-sync.test.sh +721 -0
- package/.agents/tests/fm-gate-refuse.test.sh +356 -0
- package/.agents/tests/fm-gitignore-config.test.sh +89 -0
- package/.agents/tests/fm-gotmp.test.sh +230 -0
- package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
- package/.agents/tests/fm-grok-harness.test.sh +117 -0
- package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
- package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
- package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
- package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
- package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
- package/.agents/tests/fm-herdr-lab.test.sh +243 -0
- package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
- package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
- package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
- package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
- package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
- package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
- package/.agents/tests/fm-kimi-harness.test.sh +687 -0
- package/.agents/tests/fm-lint-workflows.test.sh +532 -0
- package/.agents/tests/fm-lint.test.sh +1023 -0
- package/.agents/tests/fm-muse-harness.test.sh +932 -0
- package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
- package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
- package/.agents/tests/fm-on.test.sh +507 -0
- package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
- package/.agents/tests/fm-operational-input.test.sh +160 -0
- package/.agents/tests/fm-peek-remote.test.sh +110 -0
- package/.agents/tests/fm-pending-reply.test.sh +1288 -0
- package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
- package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
- package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
- package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
- package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
- package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
- package/.agents/tests/fm-pr-merge.test.sh +2135 -0
- package/.agents/tests/fm-procevent-quota.test.sh +225 -0
- package/.agents/tests/fm-procevent-when.test.sh +406 -0
- package/.agents/tests/fm-procevent.test.sh +1745 -0
- package/.agents/tests/fm-project-origin.test.sh +110 -0
- package/.agents/tests/fm-public-followup.test.sh +2272 -0
- package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
- package/.agents/tests/fm-quota-choose.test.sh +626 -0
- package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
- package/.agents/tests/fm-remote-doctor.test.sh +625 -0
- package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
- package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
- package/.agents/tests/fm-remote-job.test.sh +768 -0
- package/.agents/tests/fm-remote-reply.test.sh +520 -0
- package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
- package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
- package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
- package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
- package/.agents/tests/fm-review-diff.test.sh +176 -0
- package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
- package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
- package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
- package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
- package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
- package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
- package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
- package/.agents/tests/fm-send-inbox.test.sh +352 -0
- package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
- package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
- package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
- package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
- package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
- package/.agents/tests/fm-send-settle.test.sh +147 -0
- package/.agents/tests/fm-send-strict.test.sh +241 -0
- package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
- package/.agents/tests/fm-session-start.test.sh +2515 -0
- package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
- package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
- package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
- package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
- package/.agents/tests/fm-spawn-batch.test.sh +148 -0
- package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
- package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
- package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
- package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
- package/.agents/tests/fm-startup-network.test.sh +712 -0
- package/.agents/tests/fm-stow-cascade.test.sh +370 -0
- package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
- package/.agents/tests/fm-supervision-events.test.sh +156 -0
- package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
- package/.agents/tests/fm-tangle-guard.test.sh +277 -0
- package/.agents/tests/fm-task-delivery.test.sh +420 -0
- package/.agents/tests/fm-task-inbox.test.sh +513 -0
- package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
- package/.agents/tests/fm-teardown.test.sh +2665 -0
- package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
- package/.agents/tests/fm-test-fixtures.test.sh +132 -0
- package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
- package/.agents/tests/fm-test-run.test.sh +1196 -0
- package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
- package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
- package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
- package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
- package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
- package/.agents/tests/fm-transition-lib.test.sh +54 -0
- package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
- package/.agents/tests/fm-update.test.sh +304 -0
- package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
- package/.agents/tests/fm-voice-relay.test.sh +4741 -0
- package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
- package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
- package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
- package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
- package/.agents/tests/fm-wake-queue.test.sh +1224 -0
- package/.agents/tests/fm-watch-arm.test.sh +821 -0
- package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
- package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
- package/.agents/tests/fm-watch-triage.test.sh +3889 -0
- package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
- package/.agents/tests/fm-x-mode.test.sh +3042 -0
- package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
- package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
- package/.agents/tests/herdr-test-safety.sh +42 -0
- package/.agents/tests/lib.sh +315 -0
- package/.agents/tests/remote-herdr-fixture.sh +125 -0
- package/.agents/tests/secondmate-helpers.sh +193 -0
- package/.agents/tests/smoke.test.sh +93 -0
- package/.agents/tests/wake-helpers.sh +338 -0
- package/.agents/tests/zellij-test-safety.sh +60 -0
- package/.agents/tools/README.md +10 -0
- package/.agents/tools/bin/tasks-cli +267 -0
- package/.agents/tools/tasks-cli.ts +216 -0
- package/AGENTS.md +498 -0
- package/CHANGELOG.md +1913 -0
- package/LICENSE +1 -1
- package/NOTICE +63 -0
- package/README.md +114 -60
- package/RULES/01-domains.md +51 -0
- package/RULES/02-agents.md +78 -0
- package/RULES/03-houses.md +61 -0
- package/RULES/04-hoard.md +97 -0
- package/RULES/05-platforms.md +67 -0
- package/RULES/06-append-only.md +108 -0
- package/RULES/07-config.md +101 -0
- package/RULES/08-delivery-gate.md +75 -0
- package/RULES/09-electron.md +58 -0
- package/RULES/README.md +22 -0
- package/bin/README.md +107 -0
- package/bin/a2a-mcp.sh +133 -0
- package/bin/a2a-serve.py +103 -0
- package/bin/a2a-serve.sh +8 -0
- package/bin/a2a-talk.sh +50 -0
- package/bin/a2abridge-ensure.sh +108 -0
- package/bin/agent-run.sh +43 -0
- package/bin/agents-config.sh +256 -0
- package/bin/apodex-smoke-test.sh +105 -0
- package/bin/bifrost-bridge.sh +131 -0
- package/bin/bifrost-ingress.sh +77 -0
- package/bin/bootstrap-macos.sh +112 -0
- package/bin/bootstrap-windows.ps1 +112 -0
- package/bin/branch-guard.sh +64 -0
- package/bin/brokk +73 -0
- package/bin/brokk-classify-lib.sh +1770 -0
- package/bin/brokk-lease-lib.sh +222 -0
- package/bin/brokk-lease.sh +193 -0
- package/bin/brokk-lint.sh +65 -0
- package/bin/brokk-send.sh +51 -0
- package/bin/brokk-update.sh +9 -0
- package/bin/brokk-wake-grant.sh +127 -0
- package/bin/brokk-wake-lib.sh +1849 -0
- package/bin/changelog-assemble.sh +154 -0
- package/bin/changelog-guard.sh +197 -0
- package/bin/crash-sense.sh +173 -0
- package/bin/design-check.sh +80 -0
- package/bin/design-icon.sh +153 -0
- package/bin/desktop-place.sh +224 -0
- package/bin/docs-guard.sh +39 -0
- package/bin/editor-place.sh +225 -0
- package/bin/eindri-acclaim.sh +47 -0
- package/bin/eindri-control.sh +28 -0
- package/bin/eindri-role.sh +83 -0
- package/bin/eindri-seat.sh +83 -0
- package/bin/eindri-seen.sh +54 -0
- package/bin/eindri-send.sh +15 -0
- package/bin/eindri-start.sh +98 -0
- package/bin/eindri-watch.sh +57 -0
- package/bin/einherjar-spawn.sh +576 -0
- package/bin/eir-doctor.sh +158 -0
- package/bin/erindi-brief.sh +271 -0
- package/bin/feature-inventory.sh +73 -0
- package/bin/fleet-apply.sh +81 -0
- package/bin/github-deploy.sh +94 -0
- package/bin/gjallarhorn-expose.sh +136 -0
- package/bin/gjallarhorn-notify.sh +71 -0
- package/bin/gjallarhorn-purge.sh +67 -0
- package/bin/gjallarhorn-tunnel.sh +85 -0
- package/bin/gleipnir-lock-lib.sh +284 -0
- package/bin/groa-update.sh +130 -0
- package/bin/hall-snapshot.sh +111 -0
- package/bin/hamr-harness.sh +246 -0
- package/bin/heimdall-ssh-keys.sh +374 -0
- package/bin/herdr-agents.py +44 -0
- package/bin/herdr-ensure.sh +104 -0
- package/bin/herdr-run.sh +452 -0
- package/bin/hermes-ensure.sh +88 -0
- package/bin/hlidskjalf-agents.sh +172 -0
- package/bin/hlidskjalf-usage.sh +196 -0
- package/bin/hoard-guard.sh +135 -0
- package/bin/hoard-lib.sh +105 -0
- package/bin/hodd.sh +102 -0
- package/bin/host-sense.sh +199 -0
- package/bin/huginn-research-worker.sh +121 -0
- package/bin/local-model-lock.sh +70 -0
- package/bin/mimir-bridge.py +259 -0
- package/bin/mimir-bridge.sh +100 -0
- package/bin/mimir-ingest.sh +131 -0
- package/bin/mimir-reflect.sh +27 -0
- package/bin/mimir.sh +113 -0
- package/bin/mjollnir-webhook.sh +72 -0
- package/bin/mjollnir.sh +104 -0
- package/bin/model-hardware.sh +96 -0
- package/bin/model-resolve.sh +160 -0
- package/bin/models-detect.sh +178 -0
- package/bin/nornir-cron-start.sh +162 -0
- package/bin/nornir-job-daily-briefing.sh +191 -0
- package/bin/nornir-job-git-sync.sh +152 -0
- package/bin/nornir-job-hall-snapshot.sh +28 -0
- package/bin/nornir-job-memory-housekeeping.sh +114 -0
- package/bin/nornir-job-nsr-compliance.sh +54 -0
- package/bin/nornir-job-observer.sh +166 -0
- package/bin/nornir-job-skillopt-sleep.sh +76 -0
- package/bin/npm-publish.sh +136 -0
- package/bin/omarchy-hook-install.sh +60 -0
- package/bin/omarchy-install.sh +161 -0
- package/bin/omarchy-plugins.sh +187 -0
- package/bin/omarchy-sense.sh +162 -0
- package/bin/perm-guard.sh +30 -0
- package/bin/pi-agent.sh +68 -0
- package/bin/pi-ensure.sh +77 -0
- package/bin/pi-local.sh +37 -0
- package/bin/pi-model-agnostic.sh +71 -0
- package/bin/pi-seat.sh +63 -0
- package/bin/prereq-ensure.sh +166 -0
- package/bin/private-guard.sh +58 -0
- package/bin/project-git.sh +69 -0
- package/bin/public-guard.sh +71 -0
- package/bin/ratatoskr.sh +49 -0
- package/bin/realm-lib.sh +32 -0
- package/bin/repo-scrub.sh +62 -0
- package/bin/rodd-operational-input.sh +250 -0
- package/bin/runes-append.sh +198 -0
- package/bin/saga-session-start.sh +172 -0
- package/bin/saga-sessionstart-run.sh +79 -0
- package/bin/saga-wake-drain.sh +41 -0
- package/bin/secret-guard.sh +63 -0
- package/bin/sessrumnir-ensure.sh +100 -0
- package/bin/sessrumnir-sync.sh +237 -0
- package/bin/sessrumnir.sh +104 -0
- package/bin/skillopt-setup.sh +76 -0
- package/bin/skuld-branch-outcome.sh +203 -0
- package/bin/skuld-branch-prompt.sh +101 -0
- package/bin/smidja-bootstrap.sh +94 -0
- package/bin/smidja-observe.sh +86 -0
- package/bin/syn-arm-pretool-check.sh +57 -0
- package/bin/syn-asset-pretool-check.sh +89 -0
- package/bin/syn-cd-pretool-check.sh +28 -0
- package/bin/syn-guard-pretool-check.sh +83 -0
- package/bin/syn-spawn-pretool-check.sh +58 -0
- package/bin/syn-turnend-guard.sh +36 -0
- package/bin/syn-watch-arm.sh +99 -0
- package/bin/tailscale-sync.sh +203 -0
- package/bin/telegram-bot.sh +82 -0
- package/bin/toolchain.sh +74 -0
- package/bin/ui-truth-check.sh +75 -0
- package/bin/utgard.sh +111 -0
- package/bin/valhalla.sh +115 -0
- package/bin/valknut-load.sh +298 -0
- package/bin/vor-crew-state.sh +124 -0
- package/bin/wedge-notify.sh +60 -0
- package/bin/workspace-provision.sh +80 -0
- package/bin/workspace-rag.sh +163 -0
- package/bin/wyrd-db.sh +47 -0
- package/bin/yggdrasil.sh +152 -0
- package/bin/ymir-edit.sh +145 -0
- package/bin/ymir-install.sh +806 -0
- package/bin/ymir-invite.sh +93 -0
- package/bin/ymir-isolation.sh +88 -0
- package/bin/ymir-migrate.sh +61 -0
- package/bin/ymir-plan.sh +356 -0
- package/bin/ymir-platform.sh +348 -0
- package/bin/ymir-say.sh +110 -0
- package/bin/ymir-setup-auth.sh +119 -0
- package/bin/ymir-validate.sh +193 -0
- package/bin/ymir.js +74 -16
- package/docs/Architecture.md +339 -0
- package/docs/design.md +351 -0
- package/docs/installations/README.md +63 -0
- package/docs/installations/ymir-home.md +93 -0
- package/docs/integration.md +105 -0
- package/docs/lore.md +1060 -0
- package/docs/research/agent-harnesses.md +290 -0
- package/docs/runbooks/README.md +19 -0
- package/docs/runbooks/a2a.md +91 -0
- package/docs/runbooks/agent-permissions.md +99 -0
- package/docs/runbooks/agents.md +57 -0
- package/docs/runbooks/models.md +69 -0
- package/docs/runbooks/realm-onboarding.md +60 -0
- package/docs/runbooks/secrets-and-hoard.md +60 -0
- package/docs/runbooks/updates-and-migrations.md +110 -0
- package/docs/runbooks/worktrees-isolation.md +72 -0
- package/docs/session-start.md +105 -0
- package/docs/skillopt-integration.md +321 -0
- package/docs/sync-system.md +66 -0
- package/docs/workspaces.md +104 -0
- package/docs/ymir-rut.md +260 -0
- package/package.json +54 -14
- package/packaging/build.sh +74 -0
- package/packaging/macos/Ymir Installer.command +43 -0
- package/packaging/windows/ymir-setup.nsi +74 -0
- package/scripts/README.md +22 -0
- package/scripts/electron.sh +269 -0
- package/scripts/lower.sh +34 -0
- package/scripts/raise.sh +42 -0
- package/scripts/start.sh +217 -0
- package/scripts/stop.sh +113 -0
- package/install.sh +0 -35
|
@@ -0,0 +1,3042 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Behavior tests for X mode: the relay poll client (fm-x-poll.sh), the answer
|
|
3
|
+
# poster (fm-x-reply.sh), and bootstrap's .env-presence activation.
|
|
4
|
+
#
|
|
5
|
+
# X mode must be INERT by default (no token -> the poll is a hard no-op and
|
|
6
|
+
# bootstrap writes/prints nothing) and additive when on (a check shim + a 30s
|
|
7
|
+
# cadence config, both idempotent). The network is stubbed with a fakebin `curl`
|
|
8
|
+
# so these stay hermetic: no ports, no server, deterministic in CI. jq stays the
|
|
9
|
+
# real tool. End-to-end verification against a real HTTP relay is done out of
|
|
10
|
+
# band; this suite pins the client logic and the activation contract.
|
|
11
|
+
set -u
|
|
12
|
+
|
|
13
|
+
# shellcheck source=tests/lib.sh
|
|
14
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
|
15
|
+
|
|
16
|
+
BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin}
|
|
17
|
+
# The client under test uses the real jq; make it resolvable regardless of where
|
|
18
|
+
# it is installed (Homebrew, Nix profile bins, etc.), which the bare BASE_PATH may
|
|
19
|
+
# not include. Prepended after the fakebin so the fake curl still wins.
|
|
20
|
+
JQ_DIR=$(command -v jq 2>/dev/null) && JQ_DIR=$(dirname "$JQ_DIR") || JQ_DIR=
|
|
21
|
+
[ -n "$JQ_DIR" ] && BASE_PATH="$JQ_DIR:$BASE_PATH"
|
|
22
|
+
TMP_ROOT=$(fm_test_tmproot fm-x-mode-tests)
|
|
23
|
+
|
|
24
|
+
# A fakebin `curl` that mimics the relay: it reads its behavior from env
|
|
25
|
+
# (FAKE_POLL_CODE/FAKE_POLL_BODY/FAKE_ANSWER_CODE, and
|
|
26
|
+
# FAKE_REQCTX_CODE/FAKE_REQCTX_BODY for the request-context lookup), records each
|
|
27
|
+
# call to FAKE_CURL_LOG, writes the poll/lookup body to the script's -o file, and
|
|
28
|
+
# prints the HTTP code to stdout exactly as the real `-w '%{http_code}'` would.
|
|
29
|
+
make_fake_curl() {
|
|
30
|
+
local dir=$1 fakebin
|
|
31
|
+
fakebin=$(fm_fakebin "$dir")
|
|
32
|
+
cat > "$fakebin/curl" <<'SH'
|
|
33
|
+
#!/usr/bin/env bash
|
|
34
|
+
ofile="" method=GET data="" url="" auth=""
|
|
35
|
+
argv=$*
|
|
36
|
+
while [ $# -gt 0 ]; do
|
|
37
|
+
case "$1" in
|
|
38
|
+
-o) ofile=$2; shift 2 ;;
|
|
39
|
+
-X) method=$2; shift 2 ;;
|
|
40
|
+
--data) data=$2; shift 2 ;;
|
|
41
|
+
--data-binary)
|
|
42
|
+
case "$2" in
|
|
43
|
+
@-) data=$(cat) ;;
|
|
44
|
+
@*) data=$(cat -- "${2#@}") ;;
|
|
45
|
+
*) data=$2 ;;
|
|
46
|
+
esac
|
|
47
|
+
shift 2
|
|
48
|
+
;;
|
|
49
|
+
-H)
|
|
50
|
+
case "$2" in
|
|
51
|
+
@*) while IFS= read -r header; do case "$header" in Authorization:*) auth=$header ;; esac; done < "${2#@}" ;;
|
|
52
|
+
Authorization:*) auth=$2 ;;
|
|
53
|
+
esac
|
|
54
|
+
shift 2
|
|
55
|
+
;;
|
|
56
|
+
-m|-w) shift 2 ;;
|
|
57
|
+
-s) shift ;;
|
|
58
|
+
http://*|https://*) url=$1; shift ;;
|
|
59
|
+
*) shift ;;
|
|
60
|
+
esac
|
|
61
|
+
done
|
|
62
|
+
if [ -n "${FAKE_CURL_LOG:-}" ]; then
|
|
63
|
+
{ echo "argv=$argv"; echo "method=$method"; echo "url=$url"; echo "auth=$auth"; echo "data=$data"; } >> "$FAKE_CURL_LOG"
|
|
64
|
+
fi
|
|
65
|
+
case "$url" in
|
|
66
|
+
*/connector/poll)
|
|
67
|
+
[ -n "$ofile" ] && printf '%s' "${FAKE_POLL_BODY:-}" > "$ofile"
|
|
68
|
+
printf '%s' "${FAKE_POLL_CODE:-204}"
|
|
69
|
+
;;
|
|
70
|
+
*/connector/answer)
|
|
71
|
+
[ -n "$ofile" ] && printf '%s' "${FAKE_ANSWER_BODY:-}" > "$ofile"
|
|
72
|
+
printf '%s' "${FAKE_ANSWER_CODE:-200}"
|
|
73
|
+
;;
|
|
74
|
+
*/connector/followup)
|
|
75
|
+
[ -n "$ofile" ] && printf '%s' "${FAKE_FOLLOWUP_BODY:-${FAKE_ANSWER_BODY:-}}" > "$ofile"
|
|
76
|
+
[ -n "${FAKE_CURL_TOUCH_AFTER_POST:-}" ] && : > "$FAKE_CURL_TOUCH_AFTER_POST"
|
|
77
|
+
printf '%s' "${FAKE_FOLLOWUP_CODE:-${FAKE_ANSWER_CODE:-200}}"
|
|
78
|
+
;;
|
|
79
|
+
*/connector/dismiss)
|
|
80
|
+
printf '%s' "${FAKE_DISMISS_CODE:-200}"
|
|
81
|
+
;;
|
|
82
|
+
*/connector/request-context)
|
|
83
|
+
[ -n "$ofile" ] && printf '%s' "${FAKE_REQCTX_BODY:-}" > "$ofile"
|
|
84
|
+
printf '%s' "${FAKE_REQCTX_CODE:-200}"
|
|
85
|
+
;;
|
|
86
|
+
esac
|
|
87
|
+
exit 0
|
|
88
|
+
SH
|
|
89
|
+
chmod +x "$fakebin/curl"
|
|
90
|
+
printf '%s\n' "$fakebin"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
make_sample_image() {
|
|
94
|
+
local path=$1
|
|
95
|
+
case "$path" in
|
|
96
|
+
*.png) printf '\211PNG\r\n\032\nfirstmate-test-png' > "$path" ;;
|
|
97
|
+
*.jpg|*.jpeg) printf '\377\330\377firstmate-test-jpeg' > "$path" ;;
|
|
98
|
+
*.gif) printf 'GIF89afirstmate-test-gif' > "$path" ;;
|
|
99
|
+
*.webp) printf 'RIFF....WEBPfirstmate-test-webp' > "$path" ;;
|
|
100
|
+
*) printf 'firstmate-test-image' > "$path" ;;
|
|
101
|
+
esac
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
path_mode() {
|
|
105
|
+
if [ "$(uname)" = Darwin ]; then
|
|
106
|
+
stat -f %Lp "$1"
|
|
107
|
+
else
|
|
108
|
+
stat -c %a "$1"
|
|
109
|
+
fi
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
assert_no_private_artifact_temps() {
|
|
113
|
+
local dir=$1 leftovers
|
|
114
|
+
[ -d "$dir" ] || return 0
|
|
115
|
+
leftovers=$(find "$dir" -name '*.fm-x.*' -print 2>/dev/null)
|
|
116
|
+
[ -z "$leftovers" ] || fail "private artifact temp files were not cleaned up: $leftovers"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private_artifact_dir() {
|
|
120
|
+
mkdir -p "$1"
|
|
121
|
+
chmod 700 "$1"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private_artifact_file() {
|
|
125
|
+
chmod 600 "$1"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
test_poll_no_token_is_hard_noop() {
|
|
131
|
+
local home fakebin out rc
|
|
132
|
+
home="$TMP_ROOT/poll-noop"; mkdir -p "$home"
|
|
133
|
+
fakebin=$(make_fake_curl "$home")
|
|
134
|
+
# No .env, no FMX_PAIRING_TOKEN: must exit 0 with no output and touch nothing.
|
|
135
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_PAIRING_TOKEN='' \
|
|
136
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
137
|
+
expect_code 0 "$rc" "poll no-token exit"
|
|
138
|
+
[ -z "$out" ] || fail "poll no-token must be silent (got: $out)"
|
|
139
|
+
assert_absent "$home/state/x-inbox" "poll no-token must not create an inbox"
|
|
140
|
+
pass "fm-x-poll is a hard no-op without a token (inert default)"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
test_poll_empty_env_token_overrides_env_file() {
|
|
144
|
+
local home fakebin log out rc
|
|
145
|
+
home="$TMP_ROOT/poll-empty-env-token"; mkdir -p "$home"
|
|
146
|
+
fakebin=$(make_fake_curl "$home")
|
|
147
|
+
log="$home/curl.log"
|
|
148
|
+
printf 'FMX_PAIRING_TOKEN=tok-dotenv\n' > "$home/.env"
|
|
149
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_PAIRING_TOKEN='' \
|
|
150
|
+
FAKE_CURL_LOG="$log" FAKE_POLL_CODE=204 \
|
|
151
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
152
|
+
expect_code 0 "$rc" "poll empty-env-token exit"
|
|
153
|
+
[ -z "$out" ] || fail "empty env token must disable X mode despite .env token (got: $out)"
|
|
154
|
+
[ ! -f "$log" ] || fail "empty env token must not call the relay"
|
|
155
|
+
assert_absent "$home/state/x-inbox" "empty env token must not create an inbox"
|
|
156
|
+
pass "fm-x-poll treats an explicitly empty env token as configured"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
test_poll_204_is_silent() {
|
|
160
|
+
local home fakebin log out rc
|
|
161
|
+
home="$TMP_ROOT/poll-204"; mkdir -p "$home"
|
|
162
|
+
fakebin=$(make_fake_curl "$home")
|
|
163
|
+
log="$home/curl.log"
|
|
164
|
+
printf 'FMX_PAIRING_TOKEN=tok-204\n' > "$home/.env"
|
|
165
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
166
|
+
FAKE_CURL_LOG="$log" FAKE_POLL_CODE=204 \
|
|
167
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
168
|
+
expect_code 0 "$rc" "poll 204 exit"
|
|
169
|
+
[ -z "$out" ] || fail "poll 204 must be silent (got: $out)"
|
|
170
|
+
assert_grep "auth=Authorization: Bearer tok-204" "$log" "poll must send the bearer token"
|
|
171
|
+
grep '^argv=' "$log" | grep -F 'tok-204' >/dev/null 2>&1 \
|
|
172
|
+
&& fail "poll must not expose the bearer token in curl argv"
|
|
173
|
+
assert_grep "url=https://relay.test/connector/poll" "$log" "poll must hit /connector/poll"
|
|
174
|
+
ls "$home/state/x-inbox/"*.json >/dev/null 2>&1 && fail "poll 204 must not stash an inbox file"
|
|
175
|
+
pass "fm-x-poll stays silent on HTTP 204 (the common case)"
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
test_poll_empty_env_relay_overrides_env_file() {
|
|
179
|
+
local home fakebin log out rc
|
|
180
|
+
home="$TMP_ROOT/poll-empty-env-relay"; mkdir -p "$home"
|
|
181
|
+
fakebin=$(make_fake_curl "$home")
|
|
182
|
+
log="$home/curl.log"
|
|
183
|
+
printf 'FMX_PAIRING_TOKEN=tok-relay\nFMX_RELAY_URL=https://dotenv-relay.test/\n' > "$home/.env"
|
|
184
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL='' \
|
|
185
|
+
FAKE_CURL_LOG="$log" FAKE_POLL_CODE=204 \
|
|
186
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
187
|
+
expect_code 0 "$rc" "poll empty-env-relay exit"
|
|
188
|
+
[ -z "$out" ] || fail "poll 204 with empty env relay must be silent (got: $out)"
|
|
189
|
+
assert_grep "url=https://myfirstmate.io/connector/poll" "$log" \
|
|
190
|
+
"empty env relay must override .env and fall back to the default relay"
|
|
191
|
+
pass "fm-x-poll lets an explicitly empty relay env override .env"
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
test_poll_auth_error_reports_once() {
|
|
195
|
+
local home fakebin out rc
|
|
196
|
+
home="$TMP_ROOT/poll-auth"; mkdir -p "$home"
|
|
197
|
+
fakebin=$(make_fake_curl "$home")
|
|
198
|
+
printf 'FMX_PAIRING_TOKEN=tok-auth\n' > "$home/.env"
|
|
199
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
200
|
+
FAKE_POLL_CODE=401 \
|
|
201
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
202
|
+
expect_code 0 "$rc" "poll auth error exit"
|
|
203
|
+
[ "$out" = "x-mode-error relay returned HTTP 401" ] \
|
|
204
|
+
|| fail "poll auth error must emit one visible diagnostic (got: $out)"
|
|
205
|
+
assert_present "$home/state/x-poll.error" "poll auth error must write a dedupe marker"
|
|
206
|
+
[ "$(path_mode "$home/state")" = 700 ] || fail "poll auth error must create private state"
|
|
207
|
+
[ "$(path_mode "$home/state/x-poll.error")" = 600 ] || fail "poll auth error marker must be private"
|
|
208
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
209
|
+
FAKE_POLL_CODE=401 \
|
|
210
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
211
|
+
expect_code 0 "$rc" "poll repeated auth error exit"
|
|
212
|
+
[ -z "$out" ] || fail "repeated poll auth error must be quiet after the first diagnostic (got: $out)"
|
|
213
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
214
|
+
FAKE_POLL_CODE=204 \
|
|
215
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
216
|
+
expect_code 0 "$rc" "poll recovered auth error exit"
|
|
217
|
+
[ -z "$out" ] || fail "poll recovery 204 must stay silent (got: $out)"
|
|
218
|
+
assert_absent "$home/state/x-poll.error" "poll 204 must clear the auth diagnostic marker"
|
|
219
|
+
pass "fm-x-poll surfaces auth/config errors once and clears on recovery"
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test_poll_error_private_publication_rejects_unsafe_paths() {
|
|
223
|
+
local home fakebin out rc target marker hardlink
|
|
224
|
+
|
|
225
|
+
home="$TMP_ROOT/poll-error-linked-state"; mkdir -p "$home/external-state"
|
|
226
|
+
fakebin=$(make_fake_curl "$home")
|
|
227
|
+
ln -s "$home/external-state" "$home/state"
|
|
228
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
229
|
+
FMX_PAIRING_TOKEN=tok-linked-state FAKE_POLL_CODE=401 \
|
|
230
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
231
|
+
expect_code 0 "$rc" "poll linked state diagnostic exit"
|
|
232
|
+
[ "$out" = "x-mode-error relay returned HTTP 401" ] \
|
|
233
|
+
|| fail "poll must still emit a diagnostic when the marker cannot be safely stored (got: $out)"
|
|
234
|
+
assert_absent "$home/external-state/x-poll.error" "poll must not write the diagnostic through a linked state directory"
|
|
235
|
+
[ -L "$home/state" ] || fail "poll must leave a rejected linked state directory in place"
|
|
236
|
+
|
|
237
|
+
home="$TMP_ROOT/poll-error-linked-marker"; mkdir -p "$home/state"
|
|
238
|
+
fakebin=$(make_fake_curl "$home")
|
|
239
|
+
chmod 700 "$home/state"
|
|
240
|
+
target="$home/external-error"
|
|
241
|
+
printf 'relay returned HTTP 401\n' > "$target"
|
|
242
|
+
ln -s "$target" "$home/state/x-poll.error"
|
|
243
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
244
|
+
FMX_PAIRING_TOKEN=tok-linked-marker FAKE_POLL_CODE=401 \
|
|
245
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
246
|
+
expect_code 0 "$rc" "poll linked marker diagnostic exit"
|
|
247
|
+
[ "$out" = "x-mode-error relay returned HTTP 401" ] \
|
|
248
|
+
|| fail "poll must not dedupe through a linked diagnostic marker (got: $out)"
|
|
249
|
+
[ "$(cat "$target")" = "relay returned HTTP 401" ] \
|
|
250
|
+
|| fail "poll must not write through a linked diagnostic marker"
|
|
251
|
+
[ -L "$home/state/x-poll.error" ] || fail "poll must not replace a rejected linked diagnostic marker"
|
|
252
|
+
|
|
253
|
+
home="$TMP_ROOT/poll-error-hardlink-marker"; mkdir -p "$home/state"
|
|
254
|
+
fakebin=$(make_fake_curl "$home")
|
|
255
|
+
chmod 700 "$home/state"
|
|
256
|
+
marker="$home/state/x-poll.error"
|
|
257
|
+
hardlink="$home/state/x-poll.alias"
|
|
258
|
+
printf 'relay returned HTTP 401\n' > "$marker"
|
|
259
|
+
chmod 600 "$marker"
|
|
260
|
+
ln "$marker" "$hardlink"
|
|
261
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
262
|
+
FMX_PAIRING_TOKEN=tok-hard-marker FAKE_POLL_CODE=401 \
|
|
263
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
264
|
+
expect_code 0 "$rc" "poll hardlinked marker diagnostic exit"
|
|
265
|
+
[ "$out" = "x-mode-error relay returned HTTP 401" ] \
|
|
266
|
+
|| fail "poll must not dedupe through a hardlinked diagnostic marker (got: $out)"
|
|
267
|
+
[ "$(cat "$marker")" = "relay returned HTTP 401" ] || fail "poll must preserve the hardlinked marker"
|
|
268
|
+
[ "$(cat "$hardlink")" = "relay returned HTTP 401" ] || fail "poll must preserve the marker hardlink peer"
|
|
269
|
+
assert_no_private_artifact_temps "$home/state"
|
|
270
|
+
pass "fm-x-poll diagnostic markers use private guarded publication"
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
test_poll_question_stashes_and_marks() {
|
|
274
|
+
local home fakebin out rc body
|
|
275
|
+
home="$TMP_ROOT/poll-q"; mkdir -p "$home"
|
|
276
|
+
fakebin=$(make_fake_curl "$home")
|
|
277
|
+
printf 'FMX_PAIRING_TOKEN=tok-q\n' > "$home/.env"
|
|
278
|
+
body='{"request_id":"req-7","tweet_id":"555","author_id":"42","text":"what are you building?"}'
|
|
279
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
280
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
281
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
282
|
+
expect_code 0 "$rc" "poll question exit"
|
|
283
|
+
[ "$out" = "x-mention req-7" ] || fail "poll must print compact marker (got: $out)"
|
|
284
|
+
assert_present "$home/state/x-inbox/req-7.json" "poll must stash the question"
|
|
285
|
+
[ "$(jq -r .text "$home/state/x-inbox/req-7.json")" = "what are you building?" ] \
|
|
286
|
+
|| fail "stashed inbox must preserve the question text"
|
|
287
|
+
[ "$(jq -r .tweet_id "$home/state/x-inbox/req-7.json")" = "555" ] \
|
|
288
|
+
|| fail "stashed inbox must preserve the full object"
|
|
289
|
+
pass "fm-x-poll stashes the question and prints the compact marker"
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
test_poll_mentions_wake_once_per_durable_offer() {
|
|
293
|
+
local home fakebin out rc body marker
|
|
294
|
+
home="$TMP_ROOT/poll-offer-dedupe"; mkdir -p "$home"
|
|
295
|
+
fakebin=$(make_fake_curl "$home")
|
|
296
|
+
printf 'FMX_PAIRING_TOKEN=tok-offer\n' > "$home/.env"
|
|
297
|
+
body='{"request_id":"req-repeat","platform":"discord","reply_max_chars":1900,"text":"status?"}'
|
|
298
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
|
|
299
|
+
FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
300
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
301
|
+
expect_code 0 "$rc" "first offered mention poll exit"
|
|
302
|
+
[ "$out" = "x-mention req-repeat" ] \
|
|
303
|
+
|| fail "a newly offered mention must wake once (got: $out)"
|
|
304
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000030 \
|
|
305
|
+
FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
306
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
307
|
+
expect_code 0 "$rc" "repeated pending mention poll exit"
|
|
308
|
+
[ -z "$out" ] || fail "an already offered pending mention must stay silent (got: $out)"
|
|
309
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
310
|
+
FAKE_DISMISS_CODE=200 "$ROOT/bin/fm-x-dismiss.sh" req-repeat); rc=$?
|
|
311
|
+
expect_code 0 "$rc" "successful dismiss before relay re-offer exit"
|
|
312
|
+
[ "$out" = "req-repeat" ] || fail "the dismiss fixture must succeed before the re-offer"
|
|
313
|
+
rm -f "$home/state/x-inbox/req-repeat.json"
|
|
314
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000060 \
|
|
315
|
+
FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
316
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
317
|
+
expect_code 0 "$rc" "post-answer re-offer poll exit"
|
|
318
|
+
[ -z "$out" ] || fail "a relay re-offer after inbox cleanup must stay silent (got: $out)"
|
|
319
|
+
assert_absent "$home/state/x-inbox/req-repeat.json" \
|
|
320
|
+
"a suppressed post-answer re-offer must not recreate the drained inbox"
|
|
321
|
+
marker="$home/state/x-context/req-repeat.offered.json"
|
|
322
|
+
assert_present "$marker" "the durable offer marker must survive inbox cleanup"
|
|
323
|
+
rm -f "$marker"
|
|
324
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000090 \
|
|
325
|
+
FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
326
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
327
|
+
expect_code 0 "$rc" "mention re-offer after local marker loss exit"
|
|
328
|
+
[ "$out" = "x-mention req-repeat" ] \
|
|
329
|
+
|| fail "a re-offer after local marker loss must wake once (got: $out)"
|
|
330
|
+
body='{"request_id":"req-new","platform":"discord","reply_max_chars":1900,"text":"new status?"}'
|
|
331
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000120 \
|
|
332
|
+
FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
333
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
334
|
+
expect_code 0 "$rc" "genuinely new mention poll exit"
|
|
335
|
+
[ "$out" = "x-mention req-new" ] \
|
|
336
|
+
|| fail "a genuinely new request_id must wake once (got: $out)"
|
|
337
|
+
marker="$home/state/x-context/req-new.offered.json"
|
|
338
|
+
[ "$(path_mode "$marker")" = 600 ] \
|
|
339
|
+
|| fail "the durable offer marker must be a private file"
|
|
340
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700604921 \
|
|
341
|
+
FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
342
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
343
|
+
expect_code 0 "$rc" "mention re-offer after marker expiry exit"
|
|
344
|
+
[ "$out" = "x-mention req-new" ] \
|
|
345
|
+
|| fail "a re-offer after the bounded marker expiry must wake once (got: $out)"
|
|
346
|
+
pass "fm-x-poll wakes once per durable request offer across inbox cleanup"
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
test_poll_offer_claim_failure_reports_once() {
|
|
350
|
+
local home fakebin out rc body
|
|
351
|
+
home="$TMP_ROOT/poll-offer-claim-failure"; mkdir -p "$home/state" "$home/external-context"
|
|
352
|
+
fakebin=$(make_fake_curl "$home")
|
|
353
|
+
chmod 700 "$home/state"
|
|
354
|
+
ln -s "$home/external-context" "$home/state/x-context"
|
|
355
|
+
body='{"request_id":"req-claim-failure","text":"status?"}'
|
|
356
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
357
|
+
FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
358
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
359
|
+
expect_code 0 "$rc" "first offer claim failure poll exit"
|
|
360
|
+
[ "$out" = "x-mode-error cannot record mention offer" ] \
|
|
361
|
+
|| fail "an offer claim failure must emit one diagnostic (got: $out)"
|
|
362
|
+
assert_present "$home/state/x-poll.claim-error" "offer claim failure must write a dedupe marker"
|
|
363
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
364
|
+
FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
365
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
366
|
+
expect_code 0 "$rc" "repeated offer claim failure poll exit"
|
|
367
|
+
[ -z "$out" ] || fail "a repeated offer claim failure must stay silent (got: $out)"
|
|
368
|
+
assert_present "$home/state/x-poll.claim-error" "a repeated offer claim failure must retain its dedupe marker"
|
|
369
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
370
|
+
FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=204 \
|
|
371
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
372
|
+
expect_code 0 "$rc" "no-pending poll after offer claim failure exit"
|
|
373
|
+
[ -z "$out" ] || fail "a no-pending poll must stay silent after an offer claim failure (got: $out)"
|
|
374
|
+
assert_present "$home/state/x-poll.claim-error" \
|
|
375
|
+
"a no-pending poll must retain the offer claim dedupe marker"
|
|
376
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
377
|
+
FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
378
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
379
|
+
expect_code 0 "$rc" "re-offered claim failure poll exit"
|
|
380
|
+
[ -z "$out" ] || fail "a re-offered claim failure must stay silent (got: $out)"
|
|
381
|
+
rm "$home/state/x-context"
|
|
382
|
+
mkdir "$home/state/x-context"
|
|
383
|
+
chmod 700 "$home/state/x-context"
|
|
384
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
385
|
+
FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
386
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
387
|
+
expect_code 0 "$rc" "recovered offer claim poll exit"
|
|
388
|
+
[ "$out" = "x-mention req-claim-failure" ] \
|
|
389
|
+
|| fail "a recovered offer claim must emit the mention wake (got: $out)"
|
|
390
|
+
assert_absent "$home/state/x-poll.claim-error" "a successful offer claim must clear the diagnostic marker"
|
|
391
|
+
pass "fm-x-poll retains offer claim diagnostics until recovery"
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
test_poll_preserves_conversation_context() {
|
|
395
|
+
local home fakebin out rc body f
|
|
396
|
+
home="$TMP_ROOT/poll-ctx"; mkdir -p "$home"
|
|
397
|
+
fakebin=$(make_fake_curl "$home")
|
|
398
|
+
printf 'FMX_PAIRING_TOKEN=tok-c\n' > "$home/.env"
|
|
399
|
+
# A follow-up reply: the relay includes in_reply_to with the parent tweet.
|
|
400
|
+
body='{"request_id":"req-c","tweet_id":"9","author_id":"42","text":"and then what?","in_reply_to":{"author_handle":"@asker","text":"are you shipping today?"}}'
|
|
401
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
402
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
403
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
404
|
+
expect_code 0 "$rc" "poll conversation exit"
|
|
405
|
+
[ "$out" = "x-mention req-c" ] || fail "poll must mark the follow-up mention (got: $out)"
|
|
406
|
+
f="$home/state/x-inbox/req-c.json"
|
|
407
|
+
assert_present "$f" "poll must stash the follow-up"
|
|
408
|
+
[ "$(jq -r '.in_reply_to.author_handle' "$f")" = "@asker" ] \
|
|
409
|
+
|| fail "inbox must preserve in_reply_to.author_handle for continuity"
|
|
410
|
+
[ "$(jq -r '.in_reply_to.text' "$f")" = "are you shipping today?" ] \
|
|
411
|
+
|| fail "inbox must preserve in_reply_to.text for continuity"
|
|
412
|
+
# A fresh, standalone mention: in_reply_to is null and round-trips as null.
|
|
413
|
+
home="$TMP_ROOT/poll-ctx-fresh"; mkdir -p "$home"
|
|
414
|
+
fakebin=$(make_fake_curl "$home")
|
|
415
|
+
printf 'FMX_PAIRING_TOKEN=tok-c\n' > "$home/.env"
|
|
416
|
+
body='{"request_id":"req-f","tweet_id":"10","author_id":"42","text":"what are you up to?","in_reply_to":null}'
|
|
417
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
418
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
419
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
420
|
+
expect_code 0 "$rc" "poll fresh-mention exit"
|
|
421
|
+
[ "$(jq -r '.in_reply_to' "$home/state/x-inbox/req-f.json")" = "null" ] \
|
|
422
|
+
|| fail "a fresh mention must round-trip in_reply_to as null"
|
|
423
|
+
pass "fm-x-poll preserves in_reply_to conversation context in the inbox"
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
test_poll_inbox_commit_failure_reports_error() {
|
|
427
|
+
local home fakebin out rc body
|
|
428
|
+
home="$TMP_ROOT/poll-mv-fail"; mkdir -p "$home"
|
|
429
|
+
fakebin=$(make_fake_curl "$home")
|
|
430
|
+
cat > "$fakebin/mv" <<'SH'
|
|
431
|
+
#!/usr/bin/env bash
|
|
432
|
+
dest=
|
|
433
|
+
for arg in "$@"; do
|
|
434
|
+
dest=$arg
|
|
435
|
+
done
|
|
436
|
+
case "$dest" in
|
|
437
|
+
*/x-inbox/*) exit 1 ;;
|
|
438
|
+
esac
|
|
439
|
+
exec /bin/mv "$@"
|
|
440
|
+
SH
|
|
441
|
+
chmod +x "$fakebin/mv"
|
|
442
|
+
printf 'FMX_PAIRING_TOKEN=tok-q\n' > "$home/.env"
|
|
443
|
+
body='{"request_id":"req-rename","tweet_id":"555","author_id":"42","text":"what are you building?"}'
|
|
444
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
445
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
446
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
447
|
+
expect_code 0 "$rc" "poll inbox commit failure exit"
|
|
448
|
+
[ "$out" = "x-mode-error cannot write inbox" ] \
|
|
449
|
+
|| fail "poll inbox commit failure must emit an error, not a wake marker (got: $out)"
|
|
450
|
+
assert_absent "$home/state/x-inbox/req-rename.json" "poll must not report a committed inbox file that was not created"
|
|
451
|
+
assert_absent "$home/state/x-inbox/req-rename.json.tmp" "poll must clean up the failed inbox temp file"
|
|
452
|
+
assert_present "$home/state/x-poll.error" "poll inbox commit failure must write a dedupe marker"
|
|
453
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
454
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
455
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
456
|
+
expect_code 0 "$rc" "poll repeated inbox commit failure exit"
|
|
457
|
+
[ -z "$out" ] || fail "repeated poll inbox commit failure must be quiet after the first diagnostic (got: $out)"
|
|
458
|
+
rm -f "$fakebin/mv"
|
|
459
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
460
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
461
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
462
|
+
expect_code 0 "$rc" "poll recovered inbox commit failure exit"
|
|
463
|
+
[ "$out" = "x-mention req-rename" ] \
|
|
464
|
+
|| fail "poll must emit the mention marker once the inbox write succeeds (got: $out)"
|
|
465
|
+
assert_absent "$home/state/x-poll.error" "successful inbox write must clear the diagnostic marker"
|
|
466
|
+
pass "fm-x-poll reports inbox commit failures without emitting a mention wake"
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
test_poll_inbox_private_publication_rejects_unsafe_paths() {
|
|
470
|
+
local home fakebin out rc body target dir dest hardlink
|
|
471
|
+
body='{"request_id":"req-x","tweet_id":"555","author_id":"42","text":"what are you building?"}'
|
|
472
|
+
|
|
473
|
+
home="$TMP_ROOT/poll-inbox-linked-dir"; mkdir -p "$home/state" "$home/external"
|
|
474
|
+
fakebin=$(make_fake_curl "$home")
|
|
475
|
+
ln -s "$home/external" "$home/state/x-inbox"
|
|
476
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
477
|
+
FMX_PAIRING_TOKEN=tok-linked FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
478
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
479
|
+
expect_code 0 "$rc" "poll linked inbox dir exit"
|
|
480
|
+
[ "$out" = "x-mode-error cannot write inbox" ] \
|
|
481
|
+
|| fail "poll must report a linked inbox directory as a write failure (got: $out)"
|
|
482
|
+
assert_absent "$home/external/req-x.json" "poll must not write through a linked inbox directory"
|
|
483
|
+
[ -L "$home/state/x-inbox" ] || fail "poll must leave the rejected inbox symlink in place"
|
|
484
|
+
|
|
485
|
+
home="$TMP_ROOT/poll-inbox-public-dir"; mkdir -p "$home/state/x-inbox"
|
|
486
|
+
fakebin=$(make_fake_curl "$home")
|
|
487
|
+
chmod 755 "$home/state/x-inbox"
|
|
488
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
489
|
+
FMX_PAIRING_TOKEN=tok-public FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
490
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
491
|
+
expect_code 0 "$rc" "poll public inbox dir exit"
|
|
492
|
+
[ "$out" = "x-mode-error cannot write inbox" ] \
|
|
493
|
+
|| fail "poll must reject a nonprivate inbox directory (got: $out)"
|
|
494
|
+
assert_absent "$home/state/x-inbox/req-x.json" "poll must not publish into a nonprivate inbox directory"
|
|
495
|
+
assert_no_private_artifact_temps "$home/state/x-inbox"
|
|
496
|
+
|
|
497
|
+
home="$TMP_ROOT/poll-inbox-linked-dest"; mkdir -p "$home/state/x-inbox"
|
|
498
|
+
fakebin=$(make_fake_curl "$home")
|
|
499
|
+
chmod 700 "$home/state/x-inbox"
|
|
500
|
+
target="$home/external-target.json"
|
|
501
|
+
printf 'external sentinel\n' > "$target"
|
|
502
|
+
ln -s "$target" "$home/state/x-inbox/req-x.json"
|
|
503
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
504
|
+
FMX_PAIRING_TOKEN=tok-linkdest FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
505
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
506
|
+
expect_code 0 "$rc" "poll linked inbox destination exit"
|
|
507
|
+
[ "$out" = "x-mode-error cannot write inbox" ] \
|
|
508
|
+
|| fail "poll must reject a linked inbox destination (got: $out)"
|
|
509
|
+
[ "$(cat "$target")" = "external sentinel" ] || fail "poll must not write through a linked inbox destination"
|
|
510
|
+
[ -L "$home/state/x-inbox/req-x.json" ] || fail "poll must not replace a rejected linked destination"
|
|
511
|
+
assert_no_private_artifact_temps "$home/state/x-inbox"
|
|
512
|
+
|
|
513
|
+
home="$TMP_ROOT/poll-inbox-hardlink-dest"; mkdir -p "$home/state/x-inbox"
|
|
514
|
+
fakebin=$(make_fake_curl "$home")
|
|
515
|
+
chmod 700 "$home/state/x-inbox"
|
|
516
|
+
dest="$home/state/x-inbox/req-x.json"
|
|
517
|
+
hardlink="$home/state/x-inbox/req-x.alias"
|
|
518
|
+
printf '{"request_id":"req-x","text":"old"}\n' > "$dest"
|
|
519
|
+
chmod 600 "$dest"
|
|
520
|
+
ln "$dest" "$hardlink"
|
|
521
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
522
|
+
FMX_PAIRING_TOKEN=tok-hard FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
523
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
524
|
+
expect_code 0 "$rc" "poll hardlinked inbox destination exit"
|
|
525
|
+
[ "$out" = "x-mode-error cannot write inbox" ] \
|
|
526
|
+
|| fail "poll must reject a hardlinked inbox destination (got: $out)"
|
|
527
|
+
[ "$(jq -r .text "$dest")" = "old" ] || fail "poll must preserve a rejected hardlinked destination"
|
|
528
|
+
[ "$(jq -r .text "$hardlink")" = "old" ] || fail "poll must preserve the hardlink peer"
|
|
529
|
+
assert_no_private_artifact_temps "$home/state/x-inbox"
|
|
530
|
+
|
|
531
|
+
home="$TMP_ROOT/poll-inbox-private-success"; mkdir -p "$home"
|
|
532
|
+
fakebin=$(make_fake_curl "$home")
|
|
533
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
534
|
+
FMX_PAIRING_TOKEN=tok-ok FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
|
|
535
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
536
|
+
expect_code 0 "$rc" "poll private inbox success exit"
|
|
537
|
+
[ "$out" = "x-mention req-x" ] || fail "poll must still emit a wake after private publication (got: $out)"
|
|
538
|
+
dir="$home/state/x-inbox"
|
|
539
|
+
[ "$(path_mode "$dir")" = 700 ] || fail "poll must create the inbox directory as private"
|
|
540
|
+
[ "$(path_mode "$dir/req-x.json")" = 600 ] || fail "poll must publish the inbox file as private"
|
|
541
|
+
assert_no_private_artifact_temps "$dir"
|
|
542
|
+
pass "fm-x-poll publishes inbox records only through private guarded artifacts"
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
test_poll_rejects_unsafe_request_id() {
|
|
546
|
+
local home fakebin out rc
|
|
547
|
+
home="$TMP_ROOT/poll-evil"; mkdir -p "$home"
|
|
548
|
+
fakebin=$(make_fake_curl "$home")
|
|
549
|
+
printf 'FMX_PAIRING_TOKEN=tok-e\n' > "$home/.env"
|
|
550
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
551
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY='{"request_id":"../../etc/x","text":"hi"}' \
|
|
552
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
553
|
+
expect_code 0 "$rc" "poll unsafe id exit"
|
|
554
|
+
[ -z "$out" ] || fail "poll must not emit a marker for an unsafe request_id (got: $out)"
|
|
555
|
+
assert_absent "$home/state/x-inbox/../../etc/x.json" "poll must not write outside the inbox"
|
|
556
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
557
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY='{"request_id":".hidden","text":"hi"}' \
|
|
558
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
559
|
+
expect_code 0 "$rc" "poll hidden id exit"
|
|
560
|
+
[ -z "$out" ] || fail "poll must not emit a marker for a hidden request_id (got: $out)"
|
|
561
|
+
assert_absent "$home/state/x-inbox/.hidden.json" "poll must not stash a hidden inbox file"
|
|
562
|
+
pass "fm-x-poll rejects an unsafe request_id (path-traversal guard)"
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
test_reply_success_posts_request_bound_only() {
|
|
566
|
+
local home fakebin log out rc keys
|
|
567
|
+
home="$TMP_ROOT/reply-ok"; mkdir -p "$home"
|
|
568
|
+
fakebin=$(make_fake_curl "$home")
|
|
569
|
+
log="$home/curl.log"
|
|
570
|
+
printf 'FMX_PAIRING_TOKEN=tok-r\n' > "$home/.env"
|
|
571
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
572
|
+
FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
|
|
573
|
+
"$ROOT/bin/fm-x-reply.sh" "req-7" "Aye, charting a couple of fixes."); rc=$?
|
|
574
|
+
expect_code 0 "$rc" "reply success exit"
|
|
575
|
+
[ "$out" = "req-7" ] || fail "reply must echo only the request_id (got: $out)"
|
|
576
|
+
assert_grep "url=https://relay.test/connector/answer" "$log" "reply must POST /connector/answer"
|
|
577
|
+
assert_grep "method=POST" "$log" "reply must use POST"
|
|
578
|
+
assert_grep "auth=Authorization: Bearer tok-r" "$log" "reply must send the bearer token"
|
|
579
|
+
grep '^argv=' "$log" | grep -F 'tok-r' >/dev/null 2>&1 \
|
|
580
|
+
&& fail "reply must not expose the bearer token in curl argv"
|
|
581
|
+
# The body must be exactly {request_id, text} - never a tweet id.
|
|
582
|
+
local data
|
|
583
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
584
|
+
[ "$(printf '%s' "$data" | jq -r .request_id)" = "req-7" ] || fail "reply body request_id"
|
|
585
|
+
[ "$(printf '%s' "$data" | jq -r .text)" = "Aye, charting a couple of fixes." ] || fail "reply body text"
|
|
586
|
+
keys=$(printf '%s' "$data" | jq -r 'keys|join(",")')
|
|
587
|
+
[ "$keys" = "request_id,text" ] || fail "reply body must carry only request_id,text (got: $keys)"
|
|
588
|
+
pass "fm-x-reply posts a request-bound answer and echoes only the request_id"
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
test_reply_non_2xx_fails() {
|
|
592
|
+
local home fakebin out rc err
|
|
593
|
+
home="$TMP_ROOT/reply-500"; mkdir -p "$home"
|
|
594
|
+
fakebin=$(make_fake_curl "$home")
|
|
595
|
+
err="$home/err.txt"
|
|
596
|
+
printf 'FMX_PAIRING_TOKEN=tok-r\n' > "$home/.env"
|
|
597
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
598
|
+
FAKE_ANSWER_CODE=500 \
|
|
599
|
+
"$ROOT/bin/fm-x-reply.sh" "req-7" "hi" 2>"$err"); rc=$?
|
|
600
|
+
[ "$rc" -ne 0 ] || fail "reply must exit non-zero on a non-2xx response"
|
|
601
|
+
assert_grep "HTTP 500" "$err" "reply must report the failing status"
|
|
602
|
+
pass "fm-x-reply exits non-zero on a non-2xx relay response"
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
test_reply_auth_header_tempfile_cleans_up_on_interrupted_post() {
|
|
606
|
+
local home fakebin log out rc auth_file
|
|
607
|
+
home="$TMP_ROOT/reply-auth-interrupt"; mkdir -p "$home"
|
|
608
|
+
fakebin=$(fm_fakebin "$home")
|
|
609
|
+
log="$home/auth-file.txt"
|
|
610
|
+
cat > "$fakebin/curl" <<'SH'
|
|
611
|
+
#!/usr/bin/env bash
|
|
612
|
+
auth_file=
|
|
613
|
+
while [ $# -gt 0 ]; do
|
|
614
|
+
case "$1" in
|
|
615
|
+
-H)
|
|
616
|
+
case "$2" in @*) auth_file=${2#@} ;; esac
|
|
617
|
+
shift 2
|
|
618
|
+
;;
|
|
619
|
+
-o|-w|-X|-m|--data|--data-binary) shift 2 ;;
|
|
620
|
+
-s) shift ;;
|
|
621
|
+
*) shift ;;
|
|
622
|
+
esac
|
|
623
|
+
done
|
|
624
|
+
printf '%s\n' "$auth_file" > "$FAKE_AUTH_FILE_LOG"
|
|
625
|
+
kill -TERM "$PPID"
|
|
626
|
+
exit 143
|
|
627
|
+
SH
|
|
628
|
+
chmod +x "$fakebin/curl"
|
|
629
|
+
printf 'FMX_PAIRING_TOKEN=tok-clean\n' > "$home/.env"
|
|
630
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
631
|
+
FAKE_AUTH_FILE_LOG="$log" \
|
|
632
|
+
"$ROOT/bin/fm-x-reply.sh" "req-clean" "Hello." 2>"$home/err"); rc=$?
|
|
633
|
+
[ "$rc" -ne 0 ] || fail "interrupted relay post must fail"
|
|
634
|
+
[ -z "$out" ] || fail "interrupted relay post must not echo the request_id (got: $out)"
|
|
635
|
+
auth_file=$(cat "$log")
|
|
636
|
+
[ -n "$auth_file" ] || fail "fake curl must record the auth header temp file"
|
|
637
|
+
[ ! -e "$auth_file" ] || fail "auth header temp file must be removed after an interrupted post"
|
|
638
|
+
pass "fm-x-reply cleans up auth header temp files on interrupted posts"
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
test_reply_usage_error() {
|
|
642
|
+
local home rc err
|
|
643
|
+
home="$TMP_ROOT/reply-usage"; mkdir -p "$home"
|
|
644
|
+
err="$home/err.txt"
|
|
645
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-reply.sh" "only-one" >/dev/null 2>"$err"; rc=$?
|
|
646
|
+
expect_code 2 "$rc" "reply usage error exit"
|
|
647
|
+
assert_grep "--image <path>" "$err" "reply usage must mention --image"
|
|
648
|
+
pass "fm-x-reply rejects missing arguments with a usage error"
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
test_reply_help_mentions_image() {
|
|
652
|
+
local home out rc
|
|
653
|
+
home="$TMP_ROOT/reply-help"; mkdir -p "$home"
|
|
654
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-reply.sh" --help); rc=$?
|
|
655
|
+
expect_code 0 "$rc" "reply --help exit"
|
|
656
|
+
assert_contains "$out" "--image <path>" "reply help must mention --image"
|
|
657
|
+
assert_contains "$out" "threaded replies attach it to the opener tweet" \
|
|
658
|
+
"reply help must document thread image placement"
|
|
659
|
+
pass "fm-x-reply --help makes image support discoverable"
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
test_reply_whitespace_text_rejected() {
|
|
663
|
+
local home out rc err
|
|
664
|
+
home="$TMP_ROOT/reply-whitespace"; mkdir -p "$home"
|
|
665
|
+
err="$home/err.txt"
|
|
666
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
|
|
667
|
+
"$ROOT/bin/fm-x-reply.sh" "req-space" " " 2>"$err"); rc=$?
|
|
668
|
+
expect_code 2 "$rc" "reply whitespace text exit"
|
|
669
|
+
[ -z "$out" ] || fail "whitespace-only reply must not echo the request_id (got: $out)"
|
|
670
|
+
assert_grep "empty reply text" "$err" "reply must reject whitespace-only text"
|
|
671
|
+
assert_absent "$home/state/x-outbox/req-space.json" "whitespace-only dry-run must not record an outbox preview"
|
|
672
|
+
pass "fm-x-reply rejects whitespace-only reply text"
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
test_bootstrap_activates_on_env_token() {
|
|
676
|
+
local home out sum1 sum2 n
|
|
677
|
+
home="$TMP_ROOT/boot-on"; mkdir -p "$home"
|
|
678
|
+
printf 'FMX_PAIRING_TOKEN=tok-boot\n' > "$home/.env"
|
|
679
|
+
out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
|
|
680
|
+
assert_contains "$out" "FMX: X mode on" "bootstrap must announce X mode"
|
|
681
|
+
assert_present "$home/state/x-watch.check.sh" "bootstrap must drop the check shim"
|
|
682
|
+
[ -x "$home/state/x-watch.check.sh" ] || fail "the check shim must be executable"
|
|
683
|
+
assert_grep "fm-x-poll.sh" "$home/state/x-watch.check.sh" "the shim must exec the poll script"
|
|
684
|
+
assert_present "$home/config/x-mode.env" "bootstrap must drop the cadence config"
|
|
685
|
+
assert_grep "export FM_CHECK_INTERVAL=30" "$home/config/x-mode.env" "cadence must be 30s"
|
|
686
|
+
# Cadence inheritance: sourcing the config exports the 30s interval to a child,
|
|
687
|
+
# exactly how fm-watch-arm.sh's forked watcher inherits it.
|
|
688
|
+
local inherited
|
|
689
|
+
# shellcheck source=/dev/null
|
|
690
|
+
inherited=$( . "$home/config/x-mode.env" && bash -c 'echo "${FM_CHECK_INTERVAL:-300}"' )
|
|
691
|
+
[ "$inherited" = "30" ] \
|
|
692
|
+
|| fail "sourcing the cadence config must export FM_CHECK_INTERVAL=30 to a child"
|
|
693
|
+
# Idempotent: re-running changes nothing and does not duplicate the shim.
|
|
694
|
+
sum1=$(cat "$home/state/x-watch.check.sh" "$home/config/x-mode.env" | shasum)
|
|
695
|
+
FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" >/dev/null 2>&1
|
|
696
|
+
sum2=$(cat "$home/state/x-watch.check.sh" "$home/config/x-mode.env" | shasum)
|
|
697
|
+
[ "$sum1" = "$sum2" ] || fail "bootstrap X-mode setup must be idempotent"
|
|
698
|
+
n=$(find "$home/state" -maxdepth 1 -name 'x-watch*' | wc -l | tr -d ' ')
|
|
699
|
+
[ "$n" = "1" ] || fail "bootstrap must not duplicate the shim (found $n)"
|
|
700
|
+
pass "bootstrap activates X mode from an .env token, idempotently"
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
test_bootstrap_relative_home_writes_absolute_poll_shim() {
|
|
704
|
+
local root home out quoted_home
|
|
705
|
+
root="$TMP_ROOT/boot-relative-home"
|
|
706
|
+
mkdir -p "$root/home" "$root/cdpath/home"
|
|
707
|
+
home=$(cd "$root/home" && pwd -P)
|
|
708
|
+
printf 'FMX_PAIRING_TOKEN=tok-relative\n' > "$home/.env"
|
|
709
|
+
out=$(
|
|
710
|
+
cd "$root" || exit 1
|
|
711
|
+
CDPATH="$root/cdpath" FM_HOME=home "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null
|
|
712
|
+
)
|
|
713
|
+
assert_contains "$out" "FMX: X mode on" "relative-home bootstrap must announce X mode"
|
|
714
|
+
quoted_home=$(printf '%q' "$home")
|
|
715
|
+
assert_grep "export FM_HOME=$quoted_home" "$home/state/x-watch.check.sh" \
|
|
716
|
+
"relative FM_HOME leaked into the durable X-mode poll shim"
|
|
717
|
+
pass "bootstrap ignores CDPATH when writing absolute FM_HOME into the durable X-mode poll shim"
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
test_bootstrap_reports_missing_x_dependency() {
|
|
721
|
+
local home fakebin out tool tool_path
|
|
722
|
+
home="$TMP_ROOT/boot-missing-x"; mkdir -p "$home"
|
|
723
|
+
fakebin=$(fm_fakebin "$home")
|
|
724
|
+
fm_fake_exit0 "$fakebin" tmux node no-mistakes chrome-devtools-axi curl
|
|
725
|
+
fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46
|
|
726
|
+
cat > "$fakebin/gh-axi" <<'SH'
|
|
727
|
+
#!/usr/bin/env bash
|
|
728
|
+
if [ "${1:-}" = --version ]; then
|
|
729
|
+
printf '%s\n' '0.1.29'
|
|
730
|
+
exit 0
|
|
731
|
+
fi
|
|
732
|
+
exit 0
|
|
733
|
+
SH
|
|
734
|
+
chmod +x "$fakebin/gh-axi"
|
|
735
|
+
for tool in dirname grep tail; do
|
|
736
|
+
tool_path=$(command -v "$tool") || fail "test host must provide $tool"
|
|
737
|
+
ln -s "$tool_path" "$fakebin/$tool"
|
|
738
|
+
done
|
|
739
|
+
cat > "$fakebin/gh" <<'SH'
|
|
740
|
+
#!/usr/bin/env bash
|
|
741
|
+
if [ "${1:-}" = auth ] && [ "${2:-}" = status ]; then
|
|
742
|
+
exit 0
|
|
743
|
+
fi
|
|
744
|
+
exit 0
|
|
745
|
+
SH
|
|
746
|
+
chmod +x "$fakebin/gh"
|
|
747
|
+
cat > "$fakebin/treehouse" <<'SH'
|
|
748
|
+
#!/usr/bin/env bash
|
|
749
|
+
if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then
|
|
750
|
+
printf '%s\n' 'Usage: treehouse get [--lease] [--lease-holder <holder>]'
|
|
751
|
+
exit 0
|
|
752
|
+
fi
|
|
753
|
+
exit 0
|
|
754
|
+
SH
|
|
755
|
+
chmod +x "$fakebin/treehouse"
|
|
756
|
+
printf 'FMX_PAIRING_TOKEN=tok-missing\n' > "$home/.env"
|
|
757
|
+
out=$(PATH="$fakebin" FM_HOME="$home" FM_ROOT_OVERRIDE="$home" \
|
|
758
|
+
"$BASH" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
|
|
759
|
+
assert_contains "$out" "MISSING: jq" "bootstrap must report missing jq when X mode is opted in"
|
|
760
|
+
assert_not_contains "$out" "FMX: X mode on" "bootstrap must not announce X mode when a dependency is missing"
|
|
761
|
+
assert_absent "$home/state/x-watch.check.sh" "missing jq must not arm the check shim"
|
|
762
|
+
assert_absent "$home/config/x-mode.env" "missing jq must not write the cadence config"
|
|
763
|
+
pass "bootstrap reports missing X-mode dependencies before arming"
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
test_bootstrap_does_not_announce_when_arm_fails() {
|
|
767
|
+
local home out
|
|
768
|
+
home="$TMP_ROOT/boot-arm-fail"; mkdir -p "$home"
|
|
769
|
+
printf 'FMX_PAIRING_TOKEN=tok-boot\n' > "$home/.env"
|
|
770
|
+
printf '%s\n' 'not a directory' > "$home/config"
|
|
771
|
+
out=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$home/config" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
|
|
772
|
+
assert_contains "$out" "FMX: X mode off - failed to arm relay poll shim or 30s cadence" \
|
|
773
|
+
"bootstrap must report a failed X-mode activation"
|
|
774
|
+
assert_not_contains "$out" "FMX: X mode on" \
|
|
775
|
+
"bootstrap must not announce X mode when the shim or cadence was not armed"
|
|
776
|
+
assert_absent "$home/state/x-watch.check.sh" "failed X-mode activation must not leave an armed shim"
|
|
777
|
+
pass "bootstrap does not report X mode on when activation artifacts cannot be written"
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
test_bootstrap_does_not_follow_x_artifact_symlinks() {
|
|
781
|
+
local home shim_target cadence_target out
|
|
782
|
+
home="$TMP_ROOT/boot-linked-artifacts"
|
|
783
|
+
mkdir -p "$home/state" "$home/config"
|
|
784
|
+
printf 'FMX_PAIRING_TOKEN=tok-linked\n' > "$home/.env"
|
|
785
|
+
shim_target="$home/external-shim"
|
|
786
|
+
cadence_target="$home/external-cadence"
|
|
787
|
+
printf 'external shim sentinel\n' > "$shim_target"
|
|
788
|
+
printf 'external cadence sentinel\n' > "$cadence_target"
|
|
789
|
+
chmod 0640 "$shim_target" "$cadence_target"
|
|
790
|
+
ln -s "$shim_target" "$home/state/x-watch.check.sh"
|
|
791
|
+
ln -s "$cadence_target" "$home/config/x-mode.env"
|
|
792
|
+
|
|
793
|
+
out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>"$home/bootstrap.err")
|
|
794
|
+
|
|
795
|
+
assert_contains "$out" "FMX: X mode off - failed to arm relay poll shim or 30s cadence" \
|
|
796
|
+
"bootstrap must reject linked X-mode destinations"
|
|
797
|
+
assert_not_contains "$out" "FMX: X mode on" \
|
|
798
|
+
"bootstrap must not announce X mode after rejecting linked destinations"
|
|
799
|
+
[ "$(cat "$shim_target")" = 'external shim sentinel' ] \
|
|
800
|
+
|| fail "bootstrap changed the linked shim target"
|
|
801
|
+
[ "$(cat "$cadence_target")" = 'external cadence sentinel' ] \
|
|
802
|
+
|| fail "bootstrap changed the linked cadence target"
|
|
803
|
+
[ "$(path_mode "$shim_target")" = 640 ] \
|
|
804
|
+
|| fail "bootstrap changed the linked shim target mode"
|
|
805
|
+
[ "$(path_mode "$cadence_target")" = 640 ] \
|
|
806
|
+
|| fail "bootstrap changed the linked cadence target mode"
|
|
807
|
+
assert_absent "$home/state/x-watch.check.sh" "bootstrap must remove the rejected shim link"
|
|
808
|
+
assert_absent "$home/config/x-mode.env" "bootstrap must remove the rejected cadence link"
|
|
809
|
+
pass "bootstrap rejects linked X artifacts without touching their targets"
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
test_bootstrap_inert_without_token() {
|
|
813
|
+
local home out
|
|
814
|
+
# No .env at all.
|
|
815
|
+
home="$TMP_ROOT/boot-off"; mkdir -p "$home"
|
|
816
|
+
out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
|
|
817
|
+
assert_not_contains "$out" "FMX:" "bootstrap must say nothing about X mode without a token"
|
|
818
|
+
assert_absent "$home/state/x-watch.check.sh" "no token -> no check shim"
|
|
819
|
+
assert_absent "$home/config/x-mode.env" "no token -> no cadence config"
|
|
820
|
+
# .env present but token empty -> still off.
|
|
821
|
+
home="$TMP_ROOT/boot-empty"; mkdir -p "$home"
|
|
822
|
+
printf 'FMX_PAIRING_TOKEN=\n' > "$home/.env"
|
|
823
|
+
out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
|
|
824
|
+
assert_not_contains "$out" "FMX:" "an empty token must be treated as off"
|
|
825
|
+
assert_absent "$home/state/x-watch.check.sh" "empty token -> no check shim"
|
|
826
|
+
pass "bootstrap is inert without a non-empty .env token (non-X users unaffected)"
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
test_poll_empty_text_is_silent() {
|
|
830
|
+
local home fakebin out rc
|
|
831
|
+
home="$TMP_ROOT/poll-empty-text"; mkdir -p "$home"
|
|
832
|
+
fakebin=$(make_fake_curl "$home")
|
|
833
|
+
printf 'FMX_PAIRING_TOKEN=tok-t\n' > "$home/.env"
|
|
834
|
+
# A 200 with a request_id but an empty .text is not an actionable question.
|
|
835
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
836
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY='{"request_id":"req-9","text":""}' \
|
|
837
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
838
|
+
expect_code 0 "$rc" "poll empty-text exit"
|
|
839
|
+
[ -z "$out" ] || fail "poll must not emit a marker for an empty question (got: $out)"
|
|
840
|
+
assert_absent "$home/state/x-inbox/req-9.json" "poll must not stash an empty question"
|
|
841
|
+
# Same when .text is missing entirely.
|
|
842
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
843
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY='{"request_id":"req-10"}' \
|
|
844
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
845
|
+
expect_code 0 "$rc" "poll missing-text exit"
|
|
846
|
+
[ -z "$out" ] || fail "poll must not emit a marker when .text is absent (got: $out)"
|
|
847
|
+
assert_absent "$home/state/x-inbox/req-10.json" "poll must not stash when .text is absent"
|
|
848
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
849
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY='{"request_id":"req-11","text":" \n\t "}' \
|
|
850
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
851
|
+
expect_code 0 "$rc" "poll whitespace-text exit"
|
|
852
|
+
[ -z "$out" ] || fail "poll must not emit a marker for a whitespace-only question (got: $out)"
|
|
853
|
+
assert_absent "$home/state/x-inbox/req-11.json" "poll must not stash a whitespace-only question"
|
|
854
|
+
pass "fm-x-poll requires a non-empty question before waking"
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
test_reply_text_file_and_stdin() {
|
|
858
|
+
local home fakebin log data rc out
|
|
859
|
+
home="$TMP_ROOT/reply-input"; mkdir -p "$home"
|
|
860
|
+
fakebin=$(make_fake_curl "$home")
|
|
861
|
+
printf 'FMX_PAIRING_TOKEN=tok-r\n' > "$home/.env"
|
|
862
|
+
# --text-file: text with shell metacharacters must survive verbatim (no shell
|
|
863
|
+
# expansion) because it never touches a shell command line.
|
|
864
|
+
log="$home/file.log"
|
|
865
|
+
# shellcheck disable=SC2016 # single quotes are deliberate: the metacharacters must stay literal
|
|
866
|
+
printf '%s' 'Aye $(whoami) & "fixes" `now`' > "$home/reply.txt"
|
|
867
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
868
|
+
FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
|
|
869
|
+
"$ROOT/bin/fm-x-reply.sh" "req-1" --text-file "$home/reply.txt"); rc=$?
|
|
870
|
+
expect_code 0 "$rc" "reply --text-file exit"
|
|
871
|
+
[ "$out" = "req-1" ] || fail "reply --text-file must echo only the request_id (got: $out)"
|
|
872
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
873
|
+
# shellcheck disable=SC2016 # single quotes are deliberate: comparing against the literal text
|
|
874
|
+
[ "$(printf '%s' "$data" | jq -r .text)" = 'Aye $(whoami) & "fixes" `now`' ] \
|
|
875
|
+
|| fail "reply --text-file must send the text verbatim, unexpanded"
|
|
876
|
+
# stdin form.
|
|
877
|
+
log="$home/stdin.log"
|
|
878
|
+
out=$(printf '%s' 'reply via stdin' | PATH="$fakebin:$BASE_PATH" FM_HOME="$home" \
|
|
879
|
+
FMX_RELAY_URL="https://relay.test" FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
|
|
880
|
+
"$ROOT/bin/fm-x-reply.sh" "req-2" -); rc=$?
|
|
881
|
+
expect_code 0 "$rc" "reply stdin exit"
|
|
882
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
883
|
+
[ "$(printf '%s' "$data" | jq -r .text)" = 'reply via stdin' ] \
|
|
884
|
+
|| fail "reply via stdin must send the piped text"
|
|
885
|
+
pass "fm-x-reply accepts the reply via --text-file and stdin (safe, unexpanded)"
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
test_bootstrap_opt_out_cleanup() {
|
|
889
|
+
local home out
|
|
890
|
+
home="$TMP_ROOT/boot-optout"; mkdir -p "$home"
|
|
891
|
+
# Opt in, artifacts appear.
|
|
892
|
+
printf 'FMX_PAIRING_TOKEN=tok-out\n' > "$home/.env"
|
|
893
|
+
FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" >/dev/null 2>&1
|
|
894
|
+
assert_present "$home/state/x-watch.check.sh" "opt-in must create the shim"
|
|
895
|
+
assert_present "$home/config/x-mode.env" "opt-in must create the cadence config"
|
|
896
|
+
# Opt out: empty the token, re-run bootstrap -> artifacts removed + one off line.
|
|
897
|
+
printf 'FMX_PAIRING_TOKEN=\n' > "$home/.env"
|
|
898
|
+
out=$(CLAUDECODE=1 FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
|
|
899
|
+
assert_contains "$out" "FMX: X mode off" "opt-out must announce X mode off when it removed artifacts"
|
|
900
|
+
assert_contains "$out" "watcher supervision needs Stop-owned automatic recovery" "opt-out remediation must use neutral automatic-recovery guidance"
|
|
901
|
+
assert_not_contains "$out" "is broken" "opt-out remediation claimed an unverified mechanism failure"
|
|
902
|
+
assert_not_contains "$out" "bin/fm-watch-arm.sh --restart" "opt-out remediation must not hardcode a background-arm restart"
|
|
903
|
+
assert_absent "$home/state/x-watch.check.sh" "opt-out must remove the shim"
|
|
904
|
+
assert_absent "$home/config/x-mode.env" "opt-out must remove the cadence config"
|
|
905
|
+
# Steady-state off: another run with nothing to remove is silent.
|
|
906
|
+
out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
|
|
907
|
+
assert_not_contains "$out" "FMX:" "steady-state off must be silent"
|
|
908
|
+
pass "bootstrap cleans up X artifacts on opt-out and is silent once off"
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
test_bootstrap_opt_out_reports_cleanup_failure() {
|
|
912
|
+
local home fakebin out
|
|
913
|
+
home="$TMP_ROOT/boot-optout-fail"; mkdir -p "$home"
|
|
914
|
+
printf 'FMX_PAIRING_TOKEN=tok-out\n' > "$home/.env"
|
|
915
|
+
FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" >/dev/null 2>&1
|
|
916
|
+
assert_present "$home/state/x-watch.check.sh" "opt-in must create the shim before cleanup failure"
|
|
917
|
+
assert_present "$home/config/x-mode.env" "opt-in must create the cadence config before cleanup failure"
|
|
918
|
+
fakebin=$(fm_fakebin "$home")
|
|
919
|
+
cat > "$fakebin/rm" <<'SH'
|
|
920
|
+
#!/usr/bin/env bash
|
|
921
|
+
exit 1
|
|
922
|
+
SH
|
|
923
|
+
chmod +x "$fakebin/rm"
|
|
924
|
+
printf 'FMX_PAIRING_TOKEN=\n' > "$home/.env"
|
|
925
|
+
out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
|
|
926
|
+
assert_contains "$out" "FMX: X mode off - failed to remove relay poll shim or 30s cadence" \
|
|
927
|
+
"opt-out cleanup failure must be reported"
|
|
928
|
+
assert_present "$home/state/x-watch.check.sh" "failed opt-out cleanup must leave the stale shim visible"
|
|
929
|
+
assert_present "$home/config/x-mode.env" "failed opt-out cleanup must leave the stale cadence visible"
|
|
930
|
+
pass "bootstrap reports failed X artifact cleanup on opt-out"
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
test_reply_dry_run_records_not_posts() {
|
|
934
|
+
local home fakebin log out rc
|
|
935
|
+
home="$TMP_ROOT/reply-dry"; mkdir -p "$home"
|
|
936
|
+
fakebin=$(make_fake_curl "$home")
|
|
937
|
+
log="$home/curl.log"
|
|
938
|
+
printf 'FMX_PAIRING_TOKEN=tok-d\n' > "$home/.env"
|
|
939
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
940
|
+
FMX_DRY_RUN=1 FAKE_CURL_LOG="$log" \
|
|
941
|
+
"$ROOT/bin/fm-x-reply.sh" "req-1" "Aye, a couple of fixes underway." 2>"$home/err"); rc=$?
|
|
942
|
+
expect_code 0 "$rc" "dry-run reply exit"
|
|
943
|
+
[ "$out" = "req-1" ] || fail "dry-run must still echo the request_id (got: $out)"
|
|
944
|
+
# It must NOT have posted: the fake curl is never invoked, so no POST is logged.
|
|
945
|
+
[ -f "$log" ] && grep -q "method=POST" "$log" && fail "dry-run must not POST to the relay"
|
|
946
|
+
assert_present "$home/state/x-outbox/req-1.json" "dry-run must record the would-be reply"
|
|
947
|
+
[ "$(jq -r .text "$home/state/x-outbox/req-1.json")" = "Aye, a couple of fixes underway." ] \
|
|
948
|
+
|| fail "outbox record must hold the would-be reply text"
|
|
949
|
+
[ "$(jq -r .request_id "$home/state/x-outbox/req-1.json")" = "req-1" ] \
|
|
950
|
+
|| fail "outbox record must hold the request_id"
|
|
951
|
+
assert_grep "DRY RUN" "$home/err" "dry-run must surface a DRY RUN summary on stderr"
|
|
952
|
+
pass "fm-x-reply dry-run records the would-be reply and never posts"
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
test_reply_dry_run_needs_no_token() {
|
|
956
|
+
local home out rc
|
|
957
|
+
home="$TMP_ROOT/reply-dry-notoken"; mkdir -p "$home"
|
|
958
|
+
# No token at all: dry-run still previews (it neither authenticates nor posts).
|
|
959
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
|
|
960
|
+
"$ROOT/bin/fm-x-reply.sh" "req-2" "preview without creds" 2>/dev/null); rc=$?
|
|
961
|
+
expect_code 0 "$rc" "dry-run no-token exit"
|
|
962
|
+
[ "$out" = "req-2" ] || fail "dry-run without a token must still echo the request_id (got: $out)"
|
|
963
|
+
assert_present "$home/state/x-outbox/req-2.json" "dry-run without a token must still record the preview"
|
|
964
|
+
pass "fm-x-reply dry-run works without a token"
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
test_reply_dry_run_from_env_file() {
|
|
968
|
+
local home fakebin log out rc
|
|
969
|
+
home="$TMP_ROOT/reply-dry-env"; mkdir -p "$home"
|
|
970
|
+
fakebin=$(make_fake_curl "$home")
|
|
971
|
+
log="$home/curl.log"
|
|
972
|
+
# FMX_DRY_RUN read from .env (not just the environment).
|
|
973
|
+
printf 'FMX_PAIRING_TOKEN=tok-d\nFMX_DRY_RUN=1\n' > "$home/.env"
|
|
974
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
975
|
+
FAKE_CURL_LOG="$log" "$ROOT/bin/fm-x-reply.sh" "req-3" "from dotenv" 2>/dev/null); rc=$?
|
|
976
|
+
expect_code 0 "$rc" "dry-run-from-.env exit"
|
|
977
|
+
[ "$out" = "req-3" ] || fail "dry-run from .env must echo the request_id (got: $out)"
|
|
978
|
+
[ -f "$log" ] && grep -q "method=POST" "$log" && fail "dry-run from .env must not POST"
|
|
979
|
+
assert_present "$home/state/x-outbox/req-3.json" "dry-run from .env must record the preview"
|
|
980
|
+
pass "fm-x-reply honors FMX_DRY_RUN from .env"
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
test_reply_empty_env_dry_run_overrides_env_file() {
|
|
984
|
+
local home fakebin log out rc
|
|
985
|
+
home="$TMP_ROOT/reply-dry-empty-env"; mkdir -p "$home"
|
|
986
|
+
fakebin=$(make_fake_curl "$home")
|
|
987
|
+
log="$home/curl.log"
|
|
988
|
+
printf 'FMX_PAIRING_TOKEN=tok-d\nFMX_DRY_RUN=1\n' > "$home/.env"
|
|
989
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
990
|
+
FMX_DRY_RUN='' FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
|
|
991
|
+
"$ROOT/bin/fm-x-reply.sh" "req-5" "empty env disables dry run" 2>/dev/null); rc=$?
|
|
992
|
+
expect_code 0 "$rc" "dry-run empty-env override exit"
|
|
993
|
+
[ "$out" = "req-5" ] || fail "empty dry-run env override must still echo the request_id (got: $out)"
|
|
994
|
+
assert_grep "method=POST" "$log" "empty dry-run env override must post instead of previewing"
|
|
995
|
+
assert_absent "$home/state/x-outbox/req-5.json" "empty dry-run env override must not record an outbox preview"
|
|
996
|
+
pass "fm-x-reply lets an explicitly empty dry-run env override .env"
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
test_reply_dry_run_fails_when_outbox_unwritable() {
|
|
1000
|
+
local home err out rc
|
|
1001
|
+
home="$TMP_ROOT/reply-dry-unwritable"; mkdir -p "$home/state"
|
|
1002
|
+
err="$home/err.txt"
|
|
1003
|
+
printf '%s\n' 'not a directory' > "$home/state/x-outbox"
|
|
1004
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
|
|
1005
|
+
"$ROOT/bin/fm-x-reply.sh" "req-4" "preview text" 2>"$err"); rc=$?
|
|
1006
|
+
[ "$rc" -ne 0 ] || fail "dry-run must fail when it cannot record the preview"
|
|
1007
|
+
[ -z "$out" ] || fail "dry-run record failure must not echo the request_id (got: $out)"
|
|
1008
|
+
assert_grep "cannot write dry-run outbox" "$err" "dry-run must explain the outbox failure"
|
|
1009
|
+
pass "fm-x-reply dry-run fails when it cannot record the preview"
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
test_reply_dry_run_outbox_private_publication_rejects_unsafe_paths() {
|
|
1013
|
+
local home out rc err target dest hardlink
|
|
1014
|
+
|
|
1015
|
+
home="$TMP_ROOT/reply-outbox-linked-dir"; mkdir -p "$home/state" "$home/external"
|
|
1016
|
+
err="$home/err.txt"
|
|
1017
|
+
ln -s "$home/external" "$home/state/x-outbox"
|
|
1018
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$?
|
|
1019
|
+
[ "$rc" -ne 0 ] || fail "reply dry-run must reject a linked outbox directory"
|
|
1020
|
+
[ -z "$out" ] || fail "rejected linked outbox must not echo the request_id (got: $out)"
|
|
1021
|
+
assert_grep "cannot write dry-run outbox" "$err" "reply dry-run must report the linked outbox write failure"
|
|
1022
|
+
assert_absent "$home/external/req-x.json" "reply dry-run must not write through a linked outbox directory"
|
|
1023
|
+
|
|
1024
|
+
home="$TMP_ROOT/reply-outbox-linked-dest"; mkdir -p "$home/state/x-outbox"
|
|
1025
|
+
err="$home/err.txt"
|
|
1026
|
+
chmod 700 "$home/state/x-outbox"
|
|
1027
|
+
target="$home/external-target.json"
|
|
1028
|
+
printf 'external sentinel\n' > "$target"
|
|
1029
|
+
ln -s "$target" "$home/state/x-outbox/req-x.json"
|
|
1030
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$?
|
|
1031
|
+
[ "$rc" -ne 0 ] || fail "reply dry-run must reject a linked outbox destination"
|
|
1032
|
+
[ "$(cat "$target")" = "external sentinel" ] || fail "reply dry-run must not write through a linked outbox destination"
|
|
1033
|
+
[ -L "$home/state/x-outbox/req-x.json" ] || fail "reply dry-run must not replace a rejected linked destination"
|
|
1034
|
+
assert_no_private_artifact_temps "$home/state/x-outbox"
|
|
1035
|
+
|
|
1036
|
+
home="$TMP_ROOT/reply-outbox-hardlink-dest"; mkdir -p "$home/state/x-outbox"
|
|
1037
|
+
err="$home/err.txt"
|
|
1038
|
+
chmod 700 "$home/state/x-outbox"
|
|
1039
|
+
dest="$home/state/x-outbox/req-x.json"
|
|
1040
|
+
hardlink="$home/state/x-outbox/req-x.alias"
|
|
1041
|
+
printf '{"request_id":"req-x","text":"old"}\n' > "$dest"
|
|
1042
|
+
chmod 600 "$dest"
|
|
1043
|
+
ln "$dest" "$hardlink"
|
|
1044
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$?
|
|
1045
|
+
[ "$rc" -ne 0 ] || fail "reply dry-run must reject a hardlinked outbox destination"
|
|
1046
|
+
[ "$(jq -r .text "$dest")" = "old" ] || fail "reply dry-run must preserve a rejected hardlinked destination"
|
|
1047
|
+
[ "$(jq -r .text "$hardlink")" = "old" ] || fail "reply dry-run must preserve the hardlink peer"
|
|
1048
|
+
assert_no_private_artifact_temps "$home/state/x-outbox"
|
|
1049
|
+
|
|
1050
|
+
home="$TMP_ROOT/reply-outbox-wrong-mode-dest"; mkdir -p "$home/state/x-outbox"
|
|
1051
|
+
err="$home/err.txt"
|
|
1052
|
+
chmod 700 "$home/state/x-outbox"
|
|
1053
|
+
dest="$home/state/x-outbox/req-x.json"
|
|
1054
|
+
printf '{"request_id":"req-x","text":"old"}\n' > "$dest"
|
|
1055
|
+
chmod 644 "$dest"
|
|
1056
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$?
|
|
1057
|
+
[ "$rc" -ne 0 ] || fail "reply dry-run must reject a wrong-mode outbox destination"
|
|
1058
|
+
[ "$(jq -r .text "$dest")" = "old" ] || fail "reply dry-run must preserve a rejected wrong-mode destination"
|
|
1059
|
+
[ "$(path_mode "$dest")" = 644 ] || fail "reply dry-run must leave a rejected wrong-mode destination unchanged"
|
|
1060
|
+
assert_no_private_artifact_temps "$home/state/x-outbox"
|
|
1061
|
+
|
|
1062
|
+
home="$TMP_ROOT/reply-outbox-private-success"; mkdir -p "$home"
|
|
1063
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>/dev/null); rc=$?
|
|
1064
|
+
expect_code 0 "$rc" "reply private outbox success exit"
|
|
1065
|
+
[ "$out" = "req-x" ] || fail "reply dry-run must still echo the request_id after private publication (got: $out)"
|
|
1066
|
+
[ "$(path_mode "$home/state/x-outbox")" = 700 ] || fail "reply dry-run must create the outbox directory as private"
|
|
1067
|
+
[ "$(path_mode "$home/state/x-outbox/req-x.json")" = 600 ] || fail "reply dry-run must publish the outbox file as private"
|
|
1068
|
+
assert_no_private_artifact_temps "$home/state/x-outbox"
|
|
1069
|
+
pass "fm-x-reply dry-run publishes outbox records only through private guarded artifacts"
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
test_split_thread_lib() {
|
|
1073
|
+
# shellcheck source=/dev/null
|
|
1074
|
+
. "$ROOT/bin/fm-x-lib.sh"
|
|
1075
|
+
local out n last rejoin maxlen txt
|
|
1076
|
+
# A reply that fits one tweet stays a single, UNNUMBERED chunk.
|
|
1077
|
+
out=$(printf 'Aye, all shipshape.' | fmx_split_thread 280 25)
|
|
1078
|
+
[ "$(printf '%s' "$out" | jq 'length')" = "1" ] || fail "short reply must be one chunk"
|
|
1079
|
+
[ "$(printf '%s' "$out" | jq -r '.[0]')" = "Aye, all shipshape." ] || fail "short reply must be verbatim and unnumbered"
|
|
1080
|
+
# A long reply splits on word boundaries; every chunk within the limit; lossless.
|
|
1081
|
+
txt="alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november"
|
|
1082
|
+
out=$(printf '%s' "$txt" | fmx_split_thread 30 25)
|
|
1083
|
+
n=$(printf '%s' "$out" | jq 'length')
|
|
1084
|
+
[ "$n" -gt 1 ] || fail "a long reply must split into more than one chunk"
|
|
1085
|
+
maxlen=$(printf '%s' "$out" | jq 'map(length)|max')
|
|
1086
|
+
[ "$maxlen" -le 30 ] || fail "every thread chunk must be within the limit (got max $maxlen)"
|
|
1087
|
+
last=$(printf '%s' "$out" | jq -r '.[0]')
|
|
1088
|
+
case "$last" in *" (1/$n)") : ;; *) fail "chunks must be numbered (k/n): $last" ;; esac
|
|
1089
|
+
rejoin=$(printf '%s' "$out" | jq -r 'map(sub(" \\([0-9]+/[0-9]+\\)$";""))|join(" ")')
|
|
1090
|
+
[ "$rejoin" = "$txt" ] || fail "thread must rejoin losslessly (got: $rejoin)"
|
|
1091
|
+
# A single over-long word is hard-split so no chunk exceeds the limit.
|
|
1092
|
+
out=$(printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' | fmx_split_thread 20 25)
|
|
1093
|
+
[ "$(printf '%s' "$out" | jq 'map(length)|max')" -le 20 ] || fail "over-long word must hard-split within the limit"
|
|
1094
|
+
# The cap bounds the thread; a truncated thread is marked with an ellipsis.
|
|
1095
|
+
out=$(printf 'one two three four five six seven eight nine ten' | fmx_split_thread 20 2)
|
|
1096
|
+
[ "$(printf '%s' "$out" | jq 'length')" -le 2 ] || fail "thread must respect the cap"
|
|
1097
|
+
case "$(printf '%s' "$out" | jq -r '.[-1]')" in *…*) : ;; *) fail "a capped thread must mark truncation" ;; esac
|
|
1098
|
+
txt=$(cat <<'TXT'
|
|
1099
|
+
Intro paragraph has enough words to make the reply split before the fenced block.
|
|
1100
|
+
|
|
1101
|
+
```bash
|
|
1102
|
+
printf '%s\n' "hello from a fenced block"
|
|
1103
|
+
printf '%s\n' "the marker must not land in here"
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
Final paragraph also has enough words to make the reply split after the fenced block.
|
|
1107
|
+
TXT
|
|
1108
|
+
)
|
|
1109
|
+
out=$(printf '%s' "$txt" | fmx_split_thread 120 25)
|
|
1110
|
+
[ "$(printf '%s' "$out" | jq 'length')" -gt 1 ] || fail "fenced markdown reply must split"
|
|
1111
|
+
printf '%s' "$out" | jq -e \
|
|
1112
|
+
'all(.[]; (((gsub(" \\([0-9]+/[0-9]+\\)$"; "") | split("```") | length) - 1) % 2) == 0)' \
|
|
1113
|
+
>/dev/null || fail "thread chunks must not leave an open code fence"
|
|
1114
|
+
printf '%s' "$out" | jq -e \
|
|
1115
|
+
'any(.[]; contains("```bash\nprintf") and contains("marker must not land in here\"") and contains("\n```"))' \
|
|
1116
|
+
>/dev/null || fail "the fenced code block must stay in one chunk"
|
|
1117
|
+
printf '%s' "$out" | jq -e \
|
|
1118
|
+
'all(.[] | split("\n")[]; (test("^[[:space:]]*```.* \\([0-9]+/[0-9]+\\)$") | not))' \
|
|
1119
|
+
>/dev/null || fail "numbering markers must not be appended to fenced-code boundary lines"
|
|
1120
|
+
pass "fmx_split_thread: word-boundary, fence-aware, within-limit, numbered, lossless, capped"
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
test_reply_single_no_texts() {
|
|
1124
|
+
local home out
|
|
1125
|
+
home="$TMP_ROOT/reply-single"; mkdir -p "$home"
|
|
1126
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-s "Short and sweet." 2>/dev/null)
|
|
1127
|
+
[ "$out" = "req-s" ] || fail "single dry-run must echo the request_id (got: $out)"
|
|
1128
|
+
jq -e 'has("texts")|not' "$home/state/x-outbox/req-s.json" >/dev/null || fail "a one-tweet reply must not include texts"
|
|
1129
|
+
[ "$(jq -r '.text' "$home/state/x-outbox/req-s.json")" = "Short and sweet." ] || fail "single reply text must be verbatim and unnumbered"
|
|
1130
|
+
pass "fm-x-reply keeps a concise reply as a single unnumbered tweet"
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
test_reply_thread_dry_run() {
|
|
1134
|
+
local home out long
|
|
1135
|
+
home="$TMP_ROOT/reply-thread"; mkdir -p "$home"
|
|
1136
|
+
long="The captain has me on a sign-in redirect fix, a docs tidy, and keeping the build green while other jobs run in the background today."
|
|
1137
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_X_REPLY_MAX_CHARS=50 \
|
|
1138
|
+
"$ROOT/bin/fm-x-reply.sh" req-t "$long" 2>/dev/null)
|
|
1139
|
+
[ "$out" = "req-t" ] || fail "thread dry-run must echo the request_id (got: $out)"
|
|
1140
|
+
assert_present "$home/state/x-outbox/req-t.json" "thread dry-run must record the outbox preview"
|
|
1141
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-t.json" >/dev/null || fail "a long reply must record a texts[] thread"
|
|
1142
|
+
[ "$(jq '.texts|map(length)|max' "$home/state/x-outbox/req-t.json")" -le 50 ] || fail "each thread tweet must be within the limit"
|
|
1143
|
+
[ "$(jq -r '.text' "$home/state/x-outbox/req-t.json")" = "$(jq -r '.texts[0]' "$home/state/x-outbox/req-t.json")" ] || fail "text must equal the first chunk"
|
|
1144
|
+
pass "fm-x-reply auto-splits a long reply into a numbered thread (texts[])"
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
test_reply_discord_inbox_uses_discord_budget() {
|
|
1148
|
+
local home out reply
|
|
1149
|
+
home="$TMP_ROOT/reply-discord-budget"; private_artifact_dir "$home/state/x-inbox"
|
|
1150
|
+
jq -cn '{request_id:"req-discord",tweet_id:"discord:channel:message",text:"question"}' \
|
|
1151
|
+
> "$home/state/x-inbox/req-discord.json"
|
|
1152
|
+
private_artifact_file "$home/state/x-inbox/req-discord.json"
|
|
1153
|
+
reply=$(cat <<'TXT'
|
|
1154
|
+
First paragraph stays intact in a single Discord reply even though it is comfortably over the X tweet budget.
|
|
1155
|
+
|
|
1156
|
+
```bash
|
|
1157
|
+
printf '%s\n' "the code fence must remain intact"
|
|
1158
|
+
printf '%s\n' "no numbering marker belongs here"
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
Final paragraph also remains in the same public Discord message because the total is far below the 1900 character split budget.
|
|
1162
|
+
TXT
|
|
1163
|
+
)
|
|
1164
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-discord "$reply" 2>/dev/null)
|
|
1165
|
+
[ "$out" = "req-discord" ] || fail "Discord dry-run must echo the request_id (got: $out)"
|
|
1166
|
+
jq -e 'has("texts")|not' "$home/state/x-outbox/req-discord.json" >/dev/null \
|
|
1167
|
+
|| fail "Discord reply below its message budget must not be split into texts[]"
|
|
1168
|
+
assert_contains "$(jq -r '.text' "$home/state/x-outbox/req-discord.json")" '```bash' \
|
|
1169
|
+
"Discord reply must preserve the fenced code block"
|
|
1170
|
+
pass "fm-x-reply uses the Discord inbox platform budget instead of the X tweet budget"
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
test_reply_x_inbox_still_uses_x_budget() {
|
|
1174
|
+
local home out long
|
|
1175
|
+
home="$TMP_ROOT/reply-x-budget"; private_artifact_dir "$home/state/x-inbox"
|
|
1176
|
+
jq -cn '{request_id:"req-x",tweet_id:"1234567890",text:"question"}' > "$home/state/x-inbox/req-x.json"
|
|
1177
|
+
private_artifact_file "$home/state/x-inbox/req-x.json"
|
|
1178
|
+
long="This X reply intentionally runs beyond the default tweet budget so it still needs a numbered thread on X. It has enough plain words to cross the limit while staying easy to split at word boundaries without code fences or platform ambiguity. The old default must remain intact for numeric tweet ids."
|
|
1179
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "$long" 2>/dev/null)
|
|
1180
|
+
[ "$out" = "req-x" ] || fail "X dry-run must echo the request_id (got: $out)"
|
|
1181
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-x.json" >/dev/null \
|
|
1182
|
+
|| fail "X reply over 280 characters must still split into texts[]"
|
|
1183
|
+
[ "$(jq '.texts|map(length)|max' "$home/state/x-outbox/req-x.json")" -le 280 ] \
|
|
1184
|
+
|| fail "X reply chunks must stay within the default X budget"
|
|
1185
|
+
pass "fm-x-reply keeps numeric X requests on the X tweet budget"
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
test_reply_inbox_explicit_limit_wins() {
|
|
1189
|
+
local home out long
|
|
1190
|
+
home="$TMP_ROOT/reply-explicit-limit"; private_artifact_dir "$home/state/x-inbox"
|
|
1191
|
+
jq -cn '{request_id:"req-limit",platform:"discord",reply_max_chars:90,text:"question"}' \
|
|
1192
|
+
> "$home/state/x-inbox/req-limit.json"
|
|
1193
|
+
private_artifact_file "$home/state/x-inbox/req-limit.json"
|
|
1194
|
+
long="Discord normally has a much larger budget, but an explicit relay-provided reply_max_chars value must be honored when the payload carries one."
|
|
1195
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-limit "$long" 2>/dev/null)
|
|
1196
|
+
[ "$out" = "req-limit" ] || fail "explicit-limit dry-run must echo the request_id (got: $out)"
|
|
1197
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-limit.json" >/dev/null \
|
|
1198
|
+
|| fail "explicit reply_max_chars must force a split even on Discord"
|
|
1199
|
+
[ "$(jq '.texts|map(length)|max' "$home/state/x-outbox/req-limit.json")" -le 90 ] \
|
|
1200
|
+
|| fail "explicit-limit chunks must stay within the relay-provided budget"
|
|
1201
|
+
pass "fm-x-reply prefers an explicit relay-provided reply limit"
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
test_reply_rejects_unsafe_inbox_context_reads() {
|
|
1205
|
+
local home out rc reply target dest hardlink
|
|
1206
|
+
reply="This reply is intentionally longer than a single X tweet, but shorter than a Discord message. If an unsafe inbox artifact is trusted it will stay one message; if it is rejected it will split at the default X budget, which is the fail-closed behavior for a local lookalike context record."
|
|
1207
|
+
|
|
1208
|
+
home="$TMP_ROOT/reply-inbox-linked-dir"; mkdir -p "$home/state" "$home/external-inbox"
|
|
1209
|
+
jq -cn '{request_id:"req-linked-dir",platform:"discord",reply_max_chars:1900,text:"question"}' \
|
|
1210
|
+
> "$home/external-inbox/req-linked-dir.json"
|
|
1211
|
+
chmod 600 "$home/external-inbox/req-linked-dir.json"
|
|
1212
|
+
ln -s "$home/external-inbox" "$home/state/x-inbox"
|
|
1213
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-linked-dir "$reply" 2>/dev/null); rc=$?
|
|
1214
|
+
expect_code 0 "$rc" "reply linked inbox dir read exit"
|
|
1215
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-linked-dir.json" >/dev/null \
|
|
1216
|
+
|| fail "reply must not trust a linked inbox directory for Discord budget context"
|
|
1217
|
+
|
|
1218
|
+
home="$TMP_ROOT/reply-inbox-linked-file"; private_artifact_dir "$home/state/x-inbox"
|
|
1219
|
+
target="$home/external-inbox-record.json"
|
|
1220
|
+
jq -cn '{request_id:"req-linked-file",platform:"discord",reply_max_chars:1900,text:"question"}' > "$target"
|
|
1221
|
+
ln -s "$target" "$home/state/x-inbox/req-linked-file.json"
|
|
1222
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-linked-file "$reply" 2>/dev/null); rc=$?
|
|
1223
|
+
expect_code 0 "$rc" "reply linked inbox file read exit"
|
|
1224
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-linked-file.json" >/dev/null \
|
|
1225
|
+
|| fail "reply must not trust a linked inbox file for Discord budget context"
|
|
1226
|
+
|
|
1227
|
+
home="$TMP_ROOT/reply-inbox-hardlink-file"; private_artifact_dir "$home/state/x-inbox"
|
|
1228
|
+
dest="$home/state/x-inbox/req-hardlink.json"
|
|
1229
|
+
hardlink="$home/state/x-inbox/req-hardlink.alias"
|
|
1230
|
+
jq -cn '{request_id:"req-hardlink",platform:"discord",reply_max_chars:1900,text:"question"}' > "$dest"
|
|
1231
|
+
private_artifact_file "$dest"
|
|
1232
|
+
ln "$dest" "$hardlink"
|
|
1233
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-hardlink "$reply" 2>/dev/null); rc=$?
|
|
1234
|
+
expect_code 0 "$rc" "reply hardlinked inbox file read exit"
|
|
1235
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-hardlink.json" >/dev/null \
|
|
1236
|
+
|| fail "reply must not trust a hardlinked inbox file for Discord budget context"
|
|
1237
|
+
[ "$(jq -r .platform "$dest")" = "discord" ] || fail "reply must leave a hardlinked inbox lookalike unchanged"
|
|
1238
|
+
|
|
1239
|
+
home="$TMP_ROOT/reply-inbox-public-dir"; mkdir -p "$home/state/x-inbox"
|
|
1240
|
+
chmod 755 "$home/state/x-inbox"
|
|
1241
|
+
jq -cn '{request_id:"req-public-dir",platform:"discord",reply_max_chars:1900,text:"question"}' \
|
|
1242
|
+
> "$home/state/x-inbox/req-public-dir.json"
|
|
1243
|
+
private_artifact_file "$home/state/x-inbox/req-public-dir.json"
|
|
1244
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-public-dir "$reply" 2>/dev/null); rc=$?
|
|
1245
|
+
expect_code 0 "$rc" "reply public inbox dir read exit"
|
|
1246
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-public-dir.json" >/dev/null \
|
|
1247
|
+
|| fail "reply must not trust a nonprivate inbox directory for Discord budget context"
|
|
1248
|
+
pass "fm-x-reply rejects unsafe inbox context artifacts"
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
test_reply_max_chars_floor_clamps_to_minimum() {
|
|
1252
|
+
local home out long
|
|
1253
|
+
home="$TMP_ROOT/reply-max-floor"; mkdir -p "$home"
|
|
1254
|
+
long="alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november"
|
|
1255
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_X_REPLY_MAX_CHARS=49 \
|
|
1256
|
+
"$ROOT/bin/fm-x-reply.sh" req-floor "$long" 2>/dev/null)
|
|
1257
|
+
[ "$out" = "req-floor" ] || fail "reply max floor dry-run must echo the request_id (got: $out)"
|
|
1258
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-floor.json" >/dev/null || fail "a below-floor max must clamp to 50 and still split"
|
|
1259
|
+
[ "$(jq '.texts|map(length)|max' "$home/state/x-outbox/req-floor.json")" -le 50 ] || fail "clamped thread tweets must be within the 50 character floor"
|
|
1260
|
+
pass "fm-x-reply clamps a below-floor max to 50 characters"
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
test_reply_thread_live_posts_texts() {
|
|
1264
|
+
local home fakebin log out data
|
|
1265
|
+
home="$TMP_ROOT/reply-thread-live"; mkdir -p "$home"
|
|
1266
|
+
fakebin=$(make_fake_curl "$home")
|
|
1267
|
+
log="$home/curl.log"
|
|
1268
|
+
printf 'FMX_PAIRING_TOKEN=tok-th\n' > "$home/.env"
|
|
1269
|
+
# 50 is the configured minimum per-tweet budget; the text is well over it so it
|
|
1270
|
+
# must split into a multi-tweet thread.
|
|
1271
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1272
|
+
FMX_X_REPLY_MAX_CHARS=50 FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
|
|
1273
|
+
"$ROOT/bin/fm-x-reply.sh" req-l "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo")
|
|
1274
|
+
[ "$out" = "req-l" ] || fail "live thread must echo the request_id (got: $out)"
|
|
1275
|
+
assert_grep "method=POST" "$log" "live thread must POST"
|
|
1276
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
1277
|
+
printf '%s' "$data" | jq -e '.texts and (.texts|length>1)' >/dev/null || fail "live thread POST body must carry texts[]"
|
|
1278
|
+
printf '%s' "$data" | jq -e '.text == .texts[0]' >/dev/null || fail "live thread text must equal the first chunk"
|
|
1279
|
+
pass "fm-x-reply posts a thread payload (texts[]) to the relay"
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
test_reply_image_live_posts_image_object() {
|
|
1283
|
+
local home fakebin log out rc data img expected
|
|
1284
|
+
home="$TMP_ROOT/reply-image-live"; mkdir -p "$home"
|
|
1285
|
+
fakebin=$(make_fake_curl "$home")
|
|
1286
|
+
log="$home/curl.log"
|
|
1287
|
+
img="$home/diagram.png"
|
|
1288
|
+
make_sample_image "$img"
|
|
1289
|
+
expected=$(base64 < "$img" | tr -d '\n\r')
|
|
1290
|
+
printf 'FMX_PAIRING_TOKEN=tok-img\n' > "$home/.env"
|
|
1291
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1292
|
+
FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
|
|
1293
|
+
"$ROOT/bin/fm-x-reply.sh" "req-img" --image "$img" "Here is the illustration."); rc=$?
|
|
1294
|
+
expect_code 0 "$rc" "reply image live exit"
|
|
1295
|
+
[ "$out" = "req-img" ] || fail "image reply must echo only the request_id (got: $out)"
|
|
1296
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
1297
|
+
[ "$(printf '%s' "$data" | jq -r '.image.media_type')" = "image/png" ] \
|
|
1298
|
+
|| fail "image reply must detect PNG media_type"
|
|
1299
|
+
[ "$(printf '%s' "$data" | jq -r '.image.data_base64')" = "$expected" ] \
|
|
1300
|
+
|| fail "image reply must include base64 image bytes"
|
|
1301
|
+
[ "$(printf '%s' "$data" | jq -r '.text')" = "Here is the illustration." ] \
|
|
1302
|
+
|| fail "image reply must preserve text"
|
|
1303
|
+
pass "fm-x-reply --image posts an image object on answer"
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
test_reply_image_live_streams_payload_file() {
|
|
1307
|
+
local home fakebin log out rc data img i
|
|
1308
|
+
home="$TMP_ROOT/reply-image-stream"; mkdir -p "$home"
|
|
1309
|
+
fakebin=$(make_fake_curl "$home")
|
|
1310
|
+
log="$home/curl.log"
|
|
1311
|
+
img="$home/large.png"
|
|
1312
|
+
make_sample_image "$img"
|
|
1313
|
+
i=0
|
|
1314
|
+
while [ "$i" -lt 4096 ]; do
|
|
1315
|
+
printf '0123456789abcdef0123456789abcdef' >> "$img"
|
|
1316
|
+
i=$((i + 1))
|
|
1317
|
+
done
|
|
1318
|
+
printf 'FMX_PAIRING_TOKEN=tok-img-stream\n' > "$home/.env"
|
|
1319
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1320
|
+
FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
|
|
1321
|
+
"$ROOT/bin/fm-x-reply.sh" "req-img-stream" --image "$img" "Here is the illustration."); rc=$?
|
|
1322
|
+
expect_code 0 "$rc" "streamed image reply exit"
|
|
1323
|
+
[ "$out" = "req-img-stream" ] || fail "streamed image reply must echo only the request_id (got: $out)"
|
|
1324
|
+
assert_grep "--data-binary @" "$log" "image reply must stream the POST body from a file"
|
|
1325
|
+
grep '^argv=' "$log" | tail -1 | grep -F 'data_base64' >/dev/null 2>&1 \
|
|
1326
|
+
&& fail "image reply must not place image JSON in curl argv"
|
|
1327
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
1328
|
+
printf '%s' "$data" | jq -e '.image.media_type == "image/png" and (.image.data_base64 | length > 100000)' >/dev/null \
|
|
1329
|
+
|| fail "streamed image reply must still send the base64 image body"
|
|
1330
|
+
pass "fm-x-reply streams large image payloads outside curl argv"
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
test_reply_image_thread_dry_run_records_compact_marker() {
|
|
1334
|
+
local home fakebin log out rc img bytes
|
|
1335
|
+
home="$TMP_ROOT/reply-image-thread-dry"; mkdir -p "$home"
|
|
1336
|
+
fakebin=$(make_fake_curl "$home")
|
|
1337
|
+
log="$home/curl.log"
|
|
1338
|
+
img="$home/illustration.webp"
|
|
1339
|
+
make_sample_image "$img"
|
|
1340
|
+
bytes=$(wc -c < "$img" | tr -d '[:space:]')
|
|
1341
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 FMX_X_REPLY_MAX_CHARS=50 \
|
|
1342
|
+
FAKE_CURL_LOG="$log" \
|
|
1343
|
+
"$ROOT/bin/fm-x-reply.sh" "req-img-dry" --image "$img" \
|
|
1344
|
+
"alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november" \
|
|
1345
|
+
2>"$home/err"); rc=$?
|
|
1346
|
+
expect_code 0 "$rc" "reply image dry-run exit"
|
|
1347
|
+
[ "$out" = "req-img-dry" ] || fail "image dry-run must echo the request_id (got: $out)"
|
|
1348
|
+
[ -f "$log" ] && grep -q "method=POST" "$log" && fail "image dry-run must not POST"
|
|
1349
|
+
assert_present "$home/state/x-outbox/req-img-dry.json" "image dry-run must record the preview"
|
|
1350
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-img-dry.json" >/dev/null \
|
|
1351
|
+
|| fail "image dry-run thread must keep texts[]"
|
|
1352
|
+
[ "$(jq -r '.image.media_type' "$home/state/x-outbox/req-img-dry.json")" = "image/webp" ] \
|
|
1353
|
+
|| fail "image dry-run marker must hold media_type"
|
|
1354
|
+
[ "$(jq -r '.image.bytes' "$home/state/x-outbox/req-img-dry.json")" = "$bytes" ] \
|
|
1355
|
+
|| fail "image dry-run marker must hold byte count"
|
|
1356
|
+
[ "$(jq -r '.image.source_path' "$home/state/x-outbox/req-img-dry.json")" = "$img" ] \
|
|
1357
|
+
|| fail "image dry-run marker must hold source_path"
|
|
1358
|
+
jq -e '.image | has("data_base64") | not' "$home/state/x-outbox/req-img-dry.json" >/dev/null \
|
|
1359
|
+
|| fail "image dry-run marker must not include base64 bytes"
|
|
1360
|
+
pass "fm-x-reply dry-run records compact image metadata for threaded replies"
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
test_reply_image_dry_run_cleans_payload_temp_files() {
|
|
1364
|
+
local home tmpdir img out rc leftovers
|
|
1365
|
+
home="$TMP_ROOT/reply-image-temp-clean"; mkdir -p "$home"
|
|
1366
|
+
tmpdir="$home/tmp"; mkdir -p "$tmpdir"
|
|
1367
|
+
img="$home/preview.png"
|
|
1368
|
+
make_sample_image "$img"
|
|
1369
|
+
out=$(PATH="$BASE_PATH" TMPDIR="$tmpdir" FM_HOME="$home" FMX_DRY_RUN=1 \
|
|
1370
|
+
"$ROOT/bin/fm-x-reply.sh" "req-img-temp-clean" --image "$img" "Here is the image." \
|
|
1371
|
+
2>"$home/err"); rc=$?
|
|
1372
|
+
expect_code 0 "$rc" "reply image temp cleanup exit"
|
|
1373
|
+
[ "$out" = "req-img-temp-clean" ] || fail "image dry-run temp cleanup must echo the request_id (got: $out)"
|
|
1374
|
+
leftovers=$(find "$tmpdir" -type f -name 'fm-x-reply.*' -print)
|
|
1375
|
+
[ -z "$leftovers" ] || fail "reply temp files must be cleaned (left: $leftovers)"
|
|
1376
|
+
pass "fm-x-reply cleans image and payload temp files"
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
test_reply_image_path_errors_are_clear() {
|
|
1380
|
+
local home out rc err img
|
|
1381
|
+
home="$TMP_ROOT/reply-image-errors"; mkdir -p "$home"
|
|
1382
|
+
err="$home/err.txt"
|
|
1383
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
|
|
1384
|
+
"$ROOT/bin/fm-x-reply.sh" "req-missing" --image "$home/missing.png" "text" 2>"$err"); rc=$?
|
|
1385
|
+
[ "$rc" -ne 0 ] || fail "missing image path must fail"
|
|
1386
|
+
[ -z "$out" ] || fail "missing image path must not echo the request_id (got: $out)"
|
|
1387
|
+
assert_grep "image file does not exist" "$err" "missing image path must explain the error"
|
|
1388
|
+
img="$home/not-image.txt"
|
|
1389
|
+
printf 'not an image' > "$img"
|
|
1390
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
|
|
1391
|
+
"$ROOT/bin/fm-x-reply.sh" "req-badtype" --image "$img" "text" 2>"$err"); rc=$?
|
|
1392
|
+
[ "$rc" -ne 0 ] || fail "unsupported image path must fail"
|
|
1393
|
+
assert_grep "unsupported image media type" "$err" "unsupported image path must explain the error"
|
|
1394
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
|
|
1395
|
+
"$ROOT/bin/fm-x-reply.sh" "req-noarg" --image 2>"$err"); rc=$?
|
|
1396
|
+
expect_code 2 "$rc" "missing --image argument exit"
|
|
1397
|
+
assert_grep "missing --image path" "$err" "missing --image argument must explain the error"
|
|
1398
|
+
pass "fm-x-reply --image rejects missing and unsupported image paths clearly"
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
# --- follow-up reply mode (--followup -> /connector/followup) ----------------
|
|
1402
|
+
|
|
1403
|
+
test_reply_followup_live_posts_to_followup_endpoint() {
|
|
1404
|
+
local home fakebin log out rc data keys
|
|
1405
|
+
home="$TMP_ROOT/reply-followup-live"; mkdir -p "$home"
|
|
1406
|
+
fakebin=$(make_fake_curl "$home")
|
|
1407
|
+
log="$home/curl.log"
|
|
1408
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
|
|
1409
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1410
|
+
FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
|
|
1411
|
+
FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=200 \
|
|
1412
|
+
"$ROOT/bin/fm-x-reply.sh" "req-7" --followup "Done, captain - the fix has shipped."); rc=$?
|
|
1413
|
+
expect_code 0 "$rc" "followup live exit"
|
|
1414
|
+
[ "$out" = "req-7" ] || fail "followup must echo only the request_id (got: $out)"
|
|
1415
|
+
assert_grep "url=https://relay.test/connector/followup" "$log" "followup must POST /connector/followup"
|
|
1416
|
+
assert_grep "method=POST" "$log" "followup must use POST"
|
|
1417
|
+
assert_grep "auth=Authorization: Bearer tok-fu" "$log" "followup must send the bearer token"
|
|
1418
|
+
# The live body is identical to an answer: {request_id, text}, never a marker.
|
|
1419
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
1420
|
+
keys=$(printf '%s' "$data" | jq -r 'keys|join(",")')
|
|
1421
|
+
[ "$keys" = "request_id,text" ] || fail "followup live body must carry only request_id,text (got: $keys)"
|
|
1422
|
+
[ "$(printf '%s' "$data" | jq -r .request_id)" = "req-7" ] || fail "followup body request_id"
|
|
1423
|
+
pass "fm-x-reply --followup posts to /connector/followup with the same request-bound body"
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
test_reply_followup_409_marker_exits_distinctly() {
|
|
1427
|
+
local home fakebin out rc err
|
|
1428
|
+
home="$TMP_ROOT/reply-followup-409-marker"; mkdir -p "$home"
|
|
1429
|
+
fakebin=$(make_fake_curl "$home")
|
|
1430
|
+
err="$home/err.txt"
|
|
1431
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
|
|
1432
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1433
|
+
FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
|
|
1434
|
+
FAKE_FOLLOWUP_CODE=409 FAKE_FOLLOWUP_BODY='{"error":"followup_unavailable"}' \
|
|
1435
|
+
"$ROOT/bin/fm-x-reply.sh" "req-409-marker" --followup "Late follow-up." 2>"$err"); rc=$?
|
|
1436
|
+
expect_code 9 "$rc" "followup 409 marker exit"
|
|
1437
|
+
[ -z "$out" ] || fail "followup 409 marker must not echo the request_id (got: $out)"
|
|
1438
|
+
assert_grep "confirmed followup_unavailable marker" "$err" \
|
|
1439
|
+
"followup 409 marker must be reflected in diagnostics"
|
|
1440
|
+
pass "fm-x-reply maps a followup_unavailable follow-up 409 to exit 9"
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
test_reply_followup_409_without_marker_still_exits_distinctly() {
|
|
1444
|
+
local home fakebin out rc err
|
|
1445
|
+
home="$TMP_ROOT/reply-followup-409-fallback"; mkdir -p "$home"
|
|
1446
|
+
fakebin=$(make_fake_curl "$home")
|
|
1447
|
+
err="$home/err.txt"
|
|
1448
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
|
|
1449
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1450
|
+
FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
|
|
1451
|
+
FAKE_FOLLOWUP_CODE=409 \
|
|
1452
|
+
"$ROOT/bin/fm-x-reply.sh" "req-409-bare" --followup "Late follow-up." 2>"$err"); rc=$?
|
|
1453
|
+
expect_code 9 "$rc" "followup bare 409 exit"
|
|
1454
|
+
[ -z "$out" ] || fail "followup bare 409 must not echo the request_id (got: $out)"
|
|
1455
|
+
assert_grep "marker absent" "$err" "bare followup 409 must use the fallback diagnostic"
|
|
1456
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1457
|
+
FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
|
|
1458
|
+
FAKE_FOLLOWUP_CODE=409 FAKE_FOLLOWUP_BODY='{"error":"some_other_conflict"}' \
|
|
1459
|
+
"$ROOT/bin/fm-x-reply.sh" "req-409-other" --followup "Late follow-up." 2>"$err"); rc=$?
|
|
1460
|
+
expect_code 9 "$rc" "followup unrelated-body 409 exit"
|
|
1461
|
+
[ -z "$out" ] || fail "followup unrelated-body 409 must not echo the request_id (got: $out)"
|
|
1462
|
+
assert_grep "marker absent" "$err" \
|
|
1463
|
+
"unrelated followup 409 body must still use the fallback diagnostic"
|
|
1464
|
+
pass "fm-x-reply maps every follow-up 409 to exit 9 even without the marker"
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
test_reply_answer_409_is_generic_failure() {
|
|
1468
|
+
local home fakebin out rc err
|
|
1469
|
+
home="$TMP_ROOT/reply-answer-409"; mkdir -p "$home"
|
|
1470
|
+
fakebin=$(make_fake_curl "$home")
|
|
1471
|
+
err="$home/err.txt"
|
|
1472
|
+
printf 'FMX_PAIRING_TOKEN=tok-answer\n' > "$home/.env"
|
|
1473
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1474
|
+
FAKE_ANSWER_CODE=409 FAKE_ANSWER_BODY='{"error":"followup_unavailable"}' \
|
|
1475
|
+
"$ROOT/bin/fm-x-reply.sh" "req-answer-409" "Normal answer." 2>"$err"); rc=$?
|
|
1476
|
+
expect_code 1 "$rc" "answer 409 exit"
|
|
1477
|
+
[ -z "$out" ] || fail "answer 409 must not echo the request_id (got: $out)"
|
|
1478
|
+
assert_grep "relay returned HTTP 409" "$err" "answer 409 must stay on the generic failure path"
|
|
1479
|
+
pass "fm-x-reply treats answer-endpoint 409 as a generic failure"
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
test_reply_followup_image_live_posts_image_object() {
|
|
1483
|
+
local home fakebin log out rc data img expected
|
|
1484
|
+
home="$TMP_ROOT/reply-followup-image-live"; mkdir -p "$home"
|
|
1485
|
+
fakebin=$(make_fake_curl "$home")
|
|
1486
|
+
log="$home/curl.log"
|
|
1487
|
+
img="$home/result.jpg"
|
|
1488
|
+
make_sample_image "$img"
|
|
1489
|
+
expected=$(base64 < "$img" | tr -d '\n\r')
|
|
1490
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu-img\n' > "$home/.env"
|
|
1491
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1492
|
+
FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
|
|
1493
|
+
FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=200 \
|
|
1494
|
+
"$ROOT/bin/fm-x-reply.sh" "req-fu-img" --followup --image "$img" \
|
|
1495
|
+
"Done - here is the generated image."); rc=$?
|
|
1496
|
+
expect_code 0 "$rc" "followup image live exit"
|
|
1497
|
+
[ "$out" = "req-fu-img" ] || fail "followup image must echo only the request_id (got: $out)"
|
|
1498
|
+
assert_grep "url=https://relay.test/connector/followup" "$log" "image followup must hit followup endpoint"
|
|
1499
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
1500
|
+
[ "$(printf '%s' "$data" | jq -r '.image.media_type')" = "image/jpeg" ] \
|
|
1501
|
+
|| fail "image followup must detect JPEG media_type"
|
|
1502
|
+
[ "$(printf '%s' "$data" | jq -r '.image.data_base64')" = "$expected" ] \
|
|
1503
|
+
|| fail "image followup must include base64 image bytes"
|
|
1504
|
+
pass "fm-x-reply --followup --image posts an image object"
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
test_reply_followup_flag_position_is_flexible() {
|
|
1508
|
+
local home fakebin log rc out
|
|
1509
|
+
home="$TMP_ROOT/reply-followup-pos"; mkdir -p "$home"
|
|
1510
|
+
fakebin=$(make_fake_curl "$home")
|
|
1511
|
+
printf 'FMX_PAIRING_TOKEN=tok-fp\n' > "$home/.env"
|
|
1512
|
+
printf '%s' 'done via file' > "$home/reply.txt"
|
|
1513
|
+
# --followup AFTER the text source must still select the followup endpoint.
|
|
1514
|
+
log="$home/after.log"
|
|
1515
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1516
|
+
FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
|
|
1517
|
+
FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=200 \
|
|
1518
|
+
"$ROOT/bin/fm-x-reply.sh" "req-a" --text-file "$home/reply.txt" --followup); rc=$?
|
|
1519
|
+
expect_code 0 "$rc" "followup-after-textfile exit"
|
|
1520
|
+
assert_grep "url=https://relay.test/connector/followup" "$log" "--followup after --text-file must still hit followup"
|
|
1521
|
+
# Without --followup, the answer endpoint is unchanged.
|
|
1522
|
+
log="$home/answer.log"
|
|
1523
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1524
|
+
FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
|
|
1525
|
+
"$ROOT/bin/fm-x-reply.sh" "req-a" --text-file "$home/reply.txt"); rc=$?
|
|
1526
|
+
expect_code 0 "$rc" "answer-still-default exit"
|
|
1527
|
+
assert_grep "url=https://relay.test/connector/answer" "$log" "no flag must keep the answer endpoint"
|
|
1528
|
+
pass "fm-x-reply --followup is accepted in any position and leaves the answer path default"
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
test_reply_followup_dry_run_marks_endpoint() {
|
|
1532
|
+
local home out rc
|
|
1533
|
+
home="$TMP_ROOT/reply-followup-dry"; mkdir -p "$home"
|
|
1534
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
|
|
1535
|
+
"$ROOT/bin/fm-x-reply.sh" "req-d" --followup "Shipped - all green." 2>"$home/err"); rc=$?
|
|
1536
|
+
expect_code 0 "$rc" "followup dry-run exit"
|
|
1537
|
+
[ "$out" = "req-d" ] || fail "followup dry-run must echo the request_id (got: $out)"
|
|
1538
|
+
assert_present "$home/state/x-outbox/req-d.json" "followup dry-run must record the preview"
|
|
1539
|
+
[ "$(jq -r '.endpoint' "$home/state/x-outbox/req-d.json")" = "followup" ] \
|
|
1540
|
+
|| fail "followup dry-run preview must carry the endpoint marker"
|
|
1541
|
+
[ "$(jq -r '.text' "$home/state/x-outbox/req-d.json")" = "Shipped - all green." ] \
|
|
1542
|
+
|| fail "followup dry-run preview must hold the reply text"
|
|
1543
|
+
assert_grep "/connector/followup" "$home/err" "followup dry-run summary must name the followup endpoint"
|
|
1544
|
+
# An answer dry-run must remain unchanged: no endpoint marker.
|
|
1545
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" "req-ans" "Aye." 2>/dev/null)
|
|
1546
|
+
jq -e 'has("endpoint")|not' "$home/state/x-outbox/req-ans.json" >/dev/null \
|
|
1547
|
+
|| fail "an answer dry-run preview must not gain an endpoint marker"
|
|
1548
|
+
pass "fm-x-reply --followup dry-run marks the endpoint without changing the answer path"
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
test_reply_followup_thread_dry_run() {
|
|
1552
|
+
local home out long
|
|
1553
|
+
home="$TMP_ROOT/reply-followup-thread"; mkdir -p "$home"
|
|
1554
|
+
long="The captain has me on a sign-in redirect fix, a docs tidy, and keeping the build green while other jobs run in the background today."
|
|
1555
|
+
# This test exercises follow-up thread-split + endpoint-marker mechanics for a
|
|
1556
|
+
# fully resolved X follow-up.
|
|
1557
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_X_REPLY_MAX_CHARS=50 \
|
|
1558
|
+
FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=50 \
|
|
1559
|
+
"$ROOT/bin/fm-x-reply.sh" req-ft --followup "$long" 2>/dev/null)
|
|
1560
|
+
[ "$out" = "req-ft" ] || fail "followup thread dry-run must echo the request_id (got: $out)"
|
|
1561
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-ft.json" >/dev/null \
|
|
1562
|
+
|| fail "a long followup must record a texts[] thread"
|
|
1563
|
+
[ "$(jq -r '.endpoint' "$home/state/x-outbox/req-ft.json")" = "followup" ] \
|
|
1564
|
+
|| fail "followup thread preview must carry the endpoint marker"
|
|
1565
|
+
[ "$(jq -r '.text' "$home/state/x-outbox/req-ft.json")" = "$(jq -r '.texts[0]' "$home/state/x-outbox/req-ft.json")" ] \
|
|
1566
|
+
|| fail "followup thread text must equal the first chunk"
|
|
1567
|
+
pass "fm-x-reply --followup auto-splits a long follow-up into a marked thread"
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
test_reply_followup_image_dry_run_marks_endpoint_and_compacts_image() {
|
|
1571
|
+
local home out rc img
|
|
1572
|
+
home="$TMP_ROOT/reply-followup-image-dry"; mkdir -p "$home"
|
|
1573
|
+
img="$home/result.gif"
|
|
1574
|
+
make_sample_image "$img"
|
|
1575
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
|
|
1576
|
+
"$ROOT/bin/fm-x-reply.sh" "req-fu-img-dry" --followup --image "$img" "Done with art." \
|
|
1577
|
+
2>"$home/err"); rc=$?
|
|
1578
|
+
expect_code 0 "$rc" "followup image dry-run exit"
|
|
1579
|
+
[ "$out" = "req-fu-img-dry" ] || fail "followup image dry-run must echo the request_id (got: $out)"
|
|
1580
|
+
[ "$(jq -r '.endpoint' "$home/state/x-outbox/req-fu-img-dry.json")" = "followup" ] \
|
|
1581
|
+
|| fail "followup image dry-run must carry endpoint marker"
|
|
1582
|
+
[ "$(jq -r '.image.media_type' "$home/state/x-outbox/req-fu-img-dry.json")" = "image/gif" ] \
|
|
1583
|
+
|| fail "followup image dry-run must detect GIF media_type"
|
|
1584
|
+
jq -e '.image | has("data_base64") | not' "$home/state/x-outbox/req-fu-img-dry.json" >/dev/null \
|
|
1585
|
+
|| fail "followup image dry-run must omit base64 bytes"
|
|
1586
|
+
pass "fm-x-reply followup dry-run keeps endpoint marker and compact image metadata"
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
# --- durable per-request context registry + follow-up platform fail-safe ------
|
|
1590
|
+
# Regression: a Discord milestone follow-up posted DIRECTLY by request_id (no task
|
|
1591
|
+
# link, because one persistent secondmate's single x_request slot was already
|
|
1592
|
+
# taken by a concurrent request) AFTER the inbox was drained silently defaulted to
|
|
1593
|
+
# the X 280-char budget and threaded a >280 Discord reply as "(1/2)". The fix: the
|
|
1594
|
+
# poll records a durable per-request reply-context registry, fm-x-reply resolves
|
|
1595
|
+
# platform/budget through registry -> inbox -> relay, and refuses to split an
|
|
1596
|
+
# unresolved follow-up.
|
|
1597
|
+
|
|
1598
|
+
test_poll_records_context_registry_from_relay_platform() {
|
|
1599
|
+
local home fakebin out rc body reg
|
|
1600
|
+
home="$TMP_ROOT/poll-registry"; mkdir -p "$home"
|
|
1601
|
+
fakebin=$(make_fake_curl "$home")
|
|
1602
|
+
printf 'FMX_PAIRING_TOKEN=tok-reg\n' > "$home/.env"
|
|
1603
|
+
# An explicit Discord mention: the registry must capture platform=discord.
|
|
1604
|
+
body=$(jq -cn '{request_id:"req-disc",platform:"discord",reply_max_chars:1900,text:"question from discord"}')
|
|
1605
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1606
|
+
FMX_NOW_OVERRIDE=1700000000 \
|
|
1607
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" "$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1608
|
+
expect_code 0 "$rc" "poll discord registry exit"
|
|
1609
|
+
[ "$out" = "x-mention req-disc" ] || fail "poll must still print the wake marker (got: $out)"
|
|
1610
|
+
reg="$home/state/x-context/req-disc.json"
|
|
1611
|
+
assert_present "$reg" "poll must record the durable per-request context"
|
|
1612
|
+
[ "$(jq -r .platform "$reg")" = "discord" ] || fail "registry must capture the Discord platform"
|
|
1613
|
+
[ "$(jq -r .reply_max_chars "$reg")" = "1900" ] || fail "registry must capture the Discord reply budget"
|
|
1614
|
+
[ "$(jq -r .recorded_at "$reg")" = "1700000000" ] || fail "registry must timestamp the context locally"
|
|
1615
|
+
# A numeric-tweet_id X mention: the registry must capture platform=x.
|
|
1616
|
+
body=$(jq -cn '{request_id:"req-x",tweet_id:"1234567890",reply_max_chars:280,text:"question from x"}')
|
|
1617
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1618
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" "$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1619
|
+
expect_code 0 "$rc" "poll x registry exit"
|
|
1620
|
+
[ "$(jq -r .platform "$home/state/x-context/req-x.json")" = "x" ] \
|
|
1621
|
+
|| fail "registry must capture the X platform from a numeric tweet_id"
|
|
1622
|
+
# A mention with no platform signal at all: no useless empty record is written.
|
|
1623
|
+
body=$(jq -cn '{request_id:"req-unk",text:"platformless question"}')
|
|
1624
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1625
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" "$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1626
|
+
expect_code 0 "$rc" "poll unknown-platform exit"
|
|
1627
|
+
assert_present "$home/state/x-inbox/req-unk.json" "an unknown-platform mention is still stashed"
|
|
1628
|
+
assert_absent "$home/state/x-context/req-unk.json" \
|
|
1629
|
+
"no registry record is written when the platform is unknown (no dead entry)"
|
|
1630
|
+
pass "fm-x-poll records the durable per-request reply context from the relay payload"
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
test_context_registry_private_publication_rejects_unsafe_paths() {
|
|
1634
|
+
local home rc dest hardlink target out
|
|
1635
|
+
# shellcheck source=/dev/null
|
|
1636
|
+
. "$ROOT/bin/fm-x-lib.sh"
|
|
1637
|
+
|
|
1638
|
+
home="$TMP_ROOT/context-linked-dir"; mkdir -p "$home/state" "$home/external"
|
|
1639
|
+
ln -s "$home/external" "$home/state/x-context"
|
|
1640
|
+
fmx_context_registry_set "$home/state" req-x x 280; rc=$?
|
|
1641
|
+
[ "$rc" -ne 0 ] || fail "context registry must reject a linked context directory"
|
|
1642
|
+
assert_absent "$home/external/req-x.json" "context registry must not write through a linked context directory"
|
|
1643
|
+
[ -L "$home/state/x-context" ] || fail "context registry must leave the rejected directory symlink in place"
|
|
1644
|
+
|
|
1645
|
+
home="$TMP_ROOT/context-public-dir"; mkdir -p "$home/state/x-context"
|
|
1646
|
+
chmod 755 "$home/state/x-context"
|
|
1647
|
+
fmx_context_registry_set "$home/state" req-x x 280; rc=$?
|
|
1648
|
+
[ "$rc" -ne 0 ] || fail "context registry must reject a nonprivate context directory"
|
|
1649
|
+
assert_absent "$home/state/x-context/req-x.json" "context registry must not publish into a nonprivate context directory"
|
|
1650
|
+
assert_no_private_artifact_temps "$home/state/x-context"
|
|
1651
|
+
|
|
1652
|
+
home="$TMP_ROOT/context-linked-dest"; mkdir -p "$home/state/x-context"
|
|
1653
|
+
chmod 700 "$home/state/x-context"
|
|
1654
|
+
target="$home/external-target.json"
|
|
1655
|
+
printf 'external sentinel\n' > "$target"
|
|
1656
|
+
ln -s "$target" "$home/state/x-context/req-x.json"
|
|
1657
|
+
fmx_context_registry_set "$home/state" req-x x 280; rc=$?
|
|
1658
|
+
[ "$rc" -ne 0 ] || fail "context registry must reject a linked destination"
|
|
1659
|
+
[ "$(cat "$target")" = "external sentinel" ] || fail "context registry must not write through a linked destination"
|
|
1660
|
+
[ -L "$home/state/x-context/req-x.json" ] || fail "context registry must not replace a rejected linked destination"
|
|
1661
|
+
assert_no_private_artifact_temps "$home/state/x-context"
|
|
1662
|
+
|
|
1663
|
+
home="$TMP_ROOT/context-hardlink-dest"; mkdir -p "$home/state/x-context"
|
|
1664
|
+
chmod 700 "$home/state/x-context"
|
|
1665
|
+
dest="$home/state/x-context/req-x.json"
|
|
1666
|
+
hardlink="$home/state/x-context/req-x.alias"
|
|
1667
|
+
jq -cn '{request_id:"req-x",platform:"x",reply_max_chars:"280",recorded_at:1700000000}' > "$dest"
|
|
1668
|
+
chmod 600 "$dest"
|
|
1669
|
+
ln "$dest" "$hardlink"
|
|
1670
|
+
fmx_context_registry_set "$home/state" req-x discord 1900; rc=$?
|
|
1671
|
+
[ "$rc" -ne 0 ] || fail "context registry must reject a hardlinked destination"
|
|
1672
|
+
[ "$(jq -r .platform "$dest")" = "x" ] || fail "context registry must preserve a rejected hardlinked destination"
|
|
1673
|
+
[ "$(jq -r .platform "$hardlink")" = "x" ] || fail "context registry must preserve the hardlink peer"
|
|
1674
|
+
assert_no_private_artifact_temps "$home/state/x-context"
|
|
1675
|
+
|
|
1676
|
+
home="$TMP_ROOT/context-wrong-mode-dest"; mkdir -p "$home/state/x-context"
|
|
1677
|
+
chmod 700 "$home/state/x-context"
|
|
1678
|
+
dest="$home/state/x-context/req-x.json"
|
|
1679
|
+
jq -cn '{request_id:"req-x",platform:"x",reply_max_chars:"280",recorded_at:1700000000}' > "$dest"
|
|
1680
|
+
chmod 644 "$dest"
|
|
1681
|
+
fmx_context_registry_set "$home/state" req-x discord 1900; rc=$?
|
|
1682
|
+
[ "$rc" -ne 0 ] || fail "context registry must reject a wrong-mode destination"
|
|
1683
|
+
[ "$(jq -r .platform "$dest")" = "x" ] || fail "context registry must preserve a rejected wrong-mode destination"
|
|
1684
|
+
[ "$(path_mode "$dest")" = 644 ] || fail "context registry must leave a rejected wrong-mode destination unchanged"
|
|
1685
|
+
assert_no_private_artifact_temps "$home/state/x-context"
|
|
1686
|
+
|
|
1687
|
+
home="$TMP_ROOT/context-private-success"; mkdir -p "$home"
|
|
1688
|
+
out=$(FMX_NOW_OVERRIDE=1700000000 bash -c '. "$1/bin/fm-x-lib.sh"; fmx_context_registry_set "$2/state" req-x x 280' _ "$ROOT" "$home")
|
|
1689
|
+
rc=$?
|
|
1690
|
+
expect_code 0 "$rc" "context private publication success"
|
|
1691
|
+
[ -z "$out" ] || fail "context registry setter must stay silent on success"
|
|
1692
|
+
[ "$(path_mode "$home/state/x-context")" = 700 ] || fail "context registry must create the context directory as private"
|
|
1693
|
+
[ "$(path_mode "$home/state/x-context/req-x.json")" = 600 ] || fail "context registry must publish the context file as private"
|
|
1694
|
+
assert_no_private_artifact_temps "$home/state/x-context"
|
|
1695
|
+
pass "context registry publishes records only through private guarded artifacts"
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
test_context_registry_rejects_unsafe_reads() {
|
|
1699
|
+
local home out target dest hardlink
|
|
1700
|
+
# shellcheck source=/dev/null
|
|
1701
|
+
. "$ROOT/bin/fm-x-lib.sh"
|
|
1702
|
+
|
|
1703
|
+
home="$TMP_ROOT/context-read-linked-dir"; mkdir -p "$home/state" "$home/external-context"
|
|
1704
|
+
jq -cn '{request_id:"req-linked-dir",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' \
|
|
1705
|
+
> "$home/external-context/req-linked-dir.json"
|
|
1706
|
+
chmod 600 "$home/external-context/req-linked-dir.json"
|
|
1707
|
+
ln -s "$home/external-context" "$home/state/x-context"
|
|
1708
|
+
out=$(fmx_context_registry_get "$home/state" req-linked-dir)
|
|
1709
|
+
[ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \
|
|
1710
|
+
|| fail "context registry must not read through a linked context directory"
|
|
1711
|
+
|
|
1712
|
+
home="$TMP_ROOT/context-read-linked-file"; private_artifact_dir "$home/state/x-context"
|
|
1713
|
+
target="$home/external-context-record.json"
|
|
1714
|
+
jq -cn '{request_id:"req-linked-file",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' > "$target"
|
|
1715
|
+
ln -s "$target" "$home/state/x-context/req-linked-file.json"
|
|
1716
|
+
out=$(fmx_context_registry_get "$home/state" req-linked-file)
|
|
1717
|
+
[ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \
|
|
1718
|
+
|| fail "context registry must not read through a linked context file"
|
|
1719
|
+
[ -L "$home/state/x-context/req-linked-file.json" ] \
|
|
1720
|
+
|| fail "context registry must not replace a rejected linked context file"
|
|
1721
|
+
|
|
1722
|
+
home="$TMP_ROOT/context-read-hardlink-file"; private_artifact_dir "$home/state/x-context"
|
|
1723
|
+
dest="$home/state/x-context/req-hardlink.json"
|
|
1724
|
+
hardlink="$home/state/x-context/req-hardlink.alias"
|
|
1725
|
+
jq -cn '{request_id:"req-hardlink",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' > "$dest"
|
|
1726
|
+
private_artifact_file "$dest"
|
|
1727
|
+
ln "$dest" "$hardlink"
|
|
1728
|
+
out=$(fmx_context_registry_get "$home/state" req-hardlink)
|
|
1729
|
+
[ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \
|
|
1730
|
+
|| fail "context registry must not read a hardlinked context file"
|
|
1731
|
+
[ "$(jq -r .platform "$hardlink")" = "discord" ] \
|
|
1732
|
+
|| fail "context registry must not rewrite a hardlink peer when rejecting the local record"
|
|
1733
|
+
|
|
1734
|
+
home="$TMP_ROOT/context-read-wrong-mode-file"; private_artifact_dir "$home/state/x-context"
|
|
1735
|
+
dest="$home/state/x-context/req-mode.json"
|
|
1736
|
+
jq -cn '{request_id:"req-mode",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' > "$dest"
|
|
1737
|
+
chmod 644 "$dest"
|
|
1738
|
+
out=$(fmx_context_registry_get "$home/state" req-mode)
|
|
1739
|
+
[ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \
|
|
1740
|
+
|| fail "context registry must not read a wrong-mode context file"
|
|
1741
|
+
pass "context registry reads only private single-link artifacts"
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
test_private_artifact_publisher_runs_under_system_bash() {
|
|
1745
|
+
local home out rc
|
|
1746
|
+
home="$TMP_ROOT/private-publisher-system-bash"; mkdir -p "$home"
|
|
1747
|
+
[ -x /bin/bash ] || { pass "private artifact publisher compatibility check skipped without /bin/bash"; return 0; }
|
|
1748
|
+
out=$(/bin/bash -c \
|
|
1749
|
+
'. "$1/bin/fm-x-lib.sh"; printf "%s\n" "{\"request_id\":\"req-bash\"}" | fmx_private_artifact_publish_stdin "$2/state/x-outbox" req-bash.json 600' \
|
|
1750
|
+
_ "$ROOT" "$home"); rc=$?
|
|
1751
|
+
expect_code 0 "$rc" "private artifact publisher under /bin/bash"
|
|
1752
|
+
[ -z "$out" ] || fail "private artifact publisher must stay silent under /bin/bash"
|
|
1753
|
+
assert_present "$home/state/x-outbox/req-bash.json" "private artifact publisher must create the artifact under /bin/bash"
|
|
1754
|
+
[ "$(path_mode "$home/state/x-outbox/req-bash.json")" = 600 ] \
|
|
1755
|
+
|| fail "private artifact publisher must preserve private file mode under /bin/bash"
|
|
1756
|
+
pass "private artifact publisher is compatible with the system bash path"
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
test_context_registry_prunes_expired_records() {
|
|
1760
|
+
local home dir fakebin keep preserved legacy malformed future out rc
|
|
1761
|
+
home="$TMP_ROOT/registry-retention"
|
|
1762
|
+
dir="$home/state/x-context"
|
|
1763
|
+
private_artifact_dir "$dir"
|
|
1764
|
+
keep="$dir/req-keep.json"
|
|
1765
|
+
preserved="$dir/req-iP49shRy-8ue4dtxEo87Yw.json"
|
|
1766
|
+
legacy="$dir/req-legacy.json"
|
|
1767
|
+
malformed="$dir/req-malformed.json"
|
|
1768
|
+
future="$dir/req-future.json"
|
|
1769
|
+
jq -cn '{request_id:"req-expired",platform:"x",reply_max_chars:"280",recorded_at:1699395199}' \
|
|
1770
|
+
> "$dir/req-expired.json"
|
|
1771
|
+
jq -cn '{request_id:"req-keep",platform:"discord",reply_max_chars:"1900",recorded_at:1699395200}' \
|
|
1772
|
+
> "$keep"
|
|
1773
|
+
jq -cn '{request_id:"req-iP49shRy-8ue4dtxEo87Yw",platform:"x",reply_max_chars:"280",recorded_at:1700000000}' \
|
|
1774
|
+
> "$preserved"
|
|
1775
|
+
jq -cn '{request_id:"req-legacy",platform:"discord",reply_max_chars:"1900"}' > "$legacy"
|
|
1776
|
+
printf '{not-json\n' > "$malformed"
|
|
1777
|
+
jq -cn '{request_id:"req-future",platform:"x",reply_max_chars:"280",recorded_at:"9999999999999999999"}' \
|
|
1778
|
+
> "$future"
|
|
1779
|
+
chmod 600 "$dir/"*.json
|
|
1780
|
+
touch -t 202001010000 "$legacy" "$malformed" "$future"
|
|
1781
|
+
out=$(FMX_NOW_OVERRIDE=1700000000 bash -c \
|
|
1782
|
+
'. "$1/bin/fm-x-lib.sh"; fmx_context_registry_get "$2" req-keep' _ "$ROOT" "$home/state")
|
|
1783
|
+
[ "$(printf '%s' "$out" | jq -r .platform)" = "discord" ] \
|
|
1784
|
+
|| fail "a record exactly seven days old must remain usable"
|
|
1785
|
+
assert_absent "$dir/req-expired.json" "a registry record beyond seven days must be pruned"
|
|
1786
|
+
assert_present "$keep" "a registry record at the seven-day boundary must remain"
|
|
1787
|
+
assert_present "$preserved" "the preserved request must remain while it is within the follow-up window"
|
|
1788
|
+
assert_absent "$legacy" "an expired legacy record must be pruned using its file timestamp"
|
|
1789
|
+
assert_absent "$malformed" "an expired malformed record must be pruned using its file timestamp"
|
|
1790
|
+
assert_absent "$future" "an absurd future timestamp must fall back to bounded file age"
|
|
1791
|
+
fakebin=$(make_fake_curl "$home")
|
|
1792
|
+
printf 'FMX_PAIRING_TOKEN=tok-retention\n' > "$home/.env"
|
|
1793
|
+
jq -cn '{request_id:"req-poll-expired",platform:"x",reply_max_chars:"280",recorded_at:1699395199}' \
|
|
1794
|
+
> "$dir/req-poll-expired.json"
|
|
1795
|
+
private_artifact_file "$dir/req-poll-expired.json"
|
|
1796
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
|
|
1797
|
+
FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=204 "$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1798
|
+
expect_code 0 "$rc" "poll retention sweep exit"
|
|
1799
|
+
[ -z "$out" ] || fail "a 204 poll retention sweep must stay silent (got: $out)"
|
|
1800
|
+
assert_absent "$dir/req-poll-expired.json" "a recurring empty poll must prune expired registry records"
|
|
1801
|
+
jq -cn '{request_id:"req-short-window",platform:"x",reply_max_chars:"280",recorded_at:1699999899}' \
|
|
1802
|
+
> "$dir/req-short-window.json"
|
|
1803
|
+
private_artifact_file "$dir/req-short-window.json"
|
|
1804
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
|
|
1805
|
+
FMX_FOLLOWUP_MAX_AGE_SECS=100 FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=204 \
|
|
1806
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1807
|
+
expect_code 0 "$rc" "short retention window poll exit"
|
|
1808
|
+
assert_absent "$dir/req-short-window.json" "a smaller configured follow-up window must prune earlier"
|
|
1809
|
+
jq -cn '{request_id:"req-overlong-window",platform:"x",reply_max_chars:"280",recorded_at:1699395199}' \
|
|
1810
|
+
> "$dir/req-overlong-window.json"
|
|
1811
|
+
private_artifact_file "$dir/req-overlong-window.json"
|
|
1812
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
|
|
1813
|
+
FMX_FOLLOWUP_MAX_AGE_SECS=999999999 FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=204 \
|
|
1814
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1815
|
+
expect_code 0 "$rc" "capped retention window poll exit"
|
|
1816
|
+
assert_absent "$dir/req-overlong-window.json" "a configured window must not extend retention past seven days"
|
|
1817
|
+
pass "context registry retention is bounded to the seven-day follow-up window"
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
test_context_registry_preserves_first_seen_timestamp() {
|
|
1821
|
+
local home fakebin out rc reg
|
|
1822
|
+
home="$TMP_ROOT/registry-first-seen"
|
|
1823
|
+
mkdir -p "$home"
|
|
1824
|
+
fakebin=$(make_fake_curl "$home")
|
|
1825
|
+
printf 'FMX_PAIRING_TOKEN=tok-first-seen\n' > "$home/.env"
|
|
1826
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
|
|
1827
|
+
FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 \
|
|
1828
|
+
FAKE_POLL_BODY='{"request_id":"req-repeat","platform":"x","reply_max_chars":280,"text":"q"}' \
|
|
1829
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1830
|
+
expect_code 0 "$rc" "first registry poll exit"
|
|
1831
|
+
reg="$home/state/x-context/req-repeat.json"
|
|
1832
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000100 \
|
|
1833
|
+
FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 \
|
|
1834
|
+
FAKE_POLL_BODY='{"request_id":"req-repeat","platform":"x","reply_max_chars":280,"text":"q"}' \
|
|
1835
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1836
|
+
expect_code 0 "$rc" "repeated registry poll exit"
|
|
1837
|
+
[ "$(jq -r .recorded_at "$reg")" = "1700000000" ] \
|
|
1838
|
+
|| fail "repeated writes must preserve the request's first-seen timestamp"
|
|
1839
|
+
pass "context registry rewrites preserve the first-seen timestamp"
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
test_context_registry_retention_starts_on_successful_live_answer() {
|
|
1843
|
+
local home fakebin out rc reg
|
|
1844
|
+
home="$TMP_ROOT/registry-answer-window"
|
|
1845
|
+
mkdir -p "$home"
|
|
1846
|
+
fakebin=$(make_fake_curl "$home")
|
|
1847
|
+
printf 'FMX_PAIRING_TOKEN=tok-answer-window\n' > "$home/.env"
|
|
1848
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
|
|
1849
|
+
FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 \
|
|
1850
|
+
FAKE_POLL_BODY='{"request_id":"req-answer-window","platform":"discord","reply_max_chars":1900,"text":"q"}' \
|
|
1851
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1852
|
+
expect_code 0 "$rc" "answer-window poll exit"
|
|
1853
|
+
reg="$home/state/x-context/req-answer-window.json"
|
|
1854
|
+
[ "$(jq -r .recorded_at "$reg")" = "1700000000" ] \
|
|
1855
|
+
|| fail "the pending context must start at poll time"
|
|
1856
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000100 \
|
|
1857
|
+
FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 \
|
|
1858
|
+
FAKE_POLL_BODY='{"request_id":"req-answer-window","platform":"discord","reply_max_chars":1900,"text":"q"}' \
|
|
1859
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1860
|
+
expect_code 0 "$rc" "repeated answer-window poll exit"
|
|
1861
|
+
[ "$(jq -r .recorded_at "$reg")" = "1700000000" ] \
|
|
1862
|
+
|| fail "repeated polling must not move the pending context window"
|
|
1863
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000200 \
|
|
1864
|
+
FMX_RELAY_URL="https://relay.test" FAKE_ANSWER_CODE=500 \
|
|
1865
|
+
"$ROOT/bin/fm-x-reply.sh" req-answer-window "Working on it." 2>/dev/null); rc=$?
|
|
1866
|
+
[ "$rc" -ne 0 ] || fail "the failed answer fixture must fail"
|
|
1867
|
+
[ "$(jq -r .recorded_at "$reg")" = "1700000000" ] \
|
|
1868
|
+
|| fail "a failed answer must not refresh context retention"
|
|
1869
|
+
out=$(FM_HOME="$home" FMX_NOW_OVERRIDE=1700000300 FMX_DRY_RUN=1 \
|
|
1870
|
+
"$ROOT/bin/fm-x-reply.sh" req-answer-window "Working on it." 2>/dev/null); rc=$?
|
|
1871
|
+
expect_code 0 "$rc" "answer-window dry-run exit"
|
|
1872
|
+
[ "$(jq -r .recorded_at "$reg")" = "1700000000" ] \
|
|
1873
|
+
|| fail "an answer dry-run must not refresh context retention"
|
|
1874
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700604900 \
|
|
1875
|
+
FMX_RELAY_URL="https://relay.test" FAKE_ANSWER_CODE=200 \
|
|
1876
|
+
"$ROOT/bin/fm-x-reply.sh" req-answer-window "Working on it."); rc=$?
|
|
1877
|
+
expect_code 0 "$rc" "successful answer-window answer exit"
|
|
1878
|
+
[ "$(jq -r .recorded_at "$reg")" = "1700604900" ] \
|
|
1879
|
+
|| fail "a late successful live initial answer must recreate and start the retained follow-up window"
|
|
1880
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700605000 \
|
|
1881
|
+
FMX_RELAY_URL="https://relay.test" FAKE_FOLLOWUP_CODE=200 \
|
|
1882
|
+
"$ROOT/bin/fm-x-reply.sh" req-answer-window --followup "Still working."); rc=$?
|
|
1883
|
+
expect_code 0 "$rc" "answer-window follow-up exit"
|
|
1884
|
+
[ "$(jq -r .recorded_at "$reg")" = "1700604900" ] \
|
|
1885
|
+
|| fail "a follow-up must not refresh context retention"
|
|
1886
|
+
pass "context retention starts only when a live initial answer succeeds"
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
# Regression case 1: a Discord follow-up >280 but < the Discord budget stays ONE
|
|
1890
|
+
# message even after the inbox is deleted AND posted late by request_id.
|
|
1891
|
+
test_regression_discord_followup_survives_inbox_cleanup() {
|
|
1892
|
+
local home fakebin out rc reply reg
|
|
1893
|
+
home="$TMP_ROOT/reg-discord-cleanup"; mkdir -p "$home"
|
|
1894
|
+
fakebin=$(make_fake_curl "$home")
|
|
1895
|
+
printf 'FMX_PAIRING_TOKEN=tok-rc\n' > "$home/.env"
|
|
1896
|
+
# 1. Poll a Discord mention: it stashes the inbox AND records the registry.
|
|
1897
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1898
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$(jq -cn '{request_id:"req-disc",platform:"discord",reply_max_chars:1900,text:"q"}')" \
|
|
1899
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1900
|
+
expect_code 0 "$rc" "poll exit"
|
|
1901
|
+
reg="$home/state/x-context/req-disc.json"
|
|
1902
|
+
assert_present "$reg" "poll recorded the per-request context"
|
|
1903
|
+
# 2. The acknowledgement drains the inbox file (fmx-respond step 2f).
|
|
1904
|
+
rm -f "$home/state/x-inbox/req-disc.json"
|
|
1905
|
+
# 3. The delayed milestone follow-up is posted DIRECTLY by request_id, with no
|
|
1906
|
+
# task link at all - the exact path that regressed.
|
|
1907
|
+
reply=$(cat <<'TXT'
|
|
1908
|
+
Aye captain, the sign-in redirect is patched and up for review. The fix restores the callback path that was dropping the return URL, adds a regression guard so it cannot silently break again, and keeps the existing session handling untouched. This message deliberately runs well past a single X tweet so it proves a Discord follow-up stays one message after the inbox is gone.
|
|
1909
|
+
TXT
|
|
1910
|
+
)
|
|
1911
|
+
[ "$(printf '%s' "$reply" | wc -m | tr -d '[:space:]')" -gt 280 ] \
|
|
1912
|
+
|| fail "the regression reply must exceed the X 280-char budget to be meaningful"
|
|
1913
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-disc --followup - <<<"$reply" 2>/dev/null); rc=$?
|
|
1914
|
+
expect_code 0 "$rc" "delayed discord follow-up exit"
|
|
1915
|
+
[ "$out" = "req-disc" ] || fail "follow-up must echo the request_id (got: $out)"
|
|
1916
|
+
jq -e 'has("texts")|not' "$home/state/x-outbox/req-disc.json" >/dev/null \
|
|
1917
|
+
|| fail "a >280 <2000 Discord follow-up must post as ONE message after inbox cleanup - NO (1/2) split"
|
|
1918
|
+
pass "a delayed Discord follow-up stays one message after inbox cleanup via the durable registry"
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
# Regression case 2: an X follow-up >280 still splits correctly (not broken).
|
|
1922
|
+
test_regression_x_followup_still_splits_after_cleanup() {
|
|
1923
|
+
local home fakebin out rc reply
|
|
1924
|
+
home="$TMP_ROOT/reg-x-split"; mkdir -p "$home"
|
|
1925
|
+
fakebin=$(make_fake_curl "$home")
|
|
1926
|
+
printf 'FMX_PAIRING_TOKEN=tok-rx\n' > "$home/.env"
|
|
1927
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1928
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$(jq -cn '{request_id:"req-xs",tweet_id:"777",reply_max_chars:280,text:"q"}')" \
|
|
1929
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
1930
|
+
expect_code 0 "$rc" "poll x exit"
|
|
1931
|
+
rm -f "$home/state/x-inbox/req-xs.json"
|
|
1932
|
+
reply="This X follow-up intentionally runs well beyond the default single-tweet budget so it still needs a numbered thread on X. It carries enough plain words to comfortably cross the two hundred and eighty character limit while staying easy to split at word boundaries, which proves the established X behavior is not broken by the Discord platform fix at all."
|
|
1933
|
+
[ "$(printf '%s' "$reply" | wc -m | tr -d '[:space:]')" -gt 280 ] \
|
|
1934
|
+
|| fail "the X regression reply must exceed 280 chars to force a split"
|
|
1935
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-xs --followup - <<<"$reply" 2>/dev/null); rc=$?
|
|
1936
|
+
expect_code 0 "$rc" "delayed x follow-up exit"
|
|
1937
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-xs.json" >/dev/null \
|
|
1938
|
+
|| fail "an X follow-up over 280 characters must still split into a numbered thread"
|
|
1939
|
+
[ "$(jq '.texts|map(length)|max' "$home/state/x-outbox/req-xs.json")" -le 280 ] \
|
|
1940
|
+
|| fail "X follow-up chunks must stay within the X budget"
|
|
1941
|
+
pass "an X follow-up over 280 still splits correctly after inbox cleanup"
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
# Regression case 3: when the platform/budget cannot be authoritatively
|
|
1945
|
+
# determined, a splitting follow-up is REFUSED (fail-safe) - never a silent X
|
|
1946
|
+
# split, never a wrong-platform post.
|
|
1947
|
+
test_regression_unresolved_followup_fails_safe() {
|
|
1948
|
+
local home fakebin log out rc reply err
|
|
1949
|
+
reply="Short follow-up."
|
|
1950
|
+
# (a) Dry-run, nothing resolvable, no relay reachable: refuse, no outbox.
|
|
1951
|
+
home="$TMP_ROOT/reg-failsafe-dry"; mkdir -p "$home"
|
|
1952
|
+
err="$home/err.txt"
|
|
1953
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-none --followup - <<<"$reply" 2>"$err"); rc=$?
|
|
1954
|
+
[ "$rc" -eq 8 ] || fail "any unresolved follow-up must exit 8 (fail-safe), got: $rc"
|
|
1955
|
+
[ -z "$out" ] || fail "a refused follow-up must echo nothing (got: $out)"
|
|
1956
|
+
assert_absent "$home/state/x-outbox/req-none.json" "a refused follow-up must record NO outbox preview"
|
|
1957
|
+
assert_grep "refusing follow-up" "$err" "the refusal must be reported plainly"
|
|
1958
|
+
# (b) Live, relay unavailable (404): refuse BEFORE any followup POST.
|
|
1959
|
+
home="$TMP_ROOT/reg-failsafe-live"; mkdir -p "$home"
|
|
1960
|
+
fakebin=$(make_fake_curl "$home")
|
|
1961
|
+
log="$home/curl.log"
|
|
1962
|
+
err="$home/err.txt"
|
|
1963
|
+
printf 'FMX_PAIRING_TOKEN=tok-fs\n' > "$home/.env"
|
|
1964
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1965
|
+
FAKE_CURL_LOG="$log" FAKE_REQCTX_CODE=404 \
|
|
1966
|
+
"$ROOT/bin/fm-x-reply.sh" req-live-none --followup - <<<"$reply" 2>"$err"); rc=$?
|
|
1967
|
+
[ "$rc" -eq 8 ] || fail "a live unresolved follow-up must exit 8 (fail-safe), got: $rc"
|
|
1968
|
+
assert_grep "url=https://relay.test/connector/request-context" "$log" \
|
|
1969
|
+
"the fail-safe must have TRIED the authoritative relay lookup first"
|
|
1970
|
+
assert_no_grep "url=https://relay.test/connector/followup" "$log" \
|
|
1971
|
+
"the fail-safe must refuse BEFORE any follow-up post - no wrong-platform post lands"
|
|
1972
|
+
assert_grep "relay did not supply" "$err" "the refusal must note the relay could not supply both values"
|
|
1973
|
+
pass "every unresolved follow-up is refused before posting"
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
# Requirement 1 (authoritative relay recovery): a live follow-up with only a
|
|
1977
|
+
# registry platform recovers the missing explicit budget from the relay by
|
|
1978
|
+
# request_id, so a Discord reply stays one message.
|
|
1979
|
+
test_followup_partial_registry_uses_relay_budget_live() {
|
|
1980
|
+
local home fakebin log out rc reply data
|
|
1981
|
+
home="$TMP_ROOT/reg-relay-fallback"; private_artifact_dir "$home/state/x-context"
|
|
1982
|
+
fakebin=$(make_fake_curl "$home")
|
|
1983
|
+
log="$home/curl.log"
|
|
1984
|
+
printf 'FMX_PAIRING_TOKEN=tok-rf\n' > "$home/.env"
|
|
1985
|
+
jq -cn '{request_id:"req-relay",platform:"discord",reply_max_chars:""}' \
|
|
1986
|
+
> "$home/state/x-context/req-relay.json"
|
|
1987
|
+
private_artifact_file "$home/state/x-context/req-relay.json"
|
|
1988
|
+
reply=$(cat <<'TXT'
|
|
1989
|
+
Aye captain, that one is shipped and green. The change is landed, the regression guard is in place, and nothing else was disturbed along the way. This confirmation deliberately runs past a single X tweet so it proves the relay-recovered Discord budget keeps it one message.
|
|
1990
|
+
TXT
|
|
1991
|
+
)
|
|
1992
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
1993
|
+
FAKE_CURL_LOG="$log" FAKE_REQCTX_CODE=200 FAKE_REQCTX_BODY='{"reply_max_chars":1900}' \
|
|
1994
|
+
FAKE_FOLLOWUP_CODE=200 \
|
|
1995
|
+
"$ROOT/bin/fm-x-reply.sh" req-relay --followup - <<<"$reply"); rc=$?
|
|
1996
|
+
expect_code 0 "$rc" "live relay-fallback follow-up exit"
|
|
1997
|
+
[ "$out" = "req-relay" ] || fail "relay-fallback follow-up must echo the request_id (got: $out)"
|
|
1998
|
+
assert_grep "url=https://relay.test/connector/request-context" "$log" \
|
|
1999
|
+
"a live follow-up with partial local context must consult the relay for the missing budget"
|
|
2000
|
+
assert_grep "url=https://relay.test/connector/followup" "$log" "it must then post the follow-up"
|
|
2001
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
2002
|
+
printf '%s' "$data" | jq -e 'has("texts")|not' >/dev/null \
|
|
2003
|
+
|| fail "the relay-recovered Discord follow-up must post as ONE message, not a thread"
|
|
2004
|
+
pass "a partial registry platform combines with the relay's authoritative budget"
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
# Regression case 4: concurrent requests through one secondmate keep their own
|
|
2008
|
+
# platform/budget - the per-request registry cannot be cross-overwritten the way a
|
|
2009
|
+
# single x_request per task was.
|
|
2010
|
+
test_regression_concurrent_requests_keep_own_platform() {
|
|
2011
|
+
local home fakebin out rc discord_reply x_reply
|
|
2012
|
+
home="$TMP_ROOT/reg-concurrent"; mkdir -p "$home"
|
|
2013
|
+
fakebin=$(make_fake_curl "$home")
|
|
2014
|
+
printf 'FMX_PAIRING_TOKEN=tok-cc\n' > "$home/.env"
|
|
2015
|
+
# Two concurrent public requests arrive (one Discord, one X) - as if routed
|
|
2016
|
+
# through ONE persistent secondmate whose single x_request slot would collide.
|
|
2017
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2018
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$(jq -cn '{request_id:"req-cd",platform:"discord",reply_max_chars:1900,text:"q"}')" \
|
|
2019
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
2020
|
+
expect_code 0 "$rc" "poll concurrent discord exit"
|
|
2021
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2022
|
+
FAKE_POLL_CODE=200 FAKE_POLL_BODY="$(jq -cn '{request_id:"req-cx",tweet_id:"888",reply_max_chars:280,text:"q"}')" \
|
|
2023
|
+
"$ROOT/bin/fm-x-poll.sh"); rc=$?
|
|
2024
|
+
expect_code 0 "$rc" "poll concurrent x exit"
|
|
2025
|
+
# Each keeps its OWN context - neither overwrote the other.
|
|
2026
|
+
[ "$(jq -r .platform "$home/state/x-context/req-cd.json")" = "discord" ] \
|
|
2027
|
+
|| fail "concurrent Discord request must keep its own platform"
|
|
2028
|
+
[ "$(jq -r .platform "$home/state/x-context/req-cx.json")" = "x" ] \
|
|
2029
|
+
|| fail "concurrent X request must keep its own platform"
|
|
2030
|
+
# Both inboxes drained; each delayed follow-up recovers its own budget.
|
|
2031
|
+
rm -f "$home/state/x-inbox/req-cd.json" "$home/state/x-inbox/req-cx.json"
|
|
2032
|
+
discord_reply=$(cat <<'TXT'
|
|
2033
|
+
The Discord one is done, captain - patched, guarded, and up for review. This reply is deliberately over a single X tweet to prove the Discord follow-up recovers its own one-message budget while a concurrent X request is in flight.
|
|
2034
|
+
TXT
|
|
2035
|
+
)
|
|
2036
|
+
x_reply="The X request is progressing on its own track, and this update deliberately runs well beyond the single-tweet budget on purpose, proving that the concurrent X follow-up still threads correctly at the X budget and did not inherit the larger Discord budget from the other in-flight request routed through the same secondmate."
|
|
2037
|
+
[ "$(printf '%s' "$x_reply" | wc -m | tr -d '[:space:]')" -gt 280 ] \
|
|
2038
|
+
|| fail "the concurrent X reply must exceed 280 chars to force a split"
|
|
2039
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-cd --followup - <<<"$discord_reply" 2>/dev/null); rc=$?
|
|
2040
|
+
expect_code 0 "$rc" "concurrent discord follow-up exit"
|
|
2041
|
+
jq -e 'has("texts")|not' "$home/state/x-outbox/req-cd.json" >/dev/null \
|
|
2042
|
+
|| fail "the concurrent Discord follow-up must stay one message"
|
|
2043
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-cx --followup - <<<"$x_reply" 2>/dev/null); rc=$?
|
|
2044
|
+
expect_code 0 "$rc" "concurrent x follow-up exit"
|
|
2045
|
+
jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-cx.json" >/dev/null \
|
|
2046
|
+
|| fail "the concurrent X follow-up must still split - it kept the X budget"
|
|
2047
|
+
pass "concurrent requests each recover their own platform/budget with no cross-overwrite"
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
test_dismiss_clears_context_registry() {
|
|
2051
|
+
local home out rc reg
|
|
2052
|
+
home="$TMP_ROOT/dismiss-clears-registry"; private_artifact_dir "$home/state/x-context"
|
|
2053
|
+
reg="$home/state/x-context/req-dis.json"
|
|
2054
|
+
jq -cn '{request_id:"req-dis",platform:"discord",reply_max_chars:""}' > "$reg"
|
|
2055
|
+
private_artifact_file "$reg"
|
|
2056
|
+
# A dismissed mention will never get a follow-up, so its context is dropped.
|
|
2057
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-dis 2>/dev/null); rc=$?
|
|
2058
|
+
expect_code 0 "$rc" "dismiss registry-clear exit"
|
|
2059
|
+
[ "$out" = "req-dis" ] || fail "dismiss must still echo the request_id (got: $out)"
|
|
2060
|
+
assert_absent "$reg" "dismiss must clear the durable per-request context"
|
|
2061
|
+
pass "fm-x-dismiss clears the durable per-request context (a dismissed mention gets no follow-up)"
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
# --- fm-x-dismiss: drop a mention at the relay without replying ---------------
|
|
2065
|
+
|
|
2066
|
+
test_dismiss_success_posts_request_only() {
|
|
2067
|
+
local home fakebin log out rc data keys
|
|
2068
|
+
home="$TMP_ROOT/dismiss-ok"; mkdir -p "$home"
|
|
2069
|
+
fakebin=$(make_fake_curl "$home")
|
|
2070
|
+
log="$home/curl.log"
|
|
2071
|
+
printf 'FMX_PAIRING_TOKEN=tok-d\n' > "$home/.env"
|
|
2072
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2073
|
+
FAKE_CURL_LOG="$log" FAKE_DISMISS_CODE=200 \
|
|
2074
|
+
"$ROOT/bin/fm-x-dismiss.sh" "req-9"); rc=$?
|
|
2075
|
+
expect_code 0 "$rc" "dismiss success exit"
|
|
2076
|
+
[ "$out" = "req-9" ] || fail "dismiss must echo only the request_id (got: $out)"
|
|
2077
|
+
assert_grep "url=https://relay.test/connector/dismiss" "$log" "dismiss must POST /connector/dismiss"
|
|
2078
|
+
assert_grep "method=POST" "$log" "dismiss must use POST"
|
|
2079
|
+
assert_grep "auth=Authorization: Bearer tok-d" "$log" "dismiss must send the bearer token"
|
|
2080
|
+
grep '^argv=' "$log" | grep -F 'tok-d' >/dev/null 2>&1 \
|
|
2081
|
+
&& fail "dismiss must not expose the bearer token in curl argv"
|
|
2082
|
+
# The body must be exactly {request_id} - no text, no tweet id.
|
|
2083
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
2084
|
+
[ "$(printf '%s' "$data" | jq -r .request_id)" = "req-9" ] || fail "dismiss body request_id"
|
|
2085
|
+
keys=$(printf '%s' "$data" | jq -r 'keys|join(",")')
|
|
2086
|
+
[ "$keys" = "request_id" ] || fail "dismiss body must carry only request_id (got: $keys)"
|
|
2087
|
+
pass "fm-x-dismiss posts a request-bound dismiss and echoes only the request_id"
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
test_dismiss_dry_run_records_not_posts() {
|
|
2091
|
+
local home fakebin log out rc
|
|
2092
|
+
home="$TMP_ROOT/dismiss-dry"; mkdir -p "$home"
|
|
2093
|
+
fakebin=$(make_fake_curl "$home")
|
|
2094
|
+
log="$home/curl.log"
|
|
2095
|
+
printf 'FMX_PAIRING_TOKEN=tok-d\n' > "$home/.env"
|
|
2096
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2097
|
+
FMX_DRY_RUN=1 FAKE_CURL_LOG="$log" \
|
|
2098
|
+
"$ROOT/bin/fm-x-dismiss.sh" "req-1" 2>"$home/err"); rc=$?
|
|
2099
|
+
expect_code 0 "$rc" "dry-run dismiss exit"
|
|
2100
|
+
[ "$out" = "req-1" ] || fail "dry-run dismiss must still echo the request_id (got: $out)"
|
|
2101
|
+
# It must NOT have posted: the fake curl is never invoked, so no POST is logged.
|
|
2102
|
+
[ -f "$log" ] && grep -q "method=POST" "$log" && fail "dry-run dismiss must not POST to the relay"
|
|
2103
|
+
assert_present "$home/state/x-outbox/req-1.json" "dry-run dismiss must record the would-be body"
|
|
2104
|
+
[ "$(jq -r .request_id "$home/state/x-outbox/req-1.json")" = "req-1" ] \
|
|
2105
|
+
|| fail "dismiss outbox record must hold the request_id"
|
|
2106
|
+
[ "$(jq -r '.endpoint' "$home/state/x-outbox/req-1.json")" = "dismiss" ] \
|
|
2107
|
+
|| fail "dismiss dry-run preview must carry the endpoint marker"
|
|
2108
|
+
assert_grep "DRY RUN" "$home/err" "dry-run dismiss must surface a DRY RUN summary on stderr"
|
|
2109
|
+
assert_grep "/connector/dismiss" "$home/err" "dry-run dismiss summary must name the dismiss endpoint"
|
|
2110
|
+
pass "fm-x-dismiss dry-run records the would-be body and never posts"
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
test_dismiss_dry_run_needs_no_token() {
|
|
2114
|
+
local home out rc
|
|
2115
|
+
home="$TMP_ROOT/dismiss-dry-notoken"; mkdir -p "$home"
|
|
2116
|
+
# No token at all: dry-run still previews (it neither authenticates nor posts).
|
|
2117
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
|
|
2118
|
+
"$ROOT/bin/fm-x-dismiss.sh" "req-2" 2>/dev/null); rc=$?
|
|
2119
|
+
expect_code 0 "$rc" "dry-run no-token dismiss exit"
|
|
2120
|
+
[ "$out" = "req-2" ] || fail "dry-run dismiss without a token must still echo the request_id (got: $out)"
|
|
2121
|
+
assert_present "$home/state/x-outbox/req-2.json" "dry-run dismiss without a token must still record the preview"
|
|
2122
|
+
pass "fm-x-dismiss dry-run works without a token"
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
test_dismiss_dry_run_outbox_private_publication_rejects_unsafe_paths() {
|
|
2126
|
+
local home out rc err target
|
|
2127
|
+
|
|
2128
|
+
home="$TMP_ROOT/dismiss-outbox-linked-dir"; mkdir -p "$home/state" "$home/external"
|
|
2129
|
+
err="$home/err.txt"
|
|
2130
|
+
ln -s "$home/external" "$home/state/x-outbox"
|
|
2131
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-x 2>"$err"); rc=$?
|
|
2132
|
+
[ "$rc" -ne 0 ] || fail "dismiss dry-run must reject a linked outbox directory"
|
|
2133
|
+
[ -z "$out" ] || fail "rejected dismiss outbox must not echo the request_id (got: $out)"
|
|
2134
|
+
assert_grep "cannot write dry-run outbox" "$err" "dismiss dry-run must report the linked outbox write failure"
|
|
2135
|
+
assert_absent "$home/external/req-x.json" "dismiss dry-run must not write through a linked outbox directory"
|
|
2136
|
+
|
|
2137
|
+
home="$TMP_ROOT/dismiss-outbox-linked-dest"; mkdir -p "$home/state/x-outbox"
|
|
2138
|
+
err="$home/err.txt"
|
|
2139
|
+
chmod 700 "$home/state/x-outbox"
|
|
2140
|
+
target="$home/external-target.json"
|
|
2141
|
+
printf 'external sentinel\n' > "$target"
|
|
2142
|
+
ln -s "$target" "$home/state/x-outbox/req-x.json"
|
|
2143
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-x 2>"$err"); rc=$?
|
|
2144
|
+
[ "$rc" -ne 0 ] || fail "dismiss dry-run must reject a linked outbox destination"
|
|
2145
|
+
[ "$(cat "$target")" = "external sentinel" ] || fail "dismiss dry-run must not write through a linked outbox destination"
|
|
2146
|
+
[ -L "$home/state/x-outbox/req-x.json" ] || fail "dismiss dry-run must not replace a rejected linked destination"
|
|
2147
|
+
assert_no_private_artifact_temps "$home/state/x-outbox"
|
|
2148
|
+
|
|
2149
|
+
home="$TMP_ROOT/dismiss-outbox-private-success"; mkdir -p "$home"
|
|
2150
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-x 2>/dev/null); rc=$?
|
|
2151
|
+
expect_code 0 "$rc" "dismiss private outbox success exit"
|
|
2152
|
+
[ "$out" = "req-x" ] || fail "dismiss dry-run must still echo the request_id after private publication (got: $out)"
|
|
2153
|
+
[ "$(path_mode "$home/state/x-outbox")" = 700 ] || fail "dismiss dry-run must create the outbox directory as private"
|
|
2154
|
+
[ "$(path_mode "$home/state/x-outbox/req-x.json")" = 600 ] || fail "dismiss dry-run must publish the outbox file as private"
|
|
2155
|
+
assert_no_private_artifact_temps "$home/state/x-outbox"
|
|
2156
|
+
pass "fm-x-dismiss dry-run publishes outbox records only through private guarded artifacts"
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
test_dismiss_non_2xx_fails() {
|
|
2160
|
+
local home fakebin out rc err
|
|
2161
|
+
home="$TMP_ROOT/dismiss-500"; mkdir -p "$home"
|
|
2162
|
+
fakebin=$(make_fake_curl "$home")
|
|
2163
|
+
err="$home/err.txt"
|
|
2164
|
+
printf 'FMX_PAIRING_TOKEN=tok-d\n' > "$home/.env"
|
|
2165
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2166
|
+
FAKE_DISMISS_CODE=500 \
|
|
2167
|
+
"$ROOT/bin/fm-x-dismiss.sh" "req-9" 2>"$err"); rc=$?
|
|
2168
|
+
[ "$rc" -ne 0 ] || fail "dismiss must exit non-zero on a non-2xx response"
|
|
2169
|
+
[ -z "$out" ] || fail "a failed dismiss must not echo the request_id (got: $out)"
|
|
2170
|
+
assert_grep "HTTP 500" "$err" "dismiss must report the failing status"
|
|
2171
|
+
pass "fm-x-dismiss exits non-zero on a non-2xx relay response"
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
test_dismiss_transport_failure_fails() {
|
|
2175
|
+
local home fakebin err out rc
|
|
2176
|
+
home="$TMP_ROOT/dismiss-transport"; mkdir -p "$home"
|
|
2177
|
+
fakebin=$(fm_fakebin "$home")
|
|
2178
|
+
# A curl that fails to reach the relay (non-zero exit, no HTTP code).
|
|
2179
|
+
cat > "$fakebin/curl" <<'SH'
|
|
2180
|
+
#!/usr/bin/env bash
|
|
2181
|
+
exit 7
|
|
2182
|
+
SH
|
|
2183
|
+
chmod +x "$fakebin/curl"
|
|
2184
|
+
err="$home/err.txt"
|
|
2185
|
+
printf 'FMX_PAIRING_TOKEN=tok-d\n' > "$home/.env"
|
|
2186
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2187
|
+
"$ROOT/bin/fm-x-dismiss.sh" "req-9" 2>"$err"); rc=$?
|
|
2188
|
+
[ "$rc" -ne 0 ] || fail "dismiss must exit non-zero on a transport failure"
|
|
2189
|
+
[ -z "$out" ] || fail "a transport-failed dismiss must not echo the request_id (got: $out)"
|
|
2190
|
+
assert_grep "request to relay failed" "$err" "dismiss must report the transport failure"
|
|
2191
|
+
pass "fm-x-dismiss exits non-zero on a transport failure"
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
test_dismiss_unsafe_request_id_rejected() {
|
|
2195
|
+
local home err out rc
|
|
2196
|
+
home="$TMP_ROOT/dismiss-unsafe"; mkdir -p "$home"
|
|
2197
|
+
err="$home/err.txt"
|
|
2198
|
+
# Path-traversal-shaped id must be refused before it becomes an outbox filename.
|
|
2199
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
|
|
2200
|
+
"$ROOT/bin/fm-x-dismiss.sh" "../evil" 2>"$err"); rc=$?
|
|
2201
|
+
expect_code 2 "$rc" "dismiss unsafe id exit"
|
|
2202
|
+
[ -z "$out" ] || fail "dismiss must not echo an unsafe request_id (got: $out)"
|
|
2203
|
+
assert_grep "unsafe request_id" "$err" "dismiss must reject an unsafe request_id"
|
|
2204
|
+
assert_absent "$home/state/../evil.json" "dismiss must not touch a path for an unsafe id"
|
|
2205
|
+
pass "fm-x-dismiss rejects an unsafe request_id (path-traversal guard)"
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
test_dismiss_usage_error() {
|
|
2209
|
+
local home rc
|
|
2210
|
+
home="$TMP_ROOT/dismiss-usage"; mkdir -p "$home"
|
|
2211
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-dismiss.sh" >/dev/null 2>&1; rc=$?
|
|
2212
|
+
expect_code 2 "$rc" "dismiss missing-arg usage exit"
|
|
2213
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-dismiss.sh" req-1 extra >/dev/null 2>&1; rc=$?
|
|
2214
|
+
expect_code 2 "$rc" "dismiss extra-arg usage exit"
|
|
2215
|
+
pass "fm-x-dismiss rejects missing or extra arguments with a usage error"
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
# --- fm-x-link: task <-> X-request association in meta -----------------------
|
|
2219
|
+
|
|
2220
|
+
test_link_records_request_and_timestamp() {
|
|
2221
|
+
local home meta out rc
|
|
2222
|
+
home="$TMP_ROOT/link-ok"; mkdir -p "$home/state"
|
|
2223
|
+
meta="$home/state/fix-login-k3.meta"
|
|
2224
|
+
printf 'window=w\nworktree=/wt\nkind=ship\nmode=no-mistakes\nyolo=off\n' > "$meta"
|
|
2225
|
+
# No inbox and no relay reachable here: this test pins the request/timestamp
|
|
2226
|
+
# recording, not platform resolution, so fm-x-link's no-platform warning to
|
|
2227
|
+
# stderr is expected and dropped.
|
|
2228
|
+
out=$(FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
|
|
2229
|
+
"$ROOT/bin/fm-x-link.sh" fix-login-k3 req-42 2>/dev/null); rc=$?
|
|
2230
|
+
expect_code 0 "$rc" "link exit"
|
|
2231
|
+
assert_grep "x_request=req-42" "$meta" "link must record the request_id"
|
|
2232
|
+
assert_grep "x_request_ts=1700000000" "$meta" "link must record the timestamp"
|
|
2233
|
+
assert_grep "x_followups=0" "$meta" "a fresh link must start the follow-up counter at 0"
|
|
2234
|
+
assert_grep "kind=ship" "$meta" "link must preserve other meta lines"
|
|
2235
|
+
assert_grep "yolo=off" "$meta" "link must preserve other meta lines"
|
|
2236
|
+
# Re-linking replaces the prior link rather than appending a duplicate.
|
|
2237
|
+
FM_HOME="$home" FMX_NOW_OVERRIDE=1700009999 "$ROOT/bin/fm-x-link.sh" fix-login-k3 req-99 >/dev/null 2>&1
|
|
2238
|
+
[ "$(grep -c '^x_request=' "$meta")" = "1" ] || fail "re-link must not duplicate x_request"
|
|
2239
|
+
[ "$(grep -c '^x_request_ts=' "$meta")" = "1" ] || fail "re-link must not duplicate x_request_ts"
|
|
2240
|
+
[ "$(grep -c '^x_followups=' "$meta")" = "1" ] || fail "re-link must not duplicate x_followups"
|
|
2241
|
+
assert_grep "x_request=req-99" "$meta" "re-link must replace the request_id"
|
|
2242
|
+
assert_grep "x_request_ts=1700009999" "$meta" "re-link must refresh the timestamp"
|
|
2243
|
+
assert_grep "x_followups=0" "$meta" "a plain re-link must reset the follow-up counter to 0"
|
|
2244
|
+
pass "fm-x-link records and refreshes the X-request link without disturbing meta"
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
test_link_records_discord_platform_for_followups() {
|
|
2248
|
+
local home meta out rc reply
|
|
2249
|
+
home="$TMP_ROOT/link-discord-platform"; private_artifact_dir "$home/state/x-inbox"
|
|
2250
|
+
meta="$home/state/fix-discord.meta"
|
|
2251
|
+
printf 'window=w\nworktree=/wt\nkind=ship\nmode=no-mistakes\nyolo=off\n' > "$meta"
|
|
2252
|
+
jq -cn '{request_id:"req-discord-follow",tweet_id:"discord:channel:message",reply_max_chars:1900,text:"question"}' \
|
|
2253
|
+
> "$home/state/x-inbox/req-discord-follow.json"
|
|
2254
|
+
private_artifact_file "$home/state/x-inbox/req-discord-follow.json"
|
|
2255
|
+
FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
|
|
2256
|
+
"$ROOT/bin/fm-x-link.sh" fix-discord req-discord-follow >/dev/null; rc=$?
|
|
2257
|
+
expect_code 0 "$rc" "Discord link exit"
|
|
2258
|
+
assert_grep "x_platform=discord" "$meta" "link must record Discord platform context"
|
|
2259
|
+
assert_grep "x_reply_max_chars=1900" "$meta" "link must record the Discord split budget for follow-ups"
|
|
2260
|
+
rm -f "$home/state/x-inbox/req-discord-follow.json"
|
|
2261
|
+
reply=$(cat <<'TXT'
|
|
2262
|
+
The follow-up is longer than an X tweet but should stay in one Discord message because the linked task meta recorded the platform before the inbox was drained.
|
|
2263
|
+
|
|
2264
|
+
```bash
|
|
2265
|
+
printf '%s\n' "this fenced block should stay whole"
|
|
2266
|
+
```
|
|
2267
|
+
|
|
2268
|
+
The final sentence confirms that the follow-up path did not fall back to the X budget after the inbox file disappeared.
|
|
2269
|
+
TXT
|
|
2270
|
+
)
|
|
2271
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
|
|
2272
|
+
"$ROOT/bin/fm-x-followup.sh" fix-discord - <<<"$reply" 2>/dev/null); rc=$?
|
|
2273
|
+
expect_code 0 "$rc" "Discord follow-up dry-run exit"
|
|
2274
|
+
[ "$out" = "req-discord-follow" ] || fail "Discord follow-up must echo the request_id (got: $out)"
|
|
2275
|
+
jq -e 'has("texts")|not' "$home/state/x-outbox/req-discord-follow.json" >/dev/null \
|
|
2276
|
+
|| fail "Discord follow-up below its message budget must not split after inbox drain"
|
|
2277
|
+
pass "fm-x-link records Discord platform context so follow-ups keep the Discord budget"
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
# Regression (2026-07-10 incident): a ~470-char Discord follow-up posted as a
|
|
2281
|
+
# (1/2)(2/2) thread because the link was recorded AFTER the ack reply drained the
|
|
2282
|
+
# inbox file, so the platform was lost and the splitter defaulted to X's 280-char
|
|
2283
|
+
# budget. The fix resolves the platform AUTHORITATIVELY from the relay by
|
|
2284
|
+
# request_id, so this ordering no longer loses it: the follow-up posts as ONE
|
|
2285
|
+
# message even though the inbox file is already gone at link time.
|
|
2286
|
+
test_link_resolves_platform_by_request_id_after_inbox_cleanup() {
|
|
2287
|
+
local home fakebin log meta out rc reply
|
|
2288
|
+
home="$TMP_ROOT/link-relay-lookup"; mkdir -p "$home/state"
|
|
2289
|
+
fakebin=$(make_fake_curl "$home")
|
|
2290
|
+
log="$home/curl.log"
|
|
2291
|
+
meta="$home/state/fix-after-cleanup.meta"
|
|
2292
|
+
printf 'window=w\nworktree=/wt\nkind=ship\nmode=no-mistakes\nyolo=off\n' > "$meta"
|
|
2293
|
+
printf 'FMX_PAIRING_TOKEN=tok-reqctx\n' > "$home/.env"
|
|
2294
|
+
# No inbox file at all: the ack reply already cleaned it up before the link.
|
|
2295
|
+
# The relay resolves the platform by request_id.
|
|
2296
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2297
|
+
FMX_NOW_OVERRIDE=1700000000 FAKE_CURL_LOG="$log" \
|
|
2298
|
+
FAKE_REQCTX_CODE=200 FAKE_REQCTX_BODY='{"platform":"discord","reply_max_chars":1900}' \
|
|
2299
|
+
"$ROOT/bin/fm-x-link.sh" fix-after-cleanup req-after-cleanup); rc=$?
|
|
2300
|
+
expect_code 0 "$rc" "link after inbox cleanup exit"
|
|
2301
|
+
assert_grep "url=https://relay.test/connector/request-context" "$log" \
|
|
2302
|
+
"link must resolve the platform authoritatively by request_id when the inbox is gone"
|
|
2303
|
+
grep '^data=' "$log" | tail -1 | sed 's/^data=//' | jq -e '.request_id == "req-after-cleanup"' >/dev/null \
|
|
2304
|
+
|| fail "the relay lookup must send the request_id in the body"
|
|
2305
|
+
assert_grep "x_platform=discord" "$meta" "relay lookup must record the Discord platform after inbox cleanup"
|
|
2306
|
+
assert_grep "x_reply_max_chars=1900" "$meta" "relay lookup must record the Discord split budget after inbox cleanup"
|
|
2307
|
+
# The follow-up (still with the inbox gone) must post the ~470-char reply as ONE
|
|
2308
|
+
# Discord message, not an X-length thread.
|
|
2309
|
+
reply=$(cat <<'TXT'
|
|
2310
|
+
Aye captain, the sign-in redirect is patched and the change is up for review. The fix restores the callback path that was dropping the return URL, adds a regression guard so it cannot silently break again, and keeps the existing session handling untouched. This message is deliberately longer than a single X tweet so the test proves a Discord follow-up stays in one message instead of splitting into a numbered thread.
|
|
2311
|
+
TXT
|
|
2312
|
+
)
|
|
2313
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
|
|
2314
|
+
"$ROOT/bin/fm-x-followup.sh" fix-after-cleanup - <<<"$reply" 2>/dev/null); rc=$?
|
|
2315
|
+
expect_code 0 "$rc" "Discord follow-up after relay lookup exit"
|
|
2316
|
+
[ "$out" = "req-after-cleanup" ] || fail "follow-up must echo the request_id (got: $out)"
|
|
2317
|
+
[ "$(printf '%s' "$reply" | wc -m | tr -d '[:space:]')" -gt 280 ] \
|
|
2318
|
+
|| fail "the regression reply must exceed the X 280-char budget to be meaningful"
|
|
2319
|
+
jq -e 'has("texts")|not' "$home/state/x-outbox/req-after-cleanup.json" >/dev/null \
|
|
2320
|
+
|| fail "a >280 <2000 Discord follow-up must post as ONE message even when linked after inbox cleanup"
|
|
2321
|
+
pass "fm-x-link resolves the platform by request_id so a post-cleanup link keeps the Discord budget"
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
# Criterion 2 loud-warning branch: when the inbox and relay cannot resolve both
|
|
2325
|
+
# axes, the link is still recorded but fm-x-link warns loudly and every follow-up
|
|
2326
|
+
# is refused.
|
|
2327
|
+
test_link_warns_loudly_when_platform_unresolvable() {
|
|
2328
|
+
local home fakebin err meta out rc reply
|
|
2329
|
+
home="$TMP_ROOT/link-warn-unresolvable"; mkdir -p "$home/state"
|
|
2330
|
+
fakebin=$(make_fake_curl "$home")
|
|
2331
|
+
err="$home/err.txt"
|
|
2332
|
+
meta="$home/state/fix-unresolvable.meta"
|
|
2333
|
+
printf 'window=w\nkind=ship\n' > "$meta"
|
|
2334
|
+
printf 'FMX_PAIRING_TOKEN=tok-unresolved\n' > "$home/.env"
|
|
2335
|
+
# No inbox, and the relay cannot resolve the request (404).
|
|
2336
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2337
|
+
FMX_NOW_OVERRIDE=1700000000 FAKE_REQCTX_CODE=404 \
|
|
2338
|
+
"$ROOT/bin/fm-x-link.sh" fix-unresolvable req-unresolvable 2>"$err"); rc=$?
|
|
2339
|
+
expect_code 0 "$rc" "link with unresolvable platform still records the link"
|
|
2340
|
+
[ "$out" = "linked fix-unresolvable to X request req-unresolvable" ] \
|
|
2341
|
+
|| fail "link must still succeed on stdout even when the platform is unknown (got: $out)"
|
|
2342
|
+
assert_grep "WARNING" "$err" "an unresolvable platform must warn loudly, never silently default to X"
|
|
2343
|
+
assert_grep "req-unresolvable" "$err" "the warning must name the request it could not resolve"
|
|
2344
|
+
assert_grep "x_request=req-unresolvable" "$meta" "the link itself must still be recorded"
|
|
2345
|
+
assert_no_grep "x_platform=" "$meta" "no platform must be recorded when none could be resolved"
|
|
2346
|
+
assert_no_grep "x_reply_max_chars=" "$meta" "no split budget must be recorded when none could be resolved"
|
|
2347
|
+
reply="Short follow-up."
|
|
2348
|
+
err="$home/fu-err.txt"
|
|
2349
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
|
|
2350
|
+
"$ROOT/bin/fm-x-followup.sh" fix-unresolvable - <<<"$reply" 2>"$err"); rc=$?
|
|
2351
|
+
[ "$rc" -ne 0 ] || fail "an unresolvable follow-up must be held (non-zero), not posted"
|
|
2352
|
+
[ -z "$out" ] || fail "a held follow-up must not echo the request_id (got: $out)"
|
|
2353
|
+
assert_absent "$home/state/x-outbox/req-unresolvable.json" \
|
|
2354
|
+
"a refused follow-up must leave no outbox preview - nothing was posted or split"
|
|
2355
|
+
assert_grep "held" "$err" "the hold must be reported plainly for retry"
|
|
2356
|
+
assert_grep "x_request=req-unresolvable" "$meta" "a held follow-up must leave the link in place to retry"
|
|
2357
|
+
pass "fm-x-link warns loudly and the follow-up is held (not wrongly split) when the platform is unknown"
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
test_link_carry_count_and_ts_preserve_followup_binding() {
|
|
2361
|
+
local home meta rc
|
|
2362
|
+
home="$TMP_ROOT/link-carry"; mkdir -p "$home/state"
|
|
2363
|
+
meta="$home/state/successor-task.meta"
|
|
2364
|
+
printf 'window=w\nkind=ship\n' > "$meta"
|
|
2365
|
+
FM_HOME="$home" FMX_NOW_OVERRIDE=1700999999 \
|
|
2366
|
+
"$ROOT/bin/fm-x-link.sh" successor-task req-carry \
|
|
2367
|
+
--carry-count 2 --carry-ts 1700000000 --carry-platform x --carry-max 280 >/dev/null; rc=$?
|
|
2368
|
+
expect_code 0 "$rc" "link paired carry flags exit"
|
|
2369
|
+
assert_grep "x_request=req-carry" "$meta" "carried link must record the request_id"
|
|
2370
|
+
assert_grep "x_request_ts=1700000000" "$meta" "--carry-ts must preserve the original timestamp, not the current time"
|
|
2371
|
+
assert_grep "x_followups=2" "$meta" "--carry-count must seed the follow-up counter, not reset it"
|
|
2372
|
+
assert_grep "x_platform=x" "$meta" "--carry-platform must preserve the prior reply platform"
|
|
2373
|
+
assert_grep "x_reply_max_chars=280" "$meta" "--carry-max must preserve the prior split budget"
|
|
2374
|
+
pass "fm-x-link paired carry flags preserve a prior task's follow-up binding onto a successor"
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
test_link_recovery_relink_carries_discord_context_after_inbox_drain() {
|
|
2378
|
+
local home meta out rc reply
|
|
2379
|
+
home="$TMP_ROOT/link-carry-discord"; mkdir -p "$home/state"
|
|
2380
|
+
meta="$home/state/successor-discord.meta"
|
|
2381
|
+
printf 'window=w\nkind=ship\n' > "$meta"
|
|
2382
|
+
FM_HOME="$home" FMX_NOW_OVERRIDE=1700999999 \
|
|
2383
|
+
"$ROOT/bin/fm-x-link.sh" successor-discord req-discord-recovery \
|
|
2384
|
+
--carry-count 1 --carry-ts 1700000000 --carry-platform discord --carry-max 1900 >/dev/null; rc=$?
|
|
2385
|
+
expect_code 0 "$rc" "Discord recovery relink exit"
|
|
2386
|
+
assert_grep "x_platform=discord" "$meta" "Discord recovery relink must preserve the platform after inbox drain"
|
|
2387
|
+
assert_grep "x_reply_max_chars=1900" "$meta" "Discord recovery relink must preserve the split budget after inbox drain"
|
|
2388
|
+
reply=$(cat <<'TXT'
|
|
2389
|
+
The recovered task is reporting back with enough text to exceed an X tweet, but it is still comfortably within the Discord budget carried over from the prior task.
|
|
2390
|
+
|
|
2391
|
+
```bash
|
|
2392
|
+
printf '%s\n' "the code fence should not force an unnecessary Discord split"
|
|
2393
|
+
```
|
|
2394
|
+
|
|
2395
|
+
The successor task must post this as one Discord follow-up even though the original inbox payload has already been drained.
|
|
2396
|
+
TXT
|
|
2397
|
+
)
|
|
2398
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
|
|
2399
|
+
"$ROOT/bin/fm-x-followup.sh" successor-discord - <<<"$reply" 2>/dev/null); rc=$?
|
|
2400
|
+
expect_code 0 "$rc" "Discord recovery follow-up dry-run exit"
|
|
2401
|
+
[ "$out" = "req-discord-recovery" ] || fail "Discord recovery follow-up must echo the request_id (got: $out)"
|
|
2402
|
+
jq -e 'has("texts")|not' "$home/state/x-outbox/req-discord-recovery.json" >/dev/null \
|
|
2403
|
+
|| fail "Discord recovery follow-up below its message budget must not fall back to X splitting"
|
|
2404
|
+
assert_grep "x_followups=2" "$meta" "Discord recovery follow-up must increment the carried count"
|
|
2405
|
+
pass "fm-x-link recovery relink preserves Discord platform context after inbox drain"
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
test_link_carry_count_validation() {
|
|
2409
|
+
local home rc err
|
|
2410
|
+
home="$TMP_ROOT/link-carry-bad"; mkdir -p "$home/state"
|
|
2411
|
+
err="$home/err.txt"
|
|
2412
|
+
printf 'window=w\nkind=ship\n' > "$home/state/ok.meta"
|
|
2413
|
+
PATH="$BASE_PATH" FM_HOME="$home" \
|
|
2414
|
+
"$ROOT/bin/fm-x-link.sh" ok req-1 --carry-count abc >/dev/null 2>"$err"; rc=$?
|
|
2415
|
+
expect_code 2 "$rc" "link --carry-count non-numeric exit"
|
|
2416
|
+
assert_grep "non-negative integer" "$err" "link must explain a bad --carry-count value"
|
|
2417
|
+
PATH="$BASE_PATH" FM_HOME="$home" \
|
|
2418
|
+
"$ROOT/bin/fm-x-link.sh" ok req-1 --carry-ts abc >/dev/null 2>"$err"; rc=$?
|
|
2419
|
+
expect_code 2 "$rc" "link --carry-ts non-numeric exit"
|
|
2420
|
+
assert_grep "non-negative epoch integer" "$err" "link must explain a bad --carry-ts value"
|
|
2421
|
+
PATH="$BASE_PATH" FM_HOME="$home" \
|
|
2422
|
+
"$ROOT/bin/fm-x-link.sh" ok req-1 --carry-count >/dev/null 2>&1; rc=$?
|
|
2423
|
+
expect_code 2 "$rc" "link --carry-count missing value exit"
|
|
2424
|
+
PATH="$BASE_PATH" FM_HOME="$home" \
|
|
2425
|
+
"$ROOT/bin/fm-x-link.sh" ok req-1 --carry-count 1 >/dev/null 2>"$err"; rc=$?
|
|
2426
|
+
expect_code 2 "$rc" "link --carry-count without --carry-ts exit"
|
|
2427
|
+
assert_grep "--carry-count requires --carry-ts" "$err" "link must require --carry-ts when carrying count"
|
|
2428
|
+
PATH="$BASE_PATH" FM_HOME="$home" \
|
|
2429
|
+
"$ROOT/bin/fm-x-link.sh" ok req-1 --carry-ts 1700000000 >/dev/null 2>"$err"; rc=$?
|
|
2430
|
+
expect_code 2 "$rc" "link --carry-ts without --carry-count exit"
|
|
2431
|
+
assert_grep "--carry-ts requires --carry-count" "$err" "link must require --carry-count when carrying timestamp"
|
|
2432
|
+
PATH="$BASE_PATH" FM_HOME="$home" \
|
|
2433
|
+
"$ROOT/bin/fm-x-link.sh" ok req-1 --carry-count 1 --carry-ts 1700000000 >/dev/null 2>"$err"; rc=$?
|
|
2434
|
+
expect_code 2 "$rc" "link carry without reply context exit"
|
|
2435
|
+
assert_grep "relink requires carried reply context" "$err" "link must not silently drop reply context on relink"
|
|
2436
|
+
PATH="$BASE_PATH" FM_HOME="$home" \
|
|
2437
|
+
"$ROOT/bin/fm-x-link.sh" ok req-1 --carry-platform discord >/dev/null 2>"$err"; rc=$?
|
|
2438
|
+
expect_code 2 "$rc" "link --carry-platform without paired carry flags exit"
|
|
2439
|
+
assert_grep "--carry-platform and --carry-max require --carry-count and --carry-ts" "$err" \
|
|
2440
|
+
"link must require the paired carry binding when carrying reply context"
|
|
2441
|
+
PATH="$BASE_PATH" FM_HOME="$home" \
|
|
2442
|
+
"$ROOT/bin/fm-x-link.sh" ok req-1 --carry-count 1 --carry-ts 1700000000 --carry-max 49 >/dev/null 2>"$err"; rc=$?
|
|
2443
|
+
expect_code 2 "$rc" "link --carry-max below floor exit"
|
|
2444
|
+
assert_grep "--carry-max needs an integer of at least 50" "$err" "link must reject an unusable carried split budget"
|
|
2445
|
+
pass "fm-x-link rejects malformed or unpaired carry flags"
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
test_meta_rewrites_do_not_depend_on_tmpdir() {
|
|
2449
|
+
local home badtmp meta out rc
|
|
2450
|
+
home="$TMP_ROOT/link-local-tmp"; mkdir -p "$home/state"
|
|
2451
|
+
badtmp="$home/missing-tmp"
|
|
2452
|
+
meta="$home/state/fix-meta-k4.meta"
|
|
2453
|
+
printf 'window=w\nkind=ship\n' > "$meta"
|
|
2454
|
+
out=$(TMPDIR="$badtmp" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
|
|
2455
|
+
"$ROOT/bin/fm-x-link.sh" fix-meta-k4 req-local 2>/dev/null); rc=$?
|
|
2456
|
+
expect_code 0 "$rc" "link with unusable TMPDIR exit"
|
|
2457
|
+
[ "$out" = "linked fix-meta-k4 to X request req-local" ] \
|
|
2458
|
+
|| fail "link with unusable TMPDIR must still succeed (got: $out)"
|
|
2459
|
+
assert_grep "x_request=req-local" "$meta" "link must record request with an unusable TMPDIR"
|
|
2460
|
+
out=$(TMPDIR="$badtmp" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000001 FMX_FOLLOWUP_MAX_AGE_SECS=0 \
|
|
2461
|
+
"$ROOT/bin/fm-x-followup.sh" --check fix-meta-k4 2>/dev/null); rc=$?
|
|
2462
|
+
expect_code 1 "$rc" "expired check with unusable TMPDIR exit"
|
|
2463
|
+
[ -z "$out" ] || fail "expired check must stay silent (got: $out)"
|
|
2464
|
+
assert_no_grep "x_request=" "$meta" "clear must remove request with an unusable TMPDIR"
|
|
2465
|
+
assert_no_grep "x_followups=" "$meta" "clear must remove the follow-up counter with an unusable TMPDIR"
|
|
2466
|
+
assert_grep "kind=ship" "$meta" "clear must preserve other meta lines"
|
|
2467
|
+
pass "meta rewrites are independent of TMPDIR"
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
# The shared publisher must refuse a symlink at state/<id>.meta so Relay field
|
|
2471
|
+
# rewrites cannot follow it and overwrite the target. Each helper is a real
|
|
2472
|
+
# rewrite path: link, follow-up counter, and clear.
|
|
2473
|
+
test_meta_helpers_refuse_a_symlinked_task_record() {
|
|
2474
|
+
local home meta target original rc leftover fakebin
|
|
2475
|
+
|
|
2476
|
+
assert_symlink_untouched() {
|
|
2477
|
+
local why=$1
|
|
2478
|
+
[ -L "$meta" ] || fail "$why replaced or removed the symlink record"
|
|
2479
|
+
cmp -s "$target" "$original" \
|
|
2480
|
+
|| fail "$why rewrote the symlink target in place"
|
|
2481
|
+
leftover=$(find "$home/state" -maxdepth 1 -name '.*.fm-x.*' -print 2>/dev/null || true)
|
|
2482
|
+
[ -z "$leftover" ] || fail "$why left a staging file after a refused publish: $leftover"
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
home="$TMP_ROOT/meta-symlink"
|
|
2486
|
+
mkdir -p "$home/state"
|
|
2487
|
+
meta="$home/state/sym-task.meta"
|
|
2488
|
+
target="$TMP_ROOT/meta-symlink-foreign.meta"
|
|
2489
|
+
original="$TMP_ROOT/meta-symlink-foreign.expected"
|
|
2490
|
+
|
|
2491
|
+
printf '%s\n' 'window=w' 'kind=ship' 'mode=no-mistakes' 'yolo=off' > "$target"
|
|
2492
|
+
cp "$target" "$original"
|
|
2493
|
+
ln -s "$target" "$meta"
|
|
2494
|
+
FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
|
|
2495
|
+
"$ROOT/bin/fm-x-link.sh" sym-task req-sym >/dev/null 2>&1; rc=$?
|
|
2496
|
+
[ "$rc" -ne 0 ] || fail "link through a symlink record should refuse"
|
|
2497
|
+
assert_no_grep "x_request=" "$target" "link wrote an X request through the symlink"
|
|
2498
|
+
assert_symlink_untouched "link"
|
|
2499
|
+
|
|
2500
|
+
printf '%s\n' 'window=w' 'kind=ship' 'mode=no-mistakes' 'yolo=off' \
|
|
2501
|
+
'x_request=req-sym' 'x_request_ts=1700000000' 'x_followups=0' \
|
|
2502
|
+
'x_platform=x' 'x_reply_max_chars=280' > "$target"
|
|
2503
|
+
cp "$target" "$original"
|
|
2504
|
+
rm -f "$meta"
|
|
2505
|
+
ln -s "$target" "$meta"
|
|
2506
|
+
|
|
2507
|
+
FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" --clear sym-task >/dev/null 2>&1; rc=$?
|
|
2508
|
+
[ "$rc" -ne 0 ] || fail "clear through a symlink record should refuse"
|
|
2509
|
+
assert_grep "x_request=req-sym" "$target" "clear removed the X request through the symlink"
|
|
2510
|
+
assert_symlink_untouched "clear"
|
|
2511
|
+
|
|
2512
|
+
rm -f "$meta" "$target"
|
|
2513
|
+
ln -s "$target" "$meta"
|
|
2514
|
+
FM_HOME="$home" STATE="$home/state" ROOT="$ROOT" META="$meta" bash -c '
|
|
2515
|
+
. "$ROOT/bin/fm-x-lib.sh"
|
|
2516
|
+
. "$ROOT/bin/fm-wake-lib.sh"
|
|
2517
|
+
fmx_meta_link_clear "$META"
|
|
2518
|
+
' >/dev/null 2>&1; rc=$?
|
|
2519
|
+
[ "$rc" -ne 0 ] || fail "the clear helper should refuse a dangling symlink record"
|
|
2520
|
+
[ -L "$meta" ] || fail "the clear helper replaced or removed the dangling symlink record"
|
|
2521
|
+
[ ! -e "$target" ] || fail "the clear helper created the dangling symlink target"
|
|
2522
|
+
leftover=$(find "$home/state" -maxdepth 1 -name '.*.fm-x.*' -print 2>/dev/null || true)
|
|
2523
|
+
[ -z "$leftover" ] || fail "the clear helper left a staging file after refusing a dangling symlink: $leftover"
|
|
2524
|
+
|
|
2525
|
+
printf '%s\n' 'window=w' 'kind=ship' 'mode=no-mistakes' 'yolo=off' \
|
|
2526
|
+
'x_request=req-sym' 'x_request_ts=1700000000' 'x_followups=0' \
|
|
2527
|
+
'x_platform=x' 'x_reply_max_chars=280' > "$target"
|
|
2528
|
+
cp "$target" "$original"
|
|
2529
|
+
fakebin=$(make_fake_curl "$home")
|
|
2530
|
+
printf 'FMX_PAIRING_TOKEN=tok-sym\n' > "$home/.env"
|
|
2531
|
+
FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 PATH="$fakebin:$BASE_PATH" \
|
|
2532
|
+
"$ROOT/bin/fm-x-followup.sh" sym-task - <<<"milestone update" >/dev/null 2>&1; rc=$?
|
|
2533
|
+
[ "$rc" -ne 0 ] || fail "a follow-up through a symlink record should refuse"
|
|
2534
|
+
assert_absent "$home/state/x-outbox/req-sym.json" \
|
|
2535
|
+
"a refused symlink record still published a follow-up"
|
|
2536
|
+
assert_grep "x_followups=0" "$target" "a refused follow-up incremented the counter through the symlink"
|
|
2537
|
+
assert_symlink_untouched "follow-up"
|
|
2538
|
+
pass "x-lib meta helpers refuse a symlinked task record and leave its target untouched"
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
test_link_rejects_unsafe_and_missing() {
|
|
2542
|
+
local home rc
|
|
2543
|
+
home="$TMP_ROOT/link-bad"; mkdir -p "$home/state"
|
|
2544
|
+
printf 'kind=ship\n' > "$home/state/ok.meta"
|
|
2545
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" "../evil" req-1 >/dev/null 2>&1; rc=$?
|
|
2546
|
+
expect_code 2 "$rc" "link unsafe task id exit"
|
|
2547
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" ok "../../etc/x" >/dev/null 2>&1; rc=$?
|
|
2548
|
+
expect_code 2 "$rc" "link unsafe request_id exit"
|
|
2549
|
+
assert_absent "$home/state/../evil.meta" "link must not touch meta for an unsafe id"
|
|
2550
|
+
# Missing meta is a hard error, not a silent create.
|
|
2551
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" no-such req-1 >/dev/null 2>&1; rc=$?
|
|
2552
|
+
expect_code 1 "$rc" "link missing meta exit"
|
|
2553
|
+
assert_absent "$home/state/no-such.meta" "link must not create meta for a non-existent task"
|
|
2554
|
+
# Missing arguments are a usage error.
|
|
2555
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" ok >/dev/null 2>&1; rc=$?
|
|
2556
|
+
expect_code 2 "$rc" "link missing arg exit"
|
|
2557
|
+
pass "fm-x-link rejects unsafe ids, missing meta, and missing arguments"
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
# A home with no secondmates at all learns nothing from the registry, so its
|
|
2561
|
+
# missing-task error must stay the plain one instead of routing every typo at a
|
|
2562
|
+
# mechanism that does not apply.
|
|
2563
|
+
test_link_missing_task_without_secondmates_stays_plain() {
|
|
2564
|
+
local home err rc
|
|
2565
|
+
home="$TMP_ROOT/link-no-secondmates"; mkdir -p "$home/state" "$home/data"
|
|
2566
|
+
err="$TMP_ROOT/link-no-secondmates.err"
|
|
2567
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" no-such req-1 >/dev/null 2>"$err"; rc=$?
|
|
2568
|
+
expect_code 1 "$rc" "plain missing-task exit"
|
|
2569
|
+
assert_grep "no such task: state/no-such.meta" "$err" "the plain missing-task error must still be reported"
|
|
2570
|
+
assert_no_grep "fm-public-followup.sh register" "$err" \
|
|
2571
|
+
"a home with no second mates must not be pointed at the promised-final path"
|
|
2572
|
+
pass "fm-x-link keeps the plain missing-task error when no second mate is registered"
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
# The link writes into THIS home's own state/<id>.meta, so it can never bind work
|
|
2576
|
+
# that lives in a secondmate home. Refusing with a bare "no such task" left the
|
|
2577
|
+
# public promise silently orphaned; the refusal must name the secondmate holding
|
|
2578
|
+
# the task and the promised-final path that can actually bind it.
|
|
2579
|
+
test_link_refuses_secondmate_routed_task_with_promised_final_pointer() {
|
|
2580
|
+
local main sub err out rc
|
|
2581
|
+
main="$TMP_ROOT/link-secondmate-main"; mkdir -p "$main/state" "$main/data"
|
|
2582
|
+
sub="$TMP_ROOT/link-secondmate-sub"; mkdir -p "$sub/state"
|
|
2583
|
+
printf 'sm-axi\n' > "$sub/.fm-secondmate-home"
|
|
2584
|
+
printf 'window=w\nworktree=/wt\nkind=ship\n' > "$sub/state/routed-k1.meta"
|
|
2585
|
+
printf '# Second mates\n\n- sm-axi - owns the axi domain (home: %s; scope: axi tooling; projects: axi; added 2026-01-01)\n' \
|
|
2586
|
+
"$sub" > "$main/data/secondmates.md"
|
|
2587
|
+
err="$TMP_ROOT/link-secondmate.err"
|
|
2588
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$main" "$ROOT/bin/fm-x-link.sh" routed-k1 req-routed 2>"$err"); rc=$?
|
|
2589
|
+
expect_code 1 "$rc" "secondmate-routed link exit"
|
|
2590
|
+
[ -z "$out" ] || fail "a refused link must print no success line (got: $out)"
|
|
2591
|
+
assert_grep "sm-axi" "$err" "the refusal must name the second mate holding the task"
|
|
2592
|
+
assert_grep "--work-home secondmate:sm-axi" "$err" \
|
|
2593
|
+
"the refusal must name the exact promised-final binding for that second mate"
|
|
2594
|
+
assert_grep "fm-public-followup.sh register" "$err" \
|
|
2595
|
+
"the refusal must point at the promised-final registration command"
|
|
2596
|
+
assert_absent "$main/state/routed-k1.meta" "a refused link must not create a local record"
|
|
2597
|
+
assert_no_grep "x_request=" "$sub/state/routed-k1.meta" \
|
|
2598
|
+
"a refused link must not write into the second mate's task record"
|
|
2599
|
+
# The guardrail is scoped to the missing-record case: a task this home does own
|
|
2600
|
+
# still links normally with second mates registered.
|
|
2601
|
+
printf 'window=w\nworktree=/wt\nkind=ship\n' > "$main/state/local-k1.meta"
|
|
2602
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$main" FMX_NOW_OVERRIDE=1700000000 \
|
|
2603
|
+
"$ROOT/bin/fm-x-link.sh" local-k1 req-local 2>/dev/null); rc=$?
|
|
2604
|
+
expect_code 0 "$rc" "local link exit with second mates registered"
|
|
2605
|
+
assert_grep "x_request=req-local" "$main/state/local-k1.meta" \
|
|
2606
|
+
"a local task must still link while second mates are registered"
|
|
2607
|
+
pass "fm-x-link refuses a second-mate-routed task and points at the promised-final path"
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
# A remote secondmate's home cannot be inspected from here, and neither can a
|
|
2611
|
+
# task the parent never recorded, so the refusal degrades to naming the routing
|
|
2612
|
+
# possibility and the mechanism rather than silently reporting a missing file.
|
|
2613
|
+
test_link_missing_task_with_secondmates_points_at_promised_final() {
|
|
2614
|
+
local home err rc
|
|
2615
|
+
home="$TMP_ROOT/link-remote-secondmate"; mkdir -p "$home/state" "$home/data"
|
|
2616
|
+
printf '# Second mates\n\n- sm-far - owns the far domain (host: box; root: /srv/fm; home: /srv/fm/home; scope: far things; projects: far; added 2026-01-01)\n' \
|
|
2617
|
+
> "$home/data/secondmates.md"
|
|
2618
|
+
err="$TMP_ROOT/link-remote-secondmate.err"
|
|
2619
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" unknown-k1 req-unknown >/dev/null 2>"$err"; rc=$?
|
|
2620
|
+
expect_code 1 "$rc" "unknown-task link exit with second mates registered"
|
|
2621
|
+
assert_grep "no such task: state/unknown-k1.meta" "$err" "the concrete missing record must still be reported"
|
|
2622
|
+
assert_grep "fm-public-followup.sh register" "$err" \
|
|
2623
|
+
"an unlocatable task in a home with second mates must be pointed at the promised-final path"
|
|
2624
|
+
assert_grep "--work-home secondmate:<id>" "$err" \
|
|
2625
|
+
"an unlocatable task must leave the second mate id for the caller to fill in"
|
|
2626
|
+
pass "fm-x-link points an unlocatable task at the promised-final path when second mates exist"
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
# --- fm-x-followup: detect, post up to 3 follow-ups, manage the link --------
|
|
2630
|
+
|
|
2631
|
+
mk_linked_task() { # <home> <id> <request_id> <link-epoch> [starting-count]
|
|
2632
|
+
local home=$1 id=$2 rid=$3 ts=$4 count=${5:-} meta
|
|
2633
|
+
mkdir -p "$home/state"
|
|
2634
|
+
meta="$home/state/$id.meta"
|
|
2635
|
+
printf 'window=w\nworktree=/wt\nkind=ship\nmode=no-mistakes\nyolo=off\n' > "$meta"
|
|
2636
|
+
FM_HOME="$home" FMX_NOW_OVERRIDE="$ts" "$ROOT/bin/fm-x-link.sh" "$id" "$rid" \
|
|
2637
|
+
--carry-count "${count:-0}" --carry-ts "$ts" --carry-platform x --carry-max 280 >/dev/null
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
test_followup_check_states() {
|
|
2641
|
+
local home out rc
|
|
2642
|
+
home="$TMP_ROOT/fu-check"; mkdir -p "$home/state"
|
|
2643
|
+
mk_linked_task "$home" task-a req-a 1700000000
|
|
2644
|
+
# Within window -> exit 0, prints the request_id.
|
|
2645
|
+
out=$(FM_HOME="$home" FMX_NOW_OVERRIDE=1700003600 \
|
|
2646
|
+
"$ROOT/bin/fm-x-followup.sh" --check task-a); rc=$?
|
|
2647
|
+
expect_code 0 "$rc" "check within-window exit"
|
|
2648
|
+
[ "$out" = "req-a" ] || fail "check within window must print the request_id (got: $out)"
|
|
2649
|
+
# Not linked -> exit 1, silent.
|
|
2650
|
+
printf 'kind=ship\n' > "$home/state/plain.meta"
|
|
2651
|
+
out=$(FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" --check plain 2>/dev/null); rc=$?
|
|
2652
|
+
expect_code 1 "$rc" "check not-linked exit"
|
|
2653
|
+
[ -z "$out" ] || fail "check on a non-linked task must be silent (got: $out)"
|
|
2654
|
+
# Missing meta -> exit 1, silent.
|
|
2655
|
+
out=$(FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" --check nope 2>/dev/null); rc=$?
|
|
2656
|
+
expect_code 1 "$rc" "check missing-meta exit"
|
|
2657
|
+
pass "fm-x-followup --check reports postable / not-linked correctly"
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
test_followup_check_expired_prunes_link() {
|
|
2661
|
+
local home out rc meta
|
|
2662
|
+
home="$TMP_ROOT/fu-check-exp"; mkdir -p "$home/state"
|
|
2663
|
+
mk_linked_task "$home" task-e req-e 1700000000
|
|
2664
|
+
meta="$home/state/task-e.meta"
|
|
2665
|
+
# 8 days later: past the 7-day window -> exit 1, link pruned, other lines intact.
|
|
2666
|
+
out=$(FM_HOME="$home" FMX_NOW_OVERRIDE=$((1700000000 + 8*86400)) \
|
|
2667
|
+
"$ROOT/bin/fm-x-followup.sh" --check task-e 2>/dev/null); rc=$?
|
|
2668
|
+
expect_code 1 "$rc" "check expired exit"
|
|
2669
|
+
[ -z "$out" ] || fail "check on an expired link must be silent (got: $out)"
|
|
2670
|
+
assert_no_grep "x_request=" "$meta" "expired check must prune the link"
|
|
2671
|
+
assert_grep "kind=ship" "$meta" "expired check must preserve other meta lines"
|
|
2672
|
+
pass "fm-x-followup --check prunes a link past the 7-day window"
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
test_followup_check_cap_reached_prunes_link() {
|
|
2676
|
+
local home out rc meta
|
|
2677
|
+
home="$TMP_ROOT/fu-check-cap"; mkdir -p "$home/state"
|
|
2678
|
+
# Already at the cap (3 posted) even though well within the window.
|
|
2679
|
+
mk_linked_task "$home" task-cap req-cap 1700000000 3
|
|
2680
|
+
meta="$home/state/task-cap.meta"
|
|
2681
|
+
out=$(FM_HOME="$home" FMX_NOW_OVERRIDE=1700003600 \
|
|
2682
|
+
"$ROOT/bin/fm-x-followup.sh" --check task-cap 2>/dev/null); rc=$?
|
|
2683
|
+
expect_code 1 "$rc" "check cap-reached exit"
|
|
2684
|
+
[ -z "$out" ] || fail "check at the cap must be silent (got: $out)"
|
|
2685
|
+
assert_no_grep "x_request=" "$meta" "a cap-reached check must prune the link"
|
|
2686
|
+
assert_grep "kind=ship" "$meta" "cap-reached check must preserve other meta lines"
|
|
2687
|
+
pass "fm-x-followup --check prunes a link that already reached the follow-up cap"
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
test_followup_post_increments_counter_keeps_link() {
|
|
2691
|
+
local home fakebin log out rc meta data
|
|
2692
|
+
home="$TMP_ROOT/fu-post"; mkdir -p "$home/state"
|
|
2693
|
+
fakebin=$(make_fake_curl "$home")
|
|
2694
|
+
log="$home/curl.log"
|
|
2695
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
|
|
2696
|
+
mk_linked_task "$home" task-p req-p 1700000000
|
|
2697
|
+
meta="$home/state/task-p.meta"
|
|
2698
|
+
printf 'Done, captain - build has started.' > "$home/reply.txt"
|
|
2699
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2700
|
+
FMX_NOW_OVERRIDE=1700003600 FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=200 \
|
|
2701
|
+
"$ROOT/bin/fm-x-followup.sh" task-p --text-file "$home/reply.txt"); rc=$?
|
|
2702
|
+
expect_code 0 "$rc" "followup post exit"
|
|
2703
|
+
[ "$out" = "req-p" ] || fail "followup post must echo the request_id (got: $out)"
|
|
2704
|
+
assert_grep "url=https://relay.test/connector/followup" "$log" "post must hit the followup endpoint"
|
|
2705
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
2706
|
+
[ "$(printf '%s' "$data" | jq -r .text)" = "Done, captain - build has started." ] \
|
|
2707
|
+
|| fail "post must send the composed follow-up text"
|
|
2708
|
+
# One post under the cap must NOT clear the link - it increments the counter
|
|
2709
|
+
# so up to two more follow-ups can still land against the same binding.
|
|
2710
|
+
assert_grep "x_request=req-p" "$meta" "a post under the cap must keep the link"
|
|
2711
|
+
assert_grep "x_followups=1" "$meta" "a successful post must increment the follow-up counter"
|
|
2712
|
+
assert_grep "kind=ship" "$meta" "posting must preserve other meta lines"
|
|
2713
|
+
pass "fm-x-followup posts a follow-up, increments the counter, and keeps the link under the cap"
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
test_followup_post_final_clears_link_immediately() {
|
|
2717
|
+
local home fakebin out rc meta
|
|
2718
|
+
home="$TMP_ROOT/fu-post-final"; mkdir -p "$home/state"
|
|
2719
|
+
fakebin=$(make_fake_curl "$home")
|
|
2720
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
|
|
2721
|
+
mk_linked_task "$home" task-final req-final 1700000000
|
|
2722
|
+
meta="$home/state/task-final.meta"
|
|
2723
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2724
|
+
FMX_NOW_OVERRIDE=1700003600 FAKE_FOLLOWUP_CODE=200 \
|
|
2725
|
+
"$ROOT/bin/fm-x-followup.sh" task-final --final - <<<"Shipped - all green."); rc=$?
|
|
2726
|
+
expect_code 0 "$rc" "followup --final post exit"
|
|
2727
|
+
[ "$out" = "req-final" ] || fail "followup --final post must echo the request_id (got: $out)"
|
|
2728
|
+
assert_no_grep "x_request=" "$meta" "--final must clear the link even with follow-ups remaining under the cap"
|
|
2729
|
+
assert_grep "kind=ship" "$meta" "clearing the link must preserve other meta lines"
|
|
2730
|
+
pass "fm-x-followup --final clears the link after one post regardless of the remaining count"
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
test_followup_post_cap_reached_clears_link() {
|
|
2734
|
+
local home fakebin out rc meta
|
|
2735
|
+
home="$TMP_ROOT/fu-post-cap"; mkdir -p "$home/state"
|
|
2736
|
+
fakebin=$(make_fake_curl "$home")
|
|
2737
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
|
|
2738
|
+
# Two follow-ups already posted; this third one reaches the cap.
|
|
2739
|
+
mk_linked_task "$home" task-cap3 req-cap3 1700000000 2
|
|
2740
|
+
meta="$home/state/task-cap3.meta"
|
|
2741
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2742
|
+
FMX_NOW_OVERRIDE=1700003600 FAKE_FOLLOWUP_CODE=200 \
|
|
2743
|
+
"$ROOT/bin/fm-x-followup.sh" task-cap3 - <<<"Third and final update."); rc=$?
|
|
2744
|
+
expect_code 0 "$rc" "followup cap-reaching post exit"
|
|
2745
|
+
[ "$out" = "req-cap3" ] || fail "followup cap-reaching post must echo the request_id (got: $out)"
|
|
2746
|
+
assert_no_grep "x_request=" "$meta" "reaching the cap must clear the link even without --final"
|
|
2747
|
+
pass "fm-x-followup clears the link once the third follow-up reaches the cap"
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
test_followup_post_forwards_image_to_reply_client() {
|
|
2751
|
+
local home fakebin log out rc meta data img expected
|
|
2752
|
+
home="$TMP_ROOT/fu-post-image"; mkdir -p "$home/state"
|
|
2753
|
+
fakebin=$(make_fake_curl "$home")
|
|
2754
|
+
log="$home/curl.log"
|
|
2755
|
+
img="$home/followup.png"
|
|
2756
|
+
make_sample_image "$img"
|
|
2757
|
+
expected=$(base64 < "$img" | tr -d '\n\r')
|
|
2758
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu-img\n' > "$home/.env"
|
|
2759
|
+
mk_linked_task "$home" task-img req-img 1700000000
|
|
2760
|
+
meta="$home/state/task-img.meta"
|
|
2761
|
+
printf 'Done - generated image attached.' > "$home/reply.txt"
|
|
2762
|
+
# --final keeps this test focused on image forwarding, not counter bookkeeping.
|
|
2763
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2764
|
+
FMX_NOW_OVERRIDE=1700003600 FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=200 \
|
|
2765
|
+
"$ROOT/bin/fm-x-followup.sh" task-img --image "$img" --final --text-file "$home/reply.txt"); rc=$?
|
|
2766
|
+
expect_code 0 "$rc" "followup wrapper image post exit"
|
|
2767
|
+
[ "$out" = "req-img" ] || fail "followup wrapper image post must echo the request_id (got: $out)"
|
|
2768
|
+
data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
|
|
2769
|
+
[ "$(printf '%s' "$data" | jq -r '.image.media_type')" = "image/png" ] \
|
|
2770
|
+
|| fail "followup wrapper must forward image media_type"
|
|
2771
|
+
[ "$(printf '%s' "$data" | jq -r '.image.data_base64')" = "$expected" ] \
|
|
2772
|
+
|| fail "followup wrapper must forward image base64"
|
|
2773
|
+
assert_no_grep "x_request=" "$meta" "a --final image followup must clear the link"
|
|
2774
|
+
pass "fm-x-followup --image forwards the attachment through fm-x-reply --followup"
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
test_followup_post_failure_keeps_link() {
|
|
2778
|
+
local home fakebin out rc meta
|
|
2779
|
+
home="$TMP_ROOT/fu-post-fail"; mkdir -p "$home/state"
|
|
2780
|
+
fakebin=$(make_fake_curl "$home")
|
|
2781
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
|
|
2782
|
+
mk_linked_task "$home" task-f req-f 1700000000
|
|
2783
|
+
meta="$home/state/task-f.meta"
|
|
2784
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2785
|
+
FMX_NOW_OVERRIDE=1700003600 FAKE_FOLLOWUP_CODE=500 \
|
|
2786
|
+
"$ROOT/bin/fm-x-followup.sh" task-f - <<<"retry me" 2>/dev/null); rc=$?
|
|
2787
|
+
[ "$rc" -ne 0 ] || fail "a failed follow-up post must exit non-zero"
|
|
2788
|
+
[ -z "$out" ] || fail "a failed post must not echo the request_id (got: $out)"
|
|
2789
|
+
assert_grep "x_request=req-f" "$meta" "a failed post must leave the link for a retry"
|
|
2790
|
+
assert_grep "x_followups=0" "$meta" "a failed post must not increment the follow-up counter"
|
|
2791
|
+
pass "fm-x-followup keeps the link and counter when the post fails"
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
test_followup_post_record_failure_clears_link() {
|
|
2795
|
+
local home fakebin out rc meta err flag mvflag
|
|
2796
|
+
home="$TMP_ROOT/fu-post-record-fail"; mkdir -p "$home/state"
|
|
2797
|
+
fakebin=$(make_fake_curl "$home")
|
|
2798
|
+
flag="$home/fail-followups-write"
|
|
2799
|
+
mvflag="$home/mv-failed-once"
|
|
2800
|
+
err="$home/err.txt"
|
|
2801
|
+
cat > "$fakebin/mv" <<'SH'
|
|
2802
|
+
#!/usr/bin/env bash
|
|
2803
|
+
if [ -n "${FAKE_MV_FAIL_AFTER_FLAG:-}" ] \
|
|
2804
|
+
&& [ -f "$FAKE_MV_FAIL_AFTER_FLAG" ] \
|
|
2805
|
+
&& [ -n "${FAKE_MV_FAILED_ONCE:-}" ] \
|
|
2806
|
+
&& [ ! -f "$FAKE_MV_FAILED_ONCE" ]; then
|
|
2807
|
+
: > "$FAKE_MV_FAILED_ONCE"
|
|
2808
|
+
exit 2
|
|
2809
|
+
fi
|
|
2810
|
+
exec /bin/mv "$@"
|
|
2811
|
+
SH
|
|
2812
|
+
chmod +x "$fakebin/mv"
|
|
2813
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
|
|
2814
|
+
mk_linked_task "$home" task-rf req-rf 1700000000
|
|
2815
|
+
meta="$home/state/task-rf.meta"
|
|
2816
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2817
|
+
FMX_NOW_OVERRIDE=1700003600 FAKE_FOLLOWUP_CODE=200 FAKE_CURL_TOUCH_AFTER_POST="$flag" \
|
|
2818
|
+
FAKE_MV_FAIL_AFTER_FLAG="$flag" FAKE_MV_FAILED_ONCE="$mvflag" \
|
|
2819
|
+
"$ROOT/bin/fm-x-followup.sh" task-rf - <<<"posted but local state write fails" 2>"$err"); rc=$?
|
|
2820
|
+
expect_code 0 "$rc" "followup post state-record failure exit"
|
|
2821
|
+
[ "$out" = "req-rf" ] || fail "posted followup with tombstoned state must echo the request_id (got: $out)"
|
|
2822
|
+
assert_no_grep "x_request=" "$meta" "a failed counter write must tombstone the link"
|
|
2823
|
+
assert_no_grep "x_followups=" "$meta" "a failed counter write must remove the stale counter"
|
|
2824
|
+
assert_grep "cleared the link to avoid duplicate follow-ups" "$err" "state-record failure must explain the tombstone"
|
|
2825
|
+
pass "fm-x-followup tombstones the link when a post-success counter write fails"
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
test_followup_post_relay_rejection_degrades_gracefully() {
|
|
2829
|
+
local home fakebin out rc meta err
|
|
2830
|
+
home="$TMP_ROOT/fu-post-409"; mkdir -p "$home/state"
|
|
2831
|
+
fakebin=$(make_fake_curl "$home")
|
|
2832
|
+
err="$home/err.txt"
|
|
2833
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
|
|
2834
|
+
mk_linked_task "$home" task-409 req-409 1700000000
|
|
2835
|
+
meta="$home/state/task-409.meta"
|
|
2836
|
+
# The relay's own cap/window rejection (HTTP 409) must be treated exactly like
|
|
2837
|
+
# a locally-detected expiry - not a transient failure worth retrying - so an
|
|
2838
|
+
# old single-follow-up relay or an already-exhausted binding degrades
|
|
2839
|
+
# gracefully instead of leaving a link nothing will ever clear.
|
|
2840
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2841
|
+
FMX_NOW_OVERRIDE=1700003600 FAKE_FOLLOWUP_CODE=409 \
|
|
2842
|
+
"$ROOT/bin/fm-x-followup.sh" task-409 - <<<"rejected by relay" 2>"$err"); rc=$?
|
|
2843
|
+
expect_code 0 "$rc" "relay-rejected post exit"
|
|
2844
|
+
[ -z "$out" ] || fail "a relay-rejected post must echo nothing (got: $out)"
|
|
2845
|
+
assert_no_grep "x_request=" "$meta" "a relay rejection must clear the link"
|
|
2846
|
+
assert_grep "cap or window" "$err" "a relay rejection must explain itself distinctly from a generic failure"
|
|
2847
|
+
pass "fm-x-followup treats a relay cap/window rejection as an already-exhausted link, not a retry"
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
test_followup_post_expired_skips_and_clears() {
|
|
2851
|
+
local home fakebin out rc meta
|
|
2852
|
+
home="$TMP_ROOT/fu-post-exp"; mkdir -p "$home/state"
|
|
2853
|
+
fakebin=$(make_fake_curl "$home")
|
|
2854
|
+
printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
|
|
2855
|
+
mk_linked_task "$home" task-x req-x 1700000000
|
|
2856
|
+
meta="$home/state/task-x.meta"
|
|
2857
|
+
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
|
|
2858
|
+
FMX_NOW_OVERRIDE=$((1700000000 + 8*86400)) FAKE_FOLLOWUP_CODE=200 \
|
|
2859
|
+
"$ROOT/bin/fm-x-followup.sh" task-x - <<<"too late" 2>/dev/null); rc=$?
|
|
2860
|
+
expect_code 0 "$rc" "expired post exit"
|
|
2861
|
+
[ -z "$out" ] || fail "an expired post must post nothing and echo nothing (got: $out)"
|
|
2862
|
+
assert_no_grep "x_request=" "$meta" "an expired post must clear the link"
|
|
2863
|
+
assert_absent "$home/state/x-outbox/req-x.json" "an expired post must not record any reply"
|
|
2864
|
+
pass "fm-x-followup skips silently and clears the link past the 7-day window"
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
test_followup_post_not_linked_is_noop() {
|
|
2868
|
+
local home out rc
|
|
2869
|
+
home="$TMP_ROOT/fu-noop"; mkdir -p "$home/state"
|
|
2870
|
+
printf 'kind=ship\n' > "$home/state/plain.meta"
|
|
2871
|
+
out=$(FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" plain - <<<"nothing to do" 2>/dev/null); rc=$?
|
|
2872
|
+
expect_code 0 "$rc" "not-linked post exit"
|
|
2873
|
+
[ -z "$out" ] || fail "a not-linked post must be a silent no-op (got: $out)"
|
|
2874
|
+
assert_absent "$home/state/x-outbox" "a not-linked post must not record a reply"
|
|
2875
|
+
pass "fm-x-followup is a no-op for a task with no X link"
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
test_followup_post_dry_run_increments_counter_keeps_link() {
|
|
2879
|
+
local home out rc meta
|
|
2880
|
+
home="$TMP_ROOT/fu-dry"; mkdir -p "$home/state"
|
|
2881
|
+
mk_linked_task "$home" task-d req-d 1700000000
|
|
2882
|
+
meta="$home/state/task-d.meta"
|
|
2883
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
|
|
2884
|
+
"$ROOT/bin/fm-x-followup.sh" task-d - <<<"Shipped in dry run." 2>/dev/null); rc=$?
|
|
2885
|
+
expect_code 0 "$rc" "dry-run post exit"
|
|
2886
|
+
[ "$out" = "req-d" ] || fail "dry-run post must echo the request_id (got: $out)"
|
|
2887
|
+
assert_present "$home/state/x-outbox/req-d.json" "dry-run post must record the would-be follow-up"
|
|
2888
|
+
[ "$(jq -r '.endpoint' "$home/state/x-outbox/req-d.json")" = "followup" ] \
|
|
2889
|
+
|| fail "dry-run post preview must carry the followup endpoint marker"
|
|
2890
|
+
# Dry-run must mutate the counter/link exactly as a live post would: keep the
|
|
2891
|
+
# link and increment the counter when under the cap and --final is absent.
|
|
2892
|
+
assert_grep "x_request=req-d" "$meta" "dry-run under the cap must keep the link just as a live post would"
|
|
2893
|
+
assert_grep "x_followups=1" "$meta" "dry-run must increment the follow-up counter just as a live post would"
|
|
2894
|
+
pass "fm-x-followup dry-run records the follow-up and increments the counter, keeping the link"
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
test_followup_post_dry_run_final_clears_link() {
|
|
2898
|
+
local home out rc meta
|
|
2899
|
+
home="$TMP_ROOT/fu-dry-final"; mkdir -p "$home/state"
|
|
2900
|
+
mk_linked_task "$home" task-df req-df 1700000000
|
|
2901
|
+
meta="$home/state/task-df.meta"
|
|
2902
|
+
out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
|
|
2903
|
+
"$ROOT/bin/fm-x-followup.sh" task-df --final - <<<"Shipped in dry run, for real this time." 2>/dev/null); rc=$?
|
|
2904
|
+
expect_code 0 "$rc" "dry-run --final post exit"
|
|
2905
|
+
[ "$out" = "req-df" ] || fail "dry-run --final post must echo the request_id (got: $out)"
|
|
2906
|
+
assert_no_grep "x_request=" "$meta" "dry-run --final must clear the link just as a live --final post would"
|
|
2907
|
+
pass "fm-x-followup dry-run --final clears the link just as a live post would"
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
test_followup_usage_errors() {
|
|
2911
|
+
local home rc err out
|
|
2912
|
+
home="$TMP_ROOT/fu-usage"; mkdir -p "$home/state"
|
|
2913
|
+
err="$home/err.txt"
|
|
2914
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" >/dev/null 2>"$err"; rc=$?
|
|
2915
|
+
expect_code 2 "$rc" "followup no-args exit"
|
|
2916
|
+
assert_grep "--image <path>" "$err" "followup usage must mention --image"
|
|
2917
|
+
assert_grep "--final" "$err" "followup usage must mention --final"
|
|
2918
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" --check >/dev/null 2>&1; rc=$?
|
|
2919
|
+
expect_code 2 "$rc" "followup --check no-id exit"
|
|
2920
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" some-task >/dev/null 2>&1; rc=$?
|
|
2921
|
+
expect_code 2 "$rc" "followup post no-text-source exit"
|
|
2922
|
+
out=$(PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" --help); rc=$?
|
|
2923
|
+
expect_code 0 "$rc" "followup --help exit"
|
|
2924
|
+
assert_contains "$out" "--image <path>" "followup help must mention --image"
|
|
2925
|
+
assert_contains "$out" "threaded replies attach it to the opener tweet" \
|
|
2926
|
+
"followup help must document thread image placement"
|
|
2927
|
+
assert_contains "$out" "--final" "followup help must mention --final"
|
|
2928
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" "../evil" --text-file /dev/null >/dev/null 2>&1; rc=$?
|
|
2929
|
+
expect_code 2 "$rc" "followup unsafe-id exit"
|
|
2930
|
+
PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" some-task --image >/dev/null 2>"$err"; rc=$?
|
|
2931
|
+
expect_code 2 "$rc" "followup missing --image argument exit"
|
|
2932
|
+
assert_grep "missing --image path" "$err" "followup missing --image argument must explain the error"
|
|
2933
|
+
pass "fm-x-followup rejects malformed invocations"
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
test_poll_no_token_is_hard_noop
|
|
2937
|
+
test_poll_empty_env_token_overrides_env_file
|
|
2938
|
+
test_poll_204_is_silent
|
|
2939
|
+
test_poll_empty_env_relay_overrides_env_file
|
|
2940
|
+
test_poll_auth_error_reports_once
|
|
2941
|
+
test_poll_error_private_publication_rejects_unsafe_paths
|
|
2942
|
+
test_poll_question_stashes_and_marks
|
|
2943
|
+
test_poll_mentions_wake_once_per_durable_offer
|
|
2944
|
+
test_poll_offer_claim_failure_reports_once
|
|
2945
|
+
test_poll_preserves_conversation_context
|
|
2946
|
+
test_poll_inbox_commit_failure_reports_error
|
|
2947
|
+
test_poll_inbox_private_publication_rejects_unsafe_paths
|
|
2948
|
+
test_poll_empty_text_is_silent
|
|
2949
|
+
test_poll_rejects_unsafe_request_id
|
|
2950
|
+
test_reply_success_posts_request_bound_only
|
|
2951
|
+
test_reply_text_file_and_stdin
|
|
2952
|
+
test_reply_non_2xx_fails
|
|
2953
|
+
test_reply_auth_header_tempfile_cleans_up_on_interrupted_post
|
|
2954
|
+
test_reply_usage_error
|
|
2955
|
+
test_reply_help_mentions_image
|
|
2956
|
+
test_reply_whitespace_text_rejected
|
|
2957
|
+
test_reply_dry_run_records_not_posts
|
|
2958
|
+
test_reply_dry_run_needs_no_token
|
|
2959
|
+
test_reply_dry_run_from_env_file
|
|
2960
|
+
test_reply_empty_env_dry_run_overrides_env_file
|
|
2961
|
+
test_reply_dry_run_fails_when_outbox_unwritable
|
|
2962
|
+
test_reply_dry_run_outbox_private_publication_rejects_unsafe_paths
|
|
2963
|
+
test_split_thread_lib
|
|
2964
|
+
test_reply_single_no_texts
|
|
2965
|
+
test_reply_thread_dry_run
|
|
2966
|
+
test_reply_discord_inbox_uses_discord_budget
|
|
2967
|
+
test_reply_x_inbox_still_uses_x_budget
|
|
2968
|
+
test_reply_inbox_explicit_limit_wins
|
|
2969
|
+
test_reply_rejects_unsafe_inbox_context_reads
|
|
2970
|
+
test_reply_max_chars_floor_clamps_to_minimum
|
|
2971
|
+
test_reply_thread_live_posts_texts
|
|
2972
|
+
test_reply_image_live_posts_image_object
|
|
2973
|
+
test_reply_image_live_streams_payload_file
|
|
2974
|
+
test_reply_image_thread_dry_run_records_compact_marker
|
|
2975
|
+
test_reply_image_dry_run_cleans_payload_temp_files
|
|
2976
|
+
test_reply_image_path_errors_are_clear
|
|
2977
|
+
test_reply_followup_live_posts_to_followup_endpoint
|
|
2978
|
+
test_reply_followup_409_marker_exits_distinctly
|
|
2979
|
+
test_reply_followup_409_without_marker_still_exits_distinctly
|
|
2980
|
+
test_reply_answer_409_is_generic_failure
|
|
2981
|
+
test_reply_followup_image_live_posts_image_object
|
|
2982
|
+
test_reply_followup_flag_position_is_flexible
|
|
2983
|
+
test_reply_followup_dry_run_marks_endpoint
|
|
2984
|
+
test_reply_followup_thread_dry_run
|
|
2985
|
+
test_reply_followup_image_dry_run_marks_endpoint_and_compacts_image
|
|
2986
|
+
test_poll_records_context_registry_from_relay_platform
|
|
2987
|
+
test_context_registry_private_publication_rejects_unsafe_paths
|
|
2988
|
+
test_context_registry_rejects_unsafe_reads
|
|
2989
|
+
test_private_artifact_publisher_runs_under_system_bash
|
|
2990
|
+
test_context_registry_prunes_expired_records
|
|
2991
|
+
test_context_registry_preserves_first_seen_timestamp
|
|
2992
|
+
test_context_registry_retention_starts_on_successful_live_answer
|
|
2993
|
+
test_regression_discord_followup_survives_inbox_cleanup
|
|
2994
|
+
test_regression_x_followup_still_splits_after_cleanup
|
|
2995
|
+
test_regression_unresolved_followup_fails_safe
|
|
2996
|
+
test_followup_partial_registry_uses_relay_budget_live
|
|
2997
|
+
test_regression_concurrent_requests_keep_own_platform
|
|
2998
|
+
test_dismiss_clears_context_registry
|
|
2999
|
+
test_dismiss_success_posts_request_only
|
|
3000
|
+
test_dismiss_dry_run_records_not_posts
|
|
3001
|
+
test_dismiss_dry_run_needs_no_token
|
|
3002
|
+
test_dismiss_dry_run_outbox_private_publication_rejects_unsafe_paths
|
|
3003
|
+
test_dismiss_non_2xx_fails
|
|
3004
|
+
test_dismiss_transport_failure_fails
|
|
3005
|
+
test_dismiss_unsafe_request_id_rejected
|
|
3006
|
+
test_dismiss_usage_error
|
|
3007
|
+
test_link_records_request_and_timestamp
|
|
3008
|
+
test_link_records_discord_platform_for_followups
|
|
3009
|
+
test_link_resolves_platform_by_request_id_after_inbox_cleanup
|
|
3010
|
+
test_link_warns_loudly_when_platform_unresolvable
|
|
3011
|
+
test_link_carry_count_and_ts_preserve_followup_binding
|
|
3012
|
+
test_link_recovery_relink_carries_discord_context_after_inbox_drain
|
|
3013
|
+
test_link_carry_count_validation
|
|
3014
|
+
test_meta_rewrites_do_not_depend_on_tmpdir
|
|
3015
|
+
test_meta_helpers_refuse_a_symlinked_task_record
|
|
3016
|
+
test_link_rejects_unsafe_and_missing
|
|
3017
|
+
test_link_missing_task_without_secondmates_stays_plain
|
|
3018
|
+
test_link_refuses_secondmate_routed_task_with_promised_final_pointer
|
|
3019
|
+
test_link_missing_task_with_secondmates_points_at_promised_final
|
|
3020
|
+
test_followup_check_states
|
|
3021
|
+
test_followup_check_expired_prunes_link
|
|
3022
|
+
test_followup_check_cap_reached_prunes_link
|
|
3023
|
+
test_followup_post_increments_counter_keeps_link
|
|
3024
|
+
test_followup_post_final_clears_link_immediately
|
|
3025
|
+
test_followup_post_cap_reached_clears_link
|
|
3026
|
+
test_followup_post_forwards_image_to_reply_client
|
|
3027
|
+
test_followup_post_failure_keeps_link
|
|
3028
|
+
test_followup_post_record_failure_clears_link
|
|
3029
|
+
test_followup_post_relay_rejection_degrades_gracefully
|
|
3030
|
+
test_followup_post_expired_skips_and_clears
|
|
3031
|
+
test_followup_post_not_linked_is_noop
|
|
3032
|
+
test_followup_post_dry_run_increments_counter_keeps_link
|
|
3033
|
+
test_followup_post_dry_run_final_clears_link
|
|
3034
|
+
test_followup_usage_errors
|
|
3035
|
+
test_bootstrap_activates_on_env_token
|
|
3036
|
+
test_bootstrap_relative_home_writes_absolute_poll_shim
|
|
3037
|
+
test_bootstrap_reports_missing_x_dependency
|
|
3038
|
+
test_bootstrap_does_not_announce_when_arm_fails
|
|
3039
|
+
test_bootstrap_does_not_follow_x_artifact_symlinks
|
|
3040
|
+
test_bootstrap_inert_without_token
|
|
3041
|
+
test_bootstrap_opt_out_cleanup
|
|
3042
|
+
test_bootstrap_opt_out_reports_cleanup_failure
|