diff --git a/src/core/codex-adapter.ts b/src/core/codex-adapter.ts index f498996..29e3a23 100644 --- a/src/core/codex-adapter.ts +++ b/src/core/codex-adapter.ts @@ -24,13 +24,13 @@ import { scanTarget, type PreparedScanPlan } from './scan-plan.ts'; import { userMessage } from './errors.ts'; import { savedScan } from './partial-results.ts'; import type { Finding, ScanState } from '../types.js'; +import { loadSdk } from './sdk.ts'; export type SecurityClient = Pick; export type SecurityFactory = ( config: CodexSecurityConfig, ) => SecurityClient | Promise; -const realClient: SecurityFactory = async (config) => - new (await import('@openai/codex-security')).CodexSecurity(config); +const realClient: SecurityFactory = async (config) => new (await loadSdk()).CodexSecurity(config); function initial(repository: string, revision = 0, instanceId = ''): ScanState { return { telemetry: emptyTelemetry(), diff --git a/src/core/scan-plan.ts b/src/core/scan-plan.ts index 275506a..aefec1b 100644 --- a/src/core/scan-plan.ts +++ b/src/core/scan-plan.ts @@ -12,6 +12,7 @@ import type { import { codexHome, loadSettings } from './settings.ts'; import { SecurityInputError } from './errors.ts'; import { storedState } from './sign-in.ts'; +import { loadSdk } from './sdk.ts'; export interface ScanInputs { mode?: 'standard' | 'deep'; @@ -97,7 +98,7 @@ function parseInputs(value: unknown): ScanInputs { /** The SDK target for a plan: the whole repository, paths, or a diff. */ export async function scanTarget(input: Pick) { if (input.changes) { - const { DiffTarget } = await import('@openai/codex-security'); + const { DiffTarget } = await loadSdk(); return input.changes === 'uncommitted' ? DiffTarget.workingTree() : DiffTarget.refs({ base: input.changes }); @@ -141,7 +142,7 @@ export async function withSdk( codexOverrides: JsonObject, use: (client: CodexSecurity, sdk: typeof import('@openai/codex-security')) => Promise, ): Promise { - const sdk = await import('@openai/codex-security'); + const sdk = await loadSdk(); const client = new sdk.CodexSecurity({ codexOverrides }); try { return await use(client, sdk); diff --git a/src/core/sdk.ts b/src/core/sdk.ts new file mode 100644 index 0000000..b495f4f --- /dev/null +++ b/src/core/sdk.ts @@ -0,0 +1,28 @@ +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +let ready: Promise | undefined; + +/** + * The SDK, with its bundled helper module already loaded. + * + * On load the helper compares `process.argv[1]` with its own path to see whether it is the main + * script. Inside OpenCode's compiled binary that argument is a virtual path that exists but + * cannot be resolved, so the load throws, and the SDK reports it as a scan that "completed + * without required artifacts". Loading the helper once with the argument blanked avoids that; + * later imports reuse the loaded module. + */ +export function loadSdk() { + return (ready ??= (async () => { + const sdk = await import('@openai/codex-security'); + const helper = pathToFileURL(join(await sdk.bundledPluginRoot(), 'mcp/helpers.mjs')).href; + const invoked = process.argv[1]; + process.argv[1] = ''; + try { + await import(helper); + } finally { + process.argv[1] = invoked!; + } + return sdk; + })()); +} diff --git a/src/core/security-operations.ts b/src/core/security-operations.ts index 31da381..a6d84e6 100644 --- a/src/core/security-operations.ts +++ b/src/core/security-operations.ts @@ -23,6 +23,7 @@ import { type PatchWorkspace, type PatchWorkspaceFactory, } from './patch-workspace.ts'; +import { loadSdk } from './sdk.ts'; export type SecurityOperationKind = 'policy' | 'validation' | 'fix'; export type SecurityOperationStatus = @@ -131,7 +132,7 @@ type Entry = { }; }; const realClient: SecurityOperationsFactory = async (config) => - new (await import('@openai/codex-security')).CodexSecurity(config); + new (await loadSdk()).CodexSecurity(config); const active = (state: SecurityOperationState) => state.status === 'running' || state.status === 'awaiting_answers'; const textLimit = 48_000; @@ -346,7 +347,7 @@ export class SecurityOperationsService { // Computed in Node with no model call. The SDK refuses if SECURITY.md changed since the // draft read it; the draft has been paid for, so return it without a diff instead. const diff = replacesExisting - ? await import('@openai/codex-security') + ? await loadSdk() .then((sdk) => sdk.securityPolicyDiff(result, undefined, signal)) .catch(() => undefined) : undefined; diff --git a/src/core/sign-in.ts b/src/core/sign-in.ts index 558c0d0..dcc12cc 100644 --- a/src/core/sign-in.ts +++ b/src/core/sign-in.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import type { CodexSecurity, CodexSecurityConfig } from '@openai/codex-security'; import { userMessage } from './errors.ts'; import { codexHome, loadSettings, stateDirectory } from './settings.ts'; +import { loadSdk } from './sdk.ts'; /** * Whether Codex Security has a ChatGPT sign-in of its own. Only relevant when the route uses @@ -41,8 +42,7 @@ export class SignIn { #pending?: { url: string; code?: string; cancel(): void }; #error?: string; constructor( - createClient: LoginFactory = async (config) => - new (await import('@openai/codex-security')).CodexSecurity(config), + createClient: LoginFactory = async (config) => new (await loadSdk()).CodexSecurity(config), ) { this.#factory = createClient; } diff --git a/tests/sdk.test.mjs b/tests/sdk.test.mjs new file mode 100644 index 0000000..4c2fcc6 --- /dev/null +++ b/tests/sdk.test.mjs @@ -0,0 +1,17 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadSdk } from '../src/core/sdk.ts'; + +test('the SDK helper loads even when the host reports an unresolvable main script', async () => { + const invoked = process.argv[1]; + // What OpenCode's compiled binary reports: a path that cannot be resolved. + process.argv[1] = '/$bunfs/root/opencode'; + try { + const sdk = await loadSdk(); + assert.equal(typeof sdk.CodexSecurity, 'function'); + assert.equal(process.argv[1], '/$bunfs/root/opencode'); + assert.equal(await loadSdk(), sdk); + } finally { + process.argv[1] = invoked; + } +});