|
1 | | -/** Chat's in-process CLI reading and reverting workspace file version history as the delegating user. */ |
| 1 | +/** Chat's in-process CLI reading workspace files and reverting their version history as the delegating user. */ |
2 | 2 | import { mkdtempSync } from 'node:fs' |
3 | 3 | import { rm } from 'node:fs/promises' |
4 | 4 | import { tmpdir } from 'node:os' |
@@ -35,15 +35,21 @@ import { |
35 | 35 | createKnowledgeAclFixtureIds, |
36 | 36 | seedKnowledgeAclFixture, |
37 | 37 | } from '@/lib/knowledge/__integration__/seed-source-access-fixture' |
38 | | -import { createFileReadTransport } from '@/lib/mothership/agent-cli/file-read-transport' |
| 38 | +import { runCli } from '@/lib/mothership/agent-cli/run-cli' |
39 | 39 | import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' |
| 40 | +import { createAgentCliTransport } from '@/lib/mothership/agent-cli/transport' |
| 41 | +import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation' |
40 | 42 | import { |
41 | 43 | updateWorkspaceFileContent, |
42 | 44 | uploadWorkspaceFile, |
43 | 45 | } from '@/lib/uploads/contexts/workspace/workspace-file-manager' |
44 | 46 | import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' |
| 47 | +import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' |
| 48 | +import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' |
| 49 | +import { WorkspaceFileDeliveryUnobservedError } from '@/lib/workspace-files/application/file-delivery-observer' |
45 | 50 | import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' |
46 | 51 | import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' |
| 52 | +import '@/app/api/v2/files/[fileId]/text/route' |
47 | 53 | import '@/app/api/v2/files/[fileId]/versions/route' |
48 | 54 | import '@/app/api/v2/files/[fileId]/versions/[version]/route' |
49 | 55 | import '@/app/api/v2/files/[fileId]/versions/[version]/content/route' |
@@ -121,36 +127,40 @@ describe('chat-delegated file version history', () => { |
121 | 127 | } |
122 | 128 |
|
123 | 129 | /** |
124 | | - * Chat's composed CLI transport: the provenance-observing read layer over in-process admission. |
| 130 | + * Chat's composed CLI transport, the same stack every Chat CLI invocation and engine runs on. |
125 | 131 | * `layers` drops the outer layers to prove the inner ones hold on their own. |
126 | 132 | */ |
127 | | - function chatTransport( |
| 133 | + function chatFetch( |
128 | 134 | fixture: { workspaceId: string; organizationId: string }, |
129 | 135 | userId: string, |
130 | 136 | registry?: ResolvedSecretTraceRegistry, |
131 | 137 | layers: { observer?: boolean; invocationScope?: boolean } = {} |
132 | | - ) { |
| 138 | + ): typeof fetch { |
133 | 139 | const invocation = { userId, workspaceId: fixture.workspaceId, chatId: generateId() } |
134 | | - const scoped = createScopedCliTransport(ORIGIN, invocation) |
135 | 140 | const transport = |
136 | 141 | layers.observer === false |
137 | | - ? scoped |
138 | | - : createFileReadTransport({ |
| 142 | + ? createScopedCliTransport(ORIGIN, invocation) |
| 143 | + : createAgentCliTransport({ |
139 | 144 | endpoint: ORIGIN, |
140 | | - transport: scoped, |
141 | | - userId, |
142 | 145 | invocation, |
143 | 146 | ...(registry ? { registry } : {}), |
| 147 | + resources: [], |
| 148 | + observeReads: true, |
144 | 149 | }) |
145 | | - return (url: string, init?: RequestInit) => |
| 150 | + return (input, init) => |
146 | 151 | layers.invocationScope === false |
147 | | - ? transport(`${ORIGIN}${url}`, init) |
| 152 | + ? transport(input, init) |
148 | 153 | : withWorkspaceInvocationScope( |
149 | 154 | { workspaceId: fixture.workspaceId, organizationId: fixture.organizationId }, |
150 | | - () => transport(`${ORIGIN}${url}`, init) |
| 155 | + () => transport(input, init) |
151 | 156 | ) |
152 | 157 | } |
153 | 158 |
|
| 159 | + function chatTransport(...args: Parameters<typeof chatFetch>) { |
| 160 | + const transport = chatFetch(...args) |
| 161 | + return (url: string, init?: RequestInit) => transport(`${ORIGIN}${url}`, init) |
| 162 | + } |
| 163 | + |
154 | 164 | function registryFor(fixture: { workspaceId: string }, userId: string) { |
155 | 165 | return new ResolvedSecretTraceRegistry([], { userId, workspaceId: fixture.workspaceId }) |
156 | 166 | } |
@@ -402,4 +412,129 @@ describe('chat-delegated file version history', () => { |
402 | 412 | expect(untracked.status).toBe(503) |
403 | 413 | expect(await untracked.text()).not.toContain(SECRET) |
404 | 414 | }) |
| 415 | + |
| 416 | + /** |
| 417 | + * The current-file text read and download admitted Chat whether or not a delivery observer would |
| 418 | + * record the secret provenance of the bytes; only the transport Chat composes today installed one. |
| 419 | + */ |
| 420 | + describe('current-file reads without a delivery observer', () => { |
| 421 | + async function revertToSecretVersion(fixture: Awaited<ReturnType<typeof seedVersionedFile>>) { |
| 422 | + const reverted = await chatTransport(fixture, fixture.aliceId)( |
| 423 | + `/api/v2/files/${fixture.fileId}/versions/2/revert`, |
| 424 | + { |
| 425 | + method: 'POST', |
| 426 | + headers: { 'content-type': 'application/json' }, |
| 427 | + body: JSON.stringify({ workspaceId: fixture.workspaceId }), |
| 428 | + } |
| 429 | + ) |
| 430 | + expect(reverted.status).toBe(200) |
| 431 | + } |
| 432 | + |
| 433 | + function inWorkspace<T>( |
| 434 | + fixture: { workspaceId: string; organizationId: string }, |
| 435 | + run: () => T |
| 436 | + ) { |
| 437 | + return withWorkspaceInvocationScope( |
| 438 | + { workspaceId: fixture.workspaceId, organizationId: fixture.organizationId }, |
| 439 | + run |
| 440 | + ) |
| 441 | + } |
| 442 | + |
| 443 | + function download( |
| 444 | + fixture: { workspaceId: string; fileId: string }, |
| 445 | + principal: Parameters<typeof downloadWorkspaceFileStream.execute>[0]['principal'] |
| 446 | + ) { |
| 447 | + return downloadWorkspaceFileStream.execute({ |
| 448 | + principal, |
| 449 | + input: { fileId: fixture.fileId, assertedWorkspaceId: fixture.workspaceId }, |
| 450 | + }) |
| 451 | + } |
| 452 | + |
| 453 | + it('refuses a Chat text read before returning the secret', async () => { |
| 454 | + const fixture = await seedVersionedFile() |
| 455 | + await revertToSecretVersion(fixture) |
| 456 | + |
| 457 | + const response = await chatTransport(fixture, fixture.bobId, undefined, { observer: false })( |
| 458 | + `/api/v2/files/${fixture.fileId}/text?workspaceId=${fixture.workspaceId}` |
| 459 | + ) |
| 460 | + |
| 461 | + expect(response.status).toBe(503) |
| 462 | + const body = await response.text() |
| 463 | + expect(body).not.toContain(SECRET) |
| 464 | + expect(JSON.parse(body)).toMatchObject({ error: { code: 'SERVICE_UNAVAILABLE' } }) |
| 465 | + }) |
| 466 | + |
| 467 | + it('refuses a Chat download before streaming any bytes', async () => { |
| 468 | + const fixture = await seedVersionedFile() |
| 469 | + const principal = createCopilotChatPrincipal( |
| 470 | + { userId: fixture.bobId, workspaceId: fixture.workspaceId, chatId: generateId() }, |
| 471 | + WORKSPACE_FILES_DELEGATION_AUDIENCE |
| 472 | + ) |
| 473 | + |
| 474 | + await expect(inWorkspace(fixture, () => download(fixture, principal))).rejects.toThrow( |
| 475 | + WorkspaceFileDeliveryUnobservedError |
| 476 | + ) |
| 477 | + }) |
| 478 | + |
| 479 | + it('still serves a workflow run, which no Chat model reads', async () => { |
| 480 | + const fixture = await seedVersionedFile() |
| 481 | + await revertToSecretVersion(fixture) |
| 482 | + const now = Date.now() |
| 483 | + |
| 484 | + const result = await inWorkspace(fixture, () => |
| 485 | + download(fixture, { |
| 486 | + kind: 'delegated', |
| 487 | + serviceId: 'executor', |
| 488 | + subjectUserId: fixture.bobId, |
| 489 | + workspaceId: fixture.workspaceId, |
| 490 | + delegationId: generateId(), |
| 491 | + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, |
| 492 | + issuedAt: new Date(now), |
| 493 | + expiresAt: new Date(now + 60_000), |
| 494 | + }) |
| 495 | + ) |
| 496 | + |
| 497 | + expect(await new Response(result.stream).text()).toBe(`token=${SECRET}`) |
| 498 | + }) |
| 499 | + |
| 500 | + it('serves a Chat `files read` through the composed CLI', async () => { |
| 501 | + const fixture = await seedVersionedFile() |
| 502 | + await revertToSecretVersion(fixture) |
| 503 | + const registry = registryFor(fixture, fixture.bobId) |
| 504 | + |
| 505 | + const result = await runCli( |
| 506 | + ['files', 'read', fixture.fileId], |
| 507 | + { |
| 508 | + endpoint: ORIGIN, |
| 509 | + apiKey: 'mothership-in-process', |
| 510 | + workspaceId: fixture.workspaceId, |
| 511 | + transport: chatFetch(fixture, fixture.bobId, registry), |
| 512 | + }, |
| 513 | + null |
| 514 | + ) |
| 515 | + |
| 516 | + expect(result.exitCode, result.stderr).toBe(0) |
| 517 | + const projected = projectResolvedSecretModelContent(result.stdout, registry) |
| 518 | + if (!projected.safe) throw new Error('File text was withheld') |
| 519 | + expect(projected.value).not.toContain(SECRET) |
| 520 | + expect(projected.value).toContain('token=[REDACTED_SECRET]') |
| 521 | + }) |
| 522 | + |
| 523 | + it('serves Chat through the transport that records provenance', async () => { |
| 524 | + const fixture = await seedVersionedFile() |
| 525 | + await revertToSecretVersion(fixture) |
| 526 | + const registry = registryFor(fixture, fixture.bobId) |
| 527 | + |
| 528 | + const response = await chatTransport( |
| 529 | + fixture, |
| 530 | + fixture.bobId, |
| 531 | + registry |
| 532 | + )(`/api/v2/files/${fixture.fileId}?workspaceId=${fixture.workspaceId}`) |
| 533 | + |
| 534 | + expect(response.status).toBe(200) |
| 535 | + const projected = projectResolvedSecretModelContent(await response.text(), registry) |
| 536 | + if (!projected.safe) throw new Error('Downloaded file was withheld') |
| 537 | + expect(projected.value).toBe('token=[REDACTED_SECRET]') |
| 538 | + }) |
| 539 | + }) |
405 | 540 | }) |
0 commit comments