From 138accfdc5ddd8f3a8bfe3e55c3fd3c7e42fe80c Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Tue, 22 Sep 2026 14:53:23 +0530 Subject: [PATCH 1/3] Improve error cause logging --- .../sandbox/provider/TFYSandboxProvider.ts | 50 ++++++++++------ .../src/core/util/errorLogFields.ts | 57 +++++++++++++++---- 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts index 101365649..43a149968 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts @@ -4,7 +4,7 @@ import dedent from 'dedent'; import { randomUUID } from 'node:crypto'; import { join } from 'node:path/posix'; import type { Logger } from 'winston'; -import { extractErrorLogFields } from '../../util/errorLogFields'; +import { describeUnknownError, extractErrorLogFields } from '../../util/errorLogFields'; import type { CodeModeTransport } from '../codeMode/CodeModeTransport'; import { CodeModeNatsTransport } from '../codeMode/nats/CodeModeNatsTransport'; import { DEFAULT_SANDBOX_NATS_WS_PORT } from '../constants'; @@ -152,6 +152,7 @@ export class TFYSandboxProvider implements SandboxProvider { timeout: timeoutSeconds, }; + const execUrl = `${this.serverUrl}/exec`; const controller = new AbortController(); const clientTimeoutMs = (timeoutSeconds + CLIENT_TIMEOUT_BUFFER_SECONDS) * 1000; const timer = setTimeout(() => { @@ -159,7 +160,7 @@ export class TFYSandboxProvider implements SandboxProvider { }, clientTimeoutMs); try { - const response = await fetch(`${this.serverUrl}/exec`, { + const response = await fetch(execUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -168,20 +169,31 @@ export class TFYSandboxProvider implements SandboxProvider { if (!response.ok) { const text = await response.text(); - this.logger.error(`Sandbox server returned ${String(response.status)}: ${text}`); - return { success: false, error: `Sandbox server returned ${String(response.status)}: ${text}` }; + this.logger.error(`Sandbox server returned ${String(response.status)}: ${text}`, { url: execUrl }); + return { + success: false, + error: `Sandbox server returned ${String(response.status)} from ${execUrl}: ${text}`, + }; } const result = (await response.json()) as ExecResult; return result; } catch (e: unknown) { if (e instanceof Error && e.name === 'AbortError') { - this.logger.error(`Sandbox exec timed out after ${String(timeoutSeconds)}s`, extractErrorLogFields(e)); - return { success: false, error: `Sandbox exec timed out after ${String(timeoutSeconds)}s` }; + this.logger.error(`Sandbox exec timed out after ${String(timeoutSeconds)}s`, { + url: execUrl, + ...extractErrorLogFields(e), + }); + return { + success: false, + error: `Sandbox exec to ${execUrl} timed out after ${String(timeoutSeconds)}s`, + }; } - this.logger.error('Sandbox exec failed', extractErrorLogFields(e)); - const message = e instanceof Error ? e.message : 'Unknown error'; - return { success: false, error: message }; + this.logger.error('Sandbox exec failed', { url: execUrl, ...extractErrorLogFields(e) }); + return { + success: false, + error: `Sandbox exec to ${execUrl} failed: ${describeUnknownError(e)}`, + }; } finally { clearTimeout(timer); } @@ -234,15 +246,21 @@ export class TFYSandboxProvider implements SandboxProvider { return context.with(suppressTracing(context.active()), async () => { const query = new URLSearchParams({ sandbox_id: params.sandboxId, path: params.remotePath }); - const response = await fetch(`${this.serverUrl}/files/upload?${query.toString()}`, { - method: 'POST', - headers: { 'Content-Type': 'application/octet-stream' }, - body: params.content, - signal: AbortSignal.timeout(FILE_UPLOAD_TIMEOUT_MS), - }); + const uploadUrl = `${this.serverUrl}/files/upload?${query.toString()}`; + let response: Response; + try { + response = await fetch(uploadUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: params.content, + signal: AbortSignal.timeout(FILE_UPLOAD_TIMEOUT_MS), + }); + } catch (e: unknown) { + throw new Error(`File upload to ${uploadUrl} failed: ${describeUnknownError(e)}`, { cause: e }); + } if (!response.ok) { throw new Error( - `File upload to sandbox failed: Sandbox server returned ${String(response.status)}: ${await response.text()}`, + `File upload to sandbox failed: Sandbox server returned ${String(response.status)} from ${uploadUrl}: ${await response.text()}`, ); } diff --git a/packages/trueforge-core/src/core/util/errorLogFields.ts b/packages/trueforge-core/src/core/util/errorLogFields.ts index ba0712cde..d211abfb1 100644 --- a/packages/trueforge-core/src/core/util/errorLogFields.ts +++ b/packages/trueforge-core/src/core/util/errorLogFields.ts @@ -11,13 +11,10 @@ function formatObjectErrorForLog(error: object): string { } } -/** - * User/turn-facing message for any thrown value. Prefer Error.message / `.message`; - * never surface developer-only strings (e.g. unserialisable dumps) in Agent Steps. - */ -export function describeUnknownError(error: unknown): string { +/** Single-hop message for any thrown value (no cause walk). */ +function messageOfUnknown(error: unknown): string { if (error instanceof Error) { - return error.message.length > 0 ? error.message : 'An unexpected error occurred'; + return error.message; } if (typeof error !== 'object' || error === null) { return String(error); @@ -29,23 +26,63 @@ export function describeUnknownError(error: unknown): string { try { return JSON.stringify(error); } catch { + return ''; + } +} + +/** + * Walk `Error.cause` (and plain `{ cause }` objects) so undici-style + * `TypeError: fetch failed` surfaces the nested `ECONNREFUSED` / cert reason. + */ +function describeErrorChain(error: unknown, seen: Set): string { + if (error === undefined || error === null || seen.has(error)) { + return ''; + } + seen.add(error); + + const head = messageOfUnknown(error).trim(); + const nestedCause = typeof error === 'object' && 'cause' in error ? Reflect.get(error, 'cause') : undefined; + const tail = describeErrorChain(nestedCause, seen).trim(); + + if (head.length === 0) { + return tail; + } + if (tail.length === 0 || head.includes(tail)) { + return head; + } + return `${head}: ${tail}`; +} + +/** + * User/turn-facing message for any thrown value. Prefer Error.message / `.message`; + * never surface developer-only strings (e.g. unserialisable dumps) in Agent Steps. + * Includes nested `cause` messages when present (e.g. undici "fetch failed"). + */ +export function describeUnknownError(error: unknown): string { + const chain = describeErrorChain(error, new Set()); + if (chain.length > 0) { + return chain; + } + if (typeof error === 'object' && error !== null) { return 'An unexpected error occurred'; } + return String(error); } export function extractErrorLogFields(error: unknown): ErrorLogFields { if (error instanceof Error) { + const chain = describeErrorChain(error, new Set()); return { - error: error.message.length > 0 ? error.message : formatObjectErrorForLog(error), + error: chain.length > 0 ? chain : formatObjectErrorForLog(error), stack: error.stack, }; } if (typeof error !== 'object' || error === null) { return { error: String(error) }; } - const message: unknown = Reflect.get(error, 'message'); - if (typeof message === 'string' && message.length > 0) { - return { error: message }; + const chain = describeErrorChain(error, new Set()); + if (chain.length > 0) { + return { error: chain }; } return { error: formatObjectErrorForLog(error) }; } From f87c4458dc84582333e441fd8a87ad01265ce150 Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Tue, 22 Sep 2026 09:26:06 +0000 Subject: [PATCH 2/3] Regenerate OpenAPI document and SDKs --- .github/fern/openapi/openapi.json | 2 +- docs/openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index b9d8e2deb..9190f254e 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -5816,7 +5816,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0" + "version": "0.2.1" }, "openapi": "3.1.0", "paths": { diff --git a/docs/openapi.json b/docs/openapi.json index b9d8e2deb..9190f254e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5816,7 +5816,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0" + "version": "0.2.1" }, "openapi": "3.1.0", "paths": { From fdb7d018369ce40a48f0bb0c7314441f3f6a2cd7 Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Tue, 22 Sep 2026 15:12:46 +0530 Subject: [PATCH 3/3] Add file upload start/end logs --- .changeset/sandbox-fetch-error-cause.md | 5 +++++ .../src/core/sandbox/provider/TFYSandboxProvider.ts | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 .changeset/sandbox-fetch-error-cause.md diff --git a/.changeset/sandbox-fetch-error-cause.md b/.changeset/sandbox-fetch-error-cause.md new file mode 100644 index 000000000..21c0194b0 --- /dev/null +++ b/.changeset/sandbox-fetch-error-cause.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-core': patch +--- + +[truefoundry] Surface nested `Error.cause` and the sandbox URL when TFY sandbox fetch calls fail, so undici "fetch failed" errors include ECONNREFUSED (and similar) instead of an opaque message. Log when a TFY sandbox file upload starts and finishes. diff --git a/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts index 43a149968..8a1b09eeb 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts @@ -247,6 +247,12 @@ export class TFYSandboxProvider implements SandboxProvider { return context.with(suppressTracing(context.active()), async () => { const query = new URLSearchParams({ sandbox_id: params.sandboxId, path: params.remotePath }); const uploadUrl = `${this.serverUrl}/files/upload?${query.toString()}`; + const bytes = params.content.byteLength; + this.logger.info('Uploading file to sandbox', { + sandboxId: params.sandboxId, + remotePath: params.remotePath, + bytes, + }); let response: Response; try { response = await fetch(uploadUrl, { @@ -268,6 +274,11 @@ export class TFYSandboxProvider implements SandboxProvider { if (!result.success) { throw new Error(`File upload to sandbox failed: ${result.error}`); } + this.logger.info('Uploaded file to sandbox', { + sandboxId: params.sandboxId, + remotePath: params.remotePath, + bytes, + }); }); }