From 738f014ebfcf3676acf9af12506a4a8afe516e0d Mon Sep 17 00:00:00 2001 From: credence-the-bot Date: Sun, 3 May 2026 18:23:20 +0000 Subject: [PATCH 1/2] feat(routes): enforce x-vellum-client-id ownership on host-transfer routes; add target_client_id to tool --- assistant/openapi.yaml | 12 + .../host-transfer-routes-targeted.test.ts | 447 ++++++++++++++++++ .../runtime/routes/host-transfer-routes.ts | 56 ++- .../src/tools/host-filesystem/transfer.ts | 25 +- 4 files changed, 537 insertions(+), 3 deletions(-) create mode 100644 assistant/src/__tests__/host-transfer-routes-targeted.test.ts diff --git a/assistant/openapi.yaml b/assistant/openapi.yaml index 89b71d6fe0d..bd3985cd492 100644 --- a/assistant/openapi.yaml +++ b/assistant/openapi.yaml @@ -6325,6 +6325,10 @@ paths: required: - accepted additionalProperties: false + "400": + description: x-vellum-client-id header is missing for a targeted host transfer request. + "403": + description: Submitting client does not match the targeted client for this transfer. requestBody: required: true content: @@ -11280,6 +11284,10 @@ paths: responses: "200": description: Successful response + "400": + description: x-vellum-client-id header is missing for a targeted transfer. + "403": + description: Submitting client does not match the targeted client for this transfer. parameters: - name: transferId in: path @@ -11295,6 +11303,10 @@ paths: responses: "200": description: Successful response + "400": + description: x-vellum-client-id header is missing for a targeted transfer. + "403": + description: Submitting client does not match the targeted client for this transfer. parameters: - name: transferId in: path diff --git a/assistant/src/__tests__/host-transfer-routes-targeted.test.ts b/assistant/src/__tests__/host-transfer-routes-targeted.test.ts new file mode 100644 index 00000000000..8d0177404ab --- /dev/null +++ b/assistant/src/__tests__/host-transfer-routes-targeted.test.ts @@ -0,0 +1,447 @@ +/** + * Tests for the host-transfer route 403 guard introduced in Phase 3. + * + * Covers GET /transfers/:transferId/content, PUT /transfers/:transferId/content, + * and POST /host-transfer-result ownership checks. + * + * 1. Targeted + correct x-vellum-client-id header → success + * 2. Targeted + missing header → 400 BadRequestError + * 3. Targeted + wrong header → 403 ForbiddenError, operation NOT performed + * 4. Untargeted (no targetClientId, no header) → success (regression) + */ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; + +// ── Module mocks ──────────────────────────────────────────────────────────── + +mock.module("../config/env.js", () => ({ + isHttpAuthDisabled: () => true, + hasUngatedHttpAuthDisabled: () => false, +})); + +import type { PendingInteraction } from "../runtime/pending-interactions.js"; + +const pendingStore = new Map(); + +mock.module("../runtime/pending-interactions.js", () => ({ + get: (requestId: string) => pendingStore.get(requestId), + resolve: (requestId: string) => { + const entry = pendingStore.get(requestId); + if (entry) pendingStore.delete(requestId); + return entry; + }, +})); + +// Per-test controls for the proxy stub +let stubTargetClientId: string | null = null; +const getTransferContentCalls: string[] = []; +const receiveTransferContentCalls: string[] = []; +const resolveTransferResultCalls: string[] = []; + +mock.module("../daemon/host-transfer-proxy.js", () => ({ + HostTransferProxy: { + get instance() { + return { + getRequestIdForTransfer(_transferId: string) { + return "req-1"; + }, + getTargetClientIdForTransfer(_transferId: string) { + return stubTargetClientId; + }, + getTransferContent(transferId: string) { + getTransferContentCalls.push(transferId); + return { buffer: Buffer.from("data"), sizeBytes: 4, sha256: "abc123" }; + }, + async receiveTransferContent(transferId: string, _data: Buffer, _sha256: string) { + receiveTransferContentCalls.push(transferId); + return { accepted: true }; + }, + resolveTransferResult(requestId: string, _result: unknown) { + resolveTransferResultCalls.push(requestId); + }, + }; + }, + }, +})); + +// ── Real imports (after mocks) ────────────────────────────────────────────── + +import { + BadRequestError, + ForbiddenError, +} from "../runtime/routes/errors.js"; +import { ROUTES } from "../runtime/routes/host-transfer-routes.js"; + +afterAll(() => { + mock.restore(); +}); + +const handleTransferContentGet = ROUTES.find( + (r) => r.endpoint === "transfers/:transferId/content" && r.method === "GET", +)!.handler; + +const handleTransferContentPut = ROUTES.find( + (r) => r.endpoint === "transfers/:transferId/content" && r.method === "PUT", +)!.handler; + +const handleTransferResult = ROUTES.find( + (r) => r.endpoint === "host-transfer-result", +)!.handler; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +const TEST_TRANSFER_ID = "transfer-abc"; +const TEST_REQUEST_ID = "req-1"; + +function registerPending(overrides: Partial = {}): void { + pendingStore.set(TEST_REQUEST_ID, { + conversationId: "conv-1", + kind: "host_transfer", + ...overrides, + }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("handleTransferContentGet — Phase 3 targetClientId guard", () => { + beforeEach(() => { + pendingStore.clear(); + stubTargetClientId = null; + getTransferContentCalls.length = 0; + }); + + // ── 1. Targeted + correct header → success ──────────────────────────────── + + describe("targeted + correct x-vellum-client-id header", () => { + test("returns Uint8Array and calls getTransferContent", async () => { + stubTargetClientId = "client-A"; + const result = await handleTransferContentGet({ + pathParams: { transferId: TEST_TRANSFER_ID }, + headers: { "x-vellum-client-id": "client-A" }, + }); + + expect(result).toBeInstanceOf(Uint8Array); + expect(getTransferContentCalls).toContain(TEST_TRANSFER_ID); + }); + + test("trims whitespace from header before comparing", async () => { + stubTargetClientId = "client-A"; + const result = await handleTransferContentGet({ + pathParams: { transferId: TEST_TRANSFER_ID }, + headers: { "x-vellum-client-id": " client-A " }, + }); + + expect(result).toBeInstanceOf(Uint8Array); + }); + }); + + // ── 2. Targeted + missing header → 400 ─────────────────────────────────── + + describe("targeted + missing x-vellum-client-id header", () => { + test("throws BadRequestError when header is absent", () => { + stubTargetClientId = "client-A"; + expect(() => + handleTransferContentGet({ + pathParams: { transferId: TEST_TRANSFER_ID }, + }), + ).toThrow(BadRequestError); + }); + + test("getTransferContent NOT called on 400", () => { + stubTargetClientId = "client-A"; + try { + handleTransferContentGet({ + pathParams: { transferId: TEST_TRANSFER_ID }, + }); + } catch { + // expected + } + expect(getTransferContentCalls).toHaveLength(0); + }); + }); + + // ── 3. Targeted + wrong header → 403 ───────────────────────────────────── + + describe("targeted + wrong x-vellum-client-id header", () => { + test("throws ForbiddenError when client ID does not match", () => { + stubTargetClientId = "client-A"; + expect(() => + handleTransferContentGet({ + pathParams: { transferId: TEST_TRANSFER_ID }, + headers: { "x-vellum-client-id": "client-B" }, + }), + ).toThrow(ForbiddenError); + }); + + test("getTransferContent NOT called on 403", () => { + stubTargetClientId = "client-A"; + try { + handleTransferContentGet({ + pathParams: { transferId: TEST_TRANSFER_ID }, + headers: { "x-vellum-client-id": "client-B" }, + }); + } catch { + // expected + } + expect(getTransferContentCalls).toHaveLength(0); + }); + }); + + // ── 4. Untargeted — regression ──────────────────────────────────────────── + + describe("untargeted request (no targetClientId)", () => { + test("returns Uint8Array without a header", async () => { + stubTargetClientId = null; + const result = await handleTransferContentGet({ + pathParams: { transferId: TEST_TRANSFER_ID }, + }); + + expect(result).toBeInstanceOf(Uint8Array); + expect(getTransferContentCalls).toContain(TEST_TRANSFER_ID); + }); + }); +}); + +describe("handleTransferContentPut — Phase 3 targetClientId guard", () => { + beforeEach(() => { + pendingStore.clear(); + stubTargetClientId = null; + receiveTransferContentCalls.length = 0; + }); + + // ── 1. Targeted + correct header → success ──────────────────────────────── + + describe("targeted + correct x-vellum-client-id header", () => { + test("returns { accepted: true } and calls receiveTransferContent", async () => { + stubTargetClientId = "client-A"; + const result = await handleTransferContentPut({ + pathParams: { transferId: TEST_TRANSFER_ID }, + headers: { "x-vellum-client-id": "client-A", "x-transfer-sha256": "abc" }, + rawBody: new Uint8Array(Buffer.from("data")), + }); + + expect(result).toEqual({ accepted: true }); + expect(receiveTransferContentCalls).toContain(TEST_TRANSFER_ID); + }); + + test("trims whitespace from header before comparing", async () => { + stubTargetClientId = "client-A"; + const result = await handleTransferContentPut({ + pathParams: { transferId: TEST_TRANSFER_ID }, + headers: { "x-vellum-client-id": " client-A ", "x-transfer-sha256": "abc" }, + rawBody: new Uint8Array(Buffer.from("data")), + }); + + expect(result).toEqual({ accepted: true }); + }); + }); + + // ── 2. Targeted + missing header → 400 ─────────────────────────────────── + + describe("targeted + missing x-vellum-client-id header", () => { + test("throws BadRequestError when header is absent", async () => { + stubTargetClientId = "client-A"; + expect( + handleTransferContentPut({ + pathParams: { transferId: TEST_TRANSFER_ID }, + headers: { "x-transfer-sha256": "abc" }, + rawBody: new Uint8Array(Buffer.from("data")), + }), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("receiveTransferContent NOT called on 400", async () => { + stubTargetClientId = "client-A"; + try { + await handleTransferContentPut({ + pathParams: { transferId: TEST_TRANSFER_ID }, + headers: { "x-transfer-sha256": "abc" }, + rawBody: new Uint8Array(Buffer.from("data")), + }); + } catch { + // expected + } + expect(receiveTransferContentCalls).toHaveLength(0); + }); + }); + + // ── 3. Targeted + wrong header → 403 ───────────────────────────────────── + + describe("targeted + wrong x-vellum-client-id header", () => { + test("throws ForbiddenError when client ID does not match", async () => { + stubTargetClientId = "client-A"; + expect( + handleTransferContentPut({ + pathParams: { transferId: TEST_TRANSFER_ID }, + headers: { "x-vellum-client-id": "client-B", "x-transfer-sha256": "abc" }, + rawBody: new Uint8Array(Buffer.from("data")), + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + + test("receiveTransferContent NOT called on 403", async () => { + stubTargetClientId = "client-A"; + try { + await handleTransferContentPut({ + pathParams: { transferId: TEST_TRANSFER_ID }, + headers: { "x-vellum-client-id": "client-B", "x-transfer-sha256": "abc" }, + rawBody: new Uint8Array(Buffer.from("data")), + }); + } catch { + // expected + } + expect(receiveTransferContentCalls).toHaveLength(0); + }); + }); + + // ── 4. Untargeted — regression ──────────────────────────────────────────── + + describe("untargeted request (no targetClientId)", () => { + test("returns { accepted: true } without a header", async () => { + stubTargetClientId = null; + const result = await handleTransferContentPut({ + pathParams: { transferId: TEST_TRANSFER_ID }, + headers: { "x-transfer-sha256": "abc" }, + rawBody: new Uint8Array(Buffer.from("data")), + }); + + expect(result).toEqual({ accepted: true }); + expect(receiveTransferContentCalls).toContain(TEST_TRANSFER_ID); + }); + }); +}); + +describe("handleTransferResult — Phase 3 targetClientId guard", () => { + beforeEach(() => { + pendingStore.clear(); + stubTargetClientId = null; + resolveTransferResultCalls.length = 0; + }); + + function registerHostTransferPending(targetClientId?: string): void { + registerPending({ targetClientId }); + } + + function resultBody(): Record { + return { requestId: TEST_REQUEST_ID }; + } + + // ── 1. Targeted + correct header → success ──────────────────────────────── + + describe("targeted + correct x-vellum-client-id header", () => { + test("returns { accepted: true } and calls resolveTransferResult", async () => { + registerHostTransferPending("client-A"); + const result = await handleTransferResult({ + body: resultBody(), + headers: { "x-vellum-client-id": "client-A" }, + }); + + expect(result).toEqual({ accepted: true }); + expect(resolveTransferResultCalls).toContain(TEST_REQUEST_ID); + }); + + test("trims whitespace from header before comparing", async () => { + registerHostTransferPending("client-A"); + const result = await handleTransferResult({ + body: resultBody(), + headers: { "x-vellum-client-id": " client-A " }, + }); + + expect(result).toEqual({ accepted: true }); + }); + }); + + // ── 2. Targeted + missing header → 400 ─────────────────────────────────── + + describe("targeted + missing x-vellum-client-id header", () => { + test("throws BadRequestError when header is absent", () => { + registerHostTransferPending("client-A"); + expect(() => + handleTransferResult({ body: resultBody() }), + ).toThrow(BadRequestError); + }); + + test("resolveTransferResult NOT called on 400", () => { + registerHostTransferPending("client-A"); + try { + handleTransferResult({ body: resultBody() }); + } catch { + // expected + } + expect(resolveTransferResultCalls).toHaveLength(0); + }); + + test("pending interaction still present after 400", () => { + registerHostTransferPending("client-A"); + try { + handleTransferResult({ body: resultBody() }); + } catch { + // expected + } + expect(pendingStore.has(TEST_REQUEST_ID)).toBe(true); + }); + }); + + // ── 3. Targeted + wrong header → 403 ───────────────────────────────────── + + describe("targeted + wrong x-vellum-client-id header", () => { + test("throws ForbiddenError when client ID does not match", () => { + registerHostTransferPending("client-A"); + expect(() => + handleTransferResult({ + body: resultBody(), + headers: { "x-vellum-client-id": "client-B" }, + }), + ).toThrow(ForbiddenError); + }); + + test("resolveTransferResult NOT called on 403", () => { + registerHostTransferPending("client-A"); + try { + handleTransferResult({ + body: resultBody(), + headers: { "x-vellum-client-id": "client-B" }, + }); + } catch { + // expected + } + expect(resolveTransferResultCalls).toHaveLength(0); + }); + + test("pending interaction still present after 403", () => { + registerHostTransferPending("client-A"); + try { + handleTransferResult({ + body: resultBody(), + headers: { "x-vellum-client-id": "client-B" }, + }); + } catch { + // expected + } + expect(pendingStore.has(TEST_REQUEST_ID)).toBe(true); + }); + }); + + // ── 4. Untargeted — regression ──────────────────────────────────────────── + + describe("untargeted request (no targetClientId)", () => { + test("accepts when no header is provided", async () => { + registerHostTransferPending(); + const result = await handleTransferResult({ + body: resultBody(), + }); + + expect(result).toEqual({ accepted: true }); + expect(resolveTransferResultCalls).toContain(TEST_REQUEST_ID); + }); + + test("accepts when header is present (header ignored for untargeted)", async () => { + registerHostTransferPending(); + const result = await handleTransferResult({ + body: resultBody(), + headers: { "x-vellum-client-id": "client-whatever" }, + }); + + expect(result).toEqual({ accepted: true }); + }); + }); +}); diff --git a/assistant/src/runtime/routes/host-transfer-routes.ts b/assistant/src/runtime/routes/host-transfer-routes.ts index 65243593e4f..dbbf22d7556 100644 --- a/assistant/src/runtime/routes/host-transfer-routes.ts +++ b/assistant/src/runtime/routes/host-transfer-routes.ts @@ -9,7 +9,7 @@ import { z } from "zod"; import { HostTransferProxy } from "../../daemon/host-transfer-proxy.js"; import * as pendingInteractions from "../pending-interactions.js"; -import { BadRequestError, ConflictError, NotFoundError } from "./errors.js"; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from "./errors.js"; import type { RouteDefinition, RouteHandlerArgs } from "./types.js"; /** @@ -30,6 +30,7 @@ function findProxyByTransferId(transferId: string) { function handleTransferContentGet({ pathParams = {}, + headers = {}, }: RouteHandlerArgs): Uint8Array { const transferId = pathParams.transferId; if (!transferId) { @@ -41,6 +42,13 @@ function handleTransferContentGet({ throw new NotFoundError("Unknown or consumed transfer"); } + const targetClientId = match.proxy.getTargetClientIdForTransfer(transferId); + if (targetClientId != null) { + const submittingClientId = (headers as Record)["x-vellum-client-id"]?.trim() || undefined; + if (!submittingClientId) throw new BadRequestError("x-vellum-client-id header required for targeted transfer"); + if (submittingClientId !== targetClientId) throw new ForbiddenError(`Client "${submittingClientId}" is not the owner of this transfer`); + } + const content = match.proxy.getTransferContent(transferId); if (!content) { throw new NotFoundError("Unknown or consumed transfer"); @@ -94,6 +102,13 @@ async function handleTransferContentPut({ throw new NotFoundError("Unknown or consumed transfer"); } + const targetClientId = match.proxy.getTargetClientIdForTransfer(transferId); + if (targetClientId != null) { + const submittingClientId = (headers as Record)["x-vellum-client-id"]?.trim() || undefined; + if (!submittingClientId) throw new BadRequestError("x-vellum-client-id header required for targeted transfer"); + if (submittingClientId !== targetClientId) throw new ForbiddenError(`Client "${submittingClientId}" is not the owner of this transfer`); + } + const data = rawBody ? Buffer.from(rawBody) : Buffer.alloc(0); const sha256 = headers["x-transfer-sha256"] ?? ""; @@ -114,7 +129,7 @@ async function handleTransferContentPut({ // POST /v1/host-transfer-result // --------------------------------------------------------------------------- -function handleTransferResult({ body }: RouteHandlerArgs) { +function handleTransferResult({ body, headers }: RouteHandlerArgs) { if (!body || typeof body !== "object") { throw new BadRequestError("Request body is required"); } @@ -141,6 +156,13 @@ function handleTransferResult({ body }: RouteHandlerArgs) { ); } + if (peeked.targetClientId != null) { + const rawClientId = (headers as Record)?.["x-vellum-client-id"]; + const submittingClientId = rawClientId?.trim() || undefined; + if (!submittingClientId) throw new BadRequestError("x-vellum-client-id header is missing for a targeted host transfer request."); + if (submittingClientId !== peeked.targetClientId) throw new ForbiddenError(`Client "${submittingClientId}" is not the target for this request (expected "${peeked.targetClientId}").`); + } + HostTransferProxy.instance.resolveTransferResult(requestId, { isError: isError ?? false, bytesWritten, @@ -166,6 +188,16 @@ export const ROUTES: RouteDefinition[] = [ "Serve raw file bytes for a to_host transfer. Single-use: returns 404 after first consumption.", tags: ["host-transfer"], responseHeaders: resolveTransferContentGetHeaders, + additionalResponses: { + "400": { + description: + "x-vellum-client-id header is missing for a targeted transfer.", + }, + "403": { + description: + "Submitting client does not match the targeted client for this transfer.", + }, + }, handler: handleTransferContentGet, }, { @@ -178,6 +210,16 @@ export const ROUTES: RouteDefinition[] = [ description: "Receive raw file bytes for a to_sandbox transfer. Verifies SHA-256 integrity via the X-Transfer-SHA256 header.", tags: ["host-transfer"], + additionalResponses: { + "400": { + description: + "x-vellum-client-id header is missing for a targeted transfer.", + }, + "403": { + description: + "Submitting client does not match the targeted client for this transfer.", + }, + }, handler: handleTransferContentPut, }, { @@ -198,6 +240,16 @@ export const ROUTES: RouteDefinition[] = [ responseBody: z.object({ accepted: z.boolean(), }), + additionalResponses: { + "400": { + description: + "x-vellum-client-id header is missing for a targeted host transfer request.", + }, + "403": { + description: + "Submitting client does not match the targeted client for this transfer.", + }, + }, handler: handleTransferResult, }, ]; diff --git a/assistant/src/tools/host-filesystem/transfer.ts b/assistant/src/tools/host-filesystem/transfer.ts index 71bbf20d980..8e20263b7a9 100644 --- a/assistant/src/tools/host-filesystem/transfer.ts +++ b/assistant/src/tools/host-filesystem/transfer.ts @@ -2,16 +2,18 @@ import { constants } from "node:fs"; import { copyFile, lstat, mkdir, realpath } from "node:fs/promises"; import { dirname, isAbsolute } from "node:path"; +import { supportsHostProxy } from "../../channels/types.js"; import { HostTransferProxy } from "../../daemon/host-transfer-proxy.js"; import { RiskLevel } from "../../permissions/types.js"; import type { ToolDefinition } from "../../providers/types.js"; +import { assistantEventHub } from "../../runtime/assistant-event-hub.js"; import { sandboxPolicy } from "../shared/filesystem/path-policy.js"; import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; class HostFileTransferTool implements Tool { name = "host_file_transfer"; description = - "Copy a file between the assistant's workspace and the user's host machine. Set direction to 'to_host' to send a workspace file to the host, or 'to_sandbox' to pull a host file into the workspace."; + "Copy a file between the assistant's workspace and the user's host machine. Set direction to 'to_host' to send a workspace file to the host, or 'to_sandbox' to pull a host file into the workspace. When multiple clients support host_file, specify which one to use with target_client_id."; category = "host-filesystem"; defaultRiskLevel = RiskLevel.Medium; @@ -48,6 +50,11 @@ class HostFileTransferTool implements Tool { description: "Brief description of why the file is being transferred (for audit logging)", }, + target_client_id: { + type: "string", + description: + "ID of the specific client to transfer files to/from. Required when multiple clients support host_file; omit when only one is connected. Obtain IDs from `assistant clients list --capability host_file`.", + }, }, required: ["source_path", "dest_path", "direction"], }, @@ -85,6 +92,20 @@ class HostFileTransferTool implements Tool { const overwrite = input.overwrite === true; + const targetClientId = + typeof input.target_client_id === "string" && input.target_client_id !== "" + ? input.target_client_id + : undefined; + + if ( + targetClientId == null && + context.transportInterface != null && + !supportsHostProxy(context.transportInterface) && + assistantEventHub.listClientsByCapability("host_file").length > 1 + ) { + return { content: `Error: multiple clients support host_file. Specify which client to use with \`target_client_id\`. Run \`assistant clients list --capability host_file\` to see client IDs and labels.`, isError: true }; + } + // Validate that host-side paths are absolute. if (direction === "to_host" && !isAbsolute(destPath)) { return { @@ -134,6 +155,7 @@ class HostFileTransferTool implements Tool { destPath, overwrite, conversationId: context.conversationId, + targetClientId, }, context.signal, ); @@ -144,6 +166,7 @@ class HostFileTransferTool implements Tool { destPath: resolvedDestPath, overwrite, conversationId: context.conversationId, + targetClientId, }, context.signal, ); From 4295c8af7674173f0c295c45251666aa1d2601b0 Mon Sep 17 00:00:00 2001 From: credence-the-bot Date: Sun, 3 May 2026 18:34:26 +0000 Subject: [PATCH 2/2] fix(tests): await .rejects assertions; guard executeLocal fallthrough on targetClientId --- .../src/__tests__/host-transfer-routes-targeted.test.ts | 4 ++-- assistant/src/tools/host-filesystem/transfer.ts | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/assistant/src/__tests__/host-transfer-routes-targeted.test.ts b/assistant/src/__tests__/host-transfer-routes-targeted.test.ts index 8d0177404ab..cd27f8df321 100644 --- a/assistant/src/__tests__/host-transfer-routes-targeted.test.ts +++ b/assistant/src/__tests__/host-transfer-routes-targeted.test.ts @@ -240,7 +240,7 @@ describe("handleTransferContentPut — Phase 3 targetClientId guard", () => { describe("targeted + missing x-vellum-client-id header", () => { test("throws BadRequestError when header is absent", async () => { stubTargetClientId = "client-A"; - expect( + await expect( handleTransferContentPut({ pathParams: { transferId: TEST_TRANSFER_ID }, headers: { "x-transfer-sha256": "abc" }, @@ -269,7 +269,7 @@ describe("handleTransferContentPut — Phase 3 targetClientId guard", () => { describe("targeted + wrong x-vellum-client-id header", () => { test("throws ForbiddenError when client ID does not match", async () => { stubTargetClientId = "client-A"; - expect( + await expect( handleTransferContentPut({ pathParams: { transferId: TEST_TRANSFER_ID }, headers: { "x-vellum-client-id": "client-B", "x-transfer-sha256": "abc" }, diff --git a/assistant/src/tools/host-filesystem/transfer.ts b/assistant/src/tools/host-filesystem/transfer.ts index 8e20263b7a9..2a5f0a5e343 100644 --- a/assistant/src/tools/host-filesystem/transfer.ts +++ b/assistant/src/tools/host-filesystem/transfer.ts @@ -172,6 +172,13 @@ class HostFileTransferTool implements Tool { ); } + if (targetClientId != null) { + return { + content: `Error: target_client_id '${targetClientId}' was specified but no host client is available. Ensure the client is connected.`, + isError: true, + }; + } + // Local mode: direct filesystem copy. return this.executeLocal(resolvedSourcePath, resolvedDestPath, overwrite); }