Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sandbox-fetch-error-cause.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .github/fern/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -152,14 +152,15 @@ 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(() => {
controller.abort();
}, 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),
Expand All @@ -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);
}
Expand Down Expand Up @@ -234,22 +246,39 @@ 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()}`;
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, {
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()}`,
);
}

const result = (await response.json()) as { success: true } | { success: false; error: string };
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,
});
});
}

Expand Down
57 changes: 47 additions & 10 deletions packages/trueforge-core/src/core/util/errorLogFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<unknown>): 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 };
Comment thread
chiragjn marked this conversation as resolved.
}
return { error: formatObjectErrorForLog(error) };
}
Loading