Skip to content

Commit e7fdc69

Browse files
committed
fix(mothership): let the agent copy files between workspaces without Projects
A copy between workspaces needs only the turn's resource admission, but the copy authorizer required the Projects rollout for every copy and the agent was offered copies only through Project file-owner negotiation, so with Projects off every agent copy failed. The authorizer now requires Projects only for a copy that names a Project owner. Admitted chat turns declare the worker's workspace-file-copy capability whether or not Projects are on, the recovery marker restoring admission is written for every admitted turn, and the sandbox lease records and exports the same so the workbench terminal binds workspace copies under the paired grant.
1 parent 3544f9f commit e7fdc69

16 files changed

Lines changed: 212 additions & 58 deletions

‎apps/sim/lib/mothership/application/file-owner-context.ts‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,21 @@ import {
1010
} from '@/lib/mothership/auth/application-delegation'
1111
import { getCopilotFileOwnerAdapter } from '@/lib/mothership/file-owners'
1212
import type { FileOwnerContext } from '@/lib/mothership/generated/file-owner'
13+
import { WORKSPACE_FILE_COPY_CAPABILITY } from '@/lib/mothership/generated/protocol'
1314
import { supportsFileOwnerProtocol } from '@/lib/mothership/request/lifecycle/file-owner-protocol'
1415
import { getCurrentWorkspaceProject } from '@/lib/projects/application'
1516
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
1617

1718
const logger = createLogger('CopilotFileOwnerContext')
1819

1920
/**
20-
* Adds current owner hints to admitted authoring turns using the worker's generated contract.
21-
* Hints are best-effort: a failed Project lookup sends the turn with no Project rather than failing
22-
* it. Support is probed on the selected worker URL, which may front several worker releases, and a
23-
* worker that predates the hint keys rejects them; rolling the worker back therefore requires
24-
* turning the `projects` flag off first.
21+
* Adds what admitted authoring turns may do with files, using the worker's generated contract.
22+
* Every such turn declares `workspace-file-copy`: copies between workspaces need the turn's
23+
* admission but not Projects, and a worker that predates the token ignores it. With Projects on,
24+
* it also gets current owner hints. Hints are best-effort: a failed Project lookup sends the turn
25+
* with no Project rather than failing it. Support is probed on the selected worker URL, which may
26+
* front several worker releases, and a worker that predates the hint keys rejects them; rolling
27+
* the worker back therefore requires turning the `projects` flag off first.
2528
*/
2629
export async function withFileOwnerContext(
2730
payload: Record<string, unknown>,
@@ -30,14 +33,17 @@ export async function withFileOwnerContext(
3033
route: string
3134
): Promise<Record<string, unknown>> {
3235
const clean = omit(payload, ['project', 'fileOwnerCapabilities', 'fileOwnerProtocolVersion'])
33-
if (
34-
!['/api/copilot', '/api/mothership'].includes(route) ||
35-
context.requestMode === 'assistant' ||
36-
!hasCopilotResourceAdmission(context) ||
37-
!(await isProjectsEnabled()) ||
38-
!(await supportsFileOwnerProtocol(baseURL))
36+
if (!['/api/copilot', '/api/mothership'].includes(route)) return clean
37+
const admitted = context.requestMode !== 'assistant' && hasCopilotResourceAdmission(context)
38+
const declared = (Array.isArray(clean.clientCapabilities) ? clean.clientCapabilities : []).filter(
39+
(capability) => capability !== WORKSPACE_FILE_COPY_CAPABILITY
3940
)
40-
return clean
41+
const turn = {
42+
...clean,
43+
clientCapabilities: admitted ? [...declared, WORKSPACE_FILE_COPY_CAPABILITY] : declared,
44+
}
45+
if (!admitted || !(await isProjectsEnabled()) || !(await supportsFileOwnerProtocol(baseURL)))
46+
return turn
4147

4248
const hints: FileOwnerContext = {
4349
fileOwnerProtocolVersion: 1,
@@ -70,5 +76,5 @@ export async function withFileOwnerContext(
7076
})
7177
}
7278
}
73-
return { ...clean, ...hints }
79+
return { ...turn, ...hints }
7480
}

‎apps/sim/lib/mothership/chat/post.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1408,7 +1408,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
14081408
if (!lease || !sendClaim?.claimToken || sendClaim.storageMethod !== 'database') {
14091409
throw new Error('Chat admission requires its controller and durable send claim')
14101410
}
1411-
const resourceAuthoringVersion = await durableResourceAuthoringVersion(
1411+
const resourceAuthoringVersion = durableResourceAuthoringVersion(
14121412
executionContext.copilotResourceAdmission !== undefined
14131413
)
14141414
admittedRun = await admitChatTurn.execute({

‎apps/sim/lib/mothership/generated/protocol.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,12 @@ export const TOOL_DEADLINE_SETTLE_MARGIN_MS = 90_000;
303303
/** The `clientCapabilities` token a Sim sends when it honors `StreamToolDeadline`. */
304304
export const TOOL_DEADLINE_CAPABILITY = "tool-deadline";
305305

306+
/**
307+
* The `clientCapabilities` token a Sim sends when this turn may copy files between workspaces
308+
* (`files copy`) without negotiated file owners, so also where Projects are off.
309+
*/
310+
export const WORKSPACE_FILE_COPY_CAPABILITY = "workspace-file-copy";
311+
306312
/** POST /api/mothership — the chat request sim sends. */
307313
export interface ChatRequest extends StreamResponseReceipt {
308314
fileOwnerProtocolVersion?: 1 | undefined;
@@ -348,7 +354,8 @@ export interface ChatRequest extends StreamResponseReceipt {
348354
* grace windows and run tools server-side immediately. ABSENT = older/unknown caller —
349355
* dispatch keeps its conservative grace (deploy-skew safe: a stale tab that predates
350356
* this field still gets waited on). Known capability: "workflow-tool-pickup". Sim also
351-
* declares its own {@link TOOL_DEADLINE_CAPABILITY} here.
357+
* declares its own {@link TOOL_DEADLINE_CAPABILITY} and {@link WORKSPACE_FILE_COPY_CAPABILITY}
358+
* here.
352359
*/
353360
clientCapabilities?: string[] | undefined;
354361
/**

‎apps/sim/lib/mothership/request/lifecycle/recovery-config.test.ts‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,22 @@ describe('recovery marker written by an admitted turn', () => {
104104
}),
105105
}
106106

107-
it('stays off a recovery config older builds must still parse while Projects are off', async () => {
108-
featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false)
109-
const recovery = await streamRecoveryConfig(options, workspaceRequest)
110-
expect(recovery).not.toHaveProperty('resourceAuthoringVersion')
111-
})
107+
it.each([true, false])(
108+
'restores authoring admission on recovery whether Projects are on (%s) or off',
109+
async (projects) => {
110+
featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(
111+
async (flag) => projects && flag === 'projects'
112+
)
113+
const recovery = await streamRecoveryConfig(options, workspaceRequest)
114+
expect(recovery?.resourceAuthoringVersion).toBe(1)
115+
}
116+
)
112117

113-
it('restores authoring admission on recovery while Projects are on', async () => {
114-
featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects')
115-
const recovery = await streamRecoveryConfig(options, workspaceRequest)
116-
expect(recovery?.resourceAuthoringVersion).toBe(1)
118+
it('stays off a turn that was never admitted', async () => {
119+
const recovery = await streamRecoveryConfig(
120+
{ ...options, copilotResourceAdmission: undefined },
121+
workspaceRequest
122+
)
123+
expect(recovery).not.toHaveProperty('resourceAuthoringVersion')
117124
})
118125
})

‎apps/sim/lib/mothership/request/lifecycle/recovery-config.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { hasCopilotResourceAdmission } from '@/lib/mothership/auth/application-d
33
import { COPILOT_REQUEST_MODES } from '@/lib/mothership/constants'
44
import { ChatPayloadSchema } from '@/lib/mothership/generated/protocol'
55
import type { CopilotLifecycleOptions } from '@/lib/mothership/request/lifecycle/run'
6-
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
76

87
export const BillingAdmissionSchema = z
98
.object({
@@ -28,14 +27,14 @@ export const DurableChatRequestSchema = ChatPayloadSchema.safeExtend({
2827
export const RESOURCE_AUTHORING_VERSION = 1
2928

3029
/**
31-
* The marker an admitted turn stores for recovery. Builds before it parse recovery strictly and
32-
* would refuse to recover the run, and only Project authoring reads it, so it is written only
33-
* while Projects are on.
30+
* The marker an admitted turn stores for recovery, so a recovered turn keeps its admission. It is
31+
* written whether or not Projects are on, since copies between workspaces need the admission
32+
* without them; every release since the marker shipped parses it.
3433
*/
35-
export async function durableResourceAuthoringVersion(
34+
export function durableResourceAuthoringVersion(
3635
admitted: boolean
37-
): Promise<typeof RESOURCE_AUTHORING_VERSION | undefined> {
38-
return admitted && (await isProjectsEnabled()) ? RESOURCE_AUTHORING_VERSION : undefined
36+
): typeof RESOURCE_AUTHORING_VERSION | undefined {
37+
return admitted ? RESOURCE_AUTHORING_VERSION : undefined
3938
}
4039

4140
export const StreamRecoveryConfigSchema = z
@@ -66,7 +65,7 @@ export async function streamRecoveryConfig(
6665
request: Record<string, unknown>
6766
) {
6867
if (options.interactive !== true) return undefined
69-
const resourceAuthoringVersion = await durableResourceAuthoringVersion(
68+
const resourceAuthoringVersion = durableResourceAuthoringVersion(
7069
hasCopilotResourceAdmission({
7170
...options,
7271
...options.executionContext,

‎apps/sim/lib/mothership/tools/sandbox-file-copy.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
recordSandboxResourceEffects,
1414
type SandboxResourceScope,
1515
} from '@/lib/mothership/tools/sandbox-resources'
16+
import { copiesWithinWorkspaces } from '@/lib/workspace-files/application/copy-operation'
1617

1718
const logger = createLogger('SandboxFileCopy')
1819

@@ -22,15 +23,17 @@ export async function proxySandboxFileCopyRequest(
2223
token: string,
2324
scope: SandboxResourceScope
2425
): Promise<Response> {
26+
const unavailable = () =>
27+
Response.json(
28+
{ error: 'Paired file owner routing is unavailable or contradictory' },
29+
{ status: 403 }
30+
)
2531
if (
26-
scope.fileOwnerProtocolVersion !== 1 ||
32+
(scope.fileOwnerProtocolVersion !== 1 && !scope.workspaceFileCopy) ||
2733
request.headers.has('x-mothership-file-owner') ||
2834
request.headers.has('x-mothership-workspace-id')
2935
)
30-
return Response.json(
31-
{ error: 'Paired file owner routing is unavailable or contradictory' },
32-
{ status: 403 }
33-
)
36+
return unavailable()
3437
if (request.method !== 'POST')
3538
return Response.json({ error: 'Method not allowed' }, { status: 405 })
3639
const parsed = await parseRequest(
@@ -43,6 +46,9 @@ export async function proxySandboxFileCopyRequest(
4346
}
4447
)
4548
if (!parsed.success) return parsed.response
49+
// Without the negotiated protocol only workspace owners exist for this lease.
50+
if (scope.fileOwnerProtocolVersion !== 1 && !copiesWithinWorkspaces(parsed.data.body))
51+
return unavailable()
4652
const endpoint = getInternalApiBaseUrl()
4753
const transport = createFileCopyCliTransport(
4854
endpoint,

‎apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts‎

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { assertWorkspaceInvocationScope } from '@/lib/core/application/workspace
1616
import { OrchestrationError } from '@/lib/core/orchestration/types'
1717
import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
1818
import { projectFileOperations } from '@/lib/projects/files/application/operations'
19+
import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation'
1920
import { fileOperations } from '@/lib/workspace-files/application/operations'
2021

2122
const { readScope, recordEffects, fetcher, routeMatcher, recordInput } = vi.hoisted(() => ({
@@ -436,26 +437,72 @@ it.each([
436437
}
437438
)
438439

439-
it('refuses a Project copy destination on a lease without the file-owner protocol', async () => {
440-
readScope.mockResolvedValue({ ...scope, fileOwnerProtocolVersion: undefined })
441-
routeMatcher.mockReturnValue({ params: {}, load: async () => ({ POST: fetcher }) })
440+
it('binds a workspace pair under the paired copy grant on an admitted lease without the protocol', async () => {
441+
readScope.mockResolvedValue({
442+
...scope,
443+
fileOwnerProtocolVersion: undefined,
444+
workspaceFileCopy: true,
445+
})
446+
routeMatcher.mockReturnValue({
447+
pattern: '/api/v2/files/copy',
448+
params: {},
449+
load: async () => ({ POST: fetcher }),
450+
})
451+
fetcher.mockImplementation(async (forwarded: Request) => {
452+
expect(
453+
copilotRequestPrincipal(forwarded, fileCopyOperation, {
454+
operation: fileCopyOperation,
455+
delegationAudience: fileCopyOperation.delegationAudience,
456+
})
457+
).toMatchObject({
458+
kind: 'resource_delegated',
459+
subjectUserId: scope.userId,
460+
invocation: { kind: 'chat', chatId: scope.chatId },
461+
scope: { kind: 'file_copy', destination: { owner: workspaceOwner } },
462+
})
463+
return Response.json({ data: { copied: [] } })
464+
})
442465
const response = await proxySandboxResourceRequest(
443466
request('/api/v2/files/copy', {
444467
method: 'POST',
445468
body: JSON.stringify({
446469
source: { owner: workspaceOwner, fileIds: ['file'] },
447-
destination: { owner: { entityType: 'project', entityId: 'project' } },
470+
destination: { owner: workspaceOwner },
448471
}),
449472
}),
450473
token
451474
)
452-
expect(response.status).toBe(403)
453-
expect(await response.json()).toEqual({
454-
error: 'Paired file owner routing is unavailable or contradictory',
455-
})
456-
expect(fetcher).not.toHaveBeenCalled()
475+
expect(response.status).toBe(200)
476+
expect(fetcher).toHaveBeenCalledTimes(1)
457477
})
458478

479+
it.each([undefined, true as const])(
480+
'refuses a Project copy destination on a lease without the file-owner protocol (workspace copies %s)',
481+
async (workspaceFileCopy) => {
482+
readScope.mockResolvedValue({
483+
...scope,
484+
fileOwnerProtocolVersion: undefined,
485+
workspaceFileCopy,
486+
})
487+
routeMatcher.mockReturnValue({ params: {}, load: async () => ({ POST: fetcher }) })
488+
const response = await proxySandboxResourceRequest(
489+
request('/api/v2/files/copy', {
490+
method: 'POST',
491+
body: JSON.stringify({
492+
source: { owner: workspaceOwner, fileIds: ['file'] },
493+
destination: { owner: { entityType: 'project', entityId: 'project' } },
494+
}),
495+
}),
496+
token
497+
)
498+
expect(response.status).toBe(403)
499+
expect(await response.json()).toEqual({
500+
error: 'Paired file owner routing is unavailable or contradictory',
501+
})
502+
expect(fetcher).not.toHaveBeenCalled()
503+
}
504+
)
505+
459506
it('validates a copy body itself once the lease negotiated the file-owner protocol', async () => {
460507
readScope.mockResolvedValue({ ...scope, fileOwnerProtocolVersion: 1 })
461508
routeMatcher.mockReturnValue({ params: {}, load: async () => ({ POST: fetcher }) })

‎apps/sim/lib/mothership/tools/sandbox-resource-transport.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
type SandboxResourceScope,
2929
} from '@/lib/mothership/tools/sandbox-resources'
3030
import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
31+
import { copiesWithinWorkspaces } from '@/lib/workspace-files/application/copy-operation'
3132

3233
const logger = createLogger('MothershipSandboxResourceTransport')
3334

@@ -54,15 +55,20 @@ export async function proxySandboxResourceRequest(
5455
if (!scope)
5556
return Response.json({ error: 'Sandbox tool execution is no longer active' }, { status: 403 })
5657
/**
57-
* Paired owners need the negotiated protocol. Without it, only a copy that names a non-workspace
58-
* owner is refused there; workspace copies and invalid bodies keep the workspace route and its
58+
* Copies bind both owners under the paired grant. Project owners need the negotiated protocol,
59+
* and an admitted lease copies between workspaces without it; a copy naming a Project owner is
60+
* refused there. Otherwise workspace copies and invalid bodies keep the workspace route and its
5961
* v2 validation response.
6062
*/
6163
if (path === v2CopyFileItemsContract.path) {
6264
const buffered = await bufferCopyRequest(request)
6365
if (buffered instanceof Response) return buffered
6466
request = buffered
65-
if (scope.fileOwnerProtocolVersion === 1 || (await copiesBeyondWorkspaces(request)))
67+
if (
68+
scope.fileOwnerProtocolVersion === 1 ||
69+
scope.workspaceFileCopy ||
70+
(await copiesBeyondWorkspaces(request))
71+
)
6672
return proxySandboxFileCopyRequest(request, token, scope)
6773
}
6874
const ownerHeader = request.headers.get('x-mothership-file-owner')
@@ -245,9 +251,5 @@ async function copiesBeyondWorkspaces(request: Request): Promise<boolean> {
245251
{},
246252
V2_PARSE_DEFAULTS
247253
)
248-
return (
249-
parsed.success &&
250-
(parsed.data.body.source.owner.entityType !== 'workspace' ||
251-
parsed.data.body.destination.owner.entityType !== 'workspace')
252-
)
254+
return parsed.success && !copiesWithinWorkspaces(parsed.data.body)
253255
}

‎apps/sim/lib/mothership/tools/sandbox-resources.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ it.each([
308308
[true, false, true, undefined],
309309
[true, true, false, undefined],
310310
] as const)(
311-
'negotiates the callback protocol only with admission=%s projects=%s peer=%s',
311+
'negotiates the callback protocol only with admission=%s projects=%s peer=%s; copies follow admission alone',
312312
async (admitted, enabled, supported, expected) => {
313313
featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(enabled)
314314
vi.stubGlobal(
@@ -334,6 +334,7 @@ it.each([
334334
if (!token) throw new Error('Callback token missing')
335335
const scope = await readSandboxResourceScope(token, 'secret')
336336
expect(scope?.fileOwnerProtocolVersion).toBe(expected)
337+
expect(scope?.workspaceFileCopy).toBe(admitted ? true : undefined)
337338
}
338339
)
339340
}

‎apps/sim/lib/mothership/tools/sandbox-resources.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const scopeSchema = z
2828
organizationId: z.string().optional(),
2929
apiKeyHash: z.string(),
3030
fileOwnerProtocolVersion: z.literal(1).optional(),
31+
workspaceFileCopy: z.literal(true).optional(),
3132
})
3233
.refine(
3334
(scope) => Boolean(scope.workspaceId) !== Boolean(scope.organizationId),
@@ -46,6 +47,7 @@ interface ActiveSandboxResourceScope {
4647
signal: AbortSignal
4748
token?: string
4849
fileOwnerProtocolVersion?: 1
50+
workspaceFileCopy?: true
4951
}
5052

5153
const resourceScope = new AsyncLocalStorage<ActiveSandboxResourceScope>()
@@ -60,6 +62,11 @@ export function activeSandboxFileOwnerProtocol() {
6062
return resourceScope.getStore()?.fileOwnerProtocolVersion
6163
}
6264

65+
/** An admitted tool's lease copies between workspaces whether or not Projects are on. */
66+
export function activeSandboxWorkspaceFileCopy() {
67+
return resourceScope.getStore()?.workspaceFileCopy
68+
}
69+
6370
const contextKey = (token: string) => `mothership:sandbox-resources:${token}:context`
6471
const inboxKey = (token: string) => `mothership:sandbox-resources:${token}:inbox`
6572
const seenKey = (token: string) => `mothership:sandbox-resources:${token}:seen`
@@ -96,6 +103,7 @@ export async function sandboxResourceEndpoint(
96103
(await supportsFileOwnerProtocol(identity.mothershipBaseURL))
97104
? 1
98105
: undefined
106+
const workspaceFileCopy = identity.resourceAdmitted ? (true as const) : undefined
99107
const scope: SandboxResourceScope = {
100108
toolCallId: identity.toolCallId,
101109
runId: identity.runId,
@@ -106,8 +114,10 @@ export async function sandboxResourceEndpoint(
106114
organizationId: identity.organizationId,
107115
apiKeyHash: sha256Hex(apiKey),
108116
fileOwnerProtocolVersion,
117+
workspaceFileCopy,
109118
}
110119
active.fileOwnerProtocolVersion = fileOwnerProtocolVersion
120+
active.workspaceFileCopy = workspaceFileCopy
111121
await redisClient().set(contextKey(token), JSON.stringify(scope), 'EX', CONTEXT_TTL_SECONDS)
112122
active.token = token
113123
if (active.signal.aborted) {

0 commit comments

Comments
 (0)