diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index eb26c83f..a2bd1000 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -65,12 +65,15 @@ import type { TurnSteerResponse, CommandExecutionRequestApprovalParams, CommandExecutionRequestApprovalResponse, + DynamicToolCallParams, + DynamicToolCallResponse, FileChangeRequestApprovalParams, FileChangeRequestApprovalResponse, PermissionsRequestApprovalParams, PermissionsRequestApprovalResponse, ItemCompletedNotification, } from "./app-server/v2"; +import {handleDynamicToolCall} from "./WorkspaceDependencies"; export interface ApprovalHandler { handleCommandExecution(params: CommandExecutionRequestApprovalParams): Promise; @@ -124,6 +127,12 @@ const ToolRequestUserInputRequest = new RequestType< void >('item/tool/requestUserInput'); +const DynamicToolCallRequest = new RequestType< + DynamicToolCallParams, + DynamicToolCallResponse, + void +>('item/tool/call'); + const GOAL_RUNTIME_EFFECTS_GRACE_MS = 1_000; /** @@ -246,6 +255,10 @@ export class CodexAppServerClient { } return await handler.handleUserInput(params); }); + + this.connection.onRequest(DynamicToolCallRequest, async (params) => { + return handleDynamicToolCall(params); + }); } onApprovalRequest(threadId: string, handler: ApprovalHandler): void { diff --git a/src/WorkspaceDependencies.ts b/src/WorkspaceDependencies.ts new file mode 100644 index 00000000..9669eed4 --- /dev/null +++ b/src/WorkspaceDependencies.ts @@ -0,0 +1,226 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type {DynamicToolCallParams, DynamicToolCallResponse} from "./app-server/v2"; + +export const LOAD_WORKSPACE_DEPENDENCIES_TOOL_NAME = "load_workspace_dependencies"; + +interface RuntimeMetadata { + bundleFormatVersion?: number; + bundleVersion?: string; + nativeDependencies?: Array; + pnpmVersion?: string; +} + +export interface WorkspaceDependencyOptions { + platform?: NodeJS.Platform; + runtimeRoot?: string; +} + +interface WorkspaceDependencyPaths { + fallbackBinPath: string; + gitPath: string | null; + nodeModulesPath: string; + nodePath: string; + overrideBinPath: string; + pnpmPath: string | null; + pythonLibrariesPath: string; + pythonPath: string; +} + +export function handleDynamicToolCall( + params: DynamicToolCallParams, + options: WorkspaceDependencyOptions = {}, +): DynamicToolCallResponse { + if (params.namespace !== null || params.tool !== LOAD_WORKSPACE_DEPENDENCIES_TOOL_NAME) { + return failure(`Unsupported dynamic tool: ${params.tool}`); + } + if (!isEmptyObject(params.arguments)) { + return failure(`${LOAD_WORKSPACE_DEPENDENCIES_TOOL_NAME} takes no arguments.`); + } + + try { + return loadWorkspaceDependencies(options); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return failure(`Failed to load workspace dependency runtime details: ${message}`); + } +} + +export function loadWorkspaceDependencies( + options: WorkspaceDependencyOptions = {}, +): DynamicToolCallResponse { + const platform = options.platform ?? process.platform; + const runtimeRoot = options.runtimeRoot ?? defaultRuntimeRoot(); + const metadata = readRuntimeMetadata(runtimeRoot); + const paths = resolveWorkspaceDependencyPaths(runtimeRoot, metadata, platform); + validateWorkspaceDependencyPaths(paths); + + return { + contentItems: [{ + type: "inputText", + text: [ + "Workspace dependencies are available for this local thread.", + formatInstructions(paths, metadata.bundleVersion), + ].join("\n\n"), + }], + success: true, + }; +} + +function defaultRuntimeRoot(): string { + return path.join(os.homedir(), ".cache", "codex-runtimes", "codex-primary-runtime"); +} + +function readRuntimeMetadata(runtimeRoot: string): RuntimeMetadata { + const metadataPath = path.join(runtimeRoot, "runtime.json"); + const value: unknown = JSON.parse(fs.readFileSync(metadataPath, "utf8")); + if (!isObject(value)) { + throw new Error(`Invalid workspace dependency metadata at ${metadataPath}`); + } + const bundleFormatVersion = value["bundleFormatVersion"]; + const bundleVersion = value["bundleVersion"]; + const nativeDependencies = value["nativeDependencies"]; + const pnpmVersion = value["pnpmVersion"]; + return { + ...(typeof bundleFormatVersion === "number" + ? {bundleFormatVersion} + : {}), + ...(typeof bundleVersion === "string" ? {bundleVersion} : {}), + ...(Array.isArray(nativeDependencies) + ? {nativeDependencies: nativeDependencies.filter(item => typeof item === "string")} + : {}), + ...(typeof pnpmVersion === "string" ? {pnpmVersion} : {}), + }; +} + +function resolveWorkspaceDependencyPaths( + runtimeRoot: string, + metadata: RuntimeMetadata, + platform: NodeJS.Platform, +): WorkspaceDependencyPaths { + const bundleFormatVersion = metadata.bundleFormatVersion ?? 1; + const dependenciesRoot = path.join(runtimeRoot, "dependencies"); + const binPath = path.join(dependenciesRoot, "bin"); + const fallbackBinPath = path.join(binPath, "fallback"); + const overrideBinPath = path.join(binPath, "override"); + const nodeRoot = bundleFormatVersion >= 2 + ? path.join(dependenciesRoot, "node") + : runtimeRoot; + const pythonRoot = bundleFormatVersion >= 2 + ? path.join(dependenciesRoot, "python") + : path.join(runtimeRoot, "python"); + const windows = platform === "win32"; + const nodePath = path.join(nodeRoot, "bin", windows ? "node.exe" : "node"); + const pythonPath = firstAccessible(windows + ? [ + path.join(pythonRoot, "python.exe"), + path.join(pythonRoot, "python", "python.exe"), + path.join(pythonRoot, "bin", "python.exe"), + ] + : [ + path.join(pythonRoot, "bin", "python3"), + path.join(pythonRoot, "bin", "python"), + ]); + const pnpmName = windows ? "pnpm.cmd" : "pnpm"; + const pnpmPath = metadata.pnpmVersion == null + ? null + : firstAccessible([ + path.join(fallbackBinPath, pnpmName), + path.join(binPath, pnpmName), + ]); + const gitPath = metadata.nativeDependencies?.includes("git") === true + ? firstAccessible(windows + ? [path.join(dependenciesRoot, "native", "git", "cmd", "git.exe")] + : [path.join(fallbackBinPath, "git"), path.join(binPath, "git")]) + : null; + + return { + fallbackBinPath, + gitPath, + nodeModulesPath: path.join(nodeRoot, "node_modules"), + nodePath, + overrideBinPath, + pnpmPath, + pythonLibrariesPath: pythonPackageRoot(pythonPath), + pythonPath, + }; +} + +function firstAccessible(candidates: Array): string { + const candidate = candidates.find(value => { + try { + fs.accessSync(value); + return true; + } catch { + return false; + } + }); + return candidate ?? candidates[0] ?? ""; +} + +function pythonPackageRoot(pythonPath: string): string { + const parent = path.dirname(pythonPath); + return path.basename(parent) === "bin" ? path.dirname(parent) : parent; +} + +function validateWorkspaceDependencyPaths(paths: WorkspaceDependencyPaths): void { + for (const executable of [paths.gitPath, paths.nodePath, paths.pnpmPath, paths.pythonPath]) { + if (executable != null) { + fs.accessSync(executable, fs.constants.X_OK); + } + } + for (const directory of [ + paths.nodeModulesPath, + paths.pythonLibrariesPath, + paths.overrideBinPath, + paths.fallbackBinPath, + ]) { + if (!fs.statSync(directory).isDirectory()) { + throw new Error(`Expected a directory at ${directory}`); + } + } +} + +function formatInstructions(paths: WorkspaceDependencyPaths, bundleVersion?: string): string { + const lines = [ + "### Workspace Dependencies", + "Use these bundled paths for sheets, slides, documents, PDFs, images, or browser automation:", + ]; + if (bundleVersion != null) { + lines.push(`- Bundle version: ${quote(bundleVersion)}`); + } + if (paths.gitPath != null) { + lines.push(`- Git executable: ${quote(paths.gitPath)}`); + } + lines.push(`- Node.js executable: ${quote(paths.nodePath)}`); + lines.push(`- Node.js packages: ${quote(paths.nodeModulesPath)}`); + if (paths.pnpmPath != null) { + lines.push(`- pnpm executable: ${quote(paths.pnpmPath)}`); + } + lines.push(`- Python executable: ${quote(paths.pythonPath)}`); + lines.push(`- Python packages: ${quote(paths.pythonLibrariesPath)}`); + lines.push(`- Override binaries: ${quote(paths.overrideBinPath)}`); + lines.push(`- Fallback binaries: ${quote(paths.fallbackBinPath)}`); + return lines.join("\n"); +} + +function quote(value: string): string { + return `\`${value.replaceAll("`", "\\`")}\``; +} + +function failure(message: string): DynamicToolCallResponse { + return { + contentItems: [{type: "inputText", text: message}], + success: false, + }; +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isEmptyObject(value: unknown): boolean { + return isObject(value) && Object.keys(value).length === 0; +} diff --git a/src/__tests__/CodexACPAgent/data/workspace-dependencies-success.json b/src/__tests__/CodexACPAgent/data/workspace-dependencies-success.json new file mode 100644 index 00000000..29761952 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/workspace-dependencies-success.json @@ -0,0 +1,9 @@ +{ + "contentItems": [ + { + "type": "inputText", + "text": "Workspace dependencies are available for this local thread.\n\n### Workspace Dependencies\nUse these bundled paths for sheets, slides, documents, PDFs, images, or browser automation:\n- Bundle version: `test-bundle`\n- Git executable: `/dependencies/bin/fallback/git`\n- Node.js executable: `/dependencies/node/bin/node`\n- Node.js packages: `/dependencies/node/node_modules`\n- pnpm executable: `/dependencies/bin/fallback/pnpm`\n- Python executable: `/dependencies/python/bin/python3`\n- Python packages: `/dependencies/python`\n- Override binaries: `/dependencies/bin/override`\n- Fallback binaries: `/dependencies/bin/fallback`" + } + ], + "success": true +} diff --git a/src/__tests__/CodexACPAgent/workspace-dependencies.test.ts b/src/__tests__/CodexACPAgent/workspace-dependencies.test.ts new file mode 100644 index 00000000..1f248f68 --- /dev/null +++ b/src/__tests__/CodexACPAgent/workspace-dependencies.test.ts @@ -0,0 +1,151 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import {afterEach, describe, expect, it, vi} from "vitest"; +import type {MessageConnection} from "vscode-jsonrpc/node"; + +import {CodexAppServerClient} from "../../CodexAppServerClient"; +import type {DynamicToolCallParams, DynamicToolCallResponse} from "../../app-server/v2"; +import { + handleDynamicToolCall, + LOAD_WORKSPACE_DEPENDENCIES_TOOL_NAME, +} from "../../WorkspaceDependencies"; + +describe("workspace dependencies", () => { + const runtimeRoots: Array = []; + + afterEach(() => { + vi.restoreAllMocks(); + for (const runtimeRoot of runtimeRoots.splice(0)) { + fs.rmSync(runtimeRoot, {recursive: true, force: true}); + } + }); + + it("returns validated bundled runtime paths", async () => { + const runtimeRoot = createRuntime("darwin"); + + const response = handleDynamicToolCall(dynamicToolParams(), {runtimeRoot, platform: "darwin"}); + const snapshot = `${JSON.stringify(response, null, 2).replaceAll(runtimeRoot, "")}\n`; + + await expect(snapshot).toMatchFileSnapshot("data/workspace-dependencies-success.json"); + }); + + it("resolves Windows executable names and bundled Git", () => { + const runtimeRoot = createRuntime("win32"); + + const response = handleDynamicToolCall(dynamicToolParams(), {runtimeRoot, platform: "win32"}); + const text = response.contentItems[0]?.type === "inputText" + ? response.contentItems[0].text + : ""; + + expect(response.success).toBe(true); + expect(text).toContain(path.join("dependencies", "node", "bin", "node.exe")); + expect(text).toContain(path.join("dependencies", "python", "python.exe")); + expect(text).toContain(path.join("dependencies", "bin", "fallback", "pnpm.cmd")); + expect(text).toContain(path.join("dependencies", "native", "git", "cmd", "git.exe")); + }); + + it("returns a useful failure when a required runtime executable is missing", () => { + const runtimeRoot = createRuntime("darwin"); + fs.unlinkSync(path.join(runtimeRoot, "dependencies", "node", "bin", "node")); + + const response = handleDynamicToolCall(dynamicToolParams(), {runtimeRoot, platform: "darwin"}); + + expect(response).toEqual({ + contentItems: [{ + type: "inputText", + text: expect.stringContaining("Failed to load workspace dependency runtime details"), + }], + success: false, + }); + }); + + it("rejects unsupported tools and arguments", () => { + expect(handleDynamicToolCall(dynamicToolParams({tool: "unknown_tool"}))).toEqual({ + contentItems: [{type: "inputText", text: "Unsupported dynamic tool: unknown_tool"}], + success: false, + }); + expect(handleDynamicToolCall(dynamicToolParams({arguments: {unexpected: true}}))).toEqual({ + contentItems: [{ + type: "inputText", + text: `${LOAD_WORKSPACE_DEPENDENCIES_TOOL_NAME} takes no arguments.`, + }], + success: false, + }); + }); + + it("registers the app-server dynamic tool request handler", async () => { + const requestHandlers = new Map Promise>(); + const connection = { + onUnhandledNotification: vi.fn(), + onRequest: vi.fn((requestType: {method: string}, handler: (params: DynamicToolCallParams) => Promise) => { + requestHandlers.set(requestType.method, handler); + }), + } as unknown as MessageConnection; + new CodexAppServerClient(connection); + + const handler = requestHandlers.get("item/tool/call"); + + expect(handler).toBeDefined(); + await expect(handler?.(dynamicToolParams({tool: "unknown_tool"}))).resolves.toEqual({ + contentItems: [{type: "inputText", text: "Unsupported dynamic tool: unknown_tool"}], + success: false, + }); + }); + + function createRuntime(platform: NodeJS.Platform): string { + const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-acp-workspace-dependencies-")); + runtimeRoots.push(runtimeRoot); + const dependenciesRoot = path.join(runtimeRoot, "dependencies"); + const fallbackBinPath = path.join(dependenciesRoot, "bin", "fallback"); + const overrideBinPath = path.join(dependenciesRoot, "bin", "override"); + const nodeRoot = path.join(dependenciesRoot, "node"); + const pythonRoot = path.join(dependenciesRoot, "python"); + for (const directory of [ + fallbackBinPath, + overrideBinPath, + path.join(nodeRoot, "bin"), + path.join(nodeRoot, "node_modules"), + path.join(pythonRoot, "bin"), + ]) { + fs.mkdirSync(directory, {recursive: true}); + } + fs.writeFileSync(path.join(runtimeRoot, "runtime.json"), JSON.stringify({ + bundleFormatVersion: 2, + bundleVersion: "test-bundle", + nativeDependencies: ["git"], + pnpmVersion: "test-pnpm", + })); + if (platform === "win32") { + writeExecutable(path.join(nodeRoot, "bin", "node.exe")); + writeExecutable(path.join(pythonRoot, "python.exe")); + writeExecutable(path.join(fallbackBinPath, "pnpm.cmd")); + writeExecutable(path.join(dependenciesRoot, "native", "git", "cmd", "git.exe")); + } else { + writeExecutable(path.join(nodeRoot, "bin", "node")); + writeExecutable(path.join(pythonRoot, "bin", "python3")); + writeExecutable(path.join(fallbackBinPath, "pnpm")); + writeExecutable(path.join(fallbackBinPath, "git")); + } + return runtimeRoot; + } +}); + +function dynamicToolParams(overrides: Partial = {}): DynamicToolCallParams { + return { + threadId: "thread-id", + turnId: "turn-id", + callId: "call-id", + namespace: null, + tool: LOAD_WORKSPACE_DEPENDENCIES_TOOL_NAME, + arguments: {}, + ...overrides, + }; +} + +function writeExecutable(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + fs.writeFileSync(filePath, "test executable"); + fs.chmodSync(filePath, 0o755); +}