diff --git a/Dockerfile b/Dockerfile index 24920f3e72..bb2f33e9f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,18 @@ COPY scripts/postinstall.sh scripts/ RUN bun install --frozen-lockfile && \ touch node_modules/.installed +# Archive all externalized packages and their transitive deps (including installed +# optional deps such as platform-specific native binaries) into a single tarball. +# The runtime stage extracts this in one step; the script auto-derives the full +# closure so the Dockerfile never needs updating when transitive deps change. +COPY scripts/collect-runtime-deps.js scripts/ +RUN node scripts/collect-runtime-deps.js /tmp/runtime-deps.tar.gz \ + @lydell/node-pty node-pty \ + ssh2 \ + sharp \ + @1password/sdk @1password/sdk-core \ + jsdom + # Copy build orchestration files used by Make targets. COPY Makefile fmt.mk ./ @@ -89,22 +101,13 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends git openssh-client ca-certificates && \ rm -rf /var/lib/apt/lists/* -# Copy runtime dependencies first so app-code changes don't invalidate these layers. -# - @lydell/node-pty: native module for terminal support -# - ssh2 + deps: externalized to avoid .node addon bundling issues -COPY --from=builder /app/node_modules/@lydell ./node_modules/@lydell -COPY --from=builder /app/node_modules/ssh2 ./node_modules/ssh2 -COPY --from=builder /app/node_modules/asn1 ./node_modules/asn1 -COPY --from=builder /app/node_modules/safer-buffer ./node_modules/safer-buffer -COPY --from=builder /app/node_modules/bcrypt-pbkdf ./node_modules/bcrypt-pbkdf -COPY --from=builder /app/node_modules/tweetnacl ./node_modules/tweetnacl -# - sharp + runtime deps: externalized for attach_file raster resizing in bundled server mode -COPY --from=builder /app/node_modules/sharp ./node_modules/sharp -COPY --from=builder /app/node_modules/@img ./node_modules/@img -COPY --from=builder /app/node_modules/detect-libc ./node_modules/detect-libc -COPY --from=builder /app/node_modules/semver ./node_modules/semver -# - @1password/sdk + sdk-core: externalized; contains native WASM for secret resolution -COPY --from=builder /app/node_modules/@1password ./node_modules/@1password +# Extract all externalized runtime packages (node-pty, ssh2, sharp, @1password, jsdom) +# and their full transitive dependency closures in one step. The tarball is built by +# scripts/collect-runtime-deps.js in the builder stage and self-maintains as deps change. +COPY --from=builder /tmp/runtime-deps.tar.gz /tmp/ +RUN mkdir -p node_modules && \ + tar xzf /tmp/runtime-deps.tar.gz -C node_modules && \ + rm /tmp/runtime-deps.tar.gz # Copy frontend/static assets from least to most volatile for better cache reuse. # Vite outputs JS/CSS/HTML directly to dist/ (assetsDir: "."). diff --git a/Makefile b/Makefile index 3e7b40ad59..b2db7014ad 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,7 @@ ESBUILD_CLI_FLAGS := --bundle --format=esm --platform=node --target=node20 --out # Common esbuild flags for server runtime Docker bundle. # Place runtime bundles under dist/runtime so frontend dist/*.js layers remain stable. # External native modules (node-pty, ssh2) and electron remain runtime dependencies. -ESBUILD_SERVER_FLAGS := --bundle --platform=node --target=node22 --format=cjs --outfile=dist/runtime/server-bundle.js --external:@lydell/node-pty --external:node-pty --external:electron --external:ssh2 --external:@1password/sdk --external:@1password/sdk-core --alias:jsonc-parser=jsonc-parser/lib/esm/main.js --minify +ESBUILD_SERVER_FLAGS := --bundle --platform=node --target=node22 --format=cjs --outfile=dist/runtime/server-bundle.js --external:@lydell/node-pty --external:node-pty --external:electron --external:ssh2 --external:@1password/sdk --external:@1password/sdk-core --external:jsdom --alias:jsonc-parser=jsonc-parser/lib/esm/main.js --minify # Common esbuild flags for tokenizer worker bundle used by server-bundle runtime. ESBUILD_TOKENIZER_WORKER_FLAGS := --bundle --platform=node --target=node22 --format=cjs --outfile=dist/runtime/tokenizer.worker.js --minify diff --git a/scripts/collect-runtime-deps.js b/scripts/collect-runtime-deps.js new file mode 100644 index 0000000000..b9c08aa8e3 --- /dev/null +++ b/scripts/collect-runtime-deps.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// Collect externalized npm packages and their transitive dependencies into a +// single tarball for use in the Docker runtime stage. +// +// Usage: node scripts/collect-runtime-deps.js [pkg2 ...] +// +// Walks both `dependencies` and `optionalDependencies`. Optional packages that +// are not installed (e.g. wrong-platform sharp binaries) are skipped silently. +// Produces a tarball whose entries are relative to node_modules/, so it can be +// extracted directly into any node_modules/ directory. +"use strict"; + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const nodeModules = path.resolve(__dirname, "..", "node_modules"); +const [, , outFile, ...roots] = process.argv; + +if (!outFile || roots.length === 0) { + process.stderr.write( + "Usage: collect-runtime-deps.js [pkg2 ...]\n" + ); + process.exit(1); +} + +const collected = new Set(); + +function collect(pkgName) { + if (collected.has(pkgName)) return; + if (!fs.existsSync(path.join(nodeModules, pkgName))) return; // optional dep not installed + collected.add(pkgName); + + let pkg; + try { + pkg = JSON.parse( + fs.readFileSync(path.join(nodeModules, pkgName, "package.json"), "utf8") + ); + } catch { + return; + } + + for (const dep of Object.keys(pkg.dependencies ?? {})) collect(dep); + for (const dep of Object.keys(pkg.optionalDependencies ?? {})) collect(dep); +} + +for (const root of roots) { + collect(root); +} + +const packages = [...collected].sort(); +process.stdout.write( + `Archiving ${packages.length} packages → ${outFile}\n` +); + +const result = spawnSync("tar", ["czf", outFile, ...packages], { + cwd: nodeModules, + stdio: "inherit", +}); +process.exit(result.status ?? 1); diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index d11cc20e75..b3bb78a172 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -44,6 +44,7 @@ import { createMuxConfigReadTool } from "@/node/services/tools/mux_config_read"; import { createMuxConfigWriteTool } from "@/node/services/tools/mux_config_write"; import { createAgentReportTool } from "@/node/services/tools/agent_report"; import { createSwitchAgentTool } from "@/node/services/tools/switch_agent"; +import { createWebFetchTool } from "@/node/services/tools/web_fetch"; import { wrapWithInitWait } from "@/node/services/tools/wrapWithInitWait"; import { withHooks, type HookConfig } from "@/node/services/tools/withHooks"; import { log } from "@/node/services/log"; @@ -411,10 +412,6 @@ export async function getToolsForModel( const wrap = (tool: Tool) => wrapWithInitWait(tool, workspaceId, initStateManager); - // Lazy-load web_fetch to avoid loading jsdom (ESM-only) at Jest setup time - // This allows integration tests to run without transforming jsdom's dependencies - const { createWebFetchTool } = await import("@/node/services/tools/web_fetch"); - // Runtime-dependent tools need to wait for workspace initialization // Wrap them to handle init waiting centrally instead of in each tool const runtimeTools: Record = { diff --git a/src/node/services/workspaceTitleGenerator.ts b/src/node/services/workspaceTitleGenerator.ts index 0cea63887d..f88dcb50e1 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -225,9 +225,21 @@ export async function generateWorkspaceIdentity( // which the StreamManager enforces via stopWhen for full agent sessions. // For this direct streamText path, the candidate retry loop handles the // (rare) case where the model ignores the instruction. + // 15 s deadline per candidate — prevents indefinite hangs when a custom + // OpenAI-compatible provider stalls or doesn't support tool calls and + // never closes the stream. + // + // toolChoice "required": name generation is a structured extraction task, + // not an open-ended conversation. Without it, small or thinking-mode models + // (e.g. Qwen3 via LiteLLM) often reply in plain text and never call the + // tool. The original omission was to preserve compatibility with extended- + // thinking models in the main chat stream, but this call is separate and + // does not use thinking — forcing the tool call is safe here. const currentStream = streamText({ model: modelResult.data, prompt: buildWorkspaceIdentityPrompt(message, conversationContext, latestUserMessage), + abortSignal: AbortSignal.timeout(15_000), + toolChoice: "required", tools: { // Defined inline so TypeScript preserves full schema inference on // toolResult.output (the propose_name tool is only used here).