Repository navigation
fix(memory): write the LinkedIn profile to the workspace the host bound - #5575
Conversation
`build_memory_client` used `MemoryClient::new_local()`, which resolves `~/.openhuman/workspace` from the home directory and never consults `OPENHUMAN_WORKSPACE`. On any host that scopes its workspace — every hosted tenant, and any local run with the variable set — the scraped profile was written to a store nothing else reads. It failed by *succeeding*, which is why nothing surfaced it: the write returned `Ok`, and the profile simply was not there later. It now takes the process-wide client, which is the workspace the host actually bound. The caller already treats an error as "warn and skip persistence", so a host that has not bound memory yet loses the write rather than misplacing it — the better of the two failures. `new_local` was also a second engine construction against the same data directory, one call deeper than `MemoryClient::from_workspace_dir`, which `memory::bypass_allowlist_tests` refuses on the grounds that it "risks a second ingestion worker on one store". The scanner had no needle for `new_local`, so that hazard was invisible. The allowlist gains an entry, and the bypass count does not change. This site always bypassed the guard; it did so through a pattern nothing scanned for, and now does so through one that is scanned, declared, and reasoned. Routing it through the guard instead is not available: it needs `MemoryClient::store_skill_sync`, which derives the `skill-<id>` namespace and keys the upsert on an opaque `document_id` precisely so a scraped title never reaches the secret/PII key guard. No provider family carries that shape, and reimplementing it at the call site is the duplication that method exists to prevent. An earlier revision of this change added `MemoryClient::new_local(` to `BYPASS_PATTERNS`. `bypass_patterns_are_all_live` rejected it, correctly: after the fix nothing calls it, and a needle that matches nothing guards nothing. Validation: - `cargo check -p openhuman --lib` — clean - `cargo clippy -p openhuman --lib` — clean - `cargo fmt --all -- --check` — clean - `cargo test -p openhuman --lib -- bypass_allowlist direct_engine_refs` — 12 passed, 0 failed - `cargo test -p openhuman --lib -- linkedin` — 9 passed, 0 failed
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan includes up to 10 reviews per rolling hour; 9 remain after this review. 📝 WalkthroughWalkthroughLinkedIn enrichment now asynchronously obtains the configured active memory client. The bypass allowlist test and specification document record the call site and its persistence behavior. ChangesLinkedIn memory client
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: 🔵 Low · up to This change makes LinkedIn profile persistence use the host-bound workspace instead of an unintended home-directory store. A bounded security risk remains if multiple workspaces share one process or the global binding is stale, because profile data could be saved to the wrong tenant; merge is reasonable with explicit owner awareness and follow-up on workspace-context isolation. Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review The earlier run reported "Review failed — an error occurred during the review process" rather than any findings, so this PR has not actually been reviewed by CodeRabbit. Requesting a fresh pass. For context while it runs: the change is three files — a one-line swap of Separately, the failing Markdown Link Check is not from this PR. All four reported 404s are in |
|
✅ Action performedReview finished.
|
How this change flows2 changed behaviours across 1 relationship. The code graph does not know these behaviours yet — normal for newly added code, and a cold index otherwise. 2 further behaviours left out to keep the diagram readable. flowchart LR
n0["run_linkedin_enrichment<br/>changed"]:::changed
n1["scrape_linkedin_profile<br/>changed"]:::changed
n0 -->|calls| n1
classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Green: changed behaviour. Grey: surrounding behaviour. Arrows name the call, use, implementation, or test relationship. Orange: has findings. Red: has a finding that blocks the merge. |
CodeGhost21
left a comment
There was a problem hiding this comment.
The fix is right. The justification attached to it is not — one clause of the allowlist reason describes a protection this call site specifically does not get.
Verified against the pinned submodule (vendor/tinymemory @ dc3a725), not just the diff.
The fix holds. global::client() returns the workspace init(…) bound (core/src/global.rs:170), with no ~/.openhuman fallback — that absence is documented there as deliberate. MemoryClientRef = Arc<MemoryClient>, so persist_linkedin_profile(&MemoryClient, …) still resolves by deref. Allowlist entry is sorted correctly (path, then pattern) and the spec row landed in the right section (D).
1. Blocking — the allowlist reason claims a protection this site doesn't receive
Both the ALLOWED entry (bypass_allowlist_tests.rs:193) and the spec row (memory-guard-allowlist.md:165) say store_skill_sync:
keys the upsert on an opaque
document_idso a scraped title never reaches the secret/PII key guard
But this caller passes None for document_id — linkedin_enrichment.rs:723, and again at :745 for the URL-only fallback. And store_skill_sync names this exact caller as the exception to that behaviour:
// Callers without a stable id (e.g. LinkedIn enrichment) keep the
// title as the key, unchanged.— vendor/tinymemory/core/src/store/client.rs:305
So the upsert key here is the title, LinkedIn profile: {url}, and it does go through upsert_document's secret/PII key guard. Not a practical bug — a LinkedIn URL won't trip the detector — but this list's own doc says the reason string is "what a future reviewer relies on to tell 'deliberate' from 'forgotten'", and as written it states the opposite of what the code does. That's the one thing an allowlist entry cannot afford to get wrong.
The entry still looks justified, just on different grounds: store_skill_sync derives the skill-<id> namespace and stamps every write MemoryTaint::ExternalSync (client.rs:333-336) so the subconscious gate can see the provenance — neither of which any guard-routed provider family exposes. Suggest rewriting both the entry and the doc row around those two, and dropping the document_id clause.
2. Consider — global::client() vs ops::helpers::active_memory_client()
The PR frames "a host that hasn't bound memory loses the write" as the unavoidable half of the trade. It isn't quite. memory::ops::helpers::active_memory_client() (helpers.rs:379) returns the same MemoryClientRef and falls back to global::init(Config::load_or_init().workspace_dir) when startup wiring hasn't run — explicitly not to ~/.openhuman, so it doesn't reintroduce the bug this PR fixes.
Two things that would buy:
- No lost write in the pre-init case. The skip-and-warn branch stays for genuine failures.
- It resolves the same workspace
write_profile_md(config, …)writesPROFILE.mdinto. As written, the two halves ofrun_linkedin_enrichmentderive their target workspace from different sources — config for the file, the global binding for memory — and can in principle disagree.
Cost is identical either way: active_memory_client( is itself a BYPASS_PATTERNS needle (bypass_allowlist_tests.rs:81), so the allowlist gains exactly one entry regardless of which accessor you pick.
If global::client() is the deliberate choice — "nothing bound memory ⇒ that's a wiring bug, drop the write loudly rather than auto-binding" — that's a defensible position, but a stronger claim than the current doc comment makes. Worth stating outright so the next reader doesn't 'fix' it. Both entry points reach here via config_rpc::load_config_with_timeout() inside a running daemon (tools.rs:604, schemas.rs:593), so init has almost certainly run; this is about which failure you want in the case where it hasn't.
3. Follow-up, not for this PR
MemoryClient::new_local() is now referenced by nothing in either tree — grep finds only its definition at vendor/tinymemory/core/src/store/client.rs:138 plus the prose in this PR. It's the footgun that produced this bug, and no lint on the openhuman side can hold the line on it: a BYPASS_PATTERNS needle for it would fail bypass_patterns_are_all_live, exactly as you hit. Deleting it or #[cfg(test)]-gating it in tinymemory alongside #18 is what actually closes the hazard; otherwise the next caller reintroduces the same silent misplacement.
Only #1 blocks. CI is green across the board — Rust Quality, Core Coverage, Feature-Gate Smoke all pass.
…pace Review on tinyhumansai#5575 caught that one clause of the allowlist reason describes a protection this call site specifically does not get. The reason claimed `store_skill_sync` "keys the upsert on an opaque `document_id` so a scraped title never reaches the secret/PII key guard". This caller passes `None` for `document_id` at both sites, and `store_skill_sync` names it as the exception: // Callers without a stable id (e.g. LinkedIn enrichment) keep the // title as the key, unchanged. So the key here is the title, `LinkedIn profile: {url}`, and it does go through `upsert_document`'s guard. Harmless in practice for a LinkedIn URL, but the allowlist doc says this column is what a future reviewer relies on to tell deliberate from forgotten, and a reason that overclaims is worse than none. Rewritten on the grounds that hold -- `store_skill_sync` derives the `skill-<id>` namespace and stamps every write `MemoryTaint::ExternalSync` so the subconscious gate sees the provenance -- with the absence of the key protection stated outright rather than quietly dropped. Also switches to `memory::ops::helpers::active_memory_client`, which the review pointed out is strictly better here: - It does not lose the write when startup wiring has not run. It falls back to `global::init(Config::load_or_init().workspace_dir)`, explicitly not to `~/.openhuman/workspace`, so it cannot reintroduce the bug this PR fixes. - It resolves the same workspace `write_profile_md` writes `PROFILE.md` into. Both derive it from `Config` now; taking the global binding left the two halves of `run_linkedin_enrichment` deriving their target from different sources. Allowlist cost is identical -- `active_memory_client(` is already a BYPASS_PATTERNS needle -- so this is one entry either way. cargo fmt --check: clean cargo clippy --lib: clean cargo test --lib openhuman::memory::bypass_allowlist: 6 passed cargo test --lib linkedin: 9 passed
|
@CodeGhost21 — both taken, in 78c0bac. Thank you for checking this against the pinned submodule rather than the diff; #1 is a real defect and I would not have caught it from the PR alone. 1. Blocking — the reason string overclaimedConfirmed, and worse than a wording slip: it stated the opposite of what the code does. Both call sites pass
— so the key is Rewritten in both places on the two grounds that actually hold: One deliberate difference from your suggestion: rather than just dropping the 2. Consider — switched to
|
CodeGhost21
left a comment
There was a problem hiding this comment.
Both points taken, and taken correctly. Re-verified 78c0bac1 against the pinned submodule (vendor/tinymemory @ dc3a725, unchanged by this PR) rather than the diff. Approving.
1 — the reason string now matches the code
Both the ALLOWED entry and the section-D row now rest on the two grounds that hold, and I confirmed each at the source:
store_skill_syncderivesformat!("skill-{}", skill_id.trim())(client.rs:305) and stampstaint: crate::MemoryTaint::ExternalSyncon everyNamespaceDocumentInputit builds (client.rs:333-336).- The
Nonepath keys on the title —let key = match stable_id { Some(id) => …, None => title.to_string() }— and both call sites still passNone(linkedin_enrichment.rs:739,:761).
Stating outright that this site does not get the opaque-document_id protection, rather than dropping the clause, is the better call and I'd have been wrong to suggest otherwise — a silent removal leaves the next reader unable to distinguish "considered and rejected" from "forgotten", which is the same failure one step quieter.
2 — active_memory_client(), and it does not reintroduce the hazard
Checked the thing worth checking about auto-init, since a fallback that constructs is a different risk from one that reads: global::init is not a one-shot OnceLock::set. It returns the existing handle for the same workspace, and consults WORKSPACE_CLIENTS before constructing — the comment there names this exact hazard ("building a second client for A would put two ingestion workers over A's SQLite file"). cache_client resolves the construct-concurrently race by letting the first insert win. So the pre-init path yields one engine per workspace, not a second one over the store, and a later login rebind still replaces cleanly.
Also confirmed:
current_workspace_dir()isConfig::load_or_init().workspace_dir(helpers.rs:363) — no~/.openhumanfallback anywhere on the path.MemoryClientRef = Arc<MemoryClient>(client.rs:28), sopersist_linkedin_profile(&MemoryClient, …)still resolves by deref from&MemoryClientRef.- The allowlist pattern for this path changed from
global::client(toactive_memory_client(rather than gaining a second row;active_memory_client(was already aBYPASS_PATTERNSneedle (bypass_allowlist_tests.rs:81), and the entry is well-formed underbypass_allowlist_entries_are_well_formedand correctly sorted. Bypass count unchanged.
CI is green — Rust Quality, Feature-Gate Smoke, Markdown Link Check all pass on this head; only Core Coverage is still running.
Two non-blocking notes, neither for this PR:
- The section-D row quotes the key as
LinkedIn profile: {url}, which is the full-scrape title. The fallback writesLinkedIn profile URL: {url}(:753). Both go through the guard identically, so the reasoning is unaffected — "the title" would just be exact where "LinkedIn profile: {url}" is one of two. - Consequence of that same title-keying, pre-existing and untouched here: a URL-only fallback followed by a later successful scrape leaves two documents in
skill-user-profile, since the two titles are different keys. If that matters, a stablelinkedin:{url}document_idat both sites would collapse them — but that changes dedup behaviour and belongs in its own change, not this fix.
And agreed on #3 — new_local() is the footgun, no openhuman-side lint can hold that line, and gating it in tinymemory alongside #18 is what closes it.
…pace\n\nReview on tinyhumansai#5575 caught that one clause of the allowlist reason describes a\nprotection this call site specifically does not get.\n\nThe reason claimed `store_skill_sync` "keys the upsert on an opaque\n`document_id` so a scraped title never reaches the secret/PII key guard".\nThis caller passes `None` for `document_id` at both sites, and\n`store_skill_sync` names it as the exception:\n\n // Callers without a stable id (e.g. LinkedIn enrichment) keep the\n // title as the key, unchanged.\n\nSo the key here is the title, `LinkedIn profile: {url}`, and it does go\nthrough `upsert_document`'s guard. Harmless in practice for a LinkedIn\nURL, but the allowlist doc says this column is what a future reviewer\nrelies on to tell deliberate from forgotten, and a reason that overclaims\nis worse than none.\n\nRewritten on the grounds that hold -- `store_skill_sync` derives the\n`skill-<id>` namespace and stamps every write `MemoryTaint::ExternalSync`\nso the subconscious gate sees the provenance -- with the absence of the\nkey protection stated outright rather than quietly dropped.\n\nAlso switches to `memory::ops::helpers::active_memory_client`, which the\nreview pointed out is strictly better here:\n\n- It does not lose the write when startup wiring has not run. It falls\n back to `global::init(Config::load_or_init().workspace_dir)`, explicitly\n not to `~/.openhuman/workspace`, so it cannot reintroduce the bug this\n PR fixes.\n- It resolves the same workspace `write_profile_md` writes `PROFILE.md`\n into. Both derive it from `Config` now; taking the global binding left\n the two halves of `run_linkedin_enrichment` deriving their target from\n different sources.\n\nAllowlist cost is identical -- `active_memory_client(` is already a\nBYPASS_PATTERNS needle -- so this is one entry either way.\n\ncargo fmt --check: clean\ncargo clippy --lib: clean\ncargo test --lib openhuman::memory::bypass_allowlist: 6 passed\ncargo test --lib linkedin: 9 passed\n
…client-workspace-scoping\n\nfix(memory): write the LinkedIn profile to the workspace the host bound\n
The bug
build_memory_client()inlinkedin_enrichment.rsusedMemoryClient::new_local():It resolves
~/.openhuman/workspacefrom the home directory and never consultsOPENHUMAN_WORKSPACE. On any host that scopes its workspace — every hosted tenant, and any local run with the variable set — the scraped LinkedIn profile was written to a store nothing else reads.It failed by succeeding. The write returned
Ok; the profile simply was not there afterwards. That is why nothing surfaced it.The fix
Take the process-wide client, which is the workspace the host actually bound.
The caller already handles the error path — it warns and skips persistence — so a host that has not bound memory yet now loses the write rather than misplacing it. That is the better of the two failures: one is visible in logs, the other is invisible until someone asks why the agent has no profile context.
On the allowlist entry
new_localwas also a second engine construction against the same data directory — one call deeper thanMemoryClient::from_workspace_dir, whichbypass_allowlist_testsalready refuses because it "risks a second ingestion worker on one store". The scanner had no needle fornew_local, so that hazard was invisible.The allowlist gains an entry; the bypass count does not change. This site always bypassed the guard. It did so through a pattern nothing scanned for, and now does so through one that is scanned, declared, and reasoned. The list is more accurate after this change, not more permissive.
Routing it through the guard instead is not available. It needs
MemoryClient::store_skill_sync, which derives theskill-<id>namespace and keys the upsert on an opaquedocument_idprecisely so a scraped profile title never reaches the secret/PII key guard — the method's own comment explains that a title "can legitimately contain verification codes, token-rotation notices, or other secret-/PII-looking strings". No provider family carries that shape, and reimplementing it at the call site is the duplication the method exists to prevent.docs/specs/memory-guard-allowlist.mdgains the matching row in section D.One thing the lints caught me on
An earlier revision added
MemoryClient::new_local(toBYPASS_PATTERNS.bypass_patterns_are_all_liverejected it:Correct — after the fix nothing calls it. Removed.
no_new_memory_driver_bypassesthen flagged theglobal::client(site, which is what produced the declared entry above rather than a silent swap of one bypass for another.Validation
cargo check -p openhuman --libcargo clippy -p openhuman --libcargo fmt --all -- --checkcargo test -p openhuman --lib -- bypass_allowlist direct_engine_refscargo test -p openhuman --lib -- linkedinScope
Three files: the fix, the allowlist entry, the spec row. No feature flags, no dependency changes.
Found while working tinyhumansai/tinymemory#18 §C4 (removing the OpenHuman framing from
tinymemory-core), but it stands on its own — the workspace bug is real today, independent of that work.Summary by CodeRabbit
Bug Fixes
Documentation