From a77dc85abc34b1142981f22b20850733ee57becd Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 11 May 2026 13:36:06 -0700 Subject: [PATCH] feat(app-server) install runtime Co-authored-by: Codex --- codex-rs/Cargo.lock | 2 + .../schema/json/ClientRequest.json | 100 ++ .../codex_app_server_protocol.schemas.json | 172 +++ .../codex_app_server_protocol.v2.schemas.json | 172 +++ .../schema/json/v2/RuntimeInstallParams.json | 80 + .../json/v2/RuntimeInstallResponse.json | 76 + .../schema/typescript/ClientRequest.ts | 3 +- .../typescript/v2/RuntimeInstallManifest.ts | 5 + .../typescript/v2/RuntimeInstallParams.ts | 6 + .../typescript/v2/RuntimeInstallPaths.ts | 6 + .../typescript/v2/RuntimeInstallResponse.ts | 7 + .../typescript/v2/RuntimeInstallStatus.ts | 5 + .../schema/typescript/v2/index.ts | 5 + .../src/protocol/common.rs | 5 + .../src/protocol/v2/mod.rs | 2 + .../src/protocol/v2/runtime.rs | 58 + codex-rs/app-server/src/message_processor.rs | 22 + codex-rs/exec-server/Cargo.toml | 4 +- codex-rs/exec-server/src/client.rs | 10 + codex-rs/exec-server/src/environment.rs | 54 +- codex-rs/exec-server/src/lib.rs | 2 + codex-rs/exec-server/src/protocol.rs | 1 + codex-rs/exec-server/src/rpc.rs | 12 +- codex-rs/exec-server/src/runtime_install.rs | 1283 +++++++++++++++++ codex-rs/exec-server/src/server/handler.rs | 10 + codex-rs/exec-server/src/server/registry.rs | 8 + 26 files changed, 2101 insertions(+), 9 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/RuntimeInstallParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/RuntimeInstallResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallManifest.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallPaths.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallStatus.ts create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/runtime.rs create mode 100644 codex-rs/exec-server/src/runtime_install.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d15a1062d9..1631edf256 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2739,6 +2739,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "tempfile", "test-case", "thiserror 2.0.18", @@ -2749,6 +2750,7 @@ dependencies = [ "tracing", "uuid", "wiremock", + "zip 2.4.2", ] [[package]] diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6351993046..692fed252b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3043,6 +3043,82 @@ } ] }, + "RuntimeInstallManifest": { + "properties": { + "archiveName": { + "type": [ + "string", + "null" + ] + }, + "archiveSha256": { + "type": "string" + }, + "archiveSizeBytes": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "archiveUrl": { + "type": "string" + }, + "bundleFormatVersion": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "bundleVersion": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "runtimeRootDirectoryName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "archiveSha256", + "archiveUrl" + ], + "type": "object" + }, + "RuntimeInstallParams": { + "properties": { + "environmentId": { + "type": [ + "string", + "null" + ] + }, + "manifest": { + "$ref": "#/definitions/RuntimeInstallManifest" + }, + "release": { + "type": "string" + } + }, + "required": [ + "manifest", + "release" + ], + "type": "object" + }, "SandboxMode": { "enum": [ "read-only", @@ -5387,6 +5463,30 @@ "title": "Plugin/installRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "runtime/install" + ], + "title": "Runtime/installRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/RuntimeInstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Runtime/installRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 215842d929..73876c281c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1189,6 +1189,30 @@ "title": "Plugin/installRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "runtime/install" + ], + "title": "Runtime/installRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/RuntimeInstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Runtime/installRequest", + "type": "object" + }, { "properties": { "id": { @@ -14300,6 +14324,154 @@ } ] }, + "RuntimeInstallManifest": { + "properties": { + "archiveName": { + "type": [ + "string", + "null" + ] + }, + "archiveSha256": { + "type": "string" + }, + "archiveSizeBytes": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "archiveUrl": { + "type": "string" + }, + "bundleFormatVersion": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "bundleVersion": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "runtimeRootDirectoryName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "archiveSha256", + "archiveUrl" + ], + "type": "object" + }, + "RuntimeInstallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "environmentId": { + "type": [ + "string", + "null" + ] + }, + "manifest": { + "$ref": "#/definitions/v2/RuntimeInstallManifest" + }, + "release": { + "type": "string" + } + }, + "required": [ + "manifest", + "release" + ], + "title": "RuntimeInstallParams", + "type": "object" + }, + "RuntimeInstallPaths": { + "properties": { + "bundledPluginMarketplacePaths": { + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" + }, + "bundledSkillPaths": { + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" + }, + "nodeModulesPath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "nodePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "pythonPath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "skillsToRemove": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "bundledPluginMarketplacePaths", + "bundledSkillPaths", + "nodeModulesPath", + "nodePath", + "pythonPath", + "skillsToRemove" + ], + "type": "object" + }, + "RuntimeInstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "bundleVersion": { + "type": [ + "string", + "null" + ] + }, + "paths": { + "$ref": "#/definitions/v2/RuntimeInstallPaths" + }, + "status": { + "$ref": "#/definitions/v2/RuntimeInstallStatus" + } + }, + "required": [ + "paths", + "status" + ], + "title": "RuntimeInstallResponse", + "type": "object" + }, + "RuntimeInstallStatus": { + "enum": [ + "already-current", + "installed" + ], + "type": "string" + }, "SandboxMode": { "enum": [ "read-only", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 085744e819..ec27e87949 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1929,6 +1929,30 @@ "title": "Plugin/installRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "runtime/install" + ], + "title": "Runtime/installRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/RuntimeInstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Runtime/installRequest", + "type": "object" + }, { "properties": { "id": { @@ -10849,6 +10873,154 @@ } ] }, + "RuntimeInstallManifest": { + "properties": { + "archiveName": { + "type": [ + "string", + "null" + ] + }, + "archiveSha256": { + "type": "string" + }, + "archiveSizeBytes": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "archiveUrl": { + "type": "string" + }, + "bundleFormatVersion": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "bundleVersion": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "runtimeRootDirectoryName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "archiveSha256", + "archiveUrl" + ], + "type": "object" + }, + "RuntimeInstallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "environmentId": { + "type": [ + "string", + "null" + ] + }, + "manifest": { + "$ref": "#/definitions/RuntimeInstallManifest" + }, + "release": { + "type": "string" + } + }, + "required": [ + "manifest", + "release" + ], + "title": "RuntimeInstallParams", + "type": "object" + }, + "RuntimeInstallPaths": { + "properties": { + "bundledPluginMarketplacePaths": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "bundledSkillPaths": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "nodeModulesPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "nodePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "pythonPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "skillsToRemove": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "bundledPluginMarketplacePaths", + "bundledSkillPaths", + "nodeModulesPath", + "nodePath", + "pythonPath", + "skillsToRemove" + ], + "type": "object" + }, + "RuntimeInstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "bundleVersion": { + "type": [ + "string", + "null" + ] + }, + "paths": { + "$ref": "#/definitions/RuntimeInstallPaths" + }, + "status": { + "$ref": "#/definitions/RuntimeInstallStatus" + } + }, + "required": [ + "paths", + "status" + ], + "title": "RuntimeInstallResponse", + "type": "object" + }, + "RuntimeInstallStatus": { + "enum": [ + "already-current", + "installed" + ], + "type": "string" + }, "SandboxMode": { "enum": [ "read-only", diff --git a/codex-rs/app-server-protocol/schema/json/v2/RuntimeInstallParams.json b/codex-rs/app-server-protocol/schema/json/v2/RuntimeInstallParams.json new file mode 100644 index 0000000000..975795f178 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/RuntimeInstallParams.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "RuntimeInstallManifest": { + "properties": { + "archiveName": { + "type": [ + "string", + "null" + ] + }, + "archiveSha256": { + "type": "string" + }, + "archiveSizeBytes": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "archiveUrl": { + "type": "string" + }, + "bundleFormatVersion": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "bundleVersion": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "runtimeRootDirectoryName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "archiveSha256", + "archiveUrl" + ], + "type": "object" + } + }, + "properties": { + "environmentId": { + "type": [ + "string", + "null" + ] + }, + "manifest": { + "$ref": "#/definitions/RuntimeInstallManifest" + }, + "release": { + "type": "string" + } + }, + "required": [ + "manifest", + "release" + ], + "title": "RuntimeInstallParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/RuntimeInstallResponse.json b/codex-rs/app-server-protocol/schema/json/v2/RuntimeInstallResponse.json new file mode 100644 index 0000000000..26649bbc33 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/RuntimeInstallResponse.json @@ -0,0 +1,76 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "RuntimeInstallPaths": { + "properties": { + "bundledPluginMarketplacePaths": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "bundledSkillPaths": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "nodeModulesPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "nodePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "pythonPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "skillsToRemove": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "bundledPluginMarketplacePaths", + "bundledSkillPaths", + "nodeModulesPath", + "nodePath", + "pythonPath", + "skillsToRemove" + ], + "type": "object" + }, + "RuntimeInstallStatus": { + "enum": [ + "already-current", + "installed" + ], + "type": "string" + } + }, + "properties": { + "bundleVersion": { + "type": [ + "string", + "null" + ] + }, + "paths": { + "$ref": "#/definitions/RuntimeInstallPaths" + }, + "status": { + "$ref": "#/definitions/RuntimeInstallStatus" + } + }, + "required": [ + "paths", + "status" + ], + "title": "RuntimeInstallResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index a12185b501..64f6296ea3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -52,6 +52,7 @@ import type { PluginShareUpdateTargetsParams } from "./v2/PluginShareUpdateTarge import type { PluginSkillReadParams } from "./v2/PluginSkillReadParams"; import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; +import type { RuntimeInstallParams } from "./v2/RuntimeInstallParams"; import type { SendAddCreditsNudgeEmailParams } from "./v2/SendAddCreditsNudgeEmailParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; @@ -79,4 +80,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "runtime/install", id: RequestId, params: RuntimeInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallManifest.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallManifest.ts new file mode 100644 index 0000000000..7bf37ad8c5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallManifest.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RuntimeInstallManifest = { archiveName: string | null, archiveSha256: string, archiveSizeBytes: bigint | null, archiveUrl: string, bundleFormatVersion: number | null, bundleVersion: string | null, format: string | null, runtimeRootDirectoryName: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallParams.ts new file mode 100644 index 0000000000..d3386dea72 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RuntimeInstallManifest } from "./RuntimeInstallManifest"; + +export type RuntimeInstallParams = { environmentId?: string | null, manifest: RuntimeInstallManifest, release: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallPaths.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallPaths.ts new file mode 100644 index 0000000000..c762b4f2cb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallPaths.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type RuntimeInstallPaths = { bundledPluginMarketplacePaths: Array, bundledSkillPaths: Array, nodeModulesPath: AbsolutePathBuf, nodePath: AbsolutePathBuf, pythonPath: AbsolutePathBuf, skillsToRemove: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallResponse.ts new file mode 100644 index 0000000000..4d263665b6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RuntimeInstallPaths } from "./RuntimeInstallPaths"; +import type { RuntimeInstallStatus } from "./RuntimeInstallStatus"; + +export type RuntimeInstallResponse = { bundleVersion: string | null, paths: RuntimeInstallPaths, status: RuntimeInstallStatus, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallStatus.ts new file mode 100644 index 0000000000..dc9c499c31 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RuntimeInstallStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RuntimeInstallStatus = "already-current" | "installed"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index a6b961366e..62dec97efa 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -322,6 +322,11 @@ export type { ReviewDelivery } from "./ReviewDelivery"; export type { ReviewStartParams } from "./ReviewStartParams"; export type { ReviewStartResponse } from "./ReviewStartResponse"; export type { ReviewTarget } from "./ReviewTarget"; +export type { RuntimeInstallManifest } from "./RuntimeInstallManifest"; +export type { RuntimeInstallParams } from "./RuntimeInstallParams"; +export type { RuntimeInstallPaths } from "./RuntimeInstallPaths"; +export type { RuntimeInstallResponse } from "./RuntimeInstallResponse"; +export type { RuntimeInstallStatus } from "./RuntimeInstallStatus"; export type { SandboxMode } from "./SandboxMode"; export type { SandboxPolicy } from "./SandboxPolicy"; export type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ae00b08b73..87f66fe43e 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -716,6 +716,11 @@ client_request_definitions! { serialization: global("config"), response: v2::PluginInstallResponse, }, + RuntimeInstall => "runtime/install" { + params: v2::RuntimeInstallParams, + serialization: global("runtime-install"), + response: v2::RuntimeInstallResponse, + }, PluginUninstall => "plugin/uninstall" { params: v2::PluginUninstallParams, serialization: global("config"), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index b5fa9fdc65..e8d4317934 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -21,6 +21,7 @@ mod process; mod realtime; mod remote_control; mod review; +mod runtime; mod thread; mod thread_data; mod turn; @@ -47,6 +48,7 @@ pub use process::*; pub use realtime::*; pub use remote_control::*; pub use review::*; +pub use runtime::*; pub use shared::*; pub use thread::*; pub use thread_data::*; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/runtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/runtime.rs new file mode 100644 index 0000000000..a6831bd639 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/runtime.rs @@ -0,0 +1,58 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RuntimeInstallManifest { + pub archive_name: Option, + pub archive_sha256: String, + pub archive_size_bytes: Option, + pub archive_url: String, + pub bundle_format_version: Option, + pub bundle_version: Option, + pub format: Option, + pub runtime_root_directory_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RuntimeInstallParams { + #[ts(optional = nullable)] + pub environment_id: Option, + pub manifest: Box, + pub release: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export_to = "v2/")] +pub enum RuntimeInstallStatus { + AlreadyCurrent, + Installed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RuntimeInstallPaths { + pub bundled_plugin_marketplace_paths: Vec, + pub bundled_skill_paths: Vec, + pub node_modules_path: AbsolutePathBuf, + pub node_path: AbsolutePathBuf, + pub python_path: AbsolutePathBuf, + pub skills_to_remove: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RuntimeInstallResponse { + pub bundle_version: Option, + pub paths: RuntimeInstallPaths, + pub status: RuntimeInstallStatus, +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4897684564..5d20966e89 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -163,6 +163,7 @@ pub(crate) struct MessageProcessor { command_exec_processor: CommandExecRequestProcessor, process_exec_processor: ProcessExecRequestProcessor, config_processor: ConfigRequestProcessor, + environment_manager: Arc, environment_processor: EnvironmentRequestProcessor, external_agent_config_processor: ExternalAgentConfigRequestProcessor, feedback_processor: FeedbackRequestProcessor, @@ -476,6 +477,7 @@ impl MessageProcessor { command_exec_processor, process_exec_processor, config_processor, + environment_manager: thread_manager.environment_manager(), environment_processor, external_agent_config_processor, feedback_processor, @@ -941,6 +943,26 @@ impl MessageProcessor { .model_provider_capabilities_read() .await .map(|response| Some(response.into())), + ClientRequest::RuntimeInstall { params, .. } => { + let mut params = params; + let environment = if let Some(environment_id) = params.environment_id.take() { + self.environment_manager + .get_environment(&environment_id) + .ok_or_else(|| { + invalid_request(format!( + "unknown runtime install environment id `{environment_id}`" + )) + })? + } else { + self.environment_manager + .default_environment() + .unwrap_or_else(|| self.environment_manager.local_environment()) + }; + environment + .install_runtime(params) + .await + .map(|response| Some(response.into())) + } ClientRequest::ThreadStart { params, .. } => { self.thread_processor .thread_start( diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 9fbdd91117..548fdeefbf 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -26,6 +26,8 @@ futures = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } +tempfile = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tokio = { workspace = true, features = [ @@ -43,6 +45,7 @@ tokio-util = { workspace = true, features = ["rt"] } tokio-tungstenite = { workspace = true } tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } +zip = { workspace = true } [dev-dependencies] anyhow = { workspace = true } @@ -50,6 +53,5 @@ codex-test-binary-support = { workspace = true } ctor = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } -tempfile = { workspace = true } test-case = "3.3.1" wiremock = { workspace = true } diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 9261a59542..e913eaf54a 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -8,6 +8,8 @@ use std::time::Duration; use arc_swap::ArcSwap; use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::RuntimeInstallParams; +use codex_app_server_protocol::RuntimeInstallResponse; use futures::FutureExt; use futures::future::BoxFuture; use serde_json::Value; @@ -69,6 +71,7 @@ use crate::protocol::INITIALIZED_METHOD; use crate::protocol::InitializeParams; use crate::protocol::InitializeResponse; use crate::protocol::ProcessOutputChunk; +use crate::protocol::RUNTIME_INSTALL_METHOD; use crate::protocol::ReadParams; use crate::protocol::ReadResponse; use crate::protocol::TerminateParams; @@ -397,6 +400,13 @@ impl ExecServerClient { self.call(FS_COPY_METHOD, ¶ms).await } + pub async fn runtime_install( + &self, + params: RuntimeInstallParams, + ) -> Result { + self.call(RUNTIME_INSTALL_METHOD, ¶ms).await + } + pub(crate) async fn register_session( &self, process_id: &ProcessId, diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 7e4a3fb056..32d4670848 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -2,6 +2,10 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::RwLock; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RuntimeInstallParams; +use codex_app_server_protocol::RuntimeInstallResponse; + use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; @@ -20,6 +24,7 @@ use crate::local_process::LocalProcess; use crate::process::ExecBackend; use crate::remote_file_system::RemoteFileSystem; use crate::remote_process::RemoteProcess; +use crate::rpc::internal_error; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; @@ -294,9 +299,45 @@ pub struct Environment { exec_backend: Arc, filesystem: Arc, http_client: Arc, + runtime_installer: RuntimeInstaller, local_runtime_paths: Option, } +#[derive(Clone)] +enum RuntimeInstaller { + Local, + Remote(LazyRemoteExecServerClient), +} + +impl RuntimeInstaller { + async fn install_runtime( + &self, + params: RuntimeInstallParams, + ) -> Result { + match self { + RuntimeInstaller::Local => crate::runtime_install::install_runtime(params).await, + RuntimeInstaller::Remote(client) => { + let client = client.get().await.map_err(exec_server_error_to_jsonrpc)?; + client + .runtime_install(params) + .await + .map_err(exec_server_error_to_jsonrpc) + } + } + } +} + +fn exec_server_error_to_jsonrpc(err: ExecServerError) -> JSONRPCErrorError { + match err { + ExecServerError::Server { code, message } => JSONRPCErrorError { + code, + data: None, + message, + }, + _ => internal_error(err.to_string()), + } +} + impl Environment { /// Builds a test-only local environment without configured sandbox helper paths. pub fn default_for_tests() -> Self { @@ -306,6 +347,7 @@ impl Environment { exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::unsandboxed()), http_client: Arc::new(ReqwestHttpClient), + runtime_installer: RuntimeInstaller::Local, local_runtime_paths: None, } } @@ -364,6 +406,7 @@ impl Environment { local_runtime_paths.clone(), )), http_client: Arc::new(ReqwestHttpClient), + runtime_installer: RuntimeInstaller::Local, local_runtime_paths: Some(local_runtime_paths), } } @@ -393,13 +436,15 @@ impl Environment { let exec_backend: Arc = Arc::new(RemoteProcess::new(client.clone())); let filesystem: Arc = Arc::new(RemoteFileSystem::new(client.clone())); + let http_client = client.clone(); Self { exec_server_url, remote_transport: Some(remote_transport), exec_backend, filesystem, - http_client: Arc::new(client), + http_client: Arc::new(http_client), + runtime_installer: RuntimeInstaller::Remote(client), local_runtime_paths, } } @@ -428,6 +473,13 @@ impl Environment { pub fn get_filesystem(&self) -> Arc { Arc::clone(&self.filesystem) } + + pub async fn install_runtime( + &self, + params: RuntimeInstallParams, + ) -> Result { + self.runtime_installer.install_runtime(params).await + } } #[cfg(test)] diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index d8c147127c..6b3c96d67c 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -17,6 +17,7 @@ mod remote; mod remote_file_system; mod remote_process; mod rpc; +mod runtime_install; mod runtime_paths; mod sandboxed_file_system; mod server; @@ -82,6 +83,7 @@ pub use protocol::HttpRequestResponse; pub use protocol::InitializeParams; pub use protocol::InitializeResponse; pub use protocol::ProcessOutputChunk; +pub use protocol::RUNTIME_INSTALL_METHOD; pub use protocol::ReadParams; pub use protocol::ReadResponse; pub use protocol::TerminateParams; diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index e801a7f437..629c1da767 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -30,6 +30,7 @@ pub const FS_COPY_METHOD: &str = "fs/copy"; pub const HTTP_REQUEST_METHOD: &str = "http/request"; /// JSON-RPC notification method for streamed executor HTTP response bodies. pub const HTTP_REQUEST_BODY_DELTA_METHOD: &str = "http/request/bodyDelta"; +pub const RUNTIME_INSTALL_METHOD: &str = "runtime/install"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] diff --git a/codex-rs/exec-server/src/rpc.rs b/codex-rs/exec-server/src/rpc.rs index e4f2ff554a..2250403110 100644 --- a/codex-rs/exec-server/src/rpc.rs +++ b/codex-rs/exec-server/src/rpc.rs @@ -80,7 +80,7 @@ impl RpcNotificationSender { self.outgoing_tx .send(RpcServerOutboundMessage::Response { request_id, result }) .await - .map_err(|_| internal_error("RPC connection closed while sending response".into())) + .map_err(|_| internal_error("RPC connection closed while sending response")) } pub(crate) async fn notify( @@ -97,7 +97,7 @@ impl RpcNotificationSender { }, )) .await - .map_err(|_| internal_error("RPC connection closed while sending notification".into())) + .map_err(|_| internal_error("RPC connection closed while sending notification")) } } @@ -421,11 +421,11 @@ pub(crate) fn method_not_found(message: String) -> JSONRPCErrorError { } } -pub(crate) fn invalid_params(message: String) -> JSONRPCErrorError { +pub(crate) fn invalid_params(message: impl Into) -> JSONRPCErrorError { JSONRPCErrorError { code: -32602, data: None, - message, + message: message.into(), } } @@ -437,11 +437,11 @@ pub(crate) fn not_found(message: String) -> JSONRPCErrorError { } } -pub(crate) fn internal_error(message: String) -> JSONRPCErrorError { +pub(crate) fn internal_error(message: impl Into) -> JSONRPCErrorError { JSONRPCErrorError { code: -32603, data: None, - message, + message: message.into(), } } diff --git a/codex-rs/exec-server/src/runtime_install.rs b/codex-rs/exec-server/src/runtime_install.rs new file mode 100644 index 0000000000..63792b50ea --- /dev/null +++ b/codex-rs/exec-server/src/runtime_install.rs @@ -0,0 +1,1283 @@ +use std::ffi::OsStr; +use std::future::Future; +use std::io::ErrorKind; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; + +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RuntimeInstallManifest; +use codex_app_server_protocol::RuntimeInstallParams; +use codex_app_server_protocol::RuntimeInstallPaths; +use codex_app_server_protocol::RuntimeInstallResponse; +use codex_app_server_protocol::RuntimeInstallStatus; +use codex_utils_absolute_path::AbsolutePathBuf; +use futures::StreamExt; +use serde::Deserialize; +use sha2::Digest; +use sha2::Sha256; +use tokio::fs; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +use crate::rpc::internal_error; +use crate::rpc::invalid_params; + +const PUBLISHED_ARTIFACT_NAME: &str = "codex-primary-runtime"; +const USER_AGENT: &str = "codex-exec-server-runtime-installer"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RuntimeArchiveFormat { + TarXz, + Zip, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct InstalledRuntimeMetadata { + bundle_format_version: Option, + bundle_version: Option, + bundled_plugins: Option>, + bundled_skills: Option>, + skills_to_remove: Option>, +} + +pub(crate) async fn install_runtime( + params: RuntimeInstallParams, +) -> Result { + let install_root = default_install_root()?; + install_runtime_with_root(params, install_root).await +} + +async fn install_runtime_with_root( + params: RuntimeInstallParams, + install_root: PathBuf, +) -> Result { + validate_manifest(¶ms.manifest)?; + let archive_format = runtime_archive_format(¶ms.manifest)?; + let archive_name = params + .manifest + .archive_name + .clone() + .unwrap_or_else(|| default_archive_name(archive_format).to_string()); + validate_path_segment(&archive_name, "archiveName")?; + + let staging_dir = make_staging_dir(&install_root).await?; + let archive_path = staging_dir.join(archive_name); + let result = async { + download_archive(¶ms.manifest.archive_url, &archive_path).await?; + install_runtime_from_archive(¶ms.manifest, &archive_path, &install_root).await + } + .await; + let cleanup_result = fs::remove_dir_all(&staging_dir).await; + if let Err(err) = cleanup_result + && err.kind() != ErrorKind::NotFound + { + tracing::warn!( + "failed to remove runtime install staging directory {}: {err}", + staging_dir.display() + ); + } + result +} + +async fn install_runtime_from_archive( + manifest: &RuntimeInstallManifest, + archive_path: &Path, + install_root: &Path, +) -> Result { + let runtime_root_directory_name = runtime_root_directory_name(manifest)?; + let installed_runtime_root = install_root.join(&runtime_root_directory_name); + let target_platform = target_platform(); + + if let Some(bundle_version) = manifest.bundle_version.as_ref() + && let Ok(Some(metadata)) = read_installed_runtime_metadata(&installed_runtime_root).await + && metadata.bundle_version.as_ref() == Some(bundle_version) + && let Ok(paths) = validate_runtime_root( + &installed_runtime_root, + manifest.bundle_format_version, + target_platform, + ) + .await + { + let paths = configure_runtime_paths(paths).await?; + return Ok(RuntimeInstallResponse { + bundle_version: Some(bundle_version.clone()), + paths, + status: RuntimeInstallStatus::AlreadyCurrent, + }); + } + + fs::create_dir_all(install_root) + .await + .map_err(|err| internal_error(format!("failed to create runtime install root: {err}")))?; + + verify_archive_checksum( + archive_path, + &manifest.archive_sha256, + &manifest.archive_url, + ) + .await?; + + let archive_format = runtime_archive_format(manifest)?; + let staging_dir = make_staging_dir(install_root).await?; + let result = async { + let extract_dir = staging_dir.join("payload"); + fs::create_dir_all(&extract_dir).await.map_err(|err| { + internal_error(format!("failed to create runtime extract dir: {err}")) + })?; + + let entries = list_archive_entries(archive_format, archive_path).await?; + assert_archive_entries_stay_within_directory(&entries, &extract_dir)?; + extract_archive(archive_format, archive_path, &extract_dir).await?; + + let extracted_runtime_root = extract_dir.join(&runtime_root_directory_name); + validate_runtime_root( + &extracted_runtime_root, + manifest.bundle_format_version, + target_platform, + ) + .await?; + + let previous_runtime_root = + install_root.join(format!("{runtime_root_directory_name}.previous")); + remove_dir_if_exists(&previous_runtime_root).await?; + if path_exists(&installed_runtime_root).await { + fs::rename(&installed_runtime_root, &previous_runtime_root) + .await + .map_err(|err| { + internal_error(format!("failed to move previous runtime aside: {err}")) + })?; + } + + let install_result = async { + fs::rename(&extracted_runtime_root, &installed_runtime_root) + .await + .map_err(|err| internal_error(format!("failed to install runtime: {err}")))?; + validate_runtime_root( + &installed_runtime_root, + manifest.bundle_format_version, + target_platform, + ) + .await + } + .await; + + let paths = match install_result { + Ok(paths) => paths, + Err(error) => { + remove_dir_if_exists(&installed_runtime_root).await?; + if path_exists(&previous_runtime_root).await { + fs::rename(&previous_runtime_root, &installed_runtime_root) + .await + .map_err(|err| { + internal_error(format!("failed to restore previous runtime: {err}")) + })?; + } + return Err(error); + } + }; + let paths = configure_runtime_paths(paths).await?; + + remove_dir_if_exists(&previous_runtime_root).await?; + Ok(RuntimeInstallResponse { + bundle_version: manifest.bundle_version.clone(), + paths, + status: RuntimeInstallStatus::Installed, + }) + } + .await; + let cleanup_result = fs::remove_dir_all(&staging_dir).await; + if let Err(err) = cleanup_result + && err.kind() != ErrorKind::NotFound + { + tracing::warn!( + "failed to remove runtime install extraction directory {}: {err}", + staging_dir.display() + ); + } + result +} + +fn default_install_root() -> Result { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or_else(|| internal_error("failed to locate home directory for runtime install"))?; + Ok(home.join(".cache").join("codex-runtimes")) +} + +async fn make_staging_dir(install_root: &Path) -> Result { + fs::create_dir_all(install_root) + .await + .map_err(|err| internal_error(format!("failed to create runtime install root: {err}")))?; + tempfile::Builder::new() + .prefix("codex-runtime-install-") + .tempdir_in(install_root) + .map(tempfile::TempDir::keep) + .map_err(|err| { + internal_error(format!( + "failed to create runtime install staging dir: {err}" + )) + }) +} + +fn validate_manifest(manifest: &RuntimeInstallManifest) -> Result<(), JSONRPCErrorError> { + if manifest.archive_url.trim().is_empty() { + return Err(invalid_params( + "runtime manifest archiveUrl must not be empty", + )); + } + if !is_sha256(&manifest.archive_sha256) { + return Err(invalid_params( + "runtime manifest archiveSha256 must be a 64-character hex digest", + )); + } + if let Some(archive_name) = manifest.archive_name.as_ref() { + validate_path_segment(archive_name, "archiveName")?; + } + if let Some(runtime_root_directory_name) = manifest.runtime_root_directory_name.as_ref() { + validate_path_segment(runtime_root_directory_name, "runtimeRootDirectoryName")?; + } + Ok(()) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn validate_path_segment(value: &str, field_name: &str) -> Result<(), JSONRPCErrorError> { + let value = value.trim(); + if value.is_empty() + || value == "." + || value == ".." + || value.contains('/') + || value.contains('\\') + { + return Err(invalid_params(format!( + "runtime manifest {field_name} must be a single path segment" + ))); + } + Ok(()) +} + +fn runtime_root_directory_name( + manifest: &RuntimeInstallManifest, +) -> Result { + let runtime_root_directory_name = manifest + .runtime_root_directory_name + .clone() + .unwrap_or_else(|| PUBLISHED_ARTIFACT_NAME.to_string()); + validate_path_segment(&runtime_root_directory_name, "runtimeRootDirectoryName")?; + Ok(runtime_root_directory_name) +} + +fn runtime_archive_format( + manifest: &RuntimeInstallManifest, +) -> Result { + if let Some(format) = manifest.format.as_deref() { + match format.to_ascii_lowercase().as_str() { + "tar.xz" => return Ok(RuntimeArchiveFormat::TarXz), + "zip" => return Ok(RuntimeArchiveFormat::Zip), + _ => { + return Err(invalid_params(format!( + "unsupported runtime archive format: {format}" + ))); + } + } + } + if manifest + .archive_name + .as_deref() + .is_some_and(|name| name.to_ascii_lowercase().ends_with(".zip")) + || manifest.archive_url.to_ascii_lowercase().ends_with(".zip") + { + return Ok(RuntimeArchiveFormat::Zip); + } + Ok(RuntimeArchiveFormat::TarXz) +} + +fn default_archive_name(format: RuntimeArchiveFormat) -> &'static str { + match format { + RuntimeArchiveFormat::TarXz => "node-runtime.tar.xz", + RuntimeArchiveFormat::Zip => "node-runtime.zip", + } +} + +async fn download_archive(url: &str, destination: &Path) -> Result<(), JSONRPCErrorError> { + let response = reqwest::Client::new() + .get(url) + .header(reqwest::header::USER_AGENT, USER_AGENT) + .send() + .await + .map_err(|err| internal_error(format!("failed to download runtime archive: {err}")))?; + if !response.status().is_success() { + return Err(internal_error(format!( + "failed to download runtime archive ({} {})", + response.status().as_u16(), + response + .status() + .canonical_reason() + .unwrap_or("unknown status") + ))); + } + + let mut file = fs::File::create(destination) + .await + .map_err(|err| internal_error(format!("failed to create runtime archive file: {err}")))?; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|err| { + internal_error(format!("failed to read runtime archive bytes: {err}")) + })?; + file.write_all(&chunk) + .await + .map_err(|err| internal_error(format!("failed to write runtime archive: {err}")))?; + } + file.flush() + .await + .map_err(|err| internal_error(format!("failed to flush runtime archive: {err}")))?; + Ok(()) +} + +async fn verify_archive_checksum( + archive_path: &Path, + expected_sha256: &str, + source_url: &str, +) -> Result<(), JSONRPCErrorError> { + let actual_sha256 = compute_sha256(archive_path).await?; + if !actual_sha256.eq_ignore_ascii_case(expected_sha256) { + return Err(invalid_params(format!( + "checksum mismatch for '{source_url}': expected {expected_sha256}, got {actual_sha256}" + ))); + } + Ok(()) +} + +async fn compute_sha256(path: &Path) -> Result { + let mut file = fs::File::open(path) + .await + .map_err(|err| internal_error(format!("failed to open runtime archive: {err}")))?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let bytes_read = file + .read(&mut buffer) + .await + .map_err(|err| internal_error(format!("failed to read runtime archive: {err}")))?; + if bytes_read == 0 { + break; + } + digest.update(&buffer[..bytes_read]); + } + Ok(format!("{:x}", digest.finalize())) +} + +async fn list_archive_entries( + format: RuntimeArchiveFormat, + archive_path: &Path, +) -> Result, JSONRPCErrorError> { + match format { + RuntimeArchiveFormat::TarXz => list_tar_entries(archive_path).await, + RuntimeArchiveFormat::Zip => list_zip_entries(archive_path).await, + } +} + +async fn extract_archive( + format: RuntimeArchiveFormat, + archive_path: &Path, + extract_dir: &Path, +) -> Result<(), JSONRPCErrorError> { + match format { + RuntimeArchiveFormat::TarXz => extract_tar_archive(archive_path, extract_dir).await, + RuntimeArchiveFormat::Zip => extract_zip_archive(archive_path, extract_dir).await, + } +} + +async fn list_tar_entries(archive_path: &Path) -> Result, JSONRPCErrorError> { + let output = Command::new("tar") + .arg("-tf") + .arg(archive_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|err| internal_error(format!("failed to list runtime archive: {err}")))?; + if !output.status.success() { + return Err(invalid_params(format!( + "failed to list runtime archive: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(parse_archive_entries(&String::from_utf8_lossy( + &output.stdout, + ))) +} + +async fn extract_tar_archive( + archive_path: &Path, + extract_dir: &Path, +) -> Result<(), JSONRPCErrorError> { + let output = Command::new("tar") + .arg("-xJf") + .arg(archive_path) + .arg("-C") + .arg(extract_dir) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|err| internal_error(format!("failed to extract runtime archive: {err}")))?; + if !output.status.success() { + return Err(invalid_params(format!( + "failed to extract runtime archive: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(()) +} + +fn list_zip_entries( + archive_path: &Path, +) -> impl Future, JSONRPCErrorError>> + Send + 'static { + let archive_path = archive_path.to_path_buf(); + async move { + tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(&archive_path).map_err(|err| { + internal_error(format!("failed to open runtime zip archive: {err}")) + })?; + let mut archive = zip::ZipArchive::new(file).map_err(|err| { + invalid_params(format!("failed to read runtime zip archive: {err}")) + })?; + let mut entries = Vec::with_capacity(archive.len()); + for index in 0..archive.len() { + let file = archive.by_index(index).map_err(|err| { + invalid_params(format!("failed to read runtime zip entry: {err}")) + })?; + entries.push(file.name().to_string()); + } + Ok(entries) + }) + .await + .map_err(|err| internal_error(format!("failed to join zip listing task: {err}")))? + } +} + +fn extract_zip_archive( + archive_path: &Path, + extract_dir: &Path, +) -> impl Future> + Send + 'static { + let archive_path = archive_path.to_path_buf(); + let extract_dir = extract_dir.to_path_buf(); + async move { + tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(&archive_path).map_err(|err| { + internal_error(format!("failed to open runtime zip archive: {err}")) + })?; + let mut archive = zip::ZipArchive::new(file).map_err(|err| { + invalid_params(format!("failed to read runtime zip archive: {err}")) + })?; + archive.extract(&extract_dir).map_err(|err| { + invalid_params(format!("failed to extract runtime zip archive: {err}")) + })?; + Ok(()) + }) + .await + .map_err(|err| internal_error(format!("failed to join zip extraction task: {err}")))? + } +} + +fn parse_archive_entries(stdout: &str) -> Vec { + stdout + .lines() + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect() +} + +fn assert_archive_entries_stay_within_directory( + entries: &[String], + extract_dir: &Path, +) -> Result<(), JSONRPCErrorError> { + let resolved_extract_dir = normalize_path(extract_dir); + for entry in entries { + let resolved_entry_path = normalize_path(extract_dir.join(entry)); + if resolved_entry_path != resolved_extract_dir + && !resolved_entry_path.starts_with(&resolved_extract_dir) + { + return Err(invalid_params(format!( + "archive entry '{entry}' would extract outside target" + ))); + } + } + Ok(()) +} + +fn normalize_path(path: impl AsRef) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.as_ref().components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + _ => normalized.push(component.as_os_str()), + } + } + normalized +} + +async fn read_installed_runtime_metadata( + runtime_root: &Path, +) -> Result, JSONRPCErrorError> { + let raw = match fs::read_to_string(runtime_root.join("runtime.json")).await { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(internal_error(format!( + "failed to read installed runtime metadata: {err}" + ))); + } + }; + serde_json::from_str(&raw) + .map(Some) + .map_err(|err| invalid_params(format!("failed to parse installed runtime metadata: {err}"))) +} + +async fn validate_runtime_root( + runtime_root: &Path, + manifest_bundle_format_version: Option, + target_platform: &str, +) -> Result { + let metadata = read_installed_runtime_metadata(runtime_root) + .await? + .ok_or_else(|| invalid_params("runtime metadata is missing"))?; + let bundle_format_version = manifest_bundle_format_version + .or(metadata.bundle_format_version) + .unwrap_or(1); + let node_root = if bundle_format_version >= 2 { + runtime_root.join("dependencies").join("node") + } else { + runtime_root.to_path_buf() + }; + let node_path = node_root + .join("bin") + .join(node_executable_name(target_platform)); + let node_modules_path = node_root.join("node_modules"); + let python_path = find_python_path(runtime_root, bundle_format_version, target_platform).await; + let bundled_plugin_marketplace_paths = runtime_contained_paths( + runtime_root, + metadata.bundled_plugins.unwrap_or_default(), + &[], + )?; + let bundled_skill_paths = runtime_contained_paths( + runtime_root, + metadata.bundled_skills.unwrap_or_default(), + &["SKILL.md"], + )?; + + Ok(RuntimeInstallPaths { + bundled_plugin_marketplace_paths, + bundled_skill_paths, + node_modules_path: absolute_path(node_modules_path)?, + node_path: absolute_path(node_path)?, + python_path: absolute_path(python_path)?, + skills_to_remove: metadata.skills_to_remove.unwrap_or_default(), + }) +} + +async fn find_python_path( + runtime_root: &Path, + bundle_format_version: u32, + target_platform: &str, +) -> PathBuf { + let python_root = if bundle_format_version >= 2 { + runtime_root.join("dependencies").join("python") + } else { + runtime_root.join("python") + }; + let executable_name = python_executable_name(target_platform); + let candidates = if target_platform == "win32" { + vec![ + python_root.join(executable_name), + python_root.join("python").join(executable_name), + python_root.join("bin").join(executable_name), + ] + } else { + vec![ + python_root.join("bin").join(executable_name), + python_root.join("bin").join("python"), + ] + }; + for candidate in candidates.iter() { + if path_exists(candidate).await { + return candidate.clone(); + } + } + candidates[0].clone() +} + +fn runtime_contained_paths( + runtime_root: &Path, + directories: Vec, + suffix: &[&str], +) -> Result, JSONRPCErrorError> { + directories + .into_iter() + .map(|directory| { + let mut path = runtime_root.join(directory); + for segment in suffix { + path.push(segment); + } + let normalized_runtime_root = normalize_path(runtime_root); + let normalized_path = normalize_path(&path); + if normalized_path != normalized_runtime_root + && normalized_path.starts_with(&normalized_runtime_root) + { + absolute_path(path) + } else { + Err(invalid_params( + "runtime-contained path must stay within the runtime root", + )) + } + }) + .collect() +} + +async fn configure_runtime_paths( + mut paths: RuntimeInstallPaths, +) -> Result { + if paths.bundled_plugin_marketplace_paths.is_empty() + && paths.bundled_skill_paths.is_empty() + && paths.skills_to_remove.is_empty() + { + return Ok(paths); + } + + let codex_home = default_codex_home()?; + let marketplace_roots = materialize_bundled_plugin_marketplaces( + &codex_home, + &paths.bundled_plugin_marketplace_paths, + ) + .await?; + let skill_paths = sync_primary_runtime_skills( + &codex_home, + &paths.bundled_skill_paths, + &paths.skills_to_remove, + ) + .await?; + + paths.bundled_plugin_marketplace_paths = marketplace_roots; + paths.bundled_skill_paths = skill_paths; + Ok(paths) +} + +fn default_codex_home() -> Result { + if let Some(codex_home) = std::env::var_os("CODEX_HOME") { + return Ok(PathBuf::from(codex_home)); + } + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or_else(|| internal_error("failed to locate home directory for runtime install"))?; + Ok(home.join(".codex")) +} + +async fn materialize_bundled_plugin_marketplaces( + codex_home: &Path, + marketplace_roots: &[AbsolutePathBuf], +) -> Result, JSONRPCErrorError> { + if marketplace_roots.is_empty() { + return Ok(Vec::new()); + } + let destination_root = codex_home + .join("plugins") + .join(PUBLISHED_ARTIFACT_NAME) + .join("marketplaces"); + let mut materialized = Vec::with_capacity(marketplace_roots.len()); + for marketplace_root in marketplace_roots { + let marketplace_name = marketplace_root.as_path().file_name().ok_or_else(|| { + invalid_params("bundled plugin marketplace path has no directory name") + })?; + let destination = destination_root.join(safe_path_segment(marketplace_name)); + copy_dir(marketplace_root.as_path(), &destination).await?; + materialized.push(absolute_path(destination)?); + } + Ok(materialized) +} + +async fn sync_primary_runtime_skills( + codex_home: &Path, + bundled_skill_paths: &[AbsolutePathBuf], + skills_to_remove: &[String], +) -> Result, JSONRPCErrorError> { + if bundled_skill_paths.is_empty() && skills_to_remove.is_empty() { + return Ok(Vec::new()); + } + + move_legacy_primary_runtime_skills(codex_home, skills_to_remove).await?; + + if bundled_skill_paths.is_empty() { + return Ok(Vec::new()); + } + + let destination_root = codex_home.join("skills").join(PUBLISHED_ARTIFACT_NAME); + remove_dir_if_exists(&destination_root).await?; + fs::create_dir_all(&destination_root).await.map_err(|err| { + internal_error(format!( + "failed to create bundled skills directory {}: {err}", + destination_root.display() + )) + })?; + + let mut materialized = Vec::with_capacity(bundled_skill_paths.len()); + for bundled_skill_path in bundled_skill_paths { + let skill_root = bundled_skill_path.as_path().parent().ok_or_else(|| { + invalid_params(format!( + "bundled skill path {} has no parent directory", + bundled_skill_path.display() + )) + })?; + let skill_name = skill_root.file_name().ok_or_else(|| { + invalid_params(format!( + "bundled skill path {} has no skill directory name", + bundled_skill_path.display() + )) + })?; + let destination = destination_root.join(skill_name); + copy_dir(skill_root, &destination).await?; + materialized.push(absolute_path(destination.join("SKILL.md"))?); + } + + Ok(materialized) +} + +async fn move_legacy_primary_runtime_skills( + codex_home: &Path, + skills_to_remove: &[String], +) -> Result<(), JSONRPCErrorError> { + if skills_to_remove.is_empty() { + return Ok(()); + } + + let skills_root = codex_home.join("skills"); + for skill_dir in skills_to_remove { + let skill_root = resolve_legacy_skill_directory(&skills_root, skill_dir); + let metadata = match fs::metadata(&skill_root).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => { + return Err(internal_error(format!( + "failed to inspect legacy skill directory {}: {err}", + skill_root.display() + ))); + } + }; + if !metadata.is_dir() { + continue; + } + + let backup_path = codex_home + .join(".tmp") + .join("legacy-primary-runtime-skills") + .join(format!( + "{}-{}", + skill_root + .file_name() + .and_then(OsStr::to_str) + .unwrap_or("skill"), + uuid::Uuid::new_v4() + )); + if let Some(parent) = backup_path.parent() { + fs::create_dir_all(parent).await.map_err(|err| { + internal_error(format!( + "failed to create legacy skill backup directory {}: {err}", + parent.display() + )) + })?; + } + match fs::rename(&skill_root, &backup_path).await { + Ok(()) => { + tracing::info!( + skill_dir = %skill_dir, + skill_root = %skill_root.display(), + backup_path = %backup_path.display(), + "moved legacy primary runtime skill" + ); + } + Err(err) if err.kind() == ErrorKind::NotFound => {} + Err(err) => { + return Err(internal_error(format!( + "failed to move legacy skill directory {} to {}: {err}", + skill_root.display(), + backup_path.display() + ))); + } + } + } + Ok(()) +} + +fn resolve_legacy_skill_directory(skills_root: &Path, skill_dir: &str) -> PathBuf { + let relative = Path::new(skill_dir); + if relative + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return skills_root.join(relative); + } + skills_root.join( + relative + .file_name() + .unwrap_or_else(|| OsStr::new(skill_dir.trim_matches(['/', '\\']))), + ) +} + +async fn copy_dir(source: &Path, destination: &Path) -> Result<(), JSONRPCErrorError> { + remove_dir_if_exists(destination).await?; + fs::create_dir_all(destination).await.map_err(|err| { + internal_error(format!( + "failed to create destination directory {}: {err}", + destination.display() + )) + })?; + + let mut pending = vec![(source.to_path_buf(), destination.to_path_buf())]; + while let Some((source_dir, destination_dir)) = pending.pop() { + let mut entries = fs::read_dir(&source_dir).await.map_err(|err| { + internal_error(format!( + "failed to read directory {}: {err}", + source_dir.display() + )) + })?; + while let Some(entry) = entries.next_entry().await.map_err(|err| { + internal_error(format!( + "failed to read directory entry in {}: {err}", + source_dir.display() + )) + })? { + let file_type = entry.file_type().await.map_err(|err| { + internal_error(format!( + "failed to inspect directory entry {}: {err}", + entry.path().display() + )) + })?; + let destination_path = destination_dir.join(entry.file_name()); + if file_type.is_dir() { + fs::create_dir_all(&destination_path).await.map_err(|err| { + internal_error(format!( + "failed to create destination directory {}: {err}", + destination_path.display() + )) + })?; + pending.push((entry.path(), destination_path)); + } else if file_type.is_file() { + if let Some(parent) = destination_path.parent() { + fs::create_dir_all(parent).await.map_err(|err| { + internal_error(format!( + "failed to create destination directory {}: {err}", + parent.display() + )) + })?; + } + fs::copy(entry.path(), &destination_path) + .await + .map_err(|err| { + internal_error(format!( + "failed to copy file to {}: {err}", + destination_path.display() + )) + })?; + } + } + } + Ok(()) +} + +fn safe_path_segment(segment: &OsStr) -> String { + let safe = segment + .to_string_lossy() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '-' + } + }) + .collect::(); + let safe = safe.trim_matches('.').to_string(); + if safe.is_empty() || safe == ".." { + "runtime-item".to_string() + } else { + safe + } +} + +fn absolute_path(path: PathBuf) -> Result { + AbsolutePathBuf::from_absolute_path_checked(path) + .map_err(|err| internal_error(format!("runtime path is not absolute: {err}"))) +} + +fn target_platform() -> &'static str { + if cfg!(target_os = "windows") { + "win32" + } else if cfg!(target_os = "macos") { + "darwin" + } else { + "linux" + } +} + +fn node_executable_name(target_platform: &str) -> &'static str { + if target_platform == "win32" { + "node.exe" + } else { + "node" + } +} + +fn python_executable_name(target_platform: &str) -> &'static str { + if target_platform == "win32" { + "python.exe" + } else { + "python3" + } +} + +async fn path_exists(path: &Path) -> bool { + fs::metadata(path).await.is_ok() +} + +async fn remove_dir_if_exists(path: &Path) -> Result<(), JSONRPCErrorError> { + match fs::remove_dir_all(path).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(internal_error(format!( + "failed to remove runtime directory {}: {err}", + path.display() + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn archive_traversal_entries_are_rejected() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![ + "codex-primary-runtime/runtime.json".to_string(), + "../x".to_string(), + ]; + + let error = assert_archive_entries_stay_within_directory(&entries, temp_dir.path()) + .expect_err("entry should be rejected"); + + assert!(error.message.contains("would extract outside target")); + } + + #[tokio::test] + async fn materialize_bundled_plugin_marketplaces_copies_to_codex_home() { + let codex_home = tempfile::tempdir().expect("codex home"); + let runtime = tempfile::tempdir().expect("runtime"); + let marketplace_root = runtime.path().join("market"); + fs::create_dir_all(marketplace_root.join(".agents/plugins")) + .await + .expect("create marketplace manifest dir"); + fs::write( + marketplace_root.join(".agents/plugins/marketplace.json"), + r#"{"name":"debug","plugins":[]}"#, + ) + .await + .expect("write marketplace"); + + let materialized = materialize_bundled_plugin_marketplaces( + codex_home.path(), + &[AbsolutePathBuf::try_from(marketplace_root).expect("absolute path")], + ) + .await + .expect("materialize marketplaces"); + + let expected_root = codex_home + .path() + .join("plugins") + .join(PUBLISHED_ARTIFACT_NAME) + .join("marketplaces") + .join("market"); + assert_eq!( + materialized, + vec![AbsolutePathBuf::try_from(expected_root.clone()).expect("absolute path")] + ); + assert!( + expected_root + .join(".agents/plugins/marketplace.json") + .is_file() + ); + } + + #[tokio::test] + async fn sync_primary_runtime_skills_copies_bundled_and_moves_legacy() { + let codex_home = tempfile::tempdir().expect("codex home"); + let runtime = tempfile::tempdir().expect("runtime"); + let bundled_skill_root = runtime.path().join("skills").join("debug"); + fs::create_dir_all(&bundled_skill_root) + .await + .expect("create bundled skill"); + fs::write(bundled_skill_root.join("SKILL.md"), "debug") + .await + .expect("write bundled skill"); + + let legacy_skill_root = codex_home.path().join("skills").join("legacy"); + fs::create_dir_all(&legacy_skill_root) + .await + .expect("create legacy skill"); + fs::write(legacy_skill_root.join("SKILL.md"), "legacy") + .await + .expect("write legacy skill"); + + let materialized = sync_primary_runtime_skills( + codex_home.path(), + &[ + AbsolutePathBuf::try_from(bundled_skill_root.join("SKILL.md")) + .expect("absolute path"), + ], + &["legacy".to_string()], + ) + .await + .expect("sync skills"); + + let expected_skill_path = codex_home + .path() + .join("skills") + .join(PUBLISHED_ARTIFACT_NAME) + .join("debug") + .join("SKILL.md"); + assert_eq!( + materialized, + vec![AbsolutePathBuf::try_from(expected_skill_path.clone()).expect("absolute path")] + ); + assert_eq!( + fs::read_to_string(expected_skill_path) + .await + .expect("read materialized skill"), + "debug" + ); + assert!(!legacy_skill_root.exists()); + assert_eq!( + std::fs::read_dir( + codex_home + .path() + .join(".tmp") + .join("legacy-primary-runtime-skills") + ) + .expect("read legacy backups") + .count(), + 1 + ); + } + + #[tokio::test] + async fn install_from_archive_reuses_current_runtime() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let install_root = temp_dir.path().join("install"); + let runtime_root = install_root.join(PUBLISHED_ARTIFACT_NAME); + create_runtime_root(&runtime_root, "v1").await; + let archive_path = temp_dir.path().join("unused.tar.xz"); + fs::write(&archive_path, b"not used") + .await + .expect("write archive"); + let manifest = manifest_for_archive(&archive_path, "v1").await; + + let response = install_runtime_from_archive(&manifest, &archive_path, &install_root) + .await + .expect("install should succeed"); + + assert_eq!(response.status, RuntimeInstallStatus::AlreadyCurrent); + assert_eq!(response.bundle_version.as_deref(), Some("v1")); + assert_eq!( + response.paths.node_path, + absolute_path( + runtime_root + .join("dependencies") + .join("node") + .join("bin") + .join(node_executable_name(target_platform())) + ) + .expect("absolute path") + ); + } + + #[tokio::test] + async fn install_from_archive_uses_runtime_metadata_bundle_format_when_manifest_omits_it() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let install_root = temp_dir.path().join("install"); + let runtime_root = install_root.join(PUBLISHED_ARTIFACT_NAME); + create_runtime_root(&runtime_root, "v1").await; + let archive_path = temp_dir.path().join("unused.tar.xz"); + fs::write(&archive_path, b"not used") + .await + .expect("write archive"); + let mut manifest = manifest_for_archive(&archive_path, "v1").await; + manifest.bundle_format_version = None; + + let response = install_runtime_from_archive(&manifest, &archive_path, &install_root) + .await + .expect("install should succeed"); + + assert_eq!( + response.paths.node_modules_path, + absolute_path( + runtime_root + .join("dependencies") + .join("node") + .join("node_modules") + ) + .expect("absolute path") + ); + assert_eq!( + response.paths.python_path, + absolute_path( + runtime_root + .join("dependencies") + .join("python") + .join("bin") + .join(python_executable_name(target_platform())) + ) + .expect("absolute path") + ); + } + + #[tokio::test] + async fn install_from_archive_rejects_checksum_mismatch() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let archive_path = temp_dir.path().join("archive.tar.xz"); + fs::write(&archive_path, b"archive") + .await + .expect("write archive"); + let manifest = RuntimeInstallManifest { + archive_name: None, + archive_sha256: "0".repeat(64), + archive_size_bytes: None, + archive_url: "https://example.com/archive.tar.xz".to_string(), + bundle_format_version: Some(2), + bundle_version: Some("v1".to_string()), + format: Some("tar.xz".to_string()), + runtime_root_directory_name: None, + }; + + let error = install_runtime_from_archive( + &manifest, + &archive_path, + &temp_dir.path().join("install"), + ) + .await + .expect_err("checksum mismatch should fail"); + + assert!(error.message.contains("checksum mismatch")); + } + + #[tokio::test] + async fn install_from_archive_restores_previous_runtime_when_new_runtime_is_invalid() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let install_root = temp_dir.path().join("install"); + let runtime_root = install_root.join(PUBLISHED_ARTIFACT_NAME); + create_runtime_root(&runtime_root, "old").await; + + let payload_root = temp_dir.path().join("payload").join("wrong-root"); + fs::create_dir_all(&payload_root) + .await + .expect("payload root"); + fs::write( + payload_root.join("runtime.json"), + r#"{"bundleFormatVersion":2,"bundleVersion":"new"}"#, + ) + .await + .expect("runtime metadata"); + let archive_path = temp_dir.path().join("invalid.tar.xz"); + create_tar_xz(temp_dir.path().join("payload").as_path(), &archive_path).await; + let manifest = manifest_for_archive(&archive_path, "new").await; + + let error = install_runtime_from_archive(&manifest, &archive_path, &install_root) + .await + .expect_err("invalid runtime should fail"); + + assert!(error.message.contains("runtime metadata is missing")); + let metadata = read_installed_runtime_metadata(&runtime_root) + .await + .expect("read metadata") + .expect("metadata"); + assert_eq!(metadata.bundle_version.as_deref(), Some("old")); + } + + async fn create_runtime_root(runtime_root: &Path, bundle_version: &str) { + let node_bin = runtime_root.join("dependencies").join("node").join("bin"); + let python_bin = runtime_root.join("dependencies").join("python").join("bin"); + fs::create_dir_all(&node_bin).await.expect("node bin"); + fs::create_dir_all( + runtime_root + .join("dependencies") + .join("node") + .join("node_modules"), + ) + .await + .expect("node_modules"); + fs::create_dir_all(&python_bin).await.expect("python bin"); + fs::write( + node_bin.join(node_executable_name(target_platform())), + b"node", + ) + .await + .expect("node"); + fs::write( + python_bin.join(python_executable_name(target_platform())), + b"python", + ) + .await + .expect("python"); + fs::write( + runtime_root.join("runtime.json"), + format!(r#"{{"bundleFormatVersion":2,"bundleVersion":"{bundle_version}"}}"#), + ) + .await + .expect("runtime metadata"); + } + + async fn manifest_for_archive( + archive_path: &Path, + bundle_version: &str, + ) -> RuntimeInstallManifest { + RuntimeInstallManifest { + archive_name: None, + archive_sha256: compute_sha256(archive_path).await.expect("sha256"), + archive_size_bytes: None, + archive_url: "https://example.com/archive.tar.xz".to_string(), + bundle_format_version: Some(2), + bundle_version: Some(bundle_version.to_string()), + format: Some("tar.xz".to_string()), + runtime_root_directory_name: None, + } + } + + async fn create_tar_xz(payload_dir: &Path, archive_path: &Path) { + let output = Command::new("tar") + .arg("-cJf") + .arg(archive_path) + .arg("-C") + .arg(payload_dir) + .arg(".") + .output() + .await + .expect("tar should run"); + assert!( + output.status.success(), + "tar failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index d0645724c4..5e68ebd315 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -5,6 +5,8 @@ use std::sync::atomic::Ordering; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::RuntimeInstallParams; +use codex_app_server_protocol::RuntimeInstallResponse; use serde_json::to_value; use std::collections::HashSet; use tokio::sync::Mutex; @@ -264,6 +266,14 @@ impl ExecServerHandler { self.file_system.copy(params).await } + pub(crate) async fn runtime_install( + &self, + params: RuntimeInstallParams, + ) -> Result { + self.require_initialized_for("runtime")?; + crate::runtime_install::install_runtime(params).await + } + fn require_initialized_for( &self, method_family: &str, diff --git a/codex-rs/exec-server/src/server/registry.rs b/codex-rs/exec-server/src/server/registry.rs index 87dee6aa58..eafeffbcb0 100644 --- a/codex-rs/exec-server/src/server/registry.rs +++ b/codex-rs/exec-server/src/server/registry.rs @@ -24,11 +24,13 @@ use crate::protocol::HttpRequestParams; use crate::protocol::INITIALIZE_METHOD; use crate::protocol::INITIALIZED_METHOD; use crate::protocol::InitializeParams; +use crate::protocol::RUNTIME_INSTALL_METHOD; use crate::protocol::ReadParams; use crate::protocol::TerminateParams; use crate::protocol::WriteParams; use crate::rpc::RpcRouter; use crate::server::ExecServerHandler; +use codex_app_server_protocol::RuntimeInstallParams; pub(crate) fn build_router() -> RpcRouter { let mut router = RpcRouter::new(); @@ -114,5 +116,11 @@ pub(crate) fn build_router() -> RpcRouter { handler.fs_copy(params).await }, ); + router.request( + RUNTIME_INSTALL_METHOD, + |handler: Arc, params: RuntimeInstallParams| async move { + handler.runtime_install(params).await + }, + ); router }