diff --git a/.github/ISSUE_TEMPLATE/1-codex-app.yml b/.github/ISSUE_TEMPLATE/1-codex-app.yml new file mode 100644 index 0000000000..569094907f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-codex-app.yml @@ -0,0 +1,47 @@ +name: πŸ–₯️ Codex App Bug +description: Report an issue with the Codex App +labels: + - app +body: + - type: markdown + attributes: + value: | + Before submitting a new issue, please search for existing issues to see if your issue has already been reported. + If it has, please add a πŸ‘ reaction (no need to leave a comment) to the existing issue instead of creating a new one. + + - type: input + id: version + attributes: + label: What version of the Codex App are you using (From β€œAbout Codex” dialog)? + validations: + required: true + - type: input + id: plan + attributes: + label: What subscription do you have? + validations: + required: true + - type: textarea + id: actual + attributes: + label: What issue are you seeing? + description: Please include the full error messages and prompts with PII redacted. If possible, please provide text instead of a screenshot. + validations: + required: true + - type: textarea + id: steps + attributes: + label: What steps can reproduce the bug? + description: Explain the bug and provide a code snippet that can reproduce it. Please include session id, token limit usage, context window usage if applicable. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What is the expected behavior? + description: If possible, please provide text instead of a screenshot. + - type: textarea + id: notes + attributes: + label: Additional information + description: Is there anything else you think we should know? diff --git a/.github/ISSUE_TEMPLATE/5-vs-code-extension.yml b/.github/ISSUE_TEMPLATE/2-extension.yml similarity index 83% rename from .github/ISSUE_TEMPLATE/5-vs-code-extension.yml rename to .github/ISSUE_TEMPLATE/2-extension.yml index 52da6a7cad..599bc08b42 100644 --- a/.github/ISSUE_TEMPLATE/5-vs-code-extension.yml +++ b/.github/ISSUE_TEMPLATE/2-extension.yml @@ -1,8 +1,7 @@ -name: πŸ§‘β€πŸ’» VS Code Extension -description: Report an issue with the VS Code extension +name: πŸ§‘β€πŸ’» IDE Extension Bug +description: Report an issue with the IDE extension labels: - extension - - needs triage body: - type: markdown attributes: @@ -13,7 +12,7 @@ body: - type: input id: version attributes: - label: What version of the VS Code extension are you using? + label: What version of the IDE extension are you using? validations: required: true - type: input @@ -34,20 +33,20 @@ body: attributes: label: What platform is your computer? description: | - For MacOS and Linux: copy the output of `uname -mprs` + For macOS and Linux: copy the output of `uname -mprs` For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console - type: textarea id: actual attributes: label: What issue are you seeing? - description: Please include the full error messages and prompts with PII redacted. If possible, please provide text instead of a screenshot. + description: Please include the full error messages and prompts with PII redacted. If possible, please provide text instead of a screenshot. validations: required: true - type: textarea id: steps attributes: label: What steps can reproduce the bug? - description: Explain the bug and provide a code snippet that can reproduce it. Please include session id, token limit usage, context window usage if applicable. + description: Explain the bug and provide a code snippet that can reproduce it. validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.yml b/.github/ISSUE_TEMPLATE/3-cli.yml similarity index 69% rename from .github/ISSUE_TEMPLATE/2-bug-report.yml rename to .github/ISSUE_TEMPLATE/3-cli.yml index 2d5ec2ad3f..4aff813e5f 100644 --- a/.github/ISSUE_TEMPLATE/2-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/3-cli.yml @@ -1,5 +1,5 @@ -name: πŸͺ² Bug Report -description: Report an issue that should be fixed +name: πŸ’» CLI Bug +description: Report an issue in the Codex CLI labels: - bug - needs triage @@ -7,19 +7,16 @@ body: - type: markdown attributes: value: | - Thank you for submitting a bug report! It helps make Codex better for everyone. - - If you need help or support using Codex, and are not reporting a bug, please post on [codex/discussions](https://github.com/openai/codex/discussions), where you can ask questions or engage with others on ideas for how to improve codex. + Before submitting a new issue, please search for existing issues to see if your issue has already been reported. + If it has, please add a πŸ‘ reaction (no need to leave a comment) to the existing issue instead of creating a new one. Make sure you are running the [latest](https://npmjs.com/package/@openai/codex) version of Codex CLI. The bug you are experiencing may already have been fixed. - Please try to include as much information as possible. - - type: input id: version attributes: - label: What version of Codex is running? - description: (App) look in "About Codex" dialog; (CLI) use `codex --version`; (IDE Extension) look in extensions panel. + label: What version of Codex CLI is running? + description: use `codex --version` validations: required: true - type: input @@ -32,13 +29,13 @@ body: id: model attributes: label: Which model were you using? - description: Like `gpt-4.1`, `o4-mini`, `o3`, etc. + description: Like `gpt-5.2`, `gpt-5.2-codex`, etc. - type: input id: platform attributes: label: What platform is your computer? description: | - For MacOS and Linux: copy the output of `uname -mprs` + For macOS and Linux: copy the output of `uname -mprs` For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console - type: input id: terminal @@ -58,7 +55,7 @@ body: id: steps attributes: label: What steps can reproduce the bug? - description: Explain the bug and provide a code snippet that can reproduce it. Please include session id, token limit usage, context window usage if applicable. + description: Explain the bug and provide a code snippet that can reproduce it. Please include thread id if applicable. validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/4-bug-report.yml b/.github/ISSUE_TEMPLATE/4-bug-report.yml new file mode 100644 index 0000000000..4de8841460 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-bug-report.yml @@ -0,0 +1,37 @@ +name: πŸͺ² Other Bug +description: Report an issue in Codex Web, integrations, or other Codex components +labels: + - bug +body: + - type: markdown + attributes: + value: | + Before submitting a new issue, please search for existing issues to see if your issue has already been reported. + If it has, please add a πŸ‘ reaction (no need to leave a comment) to the existing issue instead of creating a new one. + + If you need help or support using Codex and are not reporting a bug, please post on [codex/discussions](https://github.com/openai/codex/discussions), where you can ask questions or engage with others on ideas for how to improve codex. + + - type: textarea + id: actual + attributes: + label: What issue are you seeing? + description: Please include the full error messages and prompts with PII redacted. If possible, please provide text instead of a screenshot. + validations: + required: true + - type: textarea + id: steps + attributes: + label: What steps can reproduce the bug? + description: Explain the bug and provide a code snippet that can reproduce it. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What is the expected behavior? + description: If possible, please provide text instead of a screenshot. + - type: textarea + id: notes + attributes: + label: Additional information + description: Is there anything else you think we should know? diff --git a/.github/ISSUE_TEMPLATE/4-feature-request.yml b/.github/ISSUE_TEMPLATE/5-feature-request.yml similarity index 94% rename from .github/ISSUE_TEMPLATE/4-feature-request.yml rename to .github/ISSUE_TEMPLATE/5-feature-request.yml index 780fbfab36..55ff9fbbcd 100644 --- a/.github/ISSUE_TEMPLATE/4-feature-request.yml +++ b/.github/ISSUE_TEMPLATE/5-feature-request.yml @@ -16,7 +16,7 @@ body: id: variant attributes: label: What variant of Codex are you using? - description: (e.g., CLI, App, IDE Extension, Web) + description: (e.g., App, IDE Extension, CLI, Web) validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/3-docs-issue.yml b/.github/ISSUE_TEMPLATE/6-docs-issue.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/3-docs-issue.yml rename to .github/ISSUE_TEMPLATE/6-docs-issue.yml diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5d81ccc1eb..a2438fa5c2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -398,6 +398,15 @@ dependencies = [ "wiremock", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -924,6 +933,25 @@ dependencies = [ "bytes", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -1579,6 +1607,7 @@ dependencies = [ "which", "wildmatch", "wiremock", + "zip", "zstd", ] @@ -2354,6 +2383,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.6.0" @@ -2772,6 +2807,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + [[package]] name = "der" version = "0.7.10" @@ -2804,6 +2845,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -4930,6 +4982,27 @@ dependencies = [ "url", ] +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "maplit" version = "1.0.2" @@ -10516,6 +10589,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yansi" version = "1.0.1" @@ -10702,12 +10784,54 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.13.0", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zmij" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 22f53644f8..2c92f0b463 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -249,6 +249,7 @@ walkdir = "2.5.0" webbrowser = "1.0" which = "8" wildmatch = "2.6.1" +zip = "2.4.2" wiremock = "0.6" zeroize = "1.8.2" diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3461572779..39faeb1378 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2183,6 +2183,24 @@ }, "type": "object" }, + "SkillsRemoteReadParams": { + "type": "object" + }, + "SkillsRemoteWriteParams": { + "properties": { + "hazelnutId": { + "type": "string" + }, + "isPreload": { + "type": "boolean" + } + }, + "required": [ + "hazelnutId", + "isPreload" + ], + "type": "object" + }, "TextElement": { "properties": { "byteRange": { @@ -3312,6 +3330,54 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/remote/read" + ], + "title": "Skills/remote/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsRemoteReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/remote/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/remote/write" + ], + "title": "Skills/remote/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsRemoteWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/remote/writeRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 1274e1cd7f..ab556a77cf 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -2012,6 +2012,59 @@ "title": "ListSkillsResponseEventMsg", "type": "object" }, + { + "description": "List of remote skills available to the agent.", + "properties": { + "skills": { + "items": { + "$ref": "#/definitions/RemoteSkillSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_remote_skills_response" + ], + "title": "ListRemoteSkillsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "skills", + "type" + ], + "title": "ListRemoteSkillsResponseEventMsg", + "type": "object" + }, + { + "description": "Remote skill downloaded to local cache.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "remote_skill_downloaded" + ], + "title": "RemoteSkillDownloadedEventMsgType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "path", + "type" + ], + "title": "RemoteSkillDownloadedEventMsg", + "type": "object" + }, { "description": "Notification that skill data may have been updated and clients may want to reload.", "properties": { @@ -3429,6 +3482,25 @@ } ] }, + "RemoteSkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "RequestId": { "anyOf": [ { @@ -6536,6 +6608,59 @@ "title": "ListSkillsResponseEventMsg", "type": "object" }, + { + "description": "List of remote skills available to the agent.", + "properties": { + "skills": { + "items": { + "$ref": "#/definitions/RemoteSkillSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_remote_skills_response" + ], + "title": "ListRemoteSkillsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "skills", + "type" + ], + "title": "ListRemoteSkillsResponseEventMsg", + "type": "object" + }, + { + "description": "Remote skill downloaded to local cache.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "remote_skill_downloaded" + ], + "title": "RemoteSkillDownloadedEventMsgType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "path", + "type" + ], + "title": "RemoteSkillDownloadedEventMsg", + "type": "object" + }, { "description": "Notification that skill data may have been updated and clients may want to reload.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f89bcf24e4..f6d10c11c2 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2590,6 +2590,59 @@ "title": "ListSkillsResponseEventMsg", "type": "object" }, + { + "description": "List of remote skills available to the agent.", + "properties": { + "skills": { + "items": { + "$ref": "#/definitions/RemoteSkillSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_remote_skills_response" + ], + "title": "ListRemoteSkillsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "skills", + "type" + ], + "title": "ListRemoteSkillsResponseEventMsg", + "type": "object" + }, + { + "description": "Remote skill downloaded to local cache.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "remote_skill_downloaded" + ], + "title": "RemoteSkillDownloadedEventMsgType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "path", + "type" + ], + "title": "RemoteSkillDownloadedEventMsg", + "type": "object" + }, { "description": "Notification that skill data may have been updated and clients may want to reload.", "properties": { @@ -4469,6 +4522,25 @@ ], "type": "object" }, + "RemoteSkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "RequestId": { "anyOf": [ { 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 bc72ceb21f..83a7c781b0 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 @@ -718,6 +718,54 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/remote/read" + ], + "title": "Skills/remote/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/SkillsRemoteReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/remote/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/remote/write" + ], + "title": "Skills/remote/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/SkillsRemoteWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/remote/writeRequest", + "type": "object" + }, { "properties": { "id": { @@ -3852,6 +3900,59 @@ "title": "ListSkillsResponseEventMsg", "type": "object" }, + { + "description": "List of remote skills available to the agent.", + "properties": { + "skills": { + "items": { + "$ref": "#/definitions/v2/RemoteSkillSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_remote_skills_response" + ], + "title": "ListRemoteSkillsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "skills", + "type" + ], + "title": "ListRemoteSkillsResponseEventMsg", + "type": "object" + }, + { + "description": "Remote skill downloaded to local cache.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "remote_skill_downloaded" + ], + "title": "RemoteSkillDownloadedEventMsgType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "path", + "type" + ], + "title": "RemoteSkillDownloadedEventMsg", + "type": "object" + }, { "description": "Notification that skill data may have been updated and clients may want to reload.", "properties": { @@ -6351,6 +6452,25 @@ } ] }, + "RemoteSkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "RemoveConversationListenerParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12382,6 +12502,25 @@ "title": "ReasoningTextDeltaNotification", "type": "object" }, + "RemoteSkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "ResidencyRequirement": { "enum": [ "us" @@ -13417,6 +13556,65 @@ "title": "SkillsListResponse", "type": "object" }, + "SkillsRemoteReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SkillsRemoteReadParams", + "type": "object" + }, + "SkillsRemoteReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/RemoteSkillSummary" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "SkillsRemoteReadResponse", + "type": "object" + }, + "SkillsRemoteWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "hazelnutId": { + "type": "string" + }, + "isPreload": { + "type": "boolean" + } + }, + "required": [ + "hazelnutId", + "isPreload" + ], + "title": "SkillsRemoteWriteParams", + "type": "object" + }, + "SkillsRemoteWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "path" + ], + "title": "SkillsRemoteWriteResponse", + "type": "object" + }, "SubAgentSource": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index 9894c47c1e..215c80b28d 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -2012,6 +2012,59 @@ "title": "ListSkillsResponseEventMsg", "type": "object" }, + { + "description": "List of remote skills available to the agent.", + "properties": { + "skills": { + "items": { + "$ref": "#/definitions/RemoteSkillSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_remote_skills_response" + ], + "title": "ListRemoteSkillsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "skills", + "type" + ], + "title": "ListRemoteSkillsResponseEventMsg", + "type": "object" + }, + { + "description": "Remote skill downloaded to local cache.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "remote_skill_downloaded" + ], + "title": "RemoteSkillDownloadedEventMsgType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "path", + "type" + ], + "title": "RemoteSkillDownloadedEventMsg", + "type": "object" + }, { "description": "Notification that skill data may have been updated and clients may want to reload.", "properties": { @@ -3429,6 +3482,25 @@ } ] }, + "RemoteSkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "RequestId": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index fe4037a40f..bd6d80b6ed 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -2012,6 +2012,59 @@ "title": "ListSkillsResponseEventMsg", "type": "object" }, + { + "description": "List of remote skills available to the agent.", + "properties": { + "skills": { + "items": { + "$ref": "#/definitions/RemoteSkillSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_remote_skills_response" + ], + "title": "ListRemoteSkillsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "skills", + "type" + ], + "title": "ListRemoteSkillsResponseEventMsg", + "type": "object" + }, + { + "description": "Remote skill downloaded to local cache.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "remote_skill_downloaded" + ], + "title": "RemoteSkillDownloadedEventMsgType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "path", + "type" + ], + "title": "RemoteSkillDownloadedEventMsg", + "type": "object" + }, { "description": "Notification that skill data may have been updated and clients may want to reload.", "properties": { @@ -3429,6 +3482,25 @@ } ] }, + "RemoteSkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "RequestId": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index 8e4c361979..a61d4141a6 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -2012,6 +2012,59 @@ "title": "ListSkillsResponseEventMsg", "type": "object" }, + { + "description": "List of remote skills available to the agent.", + "properties": { + "skills": { + "items": { + "$ref": "#/definitions/RemoteSkillSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_remote_skills_response" + ], + "title": "ListRemoteSkillsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "skills", + "type" + ], + "title": "ListRemoteSkillsResponseEventMsg", + "type": "object" + }, + { + "description": "Remote skill downloaded to local cache.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "remote_skill_downloaded" + ], + "title": "RemoteSkillDownloadedEventMsgType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "path", + "type" + ], + "title": "RemoteSkillDownloadedEventMsg", + "type": "object" + }, { "description": "Notification that skill data may have been updated and clients may want to reload.", "properties": { @@ -3429,6 +3482,25 @@ } ] }, + "RemoteSkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "RequestId": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json new file mode 100644 index 0000000000..ace2fb5ba7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SkillsRemoteReadParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadResponse.json new file mode 100644 index 0000000000..a8e19c65bb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadResponse.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "RemoteSkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/RemoteSkillSummary" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "SkillsRemoteReadResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json new file mode 100644 index 0000000000..871a5a428a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "hazelnutId": { + "type": "string" + }, + "isPreload": { + "type": "boolean" + } + }, + "required": [ + "hazelnutId", + "isPreload" + ], + "title": "SkillsRemoteWriteParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json new file mode 100644 index 0000000000..1a9473d054 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "path" + ], + "title": "SkillsRemoteWriteResponse", + "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 b65d9ee931..d526e5c5e9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -36,6 +36,8 @@ import type { ModelListParams } from "./v2/ModelListParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; +import type { SkillsRemoteReadParams } from "./v2/SkillsRemoteReadParams"; +import type { SkillsRemoteWriteParams } from "./v2/SkillsRemoteWriteParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; import type { ThreadListParams } from "./v2/ThreadListParams"; @@ -52,4 +54,4 @@ import type { TurnStartParams } from "./v2/TurnStartParams"; /** * 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/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "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": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "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": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "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": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; +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/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "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": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/read", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/write", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "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": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "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": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts index 5f158de2cb..c18088eaaf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts +++ b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts @@ -33,6 +33,7 @@ import type { GetHistoryEntryResponseEvent } from "./GetHistoryEntryResponseEven import type { ItemCompletedEvent } from "./ItemCompletedEvent"; import type { ItemStartedEvent } from "./ItemStartedEvent"; import type { ListCustomPromptsResponseEvent } from "./ListCustomPromptsResponseEvent"; +import type { ListRemoteSkillsResponseEvent } from "./ListRemoteSkillsResponseEvent"; import type { ListSkillsResponseEvent } from "./ListSkillsResponseEvent"; import type { McpListToolsResponseEvent } from "./McpListToolsResponseEvent"; import type { McpStartupCompleteEvent } from "./McpStartupCompleteEvent"; @@ -45,6 +46,7 @@ import type { PlanDeltaEvent } from "./PlanDeltaEvent"; import type { RawResponseItemEvent } from "./RawResponseItemEvent"; import type { ReasoningContentDeltaEvent } from "./ReasoningContentDeltaEvent"; import type { ReasoningRawContentDeltaEvent } from "./ReasoningRawContentDeltaEvent"; +import type { RemoteSkillDownloadedEvent } from "./RemoteSkillDownloadedEvent"; import type { RequestUserInputEvent } from "./RequestUserInputEvent"; import type { ReviewRequest } from "./ReviewRequest"; import type { SessionConfiguredEvent } from "./SessionConfiguredEvent"; @@ -70,4 +72,4 @@ import type { WebSearchEndEvent } from "./WebSearchEndEvent"; * Response event from the agent * NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. */ -export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent; +export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent; diff --git a/codex-rs/app-server-protocol/schema/typescript/ListRemoteSkillsResponseEvent.ts b/codex-rs/app-server-protocol/schema/typescript/ListRemoteSkillsResponseEvent.ts new file mode 100644 index 0000000000..e3b277f4d6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ListRemoteSkillsResponseEvent.ts @@ -0,0 +1,9 @@ +// 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 { RemoteSkillSummary } from "./RemoteSkillSummary"; + +/** + * Response payload for `Op::ListRemoteSkills`. + */ +export type ListRemoteSkillsResponseEvent = { skills: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RemoteSkillDownloadedEvent.ts b/codex-rs/app-server-protocol/schema/typescript/RemoteSkillDownloadedEvent.ts new file mode 100644 index 0000000000..83082f2a57 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RemoteSkillDownloadedEvent.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * Response payload for `Op::DownloadRemoteSkill`. + */ +export type RemoteSkillDownloadedEvent = { id: string, name: string, path: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RemoteSkillSummary.ts b/codex-rs/app-server-protocol/schema/typescript/RemoteSkillSummary.ts new file mode 100644 index 0000000000..7bf57b3b09 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RemoteSkillSummary.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 RemoteSkillSummary = { id: string, name: string, description: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 91da0708e1..7d3ecb818e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -98,6 +98,7 @@ export type { ItemStartedEvent } from "./ItemStartedEvent"; export type { ListConversationsParams } from "./ListConversationsParams"; export type { ListConversationsResponse } from "./ListConversationsResponse"; export type { ListCustomPromptsResponseEvent } from "./ListCustomPromptsResponseEvent"; +export type { ListRemoteSkillsResponseEvent } from "./ListRemoteSkillsResponseEvent"; export type { ListSkillsResponseEvent } from "./ListSkillsResponseEvent"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; @@ -140,6 +141,8 @@ export type { ReasoningItemContent } from "./ReasoningItemContent"; export type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; export type { ReasoningRawContentDeltaEvent } from "./ReasoningRawContentDeltaEvent"; export type { ReasoningSummary } from "./ReasoningSummary"; +export type { RemoteSkillDownloadedEvent } from "./RemoteSkillDownloadedEvent"; +export type { RemoteSkillSummary } from "./RemoteSkillSummary"; export type { RemoveConversationListenerParams } from "./RemoveConversationListenerParams"; export type { RemoveConversationSubscriptionResponse } from "./RemoveConversationSubscriptionResponse"; export type { RequestId } from "./RequestId"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteSkillSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteSkillSummary.ts new file mode 100644 index 0000000000..7bf57b3b09 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteSkillSummary.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 RemoteSkillSummary = { id: string, name: string, description: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts new file mode 100644 index 0000000000..9f91787686 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.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 SkillsRemoteReadParams = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadResponse.ts new file mode 100644 index 0000000000..c1c7b1cc70 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadResponse.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 { RemoteSkillSummary } from "./RemoteSkillSummary"; + +export type SkillsRemoteReadResponse = { data: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts new file mode 100644 index 0000000000..857b609ef1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.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 SkillsRemoteWriteParams = { hazelnutId: string, isPreload: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts new file mode 100644 index 0000000000..cf1665ab97 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.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 SkillsRemoteWriteResponse = { id: string, name: string, path: string, }; 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 0c50231a80..83357ff5c6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -96,6 +96,7 @@ export type { ReasoningEffortOption } from "./ReasoningEffortOption"; export type { ReasoningSummaryPartAddedNotification } from "./ReasoningSummaryPartAddedNotification"; export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTextDeltaNotification"; export type { ReasoningTextDeltaNotification } from "./ReasoningTextDeltaNotification"; +export type { RemoteSkillSummary } from "./RemoteSkillSummary"; export type { ResidencyRequirement } from "./ResidencyRequirement"; export type { ReviewDelivery } from "./ReviewDelivery"; export type { ReviewStartParams } from "./ReviewStartParams"; @@ -116,6 +117,10 @@ export type { SkillsConfigWriteResponse } from "./SkillsConfigWriteResponse"; export type { SkillsListEntry } from "./SkillsListEntry"; export type { SkillsListParams } from "./SkillsListParams"; export type { SkillsListResponse } from "./SkillsListResponse"; +export type { SkillsRemoteReadParams } from "./SkillsRemoteReadParams"; +export type { SkillsRemoteReadResponse } from "./SkillsRemoteReadResponse"; +export type { SkillsRemoteWriteParams } from "./SkillsRemoteWriteParams"; +export type { SkillsRemoteWriteResponse } from "./SkillsRemoteWriteResponse"; export type { TerminalInteractionNotification } from "./TerminalInteractionNotification"; export type { TextElement } from "./TextElement"; export type { TextPosition } from "./TextPosition"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d20f23a88a..bb2510684e 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -228,6 +228,14 @@ client_request_definitions! { params: v2::SkillsListParams, response: v2::SkillsListResponse, }, + SkillsRemoteRead => "skills/remote/read" { + params: v2::SkillsRemoteReadParams, + response: v2::SkillsRemoteReadResponse, + }, + SkillsRemoteWrite => "skills/remote/write" { + params: v2::SkillsRemoteWriteParams, + response: v2::SkillsRemoteWriteResponse, + }, AppsList => "app/list" { params: v2::AppsListParams, response: v2::AppsListResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7ce8cc2ed3..67e7013a31 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1554,6 +1554,44 @@ pub struct SkillsListResponse { pub data: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsRemoteReadParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteSkillSummary { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsRemoteReadResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsRemoteWriteParams { + pub hazelnut_id: String, + pub is_preload: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsRemoteWriteResponse { + pub id: String, + pub name: String, + pub path: PathBuf, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1d5b6d15fd..d6fae48466 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -93,6 +93,8 @@ Example (from OpenAI's official VSCode extension): - `model/list` β€” list available models (with reasoning effort options). - `collaborationMode/list` β€” list available collaboration mode presets (experimental, no pagination). - `skills/list` β€” list skills for one or more `cwd` values (optional `forceReload`). +- `skills/remote/read` β€” list public remote skills (**under development; do not call from production clients yet**). +- `skills/remote/write` β€” download a public remote skill by `hazelnutId`; `isPreload=true` writes to `.codex/vendor_imports/skills` under `codex_home` (**under development; do not call from production clients yet**). - `app/list` β€” list available apps. - `skills/config/write` β€” write user-level skill config by path. - `mcpServer/oauth/login` β€” start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2a1c534056..866998a4fd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -97,6 +97,10 @@ use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::SkillsRemoteReadParams; +use codex_app_server_protocol::SkillsRemoteReadResponse; +use codex_app_server_protocol::SkillsRemoteWriteParams; +use codex_app_server_protocol::SkillsRemoteWriteResponse; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; @@ -176,6 +180,8 @@ use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; use codex_core::rollout_date_parts; use codex_core::sandboxing::SandboxPermissions; +use codex_core::skills::remote::download_remote_skill; +use codex_core::skills::remote::list_remote_skills; use codex_core::state_db::get_state_db; use codex_core::token_data::parse_id_token; use codex_core::windows_sandbox::WindowsSandboxLevelExt; @@ -464,6 +470,12 @@ impl CodexMessageProcessor { ClientRequest::SkillsList { request_id, params } => { self.skills_list(request_id, params).await; } + ClientRequest::SkillsRemoteRead { request_id, params } => { + self.skills_remote_read(request_id, params).await; + } + ClientRequest::SkillsRemoteWrite { request_id, params } => { + self.skills_remote_write(request_id, params).await; + } ClientRequest::AppsList { request_id, params } => { self.apps_list(request_id, params).await; } @@ -4053,6 +4065,61 @@ impl CodexMessageProcessor { .await; } + async fn skills_remote_read(&self, request_id: RequestId, _params: SkillsRemoteReadParams) { + match list_remote_skills(&self.config).await { + Ok(skills) => { + let data = skills + .into_iter() + .map(|skill| codex_app_server_protocol::RemoteSkillSummary { + id: skill.id, + name: skill.name, + description: skill.description, + }) + .collect(); + self.outgoing + .send_response(request_id, SkillsRemoteReadResponse { data }) + .await; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to read remote skills: {err}"), + ) + .await; + } + } + } + + async fn skills_remote_write(&self, request_id: RequestId, params: SkillsRemoteWriteParams) { + let SkillsRemoteWriteParams { + hazelnut_id, + is_preload, + } = params; + let response = download_remote_skill(&self.config, hazelnut_id.as_str(), is_preload).await; + + match response { + Ok(downloaded) => { + self.outgoing + .send_response( + request_id, + SkillsRemoteWriteResponse { + id: downloaded.id, + name: downloaded.name, + path: downloaded.path, + }, + ) + .await; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to download remote skill: {err}"), + ) + .await; + } + } + } + async fn skills_config_write(&self, request_id: RequestId, params: SkillsConfigWriteParams) { let SkillsConfigWriteParams { path, enabled } = params; let edits = vec![ConfigEdit::SetSkillConfig { path, enabled }]; diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index bf2f7afb7c..9c455ddbba 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -1,3 +1,4 @@ +use std::fs::File; use std::future::Future; use std::path::Path; use std::path::PathBuf; @@ -10,8 +11,24 @@ use tempfile::TempDir; const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox"; const APPLY_PATCH_ARG0: &str = "apply_patch"; const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch"; +const LOCK_FILENAME: &str = ".lock"; -pub fn arg0_dispatch() -> Option { +/// Keeps the per-session PATH entry alive and locked for the process lifetime. +pub struct Arg0PathEntryGuard { + _temp_dir: TempDir, + _lock_file: File, +} + +impl Arg0PathEntryGuard { + fn new(temp_dir: TempDir, lock_file: File) -> Self { + Self { + _temp_dir: temp_dir, + _lock_file: lock_file, + } + } +} + +pub fn arg0_dispatch() -> Option { // Determine if we were invoked via the special alias. let mut args = std::env::args_os(); let argv0 = args.next().unwrap_or_default(); @@ -149,7 +166,7 @@ where /// /// IMPORTANT: This function modifies the PATH environment variable, so it MUST /// be called before multiple threads are spawned. -pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { +pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { let codex_home = codex_core::config::find_codex_home()?; #[cfg(not(debug_assertions))] { @@ -167,7 +184,7 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { std::fs::create_dir_all(&codex_home)?; // Use a CODEX_HOME-scoped temp root to avoid cluttering the top-level directory. - let temp_root = codex_home.join("tmp").join("path"); + let temp_root = codex_home.join("tmp").join("arg0"); std::fs::create_dir_all(&temp_root)?; #[cfg(unix)] { @@ -177,11 +194,25 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { std::fs::set_permissions(&temp_root, std::fs::Permissions::from_mode(0o700))?; } + // Best-effort cleanup of stale per-session dirs. Ignore failures so startup proceeds. + if let Err(err) = janitor_cleanup(&temp_root) { + eprintln!("WARNING: failed to clean up stale arg0 temp dirs: {err}"); + } + let temp_dir = tempfile::Builder::new() .prefix("codex-arg0") .tempdir_in(&temp_root)?; let path = temp_dir.path(); + let lock_path = path.join(LOCK_FILENAME); + let lock_file = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path)?; + lock_file.try_lock()?; + for filename in &[ APPLY_PATCH_ARG0, MISSPELLED_APPLY_PATCH_ARG0, @@ -231,5 +262,107 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { std::env::set_var("PATH", updated_path_env_var); } - Ok(temp_dir) + Ok(Arg0PathEntryGuard::new(temp_dir, lock_file)) +} + +fn janitor_cleanup(temp_root: &Path) -> std::io::Result<()> { + let entries = match std::fs::read_dir(temp_root) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + // Skip the directory if locking fails or the lock is currently held. + let Some(_lock_file) = try_lock_dir(&path)? else { + continue; + }; + + match std::fs::remove_dir_all(&path) { + Ok(()) => {} + // Expected TOCTOU race: directory can disappear after read_dir/lock checks. + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(err) => return Err(err), + } + } + + Ok(()) +} + +fn try_lock_dir(dir: &Path) -> std::io::Result> { + let lock_path = dir.join(LOCK_FILENAME); + let lock_file = match File::options().read(true).write(true).open(&lock_path) { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + + match lock_file.try_lock() { + Ok(()) => Ok(Some(lock_file)), + Err(std::fs::TryLockError::WouldBlock) => Ok(None), + Err(err) => Err(err.into()), + } +} + +#[cfg(test)] +mod tests { + use super::LOCK_FILENAME; + use super::janitor_cleanup; + use std::fs; + use std::fs::File; + use std::path::Path; + + fn create_lock(dir: &Path) -> std::io::Result { + let lock_path = dir.join(LOCK_FILENAME); + File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(lock_path) + } + + #[test] + fn janitor_skips_dirs_without_lock_file() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let dir = root.path().join("no-lock"); + fs::create_dir(&dir)?; + + janitor_cleanup(root.path())?; + + assert!(dir.exists()); + Ok(()) + } + + #[test] + fn janitor_skips_dirs_with_held_lock() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let dir = root.path().join("locked"); + fs::create_dir(&dir)?; + let lock_file = create_lock(&dir)?; + lock_file.try_lock()?; + + janitor_cleanup(root.path())?; + + assert!(dir.exists()); + Ok(()) + } + + #[test] + fn janitor_removes_dirs_with_unlocked_lock() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let dir = root.path().join("stale"); + fs::create_dir(&dir)?; + create_lock(&dir)?; + + janitor_cleanup(root.path())?; + + assert!(!dir.exists()); + Ok(()) + } } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index b4e271f51c..c26ad9dc16 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -108,6 +108,7 @@ url = { workspace = true } uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } wildmatch = { workspace = true } +zip = { workspace = true } [features] deterministic_process_ids = [] diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ba34af5ed8..b7c0404056 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2469,6 +2469,22 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::ListSkills { cwds, force_reload } => { handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await; } + Op::ListRemoteSkills => { + handlers::list_remote_skills(&sess, &config, sub.id.clone()).await; + } + Op::DownloadRemoteSkill { + hazelnut_id, + is_preload, + } => { + handlers::download_remote_skill( + &sess, + &config, + sub.id.clone(), + hazelnut_id, + is_preload, + ) + .await; + } Op::Undo => { handlers::undo(&sess, sub.id.clone()).await; } @@ -2535,9 +2551,12 @@ mod handlers { use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ListCustomPromptsResponseEvent; + use codex_protocol::protocol::ListRemoteSkillsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; + use codex_protocol::protocol::RemoteSkillDownloadedEvent; + use codex_protocol::protocol::RemoteSkillSummary; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SkillsListEntry; @@ -2895,6 +2914,77 @@ mod handlers { sess.send_event_raw(event).await; } + pub async fn list_remote_skills(sess: &Session, config: &Arc, sub_id: String) { + let response = crate::skills::remote::list_remote_skills(config) + .await + .map(|skills| { + skills + .into_iter() + .map(|skill| RemoteSkillSummary { + id: skill.id, + name: skill.name, + description: skill.description, + }) + .collect::>() + }); + + match response { + Ok(skills) => { + let event = Event { + id: sub_id, + msg: EventMsg::ListRemoteSkillsResponse(ListRemoteSkillsResponseEvent { + skills, + }), + }; + sess.send_event_raw(event).await; + } + Err(err) => { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: format!("failed to list remote skills: {err}"), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }; + sess.send_event_raw(event).await; + } + } + } + + pub async fn download_remote_skill( + sess: &Session, + config: &Arc, + sub_id: String, + hazelnut_id: String, + is_preload: bool, + ) { + match crate::skills::remote::download_remote_skill(config, hazelnut_id.as_str(), is_preload) + .await + { + Ok(result) => { + let event = Event { + id: sub_id, + msg: EventMsg::RemoteSkillDownloaded(RemoteSkillDownloadedEvent { + id: result.id, + name: result.name, + path: result.path, + }), + }; + sess.send_event_raw(event).await; + } + Err(err) => { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: format!("failed to download remote skill {hazelnut_id}: {err}"), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }; + sess.send_event_raw(event).await; + } + } + } + pub async fn undo(sess: &Arc, sub_id: String) { let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; sess.spawn_task(turn_context, Vec::new(), UndoTask::new()) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 912d43eac9..73da03d83a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1103,7 +1103,11 @@ impl ConfigToml { profile_sandbox_mode: Option, windows_sandbox_level: WindowsSandboxLevel, resolved_cwd: &Path, + sandbox_policy_constraint: Option<&Constrained>, ) -> SandboxPolicyResolution { + let sandbox_mode_was_explicit = sandbox_mode_override.is_some() + || profile_sandbox_mode.is_some() + || self.sandbox_mode.is_some(); let resolved_sandbox_mode = sandbox_mode_override .or(profile_sandbox_mode) .or(self.sandbox_mode) @@ -1137,13 +1141,30 @@ impl ConfigToml { SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess, }; let mut forced_auto_mode_downgraded_on_windows = false; - if cfg!(target_os = "windows") - && matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) - // If the experimental Windows sandbox is enabled, do not force a downgrade. - && windows_sandbox_level == codex_protocol::config_types::WindowsSandboxLevel::Disabled + let mut downgrade_workspace_write_if_unsupported = |policy: &mut SandboxPolicy| { + if cfg!(target_os = "windows") + // If the experimental Windows sandbox is enabled, do not force a downgrade. + && windows_sandbox_level + == codex_protocol::config_types::WindowsSandboxLevel::Disabled + && matches!(&*policy, SandboxPolicy::WorkspaceWrite { .. }) + { + *policy = SandboxPolicy::new_read_only_policy(); + forced_auto_mode_downgraded_on_windows = true; + } + }; + if matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) { + downgrade_workspace_write_if_unsupported(&mut sandbox_policy); + } + if !sandbox_mode_was_explicit + && let Some(constraint) = sandbox_policy_constraint + && let Err(err) = constraint.can_set(&sandbox_policy) { - sandbox_policy = SandboxPolicy::new_read_only_policy(); - forced_auto_mode_downgraded_on_windows = true; + tracing::warn!( + error = %err, + "default sandbox policy is disallowed by requirements; falling back to required default" + ); + sandbox_policy = constraint.get().clone(); + downgrade_workspace_write_if_unsupported(&mut sandbox_policy); } SandboxPolicyResolution { policy: sandbox_policy, @@ -1373,6 +1394,9 @@ impl Config { let active_project = cfg .get_active_project(&resolved_cwd) .unwrap_or(ProjectConfig { trust_level: None }); + let sandbox_mode_was_explicit = sandbox_mode.is_some() + || config_profile.sandbox_mode.is_some() + || cfg.sandbox_mode.is_some(); let windows_sandbox_level = WindowsSandboxLevel::from_features(&features); let SandboxPolicyResolution { @@ -1383,6 +1407,7 @@ impl Config { config_profile.sandbox_mode, windows_sandbox_level, &resolved_cwd, + Some(&requirements.sandbox_policy), ); if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { for path in additional_writable_roots { @@ -1391,7 +1416,10 @@ impl Config { } } } - let approval_policy = approval_policy_override + let approval_policy_was_explicit = approval_policy_override.is_some() + || config_profile.approval_policy.is_some() + || cfg.approval_policy.is_some(); + let mut approval_policy = approval_policy_override .or(config_profile.approval_policy) .or(cfg.approval_policy) .unwrap_or_else(|| { @@ -1403,16 +1431,20 @@ impl Config { AskForApproval::default() } }); + if !approval_policy_was_explicit + && let Err(err) = requirements.approval_policy.can_set(&approval_policy) + { + tracing::warn!( + error = %err, + "default approval policy is disallowed by requirements; falling back to required default" + ); + approval_policy = requirements.approval_policy.value(); + } let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features); // TODO(dylan): We should be able to leverage ConfigLayerStack so that // we can reliably check this at every config level. - let did_user_set_custom_approval_policy_or_sandbox_mode = approval_policy_override - .is_some() - || config_profile.approval_policy.is_some() - || cfg.approval_policy.is_some() - || sandbox_mode.is_some() - || config_profile.sandbox_mode.is_some() - || cfg.sandbox_mode.is_some(); + let did_user_set_custom_approval_policy_or_sandbox_mode = + approval_policy_was_explicit || sandbox_mode_was_explicit; let mut model_providers = built_in_model_providers(); // Merge user-defined providers into the built-in list. @@ -1934,6 +1966,7 @@ network_access = false # This should be ignored. None, WindowsSandboxLevel::Disabled, &PathBuf::from("/tmp/test"), + None, ); assert_eq!( resolution, @@ -1958,6 +1991,7 @@ network_access = true # This should be ignored. None, WindowsSandboxLevel::Disabled, &PathBuf::from("/tmp/test"), + None, ); assert_eq!( resolution, @@ -1990,6 +2024,7 @@ exclude_slash_tmp = true None, WindowsSandboxLevel::Disabled, &PathBuf::from("/tmp/test"), + None, ); if cfg!(target_os = "windows") { assert_eq!( @@ -2039,6 +2074,7 @@ trust_level = "trusted" None, WindowsSandboxLevel::Disabled, &PathBuf::from("/tmp/test"), + None, ); if cfg!(target_os = "windows") { assert_eq!( @@ -4350,6 +4386,7 @@ trust_level = "untrusted" None, WindowsSandboxLevel::Disabled, &PathBuf::from("/tmp/test"), + None, ); // Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade) @@ -4370,6 +4407,103 @@ trust_level = "untrusted" Ok(()) } + #[test] + fn derive_sandbox_policy_falls_back_to_constraint_value_for_implicit_defaults() + -> anyhow::Result<()> { + let project_dir = TempDir::new()?; + let project_path = project_dir.path().to_path_buf(); + let project_key = project_path.to_string_lossy().to_string(); + let cfg = ConfigToml { + projects: Some(HashMap::from([( + project_key, + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }; + let constrained = Constrained::new(SandboxPolicy::DangerFullAccess, |candidate| { + if matches!(candidate, SandboxPolicy::DangerFullAccess) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{candidate:?}"), + allowed: "[DangerFullAccess]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + } + })?; + + let resolution = cfg.derive_sandbox_policy( + None, + None, + WindowsSandboxLevel::Disabled, + &project_path, + Some(&constrained), + ); + + assert_eq!(resolution.policy, SandboxPolicy::DangerFullAccess); + Ok(()) + } + + #[test] + fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallback() + -> anyhow::Result<()> { + let project_dir = TempDir::new()?; + let project_path = project_dir.path().to_path_buf(); + let project_key = project_path.to_string_lossy().to_string(); + let cfg = ConfigToml { + projects: Some(HashMap::from([( + project_key, + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }; + let constrained = + Constrained::new(SandboxPolicy::new_workspace_write_policy(), |candidate| { + if matches!(candidate, SandboxPolicy::WorkspaceWrite { .. }) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{candidate:?}"), + allowed: "[WorkspaceWrite]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + } + })?; + + let resolution = cfg.derive_sandbox_policy( + None, + None, + WindowsSandboxLevel::Disabled, + &project_path, + Some(&constrained), + ); + + if cfg!(target_os = "windows") { + assert_eq!( + resolution, + SandboxPolicyResolution { + policy: SandboxPolicy::ReadOnly, + forced_auto_mode_downgraded_on_windows: true, + } + ); + } else { + assert_eq!( + resolution, + SandboxPolicyResolution { + policy: SandboxPolicy::new_workspace_write_policy(), + forced_auto_mode_downgraded_on_windows: false, + } + ); + } + Ok(()) + } + #[test] fn test_resolve_oss_provider_explicit_override() { let config_toml = ConfigToml::default(); @@ -4525,6 +4659,125 @@ mcp_oauth_callback_port = 5678 Ok(()) } + + #[tokio::test] + async fn requirements_disallowing_default_sandbox_falls_back_to_required_default() + -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Some(crate::config_loader::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![ + crate::config_loader::SandboxModeRequirement::ReadOnly, + ]), + ..Default::default() + }) + })) + .build() + .await?; + + assert_eq!(*config.sandbox_policy.get(), SandboxPolicy::ReadOnly); + Ok(()) + } + + #[tokio::test] + async fn explicit_sandbox_mode_still_errors_when_disallowed_by_requirements() + -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"sandbox_mode = "danger-full-access" +"#, + )?; + + let requirements = crate::config_loader::ConfigRequirementsToml { + allowed_approval_policies: None, + allowed_sandbox_modes: Some(vec![ + crate::config_loader::SandboxModeRequirement::ReadOnly, + ]), + mcp_servers: None, + rules: None, + enforce_residency: None, + }; + + let err = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new( + async move { Some(requirements) }, + )) + .build() + .await + .expect_err("explicit disallowed mode should still fail"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + let message = err.to_string(); + assert!(message.contains("invalid value for `sandbox_mode`")); + assert!(message.contains("set by cloud requirements")); + Ok(()) + } + + #[tokio::test] + async fn requirements_disallowing_default_approval_falls_back_to_required_default() + -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +[projects."{workspace_key}"] +trust_level = "untrusted" +"# + ), + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(workspace.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Some(crate::config_loader::ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + ..Default::default() + }) + })) + .build() + .await?; + + assert_eq!(config.approval_policy.value(), AskForApproval::OnRequest); + Ok(()) + } + + #[tokio::test] + async fn explicit_approval_policy_still_errors_when_disallowed_by_requirements() + -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"approval_policy = "untrusted" +"#, + )?; + + let err = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Some(crate::config_loader::ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + ..Default::default() + }) + })) + .build() + .await + .expect_err("explicit disallowed approval policy should fail"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + let message = err.to_string(); + assert!(message.contains("invalid value for `approval_policy`")); + assert!(message.contains("set by cloud requirements")); + Ok(()) + } } #[cfg(test)] diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 1a5ec559b8..c5fbb8ec3d 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -463,16 +463,8 @@ impl McpConnectionManager { #[instrument(level = "trace", skip_all)] pub async fn list_all_tools(&self) -> HashMap { let mut tools = HashMap::new(); - for (server_name, managed_client) in &self.clients { - let client = if server_name == CODEX_APPS_MCP_SERVER_NAME { - // Avoid blocking on codex_apps_mcp startup; use tools only when ready. - match managed_client.client.clone().now_or_never() { - Some(Ok(client)) => Some(client), - _ => None, - } - } else { - managed_client.client().await.ok() - }; + for managed_client in self.clients.values() { + let client = managed_client.client().await.ok(); if let Some(client) = client { tools.extend(qualify_tools(filter_tools( client.tools, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 5796bd1961..587b68a913 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -90,6 +90,8 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::McpStartupComplete(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) + | EventMsg::ListRemoteSkillsResponse(_) + | EventMsg::RemoteSkillDownloaded(_) | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete | EventMsg::ViewImageToolCall(_) diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index 4d79bca258..6148092a12 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -3,6 +3,7 @@ pub mod injection; pub mod loader; pub mod manager; pub mod model; +pub mod remote; pub mod render; pub mod system; diff --git a/codex-rs/core/src/skills/remote.rs b/codex-rs/core/src/skills/remote.rs new file mode 100644 index 0000000000..af6dd5593a --- /dev/null +++ b/codex-rs/core/src/skills/remote.rs @@ -0,0 +1,314 @@ +use anyhow::Context; +use anyhow::Result; +use serde::Deserialize; +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use crate::config::Config; +use crate::default_client::build_reqwest_client; + +const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteSkillSummary { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteSkillDownload { + pub id: String, + pub name: String, + pub base_sediment_id: String, + pub files: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteSkillDownloadResult { + pub id: String, + pub name: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteSkillFileRange { + pub start: u64, + pub length: u64, +} + +#[derive(Debug, Deserialize)] +struct RemoteSkillsResponse { + hazelnuts: Vec, +} + +#[derive(Debug, Deserialize)] +struct RemoteSkill { + id: String, + name: String, + description: String, +} + +#[derive(Debug, Deserialize)] +struct RemoteSkillsDownloadResponse { + hazelnuts: Vec, +} + +#[derive(Debug, Deserialize)] +struct RemoteSkillDownloadPayload { + id: String, + name: String, + #[serde(rename = "base_sediment_id")] + base_sediment_id: String, + files: HashMap, +} + +#[derive(Debug, Deserialize)] +struct RemoteSkillFileRangePayload { + start: u64, + length: u64, +} + +pub async fn list_remote_skills(config: &Config) -> Result> { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url); + let url = format!("{base_url}/public-api/hazelnuts/"); + + let client = build_reqwest_client(); + let response = client + .get(&url) + .timeout(REMOTE_SKILLS_API_TIMEOUT) + .query(&[("product_surface", "codex")]) + .send() + .await + .with_context(|| format!("Failed to send request to {url}"))?; + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("Request failed with status {status} from {url}: {body}"); + } + + let parsed: RemoteSkillsResponse = + serde_json::from_str(&body).context("Failed to parse skills response")?; + + Ok(parsed + .hazelnuts + .into_iter() + .map(|skill| RemoteSkillSummary { + id: skill.id, + name: skill.name, + description: skill.description, + }) + .collect()) +} + +pub async fn download_remote_skill( + config: &Config, + hazelnut_id: &str, + is_preload: bool, +) -> Result { + let hazelnut = fetch_remote_skill(config, hazelnut_id).await?; + + let client = build_reqwest_client(); + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url); + let url = format!("{base_url}/public-api/hazelnuts/{hazelnut_id}/export"); + let response = client + .get(&url) + .timeout(REMOTE_SKILLS_API_TIMEOUT) + .send() + .await + .with_context(|| format!("Failed to send download request to {url}"))?; + + let status = response.status(); + let body = response.bytes().await.context("Failed to read download")?; + if !status.is_success() { + let body_text = String::from_utf8_lossy(&body); + anyhow::bail!("Download failed with status {status} from {url}: {body_text}"); + } + + if !is_zip_payload(&body) { + anyhow::bail!("Downloaded remote skill payload is not a zip archive"); + } + + let preferred_dir_name = if hazelnut.name.trim().is_empty() { + None + } else { + Some(hazelnut.name.as_str()) + }; + let dir_name = preferred_dir_name + .and_then(validate_dir_name_format) + .or_else(|| validate_dir_name_format(&hazelnut.id)) + .ok_or_else(|| anyhow::anyhow!("Remote skill has no valid directory name"))?; + let output_root = if is_preload { + config + .codex_home + .join("vendor_imports") + .join("skills") + .join("skills") + .join(".curated") + } else { + config.codex_home.join("skills").join("downloaded") + }; + let output_dir = output_root.join(dir_name); + tokio::fs::create_dir_all(&output_dir) + .await + .context("Failed to create downloaded skills directory")?; + + let allowed_files = hazelnut.files.keys().cloned().collect::>(); + let zip_bytes = body.to_vec(); + let output_dir_clone = output_dir.clone(); + let prefix_candidates = vec![hazelnut.name.clone(), hazelnut.id.clone()]; + tokio::task::spawn_blocking(move || { + extract_zip_to_dir( + zip_bytes, + &output_dir_clone, + &allowed_files, + &prefix_candidates, + ) + }) + .await + .context("Zip extraction task failed")??; + + Ok(RemoteSkillDownloadResult { + id: hazelnut.id, + name: hazelnut.name, + path: output_dir, + }) +} + +fn safe_join(base: &Path, name: &str) -> Result { + let path = Path::new(name); + for component in path.components() { + match component { + Component::Normal(_) => {} + _ => { + anyhow::bail!("Invalid file path in remote skill payload: {name}"); + } + } + } + Ok(base.join(path)) +} + +fn validate_dir_name_format(name: &str) -> Option { + let mut components = Path::new(name).components(); + match (components.next(), components.next()) { + (Some(Component::Normal(component)), None) => { + let value = component.to_string_lossy().to_string(); + if value.is_empty() { None } else { Some(value) } + } + _ => None, + } +} + +fn is_zip_payload(bytes: &[u8]) -> bool { + bytes.starts_with(b"PK\x03\x04") + || bytes.starts_with(b"PK\x05\x06") + || bytes.starts_with(b"PK\x07\x08") +} + +fn extract_zip_to_dir( + bytes: Vec, + output_dir: &Path, + allowed_files: &HashSet, + prefix_candidates: &[String], +) -> Result<()> { + let cursor = std::io::Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(cursor).context("Failed to open zip archive")?; + for i in 0..archive.len() { + let mut file = archive.by_index(i).context("Failed to read zip entry")?; + if file.is_dir() { + continue; + } + let raw_name = file.name().to_string(); + let normalized = normalize_zip_name(&raw_name, prefix_candidates); + let Some(normalized) = normalized else { + continue; + }; + if !allowed_files.contains(&normalized) { + continue; + } + let file_path = safe_join(output_dir, &normalized)?; + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent dir for {normalized}"))?; + } + let mut out = std::fs::File::create(&file_path) + .with_context(|| format!("Failed to create file {normalized}"))?; + std::io::copy(&mut file, &mut out) + .with_context(|| format!("Failed to write skill file {normalized}"))?; + } + Ok(()) +} + +fn normalize_zip_name(name: &str, prefix_candidates: &[String]) -> Option { + let mut trimmed = name.trim_start_matches("./"); + for prefix in prefix_candidates { + if prefix.is_empty() { + continue; + } + let prefix = format!("{prefix}/"); + if let Some(rest) = trimmed.strip_prefix(&prefix) { + trimmed = rest; + break; + } + } + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +async fn fetch_remote_skill(config: &Config, hazelnut_id: &str) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url); + let url = format!("{base_url}/public-api/hazelnuts/"); + + let client = build_reqwest_client(); + let response = client + .get(&url) + .timeout(REMOTE_SKILLS_API_TIMEOUT) + .query(&[("product_surface", "codex")]) + .send() + .await + .with_context(|| format!("Failed to send request to {url}"))?; + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("Request failed with status {status} from {url}: {body}"); + } + + let parsed: RemoteSkillsDownloadResponse = + serde_json::from_str(&body).context("Failed to parse skills response")?; + let hazelnut = parsed + .hazelnuts + .into_iter() + .find(|hazelnut| hazelnut.id == hazelnut_id) + .ok_or_else(|| anyhow::anyhow!("Remote skill {hazelnut_id} not found"))?; + + Ok(RemoteSkillDownload { + id: hazelnut.id, + name: hazelnut.name, + base_sediment_id: hazelnut.base_sediment_id, + files: hazelnut + .files + .into_iter() + .map(|(name, range)| { + ( + name, + RemoteSkillFileRange { + start: range.start, + length: range.length, + }, + ) + }) + .collect(), + }) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index f34ca09b40..47a0829898 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -1,16 +1,57 @@ // Aggregates all former standalone integration tests as modules. +use std::ffi::OsString; + +use codex_arg0::Arg0PathEntryGuard; use codex_arg0::arg0_dispatch; use ctor::ctor; use tempfile::TempDir; +struct TestCodexAliasesGuard { + _codex_home: TempDir, + _arg0: Arg0PathEntryGuard, + _previous_codex_home: Option, +} + +const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; + // This code runs before any other tests are run. // It allows the test binary to behave like codex and dispatch to apply_patch and codex-linux-sandbox // based on the arg0. // NOTE: this doesn't work on ARM #[ctor] -pub static CODEX_ALIASES_TEMP_DIR: TempDir = unsafe { +pub static CODEX_ALIASES_TEMP_DIR: TestCodexAliasesGuard = unsafe { #[allow(clippy::unwrap_used)] - arg0_dispatch().unwrap() + let codex_home = tempfile::Builder::new() + .prefix("codex-core-tests") + .tempdir() + .unwrap(); + let previous_codex_home = std::env::var_os(CODEX_HOME_ENV_VAR); + // arg0_dispatch() creates helper links under CODEX_HOME/tmp. Point it at a + // test-owned temp dir so startup never mutates the developer's real ~/.codex. + // + // Safety: #[ctor] runs before tests start, so no test threads exist yet. + unsafe { + std::env::set_var(CODEX_HOME_ENV_VAR, codex_home.path()); + } + + #[allow(clippy::unwrap_used)] + let arg0 = arg0_dispatch().unwrap(); + // Restore the process environment immediately so later tests observe the + // same CODEX_HOME state they started with. + match previous_codex_home.as_ref() { + Some(value) => unsafe { + std::env::set_var(CODEX_HOME_ENV_VAR, value); + }, + None => unsafe { + std::env::remove_var(CODEX_HOME_ENV_VAR); + }, + } + + TestCodexAliasesGuard { + _codex_home: codex_home, + _arg0: arg0, + _previous_codex_home: previous_codex_home, + } }; #[cfg(not(target_os = "windows"))] diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 3b4a3bf86b..9175b100a5 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -773,6 +773,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) + | EventMsg::ListRemoteSkillsResponse(_) + | EventMsg::RemoteSkillDownloaded(_) | EventMsg::RawResponseItem(_) | EventMsg::UserMessage(_) | EventMsg::EnteredReviewMode(_) diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 6273607a22..6513c9e6f7 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -322,6 +322,8 @@ async fn run_codex_tool_session_inner( | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) + | EventMsg::ListRemoteSkillsResponse(_) + | EventMsg::RemoteSkillDownloaded(_) | EventMsg::ExecCommandBegin(_) | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 140d3931e0..078ea31d18 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -269,6 +269,15 @@ pub enum Op { force_reload: bool, }, + /// Request the list of remote skills available via ChatGPT sharing. + ListRemoteSkills, + + /// Download a remote skill by id into the local skills cache. + DownloadRemoteSkill { + hazelnut_id: String, + is_preload: bool, + }, + /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) /// to generate a summary which will be returned as an AgentMessage event. @@ -822,6 +831,12 @@ pub enum EventMsg { /// List of skills available to the agent. ListSkillsResponse(ListSkillsResponseEvent), + /// List of remote skills available to the agent. + ListRemoteSkillsResponse(ListRemoteSkillsResponseEvent), + + /// Remote skill downloaded to local cache. + RemoteSkillDownloaded(RemoteSkillDownloadedEvent), + /// Notification that skill data may have been updated and clients may want to reload. SkillsUpdateAvailable, @@ -2127,6 +2142,27 @@ pub struct ListSkillsResponseEvent { pub skills: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct RemoteSkillSummary { + pub id: String, + pub name: String, + pub description: String, +} + +/// Response payload for `Op::ListRemoteSkills`. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct ListRemoteSkillsResponseEvent { + pub skills: Vec, +} + +/// Response payload for `Op::DownloadRemoteSkill`. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct RemoteSkillDownloadedEvent { + pub id: String, + pub name: String, + pub path: PathBuf, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a952b3354c..40247c35a4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3526,6 +3526,7 @@ impl ChatWidget { EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), + EventMsg::ListRemoteSkillsResponse(_) | EventMsg::RemoteSkillDownloaded(_) => {} EventMsg::SkillsUpdateAvailable => { self.submit_op(Op::ListSkills { cwds: Vec::new(), diff --git a/codex-rs/windows-sandbox-rs/src/acl.rs b/codex-rs/windows-sandbox-rs/src/acl.rs index 7dd37a9452..0018856a44 100644 --- a/codex-rs/windows-sandbox-rs/src/acl.rs +++ b/codex-rs/windows-sandbox-rs/src/acl.rs @@ -233,7 +233,9 @@ pub unsafe fn dacl_has_write_deny_for_sid(p_dacl: *mut ACL, psid: *mut c_void) - | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES - | GENERIC_WRITE_MASK; + | GENERIC_WRITE_MASK + | DELETE + | FILE_DELETE_CHILD; for i in 0..info.AceCount { let mut p_ace: *mut c_void = std::ptr::null_mut(); if GetAce(p_dacl as *const ACL, i, &mut p_ace) == 0 { @@ -477,7 +479,9 @@ pub unsafe fn add_deny_write_ace(path: &Path, psid: *mut c_void) -> Result | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES - | GENERIC_WRITE_MASK; + | GENERIC_WRITE_MASK + | DELETE + | FILE_DELETE_CHILD; explicit.grfAccessMode = DENY_ACCESS; explicit.grfInheritance = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE; explicit.Trustee = trustee; diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 2e6059e819..1af9f1b919 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -3,6 +3,7 @@ use crate::acl::path_mask_allows; use crate::cap::cap_sid_file; use crate::cap::load_or_create_cap_sids; use crate::logging::{debug_log, log_note}; +use crate::path_normalization::canonical_path_key; use crate::policy::SandboxPolicy; use crate::token::convert_string_sid_to_sid; use crate::token::world_sid; @@ -31,11 +32,6 @@ const SKIP_DIR_SUFFIXES: &[&str] = &[ "/programdata", ]; -pub(crate) fn normalize_path_key(p: &Path) -> String { - let n = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); - n.to_string_lossy().replace('\\', "/").to_ascii_lowercase() -} - fn unique_push(set: &mut HashSet, out: &mut Vec, p: PathBuf) { if let Ok(abs) = p.canonicalize() { if set.insert(abs.clone()) { @@ -131,7 +127,7 @@ pub fn audit_everyone_writable( checked += 1; let has = check_world_writable(&p); if has { - let key = normalize_path_key(&p); + let key = canonical_path_key(&p); if seen.insert(key) { flagged.push(p); } @@ -149,7 +145,7 @@ pub fn audit_everyone_writable( checked += 1; let has_root = check_world_writable(&root); if has_root { - let key = normalize_path_key(&root); + let key = canonical_path_key(&root); if seen.insert(key) { flagged.push(root.clone()); } @@ -181,7 +177,7 @@ pub fn audit_everyone_writable( checked += 1; let has_child = check_world_writable(&p); if has_child { - let key = normalize_path_key(&p); + let key = canonical_path_key(&p); if seen.insert(key) { flagged.push(p); } diff --git a/codex-rs/windows-sandbox-rs/src/cap.rs b/codex-rs/windows-sandbox-rs/src/cap.rs index 5100684965..a699d81d20 100644 --- a/codex-rs/windows-sandbox-rs/src/cap.rs +++ b/codex-rs/windows-sandbox-rs/src/cap.rs @@ -5,14 +5,23 @@ use rand::RngCore; use rand::SeedableRng; use serde::Deserialize; use serde::Serialize; +use std::collections::HashMap; use std::fs; use std::path::Path; use std::path::PathBuf; +use crate::path_normalization::canonical_path_key; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct CapSids { pub workspace: String, pub readonly: String, + /// Per-workspace capability SIDs keyed by canonicalized CWD string. + /// + /// This is used to isolate workspaces from other workspace sandbox writes and to + /// apply per-workspace denies (e.g. protect `CWD/.codex`) + /// without permanently affecting other workspaces. + #[serde(default)] + pub workspace_by_cwd: HashMap, } pub fn cap_sid_file(codex_home: &Path) -> PathBuf { @@ -30,8 +39,7 @@ fn make_random_cap_sid_string() -> String { fn persist_caps(path: &Path, caps: &CapSids) -> Result<()> { if let Some(dir) = path.parent() { - fs::create_dir_all(dir) - .with_context(|| format!("create cap sid dir {}", dir.display()))?; + fs::create_dir_all(dir).with_context(|| format!("create cap sid dir {}", dir.display()))?; } let json = serde_json::to_string(caps)?; fs::write(path, json).with_context(|| format!("write cap sid file {}", path.display()))?; @@ -52,6 +60,7 @@ pub fn load_or_create_cap_sids(codex_home: &Path) -> Result { let caps = CapSids { workspace: t.to_string(), readonly: make_random_cap_sid_string(), + workspace_by_cwd: HashMap::new(), }; persist_caps(&path, &caps)?; return Ok(caps); @@ -60,7 +69,53 @@ pub fn load_or_create_cap_sids(codex_home: &Path) -> Result { let caps = CapSids { workspace: make_random_cap_sid_string(), readonly: make_random_cap_sid_string(), + workspace_by_cwd: HashMap::new(), }; persist_caps(&path, &caps)?; Ok(caps) } + +/// Returns the workspace-specific capability SID for `cwd`, creating and persisting it if missing. +pub fn workspace_cap_sid_for_cwd(codex_home: &Path, cwd: &Path) -> Result { + let path = cap_sid_file(codex_home); + let mut caps = load_or_create_cap_sids(codex_home)?; + let key = canonical_path_key(cwd); + if let Some(sid) = caps.workspace_by_cwd.get(&key) { + return Ok(sid.clone()); + } + let sid = make_random_cap_sid_string(); + caps.workspace_by_cwd.insert(key, sid.clone()); + persist_caps(&path, &caps)?; + Ok(sid) +} + +#[cfg(test)] +mod tests { + use super::load_or_create_cap_sids; + use super::workspace_cap_sid_for_cwd; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn equivalent_cwd_spellings_share_workspace_sid_key() { + let temp = tempfile::tempdir().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + std::fs::create_dir_all(&codex_home).expect("create codex home"); + + let workspace = temp.path().join("WorkspaceRoot"); + std::fs::create_dir_all(&workspace).expect("create workspace root"); + + let canonical = dunce::canonicalize(&workspace).expect("canonical workspace root"); + let alt_spelling = PathBuf::from(canonical.to_string_lossy().replace('\\', "/").to_ascii_uppercase()); + + let first_sid = + workspace_cap_sid_for_cwd(&codex_home, canonical.as_path()).expect("first sid"); + let second_sid = + workspace_cap_sid_for_cwd(&codex_home, alt_spelling.as_path()).expect("second sid"); + + assert_eq!(first_sid, second_sid); + + let caps = load_or_create_cap_sids(&codex_home).expect("load caps"); + assert_eq!(caps.workspace_by_cwd.len(), 1); + } +} diff --git a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs index a016c00c75..9ff61de932 100644 --- a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs @@ -5,8 +5,8 @@ use anyhow::Result; use codex_windows_sandbox::allow_null_device; use codex_windows_sandbox::convert_string_sid_to_sid; use codex_windows_sandbox::create_process_as_user; -use codex_windows_sandbox::create_readonly_token_with_cap_from; -use codex_windows_sandbox::create_workspace_write_token_with_cap_from; +use codex_windows_sandbox::create_readonly_token_with_caps_from; +use codex_windows_sandbox::create_workspace_write_token_with_caps_from; use codex_windows_sandbox::get_current_token_for_restriction; use codex_windows_sandbox::hide_current_user_profile_dir; use codex_windows_sandbox::log_note; @@ -20,7 +20,9 @@ use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Foundation::HLOCAL; use windows_sys::Win32::Storage::FileSystem::CreateFileW; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; @@ -48,7 +50,7 @@ struct RunnerRequest { codex_home: PathBuf, // Real user's CODEX_HOME for shared data (caps, config). real_codex_home: PathBuf, - cap_sid: String, + cap_sids: Vec, command: Vec, cwd: PathBuf, env_map: HashMap, @@ -112,27 +114,43 @@ pub fn main() -> Result<()> { ); let policy = parse_policy(&req.policy_json_or_preset).context("parse policy_json_or_preset")?; - let psid_cap: *mut c_void = unsafe { convert_string_sid_to_sid(&req.cap_sid).unwrap() }; + let mut cap_psids: Vec<*mut c_void> = Vec::new(); + for sid in &req.cap_sids { + let Some(psid) = (unsafe { convert_string_sid_to_sid(sid) }) else { + anyhow::bail!("ConvertStringSidToSidW failed for capability SID"); + }; + cap_psids.push(psid); + } + if cap_psids.is_empty() { + anyhow::bail!("runner: empty capability SID list"); + } // Create restricted token from current process token. let base = unsafe { get_current_token_for_restriction()? }; - let token_res: Result<(HANDLE, *mut c_void)> = unsafe { + let token_res: Result = unsafe { match &policy { - SandboxPolicy::ReadOnly => create_readonly_token_with_cap_from(base, psid_cap), + SandboxPolicy::ReadOnly => create_readonly_token_with_caps_from(base, &cap_psids), SandboxPolicy::WorkspaceWrite { .. } => { - create_workspace_write_token_with_cap_from(base, psid_cap) + create_workspace_write_token_with_caps_from(base, &cap_psids) } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!() } } }; - let (h_token, psid_to_use) = token_res?; + let h_token = token_res?; unsafe { CloseHandle(base); } unsafe { - allow_null_device(psid_to_use); + for psid in &cap_psids { + allow_null_device(*psid); + } + for psid in cap_psids { + if !psid.is_null() { + LocalFree(psid as HLOCAL); + } + } } // Open named pipes for stdio. diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index 7dfcd6e0ae..ecd5732c09 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -196,7 +196,7 @@ mod windows_impl { codex_home: PathBuf, // Real user's CODEX_HOME for shared data (caps, config). real_codex_home: PathBuf, - cap_sid: String, + cap_sids: Vec, request_file: Option, command: Vec, cwd: PathBuf, @@ -239,14 +239,17 @@ mod windows_impl { anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") } let caps = load_or_create_cap_sids(codex_home)?; - let (psid_to_use, cap_sid_str) = match &policy { + let (psid_to_use, cap_sids) = match &policy { SandboxPolicy::ReadOnly => ( unsafe { convert_string_sid_to_sid(&caps.readonly).unwrap() }, - caps.readonly.clone(), + vec![caps.readonly.clone()], ), SandboxPolicy::WorkspaceWrite { .. } => ( unsafe { convert_string_sid_to_sid(&caps.workspace).unwrap() }, - caps.workspace.clone(), + vec![ + caps.workspace.clone(), + crate::cap::workspace_cap_sid_for_cwd(codex_home, cwd)?, + ], ), SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!("DangerFullAccess handled above") @@ -294,7 +297,7 @@ mod windows_impl { sandbox_policy_cwd: sandbox_policy_cwd.to_path_buf(), codex_home: sandbox_base.clone(), real_codex_home: codex_home.to_path_buf(), - cap_sid: cap_sid_str.clone(), + cap_sids: cap_sids.clone(), request_file: Some(req_file.clone()), command: command.clone(), cwd: cwd.to_path_buf(), diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index fc062d6fd5..b938a86e6b 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -5,8 +5,21 @@ macro_rules! windows_modules { } windows_modules!( - acl, allow, audit, cap, dpapi, env, hide_users, identity, logging, policy, process, token, - winutil + acl, + allow, + audit, + cap, + dpapi, + env, + hide_users, + identity, + logging, + path_normalization, + policy, + process, + token, + winutil, + workspace_acl ); #[cfg(target_os = "windows")] @@ -19,6 +32,8 @@ mod elevated_impl; #[cfg(target_os = "windows")] mod setup_error; +#[cfg(target_os = "windows")] +pub use acl::add_deny_write_ace; #[cfg(target_os = "windows")] pub use acl::allow_null_device; #[cfg(target_os = "windows")] @@ -36,6 +51,8 @@ pub use audit::apply_world_writable_scan_and_denies; #[cfg(target_os = "windows")] pub use cap::load_or_create_cap_sids; #[cfg(target_os = "windows")] +pub use cap::workspace_cap_sid_for_cwd; +#[cfg(target_os = "windows")] pub use dpapi::protect as dpapi_protect; #[cfg(target_os = "windows")] pub use dpapi::unprotect as dpapi_unprotect; @@ -54,6 +71,8 @@ pub use logging::log_note; #[cfg(target_os = "windows")] pub use logging::LOG_FILE_NAME; #[cfg(target_os = "windows")] +pub use path_normalization::canonicalize_path; +#[cfg(target_os = "windows")] pub use policy::parse_policy; #[cfg(target_os = "windows")] pub use policy::SandboxPolicy; @@ -88,7 +107,9 @@ pub use token::convert_string_sid_to_sid; #[cfg(target_os = "windows")] pub use token::create_readonly_token_with_cap_from; #[cfg(target_os = "windows")] -pub use token::create_workspace_write_token_with_cap_from; +pub use token::create_readonly_token_with_caps_from; +#[cfg(target_os = "windows")] +pub use token::create_workspace_write_token_with_caps_from; #[cfg(target_os = "windows")] pub use token::get_current_token_for_restriction; #[cfg(target_os = "windows")] @@ -99,6 +120,10 @@ pub use windows_impl::CaptureResult; pub use winutil::string_from_sid_bytes; #[cfg(target_os = "windows")] pub use winutil::to_wide; +#[cfg(target_os = "windows")] +pub use workspace_acl::is_command_cwd_root; +#[cfg(target_os = "windows")] +pub use workspace_acl::protect_workspace_codex_dir; #[cfg(not(target_os = "windows"))] pub use stub::apply_world_writable_scan_and_denies; @@ -116,6 +141,7 @@ mod windows_impl { use super::allow::compute_allow_paths; use super::allow::AllowDenyPaths; use super::cap::load_or_create_cap_sids; + use super::cap::workspace_cap_sid_for_cwd; use super::env::apply_no_network_to_env; use super::env::ensure_non_interactive_pager; use super::env::normalize_null_device_env; @@ -123,13 +149,17 @@ mod windows_impl { use super::logging::log_failure; use super::logging::log_start; use super::logging::log_success; + use super::path_normalization::canonicalize_path; use super::policy::parse_policy; use super::policy::SandboxPolicy; use super::process::make_env_block; use super::token::convert_string_sid_to_sid; + use super::token::create_workspace_write_token_with_caps_from; use super::winutil::format_last_error; use super::winutil::quote_windows_arg; use super::winutil::to_wide; + use super::workspace_acl::is_command_cwd_root; + use super::workspace_acl::protect_workspace_codex_dir; use anyhow::Result; use std::collections::HashMap; use std::ffi::c_void; @@ -229,15 +259,25 @@ mod windows_impl { anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") } let caps = load_or_create_cap_sids(codex_home)?; - let (h_token, psid_to_use): (HANDLE, *mut c_void) = unsafe { + let (h_token, psid_generic, psid_workspace): (HANDLE, *mut c_void, Option<*mut c_void>) = unsafe { match &policy { SandboxPolicy::ReadOnly => { let psid = convert_string_sid_to_sid(&caps.readonly).unwrap(); - super::token::create_readonly_token_with_cap(psid)? + let (h, _) = super::token::create_readonly_token_with_cap(psid)?; + (h, psid, None) } SandboxPolicy::WorkspaceWrite { .. } => { - let psid = convert_string_sid_to_sid(&caps.workspace).unwrap(); - super::token::create_workspace_write_token_with_cap(psid)? + let psid_generic = convert_string_sid_to_sid(&caps.workspace).unwrap(); + let ws_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; + let psid_workspace = convert_string_sid_to_sid(&ws_sid).unwrap(); + let base = super::token::get_current_token_for_restriction()?; + let h_res = create_workspace_write_token_with_caps_from( + base, + &[psid_generic, psid_workspace], + ); + windows_sys::Win32::Foundation::CloseHandle(base); + let h = h_res?; + (h, psid_generic, Some(psid_workspace)) } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!("DangerFullAccess handled above") @@ -261,29 +301,39 @@ mod windows_impl { let persist_aces = is_workspace_write; let AllowDenyPaths { allow, deny } = compute_allow_paths(&policy, sandbox_policy_cwd, ¤t_dir, &env_map); + let canonical_cwd = canonicalize_path(¤t_dir); let mut guards: Vec<(PathBuf, *mut c_void)> = Vec::new(); unsafe { for p in &allow { - if let Ok(added) = add_allow_ace(p, psid_to_use) { + let psid = if is_workspace_write && is_command_cwd_root(p, &canonical_cwd) { + psid_workspace.unwrap_or(psid_generic) + } else { + psid_generic + }; + if let Ok(added) = add_allow_ace(p, psid) { if added { if persist_aces { if p.is_dir() { // best-effort seeding omitted intentionally } } else { - guards.push((p.clone(), psid_to_use)); + guards.push((p.clone(), psid)); } } } } for p in &deny { - if let Ok(added) = add_deny_write_ace(p, psid_to_use) { + if let Ok(added) = add_deny_write_ace(p, psid_generic) { if added && !persist_aces { - guards.push((p.clone(), psid_to_use)); + guards.push((p.clone(), psid_generic)); } } } - allow_null_device(psid_to_use); + allow_null_device(psid_generic); + if let Some(psid) = psid_workspace { + allow_null_device(psid); + let _ = protect_workspace_codex_dir(¤t_dir, psid); + } } let (stdin_pair, stdout_pair, stderr_pair) = unsafe { setup_stdio_pipes()? }; diff --git a/codex-rs/windows-sandbox-rs/src/path_normalization.rs b/codex-rs/windows-sandbox-rs/src/path_normalization.rs new file mode 100644 index 0000000000..fe6a932306 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/path_normalization.rs @@ -0,0 +1,28 @@ +use std::path::Path; +use std::path::PathBuf; + +pub fn canonicalize_path(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +pub fn canonical_path_key(path: &Path) -> String { + canonicalize_path(path) + .to_string_lossy() + .replace('\\', "/") + .to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::canonical_path_key; + use pretty_assertions::assert_eq; + use std::path::Path; + + #[test] + fn canonical_path_key_normalizes_case_and_separators() { + let windows_style = Path::new(r"C:\Users\Dev\Repo"); + let slash_style = Path::new("c:/users/dev/repo"); + + assert_eq!(canonical_path_key(windows_style), canonical_path_key(slash_style)); + } +} diff --git a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs index 6868028782..28c94a769c 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs @@ -6,18 +6,22 @@ use anyhow::Context; use anyhow::Result; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; +use codex_windows_sandbox::canonicalize_path; use codex_windows_sandbox::convert_string_sid_to_sid; use codex_windows_sandbox::ensure_allow_mask_aces_with_inheritance; use codex_windows_sandbox::ensure_allow_write_aces; use codex_windows_sandbox::extract_setup_failure; use codex_windows_sandbox::hide_newly_created_users; +use codex_windows_sandbox::is_command_cwd_root; use codex_windows_sandbox::load_or_create_cap_sids; use codex_windows_sandbox::log_note; use codex_windows_sandbox::path_mask_allows; +use codex_windows_sandbox::protect_workspace_codex_dir; use codex_windows_sandbox::sandbox_dir; use codex_windows_sandbox::sandbox_secrets_dir; use codex_windows_sandbox::string_from_sid_bytes; use codex_windows_sandbox::to_wide; +use codex_windows_sandbox::workspace_cap_sid_for_cwd; use codex_windows_sandbox::write_setup_error_report; use codex_windows_sandbox::SetupErrorCode; use codex_windows_sandbox::SetupErrorReport; @@ -75,6 +79,7 @@ struct Payload { offline_username: String, online_username: String, codex_home: PathBuf, + command_cwd: PathBuf, read_roots: Vec, write_roots: Vec, real_user: String, @@ -560,6 +565,11 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( )) })? }; + let workspace_sid_str = workspace_cap_sid_for_cwd(&payload.codex_home, &payload.command_cwd)?; + let workspace_psid = unsafe { + convert_string_sid_to_sid(&workspace_sid_str) + .ok_or_else(|| anyhow::anyhow!("convert workspace capability SID failed"))? + }; let mut refresh_errors: Vec = Vec::new(); if !refresh_only { let firewall_result = firewall::ensure_offline_outbound_block(&offline_sid_str, log); @@ -609,12 +619,12 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( let cap_sid_str = caps.workspace.clone(); let sandbox_group_sid_str = string_from_sid_bytes(&sandbox_group_sid).map_err(anyhow::Error::msg)?; - let sid_strings = vec![sandbox_group_sid_str, cap_sid_str]; let write_mask = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD; let mut grant_tasks: Vec = Vec::new(); let mut seen_write_roots: HashSet = HashSet::new(); + let canonical_command_cwd = canonicalize_path(&payload.command_cwd); for root in &payload.write_roots { if !seen_write_roots.insert(root.clone()) { @@ -628,7 +638,21 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( continue; } let mut need_grant = false; - for (label, psid) in [("sandbox_group", sandbox_group_psid), ("cap", cap_psid)] { + let is_command_cwd = is_command_cwd_root(root, &canonical_command_cwd); + let cap_label = if is_command_cwd { + "workspace_cap" + } else { + "cap" + }; + let cap_psid_for_root = if is_command_cwd { + workspace_psid + } else { + cap_psid + }; + for (label, psid) in [ + ("sandbox_group", sandbox_group_psid), + (cap_label, cap_psid_for_root), + ] { let has = match path_mask_allows(root, &[psid], write_mask, true) { Ok(h) => h, Err(e) => { @@ -667,7 +691,12 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( let (tx, rx) = mpsc::channel::<(PathBuf, Result)>(); std::thread::scope(|scope| { for root in grant_tasks { - let sid_strings = sid_strings.clone(); + let is_command_cwd = is_command_cwd_root(&root, &canonical_command_cwd); + let sid_strings = if is_command_cwd { + vec![sandbox_group_sid_str.clone(), workspace_sid_str.clone()] + } else { + vec![sandbox_group_sid_str.clone(), cap_sid_str.clone()] + }; let tx = tx.clone(); scope.spawn(move || { // Convert SID strings to psids locally in this thread. @@ -758,6 +787,31 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( let _ = std::fs::remove_file(&legacy_users); } } + + // Protect the current workspace's `.codex` directory from tampering (write/delete) by using a + // workspace-specific capability SID. If `.codex` doesn't exist yet, skip it (it will be picked + // up on the next refresh). + match unsafe { protect_workspace_codex_dir(&payload.command_cwd, workspace_psid) } { + Ok(true) => { + let cwd_codex = payload.command_cwd.join(".codex"); + log_line( + log, + &format!( + "applied deny ACE to protect workspace .codex {}", + cwd_codex.display() + ), + )?; + } + Ok(false) => {} + Err(err) => { + let cwd_codex = payload.command_cwd.join(".codex"); + refresh_errors.push(format!("deny ACE failed on {}: {err}", cwd_codex.display())); + log_line( + log, + &format!("deny ACE failed on {}: {err}", cwd_codex.display()), + )?; + } + } unsafe { if !sandbox_group_psid.is_null() { LocalFree(sandbox_group_psid as HLOCAL); @@ -765,6 +819,9 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( if !cap_psid.is_null() { LocalFree(cap_psid as HLOCAL); } + if !workspace_psid.is_null() { + LocalFree(workspace_psid as HLOCAL); + } } if refresh_only && !refresh_errors.is_empty() { log_line( diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 78e6d566fb..0e43d1ba4b 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -12,6 +12,7 @@ use std::process::Stdio; use crate::allow::compute_allow_paths; use crate::allow::AllowDenyPaths; use crate::logging::log_note; +use crate::path_normalization::canonical_path_key; use crate::policy::SandboxPolicy; use crate::setup_error::clear_setup_error_report; use crate::setup_error::failure; @@ -82,6 +83,7 @@ pub fn run_setup_refresh( offline_username: OFFLINE_USERNAME.to_string(), online_username: ONLINE_USERNAME.to_string(), codex_home: codex_home.to_path_buf(), + command_cwd: command_cwd.to_path_buf(), read_roots, write_roots, real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()), @@ -259,6 +261,7 @@ struct ElevationPayload { offline_username: String, online_username: String, codex_home: PathBuf, + command_cwd: PathBuf, read_roots: Vec, write_roots: Vec, real_user: String, @@ -475,6 +478,7 @@ pub fn run_elevated_setup( offline_username: OFFLINE_USERNAME.to_string(), online_username: ONLINE_USERNAME.to_string(), codex_home: codex_home.to_path_buf(), + command_cwd: command_cwd.to_path_buf(), read_roots, write_roots, real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()), @@ -517,14 +521,14 @@ fn build_payload_roots( fn filter_sensitive_write_roots(mut roots: Vec, codex_home: &Path) -> Vec { // Never grant capability write access to CODEX_HOME or anything under CODEX_HOME/.sandbox. // These locations contain sandbox control/state and must remain tamper-resistant. - let codex_home_key = crate::audit::normalize_path_key(codex_home); - let sbx_dir_key = crate::audit::normalize_path_key(&sandbox_dir(codex_home)); + let codex_home_key = canonical_path_key(codex_home); + let sbx_dir_key = canonical_path_key(&sandbox_dir(codex_home)); let sbx_dir_prefix = format!("{}/", sbx_dir_key.trim_end_matches('/')); - let secrets_dir_key = crate::audit::normalize_path_key(&sandbox_secrets_dir(codex_home)); + let secrets_dir_key = canonical_path_key(&sandbox_secrets_dir(codex_home)); let secrets_dir_prefix = format!("{}/", secrets_dir_key.trim_end_matches('/')); roots.retain(|root| { - let key = crate::audit::normalize_path_key(root); + let key = canonical_path_key(root); key != codex_home_key && key != sbx_dir_key && !key.starts_with(&sbx_dir_prefix) diff --git a/codex-rs/windows-sandbox-rs/src/token.rs b/codex-rs/windows-sandbox-rs/src/token.rs index fc0e840eb5..0cc9f59da4 100644 --- a/codex-rs/windows-sandbox-rs/src/token.rs +++ b/codex-rs/windows-sandbox-rs/src/token.rs @@ -282,17 +282,6 @@ unsafe fn enable_single_privilege(h_token: HANDLE, name: &str) -> Result<()> { Ok(()) } -/// # Safety -/// Caller must close the returned token handle. -pub unsafe fn create_workspace_write_token_with_cap( - psid_capability: *mut c_void, -) -> Result<(HANDLE, *mut c_void)> { - let base = get_current_token_for_restriction()?; - let res = create_workspace_write_token_with_cap_from(base, psid_capability); - CloseHandle(base); - res -} - /// # Safety /// Caller must close the returned token handle. pub unsafe fn create_readonly_token_with_cap( @@ -306,61 +295,63 @@ pub unsafe fn create_readonly_token_with_cap( /// # Safety /// Caller must close the returned token handle; base_token must be a valid primary token. -pub unsafe fn create_workspace_write_token_with_cap_from( - base_token: HANDLE, - psid_capability: *mut c_void, -) -> Result<(HANDLE, *mut c_void)> { - let mut logon_sid_bytes = get_logon_sid_bytes(base_token)?; - let psid_logon = logon_sid_bytes.as_mut_ptr() as *mut c_void; - let mut everyone = world_sid()?; - let psid_everyone = everyone.as_mut_ptr() as *mut c_void; - let mut entries: [SID_AND_ATTRIBUTES; 3] = std::mem::zeroed(); - // Exact set and order: Capability, Logon, Everyone - entries[0].Sid = psid_capability; - entries[0].Attributes = 0; - entries[1].Sid = psid_logon; - entries[1].Attributes = 0; - entries[2].Sid = psid_everyone; - entries[2].Attributes = 0; - let mut new_token: HANDLE = 0; - let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; - let ok = CreateRestrictedToken( - base_token, - flags, - 0, - std::ptr::null(), - 0, - std::ptr::null(), - 3, - entries.as_mut_ptr(), - &mut new_token, - ); - if ok == 0 { - return Err(anyhow!("CreateRestrictedToken failed: {}", GetLastError())); - } - set_default_dacl(new_token, &[psid_logon, psid_everyone, psid_capability])?; - enable_single_privilege(new_token, "SeChangeNotifyPrivilege")?; - Ok((new_token, psid_capability)) -} - /// # Safety /// Caller must close the returned token handle; base_token must be a valid primary token. pub unsafe fn create_readonly_token_with_cap_from( base_token: HANDLE, psid_capability: *mut c_void, ) -> Result<(HANDLE, *mut c_void)> { + let new_token = create_token_with_caps_from(base_token, &[psid_capability])?; + Ok((new_token, psid_capability)) +} + +/// Create a restricted token that includes all provided capability SIDs. +/// +/// # Safety +/// Caller must close the returned token handle; base_token must be a valid primary token. +pub unsafe fn create_workspace_write_token_with_caps_from( + base_token: HANDLE, + psid_capabilities: &[*mut c_void], +) -> Result { + create_token_with_caps_from(base_token, psid_capabilities) +} + +/// Create a restricted token that includes all provided capability SIDs. +/// +/// # Safety +/// Caller must close the returned token handle; base_token must be a valid primary token. +pub unsafe fn create_readonly_token_with_caps_from( + base_token: HANDLE, + psid_capabilities: &[*mut c_void], +) -> Result { + create_token_with_caps_from(base_token, psid_capabilities) +} + +unsafe fn create_token_with_caps_from( + base_token: HANDLE, + psid_capabilities: &[*mut c_void], +) -> Result { + if psid_capabilities.is_empty() { + return Err(anyhow!("no capability SIDs provided")); + } let mut logon_sid_bytes = get_logon_sid_bytes(base_token)?; let psid_logon = logon_sid_bytes.as_mut_ptr() as *mut c_void; let mut everyone = world_sid()?; let psid_everyone = everyone.as_mut_ptr() as *mut c_void; - let mut entries: [SID_AND_ATTRIBUTES; 3] = std::mem::zeroed(); - // Exact set and order: Capability, Logon, Everyone - entries[0].Sid = psid_capability; - entries[0].Attributes = 0; - entries[1].Sid = psid_logon; - entries[1].Attributes = 0; - entries[2].Sid = psid_everyone; - entries[2].Attributes = 0; + + // Exact order: Capabilities..., Logon, Everyone + let mut entries: Vec = + vec![std::mem::zeroed(); psid_capabilities.len() + 2]; + for (i, psid) in psid_capabilities.iter().enumerate() { + entries[i].Sid = *psid; + entries[i].Attributes = 0; + } + let logon_idx = psid_capabilities.len(); + entries[logon_idx].Sid = psid_logon; + entries[logon_idx].Attributes = 0; + entries[logon_idx + 1].Sid = psid_everyone; + entries[logon_idx + 1].Attributes = 0; + let mut new_token: HANDLE = 0; let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; let ok = CreateRestrictedToken( @@ -370,14 +361,20 @@ pub unsafe fn create_readonly_token_with_cap_from( std::ptr::null(), 0, std::ptr::null(), - 3, + entries.len() as u32, entries.as_mut_ptr(), &mut new_token, ); if ok == 0 { return Err(anyhow!("CreateRestrictedToken failed: {}", GetLastError())); } - set_default_dacl(new_token, &[psid_logon, psid_everyone, psid_capability])?; + + let mut dacl_sids: Vec<*mut c_void> = Vec::with_capacity(psid_capabilities.len() + 2); + dacl_sids.push(psid_logon); + dacl_sids.push(psid_everyone); + dacl_sids.extend_from_slice(psid_capabilities); + set_default_dacl(new_token, &dacl_sids)?; + enable_single_privilege(new_token, "SeChangeNotifyPrivilege")?; - Ok((new_token, psid_capability)) + Ok(new_token) } diff --git a/codex-rs/windows-sandbox-rs/src/workspace_acl.rs b/codex-rs/windows-sandbox-rs/src/workspace_acl.rs new file mode 100644 index 0000000000..b34a7191ad --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/workspace_acl.rs @@ -0,0 +1,20 @@ +use crate::acl::add_deny_write_ace; +use crate::path_normalization::canonicalize_path; +use anyhow::Result; +use std::ffi::c_void; +use std::path::Path; + +pub fn is_command_cwd_root(root: &Path, canonical_command_cwd: &Path) -> bool { + canonicalize_path(root) == canonical_command_cwd +} + +/// # Safety +/// Caller must ensure `psid` is a valid SID pointer. +pub unsafe fn protect_workspace_codex_dir(cwd: &Path, psid: *mut c_void) -> Result { + let cwd_codex = cwd.join(".codex"); + if cwd_codex.is_dir() { + add_deny_write_ace(&cwd_codex, psid) + } else { + Ok(false) + } +}