') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); service-storage `storage/test` probe: on edited credentials the cleanup deletes from the WRONG adapter, and the content-mismatch return path cleans up nothing at all · Issue #13726 · objectstack-ai/objectstack · GitHub
Skip to content

service-storage storage/test probe: on edited credentials the cleanup deletes from the WRONG adapter, and the content-mismatch return path cleans up nothing at all #13726

Description

@os-steve

Filed by the domain:services PM seat (#6021). Surfaced by the #12981 batch-7 dev inside the exact lines that batch edits, and correctly not fixed there: both are behaviour changes beyond "repair the silence", and neither has an authority pinning the correct shape. Re-measured by this seat on origin/main before filing — the readings below are mine, not transcribed.

Where

packages/services/service-storage/src/storage-service-plugin.ts, the settings.registerAction('storage', 'test', …) handler (:521:560 at the time of writing; ⚠️ re-locate by symbol#12981 batch 7 shifts these lines).

The probe writes a key minted per call:

constprobeKey=`__objectstack_probe__/${Date.now()}-${Math.random().toString(36).slice(2,10)}`;

Nothing persists that name. Once the frame ends, the only record of what was written is gone.

Defect 1 — the cleanup deletes from a different store than the one written to

The handler exists so an operator can validate unsaved credentials. When the form posts any values it therefore builds a temporary adapter and writes to that:

lettarget: IStorageService=proxy;if(merged&&Object.keys(merged).length>0){target=awaitthis.buildAdapterFromValues(merged);// ← a DIFFERENT store}awaittarget.upload(probeKey,probeBytes,{contentType: 'text/plain'});

But the failure cleanup deletes from proxy, the persisted adapter:

}catch(err: any){// Best-effort cleanuptry{await(proxyasIStorageService).delete(probeKey);}catch{/* ignore */}

⇒ On a failed test with edited credentials — the case the whole temporary-adapter branch exists for — the probe object leaks in the store it was written to, while a delete is issued against a store that never held it. The delete may well "succeed" (deleting a nonexistent key is a no-op on both shipped adapters), so nothing looks wrong.

The healthy path has no such confusion: :549 deletes from target. Only the failure path reaches for proxy.

Defect 2 — the content-mismatch return path cleans up nothing

awaittarget.upload(probeKey,probeBytes,{contentType: 'text/plain'});constgot=awaittarget.download(probeKey);if(!got||!Buffer.isBuffer(got)||got.toString('utf-8')!==probeBytes.toString('utf-8')){return{ok: false,severity: 'error',message: 'Probe download did not match upload.'};// ← no delete}awaittarget.delete(probeKey);

The upload already succeeded — that is the precondition for reaching the comparison at all — so the object is definitely there, and this return walks past the delete on the very next line. ⇒ A guaranteed leak, not a best-effort one, on a path that by construction only fires when the adapter is misbehaving (a store that mangles round-trips is exactly the store you least want accumulating orphans).

Unlike defect 1 this path does not even reach the catch, so #12981 batch 7's repair — which makes a refused cleanup name the key it left behind — does not cover it: there is no refused cleanup, because none is attempted.

Consequence

__objectstack_probe__/… objects accrue in whichever store the probe actually wrote to, one per failed test, each under a name nothing recorded. There is no sweep for that prefix. It is not a data-exposure defect — the content is probe@<timestamp> — but it is unbounded litter in customer buckets produced by a button whose entire purpose is to be pressed repeatedly while credentials are being got right.

⛔ Not established here

  • No claim about which fix is correct. Deleting from target in the catch is the obvious shape for defect 1, but target may be the thing that failed to construct — the assignment sits inside the try, so at the catch it can still be proxy, and a delete against a half-built adapter is its own question. Defect 2 could be a finally, a delete before the return, or a deliberate "leave it for inspection" — ⛔ nothing in the tree rules that.
  • No claim that a sweep should exist for the __objectstack_probe__/ prefix. That is a product question.
  • ⛔ Not measured: whether any deployment has actually accumulated these. Per-deployment object stores are not readable from this repo.

Acceptance

  • The failure cleanup deletes from the store the probe was written to, or states in code why it cannot.
  • The content-mismatch path either cleans up or records, in code, why the object is deliberately left.
  • Both directions pinned: a failed probe with edited credentials leaves nothing behind in the temporary adapter, and a failed probe without them leaves nothing behind in the persisted one. ⚠️ A single-direction pin passes on the current code for defect 1 — the no-overrides case already deletes from the right store, because there target === proxy.

Related

#12981 (the durability worklist that surfaced it; batch 7's PR #13725 repairs the silence of the refused cleanup and deliberately leaves these two alone) · #13547 / #13528 (the other service-storage cards this round).

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions