Skip to content

fix(artifacts): isolate in-memory composite keys - #576

Open
fallintoplace wants to merge 3 commits into
google:mainfrom
fallintoplace:fix/in-memory-artifact-key-collision
Open

fix(artifacts): isolate in-memory composite keys#576
fallintoplace wants to merge 3 commits into
google:mainfrom
fallintoplace:fix/in-memory-artifact-key-collision

Conversation

@fallintoplace

Copy link
Copy Markdown
Contributor

Summary

Encode in-memory artifact keys as scoped tuples instead of joining components with slashes.

The previous key format could map distinct artifacts to the same array when a nested filename overlapped with a separator in another key component. That combined version histories and allowed loads or deletes through one logical key to affect another.

The explicit scope marker also keeps user-scoped artifacts separate while preserving existing listing behavior.

Tests

  • Added regression coverage for a nested filename and overlapping session ID
  • npx vitest run --project unit:core
  • Prettier and ESLint checks for changed files
  • Workspace build

Fixes #574

@Varun-S10

Copy link
Copy Markdown
Contributor

Hi @kalenkevich, I have checked the issue and validated the proposed changes. I was able to reproduce the issue. Could you please review this PR?

@Varun-S10 Varun-S10 added the needs review [Status] The PR/issue is awaiting review from the maintainer label Jul 30, 2026

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key encoding is correct as far as I can tell: JSON.stringify over a tagged tuple is injective for these components (a sessionId containing "," is escaped to \",\" inside the quotes, so it can't forge a field boundary), the 'session'/'user' discriminant closes the sessionId === 'user' ambiguity the flat key had, and listArtifactKeys still emits user-scoped names with their user: prefix, so artifact_service_test_utils.ts:225 keeps passing. I grepped for every place the old flat key was built or split: all six reads go through artifactPath, the store is private, and nothing outside this file (dev API server, runner tests) depends on the key format, so there is no migration surface for an in-memory store. I also type-checked the tuple narrowing (key[0] === 'session' -> key[4]) against TS 5.9 strict in a scratch file — it compiles. Three comments below: one uncovered half of the fix, one readability nit, and a heads-up that GcsArtifactService still has this bug.

}

return `${appName}/${userId}/${sessionId}/${filename}`;
return JSON.stringify(['session', appName, userId, sessionId, filename]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a nit, but out of scope for this PR — worth a follow-up issue rather than a change here.

The encoding on this line is right, but GcsArtifactService still builds its object names the way this file used to, so the exact repro from #574 conflates artifacts there too:

// core/src/artifacts/gcs_artifact_service.ts:283-285
const prefix = isUser
  ? `${appName}/${userId}/user/${cleanFilename}`
  : `${appName}/${userId}/${sessionId}/${cleanFilename}`;

{sessionId: 'session', filename: 'nested/report.txt'} and {sessionId: 'session/nested', filename: 'report.txt'} both resolve to app/user/session/nested/report.txt/<version>.

It is actually worse there than it was here. GCS strips the user: prefix before writing (cleanFilename, line 281), so a session literally named user collides with the user namespace outright: {sessionId: 'user', filename: 'foo.txt'} and {filename: 'user:foo.txt'} land on the same blob. The in-memory service never had that particular collision, because it kept user: inside the stored filename.

For contrast, FileArtifactService is the sibling that already gets this right — assertSafeSegment (file_artifact_service.ts:411) rejects separators in userId/sessionId, and user- vs session-scoped artifacts live under structurally distinct roots.

I am not asking you to fix GCS in this PR; flagging it so #574 doesn't get closed as fully resolved when only one of the two implementations is fixed.

Comment on lines +223 to +225
type ArtifactStorageKey =
| ['session', string, string, string, string]
| ['user', string, string, string];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, optional.

The declaration is positional, so the reads in listArtifactKeys (key[3] for user scope, key[4] for session scope) are hard to check without scrolling back here.

type ArtifactStorageKey =
  | ['session', string, string, string, string]
  | ['user', string, string, string];

Labelled tuple members are erased at runtime and make the indices self-explanatory:

type ArtifactStorageKey =
  | [
      'session',
      appName: string,
      userId: string,
      sessionId: string,
      filename: string,
    ]
  | ['user', appName: string, userId: string, filename: string];

Genuine trade-off: prettier will wrap the session arm as shown, so 3 lines become 9. Take it or leave it — I checked the labels don't affect the key[0] === 'session' narrowing.

Separately, while you're in this area: the JSDoc above artifactPath (lines 201-209) still reads "Constructs the path to the artifact" / "@return The path to the artifact", and every caller still names the result path. The point of this change is that the value is no longer a path, and leaving the wording invites someone to "simplify" it back to slashes later. Renaming the function would touch six call sites, so a one-line doc tweak is probably the right size of fix.

filename: 'nested/report.txt',
});

expect(artifact?.text).toBe('artifact-a');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a nit.

This pins the separator collision from #574, but not the other half of the change. The scope marker also fixes a cross-session listing leak, and nothing exercises it.

Under the old key scheme:

const usernamespacePrefix = `${appName}/${userId}/user/`;
...
} else if (path.startsWith(usernamespacePrefix)) {

an artifact saved into a session literally named user was stored at app/<user>/user/foo.txt, which matches usernamespacePrefix — so listArtifactKeys for any other session of the same user returned foo.txt. The 'session' / 'user' discriminant is what stops that, and it is the part the PR description calls out ("the explicit scope marker also keeps user-scoped artifacts separate"), but a regression would go unnoticed:

it('does not leak a session named "user" into other sessions', async () => {
  const service = new InMemoryArtifactService();

  await service.saveArtifact({
    appName: 'app',
    userId: 'user',
    sessionId: 'user',
    filename: 'foo.txt',
    artifact: {text: 'session-scoped'},
  });

  const keys = await service.listArtifactKeys({
    appName: 'app',
    userId: 'user',
    sessionId: 'other',
  });

  expect(keys).toEqual([]);
});

I traced this by hand rather than running it: on the pre-change code the save lands at app/user/user/foo.txt and the usernamespacePrefix branch pushes foo.txt, so it fails; on your code key[0] === 'session' with key[3] === 'user' !== 'other' skips it, so it passes.

Also worth one more line in the test above — loading {sessionId: 'session/nested', filename: 'report.txt'} and asserting artifact-b — so the assertion pins both sides of the split instead of only proving the first key won.

userId,
sessionId,
}: ListArtifactKeysRequest): Promise<string[]> {
const sessionPrefix = `${appName}/${userId}/${sessionId}/`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just encode/decode every part of the path (appName, userId, sessionId, filename) to not to have the delimiter (/)?

No need to create an array as a key. I think this is antipatern and better to keep keys as numbers or strings but not as objects.

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked at d0ff1087. The tuple key is gone in favour of Alexey's per-segment encodeURIComponent, which I think is the better answer — the key stays a string and the ambiguity is removed at the source rather than encoded in a shape.

I re-derived the collisions by hand against the new scheme rather than trusting the tests: {sessionId: 'session', filename: 'nested/report.txt'} -> session/app/user/session/nested%2Freport.txt vs {sessionId: 'session/nested', filename: 'report.txt'} -> session/app/user/session%2Fnested/report.txt, distinct. / now only ever appears as a separator, so the startsWith(prefix) scans in listArtifactKeys can't over-match a sibling segment (.../s/ no longer prefixes .../s2/...), and the session/user discriminant keeps a session literally named user out of the user namespace — which is what the test I asked for pins. Both sides of the split are asserted now, not just the winner.

The doc wording is fixed ("storage key", not "path"). My GCS note stands as a follow-up, not a blocker for this PR — worth keeping #574 open until gcs_artifact_service.ts:283 gets the same treatment, since that one is strictly worse (it strips user: before writing, so a session named user collides outright).

CI green on all three platforms after a re-run. LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs review [Status] The PR/issue is awaiting review from the maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

InMemoryArtifactService can conflate artifacts from different sessions

4 participants