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
20 changes: 13 additions & 7 deletions compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,21 @@ services:
# accidentally invoke the E2E harness. Dev compose explicitly opts in.
- NODE_ENV=development

# Dev-only bridge: the agent sandbox container is on the `--internal`
# tale-sandbox-net (SSRF-locked) and `bun dev` runs convex on the HOST
# (host.docker.internal:3211), so the in-container MCP integration bridge
# can't reach it. This socat relay is dual-homed (internal + sandbox), aliased
# `convex` on the sandbox net, forwarding :3211 → the host-run convex. In prod
# the real convex container is dual-homed onto `sandbox` instead (compose.yml).
# Dev-only bridge: the agent sandbox + session containers are on the
# `--internal` tale-sandbox-net (SSRF-locked) and `bun dev` runs convex on the
# HOST, so in-container code can't reach it. This socat relay is dual-homed
# (internal + sandbox), aliased `convex` on the sandbox net, forwarding BOTH
# :3211 (HTTP actions / integration bridge) AND :3210 (storage — the session
# daemon fetches staged file URLs from here) → the host-run convex. In prod the
# real convex container is dual-homed onto `sandbox` instead (compose.yml).
convex-relay:
image: alpine/socat
command: ['TCP-LISTEN:3211,fork,reuseaddr', 'TCP:host.docker.internal:3211']
entrypoint: ['/bin/sh', '-c']
command:
- >-
socat TCP-LISTEN:3211,fork,reuseaddr TCP:host.docker.internal:3211 &
socat TCP-LISTEN:3210,fork,reuseaddr TCP:host.docker.internal:3210 &
wait
extra_hosts:
- 'host.docker.internal:host-gateway'
restart: unless-stopped
Expand Down
8 changes: 8 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ services:

# Restart policy
# Automatically restart the container if it crashes
# Graceful shutdown: drain in-flight HTTP/SSE chat streams before SIGKILL.
# Mirrors the CLI generator (create-platform-service.ts: 45s); without it
# `docker compose up` gets Docker's 10s default and cuts streams mid-flight.
stop_grace_period: 45s
restart: unless-stopped

# Health check — only checks Vite now (Convex is a separate service with
Expand Down Expand Up @@ -574,6 +578,10 @@ services:
# tier from deployment.json (same convex-data volume the convex/platform
# services share).
- ${PLATFORM_SHARED_CONFIG:-convex-data}:/app/platform-config:ro
# Graceful shutdown: let the spawner drain in-flight executions before
# SIGKILL. Mirrors the CLI generator (create-sandbox-service.ts: 30s);
# without it `docker compose up` gets Docker's 10s default.
stop_grace_period: 30s
restart: unless-stopped
# Resource caps mirror the CLI compose generator
# (`tools/cli/src/lib/compose/services/create-sandbox-service.ts`). The
Expand Down
2 changes: 2 additions & 0 deletions services/platform/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ import type * as agents_auto_route_helpers from "../agents/auto_route_helpers.js
import type * as agents_chat_turn from "../agents/chat_turn.js";
import type * as agents_chat_turn_generate from "../agents/chat_turn_generate.js";
import type * as agents_config from "../agents/config.js";
import type * as agents_external_agent_attachment_files from "../agents/external_agent/attachment_files.js";
import type * as agents_external_agent_continue_external_agent_turn from "../agents/external_agent/continue_external_agent_turn.js";
import type * as agents_external_agent_recover_external_agent_turns from "../agents/external_agent/recover_external_agent_turns.js";
import type * as agents_external_agent_run_external_agent from "../agents/external_agent/run_external_agent.js";
Expand Down Expand Up @@ -1753,6 +1754,7 @@ declare const fullApi: ApiFromModules<{
"agents/chat_turn": typeof agents_chat_turn;
"agents/chat_turn_generate": typeof agents_chat_turn_generate;
"agents/config": typeof agents_config;
"agents/external_agent/attachment_files": typeof agents_external_agent_attachment_files;
"agents/external_agent/continue_external_agent_turn": typeof agents_external_agent_continue_external_agent_turn;
"agents/external_agent/recover_external_agent_turns": typeof agents_external_agent_recover_external_agent_turns;
"agents/external_agent/run_external_agent": typeof agents_external_agent_run_external_agent;
Expand Down
Binary file not shown.
192 changes: 192 additions & 0 deletions services/platform/convex/agents/external_agent/attachment_files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Pure, dependency-free contract for delivering chat attachments to a sandboxed
// external agent (Claude Code / OpenCode).
//
// Three call sites must agree on WHERE the files live: the staging action
// (writes them via sessionStageFiles), the prompt preamble (tells the agent the
// paths), and the adapter's `--add-dir` grant (lets the agent read them).
// They all derive their paths from THIS module so they cannot drift apart
// (mirrors steer_files.ts).
//
// Files are staged OUTSIDE the agent's workspace (/user/workspace) so chat
// uploads never pollute the user's project files; they sit on the same
// persistent /user volume and the agent is granted read access via --add-dir.
// On-disk names are the human file names (sanitized + de-duped per turn), NEVER
// the opaque Convex _storage id — so "summarize report.pdf" resolves naturally.

/** The daemon stages workspace-relative paths under WORKSPACE_ROOT=/user. */
const WORKSPACE_ROOT = '/user';
/** Sub-tree (under /user) that holds chat uploads; granted to the agent via
* `--add-dir /user/uploads`. Outside /user/workspace so it never pollutes the
* user's project files. */
export const UPLOADS_SUBDIR = 'uploads';
/** Absolute staging root inside the container — the path passed to `--add-dir`. */
export const UPLOADS_ABS_ROOT = `${WORKSPACE_ROOT}/${UPLOADS_SUBDIR}`;

/** Per-turn cap on staged attachments — a runaway/abuse backstop, not a product
* limit (a typical chat attaches a handful). Extras are surfaced as skipped in
* the preamble, never silently dropped. */
export const MAX_ATTACHMENTS_PER_TURN = 20;

export interface AttachmentInput {
fileId: string;
fileName: string;
fileType: string;
fileSize: number;
}

export interface PlannedAttachment {
fileId: string;
/** Workspace-relative path handed to sessionStageFiles (daemon resolves it
* under /user → absPath). */
stagePath: string;
/** Absolute container path the agent is told about. */
absPath: string;
/** Human, sanitized, per-turn-unique on-disk name (never a storage id). */
diskName: string;
fileType: string;
}

export interface SkippedAttachment {
name: string;
reason: string;
}

export interface AttachmentStagePlan {
/** Absolute dir holding this turn's uploads (/user/uploads/<promptMessageId>). */
dirAbs: string;
planned: PlannedAttachment[];
/** Attachments dropped before staging (currently: over the per-turn cap). */
skipped: SkippedAttachment[];
}

/** Reduce an arbitrary upload name to one safe path segment: basename only (no
* dir components), no control/null bytes, no traversal. Falls back to `file`
* when nothing usable remains; preserves a sensible extension. */
export function sanitizeAttachmentName(raw: string): string {
// Basename: drop anything up to the last slash/backslash.
const base = raw.split(/[/\\]/).pop() ?? '';
// Drop C0 control chars (incl. NUL — the daemon rejects it outright) + DEL by
// code point, so a name can never break the path or a shell. Filtering by
// code point beats a control-char regex (cleaner, no lint suppression).
const cleaned = Array.from(base)
.filter((ch) => {
const code = ch.codePointAt(0) ?? 0;
return code > 0x1f && code !== 0x7f;
})
.join('')
.replace(/\s+/g, ' ')
.trim();
// Guard against `.` / `..` / empty so the segment is always a real filename.
if (cleaned === '' || cleaned === '.' || cleaned === '..') return 'file';
return cleaned;
}

/** Make `name` unique within `used` by inserting `-N` before the extension. */
function dedupeName(name: string, used: Set<string>): string {
if (!used.has(name)) {
used.add(name);
return name;
}
const dot = name.lastIndexOf('.');
const stem = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let n = 2;
let candidate = `${stem}-${n}${ext}`;
while (used.has(candidate)) {
n += 1;
candidate = `${stem}-${n}${ext}`;
}
used.add(candidate);
return candidate;
}

/** Restrict the per-turn dir segment to a path-safe token (the message id is
* already opaque; this just hardens it). */
function sanitizeSegment(s: string): string {
const cleaned = s.replace(/[^a-zA-Z0-9_-]/g, '_');
return cleaned === '' ? 'turn' : cleaned;
}

/** Plan where each attachment lands. Pure: no I/O — the caller resolves the
* storage URLs and calls sessionStageFiles with `planned[].stagePath`. */
export function buildAttachmentStagePlan(
promptMessageId: string,
attachments: readonly AttachmentInput[],
): AttachmentStagePlan {
const dirSegment = sanitizeSegment(promptMessageId);
const dirRel = `${UPLOADS_SUBDIR}/${dirSegment}`;
const dirAbs = `${UPLOADS_ABS_ROOT}/${dirSegment}`;
const planned: PlannedAttachment[] = [];
const skipped: SkippedAttachment[] = [];
const used = new Set<string>();
attachments.forEach((att, i) => {
if (i >= MAX_ATTACHMENTS_PER_TURN) {
skipped.push({ name: att.fileName, reason: 'too_many' });
return;
}
const diskName = dedupeName(sanitizeAttachmentName(att.fileName), used);
planned.push({
fileId: att.fileId,
stagePath: `${dirRel}/${diskName}`,
absPath: `${dirAbs}/${diskName}`,
diskName,
fileType: att.fileType,
});
});
return { dirAbs, planned, skipped };
}

function reasonText(reason: string): string {
switch (reason) {
case 'too_many':
return `skipped (over the ${MAX_ATTACHMENTS_PER_TURN}-file per-message limit)`;
case 'too_large':
return 'skipped (file too large to stage)';
default:
return `skipped (${reason})`;
}
}

/** Build the message preamble that tells the agent where the uploads are. It is
* prepended to the user's prompt (and becomes the whole prompt when the user
* sent only files). Skipped files are surfaced explicitly so the agent never
* assumes a file it cannot read is present. Returns '' when nothing to say. */
export function buildAttachmentPreamble(
staged: readonly { absPath: string; fileType: string }[],
skipped: readonly SkippedAttachment[],
): string {
if (staged.length === 0 && skipped.length === 0) return '';
const lines: string[] = [];
if (staged.length > 0) {
lines.push(
staged.length === 1
? 'The user attached 1 file to this message. It has been saved to the sandbox filesystem at the absolute path below — read it directly with your file tools (images load as vision):'
: `The user attached ${staged.length} files to this message. They have been saved to the sandbox filesystem at the absolute paths below — read them directly with your file tools (images load as vision):`,
);
for (const f of staged) {
lines.push(`- ${f.absPath} (${f.fileType})`);
}
}
if (skipped.length > 0) {
if (lines.length > 0) lines.push('');
lines.push(
'The following attachment(s) could NOT be delivered — do not assume their contents:',
);
for (const s of skipped) {
lines.push(`- ${s.name} — ${reasonText(s.reason)}`);
}
}
return lines.join('\n');
}

/** Combine the user's text with the attachment preamble. The preamble goes
* AFTER the user's words so their intent stays primary; when there is no text
* (attachment-only message) the preamble stands alone. */
export function composePromptWithAttachments(
rawPrompt: string,
preamble: string,
): string {
if (preamble === '') return rawPrompt;
const text = rawPrompt.trim();
return text === '' ? preamble : `${text}\n\n${preamble}`;
}
Loading
Loading