Skip to content

fix: cross-workspace memory search stays inside the caller's tenant (#415) - #421

Merged
saucam merged 4 commits into
mainfrom
fix/fleet-recall-tenant
Oct 25, 2026
Merged

saucam merged 4 commits into
mainfrom
fix/fleet-recall-tenant

Conversation

@saucam

@saucam saucam commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

What this fixes

On a daemon shared by more than one tenant, conductor fleet tools and the search box could return other tenants' memory.
fleet_recall, fleet_find, and session.search with scope all searched every workspace in the memory store, which spans every tenant on the daemon.
fleet_find had the same hole as the two paths named in #415.

Now every search that spans workspaces is limited to the caller's tenant, and that limit is enforced in the memory store itself, not by each caller.
A workspace whose owner can't be proven is excluded from those searches.

Fixes #415.

What changes for users

  • Shared daemons: one tenant's conductor and search no longer see another tenant's sessions, live or destroyed.
  • Single-user daemons: cross-workspace search behaves as before for every session that still exists and for memory whose owner can be proven. On the maintainer's real store, 28 of 29 workspaces got an owner automatically. The remaining one (8 episodes from long-destroyed sessions) has no provable owner and stays out of cross-workspace search; per-workspace recall in its own directory is unchanged.
  • One-time boot step: the first start after upgrading records each existing workspace's owner. It took 2.5 s on that store and logs what it did.
  • Speed: cross-workspace keyword search runs in 1–16 ms on that store. An earlier draft of this fix made it 200–8000x slower; the reviews caught it and a query-plan test now guards it.

Review

Two independent audit rounds, each with a security reviewer and a correctness reviewer, all findings folded in:

  • round 1 fixed the query-plan regression, made the backfill use the ids episodes actually carry, and stopped an older migration from handing a destroyed tenant's memory to a surviving one;
  • round 2 made the backfill wait for that older migration, bounded and memoized the git probes it runs, kept probing to session workdirs and the user's home, and chunked a query under SQLite's parameter limit.

Full suite: 3709 pass / 0 fail. Typecheck and lint clean.

For agents: implementation
  • memory/store.ts: new workspace_tenants (workspace_id PK, account_id, project_id) and memory_migrations. listWorkspaceIds(tenant) and ftsSearchGlobal(query, limit, tenant) join it; the FTS query uses CROSS JOIN so the FTS index drives (explainFtsSearchGlobal + plan test). registerWorkspaceTenant, isWorkspaceOwnedBy, listWorkspaceTenants.
  • backfillWorkspaceTenants: one-time, waits until needsWorkspaceMigration() is false. Registers (a) episode-carried ids whose persisted sessions all share one tenant, excluding pre-tenant path ids, and (b) orphan ids only when workspaceIdFromPath(dir, tenant) reproduces them, for dirs under session workdirs or $HOME (≤400) and tenants from sessions plus local.
  • migrateWorkspaceIdsToTenant no longer re-keys orphans (surviving sessions cannot prove a destroyed session's tenant), and skips a malformed row instead of aborting.
  • workspaceIdFromPath refuses NUL in tenant parts; the git probe has a 5 s timeout and a per-directory cache.
  • memory/engine.ts: recallGlobal requires tenant (throws without); searchSessions takes SearchSessionsScope — a workspace, or a tenant for cross-workspace; a workspace given with a tenant must belong to it; registerWorkspace.
  • session.ts: registers its workspace at construction and retries before the next episode if that failed.
  • fleet.ts: FleetDeps.tenant, used by fleet_find/fleet_recall. session-manager.ts: conductor + orchestrator deps set it; session.search passes the caller's tenant.
  • server.ts: runs the backfill after the re-key migration, with local tenant and $HOME probe root.
  • Not fixed here (pre-existing, documented in review): stores already migrated by the old orphan re-key may hold a destroyed tenant's episodes inside a surviving tenant's workspace; this PR does not widen who can read them.

🤖 Generated with Claude Code

saucam and others added 4 commits October 25, 2026 07:44
…415)

fleet_recall, fleet_find and session.search scope:all searched every
workspace in the memory store, which spans every tenant on a shared
daemon. The store now records each workspace's owning tenant
(workspace_tenants; registered by every session at construction, plus a
one-time backfill from persisted sessions), and every cross-workspace
primitive takes the caller's tenant and joins it. A workspace whose
owner cannot be proven is invisible to cross-workspace search.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…han re-key

- ftsSearchGlobal pins the loop order with CROSS JOIN; the plain JOIN made
  cross-workspace search 200-8000x slower on a real store (plan-guard test).
- The backfill registers the workspace ids episodes actually carry under
  their session's tenant, and gives an orphan an owner only when
  workspaceIdFor(dir, tenant) reproduces its id. Path-only ids never get one.
- The tenant migration no longer re-keys orphans: surviving sessions cannot
  prove a destroyed session's tenant.
- NUL in a tenant id is refused; a workspace passed with a tenant must belong
  to it; a failed registration is retried before the next episode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…unded probes

- The ownership backfill waits until the pre-tenant re-key has run: before
  it, a path-only id can hold several tenants' episodes.
- The git probe behind a workspace id has a timeout and is memoized per
  directory, so hashing one directory for several tenants forks once.
- Orphan probing stays under session workdirs and the user's home; file
  paths come from agents' tool calls.
- The orphan query is chunked under SQLite's parameter limit, and one
  malformed tenant row no longer aborts every tenant's re-key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@saucam
saucam merged commit 171478b into main Oct 25, 2026
4 checks passed
@saucam
saucam deleted the fix/fleet-recall-tenant branch October 25, 2026 00:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: fleet_recall returns memory from other tenants on a shared daemon

2 participants