diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index d548e5c65d1..351f71b51d0 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -9,6 +9,7 @@ import { externalNativeAddons, ORCAD_EXTERNAL_MODULES, ORCAD_CHILD_ENTRY_POINTS, + ORCAD_COMMONJS_MODULE_OPTIONS, orcadChildOutputFilename } from './orcad-entry-build.mjs' import { createRequire } from 'node:module' @@ -52,6 +53,8 @@ import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs' import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = process.env.ORCAD_OUT_DIR @@ -99,6 +102,7 @@ async function stageParcelWatcher(target) { const wrapperSource = requireFromWatcher.resolve('@parcel/watcher/wrapper.js') mkdirSync(WATCHER_MODULE_DIR, { recursive: true }) await build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, stdin: { contents: `const {createWrapper}=require(${JSON.stringify(wrapperSource)});` + @@ -197,6 +201,7 @@ cpSync(join(ROOT, 'resources', 'native-chat-visuals'), join(OUT_DIR, 'native-cha * their runtime resolvers look for them. */ function buildForkedChild(entryPoint, outfile) { return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [entryPoint], bundle: true, platform: 'node', @@ -209,6 +214,7 @@ function buildForkedChild(entryPoint, outfile) { minify: true, sourcemap: false, define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, 'process.env.NODE_ENV': '"production"' }, logLevel: 'error' @@ -312,6 +318,17 @@ if (graphErrors.length > 0) { ) process.exitCode = 1 } + const providerSmoke = runProcessSync({ + program: process.execPath, + args: [SERVER_OUT_FILE, '--orcad-structured-provider-load-check'], + timeoutMs: 60_000 + }) + if (providerSmoke.code !== 0 || providerSmoke.timedOut || providerSmoke.outputTruncated) { + console.error( + `[build-orcad] the packaged structured provider failed to load.\n${describeProcessFailure(providerSmoke)}` + ) + process.exitCode = 1 + } // Why require + parseArgs and not a real daemon: requiring the bundle evaluates every // top-level import, and calling its exported argv parser proves the entry's own code is // there rather than a graph that merely resolved. Booting one would need a socket, a diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index 1f0de9a11b2..7b636937b3e 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -25,6 +25,15 @@ export function orcadChildOutputFilename(entryPoint) { export const ORCAD_EXTERNAL_MODULES = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] +// Bundled ESM resolves from the deployed bundle, like CommonJS __filename. +export const ORCAD_COMMONJS_MODULE_OPTIONS = { + banner: { + js: 'var __orcadModuleUrl = require("node:url").pathToFileURL(__filename).href;' + }, + define: { 'import.meta.url': '__orcadModuleUrl' }, + logOverride: { 'empty-import-meta': 'error' } +} + // Native binaries are staged separately from every JavaScript entry. export const externalNativeAddons = { name: 'external-native-addons', @@ -45,6 +54,7 @@ const jsoncParserEsm = { export function buildOrcadEntry(outfile) { return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [join(root, ORCAD_ENTRY_POINT)], bundle: true, platform: 'node', @@ -56,7 +66,10 @@ export function buildOrcadEntry(outfile) { metafile: true, minify: true, sourcemap: false, - define: { 'process.env.NODE_ENV': '"production"' }, + define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, + 'process.env.NODE_ENV': '"production"' + }, logLevel: 'error' }) } @@ -81,6 +94,7 @@ export function buildOrcadLauncher(outfile) { .update(readFileSync(join(dirname(outfile), ORCAD_SERVER_ENTRY_FILENAME))) .digest('hex') return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [join(root, ORCAD_LAUNCHER_ENTRY_POINT)], bundle: true, platform: 'node', @@ -90,7 +104,10 @@ export function buildOrcadLauncher(outfile) { metafile: true, minify: true, sourcemap: false, - define: { ORCAD_SERVER_SHA256: JSON.stringify(serverSha256) }, + define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, + ORCAD_SERVER_SHA256: JSON.stringify(serverSha256) + }, logLevel: 'error' }) } diff --git a/config/scripts/orcad-entry-build.test.mjs b/config/scripts/orcad-entry-build.test.mjs new file mode 100644 index 00000000000..b367aba66ad --- /dev/null +++ b/config/scripts/orcad-entry-build.test.mjs @@ -0,0 +1,79 @@ +import { build } from 'esbuild' +import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, expect, it } from 'vitest' +import { buildOrcadEntry, ORCAD_COMMONJS_MODULE_OPTIONS } from './orcad-entry-build.mjs' +import { runProcessSync } from '@orca/process-host' + +const directory = mkdtempSync(join(tmpdir(), 'orca-structured-provider-build-')) +const bundle = join(directory, 'orcad-server.js') + +beforeAll(async () => { + await buildOrcadEntry(bundle) +}, 60_000) + +afterAll(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +it('loads the structured Claude SDK through the packaged server under plain Node', () => { + const loaded = runProcessSync({ + program: process.execPath, + args: [bundle, '--orcad-structured-provider-load-check'], + cwd: directory, + env: { ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30_000 + }) + expect(loaded.code, loaded.stderr.slice(-2_000)).toBe(0) +}, 40_000) + +it('resolves bundle-relative requires after deployment to a path with URL-special characters', async () => { + const buildDirectory = join(directory, 'build') + mkdirSync(buildDirectory) + const outfile = join(buildDirectory, 'entry.cjs') + await build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, + stdin: { + contents: `import { createRequire } from 'node:module'; + console.log(createRequire(import.meta.url)('./asset.cjs'));`, + resolveDir: directory + }, + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile, + minify: true, + logLevel: 'error' + }) + writeFileSync(join(buildDirectory, 'asset.cjs'), 'module.exports = "deployed asset"') + const deployedDirectory = join(directory, 'deployed # 100%') + renameSync(buildDirectory, deployedDirectory) + const loaded = runProcessSync({ + program: process.execPath, + args: [join(deployedDirectory, 'entry.cjs')], + cwd: directory, + env: { ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30_000 + }) + expect(loaded.code, loaded.stderr).toBe(0) + expect(loaded.stdout.trim()).toBe('deployed asset') +}) + +it.each(['import.meta', 'import.meta.dirname', 'import.meta.filename', 'import.meta.resolve'])( + 'rejects unsupported %s instead of silently replacing it with an empty object', + async (expression) => { + await expect( + build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, + stdin: { contents: `console.log(${expression})`, resolveDir: directory }, + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent' + }) + ).rejects.toThrow('"import.meta" is not available') + } +) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 30e21462a8f..ca28cea4174 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -42,7 +42,12 @@ function failStartup(error: unknown): void { } // One-shot launch and stop commands must skip server startup and preflights. -if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { +if (process.argv[2] === '--orcad-structured-provider-load-check') { + void import('./orcad-structured-provider-smoke') + .then(({ smokeLoadOrcadStructuredProviders }) => smokeLoadOrcadStructuredProviders()) + .then(() => process.exit(0)) + .catch(failStartup) +} else if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { runWindowsBreakawayLaunchIfRequested(ORCAD_WINDOWS_BREAKAWAY_CONTRACT, process.argv) } else if ( process.argv[2] === ORCAD_COMPLETE_MANAGED_STOP_FLAG || diff --git a/src/main/orcad/orcad-structured-provider-smoke.ts b/src/main/orcad/orcad-structured-provider-smoke.ts new file mode 100644 index 00000000000..7e64c62f38a --- /dev/null +++ b/src/main/orcad/orcad-structured-provider-smoke.ts @@ -0,0 +1,22 @@ +import { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection' + +export async function smokeLoadOrcadStructuredProviders(): Promise { + const loaded = new Error('structured provider module loaded') + try { + await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'unused', cwd: process.cwd(), options: {} }, + {}, + undefined, + () => { + // The production connection loads the SDK before calling the query implementation. + throw loaded + } + ) + } catch (error) { + if (error === loaded) { + return + } + throw error + } + throw new Error('structured provider load check did not reach the SDK boundary') +}