diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index d8d50b07b4..9e61bd62ef 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -24,6 +24,7 @@ Use raw `orca` only when the helper surface cannot answer the inspection questio ## Preflight Work from the current firstmate home or repo root. +In host-root mode, work instead from the physical `FM_HOST_ROOT` and invoke FirstMate helpers by their absolute `FM_ROOT/bin/` paths under the [four-root contract](../../../docs/configuration.md#host-root-mode-fm_host_root). If `FM_HOME` is set, remember that operational state lives under `$FM_HOME` while the helper scripts still run from this repo's `bin/`. Before switching or spawning against Orca: diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 03a9b2893e..4082f04699 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -108,6 +108,25 @@ OpenCode uses `.opencode/plugins/fm-primary-watch-arm.js`, which coordinates wit Pi and pi-signed use the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions the Pi engine auto-discovers once trusted. When changing any primary watcher adapter, update `docs/supervision-protocols/`, `docs/turnend-guard.md` if a shared idle or turn-end hook changed, and the relevant concise fact below. +## Host-root task integration + +When `FM_HOST_ROOT` is set, only the primary supervisor starts from that physical host root. +Ordinary ship and scout harnesses start from their isolated `FM_TARGET_WORKTREE`, so the target repository's instructions and lifecycle adapters load natively while the host context stays out of focused worker sessions. +FirstMate retains one task completion signal per harness and passes the host and target identities for supervision and recovery without changing the worker cwd: + +| Harness | Task completion signal in host-root mode | +|---|---| +| claude | A state-owned settings file passed with `--settings`; target project settings still load from the worker cwd. | +| codex | The existing per-launch `notify` command. | +| opencode | A state-owned task plugin named through `OPENCODE_CONFIG_CONTENT`; target project plugins load from the worker cwd. | +| pi | The existing explicit state-owned `-e` task extension; target project extensions remain subject to target trust. | +| grok | The guarded global FirstMate Stop hook reads a per-process `FM_GROK_TURNEND_TOKEN`. | +| kimi | The guarded global FirstMate Stop hook reads a per-process `FM_KIMI_TURNEND_TOKEN`. | + +Secondmate launches explicitly clear inherited `FM_HOST_ROOT` and `FM_TARGET_WORKTREE` and retain their isolated-home adapters. + +[`docs/verification/supervision.md`](../../../docs/verification/supervision.md#host-root-task-integration) owns the dated lifecycle evidence and current live-verification limits for these task adapters. + ## Launch profile axes `bin/fm-spawn.sh` accepts concrete `--harness`, `--model`, and `--effort` values chosen by firstmate at intake. @@ -301,8 +320,9 @@ Project trust dialog can appear on the first pi run in any not-yet-trusted direc Accept with Enter. The decision persists per path in `~/.pi/agent/trust.json`, so later spawns in the same worktree slot skip it. -`fm-spawn` keeps the turn-end extension in `state/`, outside the worktree, because project-local extension files make the trust gate strictly worse and pollute the project. -The extension must listen for pi's `turn_end` event, not `agent_end`, so the watcher wakes after each completed turn instead of only when the whole agent run exits. +`fm-spawn` keeps the completion extension in `state/`, outside the worktree, because project-local extension files make the trust gate strictly worse and pollute the project. +In host-root mode, the extension records semantic busy state at `agent_start`, then uses `agent_settled` with `ctx.isIdle()` to record idle before notifying the supervisor after Pi has no queued continuation. +Ordinary Pi workers retain their existing `turn_end` notification, while host-root workers keep it silent because it fires at inner response boundaries during one logical worker run. Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection env marker. **Primary-session guard fact (verified 2026-07-09, Pi 0.80.5).** @@ -335,7 +355,7 @@ The current tmux and Herdr adapters pass their captures and capability descripto See `docs/herdr-backend.md` "Composer and injection safety" for Herdr's current boundary and `tests/fm-backend-herdr.test.sh` for regression coverage. Startup dialog: the "Run Grok Build in a project directory?" project picker appears ONLY when grok is launched from a non-project directory (home, Desktop, Downloads, `/tmp`). -`fm-spawn` launches inside the treehouse worktree (a git repo root), so the picker never appears and grok treats the worktree as a trusted project automatically - no post-launch keystroke is needed. +Every ordinary `fm-spawn`, including host-root mode, launches inside the isolated target worktree, which must be a recognized project directory to avoid the picker. Pin `[hints] project_picker_disabled = true` in `~/.grok/config.toml` if a non-project launch ever needs to skip it. **TRUECOLOR placeholder styling: covered (task afk-herdr-false-pending, 2026-07-10).** @@ -354,11 +374,12 @@ Turn-end hook: grok fires a `Stop` hook at every turn boundary, giving firstmate grok loads PROJECT hooks (`/.grok/hooks/`, `/.claude/settings.local.json`) only after the folder is granted hook-trust in `~/.grok/trusted_folders.toml`, which is not automatic and which firstmate will not establish by editing grok's own managed trust store. GLOBAL hooks in `~/.grok/hooks/` are always trusted and load on first launch. So `fm-spawn` installs ONE firstmate-owned global hook, `~/.grok/hooks/fm-turn-end.json`, plus the companion `~/.grok/hooks/fm-turn-end.sh`, guarded as a no-op for every non-firstmate grok session. -Its `Stop` command fires only when the current workspace holds a `.fm-grok-turnend` token pointer that matches the firstmate-owned hook registry under `~/.grok/hooks/fm-turn-end.d/`. -`fm-spawn` writes that per-task pointer (`/.fm-grok-turnend`, gitignored via git info/exclude like the other harnesses' worktree hook files) and a matching registry entry naming this task's `state/.turn-ended`. -The hook reads `$GROK_WORKSPACE_ROOT`, which is always set for hooks and equals the worktree. +Its `Stop` command fires only when a launch-scoped `FM_GROK_TURNEND_TOKEN` or the current workspace's `.fm-grok-turnend` pointer matches the firstmate-owned hook registry under `~/.grok/hooks/fm-turn-end.d/`. +`fm-spawn` always writes the matching registry entry naming this task's `state/.turn-ended`. +Default launches write the gitignored pointer under the task worktree, while host-root launches pass the token in the worker environment and write no pointer into the host. +The default pointer path reads `$GROK_WORKSPACE_ROOT`, which Grok sets to the launch workspace. This keeps the hook outside the worktree, needs no trust grant, and writes only firstmate-owned files. -`fm-teardown` removes the worktree pointer before returning a pooled worktree. +`fm-teardown` removes any default-mode worktree pointer before returning a pooled worktree. Secondmate spawns skip the pointer (idle panes are healthy, no stale-pane detection for them). **Primary-session guard fact (verified 2026-07-28, Grok 0.2.112 and 0.2.73).** @@ -474,7 +495,8 @@ The delivery-only spinner match covers the full moon-phase glyph set rather than [`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns Kimi's verified global hook surface and captain-approved crew wake integration. `fm-spawn.sh` installs one marker-delimited Firstmate entry in `$HOME/.kimi-code/config.toml`, one silent always-zero hook script, and one private token registry under `$HOME/.kimi-code/fm-turn-end.d/`. -Each Kimi crew worktree receives a gitignored `.fm-kimi-turnend` token pointer, and the global hook touches that task's `state/.turn-ended` only when the Stop payload's `cwd`, pointer, and registry entry all agree. +Default Kimi crew worktrees receive a gitignored `.fm-kimi-turnend` token pointer, while host-root launches pass `FM_KIMI_TURNEND_TOKEN` in the worker environment and write no pointer into the host. +The global hook touches that task's `state/.turn-ended` only when the Stop payload has a cwd and the selected token resolves through the private registry. A guarded silent hook cannot be verified from absence of effect, so prove invocation with an unguarded probe before concluding that the hook did not fire. The guarded turn-end signal remains a wake notification; standalone Kimi has no busy-state source until one is live-verified. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index b878c6f765..c09be6a917 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -86,6 +86,7 @@ Release happens only on explicit retirement or seed rollback, never on routine r `bin/fm-home-seed.sh` copies the charter into the secondmate home as `data/charter.md`. It also writes the gitignored `.fm-secondmate-parent` durable binding before the required `.fm-secondmate-home` identity marker; the parser header in [`bin/fm-secondmate-parent-lib.sh`](../../../bin/fm-secondmate-parent-lib.sh) owns the record contract, and both files must remain in place. `bin/fm-spawn.sh --secondmate` launches it through the secondmate harness path, resolving `config/secondmate-harness` -> `config/crew-harness` -> the primary's own harness unless an explicit per-spawn harness override is passed. +Secondmates remain outside optional host-root mode: their launch prefix clears any inherited `FM_HOST_ROOT` and `FM_TARGET_WORKTREE`, their metadata has no `host_root=`, and their startup cwd remains the isolated FirstMate home. `config/secondmate-harness` may also pin a concrete model and effort for the secondmate agent, in the SAME file rather than a new one: the format is a single whitespace-separated line ` [] []`, with only the first non-empty, non-comment line parsed. A bare `` (today's format, e.g. `claude`) behaves exactly as before - harness only, no model/effort flag - so this is fully backward-compatible. @@ -205,17 +206,13 @@ Do not hand off `local-only` items. ## Recovery -For local `kind=secondmate` meta with no window, treat the secondmate as a dead persistent direct report and respawn it with: - -```sh -bin/fm-spawn.sh --secondmate -``` - -Use the recorded `home=` in meta. -If meta is missing but `data/secondmates.md` still registers the secondmate, respawn from the registry entry and its persistent home. -For a remote route, the same command probes and relaunches only on the configured host. -An SSH transport failure or unreadable remote endpoint remains unknown and must be reconciled on that host; never launch a local replacement. -`stuck-crewmate-recovery`'s remote-secondmate note owns why the endpoint-dead and send-failed verdicts that seem to justify this are themselves unreliable. +Retained `kind=secondmate` metadata remains recovery authority even when its window or endpoint field is missing. +Do not delete or rewrite that metadata, and do not invoke a direct same-id `fm-spawn`; direct spawn refuses retained metadata so an ambiguous or incomplete cleanup cannot create a duplicate secondmate. +The locked session-start liveness sweep owns relaunch after the recorded endpoint and verified harness produce a recovery-grade `dead` or `missing` result. +For a remote route, that sweep probes and relaunches only on the configured host; an SSH transport failure or unreadable endpoint remains unknown and must never launch a local replacement. +An absent endpoint field, ambiguous process, unreadable probe, or unverified harness is not relaunch authority; preserve the recorded `home=`, metadata, and endpoint evidence and report the exact blocker. +`stuck-crewmate-recovery`'s remote-secondmate note owns why endpoint-dead and send-failed verdicts can be unreliable. +If meta is missing but `data/secondmates.md` still registers the secondmate, respawn from the registry entry and its persistent on-disk home. Respawn re-resolves the secondmate harness from current config, uses the same guarded pre-launch sync, and re-propagates inherited local material, so recovered secondmates converge inherited config items and shared captain preferences whenever their home validates; tracked-file sync remains guarded separately. If the secondmate is already running and only inherited local material changed, prefer `bin/fm-config-push.sh` over respawning. To move a live LOCAL secondmate onto a newly pinned harness, model, or effort without a full recovery, set `config/secondmate-harness` and then relaunch it with `bin/fm-control.sh relaunch`, which re-resolves that pin, stops the agent, and launches the replacement in the same home ([`docs/agent-control.md`](../../../docs/agent-control.md)). diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index cf741b9d95..72a4ac4725 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -36,6 +36,7 @@ Do not sweep another home's endpoints or infer ownership from a matching window Before relaunch, prove that no live agent still owns the recorded task and that the existing worktree remains available. Preserve its uncommitted changes and commits, keep the same task identity, and resume or relaunch the recorded harness in that existing worktree with the same brief plus a concise progress note. +For a host-root task, relaunch the harness from the existing recorded worktree, pass it as `FM_TARGET_WORKTREE`, and retain the recorded physical `host_root=` only as the supervisor authority; [`docs/configuration.md`](../../../docs/configuration.md#host-root-mode-fm_host_root) owns that four-root contract. Do not use a fresh generic spawn while the recorded worktree is unaccounted for, because allocating another worktree can split one task across two copies. If the worktree or ownership cannot be reconciled safely, leave all state intact and report the task failed or blocked with the conflicting evidence. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..dfdb8b771c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5495ec4494..d4c6d58224 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -200,6 +200,11 @@ jobs: # worktree acquisition (presentation, workspace-per-home, autodetect). bin/fm-install-treehouse.sh "$RUNNER_TEMP/bin" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Install tasks-axi + run: | + set -eu + npm install -g tasks-axi + tasks-axi --version - name: Assert Herdr pin and protocol floor run: | set -eu diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index 62bb9e7284..105a8ef6f8 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -34,7 +34,7 @@ document: # security, Herdr, tmux, and lifecycle coverage). A full-suite override here # would duplicate CI and defeat the targeted Test contract. commands: - lint: 'bin/fm-lint.sh' + lint: 'bash bin/fm-lint.sh' # Store test evidence in this repo so it is committed alongside the change instead of kept in a temp dir. test: diff --git a/.opencode/plugins/fm-primary-cd-check.js b/.opencode/plugins/fm-primary-cd-check.js index b542b7585a..678e98fc72 100644 --- a/.opencode/plugins/fm-primary-cd-check.js +++ b/.opencode/plugins/fm-primary-cd-check.js @@ -40,6 +40,7 @@ async function resolveRoot(anchor) { } export const FmPrimaryCdCheck = async ({ directory, worktree }) => { + if (process.env.FM_TARGET_WORKTREE) return {}; const root = worktree ? (() => { try { return realpathSync(worktree); diff --git a/.opencode/plugins/fm-primary-pretool-check.js b/.opencode/plugins/fm-primary-pretool-check.js index eb0d6250d3..78714c139f 100644 --- a/.opencode/plugins/fm-primary-pretool-check.js +++ b/.opencode/plugins/fm-primary-pretool-check.js @@ -40,6 +40,7 @@ async function resolveRoot(anchor) { } export const FmPrimaryPretoolCheck = async ({ directory, worktree }) => { + if (process.env.FM_TARGET_WORKTREE) return {}; const root = worktree ? (() => { try { return realpathSync(worktree); diff --git a/.opencode/plugins/fm-primary-sessionstart-nudge.js b/.opencode/plugins/fm-primary-sessionstart-nudge.js index 5e8df0b4e9..7b596ee31b 100644 --- a/.opencode/plugins/fm-primary-sessionstart-nudge.js +++ b/.opencode/plugins/fm-primary-sessionstart-nudge.js @@ -33,6 +33,7 @@ async function resolveRoot(anchor) { } export const FmPrimarySessionstartNudge = async ({ client, directory, worktree }) => { + if (process.env.FM_TARGET_WORKTREE) return {}; const root = worktree ? resolvePath(worktree) : await resolveRoot(directory); return { diff --git a/.opencode/plugins/fm-primary-turnend-guard.js b/.opencode/plugins/fm-primary-turnend-guard.js index fb8f42eaf4..fea10aa2ac 100644 --- a/.opencode/plugins/fm-primary-turnend-guard.js +++ b/.opencode/plugins/fm-primary-turnend-guard.js @@ -55,6 +55,7 @@ async function letWatchArmRun(sessionID, client) { } export const FmPrimaryTurnendGuard = async ({ client, directory, worktree }) => { + if (process.env.FM_TARGET_WORKTREE) return {}; const root = worktree ? resolvePath(worktree) : await resolveRoot(directory); return { diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index e88c248f78..5039cfbacd 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -430,6 +430,7 @@ async function ensureArm(paths, sessionID, client, predecessorArmPid = "", inclu } export const FmPrimaryWatchArm = async ({ client, directory, worktree }) => { + if (process.env.FM_TARGET_WORKTREE) return {}; const root = worktree ? resolvePath(worktree) : await resolveRoot(directory); const paths = effectivePaths(root); globalThis[COORDINATOR_KEY] = { diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 923ec6c310..f888aa1d13 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -46,6 +46,19 @@ type WatchToolRenderContext = { isPartial: boolean; }; +type WakeQueueToken = { + seq: string; + kind: string; + key: string; +}; + +type DeferredWake = { + cycle: number; + message: string; + tokens: WakeQueueToken[] | null; + recovery?: { generation: string; watcherPid: string }; +}; + type SessionGeneration = { id: number; stopping: boolean; @@ -54,6 +67,8 @@ type SessionGeneration = { retryFailures: number; restoring: boolean; seq: number; + latestActionableCycle: number; + pendingWake: DeferredWake | null; }; function refreshWatchToolShell( @@ -194,6 +209,8 @@ function createGeneration(): SessionGeneration { retryFailures: 0, restoring: false, seq: 0, + latestActionableCycle: 0, + pendingWake: null, }; } @@ -209,6 +226,7 @@ function stopGeneration(generation: SessionGeneration): void { generation.stopping = true; if (generation.retryTimer) clearTimeout(generation.retryTimer); generation.retryTimer = null; + generation.pendingWake = null; if (generation.child) generation.child.kill("SIGTERM"); generation.child = null; } @@ -219,7 +237,10 @@ const cleanupOnProcessExit = () => { process.once("exit", cleanupOnProcessExit); export default function (pi: ExtensionAPI) { + if (process.env.FM_TARGET_WORKTREE) return; + let generation = createGeneration(); + let agentActive = false; activateGeneration(generation); let calmPresentation: CalmPresentationState = { @@ -238,6 +259,46 @@ export default function (pi: ExtensionAPI) { !calmPresentation.stockExportRendering && !calmTranscriptClassIsVisible(itemClass); + function wakeQueueRows(): string[][] | null { + const result = spawnSync( + "bash", + [ + "-lc", + '. "$FM_ROOT_OVERRIDE/bin/fm-wake-lib.sh"; trap \'fm_lock_release "$FM_WAKE_QUEUE_LOCK"\' EXIT; fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK"; [ ! -e "$FM_WAKE_QUEUE" ] || cat -- "$FM_WAKE_QUEUE"', + ], + { + cwd: fmRoot, + encoding: "utf8", + env: { ...process.env, FM_HOME: fmHome, FM_ROOT_OVERRIDE: fmRoot, FM_STATE_OVERRIDE: state }, + }, + ); + if (result.status !== 0 || typeof result.stdout !== "string") return null; + return result.stdout + .split(/\r?\n/) + .map((line: string) => line.split("\t")) + .filter((fields: string[]) => fields.length >= 5); + } + + function wakeQueueTokens(message: string): WakeQueueToken[] | null { + const rows = wakeQueueRows(); + if (rows === null) return null; + const tokens: WakeQueueToken[] = []; + for (const fields of rows) { + if (fields.slice(4).join("\t") === message) { + tokens.push({ seq: fields[1], kind: fields[2], key: fields[3] }); + } + } + return tokens; + } + + function deferredWakeIsQueued(wake: DeferredWake): boolean { + if (wake.tokens === null || wake.tokens.length === 0) return false; + const rows = wakeQueueRows(); + if (rows === null) return false; + const keys = new Set(rows.map((fields) => `${fields[1]}\t${fields[2]}\t${fields[3]}`)); + return wake.tokens.some((token) => keys.has(`${token.seq}\t${token.kind}\t${token.key}`)); + } + async function sendWake( owner: SessionGeneration, message: string, @@ -262,6 +323,28 @@ export default function (pi: ExtensionAPI) { } } + async function flushDeferredWake(owner: SessionGeneration): Promise { + const wake = owner.pendingWake; + owner.pendingWake = null; + if (!wake || !generationIsLive(owner) || wake.cycle !== owner.latestActionableCycle) return; + if (!deferredWakeIsQueued(wake)) return; + await sendWake(owner, wake.message, wake.recovery); + } + + async function deliverActionableWake( + owner: SessionGeneration, + wake: DeferredWake, + busyAtClose: boolean, + ): Promise { + if (!generationIsLive(owner) || wake.cycle !== owner.latestActionableCycle) return; + if (!busyAtClose && !agentActive) { + await sendWake(owner, wake.message, wake.recovery); + return; + } + owner.pendingWake = wake; + if (!agentActive) await flushDeferredWake(owner); + } + function surfaceFailure(owner: SessionGeneration, message: string): void { void sendWake(owner, message).catch(() => { // Pi owns delivery errors; continuity restoration never waits on prompting. @@ -448,6 +531,9 @@ export default function (pi: ExtensionAPI) { const classification = classifyClose(stdout, stderr, code, signal); const predecessor = String(armChild.pid ?? ""); if (classification.kind === "actionable") { + const busyAtClose = agentActive; + const tokens = wakeQueueTokens(classification.message); + owner.latestActionableCycle = id; owner.retryFailures = 0; owner.restoring = true; void (async () => { @@ -455,7 +541,11 @@ export default function (pi: ExtensionAPI) { if (generationIsLive(owner)) owner.restoring = false; if (!generationIsLive(owner)) return; const message = restoration.failure ? `${classification.message}\n\n${restoration.failure}` : classification.message; - await sendWake(owner, message, restoration.recovery); + await deliverActionableWake( + owner, + { cycle: id, message, tokens, recovery: restoration.recovery }, + busyAtClose, + ); })().catch(() => { }); return; @@ -479,12 +569,21 @@ export default function (pi: ExtensionAPI) { }; } + pi.on?.("agent_start", () => { + agentActive = true; + }); + pi.on?.("agent_settled", async () => { + agentActive = false; + await flushDeferredWake(generation); + }); pi.on?.("session_start", () => { if (generation.stopping) generation = createGeneration(); + agentActive = false; activateGeneration(generation); markLoaded(); }); pi.on?.("session_shutdown", () => { + agentActive = false; stopGeneration(generation); }); diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 1b2a3ec39a..054e19221d 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -197,6 +197,8 @@ function runCdCheck(command: string): Promise<{ code: number; stderr: string }> } export default function (pi: ExtensionAPI) { + if (process.env.FM_TARGET_WORKTREE) return; + pi.on?.("session_start", async (event, ctx) => { const reason = String((event as { reason?: unknown }).reason ?? ""); const source = reason === "startup" diff --git a/.pi/extensions/lib/fm-host-activator.ts b/.pi/extensions/lib/fm-host-activator.ts new file mode 100644 index 0000000000..febe46e319 --- /dev/null +++ b/.pi/extensions/lib/fm-host-activator.ts @@ -0,0 +1,150 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export type FirstmateHostConfig = { + fmRoot: string; + fmHome: string; + hostRoot: string; + backend: string; +}; + +type ExtensionFactory = (pi: ExtensionAPI) => void | Promise; + +const policyMarker = ""; + +function physicalPath(path: string): string | undefined { + try { + return realpathSync(path); + } catch { + return undefined; + } +} + +function refuse(pi: ExtensionAPI, reason: string): void { + const message = `FirstMate host activation refused: ${reason}`; + console.error(message); + pi.on("session_start", (_event, ctx) => { + ctx.ui.setStatus( + "firstmate-host", + ctx.ui.theme.fg("error", "FirstMate inactive"), + ); + ctx.ui.notify(message, "error"); + }); +} + +function validateConfig(config: FirstmateHostConfig): string | undefined { + if (physicalPath(config.fmRoot) !== config.fmRoot) + return `invalid FirstMate root ${config.fmRoot}`; + if (physicalPath(config.fmHome) !== config.fmHome) + return `invalid FirstMate home ${config.fmHome}`; + if (physicalPath(config.hostRoot) !== config.hostRoot) + return `invalid host root ${config.hostRoot}`; + if (config.fmRoot === config.hostRoot) + return "FirstMate root and host root must differ"; + if (!existsSync(resolve(config.hostRoot, "AGENTS.md"))) + return `host root has no AGENTS.md: ${config.hostRoot}`; + if (!existsSync(resolve(config.fmRoot, "AGENTS.md"))) + return `FirstMate root has no AGENTS.md: ${config.fmRoot}`; + + const result = spawnSync( + "bash", + [ + "-c", + '. "$1/bin/fm-host-root-lib.sh" && fm_host_root_assert_operational_roots "$3" "$1" "$4" && . "$1/bin/fm-backend.sh" && fm_backend_validate_spawn "$2"', + "fm-host-activator", + config.fmRoot, + config.backend, + config.hostRoot, + config.fmHome, + ], + { + encoding: "utf8", + env: { + ...process.env, + FM_ROOT_OVERRIDE: config.fmRoot, + FM_HOME: config.fmHome, + FM_HOST_ROOT: config.hostRoot, + FM_BACKEND: config.backend, + }, + }, + ); + if (result.status !== 0) + return result.stderr.trim() || `invalid backend ${config.backend}`; + return undefined; +} + +async function loadExtension(pi: ExtensionAPI, path: string): Promise { + const module = (await import(pathToFileURL(path).href)) as { + default?: ExtensionFactory; + }; + if (typeof module.default !== "function") + throw new Error(`extension has no default factory: ${path}`); + await module.default(pi); +} + +export default async function activateFirstmateHost( + pi: ExtensionAPI, + config: FirstmateHostConfig, +): Promise { + if ( + process.env.FM_TARGET_WORKTREE || + physicalPath(process.cwd()) !== config.hostRoot + ) + return; + + const invalid = validateConfig(config); + if (invalid) { + refuse(pi, invalid); + return; + } + + const expectedEnvironment: Record = { + FM_ROOT_OVERRIDE: config.fmRoot, + FM_HOME: config.fmHome, + FM_HOST_ROOT: config.hostRoot, + FM_BACKEND: config.backend, + }; + for (const [name, expected] of Object.entries(expectedEnvironment)) { + const ambient = process.env[name]; + if (ambient !== undefined && ambient !== "" && ambient !== expected) { + refuse(pi, `${name} is already set to a conflicting value`); + return; + } + } + Object.assign(process.env, expectedEnvironment); + + const supervisorPolicy = readFileSync( + resolve(config.fmRoot, "AGENTS.md"), + "utf8", + ).trimEnd(); + pi.on("resources_discover", () => ({ + skillPaths: [resolve(config.fmRoot, ".agents/skills")], + })); + pi.on("before_agent_start", (event) => { + if (event.systemPrompt.includes(policyMarker)) return; + return { + systemPrompt: `${event.systemPrompt}\n\n${policyMarker}\n# FirstMate host supervisor policy\n\nThe host context above remains authoritative for host identity, lifecycle, and cwd.\nApply the following FirstMate supervisor policy additively.\n\n${supervisorPolicy}`, + }; + }); + pi.on("session_start", (_event, ctx) => { + ctx.ui.setStatus( + "firstmate-host", + ctx.ui.theme.fg("accent", "FirstMate active"), + ); + }); + pi.on("session_shutdown", (_event, ctx) => { + ctx.ui.setStatus("firstmate-host", undefined); + }); + + await loadExtension( + pi, + resolve(config.fmRoot, ".pi/extensions/fm-primary-turnend-guard.ts"), + ); + await loadExtension( + pi, + resolve(config.fmRoot, ".pi/extensions/fm-primary-pi-watch.ts"), + ); +} diff --git a/AGENTS.md b/AGENTS.md index bd40813bf7..6fb3bf9652 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,8 @@ Never add an agent name as a commit co-author. `docs/configuration.md` is the single owner of the top-level operational-home layout and configuration schemas; each producing script's header and help own exact child fields and mutation mechanics. `FM_HOME` selects an instance's private `data/`, `state/`, `config/`, and `projects/`, while scripts continue to come from their tracked code root. -Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock. +Optional `FM_HOST_ROOT` keeps another repository authoritative for the primary supervisor's instructions, lifecycle, and cwd while ordinary workers launch from their isolated `FM_TARGET_WORKTREE`; `docs/configuration.md` owns the four-root contract. +Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock, and secondmate launches clear any inherited host-root variables. `bin/fm-send.sh` fails closed unless `FM_HOME` is explicit, so a steer cannot silently resolve against another home. Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate except under hard rule 1's concrete captain-approved project operation exception. @@ -93,7 +94,7 @@ state/ runtime records and signals; gitignored .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown .muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown .cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown - .meta task metadata; each producer script's header owns its exact fields and mutation contract, with docs/configuration.md routing operator-facing backend and trace-context details + .meta task metadata; ordinary host-root tasks record host_root=; each producer script's header owns its exact fields and mutation contract, with docs/configuration.md routing operator-facing backend and trace-context details .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Presentation spaces" .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified Relay shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check @@ -417,7 +418,7 @@ Harness-aware turn-end guards are structural backstops, not permission to omit t Invoke the `/afk` skill when the captain says `/afk`, says they are going afk, `state/.afk` exists, an incoming message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. The skill owns the daemon procedure; these safety facts remain inline: -- Every current daemon injection uses the `away-supervisor` kind from `bin/fm-operational-input.sh` after `FM_OPERATIONAL_PREFIX` (U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP: `), while the `/afk` skill owns legacy bare-marker compatibility. +- Every current daemon injection uses the `away-supervisor` kind from `bin/fm-operational-input.sh` after `FM_OPERATIONAL_PREFIX` (U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP:`), while the `/afk` skill owns legacy bare-marker compatibility. - While `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher. - A marked message while away mode is active is internal escalation and does not exit away mode. - A message beginning `/afk` refreshes away mode. diff --git a/README.md b/README.md index 92fab18637..43e86f54d2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@

firstmate

Platform --backend herdr` once, then run `pi` from that host. +The [host-root configuration](docs/configuration.md#host-root-mode-fm_host_root) covers status, uninstall, custom homes, trust, and the four-root contract. **Claude Code** diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 0d9791216a..3b2751db5d 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -350,8 +350,14 @@ fm_backend_cmux_surface_id_for_workspace() { # # workspace/surface/pane create all default focus to false) - no # focus-restore dance is needed, unlike zellij. Echoes " # " on success. +# Creation-state globals are consumed by fm-spawn after direct function calls. +# shellcheck disable=SC2034 fm_backend_cmux_create_task() { #

+ local dir=$1 fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/herdr" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-} ${2:-}" = "status --json" ]; then + printf '{"client":{"version":"0.7.1","protocol":14},"server":{"running":true}}\n' + exit 0 +fi +printf '%s\037' "$@" >> "$FM_HERDR_LOG"; printf '\n' >> "$FM_HERDR_LOG" +case "${1:-} ${2:-}" in + "pane get") + printf '{"result":{"pane":{"cwd":"C:\\\\Users\\\\captain\\\\frozen-shell"}}}\n' + ;; + "pane run") + printf '%s' "${4:-}" > "$FM_HERDR_CWD_COMMAND" + ;; + "pane read") + count=$(( $(cat "$FM_HERDR_CWD_READS" 2>/dev/null || echo 0) + 1 )) + printf '%s\n' "$count" > "$FM_HERDR_CWD_READS" + printf '%s\n' '__FM_HERDR_CWD_BEGIN__' 'C:\Users\captain\stale-worktree' '__FM_HERDR_CWD_END__' + if [ "${FM_HERDR_CWD_MODE:-fresh}" = fresh ] \ + && [ "$count" -ge "${FM_HERDR_CWD_FRESH_AFTER:-1}" ]; then + command=$(cat "$FM_HERDR_CWD_COMMAND") + begin=$(printf '%s\n' "$command" | sed -n 's/.*\(__FM_HERDR_CWD_BEGIN_[A-Za-z0-9_]*__\).*/\1/p') + end=$(printf '%s\n' "$command" | sed -n 's/.*\(__FM_HERDR_CWD_END_[A-Za-z0-9_]*__\).*/\1/p') + printf '%s\n' "$begin" 'C:\Users\captain\fresh-worktree' "$end" + fi + ;; +esac +SH + chmod +x "$fb/herdr" + printf '%s\n' "$fb" +} + test_current_path_reads_cwd() { - local dir log resp fb out - dir="$TMP_ROOT/cwd"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + local dir log resp fb out reads runs # Verified pitfall (herdr-verification-p2.md): .result.pane.cwd is frozen at # pane-creation time and never updates; .foreground_cwd tracks the live # running process (e.g. a treehouse get subshell) and is what must be read. + dir="$TMP_ROOT/cwd-posix"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" printf '{"result":{"pane":{"cwd":"/tmp/pane-creation-dir","foreground_cwd":"/tmp/fake-worktree"}}}\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_current_path default:w1:p2' "$ROOT" ) [ "$out" = "/tmp/fake-worktree" ] || fail "current_path should read foreground_cwd (the live process), not the frozen creation-time cwd, got '$out'" - assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''get'$'\x1f''w1:p2' "current_path did not call pane get" - pass "fm_backend_herdr_current_path: reads pane foreground_cwd (the live running process), not the frozen creation-time cwd" + assert_no_grep $'\x1f''pane'$'\x1f''run' "$log" "POSIX foreground_cwd unexpectedly launched a shell probe" + + # Native Windows omits foreground_cwd. Ignore plausible static-marker + # scrollback and poll until this invocation's nonce-bound pair appears. + dir="$TMP_ROOT/cwd-windows"; mkdir -p "$dir"; log="$dir/log"; : > "$log" + fb=$(make_herdr_cwd_probe_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_CWD_COMMAND="$dir/command" \ + FM_HERDR_CWD_READS="$dir/reads" FM_HERDR_CWD_FRESH_AFTER=3 \ + FM_BACKEND_HERDR_CWD_PROBE_ATTEMPTS=3 FM_BACKEND_HERDR_CWD_PROBE_DELAY=0 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_current_path default:w1:p2' "$ROOT" ) + [ "$out" = '/c/Users/captain/fresh-worktree' ] \ + || fail "current_path accepted stale scrollback or missed the fresh native Windows cwd, got '$out'" + reads=$(cat "$dir/reads") + [ "$reads" = 3 ] || fail "current_path should poll three times for its delayed marker pair, got $reads reads" + runs=$(grep -c $'pane\037run' "$log" || true) + [ "$runs" = 1 ] || fail "current_path should submit one PowerShell probe while polling, got $runs submissions" + assert_no_grep 'echo __FM_HERDR_CWD_BEGIN__;' "$log" "current_path reused the stale static marker" + + # A timeout containing only an old plausible block must return empty, then + # try the cmd.exe sibling with a distinct nonce rather than accepting it. + dir="$TMP_ROOT/cwd-timeout"; mkdir -p "$dir"; log="$dir/log"; : > "$log" + fb=$(make_herdr_cwd_probe_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_CWD_COMMAND="$dir/command" \ + FM_HERDR_CWD_READS="$dir/reads" FM_HERDR_CWD_MODE=timeout \ + FM_BACKEND_HERDR_CWD_PROBE_ATTEMPTS=2 FM_BACKEND_HERDR_CWD_PROBE_DELAY=0 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_current_path default:w1:p2' "$ROOT" ) + [ -z "$out" ] || fail "current_path trusted stale marker scrollback after timeout: '$out'" + reads=$(cat "$dir/reads") + [ "$reads" = 4 ] || fail "two bounded shell probes should perform four reads, got $reads" + runs=$(grep -c $'pane\037run' "$log" || true) + [ "$runs" = 2 ] || fail "current_path did not try both nonce-bound Windows shell probes" + [ "$(grep -o '__FM_HERDR_CWD_BEGIN_[A-Za-z0-9_]*__' "$log" | sort -u | wc -l | tr -d ' ')" = 2 ] \ + || fail "PowerShell and cmd.exe probes did not use distinct marker pairs" + + dir="$TMP_ROOT/cwd-frozen-posix"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"pane":{"cwd":"/tmp/frozen-posix-cwd"}}}\n' > "$resp/1.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_current_path default:w1:p2' "$ROOT" ) + [ -z "$out" ] || fail "current_path trusted a frozen POSIX cwd without foreground_cwd: '$out'" + pass "fm_backend_herdr_current_path: prefers foreground_cwd and polls nonce-bound native Windows probes" } # --- busy_state (semantic agent state) --------------------------------------- @@ -3762,7 +3871,11 @@ test_scripts_route_explicit_target_through_meta_backend() { dir="$TMP_ROOT/script-explicit-target"; state="$dir/state"; mkdir -p "$state" "$dir/responses" log="$dir/log"; resp="$dir/responses"; : > "$log" neutral="$dir/neutral-root"; mkdir -p "$neutral" - fm_write_meta "$state/herdr-stale.meta" "window=default:w1:p2" "backend=herdr" + fm_write_meta "$state/herdr-stale.meta" \ + "window=default:w1:p2" "endpoint_task_id=herdr-stale" \ + "worktree=$neutral" "project=$neutral" "backend=herdr" \ + "herdr_session=default" "herdr_workspace_id=w1" \ + "herdr_tab_id=w1:t2" "herdr_pane_id=w1:p2" touch "$state/.last-watcher-beat" printf 'captured herdr pane\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") @@ -4333,6 +4446,7 @@ test_launcher_identity_refuses_a_pane_from_another_server_socket test_launcher_identity_refuses_an_unreadable_pane test_launcher_identity_refuses_a_pane_and_tab_that_disagree test_launcher_identity_refuses_a_workspace_missing_from_the_session +test_task_binding_state_requires_exact_workspace_tab_and_label test_workspace_ensure_prefers_the_launcher_over_the_first_label_match test_workspace_ensure_refuses_an_ambiguous_label_with_no_launcher test_workspace_ensure_other_home_ignores_the_launcher_identity diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 4870a2e046..eaae49159e 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -23,6 +23,37 @@ next=$(( $(cat "$COUNT_FILE" 2>/dev/null || echo 0) + 1 )) for a in "$@"; do printf '\x1f%s' "$a"; done printf '\n' } >> "$LOG" +if [ "${1:-}" = terminal ] && [ "${2:-}" = close ]; then + [ -z "${FM_ORCA_CLOSE_MUTATION:-}" ] || printf 'late worker edit\n' > "$FM_ORCA_CLOSE_MUTATION" + [ -z "${FM_ORCA_CWD_PATH:-}" ] || : > "$RESP/.cwd-closed" +fi +if [ -n "${FM_ORCA_CWD_PATH:-}" ] && [ "${1:-}" = terminal ] && [ "${2:-}" = send ]; then + text= + while [ "$#" -gt 0 ]; do + [ "$1" != --text ] || { text=${2:-}; break; } + shift + done + case "$text" in + *'__FM_ORCA_CWD_BEGIN_'*) + printf '%s\n' "$text" | grep -o '__FM_ORCA_CWD_[A-Z]*_[A-Za-z0-9_]*__' > "$RESP/.cwd-markers" + printf '{"ok":true}\n' + exit 0 + ;; + esac +fi +if [ -n "${FM_ORCA_CWD_PATH:-}" ] && [ "${1:-}" = terminal ] && [ "${2:-}" = read ] && [ -f "$RESP/.cwd-markers" ]; then + if [ -e "$RESP/.cwd-closed" ]; then + printf '{"ok":false,"error":{"code":"terminal_not_found","message":"terminal closed"}}\n' + exit 0 + fi + begin=$(head -1 "$RESP/.cwd-markers") + end=$(tail -1 "$RESP/.cwd-markers") + python3 - "$begin" "$FM_ORCA_CWD_PATH" "$end" <<'PY' +import json, sys +print(json.dumps({"result": {"terminal": {"tail": sys.argv[1:]}}})) +PY + exit 0 +fi if [ "${1:-}" = status ] && [ "${FM_ORCA_STATUS_RESPONSE:-ready}" != sequence ]; then printf '{"ok":true,"result":{"runtime":{"reachable":true,"state":"ready"}}}\n' exit 0 @@ -65,12 +96,17 @@ add_tmux_fake() { #!/usr/bin/env bash set -u LOG="${FM_ORCA_LOG:?}" +stopped="${0}.stopped" { printf 'tmux' for a in "$@"; do printf '\x1f%s' "$a"; done printf '\n' } >> "$LOG" -exit 0 +case "${1:-}" in + kill-window) : > "$stopped" ;; + display-message) [ ! -e "$stopped" ] ;; + *) exit 0 ;; +esac SH chmod +x "$fb/tmux" } @@ -130,7 +166,54 @@ test_runtime_check_refuses_unready_orca_status() { status=$? [ "$status" -ne 0 ] || fail "runtime_check should fail when Orca runtime is not ready" assert_contains "$out" "requires a ready Orca runtime" "runtime_check should explain the readiness requirement" - pass "fm_backend_orca_runtime_check: fails closed when runtime is not ready" + pass "fm_backend_orca_runtime_check: fails closed when Orca runtime is not ready" +} + +test_current_path_probe_sends_once_and_bounds_reads() { + local out counts + out=$(FM_ORCA_CWD_PROBE_ATTEMPTS=3 FM_ORCA_CWD_PROBE_DELAY=0 bash -c ' + . "$1/bin/backends/orca.sh" + count_file=$2 + printf "0\n" > "$count_file" + sends=0 + fm_backend_orca_send_text_line() { + sends=$((sends + 1)) + markers=$(printf "%s\n" "$2" | grep -o "__FM_ORCA_CWD_[A-Z]*_[A-Za-z0-9_]*__") + begin=$(printf "%s\n" "$markers" | head -1) + end=$(printf "%s\n" "$markers" | tail -1) + } + fm_backend_orca_read_text_paged() { + reads=$(( $(cat "$count_file") + 1 )) + printf "%s\n" "$reads" > "$count_file" + [ "$reads" -lt 2 ] || printf "%s\n" "$begin" "/tmp/orca root with spaces" "$end" + } + probe_out=$(mktemp) + fm_backend_orca_current_path terminal-1 > "$probe_out" + printf "%s\n%s:%s\n" "$(cat "$probe_out")" "$sends" "$(cat "$count_file")" + rm -f "$probe_out" + ' _ "$ROOT" "$TMP_ROOT/current-path-count") + counts=$(printf '%s\n' "$out" | tail -1) + [ "$(printf '%s\n' "$out" | head -1)" = '/tmp/orca root with spaces' ] \ + || fail "current_path did not return the single physical path line" + [ "$counts" = '1:2' ] || fail "current_path should send once and stop after the matching read, got $counts" + + out=$(FM_ORCA_CWD_PROBE_ATTEMPTS=3 FM_ORCA_CWD_PROBE_DELAY=0 bash -c ' + . "$1/bin/backends/orca.sh" + count_file=$2 + printf "0\n" > "$count_file" + sends=0 + fm_backend_orca_send_text_line() { sends=$((sends + 1)); } + fm_backend_orca_read_text_paged() { + reads=$(( $(cat "$count_file") + 1 )) + printf "%s\n" "$reads" > "$count_file" + } + probe_out=$(mktemp) + fm_backend_orca_current_path terminal-2 > "$probe_out" + printf "%s:%s:%s\n" "$(cat "$probe_out")" "$sends" "$(cat "$count_file")" + rm -f "$probe_out" + ' _ "$ROOT" "$TMP_ROOT/current-path-empty-count") + [ "$out" = ':1:3' ] || fail "current_path probe was not bounded to one send and three reads, got $out" + pass "fm_backend_orca_current_path: one command, unique markers, bounded reads" } test_send_text_submit_verifies_empty_composer_after_enter() { @@ -433,7 +516,7 @@ test_worktree_and_terminal_helpers_parse_json() { pass "Orca lifecycle helpers: register repo, create worktree, create terminal, parse stable ids" } -test_worktree_create_removes_worktree_when_path_missing() { +test_worktree_create_defers_pathless_cleanup_to_verified_rollback() { local out status orca_case lifecycle-missing-path printf '1\n' > "$RESP/1.exit" @@ -445,14 +528,51 @@ test_worktree_create_removes_worktree_when_path_missing() { [ "$status" -ne 0 ] || fail "worktree helper should fail when Orca omits the worktree path" assert_contains "$out" "orca worktree create did not return a path for fm-task" \ "worktree helper did not explain the missing path" - assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close'$'\x1f''--terminal'$'\x1f''term-no-path'$'\x1f''--json' \ - "worktree helper did not close the implicit terminal when path parsing failed" - assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-no-path'$'\x1f''--force'$'\x1f''--json' \ - "worktree helper did not remove the pathless Orca worktree" - pass "fm_backend_orca_worktree_create: removes created worktree when path is missing" + assert_contains "$out" $'wt-no-path\t\tterm-no-path' \ + "worktree helper did not return recoverable worktree/terminal ids" + assert_no_grep $'orca\x1fterminal\x1fclose' "$LOG" \ + "pathless helper bypassed verified rollback by closing the terminal itself" + assert_no_grep $'orca\x1fworktree\x1frm' "$LOG" \ + "pathless helper removed a worktree before terminal absence was confirmed" + pass "fm_backend_orca_worktree_create: defers pathless cleanup to verified rollback" } -test_spawn_preserves_orca_metadata_when_pathless_worktree_cleanup_fails() { +test_spawn_preserves_pathless_orca_worktree_when_terminal_stop_is_unknown() { + local proj data state config id out status log_text + id="orcapathless-termz7" + proj="$TMP_ROOT/pathless-terminal-project" + data="$TMP_ROOT/pathless-terminal-data" + state="$TMP_ROOT/pathless-terminal-state" + config="$TMP_ROOT/pathless-terminal-config" + fm_git_init_commit "$proj" + mkdir -p "$data/$id" "$state" "$config" + printf 'brief\n' > "$data/$id/brief.md" + touch "$state/.last-watcher-beat" + orca_case pathless-terminal-unknown + printf '1\n' > "$RESP/1.exit" + printf '{"ok":true,"result":{"repo":{"id":"repo-pathless-terminal"}}}\n' > "$RESP/2.out" + printf '{"ok":true,"result":{"worktree":{"id":"wt-pathless-terminal"},"terminal":{"handle":"term-pathless-terminal"}}}\n' > "$RESP/3.out" + printf '{"ok":true,"result":{}}\n' > "$RESP/4.out" + printf 'not-json\n' > "$RESP/5.out" + out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 \ + FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ + FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ + "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --mode no-mistakes --yolo off --backend orca 2>&1 ) + status=$? + [ "$status" -ne 0 ] || fail "Orca spawn should fail when path parsing fails" + log_text=$(cat "$LOG") + assert_contains "$log_text" $'orca\x1f''terminal'$'\x1f''close'$'\x1f''--terminal'$'\x1f''term-pathless-terminal'$'\x1f''--json' \ + "verified rollback did not attempt to stop the pathless terminal" + assert_no_grep $'orca\x1fworktree\x1frm' "$LOG" \ + "unknown terminal state allowed destructive pathless worktree removal" + assert_present "$state/$id.meta" "unknown terminal state should preserve recovery metadata" + assert_grep 'orca_worktree_id=wt-pathless-terminal' "$state/$id.meta" "metadata missing pathless worktree id" + assert_grep 'terminal=term-pathless-terminal' "$state/$id.meta" "metadata missing pathless terminal id" + pass "fm-spawn.sh --backend orca: unknown terminal stop preserves pathless worktree and metadata" +} + +test_spawn_preserves_pathless_orca_worktree_without_terminal_proof() { local proj data state config id out status id="orcapathlessz6" proj="$TMP_ROOT/pathless-cleanup-project" @@ -467,8 +587,6 @@ test_spawn_preserves_orca_metadata_when_pathless_worktree_cleanup_fails() { printf '1\n' > "$RESP/1.exit" printf '{"ok":true,"result":{"repo":{"id":"repo-pathless-cleanup"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-pathless-cleanup"}}}\n' > "$RESP/3.out" - printf '{"ok":false,"error":{"code":"worktree_not_removed","message":"worktree not removed"}}\n' > "$RESP/4.out" - printf '{"ok":false,"error":{"code":"worktree_not_removed","message":"worktree not removed"}}\n' > "$RESP/5.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ @@ -477,35 +595,38 @@ test_spawn_preserves_orca_metadata_when_pathless_worktree_cleanup_fails() { [ "$status" -ne 0 ] || fail "Orca spawn should fail when path parsing and cleanup fail" assert_contains "$out" "orca worktree create did not return a path" \ "pathless worktree failure should explain the missing path" - assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-pathless-cleanup'$'\x1f''--force'$'\x1f''--json' \ - "pathless cleanup should attempt helper-backed worktree removal" - assert_present "$state/$id.meta" "failed pathless cleanup should preserve metadata" + assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \ + "pathless cleanup removed a worktree without terminal absence proof" + assert_present "$state/$id.meta" "pathless cleanup should preserve recovery metadata" assert_grep "window=fm-$id" "$state/$id.meta" "preserved pathless metadata missing stable window alias" assert_grep "backend=orca" "$state/$id.meta" "preserved pathless metadata missing backend=orca" assert_grep "orca_worktree_id=wt-pathless-cleanup" "$state/$id.meta" "preserved pathless metadata missing Orca worktree id" assert_no_grep "terminal=" "$state/$id.meta" "preserved pathless metadata should not invent a terminal handle" - pass "fm-spawn.sh --backend orca: preserves metadata when pathless cleanup fails" + pass "fm-spawn.sh --backend orca: preserves a pathless worktree without terminal absence proof" } test_spawn_writes_orca_metadata_and_launches_harness() { - local proj wt data state config id out log + local host proj wt data state config id out log id="orcaspawnz1" + host="$TMP_ROOT/spawn-host" proj="$TMP_ROOT/spawn-project" wt="$TMP_ROOT/spawn-wt" data="$TMP_ROOT/spawn-data" state="$TMP_ROOT/spawn-state" config="$TMP_ROOT/spawn-config" fm_git_worktree "$proj" "$wt" "fm/$id" - mkdir -p "$data/$id" "$state" "$config" - printf 'brief\n' > "$data/$id/brief.md" + mkdir -p "$host" "$TMP_ROOT/spawn-home" "$data/$id" "$state" "$config" + printf 'host instructions\n' > "$host/AGENTS.md" + printf '\nbrief\n' > "$data/$id/brief.md" touch "$state/.last-watcher-beat" orca_case spawn log="$LOG" printf '1\n' > "$RESP/1.exit" printf '{"ok":true,"result":{"repo":{"id":"repo-spawn"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-spawn","path":"%s"},"terminal":{"handle":"term-spawn"}}}\n' "$wt" > "$RESP/3.out" - out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ - FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ + out=$(cd "$host" && PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" FM_ORCA_CWD_PATH="$wt" \ + FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$TMP_ROOT/spawn-home" FM_HOST_ROOT="$host" \ + FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --mode no-mistakes --yolo off --backend orca 2>&1 ) expect_code 0 $? "fm-spawn.sh --backend orca should succeed with fake Orca"$'\n'"$out" @@ -516,14 +637,50 @@ test_spawn_writes_orca_metadata_and_launches_harness() { assert_grep "terminal=term-spawn" "$state/$id.meta" "meta missing terminal handle" assert_grep "orca_worktree_id=wt-spawn" "$state/$id.meta" "meta missing Orca worktree id" assert_grep "worktree=$wt" "$state/$id.meta" "meta missing Orca worktree path" + assert_grep "host_root=$host" "$state/$id.meta" "meta missing host supervisor authority" + assert_contains "$(cat "$log")" 'pwd -P' "host-root Orca spawn did not verify the terminal target cwd" assert_not_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''create' \ "spawn should reuse the implicit terminal returned by Orca worktree creation" assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f''export GOTMPDIR=/tmp/fm-orcaspawnz1/gotmp'$'\x1f''--enter'$'\x1f''--json' \ "spawn did not export GOTMPDIR through the Orca terminal" - assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions" \ + assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --settings" \ "spawn did not send the selected harness launch command through Orca" rm -rf "/tmp/fm-$id" - pass "fm-spawn.sh --backend orca: reuses implicit terminal, records metadata, launches harness" + pass "fm-spawn.sh --backend orca: verifies target cwd, records metadata, launches harness" +} + +test_host_root_spawn_refuses_wrong_orca_terminal_cwd() { + local host proj wt data state config id out status=0 + id="orcacwdz2" + host="$TMP_ROOT/cwd-host" + proj="$TMP_ROOT/cwd-project" + wt="$TMP_ROOT/cwd-wt" + data="$TMP_ROOT/cwd-data" + state="$TMP_ROOT/cwd-state" + config="$TMP_ROOT/cwd-config" + fm_git_worktree "$proj" "$wt" "fm/$id" + mkdir -p "$host" "$TMP_ROOT/cwd-home" "$data/$id" "$state" "$config" + printf 'host instructions\n' > "$host/AGENTS.md" + printf '\nbrief\n' > "$data/$id/brief.md" + touch "$state/.last-watcher-beat" + orca_case cwd-refusal + printf '1\n' > "$RESP/1.exit" + printf '{"ok":true,"result":{"repo":{"id":"repo-cwd"}}}\n' > "$RESP/2.out" + printf '{"ok":true,"result":{"worktree":{"id":"wt-cwd","path":"%s"},"terminal":{"handle":"term-cwd"}}}\n' "$wt" > "$RESP/3.out" + out=$(cd "$host" && PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" FM_ORCA_CWD_PATH="$host" \ + FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$TMP_ROOT/cwd-home" FM_HOST_ROOT="$host" \ + FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ + FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ + "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --mode no-mistakes --yolo off --backend orca 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "host-root Orca spawn accepted a terminal outside the target worktree" + assert_contains "$out" "did not enter FM_TARGET_WORKTREE $wt" \ + "wrong Orca terminal cwd refusal did not explain the target requirement" + assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ + "wrong Orca terminal cwd did not attempt to close the created endpoint" + assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \ + "wrong Orca terminal cwd recycled the worktree without confirmed endpoint absence" + assert_present "$state/$id.meta" "unconfirmed cleanup lost recovery metadata" + pass "fm-spawn.sh --backend orca: refuses a wrong target cwd and preserves uncertain cleanup" } test_spawn_refuses_orca_secondmate_before_home_mutation() { @@ -597,6 +754,9 @@ test_spawn_refuses_orca_nonisolated_worktree() { printf '1\n' > "$RESP/1.exit" printf '{"ok":true,"result":{"repo":{"id":"repo-bad"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-bad","path":"%s"},"terminal":{"handle":"term-bad"}}}\n' "$proj" > "$RESP/3.out" + printf '{"ok":true}\n' > "$RESP/4.out" + printf '{"ok":false,"error":{"code":"terminal_handle_stale","message":"terminal gone"}}\n' > "$RESP/5.out" + printf '{"ok":true}\n' > "$RESP/6.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ @@ -615,7 +775,7 @@ test_spawn_refuses_orca_nonisolated_worktree() { pass "fm-spawn.sh --backend orca: refuses non-isolated worktrees and closes implicit terminals" } -test_spawn_removes_orca_worktree_when_terminal_create_fails() { +test_spawn_preserves_orca_worktree_when_terminal_create_fails_without_handle() { local proj wt data state config id out status id="orcatermfailz8" proj="$TMP_ROOT/terminal-fail-project" @@ -638,17 +798,19 @@ test_spawn_removes_orca_worktree_when_terminal_create_fails() { "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --mode no-mistakes --yolo off --backend orca 2>&1 ) status=$? [ "$status" -ne 0 ] || fail "Orca spawn should fail when terminal creation fails" - assert_absent "$state/$id.meta" "terminal-create abort should not record metadata after successful cleanup" + assert_present "$state/$id.meta" "terminal-create failure should preserve recovery metadata without absence proof" + assert_grep "orca_worktree_id=wt-terminal-fail" "$state/$id.meta" "preserved metadata missing Orca worktree id" + assert_no_grep "terminal=" "$state/$id.meta" "terminal-create failure should not invent a terminal handle" assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''create'$'\x1f''--worktree'$'\x1f''id:wt-terminal-fail'$'\x1f''--title'$'\x1f'"fm-$id"$'\x1f''--json' \ "Orca spawn should attempt terminal creation before abort cleanup" - assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-terminal-fail'$'\x1f''--force'$'\x1f''--json' \ - "Orca spawn should remove the worktree when terminal creation fails" + assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \ + "Orca spawn removed a worktree after terminal creation may have succeeded without a handle" assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ "Orca spawn should not close a terminal when no handle was recorded" - pass "fm-spawn.sh --backend orca: removes worktree when terminal creation fails" + pass "fm-spawn.sh --backend orca: preserves worktree when terminal creation fails without a handle" } -test_spawn_preserves_orca_metadata_when_abort_cleanup_fails() { +test_spawn_preserves_orca_metadata_when_terminal_response_has_no_handle() { local proj wt data state config id out status id="orcacleanupleakz0" proj="$TMP_ROOT/cleanup-fail-project" @@ -664,22 +826,21 @@ test_spawn_preserves_orca_metadata_when_abort_cleanup_fails() { printf '1\n' > "$RESP/1.exit" printf '{"ok":true,"result":{"repo":{"id":"repo-cleanup-fail"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-cleanup-fail","path":"%s"}}}\n' "$wt" > "$RESP/3.out" - printf '1\n' > "$RESP/4.exit" - printf '1\n' > "$RESP/5.exit" + printf '{"ok":true,"result":{"terminal":{"title":"fm-%s"}}}\n' "$id" > "$RESP/4.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --mode no-mistakes --yolo off --backend orca 2>&1 ) status=$? - [ "$status" -ne 0 ] || fail "Orca spawn should fail when terminal creation and abort cleanup fail" - assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-cleanup-fail'$'\x1f''--force'$'\x1f''--json' \ - "Orca spawn should attempt helper cleanup before preserving metadata" - assert_present "$state/$id.meta" "failed Orca abort cleanup should preserve metadata" + [ "$status" -ne 0 ] || fail "Orca spawn should fail when terminal creation returns no handle" + assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \ + "Orca spawn removed a worktree after an unparseable terminal-create response" + assert_present "$state/$id.meta" "unparseable terminal-create response should preserve metadata" assert_grep "window=fm-$id" "$state/$id.meta" "preserved metadata missing stable window alias" assert_grep "backend=orca" "$state/$id.meta" "preserved metadata missing backend=orca" assert_grep "orca_worktree_id=wt-cleanup-fail" "$state/$id.meta" "preserved metadata missing Orca worktree id" assert_no_grep "terminal=" "$state/$id.meta" "preserved metadata should not invent a terminal handle" - pass "fm-spawn.sh --backend orca: preserves metadata when abort cleanup fails" + pass "fm-spawn.sh --backend orca: preserves metadata when terminal response has no handle" } test_spawn_releases_orca_resources_when_metadata_write_fails() { @@ -698,6 +859,9 @@ test_spawn_releases_orca_resources_when_metadata_write_fails() { printf '{"ok":true,"result":{"repo":{"id":"repo-meta-fail"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-meta-fail","path":"%s"}}}\n' "$wt" > "$RESP/3.out" printf '{"ok":true,"result":{"terminal":{"handle":"term-meta-fail"}}}\n' > "$RESP/4.out" + printf '{"ok":true}\n' > "$RESP/5.out" + printf '{"ok":false,"error":{"code":"terminal_handle_stale","message":"terminal gone"}}\n' > "$RESP/6.out" + printf '{"ok":true}\n' > "$RESP/7.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ @@ -1024,6 +1188,44 @@ test_ship_teardown_removes_orca_worktree_when_id_path_matches() { pass "fm-teardown.sh backend=orca: ship teardown requires a matching Orca id path" } +test_host_ship_teardown_rechecks_safety_after_terminal_stop() { + local host proj wt data state config id out rc neutral + id="orcashipracez9" + host="$TMP_ROOT/ship-race-host" + proj="$TMP_ROOT/ship-race-project" + wt="$TMP_ROOT/ship-race-wt" + data="$TMP_ROOT/ship-race-data" + state="$TMP_ROOT/ship-race-state" + config="$TMP_ROOT/ship-race-config" + fm_git_worktree "$proj" "$wt" "fm/$id" + mkdir -p "$host" "$data/$id" "$state" "$config" + : > "$host/AGENTS.md" + touch "$state/.last-watcher-beat" + fm_write_meta "$state/$id.meta" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-ship-race" "worktree=$wt" "project=$proj" \ + "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ + "backend=orca" "orca_worktree_id=wt-ship-race" "host_root=$host" + orca_case ship-race + printf '{"ok":true,"result":{"worktree":{"id":"wt-ship-race","path":"%s"}}}\n' "$wt" > "$RESP/1.out" + printf '{"ok":true}\n' > "$RESP/2.out" + printf '{"ok":false,"error":{"code":"terminal_handle_stale","message":"terminal gone"}}\n' > "$RESP/3.out" + neutral=$(neutral_fm_root "$CASE_DIR/neutral") + set +e + out=$( cd "$host" && PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + FM_ORCA_CLOSE_MUTATION="$wt/late-edit.txt" FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 \ + FM_ROOT_OVERRIDE="$neutral" FM_HOST_ROOT="$host" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ + "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "Orca ship teardown discarded an edit written during terminal stop (log: $(tr '\n' ';' < "$LOG"); status: $(git -C "$wt" status --porcelain 2>/dev/null | tr '\n' ';'))" + assert_contains "$out" "uncommitted changes" "post-stop safety refusal did not explain the late edit" + assert_present "$wt/late-edit.txt" "post-stop safety refusal lost the late worker edit" + assert_present "$state/$id.meta" "post-stop safety refusal removed task metadata" + assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \ + "post-stop safety refusal removed the Orca worktree" + pass "fm-teardown.sh host-root backend=orca: rechecks worktree safety after stopping the terminal" +} + test_ship_teardown_refuses_orca_unresolvable_worktree_id() { local proj wt data state config id out rc neutral id="orcashipunresolvedz1" @@ -1186,9 +1388,6 @@ test_secondmate_force_teardown_removes_orca_child_via_orca() { "backend=orca" "orca_worktree_id=wt-child-cleanup" orca_case secondmate-child-cleanup printf '{"ok":true,"result":{"worktree":{"id":"wt-child-cleanup","path":"%s"}}}\n' "$childwt" > "$RESP/1.out" - printf '{"ok":true,"result":{"worktree":{"id":"wt-child-cleanup","path":"%s"}}}\n' "$childwt" > "$RESP/2.out" - printf '{"ok":true,"result":{}}\n' > "$RESP/3.out" - printf '{"ok":true,"result":{}}\n' > "$RESP/4.out" add_tmux_fake "$FB" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e @@ -1302,6 +1501,7 @@ test_capture_falls_back_to_text_fields test_capture_fails_on_orca_error_json test_runtime_check_accepts_ready_orca_status test_runtime_check_refuses_unready_orca_status +test_current_path_probe_sends_once_and_bounds_reads test_send_text_submit_verifies_empty_composer_after_enter test_send_text_submit_borderless_claude_confirms test_composer_state_stale_banner_never_wins @@ -1322,14 +1522,16 @@ test_worktree_path_resolves_id test_dispatcher_sources_orca_and_routes_primitives test_json_get_ignores_undocumented_terminal_id_shapes test_worktree_and_terminal_helpers_parse_json -test_worktree_create_removes_worktree_when_path_missing -test_spawn_preserves_orca_metadata_when_pathless_worktree_cleanup_fails +test_worktree_create_defers_pathless_cleanup_to_verified_rollback +test_spawn_preserves_pathless_orca_worktree_when_terminal_stop_is_unknown +test_spawn_preserves_pathless_orca_worktree_without_terminal_proof test_spawn_writes_orca_metadata_and_launches_harness +test_host_root_spawn_refuses_wrong_orca_terminal_cwd test_spawn_refuses_orca_secondmate_before_home_mutation test_spawn_refuses_orca_when_runtime_not_ready test_spawn_refuses_orca_nonisolated_worktree -test_spawn_removes_orca_worktree_when_terminal_create_fails -test_spawn_preserves_orca_metadata_when_abort_cleanup_fails +test_spawn_preserves_orca_worktree_when_terminal_create_fails_without_handle +test_spawn_preserves_orca_metadata_when_terminal_response_has_no_handle test_spawn_releases_orca_resources_when_metadata_write_fails test_peek_send_and_crew_state_route_through_orca_meta test_peek_and_crew_state_fail_closed_on_orca_error_json @@ -1341,6 +1543,7 @@ test_teardown_preserves_metadata_when_orca_remove_error_json test_scout_teardown_refuses_orca_missing_report_when_path_missing test_ship_teardown_refuses_orca_missing_worktree_path test_ship_teardown_removes_orca_worktree_when_id_path_matches +test_host_ship_teardown_rechecks_safety_after_terminal_stop test_ship_teardown_refuses_orca_unresolvable_worktree_id test_ship_teardown_refuses_orca_id_path_mismatch test_teardown_refuses_orca_missing_worktree_id diff --git a/tests/fm-backend-tmux-smoke.test.sh b/tests/fm-backend-tmux-smoke.test.sh index aa1e07c326..adb5dd4531 100755 --- a/tests/fm-backend-tmux-smoke.test.sh +++ b/tests/fm-backend-tmux-smoke.test.sh @@ -75,6 +75,16 @@ pass "real tmux: fm_backend_tmux_create_task creates a window and refuses a dupl # --- send text + Enter ------------------------------------------------------- +# The operator's tmux default-command may be fish/zsh; make this Bash behavior +# test explicit instead of assuming the login shell understands Bash syntax. +tmux send-keys -t "$TARGET" "exec bash --noprofile --norc" Enter +for _ in $(seq 1 30); do + [ "$(tmux display-message -p -t "$TARGET" '#{pane_current_command}')" = bash ] && break + sleep 0.1 +done +[ "$(tmux display-message -p -t "$TARGET" '#{pane_current_command}')" = bash ] \ + || fail "real tmux: task window did not enter the explicit Bash test shell" + # A newly-created interactive shell can exist before its startup files and line # editor are ready to accept Enter. Prove command execution with an output token # that does not appear contiguously in the command, retrying the harmless probe diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index 4963b05131..284a3a5205 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -747,6 +747,37 @@ test_current_path_ignores_tilde_prefixed_banner_lines() { pass "fm_backend_zellij_current_path: never picks up a ~-prefixed banner line as the answer" } +test_target_state_requires_tab_absence() { + local dir fb out + dir="$TMP_ROOT/target-state-tab-present"; mkdir -p "$dir/responses" + printf '[]\n' > "$dir/responses/1.out" + zellij_tab_response "$dir" 2 3 other + fb=$(make_zellij_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" \ + FM_ZELLIJ_SESSION_LIST=firstmate \ + bash -c '. "$0/bin/backends/zellij.sh"; fm_backend_zellij_target_state firstmate:7 3 fm-zghost' "$ROOT") + [ "$out" = present ] || fail "target_state treated a surviving recorded tab as absent: '$out'" + + dir="$TMP_ROOT/target-state-label-present"; mkdir -p "$dir/responses" + printf '[]\n' > "$dir/responses/1.out" + zellij_tab_response "$dir" 2 4 fm-zghost + fb=$(make_zellij_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" \ + FM_ZELLIJ_SESSION_LIST=firstmate \ + bash -c '. "$0/bin/backends/zellij.sh"; fm_backend_zellij_target_state firstmate:7 3 fm-zghost' "$ROOT") + [ "$out" = present ] || fail "target_state treated a replacement task-labeled tab as absent: '$out'" + + dir="$TMP_ROOT/target-state-tab-absent"; mkdir -p "$dir/responses" + printf '[]\n' > "$dir/responses/1.out" + printf '[]\n' > "$dir/responses/2.out" + fb=$(make_zellij_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" \ + FM_ZELLIJ_SESSION_LIST=firstmate \ + bash -c '. "$0/bin/backends/zellij.sh"; fm_backend_zellij_target_state firstmate:7 3 fm-zghost' "$ROOT") + [ "$out" = absent ] || fail "target_state did not prove pane and tab absence: '$out'" + pass "fm_backend_zellij_target_state proves both recorded pane and tab absence" +} + test_kill_resolves_tab_and_closes_by_id() { local dir fb dir="$TMP_ROOT/kill"; mkdir -p "$dir/responses" @@ -764,6 +795,22 @@ test_kill_resolves_tab_and_closes_by_id() { pass "fm_backend_zellij_kill: resolves the owning tab id fresh and calls close-tab-by-id (never a bare close-pane)" } +test_kill_propagates_close_tab_failure() { + local dir fb status=0 + dir="$TMP_ROOT/kill-close-failure"; mkdir -p "$dir/responses" + zellij_pane_response "$dir" 1 7 3 + printf '1\n' > "$dir/responses/2.exit" + fb=$(make_zellij_fakebin "$dir") + PATH="$fb:$PATH" FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" \ + FM_ZELLIJ_SESSION_LIST=firstmate \ + bash -c '. "$0/bin/backends/zellij.sh"; fm_backend_zellij_kill firstmate:7' "$ROOT" \ + || status=$? + expect_code 1 "$status" "kill must propagate a close-tab-by-id failure" + assert_contains "$(cat "$dir/log")" $'\x1f''close-tab-by-id'$'\x1f''3' \ + "kill did not attempt the recorded tab close" + pass "fm_backend_zellij_kill propagates close-tab-by-id failures" +} + test_kill_falls_back_to_close_pane_when_tab_lookup_empty() { local dir fb dir="$TMP_ROOT/kill-fallback"; mkdir -p "$dir/responses" @@ -850,6 +897,7 @@ test_teardown_passes_recorded_tab_id_to_zellij_kill() { "decision_keys=" printf '[]\n' > "$dir/responses/1.out" printf '[{"tab_id":3,"name":"fm-zghost"}]\n' > "$dir/responses/2.out" + printf '[]\n' > "$dir/responses/4.out" fb=$(make_zellij_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" FM_ZELLIJ_SESSION_LIST="firstmate" \ @@ -1333,7 +1381,9 @@ test_expected_label_allows_matching_task_tab test_expected_label_rejects_reused_pane_id test_current_path_probes_with_marker_and_ignores_prompt_paths test_current_path_ignores_tilde_prefixed_banner_lines +test_target_state_requires_tab_absence test_kill_resolves_tab_and_closes_by_id +test_kill_propagates_close_tab_failure test_kill_falls_back_to_close_pane_when_tab_lookup_empty test_kill_closes_recorded_tab_when_pane_already_gone test_kill_skips_recorded_tab_when_label_mismatches diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index ece981b122..79daffd283 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -910,8 +910,13 @@ make_teardown_fakebin() { # -> echoes fakebin dir; logs tmux+treehouse ca cat > "$fb/tmux" <<'SH' #!/usr/bin/env bash set -u +stopped="${0}.stopped" { printf 'tmux'; for a in "$@"; do printf '\x1f%s' "$a"; done; printf '\n'; } >> "${FM_TMUX_LOG:?}" -exit 0 +case "${1:-}" in + kill-window) : > "$stopped" ;; + display-message) [ ! -e "$stopped" ] ;; + *) exit 0 ;; +esac SH cat > "$fb/treehouse" <<'SH' #!/usr/bin/env bash @@ -939,7 +944,7 @@ run_teardown_case() { } test_teardown_conformance_old_vs_new() { - local old_bin fb proj wt id old_tmux_ref saved_base_ref + local old_bin fb proj wt id old_tmux_ref saved_base_ref kill_line return_line old_kill_line old_return_line local state_old state_new config_old config_new data log_old log_new out_old out_new rc_old rc_new # Force the post-squash topology inside this case: merge-base with main may # equal HEAD on default-branch CI, and that must not make the legacy kill @@ -995,8 +1000,365 @@ test_teardown_conformance_old_vs_new() { "legacy teardown fixture did not exercise tmux window cleanup for the task" assert_contains "$(cat "$log_new")" "tmux"$'\x1f''kill-window'$'\x1f''-t'$'\x1f'"=firstmate:=fm-$id" \ "teardown did not call tmux kill-window with exact session and window selectors" + kill_line=$(grep -n $'^tmux\x1fkill-window\x1f' "$log_new" | head -1 | cut -d: -f1) + return_line=$(grep -n $'^treehouse\x1freturn\x1f' "$log_new" | head -1 | cut -d: -f1) + old_kill_line=$(grep -n $'^tmux\x1fkill-window\x1f' "$log_old" | head -1 | cut -d: -f1) + old_return_line=$(grep -n $'^treehouse\x1freturn\x1f' "$log_old" | head -1 | cut -d: -f1) + [ -n "$kill_line" ] && [ -n "$return_line" ] && [ "$return_line" -lt "$kill_line" ] \ + && [ -n "$old_kill_line" ] && [ -n "$old_return_line" ] && [ "$old_return_line" -lt "$old_kill_line" ] \ + || fail "unset teardown no longer returns the isolated copy before best-effort endpoint cleanup" + + pass "fm-teardown.sh preserves unset-mode cleanup ordering and exact endpoint selectors" +} - pass "fm-teardown.sh: treehouse return remains compatible while tmux cleanup uses exact selectors" +test_adapter_post_create_failure_records_ownership() { + local out counter="$TMP_ROOT/cmux-create-counter" + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + fm_tmux_cli() { + case "$1" in + list-windows) return 0 ;; + new-window) printf "@7\n" ;; + set-window-option) return 1 ;; + kill-window) printf killed ;; + esac + } + fm_backend_tmux_create_task firstmate fm-task /tmp marker >/dev/null 2>&1 + rc=$? + printf "%s|%s|%s" "$rc" "$FM_BACKEND_CREATE_OCCURRED" "$FM_BACKEND_CREATED_TARGET" + ' _ "$ROOT") + [ "$out" = "1|1|@7" ] || fail "tmux did not retain partial post-create ownership: '$out'" + + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source herdr + fm_backend_herdr_cli() { + case "$*" in + *"tab list"*) printf "%s" "{\"result\":{\"tabs\":[]}}" ;; + *"tab create"*) printf "%s" "{\"result\":{\"tab\":{\"tab_id\":\"tab-7\"}}}" ;; + esac + } + fm_backend_herdr_create_task session:ws fm-task /tmp "" >/dev/null 2>&1 + rc=$? + printf "%s|%s|%s|%s" "$rc" "$FM_BACKEND_CREATE_OCCURRED" "$FM_BACKEND_CREATED_HERDR_TAB_ID" "$FM_BACKEND_CREATED_TARGET" + ' _ "$ROOT") + [ "$out" = "1|1|tab-7|" ] || fail "herdr did not retain partial post-create ownership: '$out'" + + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source zellij + fm_backend_zellij_session_exists() { return 0; } + fm_backend_zellij_cli() { + case "$*" in + *"list-tabs"*) printf "%s" "[]" ;; + *"new-tab"*) printf "7\n" ;; + *"list-panes"*) printf "%s" "[]" ;; + esac + } + fm_backend_zellij_create_task firstmate fm-task /tmp >/dev/null 2>&1 + rc=$? + printf "%s|%s|%s|%s" "$rc" "$FM_BACKEND_CREATE_OCCURRED" "$FM_BACKEND_CREATED_ZELLIJ_TAB_ID" "$FM_BACKEND_CREATED_TARGET" + ' _ "$ROOT") + [ "$out" = "1|1|7|" ] || fail "zellij did not retain partial post-create ownership: '$out'" + + printf '0\n' > "$counter" + out=$(FM_CMUX_CREATE_COUNTER="$counter" bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source cmux + fm_backend_cmux_cli() { + case "$*" in + *"workspace list"*) + n=$(cat "$FM_CMUX_CREATE_COUNTER"); n=$((n + 1)); printf "%s" "$n" > "$FM_CMUX_CREATE_COUNTER" + if [ "$n" -eq 1 ]; then printf "%s" "{\"workspaces\":[]}"; else printf "%s" "{\"workspaces\":[{\"id\":\"ws-7\",\"title\":\"fm-task\"}]}"; fi + ;; + *"new-workspace"*) return 0 ;; + *"list-panes"*) printf "%s" "{\"panes\":[]}" ;; + esac + } + fm_backend_cmux_scoped_title() { printf fm-task; } + fm_backend_cmux_create_task fm-task /tmp >/dev/null 2>&1 + rc=$? + printf "%s|%s|%s|%s" "$rc" "$FM_BACKEND_CREATE_OCCURRED" "$FM_BACKEND_CREATED_CMUX_WORKSPACE_ID" "$FM_BACKEND_CREATED_TARGET" + ' _ "$ROOT") + [ "$out" = "1|1|ws-7|" ] || fail "cmux did not retain partial post-create ownership: '$out'" + pass "post-create adapter failures retain partial endpoint ownership for verified spawn rollback" +} + +test_adapter_target_state_matrices() { + local out + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + tmux() { + case "$CASE:${1:-}" in + present:list-panes) printf "%%7|@3|session:fm-task|session:3.0\n"; return 0 ;; + absent:list-panes) printf "%%1|@1|session:captain|session:1.0\n"; return 0 ;; + unknown:list-panes) printf "transport failure\n" >&2; return 1 ;; + esac + } + for CASE in present absent unknown; do fm_backend_tmux_target_state session:fm-task; printf " "; done + ' _ "$ROOT") + [ "$out" = "present absent unknown " ] || fail "tmux exact-inventory tri-state matrix drifted: '$out'" + + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source herdr + fm_backend_herdr_cli() { + case "$CASE" in + present) printf "%s" "{\"result\":{\"pane\":{\"pane_id\":\"w:p\"}}}" ;; + absent) printf "%s" "{\"error\":{\"code\":\"pane_not_found\"}}" ;; + unknown) printf "%s" "not-json" ;; + esac + } + for CASE in present absent unknown; do fm_backend_herdr_target_state session:w:p; printf " "; done + ' _ "$ROOT") + [ "$out" = "present absent unknown " ] || fail "herdr tri-state matrix drifted: '$out'" + + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source zellij + zellij() { + if [ "$CASE" = absent ]; then printf "No active zellij sessions found\n"; return 1; fi + printf "firstmate\n" + } + fm_backend_zellij_cli() { + case "$CASE" in + present) printf "%s" "[{\"id\":7,\"is_plugin\":false}]" ;; + unknown) printf "%s" "{\"malformed\":true}" ;; + esac + } + for CASE in present absent unknown; do fm_backend_zellij_target_state firstmate:7; printf " "; done + ' _ "$ROOT") + [ "$out" = "present absent unknown " ] || fail "zellij tri-state matrix drifted: '$out'" + + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source orca + fm_backend_orca_tool_check() { return 0; } + orca() { + case "$CASE" in + present) printf "%s" "{\"ok\":true,\"result\":{}}" ;; + absent) printf "%s" "{\"ok\":false,\"error\":{\"code\":\"terminal_handle_stale\"}}" ;; + unknown) printf "%s" "not-json" ;; + esac + } + for CASE in present absent unknown; do fm_backend_orca_target_state term-7; printf " "; done + ' _ "$ROOT") + [ "$out" = "present absent unknown " ] || fail "orca tri-state matrix drifted: '$out'" + + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source cmux + fm_backend_cmux_ping_state() { printf ok; } + fm_backend_cmux_cli() { + if [ "$1" = list-windows ]; then + case "$CASE" in + present|absent) printf "%s" "[{\"id\":\"window-1\"}]" ;; + unknown) printf "%s" "not-json" ;; + esac + else + case "$CASE" in + present) printf "%s" "{\"workspaces\":[{\"id\":\"ws-7\"}]}" ;; + absent) printf "%s" "{\"workspaces\":[]}" ;; + esac + fi + } + for CASE in present absent unknown; do fm_backend_cmux_target_state ws-7:surface-7; printf " "; done + ' _ "$ROOT") + [ "$out" = "present absent unknown " ] || fail "cmux tri-state matrix drifted: '$out'" + pass "all five backend adapters distinguish present, typed absent, and unreadable control planes" +} + +test_stop_and_verify_requires_confirmed_absence() { + local out status=0 + out=$(bash -c ' + . "$1/bin/fm-backend.sh" + fm_backend_kill() { printf called; } + fm_backend_stop_and_verify tmux "" + ' _ "$ROOT" 2>&1) || status=$? + expect_code 1 "$status" "a missing endpoint id must refuse destructive cleanup" + assert_contains "$out" 'missing backend endpoint id' "missing endpoint refusal did not explain the unsafe metadata" + assert_not_contains "$out" 'called' "missing endpoint refusal invoked a backend kill" + + status=0 + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + fm_backend_tmux_canonical_window() { return 2; } + fm_backend_kill() { printf called; } + fm_backend_stop_and_verify tmux session:renamed "" "" 1 marker /owner.sock + ' _ "$ROOT" 2>&1) || status=$? + expect_code 1 "$status" "an unresolved legacy tmux alias must not prove absence" + assert_contains "$out" 'could not be resolved to a stable tmux window' \ + "unresolved legacy tmux alias did not explain the refusal" + assert_not_contains "$out" 'called' "unresolved legacy tmux alias invoked a backend kill" + + status=0 + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + fm_backend_tmux_canonical_window() { return 2; } + fm_backend_kill() { printf called; } + fm_backend_stop_and_verify tmux session:renamed + ' _ "$ROOT" 2>&1) || status=$? + expect_code 0 "$status" "unset-mode teardown must preserve legacy missing-alias behavior" + [ -z "$out" ] || fail "unset-mode legacy missing alias emitted unexpected output: $out" + + status=0 + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + fm_backend_tmux_canonical_window() { return 2; } + fm_backend_kill() { printf called; } + fm_backend_stop_and_verify tmux @7 + ' _ "$ROOT" 2>&1) || status=$? + expect_code 0 "$status" "a missing immutable tmux window id should prove absence" + [ -z "$out" ] || fail "missing immutable tmux window id emitted unexpected output: $out" + + status=0 + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + fm_backend_kill() { printf called; } + fm_backend_stop_and_verify tmux @7 "" "" 1 + ' _ "$ROOT" 2>&1) || status=$? + expect_code 1 "$status" "host-root teardown must require a task-owned tmux marker" + assert_contains "$out" 'has no task-owned tmux window marker' "missing tmux marker refusal was not explicit" + assert_not_contains "$out" 'called' "missing tmux marker invoked a backend kill" + + status=0 + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + fm_backend_kill() { printf called; } + fm_backend_stop_and_verify tmux @7 "" "" 1 expected-task + ' _ "$ROOT" 2>&1) || status=$? + expect_code 1 "$status" "host-root teardown must require its creating tmux socket" + assert_contains "$out" 'has no creating tmux socket path' "missing tmux socket refusal was not explicit" + assert_not_contains "$out" 'called' "missing tmux socket invoked a backend kill" + + status=0 + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + tmux() { + printf "%%7|@7|session:task|session:task.0|session:7|session:7.0|other-task\n" + } + fm_backend_kill() { printf called; } + fm_backend_stop_and_verify tmux @7 "" "" 1 expected-task /owner.sock + ' _ "$ROOT" 2>&1) || status=$? + expect_code 1 "$status" "host-root teardown trusted a reused tmux window id" + assert_contains "$out" 'could not be resolved to a stable tmux window' "reused tmux id refusal was not explicit" + assert_not_contains "$out" 'called' "reused tmux id invoked a backend kill" + + status=0 + bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + tmux() { + printf "%s\n" \ + "%1|@1|session:duplicate|session:duplicate.0|session:1|session:1.0" \ + "%2|@2|session:duplicate|session:duplicate.0|session:2|session:2.0" + } + fm_backend_tmux_canonical_window session:duplicate >/dev/null + ' _ "$ROOT" || status=$? + expect_code 1 "$status" "duplicate tmux names must not resolve to an arbitrary window" + + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + tmux() { + case "$1" in + list-panes) printf "@7\n" ;; + display-message) printf "codex\n" ;; + esac + } + fm_backend_agent_state tmux @7 + ' _ "$ROOT") + [ "$out" = alive ] || fail "stable tmux window id agent state drifted: '$out'" + + out=$(bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + tmux() { + [ "${1:-}" != -S ] || shift 2 + case "$1" in + list-panes) printf "@7|other-task\n" ;; + display-message) printf "codex\n" ;; + esac + } + fm_backend_agent_state tmux @7 expected-task /owner.sock + ' _ "$ROOT") + [ "$out" = unreadable ] || fail "tmux agent state trusted a reused server-local window id: '$out'" + + local socket_dir="$TMP_ROOT/tmux-owner-socket" + mkdir -p "$socket_dir" + status=0 + out=$(FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + tmux() { + local socket=ambient + if [ "${1:-}" = -S ]; then + socket=$2 + shift 2 + fi + { printf "%s" "$socket"; for arg in "$@"; do printf "\037%s" "$arg"; done; printf "\n"; } >> "$FM_SOCKET_TEST_LOG" + case "$1" in + list-panes) + if [ "$socket" = /owner.sock ] && [ ! -e "$FM_SOCKET_TEST_STOPPED" ]; then + case "$*" in + *"#{pane_id}"*) printf "%%7|@7|session:task|session:task.0|session:7|session:7.0|expected-task\n" ;; + *) printf "@7|expected-task\n" ;; + esac + fi + ;; + kill-window) : > "$FM_SOCKET_TEST_STOPPED" ;; + esac + } + export FM_SOCKET_TEST_LOG=$2 FM_SOCKET_TEST_STOPPED=$3 + fm_backend_stop_and_verify tmux @7 "" "" 1 expected-task /owner.sock + ' _ "$ROOT" "$socket_dir/log" "$socket_dir/stopped" 2>&1) || status=$? + expect_code 0 "$status" "host-root teardown did not use the creating tmux socket: $out" + assert_present "$socket_dir/stopped" "creating-socket teardown left the owning tmux window alive" + assert_no_grep '^ambient' "$socket_dir/log" "creating-socket teardown consulted ambient tmux" + + status=0 + out=$(FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + fm_backend_tmux_canonical_window() { printf @1; } + fm_backend_kill() { return 0; } + fm_backend_target_state() { printf unknown; } + fm_backend_stop_and_verify tmux session:fm-task + ' _ "$ROOT" 2>&1) || status=$? + expect_code 1 "$status" "an unknown post-stop probe must refuse destructive cleanup" + assert_contains "$out" 'could not be confirmed absent' "unknown stop verification did not explain the refusal" + + status=0 + out=$(FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 bash -c ' + . "$1/bin/fm-backend.sh"; fm_backend_source tmux + fm_backend_tmux_canonical_window() { printf @1; } + fm_backend_kill() { return 0; } + fm_backend_target_state() { printf absent; } + fm_backend_stop_and_verify tmux session:fm-task + ' _ "$ROOT" 2>&1) || status=$? + expect_code 0 "$status" "a confirmed absent endpoint should permit cleanup" + [ -z "$out" ] || fail "confirmed absence emitted unexpected output: $out" + + status=0 + out=$(bash -c ' + . "$1/bin/fm-backend.sh" + fm_backend_source() { return 0; } + fm_backend_herdr_parse_target() { + FM_BACKEND_HERDR_SESSION=lab + FM_BACKEND_HERDR_PANE=w1:p2 + } + fm_backend_herdr_task_binding_state() { printf mismatch; } + fm_backend_kill() { printf called; } + fm_backend_stop_and_verify herdr lab:w1:p2 w1:t2 fm-task 0 "" "" w1 + ' _ "$ROOT" 2>&1) || status=$? + expect_code 1 "$status" "Herdr stop must reject a reused pane id" + assert_contains "$out" 'no longer matches its recorded workspace, tab, and task label' \ + "Herdr reused-pane refusal did not explain the ownership mismatch" + assert_not_contains "$out" called "Herdr reused-pane refusal invoked a backend kill" + + status=0 + out=$(bash -c ' + . "$1/bin/fm-backend.sh" + fm_backend_source() { return 0; } + fm_backend_herdr_parse_target() { + FM_BACKEND_HERDR_SESSION=lab + FM_BACKEND_HERDR_PANE=w1:p2 + } + fm_backend_herdr_task_binding_state() { printf dead; } + fm_backend_kill() { printf called; } + fm_backend_stop_and_verify herdr lab:w1:p2 w1:t2 fm-task 0 "" "" w1 + ' _ "$ROOT" 2>&1) || status=$? + expect_code 0 "$status" "an already-absent Herdr pane should be idempotent success" + [ -z "$out" ] || fail "an absent Herdr pane invoked cleanup: $out" + pass "fm_backend_stop_and_verify distinguishes confirmed absence and exact Herdr task ownership" } # --- backend selection loudly refuses an unknown backend -------------------- @@ -1113,6 +1475,31 @@ test_spawn_autodetect_nesting_resolves_tmux_silently() { pass "fm-spawn.sh: auto-detect resolves nested tmux-in-herdr to tmux and stays silent end to end" } +test_tmux_missing_socket_is_authoritatively_absent() { + local out status=0 + out=$(bash -c ' + . "$1/bin/fm-backend.sh" + fm_backend_source tmux + fm_tmux_cli() { + printf "error connecting to /gone.sock (No such file or directory)\n" >&2 + return 1 + } + fm_backend_tmux_canonical_window @1 >/dev/null + ' _ "$ROOT" 2>&1) || status=$? + expect_code 2 "$status" "a vanished recorded tmux socket must prove its window absent: $out" + out=$(bash -c ' + . "$1/bin/fm-backend.sh" + fm_backend_source tmux + fm_tmux_cli() { + printf "error connecting to /gone.sock (Connection refused)\n" >&2 + return 1 + } + fm_backend_tmux_target_state @1 + ' _ "$ROOT") + [ "$out" = absent ] || fail "a refused recorded tmux socket stayed unknown: $out" + pass "vanished recorded tmux sockets authoritatively prove endpoint absence" +} + test_backend_name_precedence test_backend_detect_precedence test_backend_detect_cmux_fallback_bundle_id @@ -1134,9 +1521,13 @@ test_send_tmux_contract test_peek_conformance_old_vs_new test_spawn_symlinked_project_prefix_avoids_false_refusal test_teardown_conformance_old_vs_new +test_adapter_post_create_failure_records_ownership +test_adapter_target_state_matrices +test_stop_and_verify_requires_confirmed_absence test_spawn_refuses_unknown_backend_flag test_spawn_refuses_codex_app_backend_flag test_spawn_refuses_unknown_fm_backend_env test_spawn_default_backend_writes_no_meta_field test_spawn_explicit_backend_flag_beats_autodetect_herdr_env test_spawn_autodetect_nesting_resolves_tmux_silently +test_tmux_missing_socket_is_authoritatively_absent diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index a348e2d345..0ed7daee6f 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -354,6 +354,29 @@ test_no_mistakes_dod_wording() { pass "fm-brief.sh: no-mistakes DOD keeps its apostrophe prose, now parse-safe" } +test_unset_and_empty_host_mode_match() { + local home="$TMP_ROOT/default-host-home" expected kind args + mkdir -p "$home/data" + for kind in ship scout; do + if [ "$kind" = ship ]; then + args=(--mode no-mistakes) + else + args=(--scout) + fi + env -u FM_HOST_ROOT FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + "$ROOT/bin/fm-brief.sh" byte-compat sample "${args[@]}" >/dev/null 2>&1 + expected="$TMP_ROOT/$kind-default-brief.md" + cp "$home/data/byte-compat/brief.md" "$expected" + rm -rf "$home/data/byte-compat" + FM_HOST_ROOT='' FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + "$ROOT/bin/fm-brief.sh" byte-compat sample "${args[@]}" >/dev/null 2>&1 + cmp -s "$expected" "$home/data/byte-compat/brief.md" \ + || fail "$kind brief differs between unset and explicitly empty host mode" + rm -rf "$home/data/byte-compat" + done + pass "fm-brief.sh: unset and empty host mode preserve the same default scaffold" +} + test_ship_project_memory_wording() { local home id brief home="$TMP_ROOT/project-memory-home" @@ -719,6 +742,7 @@ test_ship_mode_is_explicit_not_registry test_delivery_flags_are_refused_where_they_do_not_apply test_faster_paths_use_configured_authority_without_stacked_review test_no_mistakes_dod_wording +test_unset_and_empty_host_mode_match test_ship_project_memory_wording test_herdr_lab_contract_is_explicit_and_complete test_herdr_lab_contract_quotes_foreign_firstmate_path diff --git a/tests/fm-busy-adapter-wiring.test.sh b/tests/fm-busy-adapter-wiring.test.sh index 70f222010b..9cc6771870 100755 --- a/tests/fm-busy-adapter-wiring.test.sh +++ b/tests/fm-busy-adapter-wiring.test.sh @@ -21,16 +21,19 @@ TMP_ROOT=$(fm_test_tmproot fm-busy-adapter-wiring) make_spawn_fakebin() { local dir=$1 fakebin fakebin=$(fm_fakebin "$dir") - cat > "$fakebin/tmux" <<'SH' +cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +[ "${1:-}" != -S ] || shift 2 case "$*" in + *"#{socket_path}"*) printf '/tmp/fm-busy-adapter-wiring.tmux\n'; exit 0 ;; *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; - has-session|new-session|new-window|kill-window|send-keys) exit 0 ;; + new-window) printf '@1\n'; exit 0 ;; + has-session|new-session|set-window-option|kill-window|send-keys) exit 0 ;; esac exit 0 SH @@ -40,19 +43,21 @@ SH } make_spawn_case() { # - local name=$1 harness=$2 id=$3 case_dir home proj wt fakebin + local name=$1 harness=$2 id=$3 case_dir home host proj wt fakebin case_dir="$TMP_ROOT/$name" home="$case_dir/home" + host="$case_dir/host" proj="$case_dir/project" wt="$case_dir/wt" fakebin=$(make_spawn_fakebin "$case_dir/fake") - mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" + mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" "$host" + touch "$host/AGENTS.md" printf '%s\n' "$harness" > "$home/config/crew-harness" fm_git_worktree "$proj" "$wt" "wt-$name" touch "$home/state/.last-watcher-beat" mkdir -p "$home/data/$id" printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" - printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" + printf '%s\n' "$case_dir|$home|$host|$proj|$wt|$fakebin" } run_spawn() { # @@ -70,9 +75,16 @@ run_spawn() { # "$SPAWN" "$@" 2>&1 } +run_host_spawn() { # + local host=$1 home=$2 wt=$3 fakebin=$4 id=$5 + shift 4 + printf '%s\n' '' >> "$home/data/$id/brief.md" + (cd "$host" && FM_HOST_ROOT="$host" run_spawn "$home" "$wt" "$fakebin" "$@") +} + read_case_record() { # shellcheck disable=SC2034 # CASE_DIR is part of the shared record shape - IFS='|' read -r CASE_DIR HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR </dev/null + out=$(drive_pi_ext "$ext" settle-idle) || fail "host-root stale settle drive failed: $out" + [ ! -e "$state/$id.turn-ended" ] || fail "a rejected host-root idle write emitted a false notification" + out=$(classify pi "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "a stale host-root extension event must not change state, got '$out'" + pass "host-root pi extension notifies only after a successful semantic idle write" } test_pi_extension_serializes_settle_before_next_start() { @@ -343,6 +391,7 @@ test_kimi_and_grok_install_no_unverified_wiring() { } test_pi_extension_semantic_lifecycle +test_host_pi_extension_notifies_only_after_idle_write test_pi_extension_serializes_settle_before_next_start test_pi_extension_stale_incarnation_rejected test_kimi_and_grok_install_no_unverified_wiring diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 5284491ec9..739f8b4b2d 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -1617,7 +1617,7 @@ JS } test_operational_followup_turn_e2e() { - local project home config sessions version label case_name calm_state expected_notifications session_file pane i captain_line handled_line geometry_gap exact_session + local project home config sessions version label case_name calm_state expected_notifications session_file pane pane_file i captain_line handled_line geometry_gap exact_session if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then echo "skip: pi or tmux not found for Pi operational follow-up E2E" return 0 @@ -1789,7 +1789,7 @@ TS session_arg="--session '$session_arg'" fi - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \ + SHELL=/bin/bash tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions $extensions $session_arg; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" i=0 while [ "$i" -lt 120 ]; do @@ -1816,7 +1816,10 @@ TS fail "Pi follow-up $label case did not process the monitoring notification" fi - pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) + pane_file="$TMP_ROOT/followup-$label.txt" + wait_for_text "$pane_file" "MONITOR_HANDLED_${label}_ONE" \ + || fail "Pi follow-up $label case persisted but did not render the monitoring result" + pane=$(cat "$pane_file") [ "$(printf '%s\n' "$pane" | grep -Fc "CAPTAIN_ANSWER_$label" || true)" -eq 1 ] \ || fail "Pi follow-up $label case rendered a duplicate captain answer" assert_contains "$pane" "CAPTAIN_PROMPT_$label" "Pi follow-up $label case hid the genuine captain prompt" @@ -1917,7 +1920,7 @@ JS replay_exact_case() { tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true printf '%s\n' on >"$home/config/calm" - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \ + SHELL=/bin/bash tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./followup-e2e.ts --session '$exact_session'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" i=0 while [ "$i" -lt 120 ]; do @@ -2075,7 +2078,7 @@ TS start_geometry_pi() { local session_arg=$1 tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 100 -y 44 \ + SHELL=/bin/bash tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 100 -y 44 \ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' PI_OFFLINE=1 pi --approve --no-context-files --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./geometry-provider.ts $session_arg; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" } @@ -3319,7 +3322,7 @@ TS {"type":"message","id":"a0000016","parentId":"a0000015","timestamp":"$now","message":{"role":"assistant","content":[{"type":"text","text":"The deterministic tool example is complete."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":2,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":3,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":16}} JSON - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \ + SHELL=/bin/bash tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30" wait_for_text "$default_snapshot" "The deterministic tool example is complete." \ || fail "Pi calm E2E did not reach the restored session transcript" @@ -3358,6 +3361,7 @@ JSON if ! grep -Fq "Thinking..." "$hidden_snapshot" && ! grep -Fq "/calm" "$hidden_snapshot" && ! grep -Fq "I will run one command." "$hidden_snapshot" && + ! grep -Fq "fm_watch_arm_pi" "$hidden_snapshot" && grep -Fq "FIRSTMATE WATCHER WAKE: can you explain this phrase?" "$hidden_snapshot" && grep -Fq "The deterministic tool example is complete." "$hidden_snapshot"; then break @@ -3507,6 +3511,7 @@ JS tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/export $export_file" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s wait_for_text "$export_snapshot" "Session exported to: $export_file" \ + || [ -s "$export_file" ] \ || fail "/export did not complete while calm mode was on" node - "$export_file" <<'JS' || fail "calm-mode HTML export lost tool data or persisted synthetic provenance" const html = require("node:fs").readFileSync(process.argv[2], "utf8"); @@ -3922,7 +3927,7 @@ JS || fail "Pi did not exit cleanly before the Calm persistence restart" tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \ + SHELL=/bin/bash tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30" wait_for_text "$restarted_snapshot" "CALM_WORKING_E2E_RESPONSE" \ || fail "Pi did not restore the persisted session after restart" diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 9a7b4285ba..7880b59d1d 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -56,6 +56,12 @@ make_tmux_stub() { # #!/usr/bin/env bash set -u D=$FM_FAKE_DIR +socket=ambient +if [ "${1:-}" = -S ]; then + socket=$2 + shift 2 +fi +[ -z "${FM_FAKE_TMUX_SCOPE_LOG:-}" ] || printf '%s\t%s\n' "$socket" "$*" >> "$FM_FAKE_TMUX_SCOPE_LOG" case "${1:-}" in send-keys) shift @@ -111,6 +117,15 @@ case "${1:-}" in printf 'fakepane\n'; exit 0 ;; capture-pane) printf '╭────╮\n│ │\n╰────╯\n'; exit 0 ;; list-windows) [ -f "$D/windows" ] && cat "$D/windows"; exit 0 ;; + list-panes) + marker=$(cat "$D/marker" 2>/dev/null || true) + case "$*" in + *'#{pane_id}|#{window_id}'*) + printf '%%1|@1|fmses:fm-rlhost|fmses:fm-rlhost.0|fmses:1|fmses:1.0|%s\n' "$marker" + ;; + *) printf '@1|%s\n' "$marker" ;; + esac + exit 0 ;; esac exit 0 SH @@ -178,6 +193,7 @@ run_spawn() { # local dir=$1; shift env PATH="$dir/fakebin:$PATH" FM_HOME="$dir/home" FM_FAKE_DIR="$dir/fake" \ FM_SPAWN_NO_GUARD=1 GROK_HOME="$dir/grokhome" \ + FM_FAKE_TMUX_SCOPE_LOG="${FM_FAKE_TMUX_SCOPE_LOG:-}" \ "$SPAWN" "$@" 2>&1 } @@ -603,9 +619,12 @@ test_turnend_auth_paths_are_owned_by_the_control_adapter() { } test_secondmate_relaunch_picks_up_the_configured_harness_pin() { - local dir home out rc + local dir home host out rc dir=$(new_case smpin sm3) home="$dir/home" + host="$dir/ambient-host" + mkdir -p "$host" + printf '# host\n' > "$host/AGENTS.md" mkdir -p "$home/config" printf 'codex some-model high\n' > "$home/config/secondmate-harness" mkdir -p "$home/data/sm3" @@ -630,7 +649,7 @@ test_secondmate_relaunch_picks_up_the_configured_harness_pin() { printf '%s\n' "fm-sm3" > "$dir/fake/windows" printf '%s' "$dir/smhome" > "$dir/fake/cwd" printf 'codex' > "$dir/fake/becomes" - out=$(run_control "$dir" sm3 relaunch); rc=$? + out=$(cd "$host" && FM_HOST_ROOT="$host" FM_TARGET_WORKTREE=/ambient-target run_control "$dir" sm3 relaunch); rc=$? expect_code 0 "$rc" "a configured secondmate harness should relaunch"$'\n'"$out" [ "$(journal_field "$dir" sm3 to_harness)" = codex ] \ || fail "a secondmate relaunch should pick up the configured harness pin, got '$(journal_field "$dir" sm3 to_harness)'" @@ -638,8 +657,12 @@ test_secondmate_relaunch_picks_up_the_configured_harness_pin() { || fail "the configured model token should come with the pin" [ "$(journal_field "$dir" sm3 to_effort)" = high ] \ || fail "the configured effort token should come with the pin" + ! grep -q '^host_root=' "$home/state/sm3.meta" \ + || fail "a secondmate relaunch inherited ambient host ownership without a durable host_root" + assert_grep 'FM_HOST_ROOT= FM_TARGET_WORKTREE=' "$dir/fake/literal" \ + "a secondmate relaunch did not clear the host primary's ambient roots" assert_not_contains "$out" "not a verified harness" "codex is a verified harness" - pass "fm-control relaunch: a secondmate relaunch re-resolves its durable configured harness pin" + pass "fm-control relaunch: durable metadata overrides ambient host mode while the configured harness pin is re-resolved" } test_secondmate_relaunch_ignores_invalid_configured_effort_before_stop() { @@ -1262,6 +1285,63 @@ test_promotion_participates_in_the_lifecycle_lock_before_metadata_resolution() { # --- 6. fm-spawn --relaunch's own refusals ----------------------------------- +test_spawn_relaunch_adopts_recorded_host_root_and_tmux_identity() { + local dir host socket scope out rc key value wrong fake_root before after + dir=$(new_case host-root-relaunch rlhost) + add_ship_task "$dir" rlhost claude + host="$dir/host" + socket="$dir/recorded.sock" + scope="$dir/tmux-scope.log" + mkdir -p "$host" + printf '# Host instructions\n' > "$host/AGENTS.md" + sed -i.bak 's/^window=.*/window=@1/' "$dir/home/state/rlhost.meta" + rm -f "$dir/home/state/rlhost.meta.bak" + printf 'host_root=%s\ntmux_window_marker=owned-marker\ntmux_socket_path=%s\n' \ + "$host" "$socket" >> "$dir/home/state/rlhost.meta" + printf 'owned-marker' > "$dir/fake/marker" + printf 'zsh' > "$dir/fake/command" + printf '\n' >> "$dir/home/data/rlhost/brief.md" + : > "$scope" + + wrong="$dir/wrong-host" + fake_root="$dir/guard-root" + mkdir -p "$wrong" "$fake_root/bin" + cat > "$fake_root/bin/fm-guard.sh" <<'SH' +#!/usr/bin/env bash +: > "$FM_GUARD_MUTATION" +SH + chmod +x "$fake_root/bin/fm-guard.sh" + before=$(cd "$dir/home" && { find . -mindepth 1 -print | sort; find . -type f -exec cksum {} \; | sort; }) + out=$(cd "$wrong" && env -u FM_HOST_ROOT PATH="$dir/fakebin:$PATH" \ + FM_ROOT_OVERRIDE="$fake_root" FM_HOME="$dir/home" FM_FAKE_DIR="$dir/fake" \ + FM_GUARD_MUTATION="$dir/guard-ran" "$SPAWN" rlhost --relaunch --harness claude 2>&1); rc=$? + expect_code 2 "$rc" "wrong-cwd host-root relaunch should fail before mutation" + assert_contains "$out" 'requires the recorded host root cwd' \ + "wrong-cwd relaunch did not identify its recorded host authority" + assert_absent "$dir/guard-ran" "wrong-cwd relaunch ran the supervision guard before host validation" + after=$(cd "$dir/home" && { find . -mindepth 1 -print | sort; find . -type f -exec cksum {} \; | sort; }) + [ "$before" = "$after" ] || fail "wrong-cwd relaunch mutated the home before rejecting host authority" + + out=$(cd "$host" && env -u FM_HOST_ROOT PATH="$dir/fakebin:$PATH" \ + FM_HOME="$dir/home" FM_FAKE_DIR="$dir/fake" FM_SPAWN_NO_GUARD=1 \ + FM_FAKE_TMUX_SCOPE_LOG="$scope" GROK_HOME="$dir/grokhome" \ + "$SPAWN" rlhost --relaunch --harness claude 2>&1); rc=$? + expect_code 0 "$rc" "direct host-root relaunch should adopt its retained record"$'\n'"$out" + assert_contains "$out" "spawned rlhost" "host-root relaunch did not launch the replacement" + for key in host_root tmux_window_marker tmux_socket_path; do + value=$(meta_field "$dir" rlhost "$key") + case "$key" in + host_root) [ "$value" = "$host" ] || fail "host-root relaunch changed its recorded owner" ;; + tmux_window_marker) [ "$value" = owned-marker ] || fail "host-root relaunch changed its tmux marker" ;; + tmux_socket_path) [ "$value" = "$socket" ] || fail "host-root relaunch changed its tmux socket" ;; + esac + done + while IFS=$'\t' read -r value _; do + [ "$value" = "$socket" ] || fail "host-root relaunch escaped to tmux socket '$value'" + done < "$scope" + pass "fm-spawn relaunch: retained host-root tasks reuse their recorded owner, endpoint, marker, and socket" +} + test_spawn_relaunch_refuses_a_live_agent() { local dir out rc dir=$(new_case live rl15) @@ -1296,7 +1376,7 @@ test_spawn_relaunch_refuses_an_unrecorded_task() { add_ship_task "$dir" rl17 claude out=$(run_spawn "$dir" nosuchtask --relaunch); rc=$? expect_code 1 "$rc" "an unrecorded task should refuse" - assert_contains "$out" "needs an existing task record" "the refusal should name the missing record" + assert_contains "$out" "needs an existing regular task record" "the refusal should name the missing record" pass "fm-spawn --relaunch: an unrecorded task is refused" } @@ -1354,6 +1434,7 @@ test_secondmate_checkpoint_refuses_unreadable_child_state test_concurrent_relaunch_is_refused test_direct_spawn_relaunch_participates_in_the_lifecycle_lock test_promotion_participates_in_the_lifecycle_lock_before_metadata_resolution +test_spawn_relaunch_adopts_recorded_host_root_and_tmux_identity test_spawn_relaunch_refuses_a_live_agent test_spawn_relaunch_refuses_contradicting_flags test_spawn_relaunch_refuses_an_unrecorded_task diff --git a/tests/fm-control.test.sh b/tests/fm-control.test.sh index 0daef79c97..281feeadfe 100755 --- a/tests/fm-control.test.sh +++ b/tests/fm-control.test.sh @@ -79,6 +79,16 @@ make_tmux_stub() { # -> echoes fakebin dir #!/usr/bin/env bash set -u D=$FM_FAKE_DIR +socket=ambient +if [ "${1:-}" = -S ]; then + socket=$2 + shift 2 +fi +if [ -n "${FM_FAKE_RECORDED_SOCKET:-}" ] \ + && [ "$socket" != "$FM_FAKE_RECORDED_SOCKET" ]; then + D=$FM_FAKE_AMBIENT_DIR +fi +[ -z "${FM_FAKE_TMUX_SCOPE_LOG:-}" ] || printf '%s\t%s\n' "$socket" "$*" >> "$FM_FAKE_TMUX_SCOPE_LOG" case "${1:-}" in send-keys) shift @@ -130,6 +140,15 @@ case "${1:-}" in list-windows) if [ -f "$D/windows" ]; then cat "$D/windows"; fi exit 0 ;; + list-panes) + marker=$(cat "$D/marker" 2>/dev/null || true) + case "$*" in + *'#{pane_id}|#{window_id}'*) + printf '%%1|@1|fmses:fm-t1|fmses:fm-t1.0|fmses:1|fmses:1.0|%s\n' "$marker" + ;; + *) printf '@1|%s\n' "$marker" ;; + esac + exit 0 ;; esac exit 0 SH @@ -197,6 +216,9 @@ run_control() { FM_FAKE_MUSE_LOG="${FM_FAKE_MUSE_LOG:-}" \ FM_FAKE_MUSE_DISAPPEAR_BEFORE_ACK="${FM_FAKE_MUSE_DISAPPEAR_BEFORE_ACK:-}" \ FM_FAKE_INTERRUPT_STOPS_AGENT="${FM_FAKE_INTERRUPT_STOPS_AGENT:-}" \ + FM_FAKE_RECORDED_SOCKET="${FM_FAKE_RECORDED_SOCKET:-}" \ + FM_FAKE_AMBIENT_DIR="${FM_FAKE_AMBIENT_DIR:-}" \ + FM_FAKE_TMUX_SCOPE_LOG="${FM_FAKE_TMUX_SCOPE_LOG:-}" \ "$CONTROL" "$@" 2>&1 } @@ -827,6 +849,43 @@ test_grok_idle_footer_does_not_confirm_cancellation() { pass "fm-control interrupt: grok's idle footer does not confirm cancellation" } +test_host_root_control_uses_recorded_tmux_identity() { + local dir host ambient socket scope out rc line + dir=$(new_case host-root-control) + host="$dir/host" + ambient="$dir/ambient" + socket="$dir/recorded.sock" + scope="$dir/tmux-scope.log" + mkdir -p "$host" "$ambient" + printf '# Host instructions\n' > "$host/AGENTS.md" + add_task "$dir" t1 codex ship tmux @1 + printf 'host_root=%s\ntmux_window_marker=owned-marker\ntmux_socket_path=%s\n' \ + "$host" "$socket" >> "$dir/home/state/t1.meta" + printf 'owned-marker' > "$dir/fake/marker" + printf 'codex' > "$dir/fake/command" + : > "$scope" + : > "$ambient/literal" + : > "$ambient/keys" + printf 'codex' > "$ambient/command" + printf '%s' "$dir/wt-t1" > "$ambient/cwd" + printf 'foreign-marker' > "$ambient/marker" + + out=$(cd "$host" && env -u FM_HOST_ROOT PATH="$dir/fakebin:$PATH" \ + FM_HOME="$dir/home" FM_FAKE_DIR="$dir/fake" \ + FM_FAKE_RECORDED_SOCKET="$socket" FM_FAKE_AMBIENT_DIR="$ambient" \ + FM_FAKE_TMUX_SCOPE_LOG="$scope" FM_CONTROL_POLL=0.01 \ + FM_CONTROL_SETTLE_WAIT=0.05 "$CONTROL" t1 interrupt 2>&1); rc=$? + expect_code 0 "$rc" "host-root interrupt should use the recorded tmux server"$'\n'"$out" + [ "$(cat "$dir/fake/keys")" = Escape ] \ + || fail "the recorded endpoint did not receive the interrupt key" + [ ! -s "$ambient/keys" ] || fail "the ambient colliding endpoint received lifecycle input" + while IFS=$'\t' read -r line _; do + [ "$line" = "$socket" ] \ + || fail "a host-root tmux read or write escaped to socket '$line'" + done < "$scope" + pass "fm-control: host-root lifecycle reads and writes stay on the recorded tmux socket and marker" +} + # --- 6. marker non-regression ----------------------------------------------- test_secondmate_control_command_carries_no_marker() { @@ -903,5 +962,6 @@ test_exit_accepts_agent_stopped_by_busy_interrupt test_agent_that_does_not_stop_fails_closed test_grok_interrupt_without_acknowledgement_reports_unconfirmed test_grok_idle_footer_does_not_confirm_cancellation +test_host_root_control_uses_recorded_tmux_identity test_secondmate_control_command_carries_no_marker test_fm_send_still_marks_the_same_secondmate_task diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 2fe02fb431..26d495a17a 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -127,6 +127,40 @@ test_classify_check_and_unknown_escalate() { pass "check + unknown escalate; heartbeat self-handles" } +test_daemon_host_tmux_collision_uses_task_socket() { + local dir state fakebin log status=0 + dir=$(make_supercase daemon-host-tmux-collision) + state="$dir/state" + fakebin="$dir/fakebin" + log="$dir/tmux.log" + fm_write_meta "$state/one.meta" \ + 'window=@1' 'host_root=/host/one' 'tmux_socket_path=/one.sock' 'tmux_window_marker=one' + fm_write_meta "$state/two.meta" \ + 'window=@1' 'host_root=/host/two' 'tmux_socket_path=/two.sock' 'tmux_window_marker=two' + printf 'idle\n' > "$dir/pane" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +socket=ambient +if [ "${1:-}" = -S ]; then + socket=$2 + shift 2 +fi +printf '%s\n' "$socket" >> "$FM_SOCKET_LOG" +case "${1:-}" in + capture-pane) cat "$FM_FAKE_TMUX_CAPTURE" ;; + *) exit 1 ;; +esac +SH + chmod +x "$fakebin/tmux" + PATH="$fakebin:$PATH" FM_SOCKET_LOG="$log" FM_FAKE_TMUX_CAPTURE="$dir/pane" \ + stale_window_is_busy two "$state" || status=$? + expect_code 1 "$status" "idle task-scoped daemon probe returned an unexpected state" + assert_grep '/two.sock' "$log" "daemon did not probe the selected task's creating socket" + assert_no_grep '/one.sock' "$log" "daemon probed the other colliding task's socket" + assert_no_grep '^ambient$' "$log" "daemon probed ambient tmux for a colliding host task" + pass "daemon probes colliding host tmux windows through task-owned sockets" +} + test_stale_transient_self_records_marker() { local dir state out key dir=$(make_supercase stale-transient) @@ -1839,6 +1873,7 @@ test_daemon_state_root_uses_fm_home test_classify_routine_signal_self test_classify_terminal_signal_escalates test_classify_check_and_unknown_escalate +test_daemon_host_tmux_collision_uses_task_socket test_stale_transient_self_records_marker test_stale_diagnostic_wedge_survives_busy_housekeeping test_stale_terminal_escalates diff --git a/tests/fm-decision-hold-lifecycle.test.sh b/tests/fm-decision-hold-lifecycle.test.sh index 8326b43683..6dc2fa205e 100755 --- a/tests/fm-decision-hold-lifecycle.test.sh +++ b/tests/fm-decision-hold-lifecycle.test.sh @@ -15,6 +15,19 @@ TASKS_AXI_BIN=$(command -v tasks-axi || true) command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } command -v tasks-axi >/dev/null 2>&1 || { echo "skip: tasks-axi not found"; exit 0; } +write_stop_aware_tmux() { + cat > "$1/tmux" <<'SH' +#!/usr/bin/env bash +stopped="${0}.stopped" +case "${1:-}" in + kill-window) : > "$stopped" ;; + display-message) [ ! -e "$stopped" ] ;; + *) exit 0 ;; +esac +SH + chmod +x "$1/tmux" +} + make_home() { # local home="$TMP_ROOT/$1" fakebin mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" @@ -27,7 +40,8 @@ make_home() { # ## Done EOF fakebin=$(fm_fakebin "$home") - fm_fake_exit0 "$fakebin" tmux treehouse no-mistakes gh gh-axi + fm_fake_exit0 "$fakebin" treehouse no-mistakes gh gh-axi + write_stop_aware_tmux "$fakebin" printf '%s\n' "$home" } @@ -436,7 +450,8 @@ test_secondmate_hold_stays_in_authoritative_home() { ## Done EOF fakebin=$(fm_fakebin "$mate") - fm_fake_exit0 "$fakebin" tmux treehouse no-mistakes gh gh-axi + fm_fake_exit0 "$fakebin" treehouse no-mistakes gh gh-axi + write_stop_aware_tmux "$fakebin" origin=sample-mate-review mkdir -p "$mate/data/$origin" tasks_in "$mate" add "$origin" "Investigate secondmate sample" --kind scout --repo sample --start >/dev/null diff --git a/tests/fm-gate-refuse.test.sh b/tests/fm-gate-refuse.test.sh index 6f258ae751..c23f56de18 100755 --- a/tests/fm-gate-refuse.test.sh +++ b/tests/fm-gate-refuse.test.sh @@ -74,6 +74,7 @@ make_normal_repo() { GATE_WT=$(make_gate_worktree "$TMP/gate") NORMAL_CWD=$(make_normal_repo "$TMP/normal-cwd") +ln -s "$ROOT/bin" "$NORMAL_CWD/bin" # --- the shared helper, tested directly ------------------------------------- @@ -86,6 +87,7 @@ run_guard_lib() { ( cd "$cwd" || exit 111 unset NO_MISTAKES_GATE FM_GATE_REFUSE_BYPASS + # shellcheck disable=SC2030 # The marker is intentionally scoped to this probe subshell. case "$marker" in set) export NO_MISTAKES_GATE=1 ;; empty) export NO_MISTAKES_GATE= ;; @@ -131,6 +133,25 @@ test_helper_normal_is_noop() { pass "fm-gate-refuse-lib: no-op for a normal session (neither signal, set -eu clean)" } +test_helper_checks_physical_absolute_root_from_other_cwd() { + local root_link="$TMP/gate-root-link" out rc + ln -s "$GATE_WT" "$root_link" + out=$( + ( + cd "$NORMAL_CWD" || exit 111 + unset NO_MISTAKES_GATE FM_GATE_REFUSE_BYPASS + set -eu + # shellcheck source=bin/fm-gate-refuse-lib.sh + . "$GATE_LIB" + fm_refuse_if_gate_agent "$root_link" + ) 2>&1 + ) + rc=$? + expect_code 3 "$rc" "helper: an explicit gate-root anchor must refuse from a normal cwd" + assert_contains "$out" "$PATH_MSG" "helper: explicit physical root check did not identify the gate worktree" + pass "fm-gate-refuse-lib: checks the physical absolute FM_ROOT independently of cwd" +} + # --- fm-spawn --------------------------------------------------------------- # A fake tmux/treehouse so fm-spawn resolves the crew worktree from a controlled @@ -162,7 +183,7 @@ run_spawn() { mkdir -p "$home/data/$id" printf 'brief\n' > "$home/data/$id/brief.md" ( cd "$cwd" && env -u NO_MISTAKES_GATE -u FM_GATE_REFUSE_BYPASS \ - "FM_ROOT_OVERRIDE=" "FM_HOME=$home" \ + "FM_ROOT_OVERRIDE=$NORMAL_CWD" "FM_HOME=$home" \ "FM_STATE_OVERRIDE=$home/state" "FM_DATA_OVERRIDE=$home/data" \ "FM_PROJECTS_OVERRIDE=$home/projects" "FM_CONFIG_OVERRIDE=$home/config" \ "FM_SPAWN_NO_GUARD=1" "FM_FAKE_PANE_PATH=$pane" "TMUX=fake,1,0" \ @@ -283,13 +304,21 @@ test_send_refuses_and_admits() { # task (HEAD reachable from origin), so a normal teardown genuinely succeeds and a # refused one leaves the task untouched (mirrors tests/fm-teardown make_case). make_teardown_case() { - local name=$1 case_dir fakebin t + local name=$1 case_dir fakebin case_dir="$TMP/$name"; fakebin="$case_dir/fakebin" mkdir -p "$case_dir/state" "$case_dir/config" "$fakebin" - for t in treehouse tmux; do - printf '#!/usr/bin/env bash\nexit 0\n' > "$fakebin/$t" - chmod +x "$fakebin/$t" - done + printf '#!/usr/bin/env bash\nexit 0\n' > "$fakebin/treehouse" + chmod +x "$fakebin/treehouse" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +stopped="${0}.stopped" +case "${1:-}" in + kill-window) : > "$stopped" ;; + display-message) [ ! -e "$stopped" ] ;; + *) exit 0 ;; +esac +SH + chmod +x "$fakebin/tmux" cat > "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash case "${1:-} ${2:-}" in @@ -330,7 +359,7 @@ SH run_teardown() { local cwd=$1 case_dir=$2; shift 2 ( cd "$cwd" && env -u NO_MISTAKES_GATE -u FM_GATE_REFUSE_BYPASS \ - "FM_ROOT_OVERRIDE=$ROOT" "FM_STATE_OVERRIDE=$case_dir/state" \ + "FM_ROOT_OVERRIDE=$NORMAL_CWD" "FM_STATE_OVERRIDE=$case_dir/state" \ "FM_CONFIG_OVERRIDE=$case_dir/config" "PATH=$case_dir/fakebin:$PATH" "$@" \ "$TEARDOWN" task-x1 ) 2>&1 } @@ -366,6 +395,7 @@ test_helper_env_marker_refuses test_helper_empty_env_marker_refuses test_helper_path_backstop_refuses test_helper_normal_is_noop +test_helper_checks_physical_absolute_root_from_other_cwd test_spawn_refuses_and_admits test_send_refuses_and_admits test_teardown_refuses_and_admits diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index ecf41b933c..83876b3641 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -61,13 +61,13 @@ make_fake_root() { ln -s "$ROOT/bin/fm-nm-run-lib.sh" "$fake/bin/fm-nm-run-lib.sh" # fm-lock-lib.sh: teardown sources it for the shared lock-staleness proof. ln -s "$ROOT/bin/fm-lock-lib.sh" "$fake/bin/fm-lock-lib.sh" - # Lifecycle serialization, status presentation retirement, and shared adapter - # ownership are sourced by teardown. + # Lifecycle serialization, status presentation retirement, shared adapter + # ownership, gate refusal, and host-root binding are sourced by teardown. ln -s "$ROOT/bin/fm-control-lib.sh" "$fake/bin/fm-control-lib.sh" ln -s "$ROOT/bin/fm-classify-lib.sh" "$fake/bin/fm-classify-lib.sh" ln -s "$ROOT/bin/fm-wake-lib.sh" "$fake/bin/fm-wake-lib.sh" - # fm-gate-refuse-lib.sh: teardown sources it before any fleet mutation. ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" + ln -s "$ROOT/bin/fm-host-root-lib.sh" "$fake/bin/fm-host-root-lib.sh" # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" # fm-public-followup-lib.sh (and the fm-x-lib.sh it sources): teardown sources @@ -144,8 +144,8 @@ test_teardown_skips_gracefully_without_tasktmp() { ln -s "$ROOT/bin/fm-control-lib.sh" "$fake/bin/fm-control-lib.sh" ln -s "$ROOT/bin/fm-classify-lib.sh" "$fake/bin/fm-classify-lib.sh" ln -s "$ROOT/bin/fm-wake-lib.sh" "$fake/bin/fm-wake-lib.sh" - # fm-gate-refuse-lib.sh: teardown sources it before any fleet mutation. ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" + ln -s "$ROOT/bin/fm-host-root-lib.sh" "$fake/bin/fm-host-root-lib.sh" # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" # fm-public-followup-lib.sh (and the fm-x-lib.sh it sources): teardown sources diff --git a/tests/fm-grok-harness.test.sh b/tests/fm-grok-harness.test.sh index 957c0f1c77..bf53909244 100755 --- a/tests/fm-grok-harness.test.sh +++ b/tests/fm-grok-harness.test.sh @@ -15,13 +15,16 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +stopped="${0}.stopped" case "$*" in - *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; + *"#{pane_current_path}"*) [ ! -e "$stopped" ] && printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit $? ;; esac case "${1:-}" in - display-message) printf 'firstmate\n'; exit 0 ;; + display-message) [ ! -e "$stopped" ] && printf 'firstmate\n'; exit $? ;; list-windows) exit 0 ;; - has-session|new-session|new-window|send-keys|kill-window) exit 0 ;; + new-window) rm -f "$stopped"; exit 0 ;; + kill-window) : > "$stopped"; exit 0 ;; + has-session|new-session|send-keys) exit 0 ;; esac exit 0 SH diff --git a/tests/fm-host-root-mode.test.sh b/tests/fm-host-root-mode.test.sh new file mode 100644 index 0000000000..7fbd509485 --- /dev/null +++ b/tests/fm-host-root-mode.test.sh @@ -0,0 +1,1527 @@ +#!/usr/bin/env bash +# Focused behavior tests for the opt-in FM_HOST_ROOT four-root contract. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP=$(fm_test_tmproot fm-host-root-mode) +LIB="$ROOT/bin/fm-host-root-lib.sh" +fm_git_identity fmtest fmtest@example.invalid + +make_host() { + local path=$1 + fm_git_init_commit "$path" + : > "$path/AGENTS.md" + git -C "$path" add AGENTS.md + git -C "$path" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm instructions +} + +make_project_with_origin() { + local path=$1 + fm_git_init_commit "$path" + git clone -q --bare "$path" "$path.origin.git" + git -C "$path" remote add origin "$path.origin.git" +} + +node_path() { + case $(uname -s) in + MINGW*|MSYS*|CYGWIN*) cygpath -w "$1" ;; + *) printf '%s\n' "$1" ;; + esac +} + +resolve_host() { + FM_HOST_ROOT=$1 bash -c '. "$1"; fm_host_root_resolve "$2"' _ "$LIB" "$ROOT" +} + +paths_overlap() { + bash -c '. "$1"; fm_host_root_paths_overlap "$2" "$3"' _ "$LIB" "$1" "$2" +} + +test_resolution_and_validation() { + local host="$TMP/host with spaces and apostrophe's" link="$TMP/host-link" root="$TMP/firstmate-root" unsafe_target out status=0 + make_host "$host" + mkdir -p "$root" "$host/private-home" + ln -s "$host" "$link" + out=$(resolve_host "$link") || status=$? + expect_code 0 "$status" "symlinked host root should resolve" + [ "$out" = "$(cd "$host" && pwd -P)" ] || fail "host root did not resolve physically: $out" + + status=0 + FM_HOST_ROOT="$TMP/missing" bash -c '. "$1"; fm_host_root_resolve "$2"' _ "$LIB" "$ROOT" >/dev/null 2>&1 || status=$? + expect_code 2 "$status" "missing host root must fail" + status=0 + FM_HOST_ROOT="$ROOT" bash -c '. "$1"; fm_host_root_resolve "$2"' _ "$LIB" "$ROOT" >/dev/null 2>&1 || status=$? + expect_code 2 "$status" "host root equal to FM_ROOT must fail" + status=0 + FM_HOST_ROOT=$'bad\nroot' bash -c '. "$1"; fm_host_root_resolve "$2"' _ "$LIB" "$ROOT" >/dev/null 2>&1 || status=$? + expect_code 2 "$status" "newline-unsafe host root must fail" + status=0 + FM_HOST_ROOT=$'bad\aroot' bash -c '. "$1"; fm_host_root_resolve "$2"' _ "$LIB" "$ROOT" >/dev/null 2>&1 || status=$? + expect_code 2 "$status" "all metadata-unsafe control characters must fail" + unsafe_target="$TMP/resolved"$'\n'"host" + make_host "$unsafe_target" + ln -s "$unsafe_target" "$TMP/clean-host-link" + status=0 + FM_HOST_ROOT="$TMP/clean-host-link" bash -c '. "$1"; fm_host_root_resolve "$2"' _ "$LIB" "$ROOT" >/dev/null 2>&1 || status=$? + expect_code 2 "$status" "control characters introduced by physical resolution must fail" + mkdir -p "$TMP/claude-only-host" + : > "$TMP/claude-only-host/CLAUDE.md" + status=0 + FM_HOST_ROOT="$TMP/claude-only-host" bash -c '. "$1"; fm_host_root_resolve "$2"' _ "$LIB" "$ROOT" >/dev/null 2>&1 || status=$? + expect_code 2 "$status" "a Claude-only instruction surface must not admit non-Claude workers" + paths_overlap "$host" "$host" || fail "equal host and target roots were not classified as overlapping" + paths_overlap "$host" "$host/nested-target" || fail "target nested under host was not classified as overlapping" + paths_overlap "$host/nested-host" "$host" || fail "host nested under target was not classified as overlapping" + if paths_overlap "$host" "$TMP/host-with-similar-prefix"; then + fail "sibling paths with a shared prefix were classified as overlapping" + fi + paths_overlap / "$host" || fail "filesystem root was not classified as an ancestor" + paths_overlap "$host" / || fail "filesystem root was not classified as an enclosing target" + status=0 + out=$(bash -c '. "$1"; fm_host_root_assert_operational_roots "$2" "$3" "$4"' \ + _ "$LIB" "$host" "$root" "$host/private-home" 2>&1) || status=$? + expect_code 2 "$status" "host root overlapping FirstMate home must fail" + assert_contains "$out" 'must not overlap FM_HOME' "operational-root overlap refusal was unclear" + pass "host-root library resolves physical paths and rejects unsafe, harness-specific, or overlapping roots" +} + +test_ambiguous_host_owner_is_rejected() { + local host="$TMP/ambiguous-owner-host" other="$TMP/ambiguous-owner-other" meta="$TMP/ambiguous-owner.meta" owner="$TMP/ambiguous-owner" out status=0 + make_host "$host" + make_host "$other" + printf 'host_root=%s\nhost_root=%s\nkind=ship\n' "$host" "$other" > "$meta" + + out=$(cd "$other" && FM_HOST_ROOT="$other" bash -c \ + '. "$1"; fm_host_root_assert_task_cwd "$2" "$3"' _ "$LIB" "$ROOT" "$meta" 2>&1) || status=$? + expect_code 2 "$status" "duplicate host_root metadata must not grant task authority" + assert_contains "$out" 'ambiguous host_root ownership' "duplicate task authority refusal was unclear" + + status=0 + out=$(bash -c '. "$1"; fm_host_root_persist_task_owner "$2" "$3"' \ + _ "$LIB" "$meta" "$owner" 2>&1) || status=$? + expect_code 2 "$status" "duplicate host_root metadata must not persist owner authority" + assert_contains "$out" 'ambiguous host_root ownership' "duplicate owner persistence refusal was unclear" + assert_absent "$owner" "duplicate host_root metadata created an owner record" + pass "host-root authority rejects duplicate metadata before use or persistence" +} + +test_host_owner_publication_is_atomic() { + local dir="$TMP/owner-publication" meta owner out status=0 mode + mkdir -p "$dir/fakebin" + meta="$dir/task.meta" + owner="$dir/host-root" + printf 'host_root=%s\n' "$dir/host" > "$meta" + cat > "$dir/fakebin/mv" <<'SH' +#!/usr/bin/env bash +exit 1 +SH + chmod +x "$dir/fakebin/mv" + + out=$(PATH="$dir/fakebin:$PATH" bash -c \ + '. "$1"; fm_host_root_persist_task_owner "$2" "$3"' _ "$LIB" "$meta" "$owner" 2>&1) || status=$? + expect_code 2 "$status" "failed host-owner publication must be reported" + assert_contains "$out" 'could not persist host owner' "failed host-owner publication was not explicit" + assert_absent "$owner" "failed host-owner publication left a partial durable record" + [ -z "$(find "$dir" -name 'host-root.tmp.*' -print -quit)" ] \ + || fail "failed host-owner publication left its temporary file" + + bash -c '. "$1"; fm_host_root_persist_task_owner "$2" "$3"' _ "$LIB" "$meta" "$owner" \ + || fail "atomic host-owner publication failed" + assert_grep "host_root=$dir/host" "$owner" "atomic host-owner publication lost its value" + if [ "$(uname -s)" = Darwin ]; then + mode=$(stat -f '%Lp' "$owner") + else + mode=$(stat -c '%a' "$owner") + fi + [ "$mode" = 600 ] || fail "host-owner publication used mode $mode instead of 600" + pass "host-owner publication is atomic and leaves no partial durable record" +} + +test_session_cwd_mismatch_precedes_mutation() { + local host="$TMP/session-host" home="$TMP/session-home" overlap_home="$TMP/session-home-link" other="$TMP/session-other" fake_root before after out status=0 + make_host "$host"; mkdir -p "$home/state" "$home/data" "$home/config" "$other" + ln -s "$host" "$overlap_home" + before=$(find "$host" -mindepth 1 -maxdepth 3 -print | sort) + out=$(cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$overlap_home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-session-start.sh" 2>&1) || status=$? + expect_code 2 "$status" "session-start must reject a physical host and home overlap" + assert_contains "$out" 'must not overlap FM_HOME' "session-start host/home overlap refusal was unclear" + after=$(find "$host" -mindepth 1 -maxdepth 3 -print | sort) + [ "$before" = "$after" ] || fail "session-start mutated the host before rejecting overlapping FM_HOME" + + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-session-start.sh" 2>&1) || status=$? + expect_code 2 "$status" "session-start host cwd mismatch must fail" + assert_contains "$out" 'requires the supervisor cwd' "session-start mismatch did not explain the host cwd" + [ -z "$(find "$home/state" -mindepth 1 -print -quit)" ] || fail "session-start mismatch mutated state before refusal" + + fake_root="$TMP/guard-root" + mkdir -p "$fake_root/bin" + : > "$fake_root/AGENTS.md" + cat > "$fake_root/bin/fm-guard.sh" <<'SH' +#!/usr/bin/env bash +: > "$FM_GUARD_MUTATION" +SH + chmod +x "$fake_root/bin/fm-guard.sh" + status=0 + (cd "$other" && FM_GUARD_MUTATION="$TMP/guard-ran" FM_ROOT_OVERRIDE="$fake_root" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-spawn.sh" guarded "$TMP/missing-project" codex --mode no-mistakes --yolo off >/dev/null 2>&1) || status=$? + expect_code 2 "$status" "spawn host cwd mismatch must fail" + assert_absent "$TMP/guard-ran" "spawn ran the supervision guard before host validation" + + status=0 + (cd "$other" && FM_ROOT_OVERRIDE="$fake_root" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-brief.sh" guarded-mate --secondmate --no-projects >/dev/null 2>&1) || status=$? + expect_code 2 "$status" "secondmate brief host cwd mismatch must fail" + assert_absent "$home/data/guarded-mate" "secondmate brief mutated task data before host validation" + + status=0 + (cd "$other" && FM_GUARD_MUTATION="$TMP/guard-ran" FM_ROOT_OVERRIDE="$fake_root" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-spawn.sh" guarded-mate "$TMP/missing-home" codex --secondmate >/dev/null 2>&1) || status=$? + expect_code 2 "$status" "secondmate spawn host cwd mismatch must fail" + assert_absent "$TMP/guard-ran" "secondmate spawn ran the supervision guard before host validation" + pass "host cwd mismatch is rejected before session, brief, or spawn mutation" +} + +test_unset_session_cannot_take_over_host_owned_home() { + local host="$TMP/unset-session-host" other="$TMP/other-session-host" home="$TMP/unset-session-home" meta out status=0 before after + make_host "$host" + mkdir -p "$home/state" "$home/data" "$home/config" + meta="$home/state/host-owned.meta" + fm_write_meta "$meta" \ + "window=@1" "endpoint_task_id=host-owned" "worktree=$TMP/host-owned-worktree" \ + "project=$TMP/host-owned-project" "kind=ship" "host_root=$host" \ + "tmux_window_marker=host-owned-marker" "tmux_socket_path=/tmp/host-owned.sock" + before=$(find "$home" -mindepth 1 -print | sort) + + out=$(cd "$host" && FM_HOME="$home" FM_HOST_ROOT="$host" bash -c \ + '. "$1"; fm_host_root_assert_session_authority "$2" "$3"' _ "$LIB" "$ROOT" "$home/state" 2>&1) || status=$? + expect_code 0 "$status" "the recorded host owner should retain session authority" + [ -z "$out" ] || fail "matching host session authority emitted unexpected output: $out" + + out=$(cd "$ROOT" && env -u FM_HOST_ROOT FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + "$ROOT/bin/fm-session-start.sh" 2>&1) || status=$? + expect_code 2 "$status" "unset session must not take over a host-owned home" + assert_contains "$out" 'FM_HOST_ROOT is unset' "unset takeover refusal did not identify missing host authority" + assert_contains "$out" "$host" "unset takeover refusal omitted the recorded host root" + after=$(find "$home" -mindepth 1 -print | sort) + [ "$before" = "$after" ] || fail "unset session mutated a host-owned home before refusal" + assert_absent "$home/state/.lock" "unset session acquired the host-owned session lock" + + make_host "$other" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$other" \ + "$ROOT/bin/fm-session-start.sh" 2>&1) || status=$? + expect_code 2 "$status" "another host root must not take over a host-owned home" + assert_contains "$out" 'does not match task metadata' \ + "cross-host takeover refusal did not identify the recorded ownership mismatch" + after=$(find "$home" -mindepth 1 -print | sort) + [ "$before" = "$after" ] || fail "another host root mutated the host-owned home before refusal" + assert_absent "$home/state/.lock" "another host root acquired the host-owned session lock" + pass "unset or mismatched FM_HOST_ROOT cannot take over a home with host-owned tasks" +} + +test_host_command_rendering() { + local host="$TMP/render & host" home="$TMP/render-home" fake_root="$TMP/FirstMate & root's copy" rendered supervision command argv harness + make_host "$host"; mkdir -p "$home/state" "$home/config" "$fake_root/bin" + argv="$TMP/rendered-argv" + cat > "$fake_root/bin/argv-probe" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$FM_ARGV_LOG" +SH + cp "$fake_root/bin/argv-probe" "$fake_root/bin/fm-watch-checkpoint.sh" + chmod +x "$fake_root/bin/argv-probe" "$fake_root/bin/fm-watch-checkpoint.sh" + rendered=$(FM_HOST_ROOT="$host" bash -c '. "$1"; fm_host_root_command "$2" bin/argv-probe' _ "$LIB" "$fake_root") + FM_ARGV_LOG="$argv" bash -c "$rendered one 'two words' \"apostrophe's\"" + [ "$(cat "$argv")" = $'one\ntwo words\napostrophe\x27s' ] || fail "quoted host command did not preserve argv" + + supervision=$(FM_ROOT_OVERRIDE="$fake_root" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-supervision-instructions.sh" --harness codex --repair-line) + assert_contains "$supervision" "FirstMate & root'\\''s copy/bin'/fm-watch-checkpoint.sh" \ + "host supervision did not shell-quote its absolute command" + command=$(printf '%s\n' "$supervision" | sed -n 's/.*checkpoint: \(.*\) --seconds.*/\1/p') + FM_ARGV_LOG="$argv" bash -c "$command --seconds 7" + [ "$(cat "$argv")" = $'--seconds\n7' ] || fail "rendered supervision command did not preserve argv" + for harness in claude codex grok; do + supervision=$(FM_ROOT_OVERRIDE="$fake_root" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-supervision-instructions.sh" --harness "$harness") + assert_contains "$supervision" "FirstMate & root'\\''s copy/bin'/fm-" \ + "$harness ordinary-wake command did not use the absolute FirstMate path" + done + pass "host mode shell-quotes absolute commands and preserves argv" +} + +test_brief_variants() { + local host="$TMP/brief & host" home="$TMP/brief-home" brief scout normal_home="$TMP/normal-home" + make_host "$host" + mkdir -p "$home/data" "$normal_home/data" + (cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-brief.sh" lane-host alpha --mode no-mistakes >/dev/null 2>&1) + brief="$home/data/lane-host/brief.md" + assert_grep '' "$brief" "host brief marker missing" + assert_contains "$(cat "$brief")" "$host" "host brief corrupted the literal supervisor path" + assert_grep 'process starts inside the isolated target worktree' "$brief" "host brief does not preserve the target cwd" + assert_grep "target repository's root instructions" "$brief" "host brief does not require target instructions" + # shellcheck disable=SC2016 # Assertions intentionally match literal worker variables and Markdown code spans. + assert_contains "$(cat "$brief")" 'Do not read or modify `$FM_HOST_ROOT`' "host brief does not keep unrelated host context out" + # shellcheck disable=SC2016 + assert_grep 'Run `no-mistakes doctor`' "$brief" "host brief does not use target-native no-mistakes setup" + # shellcheck disable=SC2016 + assert_contains "$(cat "$brief")" '`no-mistakes axi run --help`' "host brief does not use target-native no-mistakes help" + # shellcheck disable=SC2016 + assert_contains "$(cat "$brief")" '`no-mistakes axi respond`' "host brief does not use target-native no-mistakes responses" + # shellcheck disable=SC2016 + assert_not_contains "$(cat "$brief")" '(cd "$FM_TARGET_WORKTREE"' "host brief still wraps target commands from the supervisor cwd" + + (cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-brief.sh" lane-host-scout alpha --scout >/dev/null 2>&1) + scout="$home/data/lane-host-scout/brief.md" + assert_contains "$(cat "$scout")" "(cd '$host' && '$ROOT/bin/fm-decision-hold.sh' complete 'lane-host-scout' --none)" \ + "host scout brief does not invoke the decision lifecycle through a host-scoped FirstMate command" + assert_grep 'subshell leaves your worker in the isolated target cwd' "$scout" \ + "host scout lifecycle guidance does not preserve the worker target cwd" + + FM_HOME="$normal_home" "$ROOT/bin/fm-brief.sh" lane-normal 'alpha & beta' --mode no-mistakes >/dev/null 2>&1 + assert_no_grep 'firstmate-execution-mode: host-root' "$normal_home/data/lane-normal/brief.md" "default brief changed execution mode" + assert_contains "$(cat "$normal_home/data/lane-normal/brief.md")" 'alpha & beta' "default brief corrupted the literal repository name" + assert_grep 'git checkout -b fm/lane-normal' "$normal_home/data/lane-normal/brief.md" "default brief lost its normal branch command" + pass "brief scaffolding has an explicit host variant and unchanged default variant" +} + +test_host_local_only_rejected_before_mutation() { + local host="$TMP/local-only-host" home="$TMP/local-only-home" project="$TMP/local-only-physical" alias="$TMP/local-only-alias" fake_root="$TMP/local-only-root" guard_marker="$TMP/local-only-guard" out status=0 + make_host "$host" + make_project_with_origin "$project" + ln -s "$project" "$alias" + mkdir -p "$home/data" "$home/state" "$home/config" "$fake_root/bin" + printf '%s\n%s\n' \ + "- $(basename "$alias") [local-only] - local target (added 2026-07-26)" \ + "- $(basename "$project") [no-mistakes] - physical target (added 2026-07-26)" > "$home/data/projects.md" + + out=$(cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-brief.sh" local-brief "$(basename "$alias")" --mode local-only 2>&1) || status=$? + expect_code 1 "$status" "host-root brief must reject local-only delivery" + assert_contains "$out" "host-root mode does not support local-only project" "host-root brief refusal was not explicit" + assert_absent "$home/data/local-brief" "host-root brief created task data before rejecting local-only delivery" + + cp "$ROOT/bin/fm-project-mode.sh" "$fake_root/bin/fm-project-mode.sh" + cat > "$fake_root/bin/fm-guard.sh" <<'SH' +#!/usr/bin/env bash +: > "$FM_GUARD_MUTATION" +SH + chmod +x "$fake_root/bin/fm-project-mode.sh" "$fake_root/bin/fm-guard.sh" + : > "$fake_root/AGENTS.md" + mkdir -p "$home/data/local-spawn" + printf '\n' > "$home/data/local-spawn/brief.md" + status=0 + out=$(cd "$host" && FM_GUARD_MUTATION="$guard_marker" FM_ROOT_OVERRIDE="$fake_root" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-spawn.sh" local-spawn "$alias" codex --mode local-only --yolo off 2>&1) || status=$? + expect_code 1 "$status" "host-root spawn must reject local-only delivery" + assert_contains "$out" "host-root mode does not support local-only project" "host-root spawn refusal was not explicit" + assert_absent "$guard_marker" "host-root spawn ran the fleet guard before rejecting local-only delivery" + assert_absent "$home/state/.spawn-local-spawn.lock" "host-root spawn acquired task state before rejecting local-only delivery" + assert_absent "$home/state/local-spawn.meta" "host-root spawn wrote task metadata before rejecting local-only delivery" + pass "host-root local-only tasks are rejected before brief or fleet mutation" +} + +make_fakebin() { + local dir=$1 fb + fb=$(fm_fakebin "$dir") + cat > "$fb/tmux" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\037' "$@" >> "$FM_TMUX_LOG"; printf '\n' >> "$FM_TMUX_LOG" +endpoint=${FM_ENDPOINT_ALIVE:-$FM_CURRENT_PATH.endpoint} +marker_file=${FM_TMUX_MARKER_FILE:-$endpoint.marker} +marker=$(cat "$marker_file" 2>/dev/null || true) +socket_path=${FM_TMUX_SOCKET_PATH:-$FM_CURRENT_PATH.tmux-socket} +if [ "${1:-}" = -S ]; then + shift 2 +fi +if [ "${1:-}" = send-keys ] && printf '%s\n' "$*" | grep -q -- ' -l '; then + case "${*: -1}" in + 'Read the brief at '*' and follow it exactly.') : ;; + *) printf '%s' "${*: -1}" > "$FM_LAUNCH_FILE" ;; + esac + : > "$FM_LAUNCH_FILE.literal" + [ "${FM_FAIL_LAUNCH_SEND:-0}" != 1 ] || exit 91 +fi +if [ "${1:-}" = send-keys ] && [ "${*: -1}" = Enter ] && [ -f "$FM_LAUNCH_FILE.literal" ]; then + [ "${FM_FAIL_LAUNCH_ENTER:-0}" != 1 ] || exit 92 +fi +case "${1:-}" in + display-message) + case "$*" in + *'#{socket_path}'*) printf '%s\n' "$socket_path" ;; + *'#{pane_current_path}'*) [ -f "$endpoint" ] && cat "$FM_CURRENT_PATH" ;; + *'#{cursor_y}'*) printf '0\n' ;; + *'#{window_id}'*) [ -f "$endpoint" ] && printf '@1\n' ;; + *'#{pane_id}'*) [ -f "$endpoint" ] && printf '%%1\n' ;; + *) printf 'test-session\n' ;; + esac + ;; + capture-pane) + if [ "${FM_FAKE_KIMI:-0}" = 1 ]; then + printf 'Welcome to Kimi Code!\n│ > │\ncontext: 1%%\n' + fi + ;; + list-windows) [ -z "${FM_EXISTING_WINDOW:-}" ] || printf '%s\n' "$FM_EXISTING_WINDOW" ;; + list-panes) + if [[ "$*" == *'#{window_id}|#{@firstmate_task_marker}'* ]] && [[ "$*" != *'#{pane_id}'* ]]; then + [ -f "$endpoint" ] && printf '@1|%s\n' "$marker" + elif [ "${FM_TMUX_NUMERIC_NAME_COLLISION:-0}" = 1 ]; then + printf '%%9|@9|test-session:1|test-session:1.0|test-session:0|test-session:0.0|other\n' + printf '%%1|@1|%s|%s|%s|%s|%s\n' \ + "${FM_ENDPOINT_TARGET:-test-session:fm-rollback-stuck}" \ + "${FM_ENDPOINT_ALIAS:-test-session:fm-rollback-stuck.0}" \ + "${FM_ENDPOINT_INDEX_ALIAS:-test-session:1}" \ + "${FM_ENDPOINT_INDEX_PANE_ALIAS:-test-session:1.0}" "$marker" + elif [ "${FM_TMUX_RENUMBER_ON_STOP:-0}" = 1 ]; then + case "$*" in + *'#{window_name}.#{pane_index}'*) + if [ -f "$endpoint" ]; then + printf '%%1|@1|%s|%s|test-session:1|test-session:1.0|%s\n' \ + "${FM_ENDPOINT_TARGET:-test-session:fm-rollback-stuck}" \ + "${FM_ENDPOINT_ALIAS:-test-session:fm-rollback-stuck.0}" "$marker" + printf '%%2|@2|test-session:survivor|test-session:survivor.0|test-session:2|test-session:2.0|other\n' + else + printf '%%2|@2|test-session:survivor|test-session:survivor.0|test-session:1|test-session:1.0|other\n' + fi + ;; + *) + if [ -f "$endpoint" ]; then + printf '%%1|@1|%s|test-session:1|test-session:1.0|%s\n' \ + "${FM_ENDPOINT_TARGET:-test-session:fm-rollback-stuck}" "$marker" + printf '%%2|@2|test-session:survivor|test-session:2|test-session:2.0|other\n' + else + printf '%%2|@2|test-session:survivor|test-session:1|test-session:1.0|other\n' + fi + ;; + esac + elif [ -f "$endpoint" ]; then + case "$*" in + *'#{window_name}.#{pane_index}'*) + printf '%%1|@1|%s|%s|%s|%s|%s\n' \ + "${FM_ENDPOINT_TARGET:-test-session:fm-rollback-stuck}" \ + "${FM_ENDPOINT_ALIAS:-test-session:fm-rollback-stuck.0}" \ + "${FM_ENDPOINT_INDEX_ALIAS:-test-session:1}" \ + "${FM_ENDPOINT_INDEX_PANE_ALIAS:-test-session:1.0}" "$marker" + ;; + *) + printf '%%1|@1|%s|%s|%s|%s\n' \ + "${FM_ENDPOINT_TARGET:-test-session:fm-rollback-stuck}" \ + "${FM_ENDPOINT_INDEX_ALIAS:-test-session:1}" \ + "${FM_ENDPOINT_INDEX_PANE_ALIAS:-test-session:1.0}" "$marker" + ;; + esac + fi + ;; + set-window-option) + if [ "${*: -2:1}" = @firstmate_task_marker ]; then + [ "${FM_FAIL_MARKER_SET:-0}" != 1 ] || exit 94 + printf '%s' "${*: -1}" > "$marker_file" + fi + ;; + has-session|new-session) ;; + new-window) : > "$endpoint"; printf '@1\n' ;; + kill-window) + [ -z "${FM_TMUX_KILL_MUTATION:-}" ] || printf 'late worker edit\n' > "$FM_TMUX_KILL_MUTATION" + [ "${FM_REFUSE_STOP:-0}" = 1 ] || rm -f "$endpoint" + ;; + send-keys) + case "$*" in + *'treehouse get'*) printf '%s\n' "$FM_TARGET_PATH" > "$FM_CURRENT_PATH" ;; + *'cd -- '* ) [ "${FM_REFUSE_HOST_MOVE:-0}" = 1 ] || printf '%s\n' "$FM_HOST_PATH" > "$FM_CURRENT_PATH" ;; + esac + ;; +esac +SH + chmod +x "$fb/tmux" + cat > "$fb/treehouse" <<'SH' +#!/usr/bin/env bash +set -u +[ -z "${FM_TREEHOUSE_LOG:-}" ] || printf '%s\n' "$*" >> "$FM_TREEHOUSE_LOG" +[ "${FM_REFUSE_RETURN:-0}" != 1 ] || exit 93 +exit 0 +SH + chmod +x "$fb/treehouse" + cat > "$fb/codex" <<'SH' +#!/usr/bin/env bash +set -u +[ -z "${FM_CODEX_ARGV:-}" ] || printf '%s\0' "$@" > "$FM_CODEX_ARGV" +printf 'cwd=%s\nhost=%s\ntarget=%s\n' "$(pwd -P)" "${FM_HOST_ROOT:-}" "${FM_TARGET_WORKTREE:-}" > "$FM_WORKER_OBS" +printf 'target edit\n' > "$FM_TARGET_WORKTREE/worker-edit.txt" +SH + chmod +x "$fb/codex" + cat > "$fb/kimi" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fb/kimi" + printf '%s\n' "$fb" +} + +test_spawn_separates_roots() { + local host="$TMP/spawn & host" home="$TMP/spawn home's \"quoted\" \\ & #%?" project="$TMP/target & repo" wt="$TMP/target & worktree" fb log current launch obs argv turnend meta marker marker_file socket out status=0 before after tree_line launch_line + make_host "$host" + mkdir -p "$home/data/lane" "$home/state" "$home/config" + make_project_with_origin "$project" + git -C "$project" worktree add -q --detach "$wt" + (cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-brief.sh" lane "$(basename "$project")" --mode no-mistakes >/dev/null 2>&1) + fb=$(make_fakebin "$TMP/fake") + log="$TMP/tmux.log"; current="$TMP/current"; launch="$TMP/launch"; obs="$TMP/worker-observation"; argv="$TMP/codex.argv" + printf '%s\n' "$project" > "$current" + before=$(git -C "$host" status --porcelain) + (cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$launch" FM_CURRENT_PATH="$current" FM_TARGET_PATH="$wt" FM_HOST_PATH="$host" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" lane "$project" codex --mode no-mistakes --yolo off > "$TMP/spawn.out" 2>&1) \ + || fail "host-root spawn failed: $(cat "$TMP/spawn.out")" + meta="$home/state/lane.meta" + assert_grep "worktree=$wt" "$meta" "spawn meta lost target worktree" + assert_grep "host_root=$host" "$meta" "spawn meta lost host root" + assert_grep 'window=@1' "$meta" "host-root spawn did not persist the immutable tmux window id" + marker=$(sed -n 's/^tmux_window_marker=//p' "$meta") + socket=$(sed -n 's/^tmux_socket_path=//p' "$meta") + marker_file="$current.endpoint.marker" + [ -n "$marker" ] || fail "host-root spawn did not persist a task-owned tmux marker" + [ "$socket" = "$current.tmux-socket" ] || fail "host-root spawn did not persist its creating tmux socket" + [ "$(cat "$marker_file")" = "$marker" ] || fail "host-root spawn did not bind the live tmux window to its recorded marker" + assert_grep $'-S\037'"$socket" "$log" "host-root spawn did not keep task operations on the creating tmux socket" + assert_contains "$(cat "$log")" "FM_TARGET_WORKTREE='$wt'" "child launch did not export exact target worktree" + assert_contains "$(cat "$log")" "FM_HOST_ROOT='$host'" "child launch did not export exact host root" + assert_contains "$(cat "$log")" 'notify=[' "Codex FirstMate turn-end safeguard was not retained" + [ "$(cat "$current")" = "$wt" ] || fail "endpoint did not remain at the isolated target worktree" + tree_line=$(grep -nF 'treehouse get' "$log" | head -1 | cut -d: -f1) + launch_line=$(grep -nF 'FM_TARGET_WORKTREE=' "$log" | head -1 | cut -d: -f1) + [ "$tree_line" -lt "$launch_line" ] || fail "spawn order was not worktree then harness" + assert_no_grep 'cd --' "$log" "spawn returned the worker endpoint to the supervisor host" + (cd "$wt" && PATH="$fb:$PATH" FM_WORKER_OBS="$obs" FM_CODEX_ARGV="$argv" bash -c "$(cat "$launch")") + assert_grep "cwd=$wt" "$obs" "worker harness did not start from the isolated target worktree" + assert_grep "host=$host" "$obs" "worker did not receive the exact host root" + assert_grep "target=$wt" "$obs" "worker did not receive the exact target worktree" + turnend="$(cd "$home/state" && pwd -P)/lane.turn-ended" +python3 - "$argv" "$turnend" <<'PY' || fail "Codex notify argv did not preserve the hostile turn-end path" +import pathlib, subprocess, sys, tomllib +args = pathlib.Path(sys.argv[1]).read_bytes().split(b"\0") +args = [a.decode() for a in args if a] +assert args.count("-c") == 1, args +i = args.index("-c") +config = args[i + 1] +notify = tomllib.loads(config)["notify"] +assert pathlib.Path(notify[0]).is_absolute(), notify +assert pathlib.Path(notify[0]).name.lower() in {"bash", "bash.exe"}, notify +assert notify[1] == "-c", notify +subprocess.run(notify, check=True) +PY + assert_present "$turnend" "Codex notify command did not touch the exact hostile path" + assert_present "$wt/worker-edit.txt" "disposable worker probe did not edit the target worktree" + assert_absent "$host/worker-edit.txt" "disposable worker probe edited the host root" + after=$(git -C "$host" status --porcelain) + [ "$before" = "$after" ] || fail "host working tree changed during spawn or worker probe" + printf 'another-task' > "$marker_file" + : > "$log" + out=$(cd "$host" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$launch" FM_CURRENT_PATH="$current" FM_TARGET_PATH="$wt" FM_HOST_PATH="$host" \ + "$ROOT/bin/fm-send.sh" lane hello 2>&1) || status=$? + expect_code 2 "$status" "host-root send trusted a reused tmux window id" + assert_contains "$out" 'recorded tmux window identity does not match' "reused tmux window refusal was not explicit" + assert_no_grep 'send-keys' "$log" "host-root send targeted a tmux window owned by another task" + pass "spawn binds host-root tmux actions to a task-owned window marker" +} + +test_duplicate_spawn_preserves_existing_task() { + local host="$TMP/duplicate-host" home="$TMP/duplicate-home" project="$TMP/duplicate-target" fb log current launch out status=0 + make_host "$host" + make_project_with_origin "$project" + mkdir -p "$home/data/lane" "$home/state" "$home/config" + (cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-brief.sh" lane target --mode no-mistakes >/dev/null 2>&1) + printf 'window=test-session:fm-lane\nworktree=/tmp/existing-worktree\nhost_root=%s\nproject=%s\nkind=ship\n' \ + "$host" "$project" > "$home/state/lane.meta" + printf 'working: existing task\n' > "$home/state/lane.status" + cp "$home/state/lane.meta" "$TMP/existing.meta" + cp "$home/state/lane.status" "$TMP/existing.status" + fb=$(make_fakebin "$TMP/fake-duplicate") + log="$TMP/duplicate.log"; current="$TMP/duplicate.current"; launch="$TMP/duplicate.launch" + printf '%s\n' "$project" > "$current" + : > "$current.endpoint" + + out=$(cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_EXISTING_WINDOW=fm-lane FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$launch" FM_CURRENT_PATH="$current" \ + FM_TARGET_PATH=/tmp/unused FM_HOST_PATH="$host" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" lane "$project" codex --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "duplicate spawn unexpectedly succeeded" + assert_contains "$out" 'task metadata already exists for lane' "duplicate spawn refusal was not explicit" + assert_no_grep 'kill-window' "$log" "duplicate spawn killed the existing task endpoint" + cmp -s "$TMP/existing.meta" "$home/state/lane.meta" || fail "duplicate spawn changed existing metadata" + cmp -s "$TMP/existing.status" "$home/state/lane.status" || fail "duplicate spawn changed existing status" + assert_present "$current.endpoint" "duplicate spawn removed the existing endpoint" + rm -f "$current.endpoint" + : > "$log" + status=0 + out=$(cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$launch" FM_CURRENT_PATH="$current" \ + FM_TARGET_PATH=/tmp/unused FM_HOST_PATH="$host" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" lane "$project" codex --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "retained metadata admitted a same-id spawn after its endpoint disappeared" + assert_contains "$out" 'task metadata already exists for lane' "retained metadata refusal was not explicit" + [ ! -s "$log" ] || fail "retained metadata retry inspected or created a backend endpoint" + cmp -s "$TMP/existing.meta" "$home/state/lane.meta" || fail "retained metadata retry changed the recovery record" + cmp -s "$TMP/existing.status" "$home/state/lane.status" || fail "retained metadata retry changed existing status" + pass "duplicate spawn preserves live and endpoint-free retained task records" +} + +test_unset_herdr_retry_reaches_existing_identity_checks() { + local home="$TMP/unset-herdr-home" project="$TMP/unset-herdr-project" fb out status=0 + make_project_with_origin "$project" + mkdir -p "$home/data/herdr-retry" "$home/state" "$home/config" + printf 'Retry existing Herdr task.\n' > "$home/data/herdr-retry/brief.md" + : > "$home/config/herdr-presentation-spaces" + { + printf 'window=default:old-pane\n' + printf 'worktree=/tmp/old-herdr-worktree\n' + printf 'project=%s\n' "$project" + printf 'harness=codex\n' + printf 'kind=ship\n' + printf 'mode=no-mistakes\n' + printf 'backend=herdr\n' + printf 'herdr_session=default\n' + printf 'herdr_workspace_id=old-workspace\n' + printf 'herdr_tab_id=old-tab\n' + printf 'herdr_pane_id=old-pane\n' + } > "$home/state/herdr-retry.meta" + printf 'journal\n' > "$home/state/herdr-retry.herdr-presentation" + cp "$home/state/herdr-retry.meta" "$TMP/unset-herdr.meta" + fb=$(fm_fakebin "$TMP/fake-unset-herdr") + cat > "$fb/herdr" <<'SH' +#!/usr/bin/env bash +case " $* " in + *' status --json '*) printf '%s\n' '{"server":{"running":true}}' ;; + *' session list --json '*) printf '%s\n' '{"sessions":[{"name":"default","running":true,"socket_path":"/tmp/fake-herdr.sock"}]}' ;; + *' pane get old-pane '*) printf '%s\n' '{"result":{"pane":{"pane_id":"old-pane"}}}' ;; + *' agent get old-pane '*) printf '%s\n' '{"result":{"agent":{"agent_status":"idle"}}}' ;; + *) exit 1 ;; +esac +SH + chmod +x "$fb/herdr" + + out=$(env -u FM_HOST_ROOT PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + "$ROOT/bin/fm-spawn.sh" herdr-retry "$project" codex --backend herdr --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "unset-mode Herdr retry bypassed its live-endpoint refusal" + assert_contains "$out" 'existing herdr endpoint for herdr-retry is live' \ + "unset-mode retry did not reach the established Herdr identity classifier" + assert_not_contains "$out" 'task metadata already exists for herdr-retry' \ + "host-root metadata protection leaked into unset-mode Herdr recovery" + cmp -s "$TMP/unset-herdr.meta" "$home/state/herdr-retry.meta" \ + || fail "unset-mode Herdr identity refusal changed existing metadata" + pass "unset-mode Herdr retries retain their established same-identity recovery checks" +} + +test_spawn_rejects_host_as_target() { + local host="$TMP/refusal-host" home="$TMP/refusal-home" project="$TMP/refusal-target" fb log current tree_log out status=0 + make_host "$host" + mkdir -p "$home/data/host-target" "$home/state" "$home/config" + make_project_with_origin "$project" + (cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-brief.sh" host-target target --mode no-mistakes >/dev/null 2>&1) + fb=$(make_fakebin "$TMP/fake-refusals") + log="$TMP/refusals.log"; current="$TMP/refusals.current"; tree_log="$TMP/treehouse.log" + + printf '%s\n' "$project" > "$current" + out=$(cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_TMUX_LOG="$log" FM_ENDPOINT_TARGET=test-session:fm-host-target \ + FM_LAUNCH_FILE="$TMP/refusal.launch" FM_CURRENT_PATH="$current" FM_TARGET_PATH="$host" \ + FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" host-target "$project" codex --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "spawn accepted FM_HOST_ROOT as the target worktree" + assert_contains "$out" 'overlapping host and target roots' "host-as-target refusal was not explicit" + assert_contains "$(cat "$log")" 'kill-window' "host-as-target refusal leaked its tmux endpoint" + assert_no_grep 'return --force' "$tree_log" "host-as-target refusal tried to recycle the authoritative host path" + assert_present "$home/state/host-target.meta" "host-as-target refusal lost recovery metadata for the preserved path" + pass "host overlap preserves the authoritative host path" +} + +test_orca_active_cwd_probe() { + local out + out=$(bash -c ' + . "$1/bin/backends/orca.sh" + fm_backend_orca_send_text_line() { + markers=$(printf "%s\n" "$2" | grep -o "__FM_ORCA_CWD_[A-Z]*_[A-Za-z0-9_]*__") + begin=$(printf "%s\n" "$markers" | head -1) + end=$(printf "%s\n" "$markers" | tail -1) + } + fm_backend_orca_read_text_paged() { + printf "%s\n" "$begin" "/tmp/orca host" "$end" + } + fm_backend_orca_current_path terminal-1 + ' _ "$ROOT") + [ "$out" = "/tmp/orca host" ] || fail "Orca active cwd probe returned '$out'" + pass "Orca backend reads the live shell cwd through a bounded marker probe" +} + +test_all_harnesses_add_one_task_safeguard() { + local host="$TMP/adapters-host" home="$TMP/adapter home's #%?" project="$TMP/adapters-target" before after harness id wt fb log current launch text count out status=0 + make_host "$host" + make_project_with_origin "$project" + mkdir -p "$home/data" "$home/state" "$home/config" + mkdir -p "$TMP/harness-home/.kimi-code" + printf 'default_model = "test"\n' > "$TMP/harness-home/.kimi-code/config.toml" + before=$(git -C "$host" status --porcelain) + for harness in claude codex opencode pi grok kimi; do + id="adapter-$harness" + wt="$TMP/$id-wt" + git -C "$project" worktree add -q --detach "$wt" + (cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-brief.sh" "$id" "$(basename "$project")" --mode no-mistakes >/dev/null 2>&1) + fb=$(make_fakebin "$TMP/fake-$harness") + log="$TMP/$id.log"; current="$TMP/$id.current"; launch="$TMP/$id.launch" + printf '%s\n' "$project" > "$current" + (cd "$host" && HOME="$TMP/harness-home" PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_FAKE_KIMI=1 FM_KIMI_READY_POLLS=1 FM_KIMI_DELIVERY_POLLS=1 FM_KIMI_POLL_INTERVAL=0 \ + FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$launch" FM_CURRENT_PATH="$current" FM_TARGET_PATH="$wt" FM_HOST_PATH="$host" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" "$id" "$project" "$harness" --mode no-mistakes --yolo off >/dev/null) + text=$(cat "$launch") + bash -n -c "$text" || fail "$harness host-root launch is not valid shell" + case "$harness" in + claude) + count=$(printf '%s' "$text" | grep -o -- '--settings' | wc -l | tr -d ' ') + [ "$count" -eq 1 ] || fail "Claude task settings appeared $count times" + assert_present "$home/state/$id.claude-settings.json" "Claude task settings file missing" + node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$(node_path "$home/state/$id.claude-settings.json")" \ + || fail "Claude task settings are invalid JSON" + ;; + codex) + count=$(printf '%s' "$text" | grep -oF 'notify=[' | wc -l | tr -d ' ') + [ "$count" -eq 1 ] || fail "Codex notify safeguard appeared $count times" + ;; + opencode) + count=$(printf '%s' "$text" | grep -oF 'opencode-turn-end.js' | wc -l | tr -d ' ') + [ "$count" -eq 1 ] || fail "OpenCode task plugin appeared $count times" + assert_present "$home/state/$id.opencode-turn-end.js" "OpenCode task plugin missing" + case $(uname -s) in + MINGW*|MSYS*) assert_contains "$text" '%23%25%EF%80%BF' "OpenCode task plugin file URL did not encode MSYS path metacharacters" ;; + *) assert_contains "$text" '%23%25%3F' "OpenCode task plugin file URL did not encode path metacharacters" ;; + esac + node --check "$(node_path "$home/state/$id.opencode-turn-end.js")" >/dev/null \ + || fail "OpenCode task plugin is invalid JavaScript" + ;; + pi) + count=$(printf '%s' "$text" | grep -oF "$id.pi-ext.ts" | wc -l | tr -d ' ') + [ "$count" -eq 1 ] || fail "Pi task extension appeared $count times" + assert_present "$home/state/$id.pi-ext.ts" "Pi task extension missing" + ;; + grok) + count=$(printf '%s' "$text" | grep -oF 'FM_GROK_TURNEND_TOKEN=' | wc -l | tr -d ' ') + [ "$count" -eq 1 ] || fail "Grok task token appeared $count times" + assert_absent "$host/.fm-grok-turnend" "Grok host launch wrote a task pointer into the host" + ;; + kimi) + count=$(printf '%s' "$text" | grep -oF 'FM_KIMI_TURNEND_TOKEN=' | wc -l | tr -d ' ') + [ "$count" -eq 1 ] || fail "Kimi task token appeared $count times" + assert_present "$home/state/$id.kimi-turnend-token" "Kimi task token state is missing" + assert_absent "$host/.fm-kimi-turnend" "Kimi host launch wrote a task pointer into the host" + ;; + esac + rm -rf "/tmp/fm-$id" + done + + id=adapter-raw + (cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-brief.sh" "$id" "$(basename "$project")" --mode no-mistakes >/dev/null 2>&1) + out=$(cd "$host" && FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-spawn.sh" "$id" "$project" 'claude --dangerously-skip-permissions' --mode no-mistakes --yolo off 2>&1) || status=$? + expect_code 2 "$status" "host mode must reject a raw launch command without a verified task safeguard" + assert_contains "$out" 'requires a named verified harness' "raw host launch refusal was not explicit" + assert_absent "$home/state/$id.meta" "raw host launch mutated task state before refusal" + + after=$(git -C "$host" status --porcelain) + [ "$before" = "$after" ] || fail "harness integration rewrote host configuration" + pass "all six harnesses add one task safeguard without changing host hooks" +} + +test_mutators_require_host_cwd() { + local host="$TMP/mutator-host" other="$TMP/mutator-other" home="$TMP/mutator-home" fake_root="$TMP/mutator-root" guard_marker="$TMP/mutator-guard" fb log current out status=0 + make_host "$host" + mkdir -p "$other" "$home/state" "$fake_root/bin" + printf 'window=fake:fm-lane\nworktree=/tmp/target\nhost_root=%s\nproject=/tmp/project\nkind=ship\n' "$host" > "$home/state/lane.meta" + printf 'window=fake:fm-scout\nworktree=/tmp/scout\nhost_root=%s\nproject=/tmp/project\nkind=scout\n' "$host" > "$home/state/scout.meta" + fb=$(make_fakebin "$TMP/fake-mutator-read") + log="$TMP/mutator-read.log" + current="$TMP/mutator-read.current" + : > "$log" + printf '%s\n' "$host" > "$current" + out=$(cd "$other" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_TMUX_LOG="$log" FM_CURRENT_PATH="$current" "$ROOT/bin/fm-peek.sh" lane 2>&1) || status=$? + expect_code 2 "$status" "fm-peek must reject a host cwd mismatch" + assert_contains "$out" 'requires the recorded host root cwd' "fm-peek host mismatch was not explicit" + [ ! -s "$log" ] || fail "fm-peek inspected the endpoint before host validation" + status=0 + out=$(cd "$other" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_TMUX_LOG="$log" FM_CURRENT_PATH="$current" "$ROOT/bin/fm-crew-state.sh" lane 2>&1) || status=$? + expect_code 2 "$status" "fm-crew-state must reject a host cwd mismatch" + assert_contains "$out" 'requires the recorded host root cwd' "fm-crew-state host mismatch was not explicit" + [ ! -s "$log" ] || fail "fm-crew-state inspected the endpoint before host validation" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-send.sh" lane hello 2>&1) || status=$? + expect_code 2 "$status" "fm-send must reject a host cwd mismatch" + assert_contains "$out" 'requires the recorded host root cwd' "fm-send host mismatch was not explicit" + status=0 + out=$(cd "$other" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_TMUX_LOG="$log" FM_CURRENT_PATH="$current" "$ROOT/bin/fm-control.sh" lane interrupt 2>&1) || status=$? + expect_code 2 "$status" "fm-control must reject a host cwd mismatch" + assert_contains "$out" 'requires the recorded host root cwd' "fm-control host mismatch was not explicit" + [ ! -s "$log" ] || fail "fm-control inspected or signaled an endpoint before host validation" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-teardown.sh" lane 2>&1) || status=$? + expect_code 2 "$status" "fm-teardown must reject a host cwd mismatch" + assert_present "$home/state/lane.meta" "teardown mutated task state before host validation" + printf 'mode=local-only\n' >> "$home/state/lane.meta" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-merge-local.sh" lane 2>&1) || status=$? + expect_code 2 "$status" "fm-merge-local must reject a host cwd mismatch" + assert_present "$home/state/lane.meta" "local merge mutated task state before host validation" + status=0 + out=$(cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-merge-local.sh" lane 2>&1) || status=$? + expect_code 1 "$status" "fm-merge-local must refuse host-root local-only tasks" + assert_contains "$out" 'local-only merge is unavailable for host-root task' "host-root local merge refusal was not explicit" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-pr-merge.sh" lane https://github.com/example/repo/pull/1 2>&1) || status=$? + expect_code 2 "$status" "fm-pr-merge must reject a host cwd mismatch" + assert_present "$home/state/lane.meta" "PR merge mutated task state before host validation" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-pr-check.sh" lane https://github.com/example/repo/pull/1 2>&1) || status=$? + expect_code 2 "$status" "fm-pr-check must reject a host cwd mismatch" + if grep -q '^pr=' "$home/state/lane.meta"; then + fail "PR check mutated task metadata before host validation" + fi + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-review-diff.sh" lane --stat 2>&1) || status=$? + expect_code 2 "$status" "fm-review-diff must reject a host cwd mismatch" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-promote.sh" scout --mode no-mistakes --yolo off 2>&1) || status=$? + expect_code 2 "$status" "fm-promote must reject a host cwd mismatch" + grep -qx 'kind=scout' "$home/state/scout.meta" || fail "promote mutated task metadata before host validation" + printf 'mode=local-only\n' >> "$home/state/scout.meta" + cat > "$fake_root/bin/fm-guard.sh" <<'SH' +#!/usr/bin/env bash +: > "$FM_GUARD_MUTATION" +SH + chmod +x "$fake_root/bin/fm-guard.sh" + status=0 + out=$(cd "$host" && FM_GUARD_MUTATION="$guard_marker" FM_ROOT_OVERRIDE="$fake_root" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-promote.sh" scout --mode local-only --yolo off 2>&1) || status=$? + expect_code 1 "$status" "fm-promote must reject a host-root local-only scout" + assert_contains "$out" 'host-root mode does not support promoting local-only scout' "host-root local-only promotion refusal was not explicit" + assert_absent "$guard_marker" "host-root promotion ran the fleet guard before rejecting local-only delivery" + grep -qx 'kind=scout' "$home/state/scout.meta" || fail "local-only promotion mutated task metadata" + sed -i '/^mode=local-only$/d' "$home/state/scout.meta" + status=0 + out=$(cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-promote.sh" scout --mode no-mistakes --yolo off 2>&1) || status=$? + expect_code 0 "$status" "fm-promote rejected a recorded host-root scout" + assert_contains "$out" "'$ROOT/bin/fm-send.sh'" "host-root promotion did not print a quoted absolute fm-send path" + # shellcheck disable=SC2016 # Assertions intentionally match literal worker variables. + assert_contains "$out" 'git -C "$FM_TARGET_WORKTREE" status' "host-root promotion did not scope scratch status" + # shellcheck disable=SC2016 + assert_contains "$out" 'git -C "$FM_TARGET_WORKTREE" log' "host-root promotion did not scope scratch history" + # shellcheck disable=SC2016 + assert_contains "$out" 'git -C "$FM_TARGET_WORKTREE" checkout -b fm/scout' "host-root promotion did not scope branch creation" + # shellcheck disable=SC2016 + assert_contains "$out" '(cd "$FM_TARGET_WORKTREE" && ...)' "host-root promotion did not scope validation commands" + printf 'window=fake:fm-plain\nworktree=/tmp/plain\nproject=/tmp/project\nkind=scout\n' > "$home/state/plain.meta" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" "$ROOT/bin/fm-promote.sh" plain --mode no-mistakes --yolo off 2>&1) || status=$? + expect_code 0 "$status" "fm-promote changed default-mode promotion" + assert_contains "$out" '' "default-mode promotion instructions changed" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-check-register.sh" lane 2>&1) || status=$? + expect_code 2 "$status" "fm-check-register must reject a host cwd mismatch" + assert_absent "$home/state/lane.check-trust" "check registration mutated trust state before host validation" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-decision-hold.sh" complete lane --none 2>&1) || status=$? + expect_code 2 "$status" "fm-decision-hold must reject a host cwd mismatch" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-x-followup.sh" --check lane 2>&1) || status=$? + expect_code 2 "$status" "fm-x-followup must reject a host cwd mismatch" + status=0 + out=$(cd "$other" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-x-link.sh" lane request-1 2>&1) || status=$? + expect_code 2 "$status" "fm-x-link must reject a host cwd mismatch" + pass "task lifecycle actions reject host cwd mismatch before mutation" +} + +test_secondmate_actions_keep_supervisor_host_authority() { + local host="$TMP/secondmate-action-host" other="$TMP/secondmate-action-other" meta="$TMP/secondmate-action.meta" ordinary="$TMP/ordinary-action.meta" out status=0 + make_host "$host" + mkdir -p "$other" + printf 'window=fm-mate\nworktree=/tmp/mate\nkind=secondmate\n' > "$meta" + printf 'window=fm-task\nworktree=/tmp/task\nkind=ship\n' > "$ordinary" + + (cd "$host" && FM_HOST_ROOT="$host" bash -c '. "$1"; fm_host_root_assert_task_cwd "$2" "$3"' _ "$LIB" "$ROOT" "$meta") \ + || status=$? + expect_code 0 "$status" "secondmate action should use the primary supervisor host cwd without host_root metadata" + + status=0 + out=$(cd "$other" && FM_HOST_ROOT="$host" bash -c '. "$1"; fm_host_root_assert_task_cwd "$2" "$3"' _ "$LIB" "$ROOT" "$meta" 2>&1) \ + || status=$? + expect_code 2 "$status" "secondmate action must still reject a supervisor host cwd mismatch" + assert_contains "$out" 'requires the supervisor cwd' "secondmate cwd mismatch did not preserve primary host authority" + + status=0 + (cd "$host" && FM_HOST_ROOT="$host" bash -c '. "$1"; fm_host_root_assert_task_cwd "$2" "$3"' _ "$LIB" "$ROOT" "$ordinary") \ + || status=$? + expect_code 0 "$status" "legacy task should remain operable from the enabled supervisor host cwd" + + status=0 + out=$(cd "$other" && FM_HOST_ROOT="$host" bash -c '. "$1"; fm_host_root_assert_task_cwd "$2" "$3"' _ "$LIB" "$ROOT" "$ordinary" 2>&1) \ + || status=$? + expect_code 2 "$status" "legacy task must still reject a supervisor host cwd mismatch" + assert_contains "$out" 'requires the supervisor cwd' "legacy task cwd mismatch did not preserve primary host authority" + pass "legacy and secondmate actions retain primary host cwd authority without host_root metadata" +} + +test_spawn_rollback_is_transactional() { + local host="$TMP/rollback-host" home="$TMP/rollback-home" project="$TMP/rollback-target" wt="$TMP/rollback-wt" stuck_wt="$TMP/rollback-stuck-wt" scout_wt="$TMP/rollback-scout-wt" uncertain_wt="$TMP/rollback-uncertain-wt" retained_wt="$TMP/rollback-retained-wt" unset_wt="$TMP/rollback-unset-wt" fb log current tree_log out status=0 + make_host "$host"; mkdir -p "$home/data" "$home/state" "$home/config"; make_project_with_origin "$project" + git -C "$project" worktree add -q --detach "$wt" + git -C "$project" worktree add -q --detach "$stuck_wt" + git -C "$project" worktree add -q --detach "$scout_wt" + git -C "$project" worktree add -q --detach "$uncertain_wt" + git -C "$project" worktree add -q --detach "$retained_wt" + git -C "$project" worktree add -q --detach "$unset_wt" + for id in rollback-marker rollback-clean rollback-stuck rollback-uncertain rollback-retained; do + (cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-brief.sh" "$id" target --mode no-mistakes >/dev/null 2>&1) + done + (cd "$host" && FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-brief.sh" rollback-scout target --scout >/dev/null 2>&1) + fb=$(make_fakebin "$TMP/fake-rollback") + printf '#!/usr/bin/env bash\nexit 99\n' > "$fb/node" + chmod +x "$fb/node" + log="$TMP/rollback.log"; current="$TMP/rollback.current"; tree_log="$TMP/rollback-treehouse.log" + + : > "$log"; : > "$tree_log"; printf '%s\n' "$project" > "$current"; status=0 + out=$(cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_FAIL_MARKER_SET=1 FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 FM_TMUX_LOG="$log" \ + FM_ENDPOINT_TARGET=test-session:fm-rollback-marker FM_LAUNCH_FILE="$TMP/rollback-marker.launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$wt" FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" rollback-marker "$project" pi --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "injected tmux marker failure unexpectedly succeeded" + assert_no_grep 'kill-window' "$log" "marker failure bypassed ownership verification during rollback" + assert_no_grep 'return --force' "$tree_log" "marker failure recycled a worktree before endpoint absence was confirmed" + assert_present "$home/state/rollback-marker.meta" "marker failure lost recovery metadata" + assert_grep 'window=@1' "$home/state/rollback-marker.meta" "marker failure did not record the created tmux window id" + assert_grep 'tmux_window_marker=' "$home/state/rollback-marker.meta" "marker failure did not retain its intended ownership marker" + assert_grep 'tmux_socket_path=' "$home/state/rollback-marker.meta" "marker failure did not retain its creating socket" + assert_present "$current.endpoint" "marker failure lost the unconfirmed live endpoint" + rm -f "$home/state/rollback-marker.meta" "$current.endpoint" + + printf '%s\n' "$project" > "$current" + touch "$home/state/rollback-clean.busy" + out=$(cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_FAIL_LAUNCH_SEND=1 FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 FM_TMUX_LOG="$log" \ + FM_ENDPOINT_TARGET=test-session:fm-rollback-clean FM_LAUNCH_FILE="$TMP/rollback.launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$wt" FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" rollback-clean "$project" pi --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "injected launch failure unexpectedly succeeded" + assert_grep 'return --force' "$tree_log" "successful rollback did not return the isolated copy" + assert_absent "$home/state/rollback-clean.meta" "successful rollback left metadata" + assert_absent "$home/state/rollback-clean.pi-ext.ts" "successful rollback left its pre-record Pi artifact" + assert_absent "$home/state/rollback-clean.busy" "successful rollback left legacy busy state" + assert_absent "$home/state/rollback-clean.busy-state" "successful rollback left semantic busy state" + assert_absent "$home/state/rollback-clean.busy-gen" "successful rollback left semantic busy generation" + assert_absent "/tmp/fm-rollback-clean" "successful rollback left its task temp root" + + : > "$log"; : > "$tree_log"; printf '%s\n' "$project" > "$current"; status=0 + out=$(cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_FAIL_LAUNCH_SEND=1 FM_REFUSE_STOP=1 FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 FM_TMUX_LOG="$log" \ + FM_ENDPOINT_TARGET=test-session:fm-rollback-stuck FM_LAUNCH_FILE="$TMP/rollback-stuck.launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$stuck_wt" FM_HOST_PATH="$host" \ + FM_TREEHOUSE_LOG="$tree_log" TMUX=fake "$ROOT/bin/fm-spawn.sh" rollback-stuck "$project" pi --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "injected stop failure unexpectedly succeeded" + assert_contains "$out" 'still exists after stop' "failed endpoint termination was not explicit (backend log: $(tr '\n' ';' < "$log"))" + assert_no_grep 'return --force' "$tree_log" "rollback reused the isolated copy after unconfirmed termination" + assert_present "$home/state/rollback-stuck.meta" "failed rollback lost recovery metadata" + assert_grep 'endpoint_task_id=rollback-stuck' "$home/state/rollback-stuck.meta" \ + "failed rollback metadata lost its endpoint task binding" + FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" bash -c \ + '. "$1"; fm_backend_validate_task_endpoint "$2" "$3"' _ "$ROOT/bin/fm-backend.sh" \ + "$home/state/rollback-stuck.meta" rollback-stuck \ + || fail "failed rollback metadata could not pass endpoint recovery validation" + assert_present "$home/state/rollback-stuck.pi-ext.ts" "failed rollback discarded a recoverable pre-record artifact" + assert_present "/tmp/fm-rollback-stuck" "failed rollback discarded its recoverable task temp root" + rm -rf "/tmp/fm-rollback-stuck" + rm -f "$home/state/rollback-stuck.meta" "$home/state/rollback-stuck.pi-ext.ts" "$current.endpoint" + + : > "$log"; : > "$tree_log"; printf '%s\n' "$project" > "$current"; status=0 + out=$(cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_FAIL_LAUNCH_SEND=1 FM_REFUSE_STOP=1 FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 FM_TMUX_LOG="$log" \ + FM_ENDPOINT_TARGET=test-session:fm-rollback-scout FM_LAUNCH_FILE="$TMP/rollback-scout.launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$scout_wt" FM_HOST_PATH="$host" \ + FM_TREEHOUSE_LOG="$tree_log" TMUX=fake "$ROOT/bin/fm-spawn.sh" rollback-scout "$project" pi --scout 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "injected scout stop failure unexpectedly succeeded" + assert_present "$home/state/rollback-scout.meta" "failed scout rollback lost recovery metadata" + assert_no_grep 'mode=' "$home/state/rollback-scout.meta" "failed scout rollback fabricated a delivery mode" + assert_no_grep 'yolo=' "$home/state/rollback-scout.meta" "failed scout rollback fabricated delivery authority" + rm -rf "/tmp/fm-rollback-scout" + rm -f "$home/state/rollback-scout.meta" "$home/state/rollback-scout.pi-ext.ts" "$current.endpoint" + + : > "$log"; : > "$tree_log"; printf '%s\n' "$project" > "$current"; status=0 + out=$(cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_FAIL_LAUNCH_ENTER=1 FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$TMP/rollback-uncertain.launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$uncertain_wt" FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" rollback-uncertain "$project" pi --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "ambiguous Enter failure unexpectedly succeeded" + assert_contains "$out" 'launch submission could not be confirmed' "ambiguous Enter failure did not explain the retained task" + assert_no_grep 'kill-window' "$log" "ambiguous Enter failure killed a possibly running worker" + assert_no_grep 'return --force' "$tree_log" "ambiguous Enter failure recycled a possibly active worktree" + assert_present "$home/state/rollback-uncertain.meta" "ambiguous Enter failure lost task metadata" + assert_present "$home/state/rollback-uncertain.pi-ext.ts" "ambiguous Enter failure lost its task safeguard" + assert_present "/tmp/fm-rollback-uncertain" "ambiguous Enter failure removed its task temp root" + rm -rf "/tmp/fm-rollback-uncertain" + rm -f "$home/state/rollback-uncertain.meta" "$home/state/rollback-uncertain.pi-ext.ts" "$current.endpoint" + + : > "$log"; : > "$tree_log"; printf '%s\n' "$project" > "$current"; status=0 + out=$(cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_FAIL_LAUNCH_SEND=1 FM_REFUSE_RETURN=1 FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 FM_TMUX_LOG="$log" \ + FM_ENDPOINT_TARGET=test-session:fm-rollback-retained FM_LAUNCH_FILE="$TMP/rollback-retained.launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$retained_wt" FM_HOST_PATH="$host" \ + FM_TREEHOUSE_LOG="$tree_log" TMUX=fake "$ROOT/bin/fm-spawn.sh" rollback-retained "$project" pi --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "injected worktree return failure unexpectedly succeeded" + assert_present "$home/state/rollback-retained.meta" "worktree return failure lost recovery metadata" + assert_absent "$current.endpoint" "worktree return failure left the stopped endpoint alive" + cp "$home/state/rollback-retained.meta" "$TMP/rollback-retained.meta" + : > "$log"; : > "$tree_log"; printf '%s\n' "$project" > "$current"; status=0 + out=$(cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$TMP/rollback-retained-retry.launch" FM_CURRENT_PATH="$current" \ + FM_TARGET_PATH="$wt" FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" rollback-retained "$project" pi --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "same-id retry overwrote retained cleanup metadata" + assert_contains "$out" 'task metadata already exists for rollback-retained' "retained cleanup retry refusal was not explicit" + [ ! -s "$log" ] || fail "retained cleanup retry inspected or created an endpoint" + [ ! -s "$tree_log" ] || fail "retained cleanup retry allocated or returned a worktree" + cmp -s "$TMP/rollback-retained.meta" "$home/state/rollback-retained.meta" || fail "retained cleanup retry changed the recovery record" + + : > "$log"; : > "$tree_log"; status=0 + out=$(cd "$host" && env -u FM_HOST_ROOT PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$TMP/rollback-retained-unset-retry.launch" FM_CURRENT_PATH="$current" \ + FM_TARGET_PATH="$wt" FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" rollback-retained "$project" pi --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "unset retry overwrote retained host cleanup metadata" + assert_contains "$out" 'task metadata already exists for rollback-retained' "unset retained-host retry refusal was not explicit" + [ ! -s "$log" ] || fail "unset retained-host retry inspected or created an endpoint" + [ ! -s "$tree_log" ] || fail "unset retained-host retry allocated or returned a worktree" + cmp -s "$TMP/rollback-retained.meta" "$home/state/rollback-retained.meta" || fail "unset retry changed the retained host recovery record" + rm -rf "/tmp/fm-rollback-retained" + rm -f "$home/state/rollback-retained.meta" "$home/state/rollback-retained.pi-ext.ts" + + env -u FM_HOST_ROOT FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + "$ROOT/bin/fm-brief.sh" rollback-unset target --mode no-mistakes >/dev/null 2>&1 + : > "$log"; : > "$tree_log"; printf '%s\n' "$project" > "$current"; status=0 + out=$(cd "$host" && env -u FM_HOST_ROOT PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_FAIL_LAUNCH_SEND=1 FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$TMP/rollback-unset.launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$unset_wt" FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" rollback-unset "$project" pi --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "unset-mode injected launch failure unexpectedly succeeded" + assert_no_grep 'kill-window' "$log" "unset-mode launch failure changed upstream endpoint cleanup" + assert_no_grep 'return --force' "$tree_log" "unset-mode launch failure changed upstream worktree cleanup" + assert_present "$home/state/rollback-unset.meta" "unset-mode launch failure removed upstream task metadata" + assert_present "$current.endpoint" "unset-mode launch failure stopped the endpoint" + rm -rf "/tmp/fm-rollback-unset" + rm -f "$home/state/rollback-unset.meta" "$home/state/rollback-unset.pi-ext.ts" "$current.endpoint" + pass "spawn rollback stays host-scoped and preserves ambiguous launch submissions" +} + +test_decision_actions_use_durable_host_owner() { + local host="$TMP/decision-host" wrong="$TMP/decision-wrong" home="$TMP/decision-home" id=decision-scout action action_id fb owner out status=0 current log tree_log + make_host "$host" + make_host "$wrong" + mkdir -p "$home/data/$id" "$home/state" "$home/config" + printf '# Decision report\n' > "$home/data/$id/report.md" + printf 'done: report complete\n' > "$home/state/$id.status" + printf 'window=fake:fm-%s\nworktree=/tmp/decision-target\nhost_root=%s\nproject=/tmp/project\nkind=scout\n' \ + "$id" "$host" > "$home/state/$id.meta" + fb=$(make_fakebin "$TMP/fake-decision-owner") + cat > "$fb/tasks-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "--version ") printf '%s\n' 'tasks-axi 0.2.4' ;; + "update --help") printf '%s\n' '--archive-body' ;; + "mv --help") printf '%s\n' '[...]' ;; + "hold --help") printf '%s\n' '--kind captain' ;; +esac +SH + chmod +x "$fb/tasks-axi" + (cd "$host" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-decision-hold.sh" complete "$id" --none >/dev/null) \ + || fail "live host-root completion could not persist ownership" + owner="$home/data/$id/host-root" + assert_grep "host_root=$host" "$owner" "completion did not persist the recorded host owner" + rm "$home/state/$id.meta" + (cd "$host" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-decision-hold.sh" complete "$id" --none >/dev/null) \ + || fail "post-metadata completion rejected its durable host owner" + rm "$owner" + out=$(cd "$host" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + "$ROOT/bin/fm-decision-hold.sh" complete "$id" --none 2>&1) || status=$? + expect_code 1 "$status" "post-metadata completion trusted ambient host authority" + assert_contains "$out" 'has no durable recorded host owner' "missing durable host owner refusal was not explicit" + + current="$TMP/decision.current" + log="$TMP/decision.log" + tree_log="$TMP/decision-treehouse.log" + for action in hold complete resolve decline repair; do + action_id="decision-post-teardown-$action" + mkdir -p "$home/data/$action_id" +printf 'window=test-session:fm-%s\nendpoint_task_id=%s\nworktree=/tmp/decision-target\nhost_root=%s\nproject=/tmp/project\nkind=ship\nmode=local-only\ntmux_window_marker=decision-marker\ntmux_socket_path=/tmp/fm-test.sock\n' \ +"$action_id" "$action_id" "$host" > "$home/state/$action_id.meta" + printf '%s\n' "$host" > "$current" + : > "$current.endpoint" + printf 'decision-marker' > "$current.endpoint.marker" + : > "$log" + : > "$tree_log" + (cd "$host" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 FM_TMUX_LOG="$log" \ + FM_ENDPOINT_TARGET="test-session:fm-$action_id" FM_LAUNCH_FILE="$TMP/unused.launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH=/tmp/decision-target FM_HOST_PATH="$host" \ + FM_TREEHOUSE_LOG="$tree_log" TMUX=fake "$ROOT/bin/fm-teardown.sh" "$action_id" --force >/dev/null) \ + || fail "host-root teardown failed before the first post-teardown $action action" + assert_absent "$home/state/$action_id.meta" "teardown retained metadata before the $action authority check" + assert_grep "host_root=$host" "$home/data/$action_id/host-root" \ + "teardown did not persist ownership before the $action authority check" + status=0 + case "$action" in + hold) + out=$(cd "$wrong" && env -u FM_HOST_ROOT PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + "$ROOT/bin/fm-decision-hold.sh" hold "$action_id" choice --title Choice --reason required 2>&1) || status=$? + ;; + complete) + out=$(cd "$wrong" && env -u FM_HOST_ROOT PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + "$ROOT/bin/fm-decision-hold.sh" complete "$action_id" --none 2>&1) || status=$? + ;; + resolve) + out=$(cd "$wrong" && env -u FM_HOST_ROOT PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + "$ROOT/bin/fm-decision-hold.sh" resolve "$action_id" choice \ + --decision-file "$TMP/missing-decision" --routed-to routed-task 2>&1) || status=$? + ;; + decline|repair) + out=$(cd "$wrong" && env -u FM_HOST_ROOT PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + "$ROOT/bin/fm-decision-hold.sh" "$action" "$action_id" choice \ + --decision-file "$TMP/missing-decision" 2>&1) || status=$? + ;; + esac + expect_code 2 "$status" "first post-teardown $action action trusted a non-host cwd" + assert_contains "$out" 'requires the recorded host root cwd' \ + "first post-teardown $action action did not enforce durable host ownership" + done + pass "post-teardown decision actions require durable recorded host ownership" +} + +test_host_teardown_requires_confirmed_stop() { + local host="$TMP/teardown-host" home="$TMP/teardown-home" project="$TMP/teardown-project" wt="$TMP/teardown-wt" fb log current tree_log out status=0 kill_line verify_line return_line + make_host "$host"; mkdir -p "$home/data/host-teardown" "$home/state" "$home/config"; make_project_with_origin "$project" + git -C "$project" worktree add -q --detach "$wt" +printf 'window=test-session:fm-host-teardown\nendpoint_task_id=host-teardown\nworktree=%s\nhost_root=%s\nproject=%s\nkind=ship\nmode=local-only\ntmux_window_marker=teardown-marker\ntmux_socket_path=/tmp/fm-test.sock\n' \ +"$wt" "$host" "$project" > "$home/state/host-teardown.meta" + fb=$(make_fakebin "$TMP/fake-host-teardown") + log="$TMP/host-teardown.log"; current="$TMP/host-teardown.current"; tree_log="$TMP/host-teardown-treehouse.log" + printf '%s\n' "$host" > "$current"; : > "$current.endpoint"; printf 'teardown-marker' > "$current.endpoint.marker"; : > "$log"; : > "$tree_log" + out=$(cd "$host" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_REFUSE_STOP=1 FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 FM_TMUX_LOG="$log" \ + FM_ENDPOINT_TARGET=test-session:fm-host-teardown FM_LAUNCH_FILE="$TMP/unused.launch" FM_CURRENT_PATH="$current" \ + FM_TARGET_PATH="$wt" FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" TMUX=fake \ + "$ROOT/bin/fm-teardown.sh" host-teardown --force 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "host-root teardown accepted an unconfirmed endpoint stop" + assert_no_grep 'return --force' "$tree_log" "host-root teardown recycled work before confirming endpoint absence" + assert_present "$home/state/host-teardown.meta" "host-root teardown removed recovery metadata after stop refusal" + + : > "$log"; : > "$tree_log"; status=0 + out=$(cd "$host" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_TMUX_KILL_MUTATION="$wt/late-edit.txt" FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 FM_TMUX_LOG="$log" \ + FM_ENDPOINT_TARGET=test-session:fm-host-teardown FM_LAUNCH_FILE="$TMP/unused.launch" FM_CURRENT_PATH="$current" \ + FM_TARGET_PATH="$wt" FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" TMUX=fake \ + "$ROOT/bin/fm-teardown.sh" host-teardown 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "host-root tmux teardown discarded an edit written during endpoint stop" + assert_contains "$out" "uncommitted changes" "post-stop tmux safety refusal did not explain the late edit" + assert_present "$wt/late-edit.txt" "post-stop tmux safety refusal lost the late worker edit" + assert_present "$home/state/host-teardown.meta" "post-stop tmux safety refusal removed task metadata" + assert_no_grep 'return --force' "$tree_log" "post-stop tmux safety refusal returned the worktree" + rm -f "$wt/late-edit.txt" + + : > "$current.endpoint"; : > "$log"; : > "$tree_log"; status=0 + sed -i.bak 's/^window=.*/window=test-session:1/' "$home/state/host-teardown.meta" + rm -f "$home/state/host-teardown.meta.bak" + out=$(cd "$host" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 FM_TMUX_LOG="$log" FM_TMUX_RENUMBER_ON_STOP=1 \ + FM_ENDPOINT_TARGET=test-session:fm-host-teardown FM_ENDPOINT_INDEX_ALIAS=test-session:1 \ + FM_LAUNCH_FILE="$TMP/unused.launch" FM_CURRENT_PATH="$current" \ + FM_TARGET_PATH="$wt" FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" TMUX=fake \ + "$ROOT/bin/fm-teardown.sh" host-teardown --force 2>&1) || status=$? + expect_code 0 "$status" "host-root teardown failed after confirmed endpoint stop: $out" + assert_grep $'kill-window\037-t\037@1' "$log" \ + "host-root teardown did not stop the immutable tmux window id" + kill_line=$(grep -n 'kill-window' "$log" | tail -1 | cut -d: -f1) + verify_line=$(awk -v start="$kill_line" 'NR > start && /list-panes/ { print NR; exit }' "$log") + return_line=$(grep -n 'return --force' "$tree_log" | head -1 | cut -d: -f1) + [ -n "$kill_line" ] && [ -n "$verify_line" ] && [ -n "$return_line" ] \ + || fail "host-root teardown did not stop, verify, and return its isolated copy" + pass "host-root teardown confirms endpoint absence before worktree cleanup" +} + +test_host_teardown_refuses_recorded_overlap_before_mutation() { + local host="$TMP/overlap-teardown-host" home="$TMP/overlap-teardown-home" project="$TMP/overlap-teardown-project" fb log current tree_log out status=0 branch + make_host "$host" + make_project_with_origin "$project" + mkdir -p "$home/state" "$home/config" +printf 'window=test-session:fm-overlap-teardown\nendpoint_task_id=overlap-teardown\nworktree=%s\nhost_root=%s\nproject=%s\nkind=ship\nmode=local-only\ntmux_window_marker=overlap-marker\ntmux_socket_path=/tmp/fm-test.sock\n' \ +"$host" "$host" "$project" > "$home/state/overlap-teardown.meta" + cp "$home/state/overlap-teardown.meta" "$TMP/overlap-teardown.meta" + touch "$home/state/.last-watcher-beat" + fb=$(make_fakebin "$TMP/fake-overlap-teardown") + log="$TMP/overlap-teardown.log" + current="$TMP/overlap-teardown.current" + tree_log="$TMP/overlap-teardown-treehouse.log" + printf '%s\n' "$host" > "$current" + : > "$current.endpoint" + : > "$log" + : > "$tree_log" + branch=$(git -C "$host" symbolic-ref --short HEAD) + + out=$(cd "$host" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" \ + FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$TMP/unused.launch" FM_CURRENT_PATH="$current" \ + FM_TARGET_PATH="$host" FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" TMUX=fake \ + "$ROOT/bin/fm-teardown.sh" overlap-teardown --force 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "teardown accepted recorded overlapping host and target roots" + assert_contains "$out" 'recorded host and target roots overlap' "overlap refusal was not explicit" + assert_present "$current.endpoint" "overlap refusal stopped the recorded endpoint" + [ ! -s "$log" ] || fail "overlap refusal touched the recorded endpoint" + [ ! -s "$tree_log" ] || fail "overlap refusal invoked treehouse" + [ "$(git -C "$host" symbolic-ref --short HEAD)" = "$branch" ] || fail "overlap refusal changed the host Git branch" + cmp -s "$TMP/overlap-teardown.meta" "$home/state/overlap-teardown.meta" \ + || fail "overlap refusal changed recovery metadata" + pass "host-root teardown preserves recorded overlaps before endpoint or Git mutation" +} + +test_secondmate_force_teardown_preserves_host_children_during_recursive_cleanup() { + local backend case_root home subhome childproj childwt host fb log current tree_log target endpoint_named out status + for backend in tmux herdr; do + case_root="$TMP/recursive-host-child-$backend" + home="$case_root/home" + subhome="$case_root/subhome" + childproj="$subhome/projects/alpha" + childwt="$case_root/child-worktree" + host="$case_root/host" + mkdir -p "$home/state" "$home/data" "$home/config" "$subhome/state" "$host" + : > "$host/AGENTS.md" + fm_git_worktree "$childproj" "$childwt" "recursive-$backend" + printf 'domain\n' > "$subhome/.fm-secondmate-home" + fm_write_meta "$home/state/domain.meta" \ + 'window=test-session:fm-domain' "worktree=$subhome" "project=$subhome" \ + 'harness=echo' 'kind=secondmate' 'mode=secondmate' "home=$subhome" 'projects=alpha' + target=test-session:1 + endpoint_named=test-session:fm-child + if [ "$backend" = herdr ]; then + target=child-session:w1:p2 + endpoint_named=$target + fi +fm_write_meta "$subhome/state/child.meta" \ +"window=$target" 'endpoint_task_id=child' "worktree=$childwt" "project=$childproj" \ +"backend=$backend" "host_root=$host" 'harness=echo' 'kind=ship' 'mode=no-mistakes' + if [ "$backend" = tmux ]; then + printf 'tmux_window_marker=child-marker\ntmux_socket_path=/tmp/fm-test.sock\n' >> "$subhome/state/child.meta" + fi + fb=$(make_fakebin "$case_root/fake") + if [ "$backend" = herdr ]; then + printf '%s\n' \ + 'herdr_session=child-session' \ + 'herdr_workspace_id=w1' \ + 'herdr_tab_id=w1:t2' \ + 'herdr_pane_id=w1:p2' >> "$subhome/state/child.meta" + printf '%s\n' \ + 'version=1' \ + 'task_id=child' \ + 'projection_id=AbCdEfGhIjKlMnOpQrStUv' > "$subhome/state/child.herdr-presentation" + cat > "$fb/herdr" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FM_FAKE_HERDR_LOG" +case "${1:-} ${2:-}" in + "status --json") printf '%s\n' '{"server":{"running":true}}' ;; + "pane close") exit 0 ;; + "pane get") printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p2"}}}' ;; + *) exit 0 ;; +esac +SH + chmod +x "$fb/herdr" + fi + log="$case_root/backend.log" + current="$case_root/current" + tree_log="$case_root/treehouse.log" + printf '%s\n' "$host" > "$current" + : > "$current.endpoint" + [ "$backend" != tmux ] || printf 'child-marker' > "$current.endpoint.marker" + : > "$log" + : > "$tree_log" + status=0 + out=$(PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_REFUSE_STOP=1 FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 \ + FM_TMUX_LOG="$log" FM_FAKE_HERDR_LOG="$log" FM_ENDPOINT_TARGET="$endpoint_named" \ + FM_ENDPOINT_INDEX_ALIAS="$target" FM_LAUNCH_FILE="$case_root/unused.launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$childwt" FM_HOST_PATH="$host" \ + FM_TREEHOUSE_LOG="$tree_log" "$ROOT/bin/fm-teardown.sh" domain --force 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "forced secondmate teardown accepted unsafe $backend host-child cleanup" + assert_present "$subhome/state/child.meta" "recursive $backend host-child cleanup removed child metadata" + assert_present "$childwt" "recursive $backend host-child cleanup removed the child worktree" + assert_present "$home/state/domain.meta" "recursive $backend host-child cleanup removed parent metadata" + assert_contains "$out" 'refusing destructive cleanup' \ + "recursive $backend host-child cleanup did not explain the refusal" + assert_no_grep 'return --force' "$tree_log" \ + "recursive $backend host-child cleanup recycled a worktree" + if [ "$backend" = herdr ]; then + assert_present "$subhome/state/child.herdr-presentation" \ + "recursive Herdr cleanup removed the presentation journal" + assert_no_grep 'pane close' "$log" \ + "recursive Herdr cleanup bypassed direct focus-preserving teardown" + fi + done + pass "forced secondmate teardown preserves tmux aliases and projected Herdr children during recursive cleanup" +} + +test_secondmate_force_teardown_closes_host_root_herdr_child() { + local case_root="$TMP/recursive-host-herdr-child" home subhome childproj childwt host fb log current tree_log closed status=0 out + home="$case_root/home" + subhome="$case_root/subhome" + childproj="$subhome/projects/alpha" + childwt="$case_root/child-worktree" + host="$case_root/host" + mkdir -p "$home/state" "$home/data" "$home/config" "$subhome/state" "$host" + : > "$host/AGENTS.md" + printf 'domain\n' > "$subhome/.fm-secondmate-home" + fm_write_meta "$home/state/domain.meta" \ + 'window=test-session:fm-domain' "worktree=$subhome" "project=$subhome" \ + 'harness=echo' 'kind=secondmate' 'mode=secondmate' "home=$subhome" 'projects=alpha' + fm_write_meta "$subhome/state/child.meta" \ + 'window=child-session:w1:p2' 'endpoint_task_id=child' "worktree=$childwt" "project=$childproj" \ + "host_root=$host" 'backend=herdr' 'herdr_session=child-session' \ + 'herdr_workspace_id=w1' 'herdr_tab_id=w1:t2' 'herdr_pane_id=w1:p2' \ + 'harness=echo' 'kind=ship' 'mode=no-mistakes' + fb=$(make_fakebin "$case_root/fake") + cat > "$fb/herdr" <> "\${FM_FAKE_HERDR_LOG:?}" +case "\${1:-} \${2:-}" in + "session list") printf '%s\n' '{"sessions":[{"name":"child-session","running":true,"socket_path":"$case_root/child.sock"}]}' ;; + "workspace list") printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1"}]}}' ;; + "tab get") printf '%s\n' '{"result":{"tab":{"tab_id":"w1:t2","workspace_id":"w1","label":"fm-child"}}}' ;; + "pane close") : > "\${FM_FAKE_HERDR_CLOSED:?}" ;; + "pane get") + if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then + printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2 + exit 1 + fi + printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p2","tab_id":"w1:t2","workspace_id":"w1"}}}' + ;; +esac +SH + chmod +x "$fb/herdr" + log="$case_root/backend.log" + current="$case_root/current" + tree_log="$case_root/treehouse.log" + closed="$case_root/closed" + printf '%s\n' "$subhome" > "$current" + : > "$current.endpoint" + : > "$log" + : > "$tree_log" + out=$(PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 FM_TMUX_LOG="$log" \ + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + FM_ENDPOINT_TARGET=test-session:fm-domain FM_LAUNCH_FILE="$case_root/unused.launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$childwt" FM_HOST_PATH="$host" \ + FM_TREEHOUSE_LOG="$tree_log" "$ROOT/bin/fm-teardown.sh" domain --force 2>&1) || status=$? + expect_code 0 "$status" "forced secondmate teardown failed to close a host-root Herdr child: $out" + assert_present "$closed" "recursive host-root Herdr cleanup did not close the child pane" + assert_grep 'pane close w1:p2' "$log" "recursive host-root Herdr cleanup did not use the serialized exact-pane close" + assert_absent "$subhome" "recursive host-root Herdr cleanup retained the empty secondmate home" + assert_absent "$home/state/domain.meta" "recursive host-root Herdr cleanup retained parent metadata" + pass "forced secondmate teardown closes host-root Herdr children under the preflight lock" +} + +test_secondmate_force_teardown_refuses_recursive_host_overlap_before_mutation() { + local case_root="$TMP/recursive-host-overlap" home subhome childproj host fb log current tree_log out status=0 + home="$case_root/home" + subhome="$case_root/subhome" + childproj="$subhome/projects/alpha" + host="$case_root/host" + mkdir -p "$home/state" "$home/data" "$home/config" "$subhome/state" + fm_git_worktree "$childproj" "$host" recursive-overlap + : > "$host/AGENTS.md" + printf 'domain\n' > "$subhome/.fm-secondmate-home" + fm_write_meta "$home/state/domain.meta" \ + 'window=test-session:fm-domain' "worktree=$subhome" "project=$subhome" \ + 'harness=echo' 'kind=secondmate' 'mode=secondmate' "home=$subhome" 'projects=alpha' +fm_write_meta "$subhome/state/child.meta" \ +'window=@1' 'endpoint_task_id=child' "worktree=$host" "project=$childproj" \ +"host_root=$host" 'tmux_window_marker=child-marker' 'tmux_socket_path=/tmp/fm-test.sock' \ +'harness=echo' 'kind=ship' 'mode=no-mistakes' + fb=$(make_fakebin "$case_root/fake") + log="$case_root/backend.log" + current="$case_root/current" + tree_log="$case_root/treehouse.log" + printf '%s\n' "$host" > "$current" + : > "$current.endpoint" + : > "$log" + : > "$tree_log" + + out=$(PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$case_root/unused.launch" FM_CURRENT_PATH="$current" \ + FM_TARGET_PATH="$host" FM_HOST_PATH="$host" FM_TREEHOUSE_LOG="$tree_log" \ + "$ROOT/bin/fm-teardown.sh" domain --force 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "recursive teardown accepted an overlapping child host and target" + assert_contains "$out" 'child host and target roots overlap' \ + "recursive overlap refusal did not explain the unsafe child roots" + assert_present "$current.endpoint" "recursive overlap refusal stopped the child endpoint" + assert_present "$subhome/state/child.meta" "recursive overlap refusal removed child recovery metadata" + assert_present "$home/state/domain.meta" "recursive overlap refusal removed parent recovery metadata" + assert_present "$host" "recursive overlap refusal removed the authoritative host" + [ ! -s "$log" ] || fail "recursive overlap refusal touched the child endpoint" + [ ! -s "$tree_log" ] || fail "recursive overlap refusal invoked treehouse" + pass "recursive teardown rejects overlapping child host and target before mutation" +} + +test_task_actions_use_recorded_host_root() { + local host="$TMP/recorded-host" wrong="$TMP/wrong-host" home="$TMP/recorded-home" fb log current out status=0 + make_host "$host"; make_host "$wrong"; mkdir -p "$home/state" "$home/config" + printf 'window=test-session:fm-lane\nworktree=/tmp/target\nhost_root=%s\nproject=/tmp/project\nkind=ship\n' "$host" > "$home/state/lane.meta" + fb=$(make_fakebin "$TMP/fake-recorded-host") + log="$TMP/recorded-host.log"; current="$TMP/recorded-host.current" + : > "$log"; printf '%s\n' "$host" > "$current"; : > "$current.endpoint" + out=$(cd "$wrong" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$wrong" \ + FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$TMP/unused.launch" FM_CURRENT_PATH="$current" FM_TARGET_PATH=/tmp/target FM_HOST_PATH="$host" \ + "$ROOT/bin/fm-send.sh" lane hello 2>&1) || status=$? + expect_code 2 "$status" "send must reject an ambient host that differs from task ownership" + assert_contains "$out" 'does not match task metadata host_root' "send did not identify recorded host ownership" + assert_no_grep 'send-keys' "$log" "send touched the endpoint before recorded-host validation" + + : > "$log"; status=0 + out=$(cd "$wrong" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$wrong" \ + FM_TMUX_LOG="$log" FM_ENDPOINT_TARGET=test-session:fm-lane FM_ENDPOINT_ALIAS=test-session:fm-lane.0 \ + FM_LAUNCH_FILE="$TMP/unused.launch" FM_CURRENT_PATH="$current" FM_TARGET_PATH=/tmp/target FM_HOST_PATH="$host" \ + "$ROOT/bin/fm-peek.sh" test-session:fm-lane.0 2>&1) || status=$? + expect_code 2 "$status" "peek must bind an equivalent tmux pane alias to recorded task ownership" + assert_contains "$out" 'does not match task metadata host_root' "peek alias did not identify recorded host ownership" + assert_no_grep 'capture-pane' "$log" "peek alias captured the endpoint before recorded-host validation" + + : > "$log"; status=0 + out=$(cd "$wrong" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$wrong" \ + FM_TMUX_LOG="$log" FM_ENDPOINT_TARGET=test-session:fm-lane FM_ENDPOINT_ALIAS=test-session:fm-lane.0 \ + FM_LAUNCH_FILE="$TMP/unused.launch" FM_CURRENT_PATH="$current" FM_TARGET_PATH=/tmp/target FM_HOST_PATH="$host" \ + "$ROOT/bin/fm-send.sh" test-session:fm-lane.0 hello 2>&1) || status=$? + expect_code 2 "$status" "send must bind an equivalent tmux pane alias to recorded task ownership" + assert_contains "$out" 'does not match task metadata host_root' "send alias did not identify recorded host ownership" + assert_no_grep 'send-keys' "$log" "send alias touched the endpoint before recorded-host validation" + + : > "$log"; status=0 + out=$(cd "$wrong" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$wrong" \ + FM_TMUX_LOG="$log" FM_TMUX_NUMERIC_NAME_COLLISION=1 \ + FM_ENDPOINT_TARGET=test-session:fm-lane FM_ENDPOINT_INDEX_ALIAS=test-session:1 \ + FM_LAUNCH_FILE="$TMP/unused.launch" FM_CURRENT_PATH="$current" FM_TARGET_PATH=/tmp/target FM_HOST_PATH="$host" \ + "$ROOT/bin/fm-peek.sh" test-session:1 2>&1) || status=$? + expect_code 2 "$status" "peek must prefer a tmux window index over a colliding numeric name" + assert_contains "$out" 'does not match task metadata host_root' "peek numeric collision lost recorded host ownership" + assert_no_grep 'capture-pane' "$log" "peek numeric collision captured the endpoint before recorded-host validation" + + : > "$log"; status=0 + out=$(cd "$wrong" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$wrong" \ + FM_TMUX_LOG="$log" FM_TMUX_NUMERIC_NAME_COLLISION=1 \ + FM_ENDPOINT_TARGET=test-session:fm-lane FM_ENDPOINT_INDEX_ALIAS=test-session:1 \ + FM_LAUNCH_FILE="$TMP/unused.launch" FM_CURRENT_PATH="$current" FM_TARGET_PATH=/tmp/target FM_HOST_PATH="$host" \ + "$ROOT/bin/fm-send.sh" test-session:1 hello 2>&1) || status=$? + expect_code 2 "$status" "send must prefer a tmux window index over a colliding numeric name" + assert_contains "$out" 'does not match task metadata host_root' "send numeric collision lost recorded host ownership" + assert_no_grep 'send-keys' "$log" "send numeric collision touched the endpoint before recorded-host validation" + + status=0 + out=$(cd "$wrong" && PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$wrong" \ + FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$TMP/unused.launch" FM_CURRENT_PATH="$current" FM_TARGET_PATH=/tmp/target FM_HOST_PATH="$host" \ + "$ROOT/bin/fm-teardown.sh" lane --force 2>&1) || status=$? + expect_code 2 "$status" "teardown must reject an ambient host that differs from task ownership" + assert_present "$home/state/lane.meta" "teardown changed task data before recorded-host validation" + pass "task actions bind to recorded physical host ownership before endpoint or task mutation" +} + +test_spawn_rejects_old_brief_and_secondmate_clears_roots() { + local host="$TMP/reject-host" home="$TMP/reject-home" project="$TMP/reject-target" subhome="$TMP/secondmate-home" unsethome="$TMP/secondmate-unset-home" aborthome="$TMP/secondmate-abort-home" fb log current launch meta out status=0 + make_host "$host"; mkdir -p "$home/data/old" "$home/state" "$home/config"; printf 'old brief\n' > "$home/data/old/brief.md"; make_project_with_origin "$project" + out=$(cd "$host" && FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_HOST_ROOT="$host" "$ROOT/bin/fm-spawn.sh" old "$project" codex --mode no-mistakes --yolo off 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "host spawn accepted a cwd-relative old brief" + assert_contains "$out" 'requires a host-root brief' "old-brief rejection was not explicit" + + mkdir -p "$subhome/bin" "$subhome/data" + printf '# Secondmate\n' > "$subhome/AGENTS.md" + printf 'mate\n' > "$subhome/.fm-secondmate-home" + printf 'charter\n' > "$subhome/data/charter.md" + mkdir -p "$unsethome/bin" "$unsethome/data" "$aborthome/bin" "$aborthome/data" + printf '# Secondmate\n' > "$unsethome/AGENTS.md" + printf 'mate-unset\n' > "$unsethome/.fm-secondmate-home" + printf 'charter\n' > "$unsethome/data/charter.md" + printf '# Secondmate\n' > "$aborthome/AGENTS.md" + printf 'mate-abort\n' > "$aborthome/.fm-secondmate-home" + printf 'charter\n' > "$aborthome/data/charter.md" + fb=$(make_fakebin "$TMP/fake-secondmate") + log="$TMP/secondmate.log"; current="$TMP/secondmate.current"; launch="$TMP/secondmate.launch" + printf '%s\n' "$subhome" > "$current" + (cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_HOST_ROOT="$host" FM_TARGET_WORKTREE=/should-not-leak FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$subhome" FM_HOST_PATH="$host" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" mate "$subhome" codex --secondmate >/dev/null 2>&1) + assert_contains "$(cat "$launch")" 'FM_HOST_ROOT= FM_TARGET_WORKTREE=' "secondmate launch did not clear both host variables" + meta="$home/state/mate.meta" + assert_no_grep 'host_root=' "$meta" "secondmate metadata inherited host_root" + assert_grep "worktree=$subhome" "$meta" "secondmate lost its isolated-home worktree" + + current="$TMP/secondmate-unset.current" + : > "$launch"; printf '%s\n' "$unsethome" > "$current" + (cd "$host" && env -u FM_HOST_ROOT -u FM_TARGET_WORKTREE PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 \ + FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_TMUX_LOG="$log" FM_LAUNCH_FILE="$launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$unsethome" FM_HOST_PATH="$host" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" mate-unset "$unsethome" codex --secondmate >/dev/null 2>&1) + assert_not_contains "$(cat "$launch")" 'FM_HOST_ROOT=' "unset-mode secondmate launch changed its historical environment prefix" + assert_not_contains "$(cat "$launch")" 'FM_TARGET_WORKTREE=' "unset-mode secondmate launch set a new empty target variable" + + current="$TMP/secondmate-abort.current" + : > "$log"; printf '%s\n' "$aborthome" > "$current"; status=0 + out=$(cd "$host" && PATH="$fb:$PATH" FM_SPAWN_NO_GUARD=1 FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_HOST_ROOT="$host" FM_FAIL_LAUNCH_SEND=1 FM_REFUSE_STOP=1 \ + FM_BACKEND_STOP_ATTEMPTS=1 FM_BACKEND_STOP_DELAY=0 FM_TMUX_LOG="$log" \ + FM_ENDPOINT_TARGET=test-session:fm-mate-abort FM_LAUNCH_FILE="$TMP/mate-abort.launch" \ + FM_CURRENT_PATH="$current" FM_TARGET_PATH="$aborthome" FM_HOST_PATH="$host" TMUX=fake \ + "$ROOT/bin/fm-spawn.sh" mate-abort "$aborthome" codex --secondmate 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "injected secondmate stop failure unexpectedly succeeded" + meta="$home/state/mate-abort.meta" + assert_grep 'mode=secondmate' "$meta" "failed secondmate rollback lost its fixed delivery mode" + assert_grep 'yolo=off' "$meta" "failed secondmate rollback lost its fixed approval posture" + rm -rf "/tmp/fm-mate-abort" + rm -f "$meta" "$current.endpoint" + pass "host spawn rejects old briefs, clears inherited secondmate roots, and preserves unset launches" +} + +test_resolution_and_validation +test_ambiguous_host_owner_is_rejected +test_host_owner_publication_is_atomic +test_session_cwd_mismatch_precedes_mutation +test_unset_session_cannot_take_over_host_owned_home +test_host_command_rendering +test_brief_variants +test_host_local_only_rejected_before_mutation +test_spawn_separates_roots +test_duplicate_spawn_preserves_existing_task +test_unset_herdr_retry_reaches_existing_identity_checks +test_spawn_rejects_host_as_target +test_orca_active_cwd_probe +test_all_harnesses_add_one_task_safeguard +test_mutators_require_host_cwd +test_secondmate_actions_keep_supervisor_host_authority +test_spawn_rollback_is_transactional +test_decision_actions_use_durable_host_owner +test_host_teardown_requires_confirmed_stop +test_host_teardown_refuses_recorded_overlap_before_mutation +test_secondmate_force_teardown_preserves_host_children_during_recursive_cleanup +test_secondmate_force_teardown_closes_host_root_herdr_child +test_secondmate_force_teardown_refuses_recursive_host_overlap_before_mutation +test_task_actions_use_recorded_host_root +test_spawn_rejects_old_brief_and_secondmate_clears_roots diff --git a/tests/fm-host-setup.test.sh b/tests/fm-host-setup.test.sh new file mode 100755 index 0000000000..1c87ed77fb --- /dev/null +++ b/tests/fm-host-setup.test.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# Focused contract and real-Pi startup checks for the single-host Pi activator. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SETUP="$ROOT/bin/fm-host-setup.sh" +LAB=$(fm_test_tmproot fm-host-setup) +PI_DIR="$LAB/pi-home" +HOST="$LAB/host" +FM_HOME_DIR="$LAB/firstmate-home" +FAKE_HOME="$LAB/user-home" +OUTSIDE="$LAB/outside" + +mkdir -p "$HOST" "$FM_HOME_DIR" "$FAKE_HOME" "$OUTSIDE" +printf '# Host policy\n\nHOST_POLICY_SENTINEL\n' >"$HOST/AGENTS.md" + +run_setup() { + env -u FM_ROOT_OVERRIDE -u FM_HOME -u FM_HOST_ROOT -u FM_BACKEND \ + PI_CODING_AGENT_DIR="$PI_DIR" HOME="$FAKE_HOME" "$SETUP" "$@" +} + +assert_present "$SETUP" "bin/fm-host-setup.sh is missing" +[ -x "$SETUP" ] || fail "fm-host-setup.sh must be executable" + +before=$(find "$HOST" -mindepth 1 -maxdepth 3 -print | sort) +install_out=$(run_setup install "$HOST" --home "$FM_HOME_DIR" --backend herdr) +after=$(find "$HOST" -mindepth 1 -maxdepth 3 -print | sort) +[ "$before" = "$after" ] || fail "install edited the host repository" +ACTIVATOR="$PI_DIR/extensions/fm-firstmate-host.ts" +assert_present "$ACTIVATOR" "install did not create the Pi activator under PI_CODING_AGENT_DIR" +assert_grep '// firstmate-host-activator managed-v1' "$ACTIVATOR" "activator ownership marker is missing" +assert_contains "$install_out" "worker backend: herdr" "install did not report the configured backend" +cp "$ACTIVATOR" "$LAB/activator.before" +second_out=$(run_setup install "$HOST" --home "$FM_HOME_DIR" --backend herdr) +cmp -s "$LAB/activator.before" "$ACTIVATOR" || fail "idempotent install changed the activator" +assert_contains "$second_out" "unchanged" "idempotent install did not report unchanged" +status_out=$(run_setup status) +assert_contains "$status_out" "host: $(cd "$HOST" && pwd -P)" "status did not report the physical host" +assert_contains "$status_out" "firstmate home: $(cd "$FM_HOME_DIR" && pwd -P)" "status did not report the FirstMate home" +pass "install is host-read-only, PI_CODING_AGENT_DIR-aware, and idempotent" + +WORKER_TARGET="$LAB/worker-target" +mkdir -p "$WORKER_TARGET" "$FM_HOME_DIR/state" +node --experimental-strip-types --input-type=module - "$ACTIVATOR" "$HOST" "$WORKER_TARGET" \ + "$(cd "$ROOT" && pwd -P)" "$(cd "$FM_HOME_DIR" && pwd -P)" <<'JS' +import { pathToFileURL } from "node:url"; +const [activator, host, target, root, home] = process.argv.slice(2); +process.chdir(host); +Object.assign(process.env, { + FM_ROOT_OVERRIDE: root, + FM_HOME: home, + FM_HOST_ROOT: host, + FM_BACKEND: "herdr", + FM_TARGET_WORKTREE: target, +}); +const events = []; +const pi = { on: (event) => events.push(event) }; +await (await import(pathToFileURL(activator).href)).default(pi); +if (events.length) throw new Error(`host worker registered supervisor events: ${events.join(", ")}`); +JS +assert_absent "$FM_HOME_DIR/state/.pi-turnend-extension-loaded" "host worker loaded the guard extension" +assert_absent "$FM_HOME_DIR/state/.pi-watch-extension-loaded" "host worker loaded the watch extension" +pass "host-root Pi workers do not load FirstMate supervisor policy or extensions" + +CONFLICT_PI="$LAB/conflict-pi" +set +e +conflict_out=$(FM_HOME="$LAB/conflict" PI_CODING_AGENT_DIR="$CONFLICT_PI" HOME="$FAKE_HOME" \ + "$SETUP" install "$HOST" --home "$FM_HOME_DIR" --backend herdr 2>&1) +conflict_status=$? +set -e +[ "$conflict_status" -ne 0 ] || fail "install accepted a conflicting ambient FM_HOME" +assert_contains "$conflict_out" "FM_HOME is already set to a conflicting value" "ambient conflict error is unclear" +assert_absent "$CONFLICT_PI/extensions/fm-firstmate-host.ts" "ambient conflict wrote an activator" + +UNMANAGED_PI="$LAB/unmanaged-pi" +mkdir -p "$UNMANAGED_PI/extensions" +printf 'export default function () {}\n' >"$UNMANAGED_PI/extensions/fm-firstmate-host.ts" +set +e +unmanaged_out=$(env -u FM_ROOT_OVERRIDE -u FM_HOME -u FM_HOST_ROOT -u FM_BACKEND \ + PI_CODING_AGENT_DIR="$UNMANAGED_PI" HOME="$FAKE_HOME" \ + "$SETUP" install "$HOST" --home "$FM_HOME_DIR" 2>&1) +unmanaged_status=$? +set -e +[ "$unmanaged_status" -ne 0 ] || fail "install overwrote an unmanaged extension" +assert_contains "$unmanaged_out" "refusing to overwrite unmanaged" "unmanaged install refusal is unclear" +assert_grep 'export default function () {}' "$UNMANAGED_PI/extensions/fm-firstmate-host.ts" \ + "unmanaged extension content changed" +pass "install refuses ambient FM conflicts and unmanaged files" + +mkdir -p "$HOST/private-home" +for overlap_home in "$HOST/private-home" "$LAB"; do + OVERLAP_PI="$LAB/overlap-pi-$(basename "$overlap_home")" + set +e + overlap_out=$(env -u FM_ROOT_OVERRIDE -u FM_HOME -u FM_HOST_ROOT -u FM_BACKEND \ + PI_CODING_AGENT_DIR="$OVERLAP_PI" HOME="$FAKE_HOME" \ + "$SETUP" install "$HOST" --home "$overlap_home" --backend herdr 2>&1) + overlap_status=$? + set -e + [ "$overlap_status" -ne 0 ] || fail "install accepted host and FirstMate home overlap" + assert_contains "$overlap_out" "must not overlap FM_HOME" "host/home overlap refusal is unclear" + assert_absent "$OVERLAP_PI/extensions/fm-firstmate-host.ts" "host/home overlap wrote an activator" +done +pass "install rejects physical ancestor and descendant host/home overlap" + +if command -v pi >/dev/null 2>&1; then + mkdir -p "$HOST/.pi/extensions" "$HOST/.agents/skills/host-skill" "$FM_HOME_DIR/state" + cat >"$HOST/.agents/skills/host-skill/SKILL.md" <<'SKILL' +--- +name: host-skill +description: Host fixture skill. +--- + +# Host skill +SKILL + cat >"$HOST/.pi/extensions/host-probe.ts" <<'TS' +import { writeFileSync } from "node:fs"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +export default function (pi: ExtensionAPI) { + pi.registerCommand("host-probe", { + description: "Write the host activation probe", + handler: async (_args, ctx) => { + const options = ctx.getSystemPromptOptions(); + writeFileSync(process.env.HOST_PROBE_OUT!, JSON.stringify({ + cwd: ctx.cwd, + contextFiles: options.contextFiles?.map((file) => file.path) ?? [], + skills: options.skills?.map((skill) => skill.name) ?? [], + env: Object.fromEntries(["FM_ROOT_OVERRIDE", "FM_HOME", "FM_HOST_ROOT", "FM_BACKEND"].map((name) => [name, process.env[name]])), + })); + }, + }); +} +TS + PROBE="$LAB/host-probe.json" + RPC_OUT="$LAB/pi-rpc.out" + RPC_ERR="$LAB/pi-rpc.err" + ( + cd "$HOST" || exit 1 + printf '%s\n' '{"type":"prompt","message":"/host-probe"}' '{"type":"get_commands"}' | + env -u FM_ROOT_OVERRIDE -u FM_HOME -u FM_HOST_ROOT -u FM_BACKEND \ + PI_CODING_AGENT_DIR="$PI_DIR" HOME="$FAKE_HOME" PI_OFFLINE=1 HOST_PROBE_OUT="$PROBE" \ + pi --approve --mode rpc --no-session >"$RPC_OUT" 2>"$RPC_ERR" + ) || fail "real Pi RPC host activation failed: $(cat "$RPC_ERR")" + assert_present "$PROBE" "host lifecycle extension did not run" + assert_present "$FM_HOME_DIR/state/.pi-turnend-extension-loaded" "FirstMate guard extension did not load" + assert_present "$FM_HOME_DIR/state/.pi-watch-extension-loaded" "FirstMate watch extension did not load" + assert_grep 'FirstMate active' "$RPC_OUT" "FirstMate-active status indicator was not emitted" + assert_grep 'skill:afk' "$RPC_OUT" "FirstMate internal skills were not discovered" + assert_grep 'skill:host-skill' "$RPC_OUT" "host skill discovery was replaced" + node -e ' + const fs = require("fs"); + const [probePath, host, root, home] = process.argv.slice(1); + const p = JSON.parse(fs.readFileSync(probePath, "utf8")); + if (p.cwd !== host) throw new Error("cwd mismatch: " + p.cwd); + if (!p.contextFiles.includes(host + "/AGENTS.md")) throw new Error("host AGENTS.md missing"); + if (!p.skills.includes("afk") || !p.skills.includes("host-skill")) throw new Error("skill sets did not compose"); + if (p.env.FM_ROOT_OVERRIDE !== root || p.env.FM_HOME !== home || p.env.FM_HOST_ROOT !== host || p.env.FM_BACKEND !== "herdr") { + throw new Error("environment mismatch: " + JSON.stringify(p.env)); + } + ' "$PROBE" "$(cd "$HOST" && pwd -P)" "$(cd "$ROOT" && pwd -P)" "$(cd "$FM_HOME_DIR" && pwd -P)" || + fail "real Pi host activation probe failed" + + RUNTIME_OVERLAP_PI="$LAB/runtime-overlap-pi" + mkdir -p "$RUNTIME_OVERLAP_PI/extensions" + node -e ' + const fs = require("fs"); + const [source, target, home, host] = process.argv.slice(1); + fs.writeFileSync(target, fs.readFileSync(source, "utf8").replaceAll(JSON.stringify(home), JSON.stringify(host))); + ' "$ACTIVATOR" "$RUNTIME_OVERLAP_PI/extensions/fm-firstmate-host.ts" \ + "$(cd "$FM_HOME_DIR" && pwd -P)" "$(cd "$HOST" && pwd -P)" + RUNTIME_OVERLAP_OUT="$LAB/runtime-overlap.out" + RUNTIME_OVERLAP_ERR="$LAB/runtime-overlap.err" + ( + cd "$HOST" || exit 1 + printf '%s\n' '{"type":"get_commands"}' | + env -u FM_ROOT_OVERRIDE -u FM_HOME -u FM_HOST_ROOT -u FM_BACKEND \ + PI_CODING_AGENT_DIR="$RUNTIME_OVERLAP_PI" HOME="$FAKE_HOME" PI_OFFLINE=1 \ + pi --mode rpc --no-session >"$RUNTIME_OVERLAP_OUT" 2>"$RUNTIME_OVERLAP_ERR" + ) || fail "real Pi runtime-overlap refusal failed: $(cat "$RUNTIME_OVERLAP_ERR")" + assert_grep 'FirstMate inactive' "$RUNTIME_OVERLAP_OUT" "runtime overlap did not leave FirstMate inactive" + assert_grep 'must not overlap FM_HOME' "$RUNTIME_OVERLAP_ERR" "runtime overlap refusal was unclear" + assert_absent "$HOST/state/.pi-turnend-extension-loaded" "runtime overlap loaded the guard into the host" + assert_absent "$HOST/state/.pi-watch-extension-loaded" "runtime overlap loaded the watcher into the host" + + rm -f "$FM_HOME_DIR/state/.pi-turnend-extension-loaded" "$FM_HOME_DIR/state/.pi-watch-extension-loaded" + OUTSIDE_OUT="$LAB/pi-outside.out" + ( + cd "$OUTSIDE" || exit 1 + printf '%s\n' '{"type":"get_commands"}' | + env -u FM_ROOT_OVERRIDE -u FM_HOME -u FM_HOST_ROOT -u FM_BACKEND \ + PI_CODING_AGENT_DIR="$PI_DIR" HOME="$FAKE_HOME" PI_OFFLINE=1 \ + pi --mode rpc --no-session >"$OUTSIDE_OUT" 2>/dev/null + ) || fail "real Pi outside-host dormancy probe failed" + assert_no_grep 'FirstMate active' "$OUTSIDE_OUT" "activator was not dormant outside the configured host" + assert_no_grep 'skill:afk' "$OUTSIDE_OUT" "activator exposed FirstMate skills outside the configured host" + assert_absent "$FM_HOME_DIR/state/.pi-turnend-extension-loaded" "guard extension loaded outside the configured host" + assert_absent "$FM_HOME_DIR/state/.pi-watch-extension-loaded" "watch extension loaded outside the configured host" + pass "real Pi preserves host resources, loads FirstMate resources, and stays dormant elsewhere" +else + printf 'skip: pi not found for real host activator startup probe\n' +fi + +printf 'keep\n' >"$PI_DIR/extensions/unrelated.ts" +uninstall_out=$(run_setup uninstall) +assert_contains "$uninstall_out" "removed" "uninstall did not report removal" +assert_absent "$ACTIVATOR" "uninstall left the owned activator" +assert_present "$PI_DIR/extensions/unrelated.ts" "uninstall removed an unrelated Pi extension" +run_setup uninstall >/dev/null || fail "repeated uninstall was not idempotent" +pass "uninstall removes only its owned file" diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 869bfc93a1..d4e74a003a 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -12,8 +12,10 @@ TMP_ROOT=$(fm_test_tmproot fm-kimi-harness) KIMI_RUNTIME_TASK_TMP= PYTHON_BIN=$(command -v python3) || fail "test needs python3" PYTHON_BIN_DIR=$(dirname "$PYTHON_BIN") +NODE_BIN=$(command -v node) || fail "test needs node" +NODE_BIN_DIR=$(dirname "$NODE_BIN") JQ_BIN=$(command -v jq) || fail "test needs jq" -BASE_PATH=${FM_TEST_BASE_PATH:-$PYTHON_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin} +BASE_PATH=${FM_TEST_BASE_PATH:-$NODE_BIN_DIR:$PYTHON_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin} cleanup_kimi_harness() { [ -z "$KIMI_RUNTIME_TASK_TMP" ] || rm -rf "$KIMI_RUNTIME_TASK_TMP" @@ -27,8 +29,10 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u -printf '%s\n' "$*" >> "$FM_FAKE_TMUX_CALL_LOG" -state=$(cat "$FM_FAKE_KIMI_STATE" 2>/dev/null || true) +call_log=${FM_FAKE_TMUX_CALL_LOG:-/dev/null} +state_file=${FM_FAKE_KIMI_STATE:-/dev/null} +printf '%s\n' "$*" >> "$call_log" +state=$(cat "$state_file" 2>/dev/null || true) fake_screen() { case "$state" in ready) @@ -379,6 +383,14 @@ test_kimi_hook_is_silent_and_requires_registered_workspace_token() { [ "$snapshot_before" = "$snapshot_after" ] || fail "Kimi hook wrote inside a tokenless workspace" assert_absent "$target" "tokenless Kimi hook invocation touched a task marker" + out=$(printf '{"hook_event_name":"Stop","session_id":"host-root-crew","cwd":"%s","stop_hook_active":false}\n' "$no_token" \ + | HOME="$HOME_DIR" FM_KIMI_TURNEND_TOKEN="$token" bash "$hook" 2>&1) + rc=$? + expect_code 0 "$rc" "launch-scoped Kimi hook invocation did not exit zero" + [ -z "$out" ] || fail "launch-scoped Kimi hook invocation printed output: $out" + assert_present "$target" "launch-scoped Kimi token did not touch the turn-end marker" + rm "$target" + printf 'token=%s\n' "$token" > "$WT_DIR/.fm-kimi-turnend" out=$(printf '{"hook_event_name":"Stop","session_id":"crew","cwd":"%s","stop_hook_active":false}\n' "$WT_DIR" \ | HOME="$HOME_DIR" bash "$hook" 2>&1) @@ -396,7 +408,7 @@ test_kimi_hook_is_silent_and_requires_registered_workspace_token() { expect_code 0 "$rc" "Kimi hook without jq must still exit zero" [ -z "$out" ] || fail "Kimi hook without jq printed output: $out" assert_absent "$target" "Kimi hook without jq touched the turn-end marker" - pass "Kimi hook stays silent and inert without a Firstmate registry token" + pass "Kimi hook accepts launch-scoped or workspace tokens and stays inert without either" } test_kimi_spawn_refuses_unsafe_global_config_before_pane_creation() { @@ -425,11 +437,11 @@ test_kimi_teardown_removes_pointer_and_registry_token() { expect_code 0 "$rc" "Kimi spawn should succeed before teardown" token=$(sed -n 's/^token=//p' "$WT_DIR/.fm-kimi-turnend") - HOME="$HOME_DIR" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$HOME_DIR" \ + out=$(HOME="$HOME_DIR" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$HOME_DIR" \ FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ FM_SPAWN_NO_GUARD=1 PATH="$FAKEBIN_DIR:$BASE_PATH" \ - "$TEARDOWN" "$id" --force >/dev/null 2>&1 || fail "Kimi teardown failed" + "$TEARDOWN" "$id" --force 2>&1) || fail "Kimi teardown failed: $out" assert_absent "$WT_DIR/.fm-kimi-turnend" "Kimi token pointer survived teardown" assert_absent "$HOME_DIR/.kimi-code/fm-turn-end.d/$token" "Kimi registry token survived teardown" assert_absent "$HOME_DIR/state/$id.kimi-turnend-token" "Kimi token state survived teardown" diff --git a/tests/fm-pi-primary-types.test.sh b/tests/fm-pi-primary-types.test.sh index 68d20afd49..eff64d54e7 100755 --- a/tests/fm-pi-primary-types.test.sh +++ b/tests/fm-pi-primary-types.test.sh @@ -4,24 +4,28 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -command -v npm >/dev/null 2>&1 || { echo "skip: npm not found for Pi extension typecheck"; exit 0; } -command -v tsc >/dev/null 2>&1 || { echo "skip: tsc not found for Pi extension typecheck"; exit 0; } +command -v npm >/dev/null 2>&1 || { + echo "skip: npm not found for Pi extension typecheck" + exit 0 +} +HAS_TSC=1 +command -v tsc >/dev/null 2>&1 || HAS_TSC=0 PI_PACKAGE_DIR=${FM_PI_PACKAGE_DIR:-"$(npm root -g)/@earendil-works/pi-coding-agent"} if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then - echo "skip: installed @earendil-works/pi-coding-agent package not found" - exit 0 + echo "skip: installed @earendil-works/pi-coding-agent package not found" + exit 0 fi -if [ ! -d "$PI_PACKAGE_DIR/node_modules/typebox" ] || \ - [ ! -d "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" ] || \ - [ ! -d "$PI_PACKAGE_DIR/node_modules/@types/node" ]; then - echo "not ok - installed Pi package is missing pi-tui, typebox, or Node declarations" >&2 - exit 1 +if [ ! -d "$PI_PACKAGE_DIR/node_modules/typebox" ] || + [ ! -d "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" ] || + [ ! -d "$PI_PACKAGE_DIR/node_modules/@types/node" ]; then + echo "not ok - installed Pi package is missing pi-tui, typebox, or Node declarations" >&2 + exit 1 fi TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-pi-primary-types.XXXXXX") cleanup() { - rm -rf "$TMP_ROOT" + rm -rf "$TMP_ROOT" } trap cleanup EXIT @@ -29,6 +33,7 @@ mkdir -p "$TMP_ROOT/lib" "$TMP_ROOT/node_modules/@earendil-works" "$TMP_ROOT/nod cp "$ROOT/.pi/extensions/fm-calm.ts" "$TMP_ROOT/fm-calm.ts" cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$TMP_ROOT/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$TMP_ROOT/fm-primary-turnend-guard.ts" +cp "$ROOT/.pi/extensions/lib/fm-host-activator.ts" "$TMP_ROOT/lib/fm-host-activator.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" "$TMP_ROOT/lib/fm-calm-assistant-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$TMP_ROOT/lib/fm-calm-operational-user-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$TMP_ROOT/lib/fm-calm-visibility.ts" @@ -37,12 +42,14 @@ cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$TMP_ROOT/lib/fm-operatio ln -s "$PI_PACKAGE_DIR" "$TMP_ROOT/node_modules/@earendil-works/pi-coding-agent" ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$TMP_ROOT/node_modules/@earendil-works/pi-tui" ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$TMP_ROOT/node_modules/typebox" +ln -s "$PI_PACKAGE_DIR/node_modules/marked" "$TMP_ROOT/node_modules/marked" +ln -s "$PI_PACKAGE_DIR/node_modules/get-east-asian-width" "$TMP_ROOT/node_modules/get-east-asian-width" ln -s "$PI_PACKAGE_DIR/node_modules/@types/node" "$TMP_ROOT/node_modules/@types/node" -cat > "$TMP_ROOT/package.json" <<'JSON' +cat >"$TMP_ROOT/package.json" <<'JSON' {"type":"module"} JSON -cat > "$TMP_ROOT/tsconfig.json" <<'JSON' +cat >"$TMP_ROOT/tsconfig.json" <<'JSON' { "compilerOptions": { "allowImportingTsExtensions": true, @@ -58,6 +65,31 @@ cat > "$TMP_ROOT/tsconfig.json" <<'JSON' } JSON -tsc -p "$TMP_ROOT/tsconfig.json" || exit 1 +if [ "$HAS_TSC" -eq 1 ]; then + tsc -p "$TMP_ROOT/tsconfig.json" || exit 1 +else + echo "skip: tsc not found for Pi extension typecheck" +fi +FM_TARGET_WORKTREE="$ROOT" EXT="$TMP_ROOT/fm-primary-pi-watch.ts" \ + node --input-type=module <<'JS' || exit 1 +import { pathToFileURL } from "node:url"; + +const registrations = []; +const pi = { + events: { on: (...args) => registrations.push(["events", ...args]) }, + on: (...args) => registrations.push(["on", ...args]), + registerCommand: (...args) => registrations.push(["command", ...args]), + registerTool: (...args) => registrations.push(["tool", ...args]), +}; +const extension = await import(`${pathToFileURL(process.env.EXT).href}?target-worker=${Date.now()}`); +extension.default(pi); +if (registrations.length !== 0) { + throw new Error(`target worker activated ${registrations.length} Pi watch registrations`); +} +JS version=$(jq -r '.version' "$PI_PACKAGE_DIR/package.json" 2>/dev/null || printf 'unknown') -printf 'ok - tracked Pi extensions pass strict no-emit typecheck against Pi %s\n' "$version" +if [ "$HAS_TSC" -eq 1 ]; then + printf 'ok - tracked Pi extensions typecheck and target workers leave primary watch supervision inert against Pi %s\n' "$version" +else + printf 'ok - target workers leave primary Pi watch supervision inert against Pi %s\n' "$version" +fi diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 9e29adc793..3e50f2e778 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -24,7 +24,8 @@ install_pi_watch_extension_fixture() { cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts" mkdir -p "$repo/bin" cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh" - chmod +x "$repo/bin/fm-operational-input.sh" + cp "$ROOT/bin/fm-wake-lib.sh" "$repo/bin/fm-wake-lib.sh" + chmod +x "$repo/bin/fm-operational-input.sh" "$repo/bin/fm-wake-lib.sh" cat > "$repo/node_modules/@earendil-works/pi-coding-agent/package.json" <<'JSON' {"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"} JSON @@ -409,6 +410,142 @@ EOF pass "Pi actionable close starts one successor before wake delivery settles" } +test_pi_busy_wakes_are_late_validated_and_superseded() { + local repo home plugin log trigger_two trigger_three restore_waiting successor_ready rotate_ready stop out status + repo="$TMP_ROOT/pi-keyed-wake-root" + home="$TMP_ROOT/pi-keyed-wake-home" + log="$TMP_ROOT/pi-keyed-wake.log" + trigger_two="$TMP_ROOT/pi-keyed-wake.two" + trigger_three="$TMP_ROOT/pi-keyed-wake.three" + restore_waiting="$TMP_ROOT/pi-keyed-wake.restore-waiting" + successor_ready="$TMP_ROOT/pi-keyed-wake.successor-ready" + rotate_ready="$TMP_ROOT/pi-keyed-wake.rotate-ready" + stop="$TMP_ROOT/pi-keyed-wake.stop" + mkdir -p "$repo/bin" "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" +count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]') +case "$count" in + 1) + printf 'watcher: started pid=%s (beacon fresh)\n' "$$" + printf '1\t1\tsignal\ttask.status\tsignal: cycle one\n' > "$FM_HOME/state/.wake-queue" + printf 'signal: cycle one\n' + exit 0 + ;; + 2) + printf 'watcher: started pid=%s (beacon fresh)\n' "$$" + while [ ! -e "$FM_TRIGGER_TWO" ]; do sleep 0.02; done + printf '2\t2\tsignal\ttask.status\tsignal: cycle two\n' >> "$FM_HOME/state/.wake-queue" + printf 'signal: cycle two\n' + exit 0 + ;; + 3) + : > "$FM_RESTORE_WAITING" + while [ ! -e "$FM_SUCCESSOR_READY" ]; do sleep 0.02; done + printf 'watcher: started pid=%s (beacon fresh)\n' "$$" + while [ ! -e "$FM_TRIGGER_THREE" ]; do sleep 0.02; done + printf '3\t3\tsignal\ttask.status\tsignal: cycle three\n' >> "$FM_HOME/state/.wake-queue" + printf 'signal: cycle three\n' + exit 0 + ;; + *) + printf 'watcher: started pid=%s (beacon fresh)\n' "$$" + trap 'exit 0' TERM INT + while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done + ;; +esac +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" \ + FM_TRIGGER_TWO="$trigger_two" FM_TRIGGER_THREE="$trigger_three" \ + FM_RESTORE_WAITING="$restore_waiting" FM_SUCCESSOR_READY="$successor_ready" \ + FM_ROTATE_READY="$rotate_ready" FM_STOP_FILE="$stop" \ + node --input-type=module 2>&1 <<'EOF' +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +let tool = null; +const handlers = new Map(); +const piQueue = ["captain follow-up"]; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async (message) => { + piQueue.push(message); + }, +}; +const rows = () => existsSync(process.env.FM_ARM_LOG) + ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n") + : []; +async function waitFor(predicate, message) { + for (let i = 0; i < 500; i += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(message); +} +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +await handlers.get("agent_start")?.({}, {}); +await tool.execute("tool-call-keyed-wake", {}, undefined, undefined, {}); +await waitFor(() => rows().length >= 2, "first actionable cycle did not restore its successor"); +if (piQueue.length !== 1) throw new Error(`busy cycle entered Pi's queue early: ${piQueue.join(" | ")}`); + +const rotator = spawn("bash", ["-lc", '. "$FM_ROOT_OVERRIDE/bin/fm-wake-lib.sh"; trap \'fm_lock_release "$FM_WAKE_QUEUE_LOCK"\' EXIT; fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK"; mv "$FM_WAKE_QUEUE" "$FM_WAKE_QUEUE.rotated"; : > "$FM_ROTATE_READY"; sleep 0.2; : > "$FM_WAKE_QUEUE"; rm -f "$FM_WAKE_QUEUE.rotated"'], { + env: process.env, + stdio: "ignore", +}); +const rotationComplete = new Promise((resolve, reject) => { + rotator.on("error", reject); + rotator.on("close", (code) => code === 0 ? resolve() : reject(new Error(`queue rotator exited ${code}`))); +}); +await waitFor(() => existsSync(process.env.FM_ROTATE_READY), "queue rotation did not enter its serialized window"); +await handlers.get("agent_settled")?.({}, {}); +await rotationComplete; +if (piQueue.length !== 1 || piQueue[0] !== "captain follow-up") { + throw new Error(`consumed wake changed an unrelated captain follow-up: ${piQueue.join(" | ")}`); +} + +writeFileSync(process.env.FM_TRIGGER_TWO, "go\n"); +await waitFor(() => existsSync(process.env.FM_RESTORE_WAITING), "second actionable cycle did not start successor restoration"); +await handlers.get("agent_start")?.({}, {}); +writeFileSync(process.env.FM_SUCCESSOR_READY, "go\n"); +await waitFor(() => rows().length >= 3, "second actionable cycle did not restore its successor"); +await new Promise((resolve) => setTimeout(resolve, 80)); +if (piQueue.length !== 1) throw new Error(`wake ignored delivery-time busy state: ${piQueue.join(" | ")}`); +writeFileSync(process.env.FM_TRIGGER_THREE, "go\n"); +await waitFor(() => rows().length >= 4, "third actionable cycle did not restore its successor"); +if (piQueue.length !== 1) throw new Error(`superseded cycles entered Pi's queue early: ${piQueue.join(" | ")}`); +const latest = readFileSync(`${process.env.FM_HOME}/state/.wake-queue`, "utf8") + .split("\n") + .find((line) => line.includes("\t3\tsignal\t")); +if (!latest) throw new Error("latest keyed wake was not recorded"); +writeFileSync(`${process.env.FM_HOME}/state/.wake-queue`, `${latest}\n`); +await handlers.get("agent_settled")?.({}, {}); +await waitFor(() => piQueue.length === 2, "latest live wake was not delivered after settlement"); +if (piQueue[0] !== "captain follow-up") throw new Error(`captain follow-up was cleared: ${piQueue.join(" | ")}`); +if (!piQueue[1].includes("cycle three") || piQueue[1].includes("cycle two")) { + throw new Error(`superseded wake was delivered instead of the latest cycle: ${piQueue[1]}`); +} +writeFileSync(process.env.FM_STOP_FILE, "stop\n"); +EOF + ) + status=$? + expect_code 0 "$status" "Pi busy wake delivery must late-validate exact durable cycles without clearing captain follow-ups" + [ -z "$out" ] || fail "Pi keyed-wake test printed output: $out" + pass "Pi busy wakes are late-validated by durable cycle and superseded without clearing captain follow-ups" +} + test_pi_hung_successor_falls_back_to_typed_wake() { local repo home plugin log out status repo="$TMP_ROOT/pi-hung-successor-root" @@ -2155,6 +2292,7 @@ test_pi_tool_returns_agent_tool_result test_pi_redundant_tool_call_is_owned_noop test_pi_scheduled_retry_call_is_owned_noop test_pi_actionable_close_starts_single_successor_before_delivery +test_pi_busy_wakes_are_late_validated_and_superseded test_pi_hung_successor_falls_back_to_typed_wake test_pi_unretired_successor_falls_back_without_retry test_pi_late_unretired_close_resumes_supervision diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 03c6ce688e..d647fe37a4 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -47,6 +47,20 @@ file_mode() { fi } +write_stop_aware_tmux() { + local path=$1 + cat > "$path" <<'SH' +#!/usr/bin/env bash +stopped="${0}.stopped" +case "${1:-}" in + kill-window) : > "$stopped" ;; + display-message) [ ! -e "$stopped" ] ;; + *) exit 0 ;; +esac +SH + chmod 0700 "$path" +} + state_snapshot() { local state=$1 file ( @@ -590,11 +604,7 @@ test_valid_recording_and_merge_derivation() { fm_pr_poll_artifacts_valid "$dir/home/state" Task_A.1 "$POLL" \ || fail "safe lifecycle-compatible task ID did not publish an authenticated poll" rm -rf "$dir/wt" - cat > "$dir/fakebin/tmux" <<'SH' -#!/usr/bin/env bash -exit 0 -SH - chmod 0700 "$dir/fakebin/tmux" + write_stop_aware_tmux "$dir/fakebin/tmux" touch "$dir/home/state/.last-watcher-beat" FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \ "$TEARDOWN" Task_A.1 --force > "$dir/teardown.out" 2> "$dir/teardown.err" \ @@ -616,11 +626,7 @@ SH printf 'reserved migration evidence\n' \ > "$dir/home/state/.pr-check-quarantine/!noncanonical.check.evidence" chmod 0600 "$dir/home/state/.pr-check-quarantine/!noncanonical.check.evidence" - cat > "$dir/fakebin/tmux" <<'SH' -#!/usr/bin/env bash -exit 0 -SH - chmod 0700 "$dir/fakebin/tmux" + write_stop_aware_tmux "$dir/fakebin/tmux" touch "$dir/home/state/.last-watcher-beat" mkdir "$dir/home/state/$id.check.sh" set +e @@ -1857,11 +1863,7 @@ test_obligation_namespace_compatibility() { "project=$dir/project" \ 'kind=ship' \ 'mode=local-only' - cat > "$dir/fakebin/tmux" <<'SH' -#!/usr/bin/env bash -exit 0 -SH - chmod 0700 "$dir/fakebin/tmux" + write_stop_aware_tmux "$dir/fakebin/tmux" touch "$state/.last-watcher-beat" set +e FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \ @@ -2635,11 +2637,7 @@ test_teardown_removes_poll_artifacts() { chmod 0700 "$dir/home/state/.pr-check-quarantine" printf 'legacy\n' > "$dir/home/state/.pr-check-quarantine/task-a.check.abc123" chmod 0600 "$dir/home/state/.pr-check-quarantine/task-a.check.abc123" - cat > "$fakebin/tmux" <<'SH' -#!/usr/bin/env bash -exit 0 -SH - chmod +x "$fakebin/tmux" + write_stop_aware_tmux "$fakebin/tmux" touch "$dir/home/state/.last-watcher-beat" FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$fakebin:$BASE_PATH" \ @@ -2695,11 +2693,7 @@ SH printf 'noncanonical evidence\n' > "$dir/home/state/.pr-check-quarantine/!noncanonical.check.abc123" chmod 0600 "$dir/home/state/.pr-check-quarantine/invalid.check.abc123" \ "$dir/home/state/.pr-check-quarantine/!noncanonical.check.abc123" - cat > "$fakebin/tmux" <<'SH' -#!/usr/bin/env bash -exit 0 -SH - chmod +x "$fakebin/tmux" + write_stop_aware_tmux "$fakebin/tmux" touch "$dir/home/state/.last-watcher-beat" FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$fakebin:$BASE_PATH" \ diff --git a/tests/fm-review-diff.test.sh b/tests/fm-review-diff.test.sh index 2193772b9d..7ecc68428a 100755 --- a/tests/fm-review-diff.test.sh +++ b/tests/fm-review-diff.test.sh @@ -169,8 +169,49 @@ test_unreachable_pr_head_falls_back_with_warning() { pass "fm-review-diff falls back to local branch with a warning when PR head is unreachable" } +test_host_mode_scopes_every_git_command_to_worktree() { + local case_dir host fake_root fb git_log real_git out + case_dir=$(make_case host-scoped) + host="$case_dir/host" + fake_root="$case_dir/fake-root" + fb="$case_dir/fakebin" + git_log="$case_dir/git.log" + real_git=$(command -v git) + mkdir -p "$host" "$fake_root/bin" "$fb" + : > "$host/AGENTS.md" + cat > "$fake_root/bin/fm-guard.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fake_root/bin/fm-guard.sh" + cat > "$fb/git" <<'SH' +#!/usr/bin/env bash +printf 'git' >> "$FM_GIT_LOG" +for arg in "$@"; do printf '\037%s' "$arg" >> "$FM_GIT_LOG"; done +printf '\n' >> "$FM_GIT_LOG" +exec "$FM_REAL_GIT" "$@" +SH + chmod +x "$fb/git" + printf 'host-scoped\n' > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add feature.txt + git -C "$case_dir/wt" commit -qm "host scoped review" + write_task_meta "$case_dir" "host_root=$host" + + out=$(cd "$host" && PATH="$fb:$PATH" FM_REAL_GIT="$real_git" FM_GIT_LOG="$git_log" \ + FM_ROOT_OVERRIDE="$fake_root" FM_STATE_OVERRIDE="$case_dir/state" FM_HOST_ROOT="$host" \ + "$REVIEW_DIFF" task-x1 --stat) + + assert_contains "$out" "feature.txt" "host-scoped review did not inspect the task branch" + assert_contains "$(cat "$git_log")" $'git\x1f-C\x1f'"$case_dir/wt" \ + "host-scoped review issued no Git command against the target worktree" + assert_not_contains "$(cat "$git_log")" $'git\x1f-C\x1f'"$case_dir/project" \ + "host-scoped review issued a Git command against the primary project checkout" + pass "fm-review-diff scopes every host-mode Git command to the target worktree" +} + test_pr_meta_uses_pr_head_not_stale_local test_pr_meta_fetches_pull_head_without_recorded_sha test_stale_recorded_pr_head_loses_to_fetched_pull_head test_no_pr_meta_uses_local_branch test_unreachable_pr_head_falls_back_with_warning +test_host_mode_scopes_every_git_command_to_worktree diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 6920cf7d12..da0de739b4 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -59,7 +59,9 @@ set -u # was launched from; every case states the marker it means to test. unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS -BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} +NODE_BIN=$(command -v node) || fail "test needs node" +NODE_BIN_DIR=$(dirname "$NODE_BIN") +BASE_PATH=${FM_TEST_BASE_PATH:-$NODE_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin} fm_git_identity fmtest fmtest@example.com TMP_ROOT=$(fm_test_tmproot fm-secondmate-harness) export FM_BACKEND=tmux diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index a412cce0f8..9ef68a6d02 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -35,7 +35,7 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} +BASE_PATH=${FM_TEST_BASE_PATH:-$(dirname "$(command -v git)"):/usr/bin:/bin:/usr/sbin:/sbin} fm_git_identity fmtest fmtest@example.com TMP_ROOT=$(fm_test_tmproot fm-secondmate-liveness) @@ -206,7 +206,7 @@ test_agent_state_dispatcher_and_compatibility() { make_toolchain() { local dir=$1 fakebin fakebin=$(fm_fakebin "$dir") - fm_fake_exit0 "$fakebin" node chrome-devtools-axi pi-signed + fm_fake_exit0 "$fakebin" node chrome-devtools-axi pi-signed codex fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46 cat > "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash @@ -300,6 +300,11 @@ case "${1:-}" in [ "${1:-}" = new-window ] && rm -f "${FM_TMUX_CALL_LOG}.killed" exit 0 ;; + send-keys) + case " $* " in + *' -l '*) [ "${FM_TEST_FAIL_LAUNCH_LITERAL:-0}" != 1 ] || exit 88 ;; + esac + ;; has-session) exit 0 ;; esac exit 0 @@ -370,6 +375,64 @@ test_sweep_respawns_confirmed_dead_secondmate() { pass "sweep: a confirmed-dead secondmate endpoint is killed and respawned" } +test_direct_secondmate_respawn_cannot_bypass_retained_metadata() { + local w fb tmuxfb log out status=0 + w=$(new_world direct-duplicate) + add_sm_home "$w" sm1 firstmate:fm-sm1 + fb=$(make_toolchain "$w"); tmuxfb=$(make_liveness_tmux "$w") + log="$w/calls.log"; : > "$log" + + out=$(PATH="$tmuxfb:$fb:$BASE_PATH" FM_HOME="$w/home" FM_SPAWN_NO_GUARD=1 \ + "$ROOT/bin/fm-spawn.sh" sm1 --secondmate 2>&1) || status=$? + + [ "$status" -ne 0 ] || fail "direct same-id secondmate respawn bypassed retained metadata" + assert_contains "$out" "task metadata already exists for sm1" \ + "direct secondmate duplicate refusal was not explicit" + [ ! -s "$log" ] || fail "direct secondmate duplicate inspected or created an endpoint: $(cat "$log")" + pass "sweep: only recovery-classified secondmate relaunches may replace retained metadata" +} + +test_clean_recovery_abort_restores_retained_metadata() { + local w fb tmuxfb log out before + w=$(new_world recovery-clean-abort) + add_sm_home "$w" sm1 firstmate:fm-sm1 pi + printf 'recovery_marker=retain-me\n' >> "$w/home/state/sm1.meta" + printf 'decision: preserve unread routed result\n' > "$w/home/state/sm1.status" + printf 'turn-ended-sentinel\n' > "$w/home/state/sm1.turn-ended" + printf 'auth-sentinel\n' > "$w/home/state/sm1.grok-turnend-token" + before="$w/sm1.meta.before" + cp -p "$w/home/state/sm1.meta" "$before" + fb=$(make_toolchain "$w"); tmuxfb=$(make_liveness_tmux "$w") + log="$w/calls.log"; : > "$log" + + out=$(PATH="$tmuxfb:$fb:$BASE_PATH" TMUX='' FM_BACKEND=tmux FM_HOME="$w/home" \ + FM_TEST_PANE_CMD=missing FM_TMUX_CALL_LOG="$log" FM_SPAWN_NO_GUARD=1 \ + FM_SPAWN_SECOND_MATE_RECOVERY=1 FM_SKIP_SECONDMATE_INHERIT=1 \ + FM_TEST_FAIL_LAUNCH_LITERAL=1 "$ROOT/bin/fm-spawn.sh" sm1 --secondmate 2>&1) \ + && fail "the injected pre-Enter recovery failure unexpectedly succeeded" + cmp -s "$before" "$w/home/state/sm1.meta" \ + || fail "a clean pre-Enter recovery abort did not restore retained secondmate metadata byte-for-byte" + [ "$(cat "$w/home/state/sm1.status")" = 'decision: preserve unread routed result' ] \ + || fail "a clean recovery abort lost the retained secondmate status" + [ "$(cat "$w/home/state/sm1.turn-ended")" = 'turn-ended-sentinel' ] \ + || fail "a clean recovery abort lost the retained turn-ended record" + [ "$(cat "$w/home/state/sm1.grok-turnend-token")" = 'auth-sentinel' ] \ + || fail "a clean recovery abort lost retained harness wiring" + assert_contains "$(cat "$log")" "new-window" "the recovery attempt did not create its replacement endpoint" + + : > "$log" + out=$(PATH="$tmuxfb:$fb:$BASE_PATH" TMUX='' FM_BACKEND=tmux FM_HOME="$w/home" \ + FM_TEST_PANE_CMD=missing FM_TMUX_CALL_LOG="$log" FM_SPAWN_NO_GUARD=1 \ + FM_SPAWN_SECOND_MATE_RECOVERY=1 FM_SKIP_SECONDMATE_INHERIT=1 \ + "$ROOT/bin/fm-spawn.sh" sm1 --secondmate 2>&1) \ + || fail "the retry after restored metadata failed: $out" + assert_contains "$(cat "$log")" "new-window" \ + "the restored record was not discoverable by the next authorized retry" + assert_no_grep '^recovery_marker=retain-me$' "$w/home/state/sm1.meta" \ + "successful recovery retained the superseded endpoint record" + pass "sweep: a clean pre-Enter recovery abort restores metadata and remains retryable" +} + test_sweep_leaves_alive_secondmate_untouched() { local w fb tmuxfb log out w=$(new_world sweep-alive) @@ -545,6 +608,8 @@ test_tmux_agent_state_rejects_malformed_targets_before_probe test_herdr_agent_state_preserves_husk_classifier test_agent_state_dispatcher_and_compatibility test_sweep_respawns_confirmed_dead_secondmate +test_direct_secondmate_respawn_cannot_bypass_retained_metadata +test_clean_recovery_abort_restores_retained_metadata test_sweep_leaves_alive_secondmate_untouched test_sweep_respawns_authoritatively_missing_pi_secondmate test_sweep_respawns_authoritatively_missing_pi_signed_secondmate diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 9f1cedbc6e..39f50a827b 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -938,7 +938,7 @@ SH # still leads, live fleet identity now outranks curated memory, and the # read-once contract arrives before the payload it governs. test_output_ordering_diagnostics_lead() { - local rec root home fakebin out lock_line boot_line wake_line read_once_line + local rec root home fakebin mask out lock_line boot_line wake_line read_once_line local context_line fleet_line next_line inventory_line missing_line rec=$(new_world ordering) IFS='|' read -r root home fakebin < "$mask" <<'SH' +command() { + if [ "${1:-}" = -v ] && [ "${2:-}" = node ]; then + return 1 + fi + builtin command "$@" +} +SH printf 'window=fm-sess:w1\nkind=ship\n' > "$home/state/task-a.meta" printf 'Captain memory that may be truncated away safely.\n' > "$home/data/captain.md" - out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + out=$(BASH_ENV="$mask" run_session_start "$home" "$root" "$fakebin:$BASE_PATH") lock_line=$(printf '%s\n' "$out" | grep -n '^LOCK$' | head -1 | cut -d: -f1) boot_line=$(printf '%s\n' "$out" | grep -n '^BOOTSTRAP$' | head -1 | cut -d: -f1) @@ -1344,7 +1354,7 @@ EOF # --- composition: real scripts run, not reimplemented ------------------------ test_composition_invokes_real_scripts() { - local rec root home fakebin out + local rec root home fakebin mask out rec=$(new_world composition) IFS='|' read -r root home fakebin < "$mask" <<'SH' +command() { + if [ "${1:-}" = -v ] && [ "${2:-}" = node ]; then + return 1 + fi + builtin command "$@" +} +SH printf 'needs-decision: pick a library\n' > "$home/state/task-z.status" append_wake "$home/state" signal task-z.status "needs-decision: pick a library" - out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + out=$(BASH_ENV="$mask" run_session_start "$home" "$root" "$fakebin:$BASE_PATH") # fm-lock.sh's own exact success text. assert_contains "$out" "lock acquired: harness pid" "fm-lock.sh's real output did not appear (composition, not reimplementation)" diff --git a/tests/fm-sessionstart-nudge.test.sh b/tests/fm-sessionstart-nudge.test.sh index baa4a68462..5c5da019e0 100755 --- a/tests/fm-sessionstart-nudge.test.sh +++ b/tests/fm-sessionstart-nudge.test.sh @@ -48,7 +48,7 @@ make_primary() { run_nudge() { local root=$1 - FM_GATE_REFUSE_BYPASS=0 FM_ROOT_OVERRIDE="$root" FM_HOME="$root" "$NUDGE" + (cd "$root" && FM_GATE_REFUSE_BYPASS=0 FM_ROOT_OVERRIDE="$root" FM_HOME="$root" "$NUDGE") } expect_silent_zero() { @@ -135,7 +135,8 @@ test_opencode_plugin_delivers_exact_nudge_once() { local root="$TMP_ROOT/opencode-primary" out status=0 make_primary "$root" cp "$ROOT/bin/fm-sessionstart-nudge.sh" "$ROOT/bin/fm-primary-scope-lib.sh" \ - "$ROOT/bin/fm-gate-refuse-lib.sh" "$ROOT/bin/fm-operational-input.sh" "$root/bin/" + "$ROOT/bin/fm-gate-refuse-lib.sh" "$ROOT/bin/fm-host-root-lib.sh" \ + "$ROOT/bin/fm-operational-input.sh" "$root/bin/" chmod +x "$root/bin/fm-sessionstart-nudge.sh" out=$(PLUGIN="$ROOT/.opencode/plugins/fm-primary-sessionstart-nudge.js" \ WORKTREE="$root" EXPECTED="$NUDGE_LINE" node --input-type=module 2>&1 <<'EOF' @@ -170,6 +171,41 @@ EOF pass "OpenCode session.created delivers the exact wrapper nudge once per session" } +test_target_workers_do_not_activate_primary_plugins() { + local out status=0 + command -v node >/dev/null 2>&1 || { + echo "skip: node not found for target-worker plugin isolation test" + return 0 + } + out=$(NODE_NO_WARNINGS=1 FM_TARGET_WORKTREE="$ROOT" FM_TEST_ROOT="$ROOT" node --input-type=module 2>&1 <<'JS' +import { pathToFileURL } from "node:url"; + +const root = process.env.FM_TEST_ROOT; +const registrations = []; +const pi = { on: (...args) => registrations.push(["on", ...args]) }; +const turnend = await import(`${pathToFileURL(`${root}/.pi/extensions/fm-primary-turnend-guard.ts`).href}?worker=${Date.now()}`); +turnend.default(pi); +if (registrations.length !== 0) throw new Error(`Pi primary hooks activated: ${registrations.length}`); + +const plugins = [ + ["fm-primary-sessionstart-nudge.js", "FmPrimarySessionstartNudge"], + ["fm-primary-watch-arm.js", "FmPrimaryWatchArm"], + ["fm-primary-turnend-guard.js", "FmPrimaryTurnendGuard"], + ["fm-primary-cd-check.js", "FmPrimaryCdCheck"], + ["fm-primary-pretool-check.js", "FmPrimaryPretoolCheck"], +]; +for (const [file, name] of plugins) { + const mod = await import(`${pathToFileURL(`${root}/.opencode/plugins/${file}`).href}?worker=${Date.now()}-${file}`); + const hooks = await mod[name]({ client: {}, directory: root, worktree: root }); + if (Object.keys(hooks).length !== 0) throw new Error(`${name} activated primary hooks`); +} +JS + ) || status=$? + expect_code 0 "$status" "target-worker plugin isolation" + [ -z "$out" ] || fail "target-worker plugin isolation printed output: $out" + pass "Pi turn-end and OpenCode workers targeting FirstMate do not activate primary supervision plugins" +} + # --- run tier ---------------------------------------------------------------- # # make_run_primary builds a primary the run wrapper accepts and the REAL @@ -541,6 +577,7 @@ test_linked_secondmate_primary_nudges test_missing_state_is_silent test_owned_lock_is_silent test_opencode_plugin_delivers_exact_nudge_once +test_target_workers_do_not_activate_primary_plugins test_run_startup_runs_the_full_digest test_run_clear_and_compact_reemit test_run_rebuild_forwards_source_to_drifted_instruction_refresh diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index d1f1effb41..df5248a486 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -649,7 +649,7 @@ test_pi_signed_threads_shared_pi_profile_and_preserves_identity() { "pi-signed launch did not force the regular TUI with Pi's model, thinking, and extension semantics" assert_contains "$launch" "fm-operational-input.sh' encode launch-brief" \ "pi-signed launch lost the canonical typed launch-brief envelope" - assert_present "$HOME_DIR/state/$id.pi-ext.ts" "pi-signed launch did not install Pi's turn-end extension" + assert_present "$HOME_DIR/state/$id.pi-ext.ts" "pi-signed launch did not install Pi's lifecycle extension" assert_present "$HOME_DIR/state/$id.busy-gen" "pi-signed spawn did not arm the busy-state contract" assert_contains "$(cat "$HOME_DIR/state/$id.busy-state")" "state=busy source=fm-spawn" \ "pi-signed spawn did not seed the busy-state record from the launch brief" @@ -661,7 +661,6 @@ test_pi_signed_threads_shared_pi_profile_and_preserves_identity() { assert_contains "$ext" 'ctx.isIdle()' "pi extension no longer confirms idle with ctx.isIdle()" assert_contains "$ext" "\"--gen\", \"$gen\"" "pi extension does not carry the armed incarnation gen" assert_contains "$ext" '"--source", "pi-ext"' "pi extension does not attribute its semantic source" - assert_contains "$ext" 'pi.on("turn_end"' "pi extension lost the turn-end notification touch" pass "pi-signed shares Pi launch semantics while preserving its configured and recorded identity" } diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index 64aabe6400..c0fe1cc2c3 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -159,13 +159,28 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +stopped="${0}.stopped" +[ "${1:-}" != list-panes ] || exit 0 case "$*" in - *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; + *"#{pane_current_path}"*) + [ ! -e "$stopped" ] || exit 1 + if [ -n "${FM_FAKE_PANE_SEQUENCE:-}" ] && [ -s "$FM_FAKE_PANE_SEQUENCE" ]; then + head -1 "$FM_FAKE_PANE_SEQUENCE" + tail -n +2 "$FM_FAKE_PANE_SEQUENCE" > "$FM_FAKE_PANE_SEQUENCE.next" + mv "$FM_FAKE_PANE_SEQUENCE.next" "$FM_FAKE_PANE_SEQUENCE" + else + printf '%s\n' "${FM_FAKE_PANE_PATH:-}" + fi + exit 0 + ;; + *"#{pane_id}"*) [ ! -e "$stopped" ] && printf '%%1\n'; exit $? ;; esac case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; - has-session|new-session|new-window|send-keys) exit 0 ;; + new-window) rm -f "$stopped"; exit 0 ;; + kill-window) : > "$stopped"; exit 0 ;; + has-session|new-session|send-keys) exit 0 ;; esac exit 0 SH @@ -181,8 +196,8 @@ run_spawn() { FM_ROOT_OVERRIDE='' FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ - FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$pane" TMUX="fake,1,0" \ - PATH="$fakebin:$PATH" \ + FM_SPAWN_NO_GUARD=1 FM_WORKTREE_CWD_ATTEMPTS="${FM_WORKTREE_CWD_ATTEMPTS:-2}" FM_WORKTREE_CWD_DELAY=0 \ + FM_FAKE_PANE_PATH="$pane" TMUX="fake,1,0" PATH="$fakebin:$PATH" \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" codex --mode no-mistakes --yolo off 2>&1 } @@ -212,7 +227,18 @@ test_spawn_isolation_abort() { expect_code 0 "$status" "spawn into a genuine isolated worktree should succeed" assert_contains "$out" "spawned ok-isolated-ff6" "isolated spawn did not report success" assert_not_contains "$out" "did not yield an isolated worktree" "isolated spawn wrongly tripped the guard" - pass "fm-spawn: aborts unless the resolved worktree is a genuine, isolated worktree" + + # A real Treehouse shell may briefly traverse a non-repository container + # directory before entering the linked worktree. Keep polling that transient + # cwd instead of treating the first project-external path as final. + mkdir -p "$TMP_ROOT/transient-container" + printf '%s\n%s\n' "$TMP_ROOT/transient-container" "$TMP_ROOT/spawn-wt" > "$TMP_ROOT/pane-sequence" + status=0 + out=$(FM_FAKE_PANE_SEQUENCE="$TMP_ROOT/pane-sequence" FM_WORKTREE_CWD_ATTEMPTS=3 \ + run_spawn "$home" transient-isolated-gg7 "$proj" "$TMP_ROOT/spawn-wt" "$fakebin"); status=$? + expect_code 0 "$status" "spawn should poll past a transient non-worktree cwd" + assert_contains "$out" "spawned transient-isolated-gg7" "transient cwd recovery did not complete the spawn" + pass "fm-spawn: requires a genuine isolated worktree and polls past transient container cwd values" } # --- GUARD 1c: fm-spawn tmux window construction ---------------------------- diff --git a/tests/fm-teardown-endpoint-safety.test.sh b/tests/fm-teardown-endpoint-safety.test.sh index b08420fc51..1a13dea688 100755 --- a/tests/fm-teardown-endpoint-safety.test.sh +++ b/tests/fm-teardown-endpoint-safety.test.sh @@ -191,7 +191,7 @@ test_metadata_lock_serializes_destructive_cleanup() { } test_supported_backend_endpoint_records_validate() { - local dir id backend target + local dir id backend target host_id dir=$(make_case valid-backends) # shellcheck source=/dev/null . "$ROOT/bin/fm-backend.sh" @@ -233,6 +233,19 @@ test_supported_backend_endpoint_records_validate() { "backend=cmux" "cmux_workspace_id=workspace-1" "cmux_surface_id=surface-2" fm_backend_validate_task_endpoint "$dir/home/state/$id.meta" "$id" || fail "valid cmux endpoint refused" + host_id=tmux-host-task + fm_write_meta "$dir/home/state/$host_id.meta" \ + "window=@1" "endpoint_task_id=$host_id" "worktree=$dir/worktree" "project=$dir/project" \ + "host_root=/host/one" "tmux_socket_path=/tmp/tmux.sock" "tmux_window_marker=marker" + fm_backend_validate_task_endpoint "$dir/home/state/$host_id.meta" "$host_id" || fail "valid host-root tmux endpoint refused" + + for host_id in tmux-host-task herdr-task zellij-task orca-task cmux-task; do + printf 'host_root=/host/two\nhost_root=/host/three\n' >> "$dir/home/state/$host_id.meta" + if fm_backend_validate_task_endpoint "$dir/home/state/$host_id.meta" "$host_id" 2>/dev/null; then + fail "$host_id accepted ambiguous host_root ownership" + fi + done + for backend in tmux herdr zellij orca cmux; do set +e fm_backend_kill "$backend" "" >/dev/null 2>&1 @@ -240,7 +253,7 @@ test_supported_backend_endpoint_records_validate() { set -e [ "$target" -ne 0 ] || fail "$backend generic kill accepted an empty target" done - pass "cleanup identity: valid tmux, Herdr, Zellij, Orca, and cmux records validate while every empty backend target refuses" + pass "cleanup identity: valid endpoints pass while ambiguous host ownership and empty backend targets refuse" } test_tmux_empty_target_refuses_without_invocation() { @@ -281,6 +294,60 @@ test_recorded_process_identity_cleanup_is_exact() { pass "process cleanup: creation-time PID identity removes only the exact child and preserves the control child" } +test_herdr_reused_pane_refuses_before_close_or_return() { + local dir id=herdr-reused rc + dir=$(make_case herdr-reused) + cat > "$dir/fakebin/herdr" <<'SH' +#!/usr/bin/env bash +printf 'herdr' >> "${FM_RUNTIME_LOG:?}" +printf ' <%s>' "$@" >> "${FM_RUNTIME_LOG:?}" +printf '\n' >> "${FM_RUNTIME_LOG:?}" +case "${1:-} ${2:-}" in + 'pane get') + count_file="${FM_RUNTIME_LOG}.pane-gets" + count=$(( $(cat "$count_file" 2>/dev/null || echo 0) + 1 )) + printf '%s\n' "$count" > "$count_file" + if [ "$count" -lt 3 ]; then + printf '{"result":{"pane":{"pane_id":"w1:p2","tab_id":"w1:t2","workspace_id":"w1"}}}\n' + else + printf '{"result":{"pane":{"pane_id":"w1:p2","tab_id":"w1:t-reused","workspace_id":"w1"}}}\n' + fi + ;; + 'tab get') + printf '{"result":{"tab":{"tab_id":"w1:t2","workspace_id":"w1","label":"fm-herdr-reused"}}}\n' + ;; + 'workspace list') + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"}]}}\n' + ;; + 'session list') + printf '{"sessions":[{"name":"lab","running":true,"socket_path":"/tmp/herdr-reused.sock"}]}\n' + ;; + *) printf '{"error":{"code":"unexpected_test_call"}}\n' ;; +esac +SH + chmod +x "$dir/fakebin/herdr" + fm_write_meta "$dir/home/state/$id.meta" \ + "window=lab:w1:p2" "endpoint_task_id=$id" \ + "worktree=$dir/worktree" "project=$dir/project" "kind=scout" \ + "backend=herdr" "herdr_session=lab" "herdr_workspace_id=w1" \ + "herdr_tab_id=w1:t2" "herdr_pane_id=w1:p2" + + set +e + run_case "$dir" "$id" > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "reused Herdr pane teardown unexpectedly succeeded" + assert_present "$dir/home/state/$id.meta" "reused Herdr pane teardown removed task metadata" + assert_present "$dir/worktree/sentinel" "reused Herdr pane teardown returned the task worktree" + assert_not_contains "$(cat "$dir/runtime.log")" ' ' \ + "reused Herdr pane teardown closed an unrelated pane" + assert_not_contains "$(cat "$dir/runtime.log")" 'treehouse' \ + "reused Herdr pane teardown returned the worktree" + assert_contains "$(cat "$dir/stderr")" 'no longer matches its recorded workspace, tab, and task label' \ + "reused Herdr pane refusal did not identify the ownership mismatch" + pass "fm-teardown: post-preflight Herdr pane-ID reuse cannot close an unrelated pane or return the task worktree" +} + isolated_tmux_window_exists() { # ( cd "$1" && "$REAL_TMUX" -S "$2" list-windows -t "$3" -F '#{window_name}' 2>/dev/null ) \ | grep -Fqx "$4" @@ -371,4 +438,5 @@ test_metadata_lock_serializes_destructive_cleanup test_supported_backend_endpoint_records_validate test_tmux_empty_target_refuses_without_invocation test_recorded_process_identity_cleanup_is_exact +test_herdr_reused_pane_refuses_before_close_or_return test_isolated_tmux_invalid_and_valid_cleanup diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index a0815a967e..552c11fe6d 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -76,7 +76,7 @@ make_case() { local name=$1 case_dir fakebin case_dir="$TMP_ROOT/$name" fakebin="$case_dir/fakebin" - mkdir -p "$case_dir/state" "$case_dir/config" "$fakebin" + mkdir -p "$case_dir/data/task-x1" "$case_dir/state" "$case_dir/config" "$fakebin" # Mocks for the post-check teardown steps. Refuse logic exits before these # run; the ALLOW cases need them so the script can complete cleanly. @@ -87,9 +87,27 @@ exit 0 SH cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash -# tmux kill-window etc.: succeed silently. -exit 0 +state="${0%/*}/tmux-live" +if [ "${1:-}" = -S ]; then + shift 2 +fi +case "${1:-}" in + display-message) [ -f "$state" ] ;; + list-panes) + [ ! -f "$state" ] || case "$*" in + *'#{window_name}.#{pane_index}'*) + printf '%%1|@1|firstmate:fm-task-x1|firstmate:fm-task-x1.0|firstmate:1|firstmate:1.0|teardown-marker\n' + ;; + *) + printf '%%1|@1|firstmate:fm-task-x1|firstmate:1|firstmate:1.0|teardown-marker\n' + ;; + esac + ;; + kill-window) [ "${FM_FAKE_TMUX_STOP_FAIL:-0}" = 1 ] || rm -f "$state" ;; + *) exit 0 ;; +esac SH + : > "$fakebin/tmux-live" # Default gh-axi mock: no PR is associated with the branch, and viewing any PR # number fails. This keeps the landed-work check hermetic (never reaching the real # gh-axi) and represents the common "no GitHub PR" baseline. Tests that need a @@ -544,6 +562,7 @@ run_teardown() { local case_dir=$1; shift FM_ROOT_OVERRIDE="$ROOT" \ FM_STATE_OVERRIDE="$case_dir/state" \ + FM_DATA_OVERRIDE="$case_dir/data" \ FM_CONFIG_OVERRIDE="$case_dir/config" \ PATH="$case_dir/fakebin:${FM_TEARDOWN_TEST_PATH:-$PATH}" \ "$TEARDOWN" task-x1 "$@" @@ -579,6 +598,23 @@ test_local_only_fork_remote_allows() { pass "local-only worktree with HEAD on a fork remote is torn down (fix holds)" } +test_teardown_removes_host_root_adapter_state() { + local case_dir artifact + case_dir=$(make_case host-adapter-state-cleanup) + write_meta "$case_dir" local-only ship + wt_commit "$case_dir" "host-root adapter cleanup" + add_fork_with_pushed_branch "$case_dir" + : > "$case_dir/state/task-x1.claude-settings.json" + : > "$case_dir/state/task-x1.opencode-turn-end.js" + + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" \ + || fail "host-adapter-state-cleanup: teardown failed" + for artifact in task-x1.claude-settings.json task-x1.opencode-turn-end.js; do + assert_absent "$case_dir/state/$artifact" "teardown left host-root adapter state $artifact" + done + pass "teardown removes state-owned Claude and OpenCode host-root adapters" +} + test_teardown_prompts_tasks_axi_done_when_compatible() { local case_dir out case_dir=$(make_case tasks-axi-reminder) @@ -1326,6 +1362,35 @@ test_teardown_missing_busy_sidecar_completes() { pass "teardown completes when an exact busy-state sidecar is already absent" } +test_unconfirmed_stop_preserves_worktree() { + local case_dir rc tree_log host + case_dir=$(make_case stop-unconfirmed) + write_meta "$case_dir" local-only ship + host="$case_dir/host" + mkdir -p "$host" + : > "$host/AGENTS.md" + printf 'host_root=%s\ntmux_window_marker=teardown-marker\ntmux_socket_path=/tmp/fm-test.sock\n' \ + "$host" >> "$case_dir/state/task-x1.meta" + tree_log="$case_dir/treehouse.log" + cat > "$case_dir/fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FM_TREEHOUSE_LOG" +SH + chmod +x "$case_dir/fakebin/treehouse" + set +e + (cd "$host" && FM_HOST_ROOT="$host" FM_FAKE_TMUX_STOP_FAIL=1 FM_BACKEND_STOP_ATTEMPTS=1 \ + FM_BACKEND_STOP_DELAY=0 FM_TREEHOUSE_LOG="$tree_log" \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr") + rc=$? + set -e + expect_code 1 "$rc" "stop-unconfirmed: teardown must fail when endpoint termination cannot be confirmed" + assert_grep 'still exists after stop' "$case_dir/stderr" "stop-unconfirmed: refusal did not explain the live endpoint" + assert_present "$case_dir/state/task-x1.meta" "stop-unconfirmed: teardown removed recoverable metadata" + assert_present "$case_dir/wt" "stop-unconfirmed: teardown recycled the isolated copy" + [ ! -s "$tree_log" ] || fail "stop-unconfirmed: teardown invoked treehouse before confirming termination" + pass "teardown preserves the isolated copy and task record when worker termination is unconfirmed" +} + test_herdr_teardown_clears_escalation_marker() { local case_dir marker case_dir=$(make_case herdr-marker-cleanup) @@ -1342,13 +1407,25 @@ test_herdr_teardown_clears_escalation_marker() { # locked close is a no-op and the record gate sees a confirmed-gone pane. cat > "$case_dir/fakebin/herdr" <&2 + exit 1 + fi + ;; *) exit 0 ;; esac SH + : > "$case_dir/fakebin/herdr-live" chmod +x "$case_dir/fakebin/herdr" marker="$case_dir/state/.herdr-escalated-default_wG_pQ" : > "$marker" @@ -1414,6 +1491,9 @@ case "\${1:-} \${2:-}" in fi printf '%s\n' '{"result":{"pane":{"pane_id":"wG:pQ","tab_id":"wG:tQ","workspace_id":"wG"}}}' ;; + "tab get") + printf '%s\n' '{"result":{"tab":{"tab_id":"wG:tQ","workspace_id":"wG","label":"fm-task-x1"}}}' + ;; "agent get") printf '%s\n' '{"error":{"code":"agent_not_found"}}' >&2 exit 1 @@ -1618,7 +1698,8 @@ case "\${1:-} \${2:-}" in printf '%s\n' '{"sessions":[{"name":"childsession","running":true,"socket_path":"$case_dir/child.sock"}]}' fi ;; - "workspace list") exit 1 ;; + "workspace list") printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"wC","label":"firstmate"}]}}' ;; + "tab get") printf '%s\n' '{"result":{"tab":{"tab_id":"wC:t1","workspace_id":"wC","label":"fm-child-herdr"}}}' ;; "pane get") if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then if [ "\${FM_FAKE_HERDR_PRESENCE_UNKNOWN:-0}" = 1 ]; then @@ -1818,7 +1899,8 @@ case "\${1:-} \${2:-}" in "session list") printf '%s\n' '{"sessions":[{"name":"grandchildsession","running":true,"socket_path":"$case_dir/grandchild.sock"}]}' ;; - "workspace list") exit 1 ;; + "workspace list") printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"wG","label":"firstmate"}]}}' ;; + "tab get") printf '%s\n' '{"result":{"tab":{"tab_id":"wG:t1","workspace_id":"wG","label":"fm-grandchild-herdr"}}}' ;; "pane get") if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then printf '%s\n' 'not-json' @@ -1918,12 +2000,13 @@ case "${1:-} ${2:-}" in printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p2","tab_id":"w1:t2","workspace_id":"w1"}}}' ;; "tab get") - printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2"}}}' + case "${3:-}" in + w1:t2) printf '%s\n' '{"result":{"tab":{"tab_id":"w1:t2","workspace_id":"w1","label":"fm-task-x1"}}}' ;; + *) printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2"}}}' ;; + esac ;; "tab focus") - if [ "${FM_FAKE_HERDR_RESTORE_FAIL:-0}" = 1 ]; then - exit 1 - fi + [ "${FM_FAKE_HERDR_RESTORE_FAIL:-0}" != 1 ] || exit 1 : > "${FM_FAKE_HERDR_RESTORED:?}" printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2","focused":true}}}' ;; @@ -1956,16 +2039,21 @@ test_herdr_projection_teardown_retires_journal_only_after_confirmed_close() { } test_herdr_projection_teardown_retains_journal_when_close_unconfirmed() { - local case_dir log closed restored + local case_dir log closed restored host status=0 case_dir=$(make_case herdr-projection-unconfirmed-close) write_meta "$case_dir" local-only ship + host="$case_dir/host" + mkdir -p "$host" + : > "$host/AGENTS.md" + printf 'host_root=%s\n' "$host" >> "$case_dir/state/task-x1.meta" configure_herdr_projection_teardown_case "$case_dir" log="$case_dir/herdr.log"; closed="$case_dir/closed"; restored="$case_dir/restored"; : > "$log" - local rc=0 - FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_RESTORED="$restored" FM_FAKE_HERDR_PRESENCE_UNKNOWN=1 \ - run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? - [ "$rc" -ne 0 ] \ + (cd "$host" && FM_HOST_ROOT="$host" FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + FM_FAKE_HERDR_RESTORED="$restored" FM_FAKE_HERDR_PRESENCE_UNKNOWN=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr") \ + || status=$? + [ "$status" -ne 0 ] \ || fail "herdr-projection-unconfirmed-close: teardown reported success after an unknown post-close presence read" [ -e "$closed" ] \ || fail "herdr-projection-unconfirmed-close: regression did not exercise an attempted close" @@ -2591,7 +2679,95 @@ EOF pass "the run abort and the leaked-process reap both complete before the destructive worktree return" } +test_herdr_projection_teardown_retains_journal_when_close_fails() { + local case_dir log closed restored host status=0 + case_dir=$(make_case herdr-projection-close-failed) + write_meta "$case_dir" local-only ship + host="$case_dir/host" + mkdir -p "$host" + : > "$host/AGENTS.md" + printf 'host_root=%s\n' "$host" >> "$case_dir/state/task-x1.meta" + configure_herdr_projection_teardown_case "$case_dir" + log="$case_dir/herdr.log"; closed="$case_dir/closed"; restored="$case_dir/restored"; : > "$log" + + (cd "$host" && FM_HOST_ROOT="$host" FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + FM_FAKE_HERDR_RESTORED="$restored" FM_FAKE_HERDR_CLOSE_FAIL=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr") \ + || status=$? + [ "$status" -ne 0 ] || fail "herdr-projection-unconfirmed-close: teardown accepted an unconfirmed endpoint stop" + [ -e "$case_dir/state/task-x1.herdr-presentation" ] \ + || fail "unconfirmed task-pane close incorrectly retired the presentation journal" + assert_present "$case_dir/state/task-x1.meta" \ + "unconfirmed projected close removed the recovery metadata" + assert_present "$case_dir/wt" \ + "unconfirmed projected close removed the isolated worktree" + assert_grep "could not be confirmed absent" "$case_dir/stderr" \ + "unconfirmed projected close did not explain the destructive-cleanup refusal" + assert_not_contains "$(cat "$log")" "workspace close" \ + "unconfirmed projected close must not escalate to workspace cleanup" + pass "herdr projection teardown retains task state and refuses destructive cleanup when exact-pane close is unconfirmed" +} + +test_herdr_projection_teardown_uses_focus_safe_close_without_token_correlation() { + local case_dir log closed restored host + case_dir=$(make_case herdr-projection-token-mismatch) + write_meta "$case_dir" local-only ship + host="$case_dir/host" + mkdir -p "$host" + : > "$host/AGENTS.md" + printf 'host_root=%s\n' "$host" >> "$case_dir/state/task-x1.meta" + configure_herdr_projection_teardown_case "$case_dir" + sed -i.bak 's/^projection_id=.*/projection_id=ZyXwVuTsRqPoNmLkJiHgFe/' \ + "$case_dir/state/task-x1.herdr-presentation" + rm -f "$case_dir/state/task-x1.herdr-presentation.bak" + log="$case_dir/herdr.log"; closed="$case_dir/closed"; restored="$case_dir/restored"; : > "$log" + + (cd "$host" && FM_HOST_ROOT="$host" FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + FM_FAKE_HERDR_RESTORED="$restored" \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr") \ + || fail "herdr-projection-token-mismatch: focus-safe teardown failed" + assert_contains "$(cat "$log")" "pane close w1:p2" \ + "token-mismatched journal bypassed the exact focus-safe pane close" + assert_contains "$(cat "$log")" "tab focus w2:t2" \ + "token-mismatched journal did not restore the exact pre-close active tab" + assert_not_contains "$(cat "$log")" "workspace close" \ + "token-mismatched journal attempted workspace cleanup" + assert_present "$case_dir/state/task-x1.herdr-presentation" \ + "token-mismatched journal was retired without correlation" + pass "journal-backed Herdr teardown preserves focus even without token correlation" +} + +test_herdr_projection_teardown_retains_cleanup_when_focus_restore_fails() { + local case_dir log closed restored host status=0 + case_dir=$(make_case herdr-projection-restore-failure) + write_meta "$case_dir" local-only ship + host="$case_dir/host" + mkdir -p "$host" + : > "$host/AGENTS.md" + printf 'host_root=%s\n' "$host" >> "$case_dir/state/task-x1.meta" + configure_herdr_projection_teardown_case "$case_dir" + log="$case_dir/herdr.log"; closed="$case_dir/closed"; restored="$case_dir/restored"; : > "$log" + + (cd "$host" && FM_HOST_ROOT="$host" FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + FM_FAKE_HERDR_RESTORED="$restored" FM_FAKE_HERDR_RESTORE_FAIL=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr") \ + || status=$? + [ "$status" -ne 0 ] || fail "herdr-projection-restore-failure: teardown ignored failed focus restoration" + assert_present "$case_dir/state/task-x1.herdr-presentation" \ + "failed focus restoration retired the presentation journal" + assert_present "$case_dir/state/task-x1.meta" \ + "failed focus restoration removed recovery metadata" + assert_present "$case_dir/wt" \ + "failed focus restoration removed the isolated worktree" + assert_grep 'exact-tab restoration failed' "$case_dir/stderr" \ + "failed focus restoration did not explain the cleanup refusal" + assert_not_contains "$(cat "$log")" "workspace close" \ + "failed focus restoration escalated to workspace cleanup" + pass "Herdr teardown retains cleanup records when focus restoration fails" +} + test_local_only_fork_remote_allows +test_teardown_removes_host_root_adapter_state test_teardown_prompts_tasks_axi_done_when_compatible test_teardown_manual_backend_prompts_hand_edit_even_when_tasks_axi_present test_local_only_truly_unpushed_refuses @@ -2600,6 +2776,7 @@ test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed test_teardown_missing_busy_sidecar_completes +test_unconfirmed_stop_preserves_worktree test_herdr_teardown_clears_escalation_marker test_herdr_flat_teardown_refuses_orphaning_records_then_retry_completes test_herdr_flat_teardown_refuses_records_on_unparseable_presence @@ -2611,6 +2788,9 @@ test_forced_teardown_retains_nested_secondmate_home_when_grandchild_close_unconf test_herdr_projection_teardown_retires_journal_only_after_confirmed_close test_herdr_projection_teardown_retains_journal_when_close_unconfirmed test_herdr_projection_teardown_surfaces_restore_failure_without_blocking_cleanup +test_herdr_projection_teardown_retains_journal_when_close_fails +test_herdr_projection_teardown_uses_focus_safe_close_without_token_correlation +test_herdr_projection_teardown_retains_cleanup_when_focus_restore_fails test_squash_merged_branch_deleted_allows test_squash_merged_pr_allows_when_head_ancestor_of_pr_head test_no_pr_recorded_discovers_merged_pr_by_branch_allows diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh index 8fe26e6f47..62e5223aa1 100755 --- a/tests/fm-test-run.test.sh +++ b/tests/fm-test-run.test.sh @@ -101,6 +101,8 @@ init_changed_fixture_repo() { fm-backend-herdr-smoke.test.sh \ fm-secondmate-safety.test.sh \ fm-session-start.test.sh \ + fm-sessionstart-nudge.test.sh \ + fm-host-root-mode.test.sh \ fm-afk-pi-herdr-return-e2e.test.sh \ fm-backend.test.sh \ fm-pr-merge.test.sh \ @@ -116,15 +118,26 @@ init_changed_fixture_repo() { : >"$repo/tests/lib.sh" : >"$repo/tests/fm-backend-herdr-eventwait.test.py" : >"$repo/bin/fm-supervisor-target-lib.sh" + : >"$repo/bin/fm-spawn.sh" + : >"$repo/bin/fm-host-root-lib.sh" : >"$repo/bin/unmapped-source.sh" printf '# .claude/settings.json\n# .pi/extensions/fm-primary-turnend-guard.ts\n' \ >>"$repo/tests/fm-cd-pretool-check.test.sh" printf '# .pi/extensions/fm-primary-pi-watch.ts\n' >>"$repo/tests/fm-pi-watch-extension.test.sh" - mkdir -p "$repo/.agents/skills/example" "$repo/.claude" "$repo/.pi/extensions" "$repo/src" + mkdir -p "$repo/.agents/skills/example" "$repo/.claude" "$repo/.pi/extensions" \ + "$repo/.opencode/plugins" "$repo/src" : >"$repo/.agents/skills/example/SKILL.md" : >"$repo/.claude/settings.json" : >"$repo/.pi/extensions/fm-primary-pi-watch.ts" : >"$repo/.pi/extensions/fm-primary-turnend-guard.ts" + for script in \ + fm-primary-cd-check.js \ + fm-primary-pretool-check.js \ + fm-primary-sessionstart-nudge.js \ + fm-primary-turnend-guard.js \ + fm-primary-watch-arm.js; do + : >"$repo/.opencode/plugins/$script" + done : >"$repo/src/unmapped.ts" git -C "$repo" init -q git -C "$repo" add . @@ -132,7 +145,7 @@ init_changed_fixture_repo() { } test_changed_dependency_selection_and_unmapped_failure() { - local tmp repo listed rc + local tmp repo listed rc script tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-changed.XXXXXX") repo="$tmp/repo" init_changed_fixture_repo "$repo" @@ -170,6 +183,25 @@ test_changed_dependency_selection_and_unmapped_failure() { git -C "$repo" add .agents .claude .pi git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm non-bin-source-change + printf '\n' >>"$repo/bin/fm-spawn.sh" + printf '\n' >>"$repo/bin/fm-host-root-lib.sh" + listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) + assert_contains "$listed" "tests/fm-secondmate-safety.test.sh" \ + "spawn source selects SecondMate recovery coverage" + assert_contains "$listed" "tests/fm-host-root-mode.test.sh" \ + "host-root authority source selects host-root behavior coverage" + git -C "$repo" add bin/fm-spawn.sh bin/fm-host-root-lib.sh + git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm lifecycle-source-change + + for script in "$repo"/.opencode/plugins/fm-primary-*.js; do + printf '\n' >>"$script" + listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) + assert_contains "$listed" "tests/fm-sessionstart-nudge.test.sh" \ + "$(basename "$script") selects shared target-worker activation coverage" + git -C "$repo" add "$script" + git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm "$(basename "$script")-change" + done + printf '\n' >>"$repo/src/unmapped.ts" set +e (cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) >"$tmp/out" 2>"$tmp/err" diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index ac02c7c37c..bf7f6fd48e 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -111,6 +111,7 @@ install_guard_scripts() { cp "$ROOT/bin/fm-turnend-guard-grok.sh" "$dir/bin/fm-turnend-guard-grok.sh" cp "$ROOT/bin/fm-operational-input.sh" "$dir/bin/fm-operational-input.sh" cp "$ROOT/bin/fm-supervision-instructions.sh" "$dir/bin/fm-supervision-instructions.sh" + cp "$ROOT/bin/fm-host-root-lib.sh" "$dir/bin/fm-host-root-lib.sh" cp "$ROOT/bin/fm-harness.sh" "$dir/bin/fm-harness.sh" cp "$ROOT/bin/fm-primary-scope-lib.sh" "$dir/bin/fm-primary-scope-lib.sh" cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" @@ -599,13 +600,18 @@ test_hook_silent_in_crewmate_worktree() { } test_hook_silent_without_jq() { - local dir out status fakebin tool tool_path + local dir out status fakebin tool tool_path bash_path dir=$(make_primary_dir "$TMP_ROOT/hook-nojq") : > "$dir/state/task1.meta" fakebin=$(fm_fakebin "$TMP_ROOT/hook-nojq-fake") + bash_path=$(type -P bash) || fail "test host must provide bash" for tool in bash sh git cat printf date uname stat mkdir dirname; do - tool_path=$(command -v "$tool") || fail "test host must provide $tool" - ln -s "$tool_path" "$fakebin/$tool" + tool_path=$(type -P "$tool") || fail "test host must provide $tool" + cat > "$fakebin/$tool" <&1) status=$? @@ -760,14 +766,19 @@ test_grok_adapter_invalid_inputs_start_neither_path() { } test_grok_adapter_missing_jq_and_no_supervision_allow() { - local dir fakebin log out status tool tool_path + local dir fakebin log out status tool tool_path bash_path dir=$(make_primary_dir "$TMP_ROOT/grok-nojq") : > "$dir/state/task1.meta" fakebin=$(fm_fakebin "$TMP_ROOT/grok-nojq-bin") log="$TMP_ROOT/grok-nojq.log" + bash_path=$(type -P bash) || fail "test host must provide bash" for tool in bash cat printf; do - tool_path=$(command -v "$tool") || fail "test host must provide $tool" - ln -s "$tool_path" "$fakebin/$tool" + tool_path=$(type -P "$tool") || fail "test host must provide $tool" + cat > "$fakebin/$tool" <> %q\n' "$log" > "$fakebin/grok" chmod +x "$fakebin/grok" @@ -1116,7 +1127,7 @@ record_autoarm_owner() { } install_integrated_autoarm() { - local dir=$1 + local dir=$1 bash_path cp "$ROOT/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-claude-stop-autoarm.sh" cp "$ROOT/bin/fm-primary-scope-lib.sh" "$dir/bin/fm-primary-scope-lib.sh" cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" @@ -1126,7 +1137,12 @@ install_integrated_autoarm() { cp "$ROOT/bin/fm-cursor-lib.sh" "$dir/bin/fm-cursor-lib.sh" cp "$ROOT/bin/fm-lock.sh" "$dir/bin/fm-lock.sh" chmod +x "$dir/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-lock.sh" - ln -s /bin/bash "$dir/fake-claude" + bash_path=$(type -P bash) || fail "test host must provide bash" + cat > "$dir/fake-claude" < "$state/herdr-term.status" stale_is_terminal "default:w1:p2" "$state" || fail "terminal herdr stale status not resolved through metadata" + printf 'done: ready in branch fm/prefixed\n' > "$state/fm-prefixed.status" + stale_is_terminal "fm-prefixed" "$state" "fm-prefixed" || fail "terminal stale status lost an explicit fm-prefixed task id" printf 'working: compiling\n' > "$state/nonterm.status" stale_is_terminal "sess:fm-nonterm" "$state" && fail "non-terminal stale classified terminal" stale_is_terminal "sess:fm-missing" "$state" && fail "stale with no status classified terminal" @@ -1925,6 +1927,42 @@ test_afk_paused_changed_pane_hands_off_plain_stale() { pass "AFK changed paused panes hand off plain stale identities for daemon-owned pause triage" } +test_host_tmux_socket_collisions_stay_task_scoped() { + local dir state fakebin log out + dir=$(make_case host-tmux-socket-collision) + state="$dir/state" + fakebin="$dir/fakebin" + log="$dir/tmux.log" + fm_write_meta "$state/one.meta" \ + 'window=@1' 'host_root=/host/one' 'tmux_socket_path=/one.sock' 'tmux_window_marker=one' + fm_write_meta "$state/two.meta" \ + 'window=@1' 'host_root=/host/two' 'tmux_socket_path=/two.sock' 'tmux_window_marker=two' + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +socket=ambient +if [ "${1:-}" = -S ]; then + socket=$2 + shift 2 +fi +printf '%s\n' "$socket" >> "$FM_SOCKET_LOG" +[ "${1:-}" = capture-pane ] && printf '%s\n' "$socket" +SH + chmod +x "$fakebin/tmux" + out=$(PATH="$fakebin:$PATH" FM_SOCKET_LOG="$log" FM_STATE_OVERRIDE="$state" bash -c ' + . "$1/bin/fm-watch.sh" + while IFS=$(printf "\t") read -r ref task window; do + pane=$( ( window_bind_context "$window" "$task" \ + && fm_backend_capture "$(window_backend "$window" "$task")" "$window" 1 ) ) + printf "%s|%s\n" "$ref" "$pane" + window_is_busy "$window" "$pane" "$task" || true + done < <(recorded_windows) + ' _ "$ROOT") + assert_contains "$out" 'one|/one.sock' "watcher did not bind the first colliding task to its socket" + assert_contains "$out" 'two|/two.sock' "watcher did not bind the second colliding task to its socket" + assert_no_grep '^ambient$' "$log" "watcher probed ambient tmux for colliding host tasks" + pass "watcher keys colliding host tmux windows by task and creating socket" +} + test_signal_reason_is_actionable_classifier test_stale_is_terminal_classifier test_scan_captain_relevant_statuses_classifier @@ -1974,3 +2012,4 @@ test_heartbeat_backstop_surfaces_unsurfaced_status test_beacon_stays_fresh_while_absorbing test_afk_present_reverts_watcher_to_one_shot test_afk_paused_changed_pane_hands_off_plain_stale +test_host_tmux_socket_collisions_stay_task_scoped diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index a3628b1694..768efcb8c6 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -191,12 +191,14 @@ test_guard_warnings() { } test_lock_single_winner_under_concurrency() { - local dir state lockdir marker i pids pid wins + local dir state lockdir marker done_file i pids pid wins attempts dir=$(make_case lock-concurrency) state="$dir/state" lockdir="$state/.contend.lock" marker="$dir/wins" + done_file="$dir/done" : > "$marker" + : > "$done_file" pids= i=1 while [ "$i" -le 40 ]; do @@ -204,11 +206,20 @@ test_lock_single_winner_under_concurrency() { . "$1" if fm_lock_try_acquire "$2"; then printf "%s\n" "$$" >> "$3" - # Stay alive so the held lock names a live pid for the whole window; - # otherwise a late contender could legitimately reclaim a dead-pid lock. - sleep 1 + won=1 + else + won=0 + fi + printf "done\n" >> "$4" + if [ "$won" = 1 ]; then + attempt=0 + while [ "$(awk "NF { c++ } END { print c + 0 }" "$4")" -lt 40 ] \ + && [ "$attempt" -lt 500 ]; do + sleep 0.01 + attempt=$((attempt + 1)) + done fi - ' _ "$LIB" "$lockdir" "$marker" & + ' _ "$LIB" "$lockdir" "$marker" "$done_file" & pids="$pids $!" i=$((i + 1)) done @@ -216,6 +227,8 @@ test_lock_single_winner_under_concurrency() { wait "$pid" 2>/dev/null || true done wins=$(awk 'NF { c++ } END { print c + 0 }' "$marker") + attempts=$(awk 'NF { c++ } END { print c + 0 }' "$done_file") + [ "$attempts" -eq 40 ] || fail "expected 40 completed lock attempts, got $attempts" [ "$wins" -eq 1 ] || fail "expected exactly one lock winner under concurrency, got $wins" pass "concurrent fm_lock_try_acquire yields exactly one winner" } @@ -239,16 +252,54 @@ test_lock_steals_dead_pid_lock() { pass "dead-pid stale lock is reclaimed by a single acquirer" } +test_lock_supports_no_symlink_fallback() { + local dir state lockdir fakebin dead + dir=$(make_case lock-msys-directory-copy) + state="$dir/state" + lockdir="$state/.contend.lock" + fakebin="$dir/fakebin" + mkdir -p "$fakebin" + cat > "$fakebin/ln" <<'SH' +#!/usr/bin/env bash +exit 1 +SH + chmod +x "$fakebin/ln" + + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_lock_try_acquire "$2" || exit 10 + [ -d "$2" ] && [ ! -L "$2" ] || exit 11 + fm_lock_release "$2" + [ ! -e "$2" ] || exit 12 + ' _ "$LIB" "$lockdir" \ + || fail "direct-directory lock fallback could not be acquired and released" + + dead=$(dead_pid) + mkdir "$lockdir" + printf '%s\n' "$dead" > "$lockdir/pid" + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_lock_try_acquire "$2" || exit 20 + [ "$(cat "$2/pid")" != "$3" ] || exit 21 + fm_lock_release "$2" + [ ! -e "$2" ] || exit 22 + ' _ "$LIB" "$lockdir" "$dead" \ + || fail "direct-directory lock fallback could not reclaim a dead owner" + pass "locks acquire, release, and reclaim stale owners without symlink support" +} + test_lock_stale_steal_single_winner_under_concurrency() { - local dir state lockdir dead marker i pids pid wins + local dir state lockdir dead marker done_file i pids pid wins attempts dir=$(make_case lock-stale-concurrency) state="$dir/state" lockdir="$state/.contend.lock" marker="$dir/wins" + done_file="$dir/done" dead=$(dead_pid) mkdir "$lockdir" printf '%s\n' "$dead" > "$lockdir/pid" : > "$marker" + : > "$done_file" pids= i=1 while [ "$i" -le 40 ]; do @@ -256,9 +307,20 @@ test_lock_stale_steal_single_winner_under_concurrency() { . "$1" if fm_lock_try_acquire "$2"; then printf "%s\n" "${BASHPID:-$$}" >> "$3" - sleep 1 + won=1 + else + won=0 fi - ' _ "$LIB" "$lockdir" "$marker" & + printf "done\n" >> "$4" + if [ "$won" = 1 ]; then + attempt=0 + while [ "$(awk "NF { c++ } END { print c + 0 }" "$4")" -lt 40 ] \ + && [ "$attempt" -lt 500 ]; do + sleep 0.01 + attempt=$((attempt + 1)) + done + fi + ' _ "$LIB" "$lockdir" "$marker" "$done_file" & pids="$pids $!" i=$((i + 1)) done @@ -266,6 +328,8 @@ test_lock_stale_steal_single_winner_under_concurrency() { wait "$pid" 2>/dev/null || true done wins=$(awk 'NF { c++ } END { print c + 0 }' "$marker") + attempts=$(awk 'NF { c++ } END { print c + 0 }' "$done_file") + [ "$attempts" -eq 40 ] || fail "expected 40 completed stale-lock attempts, got $attempts" [ "$wins" -eq 1 ] || fail "expected exactly one stale-lock stealer, got $wins" pass "concurrent stale-lock steal yields exactly one winner" } @@ -359,6 +423,35 @@ test_lock_empty_pid_uses_minimum_grace() { pass "empty mid-acquire lock keeps a minimum grace" } +test_lock_create_failure_does_not_recurse() { + local dir state vanished out pid waited=0 status=0 + dir=$(make_case lock-create-failure) + state="$dir/state" + vanished="$dir/vanished" + mkdir "$vanished" + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + rmdir "$2" + if fm_lock_try_acquire "$2/.contend.lock"; then rc=0; else rc=$?; fi + printf "rc=%s\n" "$rc" + ' _ "$LIB" "$vanished" > "$dir/out" 2>&1 & + pid=$! + while kill -0 "$pid" 2>/dev/null && [ "$waited" -lt 100 ]; do + sleep 0.01 + waited=$((waited + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fail "an absent lock path recursed instead of returning its create failure" + fi + wait "$pid" || status=$? + out=$(cat "$dir/out") + [ "$status" -eq 0 ] || fail "create-failure probe crashed or failed (rc=$status): $out" + [ "$out" = "rc=1" ] || fail "create-failure probe returned an unexpected result: $out" + pass "an absent lock path returns its create failure without recursive stale stealing" +} + test_lock_late_claim_loses_after_recreate() { local dir state lockdir out dir=$(make_case lock-late-claim) @@ -367,7 +460,11 @@ test_lock_late_claim_loses_after_recreate() { out=$(FM_LOCK_STALE_AFTER=0 FM_STATE_OVERRIDE="$state" bash -c ' . "$1" owner1=$(fm_lock_owner_dir "$2") || exit 20 - ln -s "$owner1" "$2" || exit 21 + if ! MSYS="${MSYS:+$MSYS }winsymlinks:nativestrict" ln -s "$owner1" "$2" 2>/dev/null; then + fm_lock_discard_owner "$owner1" + printf "skip=no-native-symlink\n" + exit 0 + fi touch -h -t 200001010000 "$2" 2>/dev/null || sleep 2 if ! fm_lock_try_acquire "$2"; then exit 22; fi before=$(cat "$2/pid" 2>/dev/null || true) @@ -376,6 +473,10 @@ test_lock_late_claim_loses_after_recreate() { current_owner=$(readlink "$2" 2>/dev/null || true) printf "late=%s before=%s after=%s owner_changed=%s\n" "$late" "$before" "$after" "$([ "$current_owner" != "$owner1" ] && echo yes || echo no)" ' _ "$LIB" "$lockdir") + if [ "$out" = skip=no-native-symlink ]; then + pass "late-claim symlink race skipped where native symlinks are unavailable" + return + fi case "$out" in *"late=lost"*) ;; *) fail "late original claimant succeeded after lock recreation: $out" ;; @@ -399,7 +500,11 @@ test_lock_paused_mid_acquire_claim_fails_during_steal() { out=$(FM_LOCK_STALE_AFTER=0 FM_STATE_OVERRIDE="$state" bash -c ' . "$1" owner=$(fm_lock_owner_dir "$2") || exit 20 - ln -s "$owner" "$2" || exit 21 + if ! MSYS="${MSYS:+$MSYS }winsymlinks:nativestrict" ln -s "$owner" "$2" 2>/dev/null; then + fm_lock_discard_owner "$owner" + printf "skip=no-native-symlink\n" + exit 0 + fi fm_lock_try_acquire "$2.steal" || exit 22 steal_owner=${FM_LOCK_OWNER_DIR:-} if fm_lock_claim "$2" "$owner"; then late=won; else late=lost; fi @@ -407,6 +512,10 @@ test_lock_paused_mid_acquire_claim_fails_during_steal() { pid=$(cat "$2/pid" 2>/dev/null || true) printf "late=%s stealer=%s pid=%s\n" "$late" "$stealer" "$pid" ' _ "$LIB" "$lockdir") + if [ "$out" = skip=no-native-symlink ]; then + pass "paused-claim symlink race skipped where native symlinks are unavailable" + return + fi case "$out" in *"late=lost"*) ;; *) fail "paused claimant succeeded while steal mutex was held: $out" ;; @@ -460,7 +569,7 @@ test_watch_restart_attaches_to_healthy_peer() { out="$dir/restart.out" peer_ready="$dir/peer.ready" mark_pr_check_migration_complete "$state" - node -e 'const fs = require("node:fs"); process.on("SIGTERM", () => {}); fs.writeFileSync(process.argv[1], "ready\n"); setTimeout(() => {}, 300000)' "$peer_ready" & + bash -c 'trap "" TERM; printf "ready\n" > "$1"; while :; do sleep 300; done' _ "$peer_ready" & peer=$! i=0 while [ "$i" -lt 50 ] && [ ! -s "$peer_ready" ]; do @@ -1108,10 +1217,12 @@ test_live_stale_watch_lock_is_actionable test_guard_warnings test_lock_single_winner_under_concurrency test_lock_steals_dead_pid_lock +test_lock_supports_no_symlink_fallback test_lock_stale_steal_single_winner_under_concurrency test_lock_live_steal_mutex_is_not_reclaimed test_lock_does_not_steal_live_lock test_lock_empty_pid_uses_minimum_grace +test_lock_create_failure_does_not_recurse test_lock_late_claim_loses_after_recreate test_lock_paused_mid_acquire_claim_fails_during_steal test_watch_restart_rejects_reused_pid diff --git a/tests/secondmate-helpers.sh b/tests/secondmate-helpers.sh index e78881872c..fadac218cb 100644 --- a/tests/secondmate-helpers.sh +++ b/tests/secondmate-helpers.sh @@ -25,8 +25,19 @@ make_fake_tmux() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +stopped="${0}.stopped" case "${1:-}" in - has-session|new-session|new-window|send-keys|kill-window) + has-session|new-session|send-keys) + printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" + exit 0 + ;; + new-window) + rm -f "$stopped" + printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" + exit 0 + ;; + kill-window) + : > "$stopped" printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" exit 0 ;; @@ -37,12 +48,18 @@ case "${1:-}" in exit 0 ;; display-message) + [ ! -e "$stopped" ] || exit 1 case "$*" in *'#{cursor_y}'*) printf '0\n' ;; *) printf 'firstmate\n' ;; esac exit 0 ;; + list-panes) + [ -e "$stopped" ] && exit 0 + printf '%%1\n' + exit 0 + ;; capture-pane) printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" cat "$FM_FAKE_TMUX_CAPTURE"