diff --git a/compose.dev.yml b/compose.dev.yml index ddf724935..f8d268aed 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -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 diff --git a/compose.yml b/compose.yml index fde78047c..b7df90593 100644 --- a/compose.yml +++ b/compose.yml @@ -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 @@ -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 diff --git a/services/platform/convex/_generated/api.d.ts b/services/platform/convex/_generated/api.d.ts index e90eda75c..368d5f6e8 100644 --- a/services/platform/convex/_generated/api.d.ts +++ b/services/platform/convex/_generated/api.d.ts @@ -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"; @@ -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; diff --git a/services/platform/convex/agents/external_agent/attachment_files.test.ts b/services/platform/convex/agents/external_agent/attachment_files.test.ts new file mode 100644 index 000000000..8ad96daa8 Binary files /dev/null and b/services/platform/convex/agents/external_agent/attachment_files.test.ts differ diff --git a/services/platform/convex/agents/external_agent/attachment_files.ts b/services/platform/convex/agents/external_agent/attachment_files.ts new file mode 100644 index 000000000..389394ac9 --- /dev/null +++ b/services/platform/convex/agents/external_agent/attachment_files.ts @@ -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/). */ + 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 { + 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(); + 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}`; +} diff --git a/services/platform/convex/agents/external_agent/run_external_agent.ts b/services/platform/convex/agents/external_agent/run_external_agent.ts index eb9324707..a96a316d8 100644 --- a/services/platform/convex/agents/external_agent/run_external_agent.ts +++ b/services/platform/convex/agents/external_agent/run_external_agent.ts @@ -22,8 +22,11 @@ import { listMessages, saveMessage } from '@convex-dev/agent'; import { v } from 'convex/values'; import { components, internal } from '../../_generated/api'; +import type { ActionCtx } from '../../_generated/server'; import { internalAction } from '../../_generated/server'; import { createDebugLog } from '../../lib/debug_log'; +import { toSandboxStorageUrl } from '../../lib/helpers/public_storage_url'; +import { toId } from '../../lib/type_cast_helpers'; import type { AgentAssistantContent } from '../../node_only/sandbox/agent_message_parts'; import { isRotatableApiError } from '../../node_only/sandbox/agent_run_outcome'; import { @@ -35,6 +38,7 @@ import { sessionEnvPatch, sessionIsAlive, sessionSetPinned, + sessionStageFiles, } from '../../node_only/sandbox/helpers/session_client'; import { stageBrowserControlSkill, @@ -65,6 +69,12 @@ import { SANDBOX_ADMISSION_GLOBAL_BACKOFF_MS, SANDBOX_ADMISSION_POLL_BACKOFF_MS, } from '../../sandbox/sessions_schema'; +import { + UPLOADS_ABS_ROOT, + buildAttachmentPreamble, + buildAttachmentStagePlan, + composePromptWithAttachments, +} from './attachment_files'; import { buildSystemPromptAppend } from './system_prompt'; import { finalizeTurnSideEffects, @@ -176,6 +186,97 @@ function withErrorNote( return [...content, { type: 'text', text: text.trimStart() }]; } +/** + * Stage this turn's chat attachments into the sandbox and return the prompt with + * an absolute-path preamble appended, plus the dirs to grant the agent. The + * heavy bytes never pass through this action: storage mints a presigned URL and + * the in-container daemon fetches it directly (sessionStageFiles, url mode). + * + * Best-effort: a storage miss or a daemon-side skip degrades to a "not + * delivered" line in the preamble (so the agent never assumes a file it cannot + * read), and a transport failure is swallowed — staging must not fail the turn. + */ +async function stageChatAttachments( + ctx: ActionCtx, + opts: { + attachments: ReadonlyArray<{ + fileId: string; + fileName: string; + fileType: string; + fileSize: number; + }>; + promptMessageId: string; + sessionId: string; + spawnerColor: string | null; + rawPrompt: string; + }, +): Promise<{ prompt: string; additionalDirs: string[] }> { + const plan = buildAttachmentStagePlan(opts.promptMessageId, opts.attachments); + const entries: { + path: string; + url: string; + absPath: string; + fileType: string; + diskName: string; + }[] = []; + for (const p of plan.planned) { + const raw = await ctx.storage.getUrl(toId<'_storage'>(p.fileId)); + if (!raw) { + plan.skipped.push({ name: p.diskName, reason: 'not_found' }); + continue; + } + entries.push({ + path: p.stagePath, + url: toSandboxStorageUrl(raw), + absPath: p.absPath, + fileType: p.fileType, + diskName: p.diskName, + }); + } + + const stagedOk: { absPath: string; fileType: string }[] = []; + if (entries.length > 0) { + try { + const result = await sessionStageFiles( + opts.sessionId, + entries.map((e) => ({ path: e.path, url: e.url })), + opts.spawnerColor, + ); + const skippedReason = new Map( + result.skipped.map((s) => [s.path, s.reason]), + ); + for (const e of entries) { + const reason = skippedReason.get(e.path); + if (reason !== undefined) { + plan.skipped.push({ name: e.diskName, reason }); + } else { + stagedOk.push({ absPath: e.absPath, fileType: e.fileType }); + } + } + if (result.skipped.length > 0) { + console.warn( + '[runExternalAgentTurn] some attachments were skipped:', + result.skipped, + ); + } + } catch (err) { + console.warn( + '[runExternalAgentTurn] attachment staging failed (continuing):', + err, + ); + for (const e of entries) { + plan.skipped.push({ name: e.diskName, reason: 'stage_failed' }); + } + } + } + + const preamble = buildAttachmentPreamble(stagedOk, plan.skipped); + return { + prompt: composePromptWithAttachments(opts.rawPrompt, preamble), + additionalDirs: stagedOk.length > 0 ? [UPLOADS_ABS_ROOT] : [], + }; +} + export const runExternalAgentTurn = internalAction({ args: { threadId: v.string(), @@ -210,6 +311,21 @@ export const runExternalAgentTurn = internalAction({ * (which integrations `integration({slug})` may invoke). Enforced * server-side by /api/integrations/execute; defaults to none-granted. */ integrationBindings: v.optional(v.array(v.string())), + /** Chat attachments uploaded with this turn. Staged into the sandbox under + * /user/uploads// and referenced by absolute path in the + * prompt so the agent can read them (the in-process path instead inlines + * images as multimodal parts). Org-ownership of each fileId is already + * verified upstream in start_agent_chat before dispatch. */ + attachments: v.optional( + v.array( + v.object({ + fileId: v.id('_storage'), + fileName: v.string(), + fileType: v.string(), + fileSize: v.number(), + }), + ), + ), organizationId: v.string(), userId: v.optional(v.string()), }, @@ -797,6 +913,30 @@ export const runExternalAgentTurn = internalAction({ }); await stampTurnOpRow(execId); + // Deliver this turn's chat attachments into the sandbox: stage each file + // under /user/uploads// and reference the absolute paths + // in the prompt so the agent reads the real files (images load as vision). + // claude-code only for now — opencode's out-of-cwd file access is a + // follow-up. The in-process agent path instead inlines images as + // multimodal parts; the external agent has no such channel. + let promptForRun = args.rawPrompt; + let attachmentDirs: string[] = []; + if ( + args.agentKind === 'claude-code' && + args.attachments && + args.attachments.length > 0 + ) { + const staged = await stageChatAttachments(ctx, { + attachments: args.attachments, + promptMessageId: args.promptMessageId, + sessionId: liveSessionId, + spawnerColor: liveSpawnerColor, + rawPrompt: args.rawPrompt, + }); + promptForRun = staged.prompt; + attachmentDirs = staged.additionalDirs; + } + // Compose the agent's own instructions with the plan-mode/steering // addendum + trust rules (pure, unit-tested in system_prompt.ts). const systemPromptAppend = buildSystemPromptAppend({ @@ -819,7 +959,8 @@ export const runExternalAgentTurn = internalAction({ ...(liveSpawnerColor !== null && { spawnerColor: liveSpawnerColor }), execId: id, agentSlug: args.agentKind, - prompt: args.rawPrompt, + prompt: promptForRun, + ...(attachmentDirs.length > 0 && { additionalDirs: attachmentDirs }), // Live browser view (operator flag, default off): attach Playwright // MCP over CDP to the session's headed Chromium so it can be mirrored // read-only. Only set when on so the adapter's headless self-launch diff --git a/services/platform/convex/crawler/lib/sandbox_render.ts b/services/platform/convex/crawler/lib/sandbox_render.ts index af487e5d0..d24534d9a 100644 --- a/services/platform/convex/crawler/lib/sandbox_render.ts +++ b/services/platform/convex/crawler/lib/sandbox_render.ts @@ -58,7 +58,10 @@ import type { GenericActionCtx } from 'convex/server'; import type { DataModel, Id } from '../../_generated/dataModel'; -import { toSandboxStorageUrl } from '../../lib/helpers/public_storage_url'; +import { + SANDBOX_CONVEX_STORAGE_BASE_DEFAULT, + toSandboxStorageUrl, +} from '../../lib/helpers/public_storage_url'; import { spawnerExecute } from '../../node_only/sandbox/helpers/spawner_client'; /** @@ -86,8 +89,7 @@ function resolveCallbackEndpoints(): { } { const storageBase = ( process.env.SANDBOX_STORAGE_INTERNAL_BASE_URL ?? - process.env.SITE_URL ?? - 'http://127.0.0.1:3210' + SANDBOX_CONVEX_STORAGE_BASE_DEFAULT ).replace(/\/$/, ''); const httpApiBase = ( process.env.SANDBOX_HTTP_API_BASE_URL ?? diff --git a/services/platform/convex/crawler/lib/sandbox_render_document.test.ts b/services/platform/convex/crawler/lib/sandbox_render_document.test.ts index 71a843c6d..f0fea78f9 100644 --- a/services/platform/convex/crawler/lib/sandbox_render_document.test.ts +++ b/services/platform/convex/crawler/lib/sandbox_render_document.test.ts @@ -10,6 +10,7 @@ vi.mock('../../node_only/sandbox/helpers/spawner_client', () => ({ })); vi.mock('../../lib/helpers/public_storage_url', () => ({ toSandboxStorageUrl: (url: string) => `sandbox:${url}`, + SANDBOX_CONVEX_STORAGE_BASE_DEFAULT: 'http://convex:3210', })); import { diff --git a/services/platform/convex/crawler/lib/sandbox_render_document.ts b/services/platform/convex/crawler/lib/sandbox_render_document.ts index 2fd9bbced..965720d60 100644 --- a/services/platform/convex/crawler/lib/sandbox_render_document.ts +++ b/services/platform/convex/crawler/lib/sandbox_render_document.ts @@ -56,7 +56,10 @@ import type { GenericActionCtx } from 'convex/server'; import type { DataModel, Id } from '../../_generated/dataModel'; -import { toSandboxStorageUrl } from '../../lib/helpers/public_storage_url'; +import { + SANDBOX_CONVEX_STORAGE_BASE_DEFAULT, + toSandboxStorageUrl, +} from '../../lib/helpers/public_storage_url'; import { spawnerExecute } from '../../node_only/sandbox/helpers/spawner_client'; export interface SandboxRenderDocumentContext { @@ -129,8 +132,7 @@ function resolveCallbackEndpoints(): { } { const storageBase = ( process.env.SANDBOX_STORAGE_INTERNAL_BASE_URL ?? - process.env.SITE_URL ?? - 'http://127.0.0.1:3210' + SANDBOX_CONVEX_STORAGE_BASE_DEFAULT ).replace(/\/$/, ''); const httpApiBase = ( process.env.SANDBOX_HTTP_API_BASE_URL ?? diff --git a/services/platform/convex/lib/agent_chat/start_agent_chat.ts b/services/platform/convex/lib/agent_chat/start_agent_chat.ts index 912630bbd..12799a0a3 100644 --- a/services/platform/convex/lib/agent_chat/start_agent_chat.ts +++ b/services/platform/convex/lib/agent_chat/start_agent_chat.ts @@ -670,6 +670,12 @@ export async function startAgentChat( // The agent's integration allowlist becomes the session's dispatch grant // set (scope.integrationGrants), enforced by /api/integrations/execute. integrationBindings: enforcedConfig.integrationBindings ?? [], + // Chat attachments → staged into the sandbox + referenced by path in the + // prompt (run_external_agent). Org-ownership already verified above. + ...(actionAttachments !== undefined && + actionAttachments.length > 0 && { + attachments: actionAttachments, + }), streamId: streamId || undefined, agentSlug: args.agentSlug, organizationId, diff --git a/services/platform/convex/lib/helpers/public_storage_url.test.ts b/services/platform/convex/lib/helpers/public_storage_url.test.ts new file mode 100644 index 000000000..3c13acf4f --- /dev/null +++ b/services/platform/convex/lib/helpers/public_storage_url.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + SANDBOX_CONVEX_HTTP_API_BASE_DEFAULT, + SANDBOX_CONVEX_STORAGE_BASE_DEFAULT, + toSandboxStorageUrl, +} from './public_storage_url'; + +// The session container sits on the `--internal` sandbox net and its undici +// fetch ignores the egress proxy, so the ONLY convex origin it can reach is the +// `convex` alias (dual-homed container in docker/prod; socat relay in bun dev). +// A public SITE_URL / host.docker.internal / 127.0.0.1 / RFC1918 default would +// silently break storage staging in prod — the exact bug this contract guards. +// If you change these, they MUST stay `http://convex:`. +describe('sandbox→convex reachability contract', () => { + it('defaults to the in-sandbox `convex` alias, never a public/host origin', () => { + expect(SANDBOX_CONVEX_STORAGE_BASE_DEFAULT).toBe('http://convex:3210'); + expect(SANDBOX_CONVEX_HTTP_API_BASE_DEFAULT).toBe('http://convex:3211'); + for (const base of [ + SANDBOX_CONVEX_STORAGE_BASE_DEFAULT, + SANDBOX_CONVEX_HTTP_API_BASE_DEFAULT, + ]) { + expect(base).toMatch(/^http:\/\/convex:\d+$/); + // Never an origin that is unreachable from the --internal sandbox net. + expect(base).not.toMatch( + /host\.docker\.internal|127\.0\.0\.1|localhost|192\.168\.|\b10\.|172\.(1[6-9]|2\d|3[01])\./i, + ); + } + }); + + describe('toSandboxStorageUrl', () => { + const KEY = 'SANDBOX_STORAGE_INTERNAL_BASE_URL'; + let saved: string | undefined; + beforeEach(() => { + saved = process.env[KEY]; + delete process.env[KEY]; + }); + afterEach(() => { + if (saved === undefined) delete process.env[KEY]; + else process.env[KEY] = saved; + }); + + it('rewrites an internal storage URL onto the convex alias (env unset)', () => { + expect( + toSandboxStorageUrl('http://127.0.0.1:3210/api/storage/abc?token=xyz'), + ).toBe('http://convex:3210/api/storage/abc?token=xyz'); + }); + + it('does NOT fall back to a public URL when the env is unset', () => { + // Regression: the old fallback was toPublicUrl(SITE_URL), unreachable from + // the sandbox net. The origin must be the convex alias regardless of SITE_URL. + const prevSite = process.env.SITE_URL; + process.env.SITE_URL = 'https://demo.tale.dev'; + try { + expect( + toSandboxStorageUrl('http://127.0.0.1:3210/api/storage/abc'), + ).toBe('http://convex:3210/api/storage/abc'); + } finally { + if (prevSite === undefined) delete process.env.SITE_URL; + else process.env.SITE_URL = prevSite; + } + }); + + it('honors an explicit override', () => { + process.env[KEY] = 'http://my-proxy:9000'; + expect(toSandboxStorageUrl('http://127.0.0.1:3210/api/storage/abc')).toBe( + 'http://my-proxy:9000/api/storage/abc', + ); + }); + + it('is idempotent (an already-rewritten URL passes through)', () => { + expect(toSandboxStorageUrl('http://convex:3210/api/storage/abc')).toBe( + 'http://convex:3210/api/storage/abc', + ); + }); + }); +}); diff --git a/services/platform/convex/lib/helpers/public_storage_url.ts b/services/platform/convex/lib/helpers/public_storage_url.ts index 87ba5eef2..bec1c7709 100644 --- a/services/platform/convex/lib/helpers/public_storage_url.ts +++ b/services/platform/convex/lib/helpers/public_storage_url.ts @@ -111,35 +111,47 @@ export function isStorageUrl(url: string): boolean { } /** - * Rewrite an internal Convex URL so a sandbox spawner container can reach it - * through the Caddy proxy on the internal Docker network. + * The in-sandbox origin of Convex — the single contract for how anything + * running on the sandbox network reaches Convex. * - * Sister function of {@link toPublicUrl}. They differ in audience: - * - `toPublicUrl()` builds the **browser-facing** URL (SITE_URL public host). - * - `toSandboxStorageUrl()` builds the **sandbox-bound** URL using - * `SANDBOX_STORAGE_INTERNAL_BASE_URL` (defaults to the internal proxy - * alias e.g. `http://proxy` in docker compose). Spawner containers can - * fetch / POST through this without going out to the public hostname. + * `convex` is the alias carried on the sandbox network in EVERY topology: + * - prod / `docker compose up`: the Convex container is dual-homed onto the + * sandbox net with the `convex` alias (create-convex-service.ts / compose.yml); + * - `bun dev`: the `convex-relay` socat bridge is aliased `convex` on the + * sandbox net (compose.dev.yml). * - * Falls back to `toPublicUrl()` when `SANDBOX_STORAGE_INTERNAL_BASE_URL` - * isn't set, so local `bun dev` (where the env var may be undefined) keeps - * working — the sandbox is still reachable via the public URL form. + * It is the ONLY Convex origin a session container can reach: that container + * sits on the `--internal` sandbox net (no host route) and its Node `fetch` + * (undici) ignores the egress proxy, so it can only reach hosts DIRECTLY on + * that network. The public SITE_URL is never reachable there — so it must NOT + * be a fallback for sandbox-bound URLs (that was the latent bug that broke + * storage staging in prod). Storage is served on :3210, HTTP actions / + * integrations on :3211. + */ +export const SANDBOX_CONVEX_STORAGE_BASE_DEFAULT = 'http://convex:3210'; +export const SANDBOX_CONVEX_HTTP_API_BASE_DEFAULT = 'http://convex:3211'; + +/** + * Rewrite an internal Convex storage URL to the sandbox-bound form so a session + * container's daemon can fetch it. + * + * Rewrites the origin to `SANDBOX_STORAGE_INTERNAL_BASE_URL` when set (operator + * escape hatch for non-standard topologies), else to the in-sandbox `convex` + * alias ({@link SANDBOX_CONVEX_STORAGE_BASE_DEFAULT}). It deliberately does NOT + * fall back to the public URL ({@link toPublicUrl}) — that host is unreachable + * from the `--internal` sandbox net. * * Idempotent: if the URL already starts with the configured prefix it is * returned unchanged so callers never need to worry about double-rewriting. */ export function toSandboxStorageUrl(internalUrl: string): string { - const base = process.env.SANDBOX_STORAGE_INTERNAL_BASE_URL; - if (!base) { - // Fallback for `bun dev` and any deploy that hasn't set the env yet. - // The public URL is still reachable from the spawner (it just round- - // trips through Caddy's public listener instead of the internal one). - return toPublicUrl(internalUrl); - } - const prefix = base.replace(/\/$/, ''); - if (internalUrl.startsWith(prefix)) return internalUrl; + const base = ( + process.env.SANDBOX_STORAGE_INTERNAL_BASE_URL ?? + SANDBOX_CONVEX_STORAGE_BASE_DEFAULT + ).replace(/\/$/, ''); + if (internalUrl.startsWith(base)) return internalUrl; const originMatch = internalUrl.match(/^https?:\/\/[^/]+/); if (!originMatch) return internalUrl; const path = internalUrl.slice(originMatch[0].length); - return `${prefix}${path}`; + return `${base}${path}`; } diff --git a/services/platform/convex/node_only/sandbox/internal_actions.ts b/services/platform/convex/node_only/sandbox/internal_actions.ts index f73666f7c..88348e749 100644 --- a/services/platform/convex/node_only/sandbox/internal_actions.ts +++ b/services/platform/convex/node_only/sandbox/internal_actions.ts @@ -22,7 +22,10 @@ import { ConvexError, v } from 'convex/values'; import { internal } from '../../_generated/api'; import type { Id } from '../../_generated/dataModel'; import { internalAction, type ActionCtx } from '../../_generated/server'; -import { toSandboxStorageUrl } from '../../lib/helpers/public_storage_url'; +import { + SANDBOX_CONVEX_STORAGE_BASE_DEFAULT, + toSandboxStorageUrl, +} from '../../lib/helpers/public_storage_url'; import { reserveOneshotTicketArg } from '../../sandbox/admission'; import { SANDBOX_DEFAULT_TIMEOUT_MS, @@ -353,8 +356,7 @@ export const executeCode = internalAction({ const storageBase = ( process.env.SANDBOX_STORAGE_INTERNAL_BASE_URL ?? - process.env.SITE_URL ?? - 'http://127.0.0.1:3210' + SANDBOX_CONVEX_STORAGE_BASE_DEFAULT ).replace(/\/$/, ''); const httpApiBase = ( process.env.SANDBOX_HTTP_API_BASE_URL ?? diff --git a/services/platform/convex/node_only/sandbox/run_agent.ts b/services/platform/convex/node_only/sandbox/run_agent.ts index f2a8ea4d4..beff9da65 100644 --- a/services/platform/convex/node_only/sandbox/run_agent.ts +++ b/services/platform/convex/node_only/sandbox/run_agent.ts @@ -196,6 +196,9 @@ export interface RunAgentInSessionArgs { /** Platform base URL for the integration-dispatch bridge (/api/integrations). */ integrationsBaseUrl?: string; workdir?: string; + /** Absolute dirs outside `workdir` the agent must read (e.g. /user/uploads + * for chat attachments). Threaded to the adapter as `--add-dir` grants. */ + additionalDirs?: string[]; timeoutMs?: number; /** Per-flush durable persistence hook. Called on the same throttle as the * live op flush with the timeline-so-far as AI-SDK assistant content, so the @@ -393,6 +396,10 @@ export async function runAgentInSessionImpl( ...(args.integrationsBaseUrl !== undefined && { integrationsBaseUrl: args.integrationsBaseUrl, }), + ...(args.additionalDirs !== undefined && + args.additionalDirs.length > 0 && { + additionalDirs: args.additionalDirs, + }), workdir: args.workdir ?? '/user/workspace', // Mid-turn steering: keys the per-exec TALE_STEER_DIR the platform // stages queued user messages into (claude_code adapter only). diff --git a/services/platform/lib/agent-adapters/build-exec.test.ts b/services/platform/lib/agent-adapters/build-exec.test.ts index 267fcc4e8..67be8c672 100644 --- a/services/platform/lib/agent-adapters/build-exec.test.ts +++ b/services/platform/lib/agent-adapters/build-exec.test.ts @@ -92,6 +92,22 @@ describe('ClaudeCodeAdapter.buildExec', () => { expect(cwd).toBe('/user/workspace'); }); + it('grants out-of-cwd dirs via --add-dir (chat attachment staging)', () => { + const { argv } = new ClaudeCodeAdapter().buildExec({ + ...base, + additionalDirs: ['/user/uploads'], + }); + const i = argv.indexOf('--add-dir'); + expect(i).toBeGreaterThan(-1); + expect(argv[i + 1]).toBe('/user/uploads'); + }); + + it('omits --add-dir when no additional dirs are requested', () => { + expect(new ClaudeCodeAdapter().buildExec(base).argv).not.toContain( + '--add-dir', + ); + }); + it('adds the integration MCP bridge (with URL + session key) when integrationsBaseUrl is set', () => { const { argv } = new ClaudeCodeAdapter().buildExec({ ...base, diff --git a/services/platform/lib/agent-adapters/claude-code/adapter.ts b/services/platform/lib/agent-adapters/claude-code/adapter.ts index aae1dcdc3..f124c937d 100644 --- a/services/platform/lib/agent-adapters/claude-code/adapter.ts +++ b/services/platform/lib/agent-adapters/claude-code/adapter.ts @@ -95,6 +95,13 @@ export class ClaudeCodeAdapter implements AgentAdapter { '--max-turns', String(spec.maxTurns ?? DEFAULT_MAX_TURNS), ]; + // Grant read/edit access to directories OUTSIDE cwd (e.g. the chat-upload + // staging dir /user/uploads). Claude Code's file tools are scoped to the + // working dir even under bypassPermissions, so a staged attachment at an + // absolute path outside /user/workspace is unreadable without this. + for (const dir of spec.additionalDirs ?? []) { + argv.push('--add-dir', dir); + } if (spec.agentSessionId) argv.push('--resume', spec.agentSessionId); if (spec.model) argv.push('--model', spec.model); if (spec.systemPromptAppend) { diff --git a/services/platform/lib/agent-adapters/types.ts b/services/platform/lib/agent-adapters/types.ts index 428be5372..ac22b2318 100644 --- a/services/platform/lib/agent-adapters/types.ts +++ b/services/platform/lib/agent-adapters/types.ts @@ -55,6 +55,11 @@ export interface AgentRunSpec { integrationsBaseUrl?: string; /** Working directory inside the session (e.g. /user/workspace). */ workdir: string; + /** Absolute directories OUTSIDE `workdir` the agent must be able to read/edit + * — e.g. the chat-upload staging dir /user/uploads. Claude Code scopes its + * file tools to cwd by default (even under bypassPermissions), so each entry + * is granted via `--add-dir`. Adapters without an equivalent ignore it. */ + additionalDirs?: string[]; /** Enable the in-container Playwright MCP server. Default true for the * agent profile; entry points pass false for headless/no-browser tasks to * save the per-turn tool-definition token overhead. */ diff --git a/services/platform/scripts/dev-engine.ts b/services/platform/scripts/dev-engine.ts index 6c19f54a7..8f8b234cf 100644 --- a/services/platform/scripts/dev-engine.ts +++ b/services/platform/scripts/dev-engine.ts @@ -166,27 +166,21 @@ function envNormalizeCommon() { process.env.SITE_URL = `http://${host}${host === 'localhost' ? `:${port}` : ''}`; } - // Sandbox-wobbly-origami plan §4: the spawner runs inside docker (compose) - // while Convex runs on the host in `bun dev` mode, so storage URLs the - // action sends to the spawner must use a hostname that resolves to the - // host from inside the container. `host.docker.internal` is the standard - // cross-platform alias (Docker Desktop ships it; Linux Docker requires - // `extra_hosts: ["host.docker.internal:host-gateway"]` which compose.dev.yml - // already sets on the sandbox service). + // Sandbox → Convex reachability. The URLs the platform hands the sandbox are + // fetched by the SESSION CONTAINER's daemon (not the spawner), which sits on + // the `--internal` sandbox net and whose undici fetch ignores the egress + // proxy — so it can only reach Convex via the `convex` alias carried on that + // network. In `bun dev` Convex runs on the host, so the `convex-relay` socat + // (compose.dev.yml) bridges :3210/:3211 → host Convex. (`host.docker.internal` + // resolves for the spawner but NOT for session containers, which is why it + // never worked for storage staging — see SANDBOX_CONVEX_STORAGE_BASE_DEFAULT.) // - // Override in `services/platform/.env.local` only if your network stack - // breaks the default — e.g. a VPN/proxy (singbox-tun, tailscale, ...) that - // hijacks RFC1918 traffic and blocks docker-bridge → host. In that case - // set the host's LAN IP: - // - // SANDBOX_STORAGE_INTERNAL_BASE_URL=http://192.168.x.y:3210 - // SANDBOX_HTTP_API_BASE_URL=http://192.168.x.y:3211 + // Override in `services/platform/.env.local` only for a non-standard topology. if (!process.env.SANDBOX_STORAGE_INTERNAL_BASE_URL) { - process.env.SANDBOX_STORAGE_INTERNAL_BASE_URL = - 'http://host.docker.internal:3210'; + process.env.SANDBOX_STORAGE_INTERNAL_BASE_URL = 'http://convex:3210'; } if (!process.env.SANDBOX_HTTP_API_BASE_URL) { - process.env.SANDBOX_HTTP_API_BASE_URL = 'http://host.docker.internal:3211'; + process.env.SANDBOX_HTTP_API_BASE_URL = 'http://convex:3211'; } // Writable per-org config ROOT (org-first: `///`). diff --git a/tools/cli/src/lib/compose/services/compose-parity.test.ts b/tools/cli/src/lib/compose/services/compose-parity.test.ts new file mode 100644 index 000000000..e6ee0e6c5 --- /dev/null +++ b/tools/cli/src/lib/compose/services/compose-parity.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { parse } from 'yaml'; + +import { setProjectId } from '../../project/project-context'; +import type { ServiceConfig } from '../types'; +import { createConvexService } from './create-convex-service'; + +// Guards the class of "works in dev, silently broken in `tale deploy`" bugs: +// config that lives in one pipeline but not the other. `compose.yml` (the +// `docker compose up` base) and the CLI generators (`tale deploy`) are two +// hand-maintained sources of truth; this asserts they agree on the load-bearing, +// safety-critical dimensions so drift fails CI instead of shipping. +// +// Documented past drifts this locks down: NET_ADMIN silently dropped (R1.17), +// uncapped egress proxy (R2-B11), and `stop_grace_period` missing from +// compose.yml (10s default → SIGKILL of in-flight HTTP/SSE + sandbox execs). + +setProjectId('test-project'); +const config = { + version: '0.0.0-test', + registry: 'ghcr.io/tale-project', +} satisfies ServiceConfig; + +const composePath = fileURLToPath( + new URL('../../../../../../compose.yml', import.meta.url), +); +const compose = parse(readFileSync(composePath, 'utf8')) as { + services: Record< + string, + { networks?: unknown; cap_add?: string[]; stop_grace_period?: string } + >; +}; + +function networkNames(networks: unknown): string[] { + if (Array.isArray(networks)) return networks as string[]; + if (networks && typeof networks === 'object') return Object.keys(networks); + return []; +} + +function graceSeconds(value: string | undefined): number { + if (!value) return 0; + const match = /^(\d+)s$/.exec(value); + return match ? Number(match[1]) : 0; +} + +describe('sandbox→convex reachability parity', () => { + test('CLI generator dual-homes convex onto the sandbox net with the `convex` alias', () => { + const networks = createConvexService(config).networks; + if (Array.isArray(networks) || networks === undefined) { + throw new Error('convex networks should be the object form with aliases'); + } + expect(networks.internal).toBeDefined(); + expect(networks.sandbox).toBeDefined(); + // container_name is `-convex`, so the explicit alias is what makes + // http://convex:3210 reachable from the session container. + expect(networks.sandbox?.aliases).toContain('convex'); + }); + + test('compose.yml keeps convex on the sandbox network (reachable as `convex`)', () => { + // compose.yml's service is literally named `convex`, so service-name + // resolution covers the alias — only membership must be asserted. + expect(networkNames(compose.services.convex?.networks)).toContain( + 'sandbox', + ); + }); +}); + +describe('SSRF egress-firewall cap parity (NET_ADMIN — R1.17 guard)', () => { + test('CLI generator keeps NET_ADMIN on convex', () => { + expect(createConvexService(config).cap_add).toContain('NET_ADMIN'); + }); + + test('compose.yml keeps NET_ADMIN on convex', () => { + expect(compose.services.convex?.cap_add).toContain('NET_ADMIN'); + }); + + test('compose.yml keeps NET_ADMIN on the sandbox-egress proxy', () => { + expect(compose.services['sandbox-egress']?.cap_add).toContain('NET_ADMIN'); + }); +}); + +describe('graceful-shutdown parity — compose.yml meets the floor', () => { + // The CLI side is floor-tested in generate-color-compose.test.ts (>=41s). This + // guards the OTHER pipeline: compose.yml must not regress to Docker's 10s + // default, which SIGKILLs in-flight HTTP/SSE chat streams + sandbox execs on + // `docker compose up`. + test('platform drains streams before SIGKILL (mirrors CLI 45s)', () => { + expect( + graceSeconds(compose.services.platform?.stop_grace_period), + ).toBeGreaterThanOrEqual(45); + }); + + test('sandbox spawner drains executions before SIGKILL (mirrors CLI 30s)', () => { + expect( + graceSeconds(compose.services.sandbox?.stop_grace_period), + ).toBeGreaterThanOrEqual(30); + }); +}); diff --git a/tools/cli/src/lib/config/ensure-env.ts b/tools/cli/src/lib/config/ensure-env.ts index 1663805ca..b112ba75b 100644 --- a/tools/cli/src/lib/config/ensure-env.ts +++ b/tools/cli/src/lib/config/ensure-env.ts @@ -636,6 +636,15 @@ function generateEnvContent(config: EnvConfig): string { '# with this; the spawner rejects unsigned/wrong-signed requests. Rotate', '# by setting a new value and restarting both `platform` and `sandbox`.', `SANDBOX_TOKEN=${config.sandboxToken}`, + '# Live browser view (read-only mirror in the chat UI). Default OFF. When set', + '# to 1, the spawner launches session containers with a headed Chromium +', + '# x11vnc mirror (TALE_BROWSER_CDP) and the platform attaches Playwright MCP', + "# over CDP, so the agent's browser is streamed read-only into the web page.", + '# ONE value drives BOTH sides: the sandbox spawner reads it directly and the', + '# platform entrypoint pushes it to Convex (run_external_agent gates on it),', + '# so they stay in lockstep. Off = the agent still uses a headless browser,', + '# just with no live preview.', + '# SANDBOX_BROWSER_VIEW=1', '', '# ============================================================================', '# Audit Log Signing (security / compliance)',