Skip to content

fix(runtime): record company_id on FUSE workspace and CLI agent state - #207

Merged
yetone merged 1 commit into
yetone:mainfrom
wg2038:fix/runtime-fuse-company-id-isolation
Sep 5, 2026
Merged

yetone merged 1 commit into
yetone:mainfrom
wg2038:fix/runtime-fuse-company-id-isolation

Conversation

@wg2038

@wg2038 wg2038 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Problem & Real-World Impact

Cumora enforces per-tenant multi-tenant isolation where per-agent data (agent_workspace, agent_climate, agent_tasks, agent_log) must be scoped to company_id (as added in schema migrations 583-636, 718, 835, 1885 and documented in skills.ts:250-252 and cli.ts:55-58).

However, several write paths omitted company_id when persisting records:

  1. server/src/agents/runtime/fs-endpoints.ts (PUT /runtime/fs/write):
    When agents write files to /workspace via the FUSE virtual filesystem driver in their pod, c.companyId was available in the JWT claims but omitted from the INSERT INTO agent_workspace statement, leaving company_id as NULL.

    • Devtools Workspace Explorer Broken: GET /devtools/agent-workspace/tree and /file (router.ts:5357, 5377) query WHERE agent_id = $1 AND company_id = $2. Because company_id was NULL, files written by agents via FUSE were completely invisible in the UI (returning [] or 404).
    • Data Retention / Orphan Leak (GDPR Risk): DELETE /api/companies/:id (router.ts:1827) cleans up soft-scoped tables using DELETE FROM ${table} WHERE company_id = $1. Because company_id was NULL, files created via FUSE were never deleted upon workspace deletion, permanently leaving orphaned agent workspace data in Postgres after the owner and participants were removed.
    • Agent Rename Cascade Failure: During agent rename (cascadeAgentIdChange), agent_workspace is updated with WHERE company_id = $3 (migrate.ts:1885). NULL company_id rows failed to match, severing file ownership upon rename.
  2. server/src/agents/cli.ts & climate.ts:

    • tasks add: retrieved companyId = await agentCompany(me) on line 4448 and emitted it in telemetry, but omitted company_id in INSERT INTO agent_tasks.
    • climate note & bumpClimate: omitted company_id in INSERT INTO agent_climate, causing records to default to 'personal' and bypass workspace deletion.
    • memory note & log note: omitted company_id in INSERT INTO agent_log.

Solution

  1. FUSE File Writes:
    • Include company_id: c.companyId in INSERT INTO agent_workspace in server/src/agents/runtime/fs-endpoints.ts.
    • Update company_id = COALESCE(EXCLUDED.company_id, agent_workspace.company_id) on conflict.
  2. CLI Tasks, Climate & Log:
    • Populate company_id in agent_tasks, agent_climate, and agent_log.
    • Query participant's company_id in bumpClimate in server/src/agents/climate.ts.
  3. Workspace Deletion Defense-in-Depth:
    • In DELETE /api/companies/:id (server/src/api/router.ts), explicitly purge any per-agent rows for the company's agentIds across ['agent_workspace', 'agent_memory', 'agent_log', 'agent_tasks', 'agent_climate'] to guarantee zero orphaned data even for pre-existing or legacy rows.

Verification

  • FUSE Runtime Writes:
    • Verified with dedicated repro test that company_id previously stored null, now stores the authenticated companyId.
    • Added assertion in server/src/__integration__/runtime-server.test.ts to assert agent_workspace.company_id matches the agent's tenant (34/34 pass).
  • Workspace Deletion Cascade:
    • Extended server/src/__integration__/workspace-management.test.ts to seed agent_workspace, agent_tasks, agent_climate, and agent_log, delete the workspace, and assert all rows are completely purged from Postgres (12/12 pass).
  • Full Test Suites & Guards:
    • npm test: 1127 passed, 4 skipped, 0 failed.
    • npm run test:integration: 297 passed, 5 skipped, 0 failed.
    • npm run lint: 485 files checked, 0 errors.
    • npm run typecheck & npm run server:typecheck: 0 errors.
    • npm run guard:big-brain, guard:llm-tracked, guard:engine-registry: all passed.

- Persist authenticated company_id in PUT /runtime/fs/write and update on conflict
- Populate company_id in CLI tasks add, memory note, log note, and climate updates
- Add defense-in-depth per-agent cleanup when deleting workspaces to prevent orphan data
- Verify with devtools visibility and cascade deletion integration tests
@yetone
yetone merged commit 1974764 into yetone:main Sep 5, 2026
7 checks passed
WhichPaths added a commit to WhichPaths/cumora that referenced this pull request Sep 5, 2026
Rebased onto main and reduced to what yetone#207 did not cover, per review.

Dropped: the fs-endpoints.ts change. yetone#207 landed it, and its version is
better — the tenant comes from c.companyId (the JWT claim the endpoints
already 403 on when empty) rather than a subquery against participants,
so it is one round trip fewer and cannot write NULL when the participant
row is missing. The test that exercised that endpoint went with it;
yetone#207 asserts the same property in runtime-server.test.ts.

Kept, and the only behaviour change here: agent_events, agent_runs and
agent_triages join the by-owner sweep, taking it from five tables to
eight.

Worth being precise about why, because it is not the same reason as the
five. Those five were leaking: their writers were dropping company_id,
so the tenant sweep could not see the rows. These three have writers
that all pass a tenant today — I checked every createRun, recordEvent
and recordTriage call site — so nothing is leaking through them right
now. They are here so the sweep stops depending on writer discipline at
all, which is the property that failed for the other five. And they are
the tables carrying per-run history and cost, so a row that does slip
through resurfaces on a billing report rather than in a UI.

Six of the seven integration tests pass against main's five-table list;
the seventh is the one that pins the three, and it seeds them the way a
writer that forgot the tenant would. Two are guards in the other
direction: a correctly-tenanted row is still removed, and another
workspace keeps its own run history.
yetone pushed a commit that referenced this pull request Sep 6, 2026
Rebased onto main and reduced to what #207 did not cover, per review.

Dropped: the fs-endpoints.ts change. #207 landed it, and its version is
better — the tenant comes from c.companyId (the JWT claim the endpoints
already 403 on when empty) rather than a subquery against participants,
so it is one round trip fewer and cannot write NULL when the participant
row is missing. The test that exercised that endpoint went with it;
#207 asserts the same property in runtime-server.test.ts.

Kept, and the only behaviour change here: agent_events, agent_runs and
agent_triages join the by-owner sweep, taking it from five tables to
eight.

Worth being precise about why, because it is not the same reason as the
five. Those five were leaking: their writers were dropping company_id,
so the tenant sweep could not see the rows. These three have writers
that all pass a tenant today — I checked every createRun, recordEvent
and recordTriage call site — so nothing is leaking through them right
now. They are here so the sweep stops depending on writer discipline at
all, which is the property that failed for the other five. And they are
the tables carrying per-run history and cost, so a row that does slip
through resurfaces on a billing report rather than in a UI.

Six of the seven integration tests pass against main's five-table list;
the seventh is the one that pins the three, and it seeds them the way a
writer that forgot the tenant would. Two are guards in the other
direction: a correctly-tenanted row is still removed, and another
workspace keeps its own run history.

Co-authored-by: Xialie Zhuang <62231346+Lieisyourlie@users.noreply.github.com>
@yetone yetone mentioned this pull request Sep 6, 2026
@wg2038
wg2038 deleted the fix/runtime-fuse-company-id-isolation branch September 6, 2026 06:52
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.

2 participants