diff --git a/docs/hosted-source-review-2026-10-03.md b/docs/hosted-source-review-2026-10-03.md
index 63f16a34..bd301598 100644
--- a/docs/hosted-source-review-2026-10-03.md
+++ b/docs/hosted-source-review-2026-10-03.md
@@ -122,8 +122,35 @@ temporary upload scratch and that reservation's local journal. A failed reply
uses the existing exact-reservation reconciliation instead of blindly issuing
another action. Published snapshots, live Codex files and unknown scratch stay
intact. These commands do not change the schedule or grant deletion approval.
-The customer-facing confirmation controls and actual-R2 cleanup/recovery proof
-remain separate work.
+The acceptance-gated dashboard now exposes these controls. Actual-R2
+cleanup/recovery proof remains separate work.
+
+### Acceptance-gated unfinished-upload controls
+
+From the hosted setup's saved-key or verified-backup screen, open **If a hosted
+backup is stuck**, then **Check upload status**. The app inspects the exact
+local pending reservation with its confirmed individual key. Reviewing status
+does not authorize release, intentional deletion, or another upload.
+
+For an unpublished upload, a separate unchecked confirmation enables **Release
+this unfinished upload**. The server checks the same opaque reservation again;
+an old selection cannot release its replacement. The checkbox resets when an
+action starts or the reservation changes. A published upload has no release
+control: return to backup for verification, or contact Joshua if a reviewed
+deletion upload requires operator help. Do not remove local retry state by hand.
+
+While cleanup is pending, the app preserves the journal, displays that another
+backup cannot start, and requires a fresh confirmation to finish cleanup. A
+lost reply stays uncertain until another status check. A release request clears
+the pending review only after confirmed release. Previously verified receipts, published
+backups, live Codex files, schedules, subscriptions and deletion approvals are
+not changed by these controls. Returning to backup closes the review only;
+it does not abandon an upload. Background enablement is refused while a known
+unfinished upload remains. Restart does not replay a release or retain consent.
+
+`tests/manual_hosted_setup_fixture.py` serves the real dashboard on loopback
+with clearly labelled simulated states and no file, Keychain, billing or
+provider access. It is for rendered interaction review, not hosted acceptance.
The baseline is never erased or treated as a first backup. No remote snapshot
or referenced chunk is deleted; old publication records remain selectable under
@@ -133,10 +160,11 @@ real-service/independent-Mac recovery proof remains required before release.
## Remaining release work
-The confirmation path is operator-only, not yet buyer UI. Tests exercise real
+Intentional-deletion confirmation is still operator-only, not yet buyer UI.
+Unfinished-upload controls exist only behind the hosted acceptance gate. Tests exercise real
source inventory/staging and retry logic with synthetic service/crypto boundaries;
they are not production, real-R2 or clean-Mac certification. Integrate a clear
-buyer review/confirmation and pending-cleanup flow, complete those independent
+buyer intentional-deletion review/confirmation, prove cleanup against actual R2, complete those independent
recovery checks, and certify the original full release objective.
Owner: the primary Codex Backup implementation task. The clean sibling task
diff --git a/src/codex_migrate/vault_dashboard.py b/src/codex_migrate/vault_dashboard.py
index f58acfa2..5598abde 100644
--- a/src/codex_migrate/vault_dashboard.py
+++ b/src/codex_migrate/vault_dashboard.py
@@ -25,6 +25,7 @@
.hosted-step label{display:block;margin:14px 0 8px;font-weight:700}.hosted-step input,.hosted-step select{max-width:100%;width:100%;margin-bottom:14px}.hosted-step button{margin:8px 0}.hosted-step .actions input{width:auto}.view-backup #hosted-recovery-panel,.view-conversations #hosted-recovery-panel{display:none!important}#hosted-recovery-error{color:#ffc3c8}#hosted-recovery-status{color:var(--muted)}
.view-conversations #hosted-setup-panel,.view-recovery #hosted-setup-panel{display:none!important}#hosted-setup-error{color:#ffc3c8}#hosted-setup-status{color:var(--muted)}
.brand small,.protection{font-size:14px}
+#setup-upload-confirmation{display:flex;align-items:flex-start;gap:12px;font-weight:500}#setup-upload-confirmation input{flex:0 0 auto;width:20px;height:20px;min-width:20px;margin:3px 0 0}#setup-upload-reference{overflow-wrap:anywhere;font-size:14px}
@media print{#hosted-setup-panel,#hosted-recovery-panel{display:none!important}}
@@ -139,6 +140,22 @@
+ Check the unfinished upload before retrying or releasing it. This does not delete published backups, change Codex files, stop the schedule, or cancel hosted storage.If a hosted backup is stuck
+
Releasing this upload keeps your previously published backups and live Codex work. It does not create a new backup or cancel your subscription.
+ + + + +If this upload already published, return to backup to resume its verification. If a deletion-reviewed upload cannot resume there, email Joshua. Do not delete local backup state yourself.
+Check for changes every 30 minutes while this Mac is awake and online. Backups run without a browser window. Only changed content is uploaded; previous verified versions are kept.
@@ -421,7 +438,7 @@ const fmt=n=>{const units=["B","KB","MB","GB","TB"];let i=0;while(n>=1000&&iSIMULATED UI FIXTURE — no real backup, account or subscription.
"), "text/html; charset=utf-8") + elif path == "/api/vault/hosted-setup-status": + self.send(200, public()) + elif path == "/api/vault/summary": + self.send(200, {"active_transcripts": 0, "archived_transcripts": 0, + "transcript_bytes": 0, "paginated_database_present": False}) + elif path == "/api/vault/schedule": + self.send(200, {"enabled": False}) + elif path in ("/api/vault/backup-status", "/api/vault/restore-status", + "/api/vault/install-status", "/api/vault/browse-status", + "/api/vault/thread-install-status", "/api/vault/search-index-status"): + self.send(200, {"status": "idle"}) + else: + self.send(404, {"error": "Not part of this synthetic UI fixture."}) + + def do_POST(self): + try: + size = int(self.headers.get("Content-Length", "0")) + if self.path != "/api/vault/hosted-setup" or not 0 < size <= 8192: + raise ValueError() + data = json.loads(self.rfile.read(size)) + step, action = data["step"], data["action"] + if step.get("apply") is not True: + raise ValueError() + if action == "leave_upload_review": + state["phase"] = "backup_ready" + elif action == "check_upload": + state["phase"] = "pending_upload" if state["remote"] else "backup_ready" + elif (action == "abandon_upload" and state["phase"] == "pending_upload" + and step.get("reservation_id") == RESERVATION + and step.get("confirm_abandon") is True and state["remote"] != "published"): + if state["remote"] == "active": + state["remote"] = "cleanup_pending" + else: + state.update(remote=None, phase="backup_ready") + else: + raise ValueError() + self.send(202, public()) + except (ValueError, TypeError, KeyError): + self.send(400, {"error": "Invalid synthetic fixture step."}) + + server = HTTPServer(("127.0.0.1", 0), Handler) + print("http://127.0.0.1:%d/vault?view=backup" % server.server_port, flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_vault_hosted_setup_flow.py b/tests/test_vault_hosted_setup_flow.py index 9dc64295..051225f3 100644 --- a/tests/test_vault_hosted_setup_flow.py +++ b/tests/test_vault_hosted_setup_flow.py @@ -456,6 +456,153 @@ def test_mismatched_saved_backup_refuses_startup_before_provider_work(self): HostedSetupFlow(self.registry, "/synthetic/home") self.factory.assert_not_called() + def pending_upload(self, *, state="active"): + return {"pending": True, "reservation_id": DEVICE, "snapshot_id": VAULT, + "local_phase": "active", "remote_status": state, + "can_abandon": state != "published", "automatic_protection_verified": False} + + def check_upload(self, value=None): + with patch("codex_migrate.vault_hosted_setup_flow.pending_hosted_upload", + return_value=value if value is not None else self.pending_upload()) as inspect: + result = self.step("check_upload") + return result, inspect + + def test_upload_review_requires_key_and_does_not_change_backup_receipt(self): + with self.assertRaises(MigrationError): + self.flow.stage("check_upload", {"apply": True}) + key_id = self.ready_for_backup() + self.step("first_backup") + before = json.dumps(self.saved, sort_keys=True) + result, inspect = self.check_upload() + inspect.assert_called_once_with(self.home, DEVICE, "/synthetic/state/hosted-key.json", + expected_binding={**IDENTITY, "keyId": key_id}) + self.assertEqual(result["phase"], "pending_upload") + self.assertEqual(result["last_backup"], self.receipt) + self.assertFalse(result["automatic_protection_verified"]) + self.assertEqual(json.dumps(self.saved, sort_keys=True), before) + self.backup.assert_called_once() + + def test_upload_abandon_requires_separate_exact_boolean_confirmation(self): + self.ready_for_backup() + self.check_upload() + with patch("codex_migrate.vault_hosted_setup_flow.abandon_hosted_upload") as abandon: + for values in ({"reservation_id": DEVICE, "confirm_abandon": False}, + {"reservation_id": DEVICE, "confirm_abandon": 1}, + {"reservation_id": VAULT, "confirm_abandon": True}, + {"reservation_id": "bad", "confirm_abandon": True}): + with self.subTest(values=values), self.assertRaises(MigrationError): + self.flow.stage("abandon_upload", {**values, "apply": True}) + abandon.assert_not_called() + + def test_published_upload_cannot_be_abandoned_in_setup(self): + self.ready_for_backup() + self.check_upload(self.pending_upload(state="published")) + with self.assertRaises(MigrationError): + self.flow.stage("abandon_upload", {"reservation_id": DEVICE, + "confirm_abandon": True, "apply": True}) + self.step("leave_upload_review") + self.assertEqual(self.flow.snapshot()["phase"], "key_ready") + self.assertEqual(self.flow.snapshot()["pending_upload"]["remote_status"], "published") + + def test_cleanup_pending_then_release_keeps_same_target_and_prior_backup(self): + key_id = self.ready_for_backup() + self.step("first_backup") + before = json.dumps(self.saved, sort_keys=True) + self.check_upload() + receipt = {"applied": True, "reservation_id": DEVICE, + "status": "cleanup_pending", "automatic_protection_verified": False} + with patch("codex_migrate.vault_hosted_setup_flow.abandon_hosted_upload", + return_value=receipt) as abandon: + result = self.step("abandon_upload", reservation_id=DEVICE, confirm_abandon=True) + self.assertEqual(result["phase"], "pending_upload") + self.assertEqual(result["pending_upload"]["remote_status"], "cleanup_pending") + abandon.assert_called_once_with(self.home, DEVICE, "/synthetic/state/hosted-key.json", + expected_binding={**IDENTITY, "keyId": key_id}, reservation_id=DEVICE, apply=True) + receipt["status"] = "released" + result = self.step("abandon_upload", reservation_id=DEVICE, confirm_abandon=True) + self.assertEqual(result["phase"], "backup_ready") + self.assertNotIn("pending_upload", result) + self.assertEqual(json.dumps(self.saved, sort_keys=True), before) + self.assertEqual(result["last_backup"], self.receipt) + + def test_retry_clears_review_only_after_verified_backup_and_checkpoint(self): + self.ready_for_backup() + self.check_upload(self.pending_upload(state="published")) + self.step("leave_upload_review") + self.backup.side_effect = MigrationError("reply not verified") + self.assertEqual(self.step("first_backup")["status"], "failed") + self.assertEqual(self.flow.snapshot()["pending_upload"]["reservation_id"], DEVICE) + self.backup.side_effect = None + self.registry.sync_recovery_checkpoint.side_effect = OSError("disk full") + self.assertEqual(self.step("first_backup")["status"], "failed") + self.assertIn("pending_upload", self.flow.snapshot()) + self.registry.sync_recovery_checkpoint.side_effect = None + result = self.step("first_backup") + self.assertEqual(result["phase"], "backup_ready") + self.assertNotIn("pending_upload", result) + self.assertEqual(result["last_backup"], self.receipt) + + def test_lost_cleanup_reply_keeps_exact_review_and_redacts_private_error(self): + self.ready_for_backup() + self.check_upload() + with patch("codex_migrate.vault_hosted_setup_flow.abandon_hosted_upload", + side_effect=MigrationError("private token and signed URL")) as abandon: + result = self.step("abandon_upload", reservation_id=DEVICE, confirm_abandon=True) + self.assertEqual(result["status"], "failed") + self.assertEqual(result["pending_upload"]["reservation_id"], DEVICE) + self.assertNotIn("private token", result["error"]) + self.assertIn("Check upload status", result["error"]) + abandon.assert_called_once() + self.assertEqual(self.check_upload({"pending": False, + "automatic_protection_verified": False})[0]["phase"], "key_ready") + + def test_replacement_review_invalidates_old_confirmation_and_return_does_not_release(self): + self.ready_for_backup() + self.step("first_backup") + self.check_upload() + self.check_upload({**self.pending_upload(), "reservation_id": VAULT}) + with self.assertRaises(MigrationError): + self.flow.stage("abandon_upload", {"reservation_id": DEVICE, + "confirm_abandon": True, "apply": True}) + with patch("codex_migrate.vault_hosted_setup_flow.abandon_hosted_upload") as abandon: + self.step("leave_upload_review") + abandon.assert_not_called() + with self.assertRaises(MigrationError): + self.flow.stage("enable_schedule", {"apply": True}) + + def test_restart_never_automatically_repeats_upload_cleanup(self): + self.ready_for_backup() + self.check_upload() + with patch("codex_migrate.vault_hosted_setup_flow.abandon_hosted_upload") as abandon: + self.flow = HostedSetupFlow(self.registry, self.home) + self.assertEqual(self.flow.snapshot()["phase"], "pairing_uncertain") + self.assertNotIn("pending_upload", self.flow.snapshot()) + with self.assertRaises(MigrationError): + self.flow.stage("abandon_upload", {"reservation_id": DEVICE, + "confirm_abandon": True, "apply": True}) + abandon.assert_not_called() + + def test_invalid_status_or_cleanup_receipt_cannot_repaint_success(self): + self.ready_for_backup() + pending = self.pending_upload() + for value in (None, [], {**pending, "private": "secret"}, + {**pending, "can_abandon": 1}, {**pending, "reservation_id": "bad"}, + {**pending, "remote_status": "published"}, + {"pending": False, "automatic_protection_verified": True}): + with self.subTest(value=value), patch( + "codex_migrate.vault_hosted_setup_flow.pending_hosted_upload", return_value=value): + self.assertEqual(self.step("check_upload")["status"], "failed") + self.assertNotIn("pending_upload", self.flow.snapshot()) + self.check_upload() + for status in ("published", "active", "unknown"): + with self.subTest(status=status), patch( + "codex_migrate.vault_hosted_setup_flow.abandon_hosted_upload", + return_value={"applied": True, "reservation_id": DEVICE, "status": status, + "automatic_protection_verified": False}): + self.assertEqual(self.step("abandon_upload", reservation_id=DEVICE, + confirm_abandon=True)["status"], "failed") + self.assertEqual(self.flow.snapshot()["phase"], "pending_upload") + if __name__ == "__main__": unittest.main() diff --git a/tests/vault-dashboard-ui.test.js b/tests/vault-dashboard-ui.test.js index e633514c..658c54c5 100644 --- a/tests/vault-dashboard-ui.test.js +++ b/tests/vault-dashboard-ui.test.js @@ -23,7 +23,7 @@ const hostedSetupStep = source.match(/async function hostedSetupStep\(action,ste const refreshHostedSetup = source.match(/async function refreshHostedSetup\(\)\{[\s\S]*?\n\}/)[0]; function setupFixture() { - const phases = ['start', 'email', 'pairing_checkpoint', 'pairing_uncertain', 'paired', 'key_save', 'key_ready', 'backup_ready']; + const phases = ['start', 'email', 'pairing_checkpoint', 'pairing_uncertain', 'paired', 'key_save', 'key_ready', 'backup_ready', 'pending_upload']; const blocks = phases.map(phase => ({ dataset: { setupPhase: phase }, hidden: false })); const document = { activeElement: { outside: true } }; const controls = phases.map(phase => ({ phase, disabled: false, focus() { document.activeElement = this; } })); @@ -41,7 +41,7 @@ function setupFixture() { }, document, clearInterval: () => {}, setInterval: () => 1, }; vm.createContext(context); - vm.runInContext('let hostedSetupTimer=null,hostedSetupInterval=0,hostedSetupPhase=null,hostedSetupEpoch=0; ' + hostedSetupView + '\n' + refreshHostedSetup + '\n' + hostedSetupStep, context); + vm.runInContext('let hostedSetupTimer=null,hostedSetupInterval=0,hostedSetupPhase=null,hostedSetupEpoch=0,hostedUploadReservation=null; ' + hostedSetupView + '\n' + refreshHostedSetup + '\n' + hostedSetupStep, context); return { context, panel, controls, elements, blocks, document }; } @@ -64,6 +64,55 @@ test('hosted setup is gated and pairing never claims backup or automatic protect assert.match(source, /@media print\{#hosted-setup-panel,#hosted-recovery-panel\{display:none!important\}\}/); }); +test('unfinished-upload release is separately confirmed and publication disables it', () => { + const { context, elements } = setupFixture(); + const pending = { reservation_id: 'synthetic-id', remote_status: 'active', can_abandon: true }; + const data = { enabled: true, phase: 'pending_upload', status: 'ready', pending_upload: pending }; + context.hostedSetupView(data); + assert.equal(elements.get('setup-abandon-upload').disabled, true); + assert.equal(elements.get('setup-upload-confirmation').hidden, false); + elements.get('setup-abandon-confirm').checked = true; + context.hostedSetupView(data); + assert.equal(elements.get('setup-abandon-upload').disabled, false); + context.hostedSetupView({ ...data, pending_upload: { ...pending, reservation_id: 'replacement' } }); + assert.equal(elements.get('setup-abandon-confirm').checked, false); + assert.equal(elements.get('setup-abandon-upload').disabled, true); + context.hostedSetupView({ ...data, pending_upload: { ...pending, remote_status: 'published', can_abandon: false } }); + assert.equal(elements.get('setup-abandon-upload').hidden, true); + assert.equal(elements.get('setup-upload-confirmation').hidden, true); + assert.match(elements.get('setup-upload-detail').textContent, /resume verification/); +}); + +test('cleanup pending never claims protection or clears the reviewed reference', () => { + const { context, elements } = setupFixture(); + for (const state of ['cleanup_pending', 'released']) { + context.hostedSetupView({ enabled: true, phase: 'pending_upload', status: 'ready', + pending_upload: { reservation_id: 'same-id', remote_status: state, can_abandon: true } }); + assert.equal(elements.get('setup-abandon-upload').textContent, 'Finish upload cleanup'); + assert.match(elements.get('setup-upload-reference').textContent, /same-id/); + assert.match(elements.get('hosted-setup-status').textContent, /New work may not be backed up/); + assert.equal(elements.get('setup-enable-schedule').disabled, true); + } +}); + +test('release handler sends only an explicitly checked exact reservation and clears confirmation', () => { + const elements = new Map([['setup-abandon-confirm', { checked: false }], ['setup-abandon-upload', {}]]); + const calls = []; + const context = { $: id => elements.get(id), hostedUploadReservation: 'exact-id', + hostedSetupStep: (action, step) => calls.push({ action, step }) }; + vm.createContext(context); + vm.runInContext(source.match(/\$\("setup-abandon-upload"\)\.onclick=.*?;\n/)[0], context); + elements.get('setup-abandon-upload').onclick(); + assert.equal(calls.length, 0); + elements.get('setup-abandon-confirm').checked = true; + elements.get('setup-abandon-upload').onclick(); + assert.equal(elements.get('setup-abandon-confirm').checked, false); + assert.equal(calls[0].action, 'abandon_upload'); + assert.equal(calls[0].step.reservation_id, 'exact-id'); + assert.equal(calls[0].step.confirm_abandon, true); + assert.doesNotMatch(source, /(?:localStorage|sessionStorage)\.setItem\([^\n]*reservation/); +}); + test('setup lost reply reads status exactly once without repeating pairing or echoing a private proof', async () => { const { context, controls, elements } = setupFixture(); const calls = [];