diff --git a/.env.example b/.env.example index d4a49662..e42f2130 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,10 @@ DEVSPACE_LOG_SHELL_COMMANDS=0 # DEVSPACE_TRUST_PROXY=1 # DEVSPACE_STATE_DIR=/home/waishnav/.local/share/devspace # DEVSPACE_WORKTREE_ROOT=/home/waishnav/.devspace/worktrees +# Native-file download is opt-in. Files stream to a model-selected relative +# path inside an already-open workspace without overwriting existing files. +# DEVSPACE_ARTIFACTS=1 +# DEVSPACE_ARTIFACT_MAX_FILE_BYTES=104857600 # DEVSPACE_AUTO_LOAD_AGENTS_MD=1 # Skills are enabled by default. Set DEVSPACE_SKILLS=0 to hide them. # DEVSPACE_SKILLS=0 diff --git a/README.md b/README.md index 16c8817d..5342573a 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ devspace doctor - [Setup Guide](https://github.com/Waishnav/devspace/blob/main/docs/setup.md) - [ChatGPT Coding Workflow](https://github.com/Waishnav/devspace/blob/main/docs/chatgpt-coding-workflow.md) - [Configuration Reference](https://github.com/Waishnav/devspace/blob/main/docs/configuration.md) +- [Native File Download](https://github.com/Waishnav/devspace/blob/main/docs/artifact-exchange.md) - [Security Model](https://github.com/Waishnav/devspace/blob/main/docs/security.md) - [Troubleshooting Gotchas](https://github.com/Waishnav/devspace/blob/main/docs/gotchas.md) diff --git a/docs/artifact-exchange.md b/docs/artifact-exchange.md new file mode 100644 index 00000000..c269ae96 --- /dev/null +++ b/docs/artifact-exchange.md @@ -0,0 +1,42 @@ +# Download a native file + +DevSpace can save a file attached or generated by an MCP host, such as ChatGPT, +directly into an open workspace. Enable the tool with `DEVSPACE_ARTIFACTS=1`. + +## Workflow + +```text +open_workspace + -> download_artifact({ file, workspaceId, path }) + -> { path } +``` + +1. Open the project with `open_workspace`. +2. Pass the host-provided native `file`, the returned `workspaceId`, and an + unused workspace-relative `path` to `download_artifact`. +3. Use the returned path with the ordinary DevSpace filesystem tools. + +```text +download_artifact({ + file: , + workspaceId: "ws_123", + path: "public/images/generated-image.png" +}) +``` + +DevSpace creates missing parent directories and refuses to overwrite an existing +file. After the download, normal tools can inspect, move, rename, replace, or +delete it. + +## Safety and limits + +The `file` input must be the native value supplied by the MCP host. DevSpace does +not accept pasted download URLs or local source paths. It validates the complete +file-object shape, trusted OpenAI download hosts, and redirects before streaming. +Malformed references, unknown fields, absolute paths, traversal, and symlinked +parents are rejected. + +Downloads are streamed under `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` and published as +owner-only files without overwriting an existing destination. The tool is +available on Linux, macOS, FreeBSD, OpenBSD, and NetBSD; it is not registered on +Windows. diff --git a/docs/configuration.md b/docs/configuration.md index 4c02eb1a..1e2de63b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,6 +39,38 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com | `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | | `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | +## Native Artifact Download + +Native-file download is disabled by default. Enable it when ChatGPT needs to hand +an attached or generated file into an already-open workspace: + +```bash +DEVSPACE_ARTIFACTS=1 npx @waishnav/devspace serve +``` + +This feature currently supports Linux, macOS, FreeBSD, OpenBSD, and NetBSD. It +is not registered on Windows because the secure publication path depends on +descriptor-anchored directory operations. + +| Variable | Default | Purpose | +| --- | --- | --- | +| `DEVSPACE_ARTIFACTS` | `0` | Expose `download_artifact` for trusted native files. | +| `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` | `104857600` | Maximum streamed size of one file (100 MiB). | + +The same settings may be persisted in `~/.devspace/config.json` as +`artifactsEnabled` and `artifactMaxFileBytes`. + +`download_artifact` accepts the native file object supplied by the MCP connector, +a `workspaceId` returned by `open_workspace`, and a relative workspace `path`. +DevSpace safely creates missing parent directories, refuses to overwrite an +existing destination, and returns only the normalized workspace-relative path. +It does not accept conflict modes, expected hashes, arbitrary URL strings, local +paths, embedded credentials, or extra object fields. + +There is no artifact root, total quota, TTL, pinning, persistent database record, +or background artifact cleanup service. See [Native File Download](artifact-exchange.md) +for the supported connector shape and security boundaries. + ## OAuth DevSpace uses a single-user OAuth approval flow. @@ -158,6 +190,7 @@ DEVSPACE_OAUTH_OWNER_TOKEN="$(openssl rand -base64 32)" \ DEVSPACE_ALLOWED_ROOTS="$HOME/personal,$HOME/work" \ DEVSPACE_PUBLIC_BASE_URL="https://devspace.example.com" \ DEVSPACE_WORKTREE_ROOT="$HOME/.devspace/worktrees" \ +DEVSPACE_ARTIFACTS="1" \ DEVSPACE_TOOL_MODE="minimal" \ DEVSPACE_WIDGETS="full" \ npx @waishnav/devspace serve diff --git a/docs/security.md b/docs/security.md index 53fe027b..d7ec0e1d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -92,9 +92,33 @@ Managed worktrees reduce accidental edits to your active checkout, but they are not a security boundary. They are a workflow boundary for isolated coding sessions. +## Native File Download + +Native file download is an opt-in, one-shot transfer into an already-open +workspace. `download_artifact` accepts the MCP host's native file value, the +`workspaceId` returned by `open_workspace`, and an unused relative destination +path. It returns only the workspace-relative path and does not create a +persistent artifact service or reusable artifact ID. + +DevSpace accepts only the documented native-file object and trusted OpenAI +download hosts and redirects. Arbitrary URL strings, local source paths, +credentials, malformed references, and unknown object fields are rejected. + +Absolute paths, traversal, symlinked parents, and existing destinations also +fail closed. Downloads stream under the configured per-file limit and are +published without overwrite as owner-only files. DevSpace does not extract or +execute transferred content. + ## Logs By default, DevSpace logs requests and tool calls. Shell command previews are disabled unless `DEVSPACE_LOG_SHELL_COMMANDS=1`. Do not enable shell command logging if commands may contain secrets. + +Artifact tool logs contain bounded workspace ID, validated hostname, +workspace-relative output path, byte count, hash, duration, and status metadata. +`download_artifact` does not log the opaque file value. Raw content, connector +references, native file IDs, bearer credentials, presigned URLs, host paths, +temporary paths, and base64 chunks are never included in tool logs or tool +results. diff --git a/package.json b/package.json index 25f21059..2a564f40 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/artifact-download.test.ts b/src/artifact-download.test.ts new file mode 100644 index 00000000..028de112 --- /dev/null +++ b/src/artifact-download.test.ts @@ -0,0 +1,388 @@ +import assert from "node:assert/strict"; +import { + link, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + symlink, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import * as z from "zod/v4"; +import { + artifactToolLogFields, + downloadIncomingArtifact, + isArtifactDownloadSupportedPlatform, + registerArtifactTools, +} from "./artifact-tools.js"; +import { ArtifactError } from "./artifact-error.js"; +import { + IncomingArtifactAdapterRegistry, + type IncomingArtifactAdapter, +} from "./incoming-artifacts.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-artifact-download-test-")); + +try { + testOneToolContract(); + testPlatformSupportContract(); + if (isArtifactDownloadSupportedPlatform()) { + await testSafeDownloadAndConflict(join(root, "downloads")); + await testDestinationValidation(join(root, "destinations")); + await testSizeLimitAndCleanup(join(root, "size-limit")); + await testCrashLeftoverCleanup(join(root, "stale-partials")); + await testSymlinkRejection(join(root, "symlinks")); + await testPublicationFailurePreservesReplacement(join(root, "publication-race")); + await testPublishedPermissions(join(root, "permissions")); + } else { + await testUnsupportedPlatform(join(root, "unsupported-platform")); + } + testLogRedaction(); +} finally { + await rm(root, { recursive: true, force: true }); +} + +function testOneToolContract(): void { + const registered = new Map; callback: (input: never) => unknown }>(); + const server = { + registerTool( + name: string, + descriptor: Record, + callback: (input: never) => unknown, + ) { + registered.set(name, { descriptor, callback }); + return {}; + }, + }; + + registerArtifactTools(server as never, { + config: { + artifactMaxFileBytes: 1024, + logging: { toolCalls: false }, + } as never, + workspaces: {} as never, + }); + + assert.deepEqual([...registered.keys()], ["download_artifact"]); + const descriptor = registered.get("download_artifact")?.descriptor; + assert.ok(descriptor); + assert.deepEqual(descriptor._meta, { "openai/fileParams": ["file"] }); + assert.deepEqual(Object.keys(descriptor.inputSchema as object).sort(), ["file", "path", "workspaceId"]); + assert.deepEqual(Object.keys(descriptor.outputSchema as object), ["path"]); + assert.equal((descriptor.annotations as { destructiveHint?: boolean }).destructiveHint, false); + + const fileSchema = (descriptor.inputSchema as z.ZodRawShape).file as z.ZodType; + const valid = { + download_url: "https://files.oaiusercontent.com/file_123/download?sig=secret", + file_id: "file_123", + mime_type: "image/png", + file_name: "generated.png", + }; + assert.deepEqual(fileSchema.parse(valid), valid); + assert.throws(() => fileSchema.parse({ file_id: "file_123" })); + + const sensitiveExtraValue = "Bearer should-not-leak"; + const rejected = fileSchema.safeParse({ + ...valid, + authorization: sensitiveExtraValue, + }); + assert.equal(rejected.success, false); + assert.equal(JSON.stringify(rejected).includes(sensitiveExtraValue), false); +} + +function testPlatformSupportContract(): void { + assert.equal(isArtifactDownloadSupportedPlatform("linux"), true); + assert.equal(isArtifactDownloadSupportedPlatform("darwin"), true); + assert.equal(isArtifactDownloadSupportedPlatform("freebsd"), true); + assert.equal(isArtifactDownloadSupportedPlatform("openbsd"), true); + assert.equal(isArtifactDownloadSupportedPlatform("netbsd"), true); + assert.equal(isArtifactDownloadSupportedPlatform("win32"), false); +} + +async function testUnsupportedPlatform(testRoot: string): Promise { + const workspaceRoot = join(testRoot, "workspace"); + await mkdir(workspaceRoot, { recursive: true }); + await expectArtifactError( + downloadIncomingArtifact({ + registry: registryFor({ name: "blocked.txt", stream: Readable.from(["blocked"]) }), + workspaceId: "ws_test", + workspaceRoot, + maxFileBytes: 1024, + file: { native: true }, + path: "blocked.txt", + }), + "artifact_platform_unsupported", + ); +} + +async function testSafeDownloadAndConflict(testRoot: string): Promise { + const workspaceRoot = join(testRoot, "workspace"); + await mkdir(workspaceRoot, { recursive: true }); + const bytes = Buffer.from("native artifact bytes\u0000\xff", "latin1"); + const registry = registryFor({ + name: "../../generated.png", + size: bytes.length, + stream: Readable.from([bytes]), + }); + + const first = await downloadIncomingArtifact({ + registry, + workspaceId: "ws_test", + workspaceRoot, + maxFileBytes: 1024, + file: { native: true }, + path: "public/images/generated.png", + }); + assert.equal(first.path, "public/images/generated.png"); + assert.deepEqual(await readFile(join(workspaceRoot, first.path)), bytes); + + await expectArtifactError( + downloadIncomingArtifact({ + registry: registryFor({ + name: "replacement.png", + stream: Readable.from(["replacement"]), + }), + workspaceId: "ws_test", + workspaceRoot, + maxFileBytes: 1024, + file: { native: true }, + path: "public/images/generated.png", + }), + "artifact_destination_exists", + ); + assert.deepEqual(await readFile(join(workspaceRoot, first.path)), bytes); + assert.deepEqual(await readdir(workspaceRoot), ["public"]); +} + +async function testDestinationValidation(testRoot: string): Promise { + const workspaceRoot = join(testRoot, "workspace"); + await mkdir(workspaceRoot, { recursive: true }); + + for (const path of ["../outside.txt", "nested/../outside.txt", "/absolute.txt", "folder/"]) { + await expectArtifactError( + downloadIncomingArtifact({ + registry: registryFor({ name: "blocked.txt", stream: Readable.from(["blocked"]) }), + workspaceId: "ws_test", + workspaceRoot, + maxFileBytes: 1024, + file: { native: true }, + path, + }), + "artifact_destination_invalid", + ); + } +} + +async function testSizeLimitAndCleanup(testRoot: string): Promise { + const workspaceRoot = join(testRoot, "workspace"); + await mkdir(workspaceRoot, { recursive: true }); + + await expectArtifactError( + downloadIncomingArtifact({ + registry: registryFor({ + name: "too-large.bin", + size: 5, + stream: Readable.from([Buffer.from("12345")]), + }), + workspaceId: "ws_test", + workspaceRoot, + maxFileBytes: 4, + file: { native: true }, + path: "too-large.bin", + }), + "artifact_file_too_large", + ); + + await expectArtifactError( + downloadIncomingArtifact({ + registry: registryFor({ + name: "stream-too-large.bin", + stream: Readable.from([Buffer.from("123"), Buffer.from("45")]), + }), + workspaceId: "ws_test", + workspaceRoot, + maxFileBytes: 4, + file: { native: true }, + path: "stream-too-large.bin", + }), + "artifact_file_too_large", + ); + + assert.deepEqual(await readdir(workspaceRoot), []); +} + +async function testCrashLeftoverCleanup(testRoot: string): Promise { + const workspaceRoot = join(testRoot, "workspace"); + await mkdir(workspaceRoot, { recursive: true }); + await downloadIncomingArtifact({ + registry: registryFor({ name: "first.txt", stream: Readable.from(["first"]) }), + workspaceId: "ws_test", + workspaceRoot, + maxFileBytes: 1024, + file: { native: true }, + path: "downloads/first.txt", + }); + + const destinationDirectory = join(workspaceRoot, "downloads"); + const stalePartial = join(destinationDirectory, ".devspace-download-stale.partial"); + const recentPartial = join(destinationDirectory, ".devspace-download-recent.partial"); + const unrelated = join(destinationDirectory, "keep-me.partial"); + await writeFile(stalePartial, "stale"); + await writeFile(recentPartial, "recent"); + await writeFile(unrelated, "unrelated"); + const old = new Date(Date.now() - (48 * 60 * 60 * 1_000)); + await utimes(stalePartial, old, old); + + await downloadIncomingArtifact({ + registry: registryFor({ name: "second.txt", stream: Readable.from(["second"]) }), + workspaceId: "ws_test", + workspaceRoot, + maxFileBytes: 1024, + file: { native: true }, + path: "downloads/second.txt", + }); + + const entries = await readdir(destinationDirectory); + assert.equal(entries.includes(".devspace-download-stale.partial"), false); + assert.equal(entries.includes(".devspace-download-recent.partial"), true); + assert.equal(entries.includes("keep-me.partial"), true); + assert.equal(entries.includes("first.txt"), true); + assert.equal(entries.includes("second.txt"), true); +} + +async function testSymlinkRejection(testRoot: string): Promise { + if (process.platform === "win32") return; + + const outside = join(testRoot, "outside"); + await mkdir(outside, { recursive: true, mode: 0o700 }); + + const linkedWorkspaceRoot = join(testRoot, "linked-workspace"); + await symlink(outside, linkedWorkspaceRoot, "dir"); + await expectArtifactError( + downloadIncomingArtifact({ + registry: registryFor({ name: "blocked.txt", stream: Readable.from(["blocked"]) }), + workspaceId: "ws_test", + workspaceRoot: linkedWorkspaceRoot, + maxFileBytes: 1024, + file: { native: true }, + path: "blocked.txt", + }), + "artifact_workspace_unsafe", + ); + + const linkedDestinationRoot = join(testRoot, "linked-destination-workspace"); + await mkdir(linkedDestinationRoot, { recursive: true }); + await symlink(outside, join(linkedDestinationRoot, "assets"), "dir"); + await expectArtifactError( + downloadIncomingArtifact({ + registry: registryFor({ name: "blocked.txt", stream: Readable.from(["blocked"]) }), + workspaceId: "ws_test", + workspaceRoot: linkedDestinationRoot, + maxFileBytes: 1024, + file: { native: true }, + path: "assets/blocked.txt", + }), + "artifact_destination_parent_unsafe", + ); +} + +async function testPublicationFailurePreservesReplacement(testRoot: string): Promise { + const workspaceRoot = join(testRoot, "workspace"); + await mkdir(workspaceRoot, { recursive: true }); + const destinationPath = join(workspaceRoot, "generated.txt"); + + await expectArtifactError( + downloadIncomingArtifact({ + registry: registryFor({ + name: "generated.txt", + stream: Readable.from(["downloaded"]), + }), + workspaceId: "ws_test", + workspaceRoot, + maxFileBytes: 1024, + file: { native: true }, + path: "generated.txt", + publishLink: async (partialPath, candidatePath) => { + await link(partialPath, candidatePath); + await unlink(candidatePath); + await writeFile(candidatePath, "replacement"); + }, + }), + "artifact_destination_publish_failed", + ); + + assert.equal(await readFile(destinationPath, "utf8"), "replacement"); + assert.deepEqual(await readdir(workspaceRoot), ["generated.txt"]); +} + +async function testPublishedPermissions(testRoot: string): Promise { + const workspaceRoot = join(testRoot, "workspace"); + await mkdir(workspaceRoot, { recursive: true }); + const previousUmask = process.umask(0o077); + try { + await downloadIncomingArtifact({ + registry: registryFor({ + name: "private.txt", + stream: Readable.from(["private"]), + }), + workspaceId: "ws_test", + workspaceRoot, + maxFileBytes: 1024, + file: { native: true }, + path: "private.txt", + }); + } finally { + process.umask(previousUmask); + } + + assert.equal((await stat(join(workspaceRoot, "private.txt"))).mode & 0o777, 0o600); +} + +function testLogRedaction(): void { + const fields = artifactToolLogFields({ + file: { + download_url: "https://files.oaiusercontent.com/file_123/download?sig=super-secret", + file_id: "file_secret", + file_name: "generated.png", + authorization: "Bearer log-secret", + }, + workspaceId: "ws_secret", + path: "private/generated.png", + }); + const serialized = JSON.stringify(fields); + assert.equal(serialized.includes("super-secret"), false); + assert.equal(serialized.includes("file_secret"), false); + assert.equal(serialized.includes("log-secret"), false); + assert.equal(serialized.includes("ws_secret"), true); + assert.equal(serialized.includes("files.oaiusercontent.com"), true); +} + +function registryFor(source: { + name: string; + mimeType?: string; + size?: number; + stream: Readable; +}): IncomingArtifactAdapterRegistry { + const adapter: IncomingArtifactAdapter = { + id: "test-native", + canHandle: () => true, + async open() { + return source; + }, + }; + return new IncomingArtifactAdapterRegistry([adapter]); +} + +async function expectArtifactError(promise: Promise, code: string): Promise { + await assert.rejects( + promise, + (error: unknown) => error instanceof ArtifactError && error.code === code, + ); +} diff --git a/src/artifact-error.ts b/src/artifact-error.ts new file mode 100644 index 00000000..a6076f30 --- /dev/null +++ b/src/artifact-error.ts @@ -0,0 +1,9 @@ +export class ArtifactError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "ArtifactError"; + this.code = code; + } +} diff --git a/src/artifact-tools.ts b/src/artifact-tools.ts new file mode 100644 index 00000000..85806090 --- /dev/null +++ b/src/artifact-tools.ts @@ -0,0 +1,621 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { + link, + lstat, + mkdir, + open, + readdir, + unlink, + type FileHandle, +} from "node:fs/promises"; +import { isAbsolute, join, normalize, sep } from "node:path"; +import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod/v4"; +import { ArtifactError } from "./artifact-error.js"; +import type { ServerConfig } from "./config.js"; +import { + describeIncomingArtifactValue, + IncomingArtifactAdapterRegistry, + type IncomingArtifactAdapter, +} from "./incoming-artifacts.js"; +import { logEvent } from "./logger.js"; +import type { WorkspaceRegistry } from "./workspaces.js"; + +const ARTIFACT_WRITE_ANNOTATIONS = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, +}; +const NO_FOLLOW = fsConstants.O_NOFOLLOW ?? 0; +const DIRECTORY_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0) | NO_FOLLOW; +const PARTIAL_PREFIX = ".devspace-download-"; +const PARTIAL_SUFFIX = ".partial"; +const STALE_PARTIAL_AGE_MS = 24 * 60 * 60 * 1_000; +const MAX_STALE_PARTIAL_CLEANUP = 32; +const ARTIFACT_DOWNLOAD_PLATFORMS = new Set([ + "linux", + "darwin", + "freebsd", + "openbsd", + "netbsd", +]); + +const openAIFileReferenceInputSchema = z.strictObject({ + download_url: z.string(), + file_id: z.string(), + mime_type: z.string().nullable().optional(), + file_name: z.string().nullable().optional(), + name: z.string().nullable().optional(), + size: z.number().int().nonnegative().nullable().optional(), +}); + +export interface ArtifactToolRegistrationOptions { + config: ServerConfig; + workspaces: WorkspaceRegistry; + incomingArtifactAdapters?: readonly IncomingArtifactAdapter[]; +} + +export interface DownloadIncomingArtifactInput { + file: unknown; + workspaceId: string; + path: string; +} + +export interface DownloadIncomingArtifactResult { + path: string; + size: number; + sha256: string; +} + +export function isArtifactDownloadSupportedPlatform( + platform: NodeJS.Platform = process.platform, +): boolean { + return ARTIFACT_DOWNLOAD_PLATFORMS.has(platform); +} + +interface SecureDestinationDirectory { + handle: FileHandle; + anchorPath: string; + close(): Promise; +} + +interface ArtifactDestination { + path: string; + parentParts: string[]; + name: string; +} + +export function registerArtifactTools( + server: McpServer, + { + config, + workspaces, + incomingArtifactAdapters = [], + }: ArtifactToolRegistrationOptions, +): void { + const incomingRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters); + + registerAppTool( + server, + "download_artifact", + { + title: "Download attached or generated file", + description: + "Stream one MCP-host-provided native file to a requested relative path inside an already-open workspace. Existing destinations, arbitrary URLs, absolute paths, traversal, symlinked parents, local source paths, and malformed file objects are rejected.", + inputSchema: { + file: openAIFileReferenceInputSchema.describe( + "Native file value authorized and supplied by the MCP host.", + ), + workspaceId: z.string().min(1).describe( + "Workspace identifier returned by open_workspace.", + ), + path: z.string().min(1).describe( + "Relative destination path inside the selected workspace. The destination must not already exist.", + ), + }, + outputSchema: { + path: z.string(), + }, + _meta: { "openai/fileParams": ["file"] }, + annotations: ARTIFACT_WRITE_ANNOTATIONS, + }, + async (input) => executeArtifactTool(config, input, async () => { + const workspace = workspaces.getWorkspace(input.workspaceId); + const downloaded = await downloadIncomingArtifact({ + registry: incomingRegistry, + workspaceId: workspace.id, + workspaceRoot: workspace.root, + maxFileBytes: config.artifactMaxFileBytes, + file: input.file, + path: input.path, + }); + return { + publicResult: { path: downloaded.path }, + logResult: downloaded, + }; + }), + ); +} + +/** + * Stream a trusted native file directly into one already-open workspace. + * + * Bytes are written to an exclusive partial beside the requested destination, + * hashed and size-checked, fsynced, and only then published without overwriting + * the requested workspace path. No project-level staging directory is created. + */ +export async function downloadIncomingArtifact({ + registry, + workspaceId, + workspaceRoot, + maxFileBytes, + file, + path, + publishLink = link, +}: { + registry: IncomingArtifactAdapterRegistry; + workspaceId: string; + workspaceRoot: string; + maxFileBytes: number; + file: unknown; + path: string; + publishLink?: typeof link; +}): Promise { + if (!isArtifactDownloadSupportedPlatform()) { + throw new ArtifactError( + "artifact_platform_unsupported", + "Native file download requires descriptor-anchored directory operations on this platform.", + ); + } + if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < 1) { + throw new ArtifactError( + "artifact_limit_invalid", + "Artifact file-size limit must be a positive integer.", + ); + } + if (!workspaceId) { + throw new ArtifactError( + "artifact_workspace_invalid", + "A selected workspace is required for native file download.", + ); + } + + const destination = normalizeArtifactDestination(path); + const opened = await registry.open(file); + let workspaceHandle: FileHandle | undefined; + let destinationDirectory: SecureDestinationDirectory | undefined; + let partialPath: string | undefined; + let handle: FileHandle | undefined; + + try { + if (opened.size !== undefined && opened.size > maxFileBytes) { + throw new ArtifactError( + "artifact_file_too_large", + "Native file exceeds the configured per-file limit.", + ); + } + + workspaceHandle = await openDirectoryNoFollow( + workspaceRoot, + "artifact_workspace_unsafe", + "Selected workspace root is not a real directory.", + ); + destinationDirectory = await prepareDestinationDirectory( + workspaceHandle, + destination.parentParts, + ); + await cleanupStalePartials(destinationDirectory); + + partialPath = join( + destinationDirectory.anchorPath, + `${PARTIAL_PREFIX}${randomUUID()}${PARTIAL_SUFFIX}`, + ); + handle = await open( + partialPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NO_FOLLOW, + 0o600, + ); + + const hash = createHash("sha256"); + let size = 0; + for await (const value of opened.stream) { + const chunk = incomingStreamChunk(value); + if (size + chunk.length > maxFileBytes) { + throw new ArtifactError( + "artifact_file_too_large", + "Native file exceeds the configured per-file limit.", + ); + } + await writeAll(handle, chunk, size); + hash.update(chunk); + size += chunk.length; + } + + if (opened.size !== undefined && opened.size !== size) { + throw new ArtifactError( + "artifact_file_size_mismatch", + "Native file metadata did not match the downloaded content.", + ); + } + + await handle.sync(); + const writtenEntry = await handle.stat(); + if (!writtenEntry.isFile() || writtenEntry.size !== size) { + throw new ArtifactError( + "artifact_write_integrity_failed", + "Native file could not be verified before publication.", + ); + } + + const partialEntry = await lstat(partialPath); + if ( + partialEntry.isSymbolicLink() + || !partialEntry.isFile() + || partialEntry.dev !== writtenEntry.dev + || partialEntry.ino !== writtenEntry.ino + || partialEntry.size !== writtenEntry.size + ) { + throw new ArtifactError( + "artifact_partial_unsafe", + "Native file partial changed before publication.", + ); + } + + await publishDestination( + destinationDirectory, + partialPath, + destination.name, + writtenEntry, + handle, + publishLink, + ); + await unlink(partialPath).catch(() => undefined); + partialPath = undefined; + + return { + path: destination.path, + size, + sha256: `sha256:${hash.digest("hex")}`, + }; + } catch (error) { + opened.stream.destroy(); + throw error; + } finally { + await handle?.close().catch(() => undefined); + if (partialPath) await unlink(partialPath).catch(() => undefined); + await destinationDirectory?.close().catch(() => undefined); + await workspaceHandle?.close().catch(() => undefined); + } +} + +export function artifactToolLogFields( + input: Record, +): Record { + return { + fileProvided: input.file !== undefined, + fileReferenceShape: describeIncomingArtifactValue(input.file), + downloadUrlHostname: incomingFileDownloadHostname(input.file), + workspaceId: input.workspaceId, + path: input.path, + }; +} + +async function executeArtifactTool( + config: ServerConfig, + input: Record, + operation: () => Promise<{ + publicResult: { path: string }; + logResult: DownloadIncomingArtifactResult; + }>, +) { + const startedAt = performance.now(); + try { + const { publicResult, logResult } = await operation(); + if (config.logging.toolCalls) { + logEvent(config.logging, "info", "artifact_tool_call", { + tool: "download_artifact", + ...artifactToolLogFields(input), + path: logResult.path, + size: logResult.size, + sha256: logResult.sha256, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + } + return artifactToolResponse(publicResult); + } catch (error) { + if (config.logging.toolCalls) { + logEvent(config.logging, "warn", "artifact_tool_call", { + tool: "download_artifact", + ...artifactToolLogFields(input), + success: false, + errorCode: error instanceof ArtifactError ? error.code : "internal_error", + durationMs: Math.round(performance.now() - startedAt), + }); + } + throw error; + } +} + +function artifactToolResponse(result: { path: string }) { + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result, + }; +} + +async function openDirectoryNoFollow( + path: string, + code: string, + message: string, +): Promise { + let handle: FileHandle | undefined; + try { + handle = await open(path, DIRECTORY_FLAGS); + await assertDirectoryHandle(handle); + return handle; + } catch (error) { + await handle?.close().catch(() => undefined); + if (error instanceof ArtifactError) throw error; + throw new ArtifactError(code, message); + } +} + +async function assertDirectoryHandle(handle: FileHandle): Promise { + const entry = await handle.stat(); + if (!entry.isDirectory()) { + throw new ArtifactError( + "artifact_directory_unsafe", + "Artifact destination parent is not a directory.", + ); + } +} + +function descriptorDirectoryPath(handle: FileHandle): string { + if (process.platform === "linux") return `/proc/self/fd/${handle.fd}`; + if (isArtifactDownloadSupportedPlatform()) { + return `/dev/fd/${handle.fd}`; + } + throw new ArtifactError( + "artifact_platform_unsupported", + "Native file download requires descriptor-anchored directory operations on this platform.", + ); +} + +function normalizeArtifactDestination(value: string): ArtifactDestination { + const rawParts = value.split(sep); + if ( + !value + || value.includes("\u0000") + || isAbsolute(value) + || value.endsWith(sep) + || rawParts.includes("..") + ) { + throw new ArtifactError( + "artifact_destination_invalid", + "Artifact destination must be a non-empty relative file path inside the workspace.", + ); + } + + const normalized = normalize(value); + if ( + normalized === "." + || normalized === ".." + || normalized.startsWith(`..${sep}`) + ) { + throw new ArtifactError( + "artifact_destination_invalid", + "Artifact destination must stay inside the selected workspace.", + ); + } + + const parts = normalized.split(sep); + const name = parts.at(-1); + if (!name || name === "." || name === "..") { + throw new ArtifactError( + "artifact_destination_invalid", + "Artifact destination must name a file inside the selected workspace.", + ); + } + + return { + path: normalized, + parentParts: parts.slice(0, -1), + name, + }; +} + +async function prepareDestinationDirectory( + rootHandle: FileHandle, + parentParts: readonly string[], +): Promise { + const openedHandles: FileHandle[] = []; + let parentHandle = rootHandle; + let parentAnchor = descriptorDirectoryPath(rootHandle); + + try { + for (const part of parentParts) { + const child = await ensureWorkspaceChildDirectory( + parentHandle, + parentAnchor, + part, + ); + openedHandles.push(child); + parentHandle = child; + parentAnchor = descriptorDirectoryPath(child); + } + + return { + handle: parentHandle, + anchorPath: parentAnchor, + async close() { + for (const handle of openedHandles.reverse()) { + await handle.close().catch(() => undefined); + } + }, + }; + } catch (error) { + for (const handle of openedHandles.reverse()) { + await handle.close().catch(() => undefined); + } + throw error; + } +} + +async function ensureWorkspaceChildDirectory( + parentHandle: FileHandle, + parentAnchor: string, + name: string, +): Promise { + await assertDirectoryHandle(parentHandle); + const path = join(parentAnchor, name); + try { + await mkdir(path, { mode: 0o755 }); + } catch (error) { + if (!isNodeError(error) || error.code !== "EEXIST") throw error; + } + + return openDirectoryNoFollow( + path, + "artifact_destination_parent_unsafe", + "Artifact destination parent must be a real directory inside the workspace.", + ); +} + +async function publishDestination( + directory: SecureDestinationDirectory, + partialPath: string, + filename: string, + writtenEntry: Awaited>, + handle: FileHandle, + publishLink: typeof link, +): Promise { + await assertDirectoryHandle(directory.handle); + const candidate = join(directory.anchorPath, filename); + try { + await publishLink(partialPath, candidate); + assertPublishedArtifactEntry(await lstat(candidate), writtenEntry); + } catch (error) { + if (isNodeError(error) && error.code === "EEXIST") { + throw new ArtifactError( + "artifact_destination_exists", + "Artifact destination already exists.", + ); + } + // Once the destination path exists, never unlink it during failure cleanup. + // Another process may have replaced that path after publication, and a + // path-based verification followed by unlink would introduce another race. + throw error; + } +} + +function assertPublishedArtifactEntry( + entry: Awaited>, + writtenEntry: Awaited>, +): void { + if ( + entry.isSymbolicLink() + || !entry.isFile() + || entry.dev !== writtenEntry.dev + || entry.ino !== writtenEntry.ino + || entry.size !== writtenEntry.size + ) { + throw new ArtifactError( + "artifact_destination_publish_failed", + "Published artifact did not match the verified download.", + ); + } +} + +async function cleanupStalePartials( + directory: SecureDestinationDirectory, +): Promise { + await assertDirectoryHandle(directory.handle); + const entries = await readdir(directory.anchorPath, { withFileTypes: true }); + let inspected = 0; + const cutoff = Date.now() - STALE_PARTIAL_AGE_MS; + for (const entry of entries) { + if (inspected >= MAX_STALE_PARTIAL_CLEANUP) break; + if ( + !entry.name.startsWith(PARTIAL_PREFIX) + || !entry.name.endsWith(PARTIAL_SUFFIX) + ) continue; + inspected += 1; + + const path = join(directory.anchorPath, entry.name); + const metadata = await lstatOrUndefined(path); + if ( + !metadata + || metadata.isSymbolicLink() + || !metadata.isFile() + || metadata.mtimeMs >= cutoff + || (process.getuid?.() !== undefined && metadata.uid !== process.getuid?.()) + ) continue; + await unlink(path).catch(() => undefined); + } +} + +async function writeAll( + handle: FileHandle, + buffer: Buffer, + position: number, +): Promise { + let offset = 0; + while (offset < buffer.length) { + const { bytesWritten } = await handle.write( + buffer, + offset, + buffer.length - offset, + position + offset, + ); + if (bytesWritten <= 0) { + throw new ArtifactError( + "artifact_short_write", + "Native file was not fully written.", + ); + } + offset += bytesWritten; + } +} + +function incomingFileDownloadHostname(value: unknown): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const rawUrl = (value as Record).download_url; + if (typeof rawUrl !== "string") return undefined; + try { + const hostname = new URL(rawUrl).hostname.toLowerCase(); + return hostname.length > 0 && hostname.length <= 253 ? hostname : undefined; + } catch { + return undefined; + } +} + +function incomingStreamChunk(value: unknown): Buffer { + if (Buffer.isBuffer(value)) return value; + if (typeof value === "string") return Buffer.from(value); + if (value instanceof Uint8Array) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + throw new ArtifactError( + "invalid_incoming_artifact_chunk", + "Incoming artifact stream yielded a value that is not bytes or text.", + ); +} + +async function lstatOrUndefined(path: string) { + try { + return await lstat(path); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return undefined; + throw error; + } +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/src/config.test.ts b/src/config.test.ts index 0b4f99a8..9bc8b4c9 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -26,6 +26,13 @@ assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); assert.equal(loadConfig(baseEnv).subagents, false); +assert.equal(loadConfig(baseEnv).artifactsEnabled, false); +assert.equal(loadConfig(baseEnv).artifactMaxFileBytes, 100 * 1024 * 1024); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_ARTIFACTS: "1" }).artifactsEnabled, true); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "123" }).artifactMaxFileBytes, + 123, +); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); assert.equal( @@ -138,6 +145,10 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: "0" }), /Invalid DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: 0/, ); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "0" }), + /Invalid DEVSPACE_ARTIFACT_MAX_FILE_BYTES: 0/, +); assert.equal(loadConfig(baseEnv).publicBaseUrl, "http://127.0.0.1:7676"); assert.deepEqual(loadConfig(baseEnv).allowedHosts, ["localhost", "127.0.0.1", "::1"]); @@ -163,6 +174,8 @@ writeFileSync( allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", subagents: true, + artifactsEnabled: true, + artifactMaxFileBytes: 321, }), ); writeFileSync( @@ -177,6 +190,8 @@ assert.equal(fileConfig.port, 8787); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); assert.equal(fileConfig.subagents, true); +assert.equal(fileConfig.artifactsEnabled, true); +assert.equal(fileConfig.artifactMaxFileBytes, 321); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", "127.0.0.1", diff --git a/src/config.ts b/src/config.ts index 4fc1bcbb..f8c8b995 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,7 @@ export type ToolMode = "minimal" | "full" | "codex"; export type WidgetMode = "off" | "changes" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; +const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024; export interface ServerConfig { host: string; @@ -21,6 +22,8 @@ export interface ServerConfig { widgets: WidgetMode; stateDir: string; worktreeRoot: string; + artifactsEnabled: boolean; + artifactMaxFileBytes: number; skillsEnabled: boolean; skillPaths: string[]; devspaceSkillsDir: string; @@ -124,11 +127,16 @@ function parseStringList(value: string | undefined, fallback: string[]): string[ return entries && entries.length > 0 ? entries : fallback; } -function parsePositiveInteger(value: string | undefined, fallback: number, name: string): number { +function parsePositiveInteger( + value: string | undefined, + fallback: number, + name: string, + max = Number.MAX_SAFE_INTEGER, +): number { if (!value) return fallback; const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 1) { + if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) { throw new Error(`Invalid ${name}: ${value}`); } @@ -226,6 +234,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), + artifactsEnabled: + env.DEVSPACE_ARTIFACTS === undefined + ? files.config.artifactsEnabled === true + : parseBoolean(env.DEVSPACE_ARTIFACTS), + artifactMaxFileBytes: parsePositiveInteger( + env.DEVSPACE_ARTIFACT_MAX_FILE_BYTES ?? numberConfigValue(files.config.artifactMaxFileBytes), + DEFAULT_ARTIFACT_MAX_FILE_BYTES, + "DEVSPACE_ARTIFACT_MAX_FILE_BYTES", + ), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), devspaceSkillsDir: devspaceSkillsDir(env), @@ -239,6 +256,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { }; } +function numberConfigValue(value: number | undefined): string | undefined { + return value === undefined ? undefined : String(value); +} + function parsePublicBaseUrl(value: string): string { const parsed = new URL(value); parsed.hash = ""; diff --git a/src/incoming-artifacts.test.ts b/src/incoming-artifacts.test.ts new file mode 100644 index 00000000..7f126676 --- /dev/null +++ b/src/incoming-artifacts.test.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import { ArtifactError } from "./artifact-error.js"; +import { + IncomingArtifactAdapterRegistry, + createOpenAIIncomingArtifactAdapter, + describeIncomingArtifactValue, + type IncomingArtifactAdapter, +} from "./incoming-artifacts.js"; + +await testRegistryFailsClosed(); +await testOpenAIFileAdapter(); +testLogShapeRedaction(); + +async function testRegistryFailsClosed(): Promise { + const registry = new IncomingArtifactAdapterRegistry(); + await expectArtifactError( + registry.open("https://example.com/untrusted.bin"), + "unsupported_incoming_artifact", + ); + await expectArtifactError( + registry.open("/tmp/local-file.bin"), + "unsupported_incoming_artifact", + ); + await expectArtifactError( + registry.open(Buffer.from("raw bytes")), + "unsupported_incoming_artifact", + ); + + const ambiguous: IncomingArtifactAdapter = { + id: "ambiguous", + canHandle: () => true, + async open() { + return { name: "a.bin", stream: Readable.from(["a"]) }; + }, + }; + const ambiguousRegistry = new IncomingArtifactAdapterRegistry([ + ambiguous, + { ...ambiguous, id: "ambiguous-two" }, + ]); + await expectArtifactError( + ambiguousRegistry.open({ native: true }), + "ambiguous_incoming_artifact", + ); + + assert.throws( + () => new IncomingArtifactAdapterRegistry([{ ...ambiguous, id: "UPPER" }]), + (error: unknown) => error instanceof ArtifactError && error.code === "invalid_incoming_adapter", + ); + assert.throws( + () => new IncomingArtifactAdapterRegistry([ambiguous, ambiguous]), + (error: unknown) => error instanceof ArtifactError && error.code === "duplicate_incoming_adapter", + ); +} + +async function testOpenAIFileAdapter(): Promise { + const bytes = Buffer.from("chatgpt generated image bytes"); + const requested: string[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + requested.push(String(input)); + assert.equal(init?.redirect, "manual"); + return new Response(bytes, { + status: 200, + headers: { + "content-length": String(bytes.length), + "content-type": "image/png", + }, + }); + }; + const registry = new IncomingArtifactAdapterRegistry([ + createOpenAIIncomingArtifactAdapter({ fetch: fetchImpl }), + ]); + const reference = { + download_url: "https://files.oaiusercontent.com/file_123/download?sig=secret", + file_id: "file_123", + mime_type: "image/png", + file_name: "generated.png", + }; + const generatedReference = { + download_url: "https://oaisdmntprcentralindia.blob.core.windows.net/chatgpt-file/generated-image.png?sig=secret", + file_id: "file-service://generated+opaque/abc123", + mime_type: null, + file_name: null, + name: "/mnt/data/generated-image.png", + size: bytes.length, + }; + + const opened = await registry.open(reference); + assert.equal(opened.adapterId, "openai-file"); + assert.equal(opened.name, "generated.png"); + assert.equal(opened.mimeType, "image/png"); + assert.equal(opened.size, bytes.length); + assert.deepEqual(await collect(opened.stream), bytes); + + const generatedOpened = await registry.open(generatedReference); + assert.equal(generatedOpened.name, "generated-image.png"); + assert.equal(generatedOpened.mimeType, "image/png"); + assert.deepEqual(await collect(generatedOpened.stream), bytes); + assert.equal(requested.length, 2); + + const fallbackOpened = await registry.open({ + ...generatedReference, + file_name: null, + name: null, + }); + assert.equal(fallbackOpened.name, "chatgpt-file.png"); + fallbackOpened.stream.destroy(); + + await expectArtifactError( + registry.open({ + ...generatedReference, + file_name: "first.png", + name: "second.png", + }), + "ambiguous_openai_file_name", + ); + await expectArtifactError( + registry.open({ ...generatedReference, size: bytes.length + 1 }), + "openai_file_size_mismatch", + ); + await expectArtifactError( + registry.open({ + ...reference, + download_url: "http://files.oaiusercontent.com/file_123/download", + }), + "unsafe_openai_file_reference", + ); + await expectArtifactError( + registry.open({ + ...reference, + download_url: "https://example.com/file_123/download", + }), + "unsafe_openai_file_reference", + ); + await expectArtifactError( + registry.open({ + ...reference, + download_url: "https://arbitrary.blob.core.windows.net/container/file.png", + }), + "unsafe_openai_file_reference", + ); + await expectArtifactError( + registry.open({ + ...reference, + extra: "unexpected", + }), + "unsupported_incoming_artifact", + ); + + const redirectRegistry = new IncomingArtifactAdapterRegistry([ + createOpenAIIncomingArtifactAdapter({ + fetch: async (input) => { + if (String(input).includes("first")) { + return new Response(null, { + status: 302, + headers: { + location: "https://files.oaiusercontent.com/second?sig=next", + }, + }); + } + return new Response(bytes, { status: 200 }); + }, + }), + ]); + const redirected = await redirectRegistry.open({ + ...reference, + download_url: "https://files.oaiusercontent.com/first?sig=initial", + }); + assert.deepEqual(await collect(redirected.stream), bytes); + + const unsafeRedirectRegistry = new IncomingArtifactAdapterRegistry([ + createOpenAIIncomingArtifactAdapter({ + fetch: async () => new Response(null, { + status: 302, + headers: { location: "https://example.com/stolen" }, + }), + }), + ]); + await expectArtifactError( + unsafeRedirectRegistry.open(reference), + "unsafe_openai_file_reference", + ); + + const failedDownloadRegistry = new IncomingArtifactAdapterRegistry([ + createOpenAIIncomingArtifactAdapter({ + fetch: async () => new Response("nope", { status: 500 }), + }), + ]); + await expectArtifactError( + failedDownloadRegistry.open(reference), + "openai_file_download_failed", + ); +} + +function testLogShapeRedaction(): void { + const value = { + download_url: "https://files.oaiusercontent.com/file_123/download?sig=super-secret", + file_id: "file_secret", + nested: { + arbitrary: "private-value", + }, + }; + const serialized = JSON.stringify(describeIncomingArtifactValue(value)); + assert.equal(serialized.includes("super-secret"), false); + assert.equal(serialized.includes("file_secret"), false); + assert.equal(serialized.includes("private-value"), false); + assert.equal(serialized.includes("download_url"), true); + assert.equal(serialized.includes("file_id"), true); +} + +async function collect(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +async function expectArtifactError(promise: Promise, code: string): Promise { + await assert.rejects( + promise, + (error: unknown) => error instanceof ArtifactError && error.code === code, + ); +} diff --git a/src/incoming-artifacts.ts b/src/incoming-artifacts.ts new file mode 100644 index 00000000..0f926085 --- /dev/null +++ b/src/incoming-artifacts.ts @@ -0,0 +1,529 @@ +import { basename, isAbsolute } from "node:path"; +import { Readable } from "node:stream"; +import type { ReadableStream as NodeReadableStream } from "node:stream/web"; +import { ArtifactError } from "./artifact-error.js"; + +const ADAPTER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u; +const OPENAI_FILE_HOSTS = new Set([ + "files.oaiusercontent.com", +]); +// ChatGPT-generated files are served from regional OpenAI-managed Azure storage +// accounts. Accept that account family only, never arbitrary Azure Blob hosts. +const OPENAI_REGIONAL_BLOB_HOST_PATTERN = /^oaisdmntpr[a-z0-9]+\.blob\.core\.windows\.net$/u; +const OPENAI_FILENAME_SAFE_FILE_ID_PATTERN = /^file[-_][A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u; +const OPENAI_FILE_ID_MAX_LENGTH = 512; +const OPENAI_FILE_ID_CONTROL_PATTERN = /[\u0000-\u001F\u007F]/u; +const OPENAI_FILE_KEYS = new Set([ + "download_url", + "file_id", + "mime_type", + "file_name", + "name", + "size", +]); +const OPENAI_FILE_REDIRECT_LIMIT = 3; +const OPENAI_FILE_DOWNLOAD_TIMEOUT_MS = 30_000; + +export interface IncomingArtifactSource { + name: string; + mimeType?: string; + size?: number; + stream: Readable; +} + +export interface IncomingArtifactAdapter { + readonly id: string; + canHandle(value: unknown): boolean; + open(value: unknown): Promise; +} + +export interface OpenedIncomingArtifact extends IncomingArtifactSource { + adapterId: string; +} + +export class IncomingArtifactAdapterRegistry { + private readonly adapters: readonly IncomingArtifactAdapter[]; + + constructor(adapters: readonly IncomingArtifactAdapter[] = []) { + const ids = new Set(); + for (const adapter of adapters) { + if (!ADAPTER_ID_PATTERN.test(adapter.id)) { + throw new ArtifactError( + "invalid_incoming_adapter", + "Incoming artifact adapter IDs must be short lowercase identifiers.", + ); + } + if (ids.has(adapter.id)) { + throw new ArtifactError( + "duplicate_incoming_adapter", + `Incoming artifact adapter '${adapter.id}' is registered more than once.`, + ); + } + ids.add(adapter.id); + } + this.adapters = [...adapters]; + } + + async open(value: unknown): Promise { + const matching: IncomingArtifactAdapter[] = []; + for (const adapter of this.adapters) { + let handles = false; + try { + handles = adapter.canHandle(value); + } catch { + throw new ArtifactError( + "incoming_artifact_adapter_failed", + `Incoming artifact adapter '${adapter.id}' failed during recognition.`, + ); + } + if (handles) matching.push(adapter); + } + + if (matching.length === 0) { + throw new ArtifactError( + "unsupported_incoming_artifact", + "No trusted incoming artifact adapter recognized this file reference.", + ); + } + if (matching.length > 1) { + throw new ArtifactError( + "ambiguous_incoming_artifact", + "More than one trusted incoming artifact adapter recognized this file reference.", + ); + } + + const adapter = matching[0]; + let source: IncomingArtifactSource; + try { + source = await adapter.open(value); + } catch (error) { + if (error instanceof ArtifactError) throw error; + throw new ArtifactError( + "incoming_artifact_open_failed", + `Incoming artifact adapter '${adapter.id}' could not open the file reference.`, + ); + } + try { + validateIncomingArtifactSource(source); + } catch (error) { + source?.stream?.destroy?.(); + throw error; + } + return { ...source, adapterId: adapter.id }; + } +} + +export interface OpenAIFileReference { + download_url: string; + file_id: string; + mime_type?: string; + file_name?: string; + size?: number; +} + +export interface OpenAIIncomingArtifactAdapterOptions { + fetch?: typeof fetch; + timeoutMs?: number; +} + +export function createOpenAIIncomingArtifactAdapter( + options: OpenAIIncomingArtifactAdapterOptions = {}, +): IncomingArtifactAdapter { + const fetchFile = options.fetch ?? globalThis.fetch; + const timeoutMs = options.timeoutMs ?? OPENAI_FILE_DOWNLOAD_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + throw new ArtifactError( + "invalid_openai_file_adapter", + "OpenAI file download timeout must be a positive integer.", + ); + } + + return { + id: "openai-file", + canHandle: isOpenAIFileReferenceCandidate, + async open(value: unknown): Promise { + const reference = normalizeOpenAIFileReference(value); + + let downloadUrl = validateOpenAIFileUrl(reference.download_url); + let response: Response | undefined; + for (let redirect = 0; redirect <= OPENAI_FILE_REDIRECT_LIMIT; redirect += 1) { + try { + response = await fetchFile(downloadUrl, { + redirect: "manual", + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + throw new ArtifactError( + "openai_file_download_failed", + "ChatGPT file could not be downloaded.", + ); + } + + if (!isRedirectStatus(response.status)) break; + const location = response.headers.get("location"); + await response.body?.cancel().catch(() => undefined); + if (!location || redirect === OPENAI_FILE_REDIRECT_LIMIT) { + throw new ArtifactError( + "openai_file_download_failed", + "ChatGPT file download returned an invalid redirect.", + ); + } + downloadUrl = validateOpenAIFileUrl(new URL(location, downloadUrl).toString()); + } + + if (!response?.ok || !response.body) { + await response?.body?.cancel().catch(() => undefined); + throw new ArtifactError( + "openai_file_download_failed", + "ChatGPT file download did not return file content.", + ); + } + + const responseSize = responseContentLength(response); + if ( + reference.size !== undefined + && responseSize !== undefined + && reference.size !== responseSize + ) { + await response.body.cancel().catch(() => undefined); + throw new ArtifactError( + "openai_file_size_mismatch", + "ChatGPT file metadata did not match the downloaded content.", + ); + } + const mimeType = reference.mime_type ?? responseMimeType(response); + + return { + name: normalizeOpenAIFileName(reference.file_name, reference.file_id, mimeType), + mimeType, + size: responseSize ?? reference.size, + stream: Readable.fromWeb(response.body as unknown as NodeReadableStream), + }; + }, + }; +} + +export type IncomingArtifactValueShape = + | { type: "null" } + | { type: "undefined" } + | { type: "boolean" } + | { type: "number"; finite: boolean } + | { type: "bigint" } + | { type: "string"; kind: "absolute-path" | "url" | "data-url" | "text"; length: number } + | { type: "array"; length: number; items: IncomingArtifactValueShape[]; truncated: boolean } + | { + type: "object"; + constructor?: string; + entries: Record; + truncated: boolean; + } + | { type: "function" | "symbol" } + | { type: "cycle" }; + +export function describeIncomingArtifactValue( + value: unknown, + maxDepth = 4, + maxEntries = 20, +): IncomingArtifactValueShape { + const seen = new WeakSet(); + + const describe = (current: unknown, depth: number): IncomingArtifactValueShape => { + if (current === null) return { type: "null" }; + if (current === undefined) return { type: "undefined" }; + if (typeof current === "boolean") return { type: "boolean" }; + if (typeof current === "number") return { type: "number", finite: Number.isFinite(current) }; + if (typeof current === "bigint") return { type: "bigint" }; + if (typeof current === "function") return { type: "function" }; + if (typeof current === "symbol") return { type: "symbol" }; + if (typeof current === "string") { + return { + type: "string", + kind: classifyValueString(current), + length: current.length, + }; + } + if (seen.has(current)) return { type: "cycle" }; + seen.add(current); + + if (Array.isArray(current)) { + if (depth >= maxDepth) { + return { type: "array", length: current.length, items: [], truncated: current.length > 0 }; + } + const items = current.slice(0, maxEntries).map((item) => describe(item, depth + 1)); + return { + type: "array", + length: current.length, + items, + truncated: current.length > items.length, + }; + } + + const keys = Object.keys(current).sort(); + if (depth >= maxDepth) { + return { + type: "object", + constructor: safeConstructorName(current), + entries: {}, + truncated: keys.length > 0, + }; + } + const entries: Record = {}; + for (const [index, key] of keys.slice(0, maxEntries).entries()) { + let entryValue: unknown; + try { + entryValue = (current as Record)[key]; + } catch { + entryValue = undefined; + } + entries[safeValueEntryKey(key, index)] = describe(entryValue, depth + 1); + } + return { + type: "object", + constructor: safeConstructorName(current), + entries, + truncated: keys.length > Object.keys(entries).length, + }; + }; + + return describe(value, 0); +} + +function validateIncomingArtifactSource(source: IncomingArtifactSource): void { + if (!source || typeof source !== "object") { + throw new ArtifactError( + "invalid_incoming_artifact_source", + "Incoming artifact adapter returned an invalid source.", + ); + } + if (typeof source.name !== "string" || source.name.length === 0) { + throw new ArtifactError( + "invalid_incoming_artifact_source", + "Incoming artifact adapter must provide a filename.", + ); + } + if (source.mimeType !== undefined && typeof source.mimeType !== "string") { + throw new ArtifactError( + "invalid_incoming_artifact_source", + "Incoming artifact adapter returned an invalid MIME hint.", + ); + } + if ( + source.size !== undefined + && (!Number.isSafeInteger(source.size) || source.size < 0) + ) { + throw new ArtifactError( + "invalid_incoming_artifact_source", + "Incoming artifact adapter returned an invalid byte size.", + ); + } + const stream = source.stream as Partial | undefined; + if (!stream || typeof stream[Symbol.asyncIterator] !== "function") { + throw new ArtifactError( + "invalid_incoming_artifact_source", + "Incoming artifact adapter must provide an async-readable stream.", + ); + } +} + +function isOpenAIFileReferenceCandidate(value: unknown): value is Record { + if (!isRecord(value)) return false; + const keys = Object.keys(value); + return keys.length >= 2 + && keys.every((key) => OPENAI_FILE_KEYS.has(key)) + && Object.hasOwn(value, "download_url") + && Object.hasOwn(value, "file_id"); +} + +function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { + if (!isOpenAIFileReferenceCandidate(value)) { + throw new ArtifactError( + "invalid_openai_file_reference", + "ChatGPT file reference is malformed.", + ); + } + + const downloadUrl = value.download_url; + const fileId = value.file_id; + if ( + typeof downloadUrl !== "string" + || typeof fileId !== "string" + || !isValidOpenAIFileId(fileId) + ) { + throw new ArtifactError( + "invalid_openai_file_reference", + "ChatGPT file reference is malformed.", + ); + } + + const mimeType = nullableString(value.mime_type); + const fileName = nullableString(value.file_name); + const nameAlias = nullableString(value.name); + if (mimeType === null || fileName === null || nameAlias === null) { + throw new ArtifactError( + "invalid_openai_file_reference", + "ChatGPT file reference is malformed.", + ); + } + const normalizedFileName = normalizeSuppliedOpenAIFileName(fileName); + const normalizedNameAlias = normalizeSuppliedOpenAIFileName(nameAlias); + if ( + normalizedFileName + && normalizedNameAlias + && normalizedFileName !== normalizedNameAlias + ) { + throw new ArtifactError( + "ambiguous_openai_file_name", + "ChatGPT file reference contained conflicting filenames.", + ); + } + + let size: number | undefined; + const rawSize = value.size; + if (rawSize !== undefined && rawSize !== null) { + if (typeof rawSize !== "number" || !Number.isSafeInteger(rawSize) || rawSize < 0) { + throw new ArtifactError( + "invalid_openai_file_reference", + "ChatGPT file reference is malformed.", + ); + } + size = rawSize; + } + + return { + download_url: downloadUrl, + file_id: fileId, + mime_type: mimeType, + file_name: normalizedFileName ?? normalizedNameAlias, + size, + }; +} + +function nullableString(value: unknown): string | undefined | null { + if (value === undefined || value === null) return undefined; + return typeof value === "string" ? value : null; +} + +function normalizeOpenAIFileName( + suppliedName: string | undefined, + fileId: string, + mimeType: string | undefined, +): string { + if (suppliedName) return suppliedName; + const safeBaseName = OPENAI_FILENAME_SAFE_FILE_ID_PATTERN.test(fileId) + ? fileId + : "chatgpt-file"; + return `${safeBaseName}${extensionForMimeType(mimeType) ?? ".bin"}`; +} + +function isValidOpenAIFileId(value: string): boolean { + return value.length > 0 + && value.length <= OPENAI_FILE_ID_MAX_LENGTH + && !OPENAI_FILE_ID_CONTROL_PATTERN.test(value); +} + +function normalizeSuppliedOpenAIFileName(value: string | undefined): string | undefined { + if (!value) return undefined; + const normalized = value.replaceAll("\\", "/"); + const candidate = basename(normalized).trim(); + if (!candidate || candidate === "." || candidate === ".." || candidate.startsWith(".")) { + return undefined; + } + return candidate; +} + +function extensionForMimeType(mimeType: string | undefined): string | undefined { + switch (mimeType?.toLowerCase()) { + case "image/png": return ".png"; + case "image/jpeg": return ".jpg"; + case "image/webp": return ".webp"; + case "image/gif": return ".gif"; + case "application/pdf": return ".pdf"; + case "text/plain": return ".txt"; + case "application/zip": return ".zip"; + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": return ".docx"; + default: return undefined; + } +} + +function validateOpenAIFileUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new ArtifactError( + "unsafe_openai_file_reference", + "ChatGPT file download URL is invalid.", + ); + } + if ( + url.protocol !== "https:" + || !isTrustedOpenAIFileHost(url.hostname) + || (url.port !== "" && url.port !== "443") + || url.username !== "" + || url.password !== "" + || url.hash !== "" + ) { + throw new ArtifactError( + "unsafe_openai_file_reference", + "ChatGPT file download URL is outside the trusted file host.", + ); + } + return url.toString(); +} + +function isTrustedOpenAIFileHost(hostname: string): boolean { + return OPENAI_FILE_HOSTS.has(hostname) || OPENAI_REGIONAL_BLOB_HOST_PATTERN.test(hostname); +} + +function isRedirectStatus(status: number): boolean { + return status === 301 + || status === 302 + || status === 303 + || status === 307 + || status === 308; +} + +function responseMimeType(response: Response): string | undefined { + const value = response.headers.get("content-type")?.split(";", 1)[0]?.trim(); + return value || undefined; +} + +function responseContentLength(response: Response): number | undefined { + const value = response.headers.get("content-length"); + if (!value || !/^\d+$/u.test(value)) return undefined; + const size = Number(value); + return Number.isSafeInteger(size) ? size : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function classifyValueString( + value: string, +): "absolute-path" | "url" | "data-url" | "text" { + if (value.startsWith("data:")) return "data-url"; + if (isAbsolute(value)) return "absolute-path"; + try { + const parsed = new URL(value); + if (parsed.protocol === "http:" || parsed.protocol === "https:") return "url"; + } catch { + // Non-URL strings are summarized only by type and length. + } + return "text"; +} + +function safeValueEntryKey(value: string, index: number): string { + return /^[A-Za-z_][A-Za-z0-9_.-]{0,79}$/u.test(value) + ? value + : ``; +} + +function safeConstructorName(value: object): string | undefined { + try { + const name = value.constructor?.name; + return typeof name === "string" && name.length <= 80 ? name : undefined; + } catch { + return undefined; + } +} diff --git a/src/server.ts b/src/server.ts index 7dd9629e..91f5df61 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,7 +18,15 @@ import express from "express"; import type { Request, Response } from "express"; import * as z from "zod/v4"; import { applyPatch } from "./apply-patch.js"; +import { + isArtifactDownloadSupportedPlatform, + registerArtifactTools, +} from "./artifact-tools.js"; import { loadConfig, type ServerConfig, type WidgetMode } from "./config.js"; +import { + createOpenAIIncomingArtifactAdapter, + type IncomingArtifactAdapter, +} from "./incoming-artifacts.js"; import { logEvent, requestIp, @@ -179,13 +187,16 @@ interface ToolLogFields { } function serverInstructions(config: ServerConfig): string { + const artifactInstruction = config.artifactsEnabled && isArtifactDownloadSupportedPlatform() + ? " When the user supplies or generates a file that is not present on the DevSpace host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs." + : ""; const showChangesInstruction = config.widgets === "changes" ? " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs." : ""; if (config.toolMode === "codex") { - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${showChangesInstruction}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${artifactInstruction}${showChangesInstruction}`; } const inspection = config.toolMode !== "full" @@ -198,7 +209,7 @@ function serverInstructions(config: ServerConfig): string { const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChangesInstruction}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${artifactInstruction}${showChangesInstruction}`; } function formatVisibleAgent(agent: { @@ -688,6 +699,7 @@ function createMcpServer( reviewCheckpoints: ReturnType, processSessions: ProcessSessionManager, localAgentProviders: LocalAgentProviderAvailability[], + incomingArtifactAdapters: readonly IncomingArtifactAdapter[], ): McpServer { const server = new McpServer( { @@ -1594,10 +1606,27 @@ function createMcpServer( registerCodexProcessTools(server, config, workspaces, processSessions); } + if (config.artifactsEnabled && isArtifactDownloadSupportedPlatform()) { + registerArtifactTools(server, { + config, + workspaces, + incomingArtifactAdapters, + }); + } + return server; } -export function createServer(config = loadConfig()): RunningServer { +export interface CreateServerOptions { + incomingArtifactAdapters?: readonly IncomingArtifactAdapter[]; +} + +export function createServer( + config = loadConfig(), + options: CreateServerOptions = {}, +): RunningServer { + const incomingArtifactAdapters = options.incomingArtifactAdapters + ?? [createOpenAIIncomingArtifactAdapter()]; const allowedHosts = config.allowedHosts.includes("*") ? undefined : Array.from(new Set([config.host, ...config.allowedHosts])); @@ -1781,6 +1810,7 @@ export function createServer(config = loadConfig()): RunningServer { reviewCheckpoints, processSessions, localAgentProviders, + incomingArtifactAdapters, ); await server.connect(transport); } else { @@ -1839,6 +1869,12 @@ if (await isMainModule()) { console.log(`request logging: ${config.logging.requests ? "enabled" : "disabled"}`); console.log(`asset logging: ${config.logging.assets ? "enabled" : "disabled"}`); console.log(`trust proxy: ${config.logging.trustProxy ? "enabled" : "disabled"}`); + const artifactDownloadStatus = !config.artifactsEnabled + ? "disabled" + : isArtifactDownloadSupportedPlatform() + ? "enabled" + : `unsupported on ${process.platform}`; + console.log(`native artifact download: ${artifactDownloadStatus}`); if (config.subagents) { console.log(`subagent providers: ${formatLocalAgentProviderAvailabilitySummary(localAgentProviders)}`); } diff --git a/src/user-config.ts b/src/user-config.ts index c8da90b5..5dd793ef 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -17,6 +17,8 @@ export interface DevspaceUserConfig { allowedHosts?: string[]; stateDir?: string; worktreeRoot?: string; + artifactsEnabled?: boolean; + artifactMaxFileBytes?: number; agentDir?: string; subagents?: boolean; }