From 0c23e7b62d7422d9c906a24a2b8b6ef7928be3a4 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Wed, 24 Jun 2026 21:20:49 +0800 Subject: [PATCH 1/2] feat(sandbox): deliver chat attachments to sandboxed external agents Chat attachments were dropped at the external-agent dispatch fork, so the sandboxed Claude Code / OpenCode agent never received uploaded files. Stage each attachment into the session container at /user/uploads//, reference the absolute paths in the prompt, and grant read access via --add-dir so the agent can read (and, for images, see) the real files. Fixing the fetch surfaced a latent cross-environment gap: the session daemon's undici fetch ignores the egress proxy, so it can only reach Convex via the `convex` alias on the sandbox net, but SANDBOX_STORAGE_INTERNAL_BASE_URL fell back to the public SITE_URL (unreachable from the --internal net). Unify the contract on http://convex (storage :3210, http-api :3211): correct by default in prod/docker (convex is dual-homed) and bun dev (the convex-relay socat now bridges :3210 too). Also thread SANDBOX_BROWSER_VIEW through the CLI deploy path so the live browser preview can be enabled in production, and add anti-drift guards: restore the missing stop_grace_period on compose.yml platform/sandbox (10s SIGKILL of in-flight SSE/exec on docker compose up) to match the CLI, and lock compose.yml<->CLI-generator parity (reachability, NET_ADMIN, grace floors) plus the sandbox->convex reachability contract with tests. --- compose.dev.yml | 20 +- compose.yml | 8 + services/platform/convex/_generated/api.d.ts | 2 + .../external_agent/attachment_files.test.ts | Bin 0 -> 5032 bytes .../agents/external_agent/attachment_files.ts | 192 ++++++++++++++++++ .../external_agent/run_external_agent.ts | 143 ++++++++++++- .../convex/crawler/lib/sandbox_render.ts | 8 +- .../crawler/lib/sandbox_render_document.ts | 8 +- .../convex/lib/agent_chat/start_agent_chat.ts | 6 + .../lib/helpers/public_storage_url.test.ts | 77 +++++++ .../convex/lib/helpers/public_storage_url.ts | 54 +++-- .../node_only/sandbox/internal_actions.ts | 8 +- .../convex/node_only/sandbox/run_agent.ts | 7 + .../lib/agent-adapters/build-exec.test.ts | 16 ++ .../lib/agent-adapters/claude-code/adapter.ts | 7 + services/platform/lib/agent-adapters/types.ts | 5 + services/platform/scripts/dev-engine.ts | 28 +-- .../compose/services/compose-parity.test.ts | 101 +++++++++ tools/cli/src/lib/config/ensure-env.ts | 9 + 19 files changed, 644 insertions(+), 55 deletions(-) create mode 100644 services/platform/convex/agents/external_agent/attachment_files.test.ts create mode 100644 services/platform/convex/agents/external_agent/attachment_files.ts create mode 100644 services/platform/convex/lib/helpers/public_storage_url.test.ts create mode 100644 tools/cli/src/lib/compose/services/compose-parity.test.ts diff --git a/compose.dev.yml b/compose.dev.yml index ddf724935c..f8d268aedb 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 fde78047c5..b7df90593e 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 e90eda75c7..368d5f6e84 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 0000000000000000000000000000000000000000..8ad96daa8534916026fff4eca792156cb12d0412 GIT binary patch literal 5032 zcmcIoO>f&q5bdpif5jXEkVa*yiF6LxqpY~W9OHCr`PKoz5954(mU^WkI(wOi?fp!o?Uc5oOXUX?|0sw z_s>pGd%R|6sicmzBAiU6vhP)zTV5aLaT3+*-Asz>jLJLv!4lNtzK;&byBWJXJVsV5Ga5WdC)(;DRpZ8^9z2Cd@f7B^ zS0A0jq62#QYX67gq#y_wC4X7G-dXJIEaT{Vn;qb_$ls;IHx6KIeeXuf)R3Sw5wRi# zRP-Hkz)EG{hn4MA=7zudXy3~uXH$?yBX4&%u==fBU3<+icj0D2b^)QyV%#0Y84^xs zvm&Mn$zfW=xMDRXt&$npiKL-0lHolKQV_K9CvDhHg)w(g4Q%JbYEz``o{JPm=!rOL z>-k&{`SBG237+@X^ImIjx~!4NX(kcUTWKg)ai}ASoHiiT%<<`o?)c}+pMU@UWtBwD zwPk4B{Waa!WVF)Cm7HMKew{l;Q(eZ$|VixC%&K-pbMasR%dftGk$7pd$k7y7KTIEN3K=2227uB;r zpuc|m14uF{i{wVaqk^oGlf`X&-u3(a0?E3@G_>*Z`|G7_vGQcraJ%ep)ULs@6(|$R zX1VLciJTxbTn+P!GNSV75d4I_MN)&NE}L6ug}EK%CXT%h3qe<~;3kGJZ7wJ%NHa)v zC(Fbv;Mv2UQ{p83;K6wV?dm=)(AM@bvv9gSz=gr+#XMecyEUk|uu>*CaUZW{2sa~K z)5eCYIlCKW=%Ep5!vUpN)(+TzcxS*xdglAaG0X%Qn+z)W;o%{9R_i_kt=bk2qdSTN z=z{oQI}T8d~Zi}oK|T{$);#{>z!Pya9P zi=qHD9bZq%O_$GITZ{Nj^05p)$0-mMEWsL6BoWzI)=}ExvQXy^cW>DTJXGLu8#nOuu^&8EeBC6g6JBvgsr#7()dr)ZVuCv2;%SE7i9?4S#qTaF zM^%mQW0(8hL?(&Gi{LA#GmrhnQe_Kh#jv;~RZ&qlmKTy3VUl$vf_X+visyhxw7$W7 zfrDb6xV-M!aRsMjwb$HprLGk3X%Mp^G}V+;!%VnO). */ + 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 eb93247077..a96a316d8a 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 af487e5d00..d24534d9a3 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.ts b/services/platform/convex/crawler/lib/sandbox_render_document.ts index 2fd9bbced3..965720d600 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 912630bbd5..12799a0a33 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 0000000000..3c13acf4fe --- /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 87ba5eef22..bec1c77090 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 f73666f7c6..88348e7492 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 f2a8ea4d4b..beff9da65f 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 267fcc4e89..67be8c6725 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 aae1dcdc31..f124c937dd 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 428be53728..ac22b23184 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 6c19f54a79..8f8b234cfd 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 0000000000..e6ee0e6c58 --- /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 1663805cac..b112ba75be 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)', From f973d6fe9db2dcfeb95e7df8646a15b0baaebe44 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Wed, 24 Jun 2026 21:32:18 +0800 Subject: [PATCH 2/2] test(sandbox): add SANDBOX_CONVEX_STORAGE_BASE_DEFAULT to render-document mock sandbox_render_document.ts now imports the new export from public_storage_url; the test's vi.mock replaced the whole module, so the missing export made vitest throw. Provide it in the mock. --- .../platform/convex/crawler/lib/sandbox_render_document.test.ts | 1 + 1 file changed, 1 insertion(+) 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 71a843c6db..f0fea78f95 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 {