diff --git a/scripts/deploy-tab-diff.sh b/scripts/deploy-tab-diff.sh index aa9dc0aec..24b64be32 100755 --- a/scripts/deploy-tab-diff.sh +++ b/scripts/deploy-tab-diff.sh @@ -223,7 +223,7 @@ case "$CMD" in ($snap.records // [])[] | select(.status == "open") as $rec | ($rec.panes // [])[] | {device: $dev, tabKey: $rec.tabKey, tabName: $rec.tabName, paneId: .paneId, - kind: .kind, sessionRef: .payload.sessionRef, + kind: .kind, mode: .payload.mode, sessionRef: .payload.sessionRef, liveTerminalId: .payload.liveTerminal.terminalId}; ($b[0].terminals | map(select(.status=="running") | .terminalId)) as $liveBefore | ($a[0].terminals | map(select(.status=="running") | .terminalId)) as $liveNow @@ -237,6 +237,17 @@ case "$CMD" in | ($after | map(select(.tabKey == $bp.tabKey and .paneId == $bp.paneId)) | first) as $ap | if $ap == null then {verdict: "MISSING", pane: $bp} + # A LIVE coding-CLI pane with NO captured sessionRef is + # unverifiable BY CONSTRUCTION: restore can only ever produce a + # blank session for it, and "same identity" cannot be checked + # against an identity that was never captured. Silently passing + # these panes is how the 2026-07-25 blank-restore incident hid + # behind an "OK" verdict. Shell panes are stateless by design and + # are exempt; the session-capable mode list mirrors + # crates/freshell-ws/src/existence.rs. + elif ($bp.sessionRef == null + and ((["claude", "codex", "opencode", "amplifier"] | index($bp.mode)) != null)) then + {verdict: "NO CAPTURED IDENTITY", pane: $bp} elif ($bp.sessionRef != null and $ap.sessionRef == null) then {verdict: "FRESH (identity lost)", pane: $bp} elif ($bp.sessionRef != null and $ap.sessionRef != null @@ -264,22 +275,35 @@ case "$CMD" in # every still-healthy pane). Everything is read from the BEFORE file + # $DIFF, so verify performs ZERO network operations in --after/offline # mode (:2619). - echo "REMEDIATION (rebuild each diverged device's MISSING panes from its captured immutable bundle):" - while IFS= read -r -d '' dev; do - comps=$(jq -r --arg d "$dev" '(.bundles[$d].components // []) | join(",")' "$BEFORE") - if [[ -z "$comps" ]]; then - printf 'ERROR: no captured bundle for device %q in the before-file; cannot recommend a union-consistent restore.\n' "$dev" >&2 - continue - fi - pane_args="" - while IFS= read -r -d '' pk; do - pane_args+=$(printf ' --pane %q' "$pk") - done < <(jq -j --arg d "$dev" \ - '[.[] | select(.pane.device == $d) | "\(.pane.tabKey)#\(.pane.paneId)"] | unique | .[] | . + "\u0000"' \ - <<<"$DIFF") - printf ' scripts/restore-tabs.sh --url %q --token --device %q --components %s --force%s\n' \ - "$URL" "$dev" "$comps" "$pane_args" - done < <(jq -j '[.[].pane.device] | unique | .[] | . + "\u0000"' <<<"$DIFF") + # NO CAPTURED IDENTITY panes are NOT snapshot-remediable: no generation + # carries a sessionRef for them, so restore-tabs.sh could only rebuild a + # blank session (a duplicate of the loss). They get a manual-recovery + # note instead of a --pane entry. + UNVERIFIABLE=$(jq '[.[] | select(.verdict == "NO CAPTURED IDENTITY")]' <<<"$DIFF") + REMEDIABLE=$(jq '[.[] | select(.verdict != "NO CAPTURED IDENTITY")]' <<<"$DIFF") + if [[ "$(jq 'length' <<<"$UNVERIFIABLE")" != "0" ]]; then + echo "UNRECOVERABLE FROM SNAPSHOTS: identity was never captured for these live CLI panes, so restore-tabs.sh cannot rebuild them." + echo "Recover manually from the provider's own session store (e.g. 'amplifier resume', 'codex resume', 'claude --resume'), matching by cwd and last activity." + jq -r '.[] | " NO-IDENTITY device=\(.pane.device)\ttab=\(.pane.tabName) (\(.pane.tabKey))\tpane=\(.pane.paneId)\tmode=\(.pane.mode // "-")"' <<<"$UNVERIFIABLE" + fi + if [[ "$(jq 'length' <<<"$REMEDIABLE")" != "0" ]]; then + echo "REMEDIATION (rebuild each diverged device's MISSING panes from its captured immutable bundle):" + while IFS= read -r -d '' dev; do + comps=$(jq -r --arg d "$dev" '(.bundles[$d].components // []) | join(",")' "$BEFORE") + if [[ -z "$comps" ]]; then + printf 'ERROR: no captured bundle for device %q in the before-file; cannot recommend a union-consistent restore.\n' "$dev" >&2 + continue + fi + pane_args="" + while IFS= read -r -d '' pk; do + pane_args+=$(printf ' --pane %q' "$pk") + done < <(jq -j --arg d "$dev" \ + '[.[] | select(.pane.device == $d) | "\(.pane.tabKey)#\(.pane.paneId)"] | unique | .[] | . + "\u0000"' \ + <<<"$REMEDIABLE") + printf ' scripts/restore-tabs.sh --url %q --token --device %q --components %s --force%s\n' \ + "$URL" "$dev" "$comps" "$pane_args" + done < <(jq -j '[.[].pane.device] | unique | .[] | . + "\u0000"' <<<"$REMEDIABLE") + fi cleanup; exit 1 ;; *) diff --git a/test/e2e-browser/specs/deploy-tab-diff-rust.spec.ts b/test/e2e-browser/specs/deploy-tab-diff-rust.spec.ts index 3472fd70f..3455e030f 100644 --- a/test/e2e-browser/specs/deploy-tab-diff-rust.spec.ts +++ b/test/e2e-browser/specs/deploy-tab-diff-rust.spec.ts @@ -281,7 +281,7 @@ test.describe('deploy tab-diff ritual (rust only, ephemeral server)', () => { }) // (2) DETERMINISTIC OFFLINE diff-engine coverage: drive `verify --before F - // --after F` over synthetic fixtures so ALL FOUR verdicts, the full-set-difference + // --after F` over synthetic fixtures so ALL FIVE verdicts, the full-set-difference // coverage guard, and the multi-client bundle remediation are exercised (the live // path can only produce MISSING). With --after supplied verify does ZERO network // ops (:2619) -- proven by prepending a fake `curl` that ABORTS if invoked. @@ -316,13 +316,20 @@ test.describe('deploy tab-diff ritual (rust only, ephemeral server)', () => { rec('dev-1:codexRepoint', [pane('terminal', { mode: 'codex', sessionRef: { provider: 'codex', sessionId: 'S-old' } })]), rec('dev-1:codexFresh', [pane('terminal', { mode: 'codex', sessionRef: { provider: 'codex', sessionId: 'S-fresh' } })]), rec('dev-1:sh', [pane('terminal', { mode: 'shell', liveTerminal: { terminalId: 'T-live' } })]), - ], [term('T-live', 'running'), term('T-exited', 'exited')], + // Live coding-CLI pane whose session identity was NEVER captured (no + // sessionRef in any snapshot -- the amplifier-locator gap). Restore can + // only ever produce a blank session for it, so verify must FAIL LOUDLY + // even though the pane respawns a running terminal. + rec('dev-1:cliNoId', [pane('terminal', { mode: 'amplifier', liveTerminal: { terminalId: 'T-cli' } })]), + ], [term('T-live', 'running'), term('T-exited', 'exited'), term('T-cli', 'running')], { 'dev-1': { components: ['aaaa1111', 'bbbb2222'], capturedAt: 1000 } })) // TWO-client bundle const after = await write('after.json', doc(2000, [ rec('dev-1:codexRepoint', [pane('terminal', { mode: 'codex', sessionRef: { provider: 'codex', sessionId: 'S-new' } })]), rec('dev-1:codexFresh', [pane('terminal', { mode: 'codex' })]), rec('dev-1:sh', [pane('terminal', { mode: 'shell', liveTerminal: { terminalId: 'T-gone' } })]), - ], [])) + // ...respawned and running, yet unverifiable: still no identity. + rec('dev-1:cliNoId', [pane('terminal', { mode: 'amplifier', liveTerminal: { terminalId: 'T-cli2' } })]), + ], [term('T-cli2', 'running')])) const d = await runOffline(['verify', '--url', 'http://unused.invalid', '--token', 't', '--before', before, '--after', after]) expect(d.code).not.toBe(0) expect(d.code).not.toBe(99) // curl was NEVER called (:2619) @@ -331,6 +338,18 @@ test.describe('deploy tab-diff ritual (rust only, ephemeral server)', () => { expect(d.out).toContain('RE-POINTED') expect(d.out).toContain('FRESH (identity lost)') expect(d.out).toContain('NOT RESPAWNED') + // The fifth verdict: a live coding-CLI pane whose identity was never + // captured is UNVERIFIABLE -- verify must name it, not silently pass it + // (the 2026-07-25 blank-restore incident). Exactly ONE pane qualifies: + // the shell pane is stateless by design and must NOT be flagged. + expect(d.out).toContain('NO CAPTURED IDENTITY') + expect(d.out).toContain('dev-1:cliNoId') + expect((d.out.match(/NO CAPTURED IDENTITY/g) ?? []).length).toBe(1) + // Snapshots carry no identity for it, so restore-tabs.sh CANNOT rebuild + // it -- it must be excluded from the --pane remediation and called out + // for manual provider-store recovery instead. + expect(d.out).not.toContain('--pane dev-1:cliNoId#p-terminal') + expect(d.out).toMatch(/identity was never captured/i) // Remediation uses the immutable MULTI-CLIENT bundle (BOTH component ids), not // a single-client --generation-id (:2621). expect(d.out).toMatch(/--components aaaa1111,bbbb2222/)