chore: merge trusted config gate into hooks precedence

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-04-16 16:23:24 -07:00
co-authored by Codex noreply@openai.com
555 changed files with 32808 additions and 11754 deletions
+2 -1
View File
@@ -1,9 +1,10 @@
[advisories]
# Reviewed 2026-04-11. Keep this list in sync with ../deny.toml.
# Reviewed 2026-04-15. Keep this list in sync with ../deny.toml.
ignore = [
"RUSTSEC-2024-0388", # derivative 2.2.0 via starlark; upstream crate is unmaintained
"RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map; upstream crate is unmaintained
"RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained
"RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it
"RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it
"RUSTSEC-2026-0097", # rand 0.8.5 via age/codex-secrets and zbus/keyring; remove when transitive deps move to rand >=0.9.3
]
+227 -28
View File
@@ -940,6 +940,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1375,6 +1384,7 @@ dependencies = [
"codex-login",
"codex-plugin",
"codex-protocol",
"codex-utils-absolute-path",
"os_info",
"pretty_assertions",
"serde",
@@ -1445,6 +1455,7 @@ dependencies = [
"codex-cloud-requirements",
"codex-config",
"codex-core",
"codex-core-plugins",
"codex-exec-server",
"codex-features",
"codex-feedback",
@@ -1460,6 +1471,7 @@ dependencies = [
"codex-sandboxing",
"codex-shell-command",
"codex-state",
"codex-thread-store",
"codex-tools",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
@@ -1646,6 +1658,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-app-server-protocol",
"codex-config",
"codex-connectors",
"codex-core",
@@ -1693,6 +1706,7 @@ dependencies = [
"codex-stdio-to-uds",
"codex-terminal-detection",
"codex-tui",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-cli",
"codex-utils-path",
@@ -1727,7 +1741,7 @@ dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"pretty_assertions",
"rand 0.9.2",
"rand 0.9.3",
"reqwest",
"rustls",
"rustls-native-certs",
@@ -1829,6 +1843,7 @@ name = "codex-code-mode"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-protocol",
"deno_core_icudata",
"pretty_assertions",
"serde",
@@ -1904,10 +1919,12 @@ dependencies = [
"codex-api",
"codex-app-server-protocol",
"codex-apply-patch",
"codex-arg0",
"codex-async-utils",
"codex-code-mode",
"codex-config",
"codex-connectors",
"codex-core-plugins",
"codex-core-skills",
"codex-exec-server",
"codex-execpolicy",
@@ -1934,6 +1951,7 @@ dependencies = [
"codex-state",
"codex-terminal-detection",
"codex-test-binary-support",
"codex-thread-store",
"codex-tools",
"codex-utils-absolute-path",
"codex-utils-cache",
@@ -1951,10 +1969,12 @@ dependencies = [
"codex-windows-sandbox",
"core-foundation 0.9.4",
"core_test_support",
"crypto_box",
"csv",
"ctor 0.6.3",
"dirs",
"dunce",
"ed25519-dalek",
"env-flags",
"eventsource-stream",
"futures",
@@ -1972,7 +1992,7 @@ dependencies = [
"opentelemetry_sdk",
"predicates",
"pretty_assertions",
"rand 0.9.2",
"rand 0.9.3",
"regex-lite",
"reqwest",
"rmcp",
@@ -1980,6 +2000,7 @@ dependencies = [
"serde_json",
"serial_test",
"sha1",
"sha2",
"shlex",
"similar",
"tempfile",
@@ -2006,6 +2027,34 @@ dependencies = [
"zstd 0.13.3",
]
[[package]]
name = "codex-core-plugins"
version = "0.0.0"
dependencies = [
"chrono",
"codex-app-server-protocol",
"codex-config",
"codex-core-skills",
"codex-exec-server",
"codex-git-utils",
"codex-login",
"codex-plugin",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-plugins",
"dirs",
"pretty_assertions",
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml 0.9.11+spec-1.1.0",
"tracing",
"url",
]
[[package]]
name = "codex-core-skills"
version = "0.0.0"
@@ -2014,6 +2063,7 @@ dependencies = [
"codex-analytics",
"codex-app-server-protocol",
"codex-config",
"codex-exec-server",
"codex-instructions",
"codex-login",
"codex-otel",
@@ -2237,6 +2287,7 @@ dependencies = [
"codex-app-server-protocol",
"codex-config",
"codex-protocol",
"codex-utils-absolute-path",
"futures",
"pretty_assertions",
"regex",
@@ -2247,6 +2298,15 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-install-context"
version = "0.0.0"
dependencies = [
"codex-utils-home-dir",
"pretty_assertions",
"tempfile",
]
[[package]]
name = "codex-instructions"
version = "0.0.0"
@@ -2324,7 +2384,7 @@ dependencies = [
"once_cell",
"os_info",
"pretty_assertions",
"rand 0.9.2",
"rand 0.9.3",
"regex-lite",
"reqwest",
"serde",
@@ -2386,6 +2446,7 @@ dependencies = [
"codex-models-manager",
"codex-protocol",
"codex-shell-command",
"codex-utils-absolute-path",
"codex-utils-cli",
"codex-utils-json-to-toml",
"core_test_support",
@@ -2567,6 +2628,7 @@ dependencies = [
"codex-utils-string",
"codex-utils-template",
"encoding_rs",
"globset",
"http 1.4.0",
"icu_decimal",
"icu_locale_core",
@@ -2691,6 +2753,8 @@ dependencies = [
name = "codex-sandboxing"
version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"codex-network-proxy",
"codex-protocol",
"codex-utils-absolute-path",
@@ -2699,6 +2763,7 @@ dependencies = [
"pretty_assertions",
"serde_json",
"tempfile",
"tokio",
"tracing",
"url",
"which 8.0.0",
@@ -2715,7 +2780,7 @@ dependencies = [
"codex-keyring-store",
"keyring",
"pretty_assertions",
"rand 0.9.2",
"rand 0.9.3",
"regex",
"schemars 0.8.22",
"serde",
@@ -2831,9 +2896,22 @@ version = "0.0.0"
dependencies = [
"async-trait",
"chrono",
"codex-git-utils",
"codex-protocol",
"codex-rollout",
"codex-state",
"pretty_assertions",
"prost 0.14.3",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tonic",
"tonic-prost",
"tonic-prost-build",
"uuid",
]
[[package]]
@@ -2871,11 +2949,13 @@ dependencies = [
"codex-cli",
"codex-cloud-requirements",
"codex-config",
"codex-connectors",
"codex-exec-server",
"codex-features",
"codex-feedback",
"codex-file-search",
"codex-git-utils",
"codex-install-context",
"codex-login",
"codex-mcp",
"codex-model-provider-info",
@@ -2914,7 +2994,7 @@ dependencies = [
"pathdiff",
"pretty_assertions",
"pulldown-cmark",
"rand 0.9.2",
"rand 0.9.3",
"ratatui",
"ratatui-macros",
"regex-lite",
@@ -3073,10 +3153,13 @@ dependencies = [
name = "codex-utils-plugins"
version = "0.0.0"
dependencies = [
"codex-exec-server",
"codex-login",
"codex-utils-absolute-path",
"serde",
"serde_json",
"tempfile",
"tokio",
]
[[package]]
@@ -3585,9 +3668,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "crypto_box"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009"
dependencies = [
"aead",
"blake2",
"crypto_secretbox",
"curve25519-dalek",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "crypto_secretbox"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1"
dependencies = [
"aead",
"cipher",
"generic-array",
"poly1305",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "csv"
version = "1.4.0"
@@ -3644,6 +3758,7 @@ dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
@@ -4120,7 +4235,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4224,6 +4339,30 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.15.0"
@@ -4852,6 +4991,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -5153,7 +5293,7 @@ dependencies = [
"idna",
"ipnet",
"once_cell",
"rand 0.9.2",
"rand 0.9.3",
"ring",
"thiserror 2.0.18",
"tinyvec",
@@ -5175,7 +5315,7 @@ dependencies = [
"moka",
"once_cell",
"parking_lot",
"rand 0.9.2",
"rand 0.9.3",
"resolv-conf",
"smallvec",
"thiserror 2.0.18",
@@ -6722,7 +6862,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7321,7 +7461,7 @@ dependencies = [
"futures-util",
"opentelemetry",
"percent-encoding",
"rand 0.9.2",
"rand 0.9.3",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
@@ -7474,7 +7614,7 @@ dependencies = [
"heck 0.4.1",
"itertools 0.11.0",
"prost 0.12.6",
"prost-types",
"prost-types 0.12.6",
]
[[package]]
@@ -7488,7 +7628,7 @@ dependencies = [
"pbjson",
"pbjson-build",
"prost 0.12.6",
"prost-build",
"prost-build 0.12.6",
"serde",
]
@@ -7870,7 +8010,7 @@ checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40"
dependencies = [
"bitflags 2.10.0",
"num-traits",
"rand 0.9.2",
"rand 0.9.3",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax 0.8.8",
@@ -7912,7 +8052,26 @@ dependencies = [
"petgraph 0.6.5",
"prettyplease",
"prost 0.12.6",
"prost-types",
"prost-types 0.12.6",
"regex",
"syn 2.0.114",
"tempfile",
]
[[package]]
name = "prost-build"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
dependencies = [
"heck 0.5.0",
"itertools 0.14.0",
"log",
"multimap",
"petgraph 0.8.3",
"prettyplease",
"prost 0.14.3",
"prost-types 0.14.3",
"regex",
"syn 2.0.114",
"tempfile",
@@ -7953,6 +8112,15 @@ dependencies = [
"prost 0.12.6",
]
[[package]]
name = "prost-types"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
"prost 0.14.3",
]
[[package]]
name = "psl"
version = "2.1.184"
@@ -8041,7 +8209,7 @@ dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"rand 0.9.3",
"ring",
"rustc-hash 2.1.1",
"rustls",
@@ -8173,7 +8341,7 @@ dependencies = [
"rama-http-types",
"rama-net",
"rama-utils",
"rand 0.9.2",
"rand 0.9.3",
"serde",
"serde_html_form",
"serde_json",
@@ -8243,7 +8411,7 @@ dependencies = [
"rama-macros",
"rama-net",
"rama-utils",
"rand 0.9.2",
"rand 0.9.3",
"serde",
"sha1",
]
@@ -8271,7 +8439,7 @@ dependencies = [
"rama-error",
"rama-macros",
"rama-utils",
"rand 0.9.2",
"rand 0.9.3",
"serde",
"serde_json",
"sync_wrapper",
@@ -8345,7 +8513,7 @@ dependencies = [
"rama-http-types",
"rama-net",
"rama-utils",
"rand 0.9.2",
"rand 0.9.3",
"tokio",
]
@@ -8425,9 +8593,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.2"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
@@ -8742,7 +8910,7 @@ dependencies = [
"pastey",
"pin-project-lite",
"process-wrap",
"rand 0.9.2",
"rand 0.9.3",
"reqwest",
"rmcp-macros",
"schemars 1.2.1",
@@ -8949,9 +9117,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.10"
version = "0.103.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
dependencies = [
"aws-lc-rs",
"ring",
@@ -9326,7 +9494,7 @@ version = "0.46.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26ab054c34b87f96c3e4701bea1888317cde30cc7e4a6136d2c48454ab96661c"
dependencies = [
"rand 0.9.2",
"rand 0.9.3",
"sentry-types",
"serde",
"serde_json",
@@ -9374,7 +9542,7 @@ checksum = "eecbd63e9d15a26a40675ed180d376fcb434635d2e33de1c24003f61e3e2230d"
dependencies = [
"debugid",
"hex",
"rand 0.9.2",
"rand 0.9.3",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -10861,8 +11029,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"h2",
"http 1.4.0",
"http-body",
"http-body-util",
@@ -10872,6 +11042,7 @@ dependencies = [
"percent-encoding",
"pin-project",
"rustls-native-certs",
"socket2 0.6.2",
"sync_wrapper",
"tokio",
"tokio-rustls",
@@ -10882,6 +11053,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "tonic-build"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27aac809edf60b741e2d7db6367214d078856b8a5bff0087e94ff330fb97b6fc"
dependencies = [
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "tonic-prost"
version = "0.14.3"
@@ -10893,6 +11076,22 @@ dependencies = [
"tonic",
]
[[package]]
name = "tonic-prost-build"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4556786613791cfef4ed134aa670b61a85cfcacf71543ef33e8d801abae988f"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build 0.14.3",
"prost-types 0.14.3",
"quote",
"syn 2.0.114",
"tempfile",
"tonic-build",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -11159,7 +11358,7 @@ dependencies = [
"http 1.4.0",
"httparse",
"log",
"rand 0.9.2",
"rand 0.9.3",
"rustls",
"rustls-pki-types",
"sha1",
@@ -11846,7 +12045,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+9
View File
@@ -13,6 +13,7 @@ members = [
"arg0",
"feedback",
"features",
"install-context",
"codex-backend-openapi-models",
"code-mode",
"cloud-requirements",
@@ -27,6 +28,7 @@ members = [
"shell-escalation",
"skills",
"core",
"core-plugins",
"core-skills",
"hooks",
"instructions",
@@ -127,6 +129,7 @@ codex-code-mode = { path = "code-mode" }
codex-config = { path = "config" }
codex-connectors = { path = "connectors" }
codex-core = { path = "core" }
codex-core-plugins = { path = "core-plugins" }
codex-core-skills = { path = "core-skills" }
codex-exec = { path = "exec" }
codex-exec-server = { path = "exec-server" }
@@ -134,6 +137,7 @@ codex-execpolicy = { path = "execpolicy" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
codex-features = { path = "features" }
codex-feedback = { path = "feedback" }
codex-install-context = { path = "install-context" }
codex-file-search = { path = "file-search" }
codex-git-utils = { path = "git-utils" }
codex-hooks = { path = "hooks" }
@@ -166,6 +170,7 @@ codex-state = { path = "state" }
codex-stdio-to-uds = { path = "stdio-to-uds" }
codex-terminal-detection = { path = "terminal-detection" }
codex-test-binary-support = { path = "test-binary-support" }
codex-thread-store = { path = "thread-store" }
codex-tools = { path = "tools" }
codex-tui = { path = "tui" }
codex-utils-absolute-path = { path = "utils/absolute-path" }
@@ -219,6 +224,7 @@ color-eyre = "0.6.3"
constant_time_eq = "0.3.1"
crossbeam-channel = "0.5.15"
crossterm = "0.28.1"
crypto_box = { version = "0.9.1", features = ["seal"] }
csv = "1.3.1"
ctor = "0.6.3"
deno_core_icudata = "0.77.0"
@@ -227,6 +233,7 @@ diffy = "0.4.2"
dirs = "6"
dotenvy = "0.15.7"
dunce = "1.0.4"
ed25519-dalek = { version = "2.2.0", features = ["pkcs8"] }
encoding_rs = "0.8.35"
env-flags = "0.1.1"
env_logger = "0.11.9"
@@ -341,6 +348,8 @@ tracing-appender = "0.2.3"
tracing-opentelemetry = "0.32.0"
tracing-subscriber = "0.3.22"
tracing-test = "0.2.5"
tonic = { version = "0.14.3", default-features = false, features = ["channel", "codegen"] }
tonic-prost = "0.14.3"
tree-sitter = "0.25.10"
tree-sitter-bash = "0.25"
ts-rs = "11"
+1 -1
View File
@@ -1,6 +1,6 @@
# Codex CLI (Rust Implementation)
We provide Codex CLI as a standalone, native executable to ensure a zero-dependency install.
We provide Codex CLI as a standalone executable to ensure a zero-dependency install.
## Installing Codex
+1
View File
@@ -29,4 +29,5 @@ tokio = { workspace = true, features = [
tracing = { workspace = true, features = ["log"] }
[dev-dependencies]
codex-utils-absolute-path = { workspace = true }
pretty_assertions = { workspace = true }
@@ -4,6 +4,7 @@ use crate::events::CodexAppMentionedEventRequest;
use crate::events::CodexAppServerClientMetadata;
use crate::events::CodexAppUsedEventRequest;
use crate::events::CodexCompactionEventRequest;
use crate::events::CodexHookRunEventRequest;
use crate::events::CodexPluginEventRequest;
use crate::events::CodexPluginUsedEventRequest;
use crate::events::CodexRuntimeMetadata;
@@ -12,6 +13,7 @@ use crate::events::ThreadInitializedEvent;
use crate::events::ThreadInitializedEventParams;
use crate::events::TrackEventRequest;
use crate::events::codex_app_metadata;
use crate::events::codex_hook_run_metadata;
use crate::events::codex_plugin_metadata;
use crate::events::codex_plugin_used_metadata;
use crate::events::subagent_thread_started_event_request;
@@ -28,6 +30,8 @@ use crate::facts::CompactionStatus;
use crate::facts::CompactionStrategy;
use crate::facts::CompactionTrigger;
use crate::facts::CustomAnalyticsFact;
use crate::facts::HookRunFact;
use crate::facts::HookRunInput;
use crate::facts::InputError;
use crate::facts::InvocationType;
use crate::facts::PluginState;
@@ -81,10 +85,15 @@ use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ModeKind;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TokenUsage;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashSet;
@@ -112,7 +121,7 @@ fn sample_thread_with_source(
updated_at: 2,
status: AppServerThreadStatus::Idle,
path: None,
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source,
agent_nickname: None,
@@ -131,7 +140,7 @@ fn sample_thread_start_response(thread_id: &str, ephemeral: bool, model: &str) -
model: model.to_string(),
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
instruction_sources: Vec::new(),
approval_policy: AppServerAskForApproval::OnFailure,
approvals_reviewer: AppServerApprovalsReviewer::User,
@@ -182,7 +191,7 @@ fn sample_thread_resume_response_with_source(
model: model.to_string(),
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
instruction_sources: Vec::new(),
approval_policy: AppServerAskForApproval::OnFailure,
approvals_reviewer: AppServerApprovalsReviewer::User,
@@ -1280,6 +1289,109 @@ fn plugin_management_event_serializes_expected_shape() {
);
}
#[test]
fn hook_run_event_serializes_expected_shape() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-3".to_string(),
turn_id: "turn-3".to_string(),
};
let event = TrackEventRequest::HookRun(CodexHookRunEventRequest {
event_type: "codex_hook_run",
event_params: codex_hook_run_metadata(
&tracking,
HookRunFact {
event_name: HookEventName::PreToolUse,
hook_source: HookSource::User,
status: HookRunStatus::Completed,
},
),
});
let payload = serde_json::to_value(&event).expect("serialize hook run event");
assert_eq!(
payload,
json!({
"event_type": "codex_hook_run",
"event_params": {
"thread_id": "thread-3",
"turn_id": "turn-3",
"model_slug": "gpt-5",
"hook_name": "PreToolUse",
"hook_source": "user",
"status": "completed"
}
})
);
}
#[test]
fn hook_run_metadata_maps_sources_and_statuses() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
};
let system = serde_json::to_value(codex_hook_run_metadata(
&tracking,
HookRunFact {
event_name: HookEventName::SessionStart,
hook_source: HookSource::System,
status: HookRunStatus::Completed,
},
))
.expect("serialize system hook");
let project = serde_json::to_value(codex_hook_run_metadata(
&tracking,
HookRunFact {
event_name: HookEventName::Stop,
hook_source: HookSource::Project,
status: HookRunStatus::Blocked,
},
))
.expect("serialize project hook");
let unknown = serde_json::to_value(codex_hook_run_metadata(
&tracking,
HookRunFact {
event_name: HookEventName::UserPromptSubmit,
hook_source: HookSource::Unknown,
status: HookRunStatus::Failed,
},
))
.expect("serialize unknown hook");
assert_eq!(system["hook_source"], "system");
assert_eq!(system["status"], "completed");
assert_eq!(project["hook_source"], "project");
assert_eq!(project["status"], "blocked");
assert_eq!(unknown["hook_source"], "unknown");
assert_eq!(unknown["status"], "failed");
}
#[test]
fn hook_run_metadata_maps_stopped_status() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
};
let stopped = serde_json::to_value(codex_hook_run_metadata(
&tracking,
HookRunFact {
event_name: HookEventName::Stop,
hook_source: HookSource::User,
status: HookRunStatus::Stopped,
},
))
.expect("serialize stopped hook");
assert_eq!(stopped["hook_source"], "user");
assert_eq!(stopped["status"], "stopped");
}
#[test]
fn plugin_used_dedupe_is_keyed_by_turn_and_plugin() {
let (sender, _receiver) = mpsc::channel(1);
@@ -1357,6 +1469,37 @@ async fn reducer_ingests_skill_invoked_fact() {
);
}
#[tokio::test]
async fn reducer_ingests_hook_run_fact() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::HookRun(HookRunInput {
tracking: TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
},
hook: HookRunFact {
event_name: HookEventName::PostToolUse,
hook_source: HookSource::Unknown,
status: HookRunStatus::Failed,
},
})),
&mut events,
)
.await;
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_type"], "codex_hook_run");
assert_eq!(payload[0]["event_params"]["hook_name"], "PostToolUse");
assert_eq!(payload[0]["event_params"]["hook_source"], "unknown");
assert_eq!(payload[0]["event_params"]["status"], "failed");
}
#[tokio::test]
async fn reducer_ingests_app_and_plugin_facts() {
let mut reducer = AnalyticsReducer::default();
+8
View File
@@ -9,6 +9,8 @@ use crate::facts::AppInvocation;
use crate::facts::AppMentionedInput;
use crate::facts::AppUsedInput;
use crate::facts::CustomAnalyticsFact;
use crate::facts::HookRunFact;
use crate::facts::HookRunInput;
use crate::facts::PluginState;
use crate::facts::PluginStateChangedInput;
use crate::facts::SkillInvocation;
@@ -191,6 +193,12 @@ impl AnalyticsEventsClient {
)));
}
pub fn track_hook_run(&self, tracking: TrackEventsContext, hook: HookRunFact) {
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::HookRun(
HookRunInput { tracking, hook },
)));
}
pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) {
if !self.queue.should_enqueue_plugin_used(&tracking, &plugin) {
return;
+66
View File
@@ -1,5 +1,6 @@
use crate::facts::AppInvocation;
use crate::facts::CodexCompactionEvent;
use crate::facts::HookRunFact;
use crate::facts::InvocationType;
use crate::facts::PluginState;
use crate::facts::SubAgentThreadStartedInput;
@@ -15,6 +16,9 @@ use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxPermissions;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::SubAgentSource;
use serde::Serialize;
@@ -39,6 +43,7 @@ pub(crate) enum TrackEventRequest {
GuardianReview(Box<GuardianReviewEventRequest>),
AppMentioned(CodexAppMentionedEventRequest),
AppUsed(CodexAppUsedEventRequest),
HookRun(CodexHookRunEventRequest),
Compaction(Box<CodexCompactionEventRequest>),
TurnEvent(Box<CodexTurnEventRequest>),
TurnSteer(CodexTurnSteerEventRequest),
@@ -300,6 +305,22 @@ pub(crate) struct CodexAppUsedEventRequest {
pub(crate) event_params: CodexAppMetadata,
}
#[derive(Serialize)]
pub(crate) struct CodexHookRunMetadata {
pub(crate) thread_id: Option<String>,
pub(crate) turn_id: Option<String>,
pub(crate) model_slug: Option<String>,
pub(crate) hook_name: Option<String>,
pub(crate) hook_source: Option<&'static str>,
pub(crate) status: Option<HookRunStatus>,
}
#[derive(Serialize)]
pub(crate) struct CodexHookRunEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: CodexHookRunMetadata,
}
#[derive(Serialize)]
pub(crate) struct CodexCompactionEventParams {
pub(crate) thread_id: String,
@@ -529,6 +550,43 @@ pub(crate) fn codex_plugin_used_metadata(
}
}
pub(crate) fn codex_hook_run_metadata(
tracking: &TrackEventsContext,
hook: HookRunFact,
) -> CodexHookRunMetadata {
CodexHookRunMetadata {
thread_id: Some(tracking.thread_id.clone()),
turn_id: Some(tracking.turn_id.clone()),
model_slug: Some(tracking.model_slug.clone()),
hook_name: Some(analytics_hook_event_name(hook.event_name).to_owned()),
hook_source: Some(analytics_hook_source(hook.hook_source)),
status: Some(analytics_hook_status(hook.status)),
}
}
fn analytics_hook_event_name(event_name: HookEventName) -> &'static str {
match event_name {
HookEventName::PreToolUse => "PreToolUse",
HookEventName::PostToolUse => "PostToolUse",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::Stop => "Stop",
}
}
fn analytics_hook_source(source: HookSource) -> &'static str {
match source {
HookSource::System => "system",
HookSource::User => "user",
HookSource::Project => "project",
HookSource::Mdm => "mdm",
HookSource::SessionFlags => "session_flags",
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
HookSource::Unknown => "unknown",
}
}
pub(crate) fn current_runtime_metadata() -> CodexRuntimeMetadata {
let os_info = os_info::get();
CodexRuntimeMetadata {
@@ -586,3 +644,11 @@ pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Opt
_ => None,
}
}
fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus {
match status {
// Running is unexpected here and normalized defensively.
HookRunStatus::Running => HookRunStatus::Failed,
other => other,
}
}
+15
View File
@@ -15,6 +15,9 @@ use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillScope;
@@ -298,6 +301,7 @@ pub(crate) enum CustomAnalyticsFact {
SkillInvoked(SkillInvokedInput),
AppMentioned(AppMentionedInput),
AppUsed(AppUsedInput),
HookRun(HookRunInput),
PluginUsed(PluginUsedInput),
PluginStateChanged(PluginStateChangedInput),
}
@@ -317,6 +321,17 @@ pub(crate) struct AppUsedInput {
pub app: AppInvocation,
}
pub(crate) struct HookRunInput {
pub tracking: TrackEventsContext,
pub hook: HookRunFact,
}
pub struct HookRunFact {
pub event_name: HookEventName,
pub hook_source: HookSource,
pub status: HookRunStatus,
}
pub(crate) struct PluginUsedInput {
pub tracking: TrackEventsContext,
pub plugin: PluginTelemetryMetadata,
+1
View File
@@ -29,6 +29,7 @@ pub use facts::CompactionReason;
pub use facts::CompactionStatus;
pub use facts::CompactionStrategy;
pub use facts::CompactionTrigger;
pub use facts::HookRunFact;
pub use facts::InputError;
pub use facts::InvocationType;
pub use facts::SkillInvocation;
+14
View File
@@ -3,6 +3,7 @@ use crate::events::CodexAppMentionedEventRequest;
use crate::events::CodexAppServerClientMetadata;
use crate::events::CodexAppUsedEventRequest;
use crate::events::CodexCompactionEventRequest;
use crate::events::CodexHookRunEventRequest;
use crate::events::CodexPluginEventRequest;
use crate::events::CodexPluginUsedEventRequest;
use crate::events::CodexRuntimeMetadata;
@@ -20,6 +21,7 @@ use crate::events::ThreadInitializedEventParams;
use crate::events::TrackEventRequest;
use crate::events::codex_app_metadata;
use crate::events::codex_compaction_event_params;
use crate::events::codex_hook_run_metadata;
use crate::events::codex_plugin_metadata;
use crate::events::codex_plugin_used_metadata;
use crate::events::plugin_state_event_type;
@@ -32,6 +34,7 @@ use crate::facts::AppMentionedInput;
use crate::facts::AppUsedInput;
use crate::facts::CodexCompactionEvent;
use crate::facts::CustomAnalyticsFact;
use crate::facts::HookRunInput;
use crate::facts::PluginState;
use crate::facts::PluginStateChangedInput;
use crate::facts::PluginUsedInput;
@@ -217,6 +220,9 @@ impl AnalyticsReducer {
CustomAnalyticsFact::AppUsed(input) => {
self.ingest_app_used(input, out);
}
CustomAnalyticsFact::HookRun(input) => {
self.ingest_hook_run(input, out);
}
CustomAnalyticsFact::PluginUsed(input) => {
self.ingest_plugin_used(input, out);
}
@@ -442,6 +448,14 @@ impl AnalyticsReducer {
}));
}
fn ingest_hook_run(&mut self, input: HookRunInput, out: &mut Vec<TrackEventRequest>) {
let HookRunInput { tracking, hook } = input;
out.push(TrackEventRequest::HookRun(CodexHookRunEventRequest {
event_type: "codex_hook_run",
event_params: codex_hook_run_metadata(&tracking, hook),
}));
}
fn ingest_plugin_used(&mut self, input: PluginUsedInput, out: &mut Vec<TrackEventRequest>) {
let PluginUsedInput { tracking, plugin } = input;
out.push(TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest {
+9 -3
View File
@@ -28,6 +28,7 @@ use std::time::Duration;
pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
pub use codex_app_server::in_process::InProcessServerEvent;
use codex_app_server::in_process::InProcessStartArgs;
use codex_app_server::in_process::LogDbLayer;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
@@ -64,9 +65,9 @@ pub use crate::remote::RemoteAppServerConnectArgs;
/// while legacy startup/config paths are migrated to RPCs.
pub mod legacy_core {
pub use codex_core::Cursor;
pub use codex_core::DEFAULT_PROJECT_DOC_FILENAME;
pub use codex_core::DEFAULT_AGENTS_MD_FILENAME;
pub use codex_core::INTERACTIVE_SESSION_SOURCES;
pub use codex_core::LOCAL_PROJECT_DOC_FILENAME;
pub use codex_core::LOCAL_AGENTS_MD_FILENAME;
pub use codex_core::McpManager;
pub use codex_core::PLUGIN_TEXT_MENTION_SIGIL;
pub use codex_core::RolloutRecorder;
@@ -76,7 +77,6 @@ pub mod legacy_core {
pub use codex_core::ThreadsPage;
pub use codex_core::append_message_history_entry;
pub use codex_core::check_execpolicy_for_warnings;
pub use codex_core::discover_project_doc_paths;
pub use codex_core::find_thread_meta_by_name_str;
pub use codex_core::find_thread_name_by_id;
pub use codex_core::find_thread_names_by_ids;
@@ -354,6 +354,8 @@ pub struct InProcessClientStartArgs {
pub cloud_requirements: CloudRequirementsLoader,
/// Feedback sink used by app-server/core telemetry and logs.
pub feedback: CodexFeedback,
/// SQLite tracing layer used to flush recently emitted logs before feedback upload.
pub log_db: Option<LogDbLayer>,
/// Environment manager used by core execution and filesystem operations.
pub environment_manager: Arc<EnvironmentManager>,
/// Startup warnings emitted after initialize succeeds.
@@ -405,6 +407,7 @@ impl InProcessClientStartArgs {
loader_overrides: self.loader_overrides,
cloud_requirements: self.cloud_requirements,
feedback: self.feedback,
log_db: self.log_db,
environment_manager: self.environment_manager,
config_warnings: self.config_warnings,
session_source: self.session_source,
@@ -967,6 +970,7 @@ mod tests {
match ConfigBuilder::default().build().await {
Ok(config) => config,
Err(_) => Config::load_default_with_cli_overrides(Vec::new())
.await
.expect("default config should load"),
}
}
@@ -982,6 +986,7 @@ mod tests {
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)),
config_warnings: Vec::new(),
session_source,
@@ -1994,6 +1999,7 @@ mod tests {
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
environment_manager: environment_manager.clone(),
config_warnings: Vec::new(),
session_source: SessionSource::Exec,
@@ -605,6 +605,16 @@
"description": {
"type": "string"
},
"details": {
"anyOf": [
{
"$ref": "#/definitions/MigrationDetails"
},
{
"type": "null"
}
]
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
@@ -620,6 +630,7 @@
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG"
],
"type": "string"
@@ -1359,6 +1370,20 @@
}
]
},
"MigrationDetails": {
"properties": {
"plugins": {
"items": {
"$ref": "#/definitions/PluginsMigration"
},
"type": "array"
}
},
"required": [
"plugins"
],
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
@@ -1478,6 +1503,24 @@
],
"type": "object"
},
"PluginsMigration": {
"properties": {
"marketplaceName": {
"type": "string"
},
"pluginNames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"marketplaceName",
"pluginNames"
],
"type": "object"
},
"ReadOnlyAccess": {
"oneOf": [
{
@@ -75,7 +75,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -326,11 +326,15 @@
]
},
"cwd": {
"description": "The command's working directory.",
"type": [
"string",
"null"
]
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "The command's working directory."
},
"itemId": {
"type": "string"
@@ -608,7 +608,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1180,7 +1180,7 @@
"type": "string"
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"source": {
"$ref": "#/definitions/GuardianCommandSource"
@@ -1211,7 +1211,7 @@
"type": "array"
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"program": {
"type": "string"
@@ -1240,11 +1240,11 @@
{
"properties": {
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"files": {
"items": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
@@ -1517,8 +1517,16 @@
"scope": {
"$ref": "#/definitions/HookScope"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/HookSource"
}
],
"default": "unknown"
},
"sourcePath": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"startedAt": {
"format": "int64",
@@ -1555,6 +1563,19 @@
],
"type": "string"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
},
"HookStartedNotification": {
"properties": {
"run": {
@@ -2461,8 +2482,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -2769,8 +2794,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -2883,6 +2912,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -3099,7 +3134,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -3132,9 +3167,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -141,7 +141,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -346,11 +346,15 @@
]
},
"cwd": {
"description": "The command's working directory.",
"type": [
"string",
"null"
]
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "The command's working directory."
},
"itemId": {
"type": "string"
@@ -1841,11 +1841,15 @@
]
},
"cwd": {
"description": "The command's working directory.",
"type": [
"string",
"null"
]
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "The command's working directory."
},
"itemId": {
"type": "string"
@@ -5950,7 +5954,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -7505,6 +7509,16 @@
"description": {
"type": "string"
},
"details": {
"anyOf": [
{
"$ref": "#/definitions/v2/MigrationDetails"
},
{
"type": "null"
}
]
},
"itemType": {
"$ref": "#/definitions/v2/ExternalAgentConfigMigrationItemType"
}
@@ -7520,6 +7534,7 @@
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG"
],
"type": "string"
@@ -8243,7 +8258,7 @@
"type": "string"
},
"cwd": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"source": {
"$ref": "#/definitions/v2/GuardianCommandSource"
@@ -8274,7 +8289,7 @@
"type": "array"
},
"cwd": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"program": {
"type": "string"
@@ -8303,11 +8318,11 @@
{
"properties": {
"cwd": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"files": {
"items": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
},
@@ -8582,8 +8597,16 @@
"scope": {
"$ref": "#/definitions/v2/HookScope"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/v2/HookSource"
}
],
"default": "unknown"
},
"sourcePath": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"startedAt": {
"format": "int64",
@@ -8620,6 +8643,19 @@
],
"type": "string"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
},
"HookStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -9547,6 +9583,20 @@
}
]
},
"MigrationDetails": {
"properties": {
"plugins": {
"items": {
"$ref": "#/definitions/v2/PluginsMigration"
},
"type": "array"
}
},
"required": [
"plugins"
],
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
@@ -10469,6 +10519,24 @@
"title": "PluginUninstallResponse",
"type": "object"
},
"PluginsMigration": {
"properties": {
"marketplaceName": {
"type": "string"
},
"pluginNames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"marketplaceName",
"pluginNames"
],
"type": "object"
},
"ProfileV2": {
"additionalProperties": true,
"properties": {
@@ -12158,15 +12226,23 @@
]
},
"iconLarge": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"iconSmall": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"shortDescription": {
@@ -12647,8 +12723,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -12928,13 +13008,13 @@
"description": "Reviewer currently used for approval requests on this thread."
},
"cwd": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
},
@@ -13192,8 +13272,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -13306,6 +13390,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -13522,7 +13612,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -13555,9 +13645,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -14280,13 +14374,13 @@
"description": "Reviewer currently used for approval requests on this thread."
},
"cwd": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
},
@@ -14579,13 +14673,13 @@
"description": "Reviewer currently used for approval requests on this thread."
},
"cwd": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
},
@@ -2571,7 +2571,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -4126,6 +4126,16 @@
"description": {
"type": "string"
},
"details": {
"anyOf": [
{
"$ref": "#/definitions/MigrationDetails"
},
{
"type": "null"
}
]
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
@@ -4141,6 +4151,7 @@
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG"
],
"type": "string"
@@ -4975,7 +4986,7 @@
"type": "string"
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"source": {
"$ref": "#/definitions/GuardianCommandSource"
@@ -5006,7 +5017,7 @@
"type": "array"
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"program": {
"type": "string"
@@ -5035,11 +5046,11 @@
{
"properties": {
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"files": {
"items": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
@@ -5314,8 +5325,16 @@
"scope": {
"$ref": "#/definitions/HookScope"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/HookSource"
}
],
"default": "unknown"
},
"sourcePath": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"startedAt": {
"format": "int64",
@@ -5352,6 +5371,19 @@
],
"type": "string"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
},
"HookStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -6323,6 +6355,20 @@
}
]
},
"MigrationDetails": {
"properties": {
"plugins": {
"items": {
"$ref": "#/definitions/PluginsMigration"
},
"type": "array"
}
},
"required": [
"plugins"
],
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
@@ -7245,6 +7291,24 @@
"title": "PluginUninstallResponse",
"type": "object"
},
"PluginsMigration": {
"properties": {
"marketplaceName": {
"type": "string"
},
"pluginNames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"marketplaceName",
"pluginNames"
],
"type": "object"
},
"ProfileV2": {
"additionalProperties": true,
"properties": {
@@ -10006,15 +10070,23 @@
]
},
"iconLarge": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"iconSmall": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"shortDescription": {
@@ -10495,8 +10567,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -10776,13 +10852,13 @@
"description": "Reviewer currently used for approval requests on this thread."
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
@@ -11040,8 +11116,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -11154,6 +11234,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -11370,7 +11456,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -11403,9 +11489,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -12128,13 +12218,13 @@
"description": "Reviewer currently used for approval requests on this thread."
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
@@ -12427,13 +12517,13 @@
"description": "Reviewer currently used for approval requests on this thread."
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
@@ -13,6 +13,16 @@
"description": {
"type": "string"
},
"details": {
"anyOf": [
{
"$ref": "#/definitions/MigrationDetails"
},
{
"type": "null"
}
]
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
@@ -28,9 +38,42 @@
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG"
],
"type": "string"
},
"MigrationDetails": {
"properties": {
"plugins": {
"items": {
"$ref": "#/definitions/PluginsMigration"
},
"type": "array"
}
},
"required": [
"plugins"
],
"type": "object"
},
"PluginsMigration": {
"properties": {
"marketplaceName": {
"type": "string"
},
"pluginNames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"marketplaceName",
"pluginNames"
],
"type": "object"
}
},
"properties": {
@@ -13,6 +13,16 @@
"description": {
"type": "string"
},
"details": {
"anyOf": [
{
"$ref": "#/definitions/MigrationDetails"
},
{
"type": "null"
}
]
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
@@ -28,9 +38,42 @@
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG"
],
"type": "string"
},
"MigrationDetails": {
"properties": {
"plugins": {
"items": {
"$ref": "#/definitions/PluginsMigration"
},
"type": "array"
}
},
"required": [
"plugins"
],
"type": "object"
},
"PluginsMigration": {
"properties": {
"marketplaceName": {
"type": "string"
},
"pluginNames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"marketplaceName",
"pluginNames"
],
"type": "object"
}
},
"properties": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"HookEventName": {
"enum": [
"preToolUse",
@@ -102,8 +106,16 @@
"scope": {
"$ref": "#/definitions/HookScope"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/HookSource"
}
],
"default": "unknown"
},
"sourcePath": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"startedAt": {
"format": "int64",
@@ -139,6 +151,19 @@
"turn"
],
"type": "string"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
}
},
"properties": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"HookEventName": {
"enum": [
"preToolUse",
@@ -102,8 +106,16 @@
"scope": {
"$ref": "#/definitions/HookScope"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/HookSource"
}
],
"default": "unknown"
},
"sourcePath": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"startedAt": {
"format": "int64",
@@ -139,6 +151,19 @@
"turn"
],
"type": "string"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
}
},
"properties": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -78,7 +82,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -665,8 +669,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -779,6 +787,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -995,7 +1009,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1028,9 +1042,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AutoReviewDecisionSource": {
"description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.",
"enum": [
@@ -54,7 +58,7 @@
"type": "string"
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"source": {
"$ref": "#/definitions/GuardianCommandSource"
@@ -85,7 +89,7 @@
"type": "array"
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"program": {
"type": "string"
@@ -114,11 +118,11 @@
{
"properties": {
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"files": {
"items": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"GuardianApprovalReview": {
"description": "[UNSTABLE] Temporary guardian approval review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.",
"properties": {
@@ -47,7 +51,7 @@
"type": "string"
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"source": {
"$ref": "#/definitions/GuardianCommandSource"
@@ -78,7 +82,7 @@
"type": "array"
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"program": {
"type": "string"
@@ -107,11 +111,11 @@
{
"properties": {
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"files": {
"items": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -78,7 +82,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -665,8 +669,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -779,6 +787,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -995,7 +1009,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1028,9 +1042,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -293,15 +293,23 @@
]
},
"iconLarge": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"iconSmall": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"shortDescription": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -214,7 +218,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -808,8 +812,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -922,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1138,7 +1152,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1171,9 +1185,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -55,15 +55,23 @@
]
},
"iconLarge": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"iconSmall": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"shortDescription": {
@@ -279,7 +279,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1036,8 +1036,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -1322,8 +1326,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -1436,6 +1444,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1652,7 +1666,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1685,9 +1699,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -2185,13 +2203,13 @@
"description": "Reviewer currently used for approval requests on this thread."
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -217,7 +221,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -794,8 +798,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -1080,8 +1088,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -1194,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1410,7 +1428,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1443,9 +1461,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -217,7 +221,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -794,8 +798,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -1080,8 +1088,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -1194,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1410,7 +1428,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1443,9 +1461,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -217,7 +221,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -794,8 +798,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -1080,8 +1088,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -1194,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1410,7 +1428,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1443,9 +1461,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -279,7 +279,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1036,8 +1036,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -1322,8 +1326,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -1436,6 +1444,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1652,7 +1666,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1685,9 +1699,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -2185,13 +2203,13 @@
"description": "Reviewer currently used for approval requests on this thread."
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -217,7 +221,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -794,8 +798,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -1080,8 +1088,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -1194,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1410,7 +1428,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1443,9 +1461,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -279,7 +279,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1036,8 +1036,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -1322,8 +1326,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -1436,6 +1444,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1652,7 +1666,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1685,9 +1699,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -2185,13 +2203,13 @@
"description": "Reviewer currently used for approval requests on this thread."
},
"cwd": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -217,7 +221,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -794,8 +798,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -1080,8 +1088,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -1194,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1410,7 +1428,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1443,9 +1461,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -217,7 +221,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -794,8 +798,12 @@
"type": "integer"
},
"cwd": {
"description": "Working directory captured for the thread.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Working directory captured for the thread."
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
@@ -1080,8 +1088,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -1194,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1410,7 +1428,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1443,9 +1461,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -214,7 +218,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -808,8 +812,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -922,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1138,7 +1152,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1171,9 +1185,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -214,7 +218,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -808,8 +812,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -922,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1138,7 +1152,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1171,9 +1185,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -214,7 +218,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -808,8 +812,12 @@
"type": "array"
},
"cwd": {
"description": "The command's working directory.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "The command's working directory."
},
"durationMs": {
"description": "The duration of the command execution in milliseconds.",
@@ -922,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1138,7 +1152,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -1171,9 +1185,13 @@
]
},
"savedPath": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"status": {
@@ -1,5 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type CommandAction = { "type": "read", command: string, name: string, path: string, } | { "type": "listFiles", command: string, path: string | null, } | { "type": "search", command: string, query: string | null, path: string | null, } | { "type": "unknown", command: string, };
export type CommandAction = { "type": "read", command: string, name: string, path: AbsolutePathBuf, } | { "type": "listFiles", command: string, path: string | null, } | { "type": "search", command: string, query: string | null, path: string | null, } | { "type": "unknown", command: string, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile";
import type { CommandAction } from "./CommandAction";
import type { CommandExecutionApprovalDecision } from "./CommandExecutionApprovalDecision";
@@ -34,7 +35,7 @@ command?: string | null,
/**
* The command's working directory.
*/
cwd?: string | null,
cwd?: AbsolutePathBuf | null,
/**
* Best-effort parsed command actions for friendly display.
*/
@@ -2,9 +2,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType";
import type { MigrationDetails } from "./MigrationDetails";
export type ExternalAgentConfigMigrationItem = { itemType: ExternalAgentConfigMigrationItemType, description: string,
/**
* Null or empty means home-scoped migration; non-empty means repo-scoped migration.
*/
cwd: string | null, };
cwd: string | null, details: MigrationDetails | null, };
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "MCP_SERVER_CONFIG";
export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "PLUGINS" | "MCP_SERVER_CONFIG";
@@ -1,7 +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.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { GuardianCommandSource } from "./GuardianCommandSource";
import type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
export type GuardianApprovalReviewAction = { "type": "command", source: GuardianCommandSource, command: string, cwd: string, } | { "type": "execve", source: GuardianCommandSource, program: string, argv: Array<string>, cwd: string, } | { "type": "applyPatch", cwd: string, files: Array<string>, } | { "type": "networkAccess", target: string, host: string, protocol: NetworkApprovalProtocol, port: number, } | { "type": "mcpToolCall", server: string, toolName: string, connectorId: string | null, connectorName: string | null, toolTitle: string | null, };
export type GuardianApprovalReviewAction = { "type": "command", source: GuardianCommandSource, command: string, cwd: AbsolutePathBuf, } | { "type": "execve", source: GuardianCommandSource, program: string, argv: Array<string>, cwd: AbsolutePathBuf, } | { "type": "applyPatch", cwd: AbsolutePathBuf, files: Array<AbsolutePathBuf>, } | { "type": "networkAccess", target: string, host: string, protocol: NetworkApprovalProtocol, port: number, } | { "type": "mcpToolCall", server: string, toolName: string, connectorId: string | null, connectorName: string | null, toolTitle: string | null, };
@@ -1,11 +1,13 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { HookEventName } from "./HookEventName";
import type { HookExecutionMode } from "./HookExecutionMode";
import type { HookHandlerType } from "./HookHandlerType";
import type { HookOutputEntry } from "./HookOutputEntry";
import type { HookRunStatus } from "./HookRunStatus";
import type { HookScope } from "./HookScope";
import type { HookSource } from "./HookSource";
export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: string, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array<HookOutputEntry>, };
export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: AbsolutePathBuf, source: HookSource, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array<HookOutputEntry>, };
@@ -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 HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
@@ -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 { PluginsMigration } from "./PluginsMigration";
export type MigrationDetails = { plugins: Array<PluginsMigration>, };
@@ -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 PluginsMigration = { marketplaceName: string, pluginNames: Array<string>, };
@@ -1,5 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type SkillInterface = { displayName?: string, shortDescription?: string, iconSmall?: string, iconLarge?: string, brandColor?: string, defaultPrompt?: string, };
export type SkillInterface = { displayName?: string, shortDescription?: string, iconSmall?: AbsolutePathBuf, iconLarge?: AbsolutePathBuf, brandColor?: string, defaultPrompt?: string, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { GitInfo } from "./GitInfo";
import type { SessionSource } from "./SessionSource";
import type { ThreadStatus } from "./ThreadStatus";
@@ -42,7 +43,7 @@ path: string | null,
/**
* Working directory captured for the thread.
*/
cwd: string,
cwd: AbsolutePathBuf,
/**
* Version of the CLI that created the thread.
*/
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ServiceTier } from "../ServiceTier";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
@@ -8,11 +9,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { MessagePhase } from "../MessagePhase";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { JsonValue } from "../serde_json/JsonValue";
@@ -30,7 +31,7 @@ command: string,
/**
* The command's working directory.
*/
cwd: string,
cwd: AbsolutePathBuf,
/**
* Identifier for the underlying PTY process (when available).
*/
@@ -52,7 +53,7 @@ exitCode: number | null,
/**
* The duration of the command execution in milliseconds.
*/
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, result: McpToolCallResult | null, error: McpToolCallError | null,
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, result: McpToolCallResult | null, error: McpToolCallError | null,
/**
* The duration of the MCP tool call in milliseconds.
*/
@@ -97,4 +98,4 @@ reasoningEffort: ReasoningEffort | null,
/**
* Last known status of the target agents, when available.
*/
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: string, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: string, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: AbsolutePathBuf, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ServiceTier } from "../ServiceTier";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
@@ -8,11 +9,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ServiceTier } from "../ServiceTier";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
@@ -8,11 +9,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -139,6 +139,7 @@ export type { HookPromptFragment } from "./HookPromptFragment";
export type { HookRunStatus } from "./HookRunStatus";
export type { HookRunSummary } from "./HookRunSummary";
export type { HookScope } from "./HookScope";
export type { HookSource } from "./HookSource";
export type { HookStartedNotification } from "./HookStartedNotification";
export type { ItemCompletedNotification } from "./ItemCompletedNotification";
export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification";
@@ -198,6 +199,7 @@ export type { McpToolCallStatus } from "./McpToolCallStatus";
export type { MemoryCitation } from "./MemoryCitation";
export type { MemoryCitationEntry } from "./MemoryCitationEntry";
export type { MergeStrategy } from "./MergeStrategy";
export type { MigrationDetails } from "./MigrationDetails";
export type { Model } from "./Model";
export type { ModelAvailabilityNux } from "./ModelAvailabilityNux";
export type { ModelListParams } from "./ModelListParams";
@@ -236,6 +238,7 @@ export type { PluginSource } from "./PluginSource";
export type { PluginSummary } from "./PluginSummary";
export type { PluginUninstallParams } from "./PluginUninstallParams";
export type { PluginUninstallResponse } from "./PluginUninstallResponse";
export type { PluginsMigration } from "./PluginsMigration";
export type { ProfileV2 } from "./ProfileV2";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
export type { RateLimitWindow } from "./RateLimitWindow";
@@ -289,6 +289,11 @@ client_request_definitions! {
params: v2::ThreadMemoryModeSetParams,
response: v2::ThreadMemoryModeSetResponse,
},
#[experimental("memory/reset")]
MemoryReset => "memory/reset" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
response: v2::MemoryResetResponse,
},
ThreadUnarchive => "thread/unarchive" {
params: v2::ThreadUnarchiveParams,
response: v2::ThreadUnarchiveResponse,
@@ -759,6 +764,7 @@ macro_rules! server_notification_definitions {
Display,
ExperimentalApi,
)]
#[allow(clippy::large_enum_variant)]
#[serde(tag = "method", content = "params", rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum ServerNotification {
@@ -1065,21 +1071,20 @@ mod tests {
use codex_protocol::protocol::RealtimeOutputModality;
use codex_protocol::protocol::RealtimeVoice;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
fn absolute_path_string(path: &str) -> String {
let trimmed = path.trim_start_matches('/');
if cfg!(windows) {
format!(r"C:\{}", trimmed.replace('/', "\\"))
} else {
format!("/{trimmed}")
}
let path = format!("/{}", path.trim_start_matches('/'));
test_path_buf(&path).display().to_string()
}
fn absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::from_absolute_path(absolute_path_string(path)).expect("absolute path")
let path = format!("/{}", path.trim_start_matches('/'));
test_path_buf(&path).abs()
}
#[test]
@@ -1410,7 +1415,7 @@ mod tests {
updated_at: 2,
status: v2::ThreadStatus::Idle,
path: None,
cwd: PathBuf::from("/tmp"),
cwd: absolute_path("/tmp"),
cli_version: "0.0.0".to_string(),
source: v2::SessionSource::Exec,
agent_nickname: None,
@@ -1422,8 +1427,8 @@ mod tests {
model: "gpt-5".to_string(),
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
instruction_sources: vec![PathBuf::from("/tmp/AGENTS.md")],
cwd: absolute_path("/tmp"),
instruction_sources: vec![absolute_path("/tmp/AGENTS.md")],
approval_policy: v2::AskForApproval::OnFailure,
approvals_reviewer: v2::ApprovalsReviewer::User,
sandbox: v2::SandboxPolicy::DangerFullAccess,
@@ -1450,7 +1455,7 @@ mod tests {
"type": "idle"
},
"path": null,
"cwd": "/tmp",
"cwd": absolute_path_string("tmp"),
"cliVersion": "0.0.0",
"source": "exec",
"agentNickname": null,
@@ -1462,8 +1467,8 @@ mod tests {
"model": "gpt-5",
"modelProvider": "openai",
"serviceTier": null,
"cwd": "/tmp",
"instructionSources": ["/tmp/AGENTS.md"],
"cwd": absolute_path_string("tmp"),
"instructionSources": [absolute_path_string("tmp/AGENTS.md")],
"approvalPolicy": "on-failure",
"approvalsReviewer": "user",
"sandbox": {
@@ -78,7 +78,7 @@ pub fn build_command_execution_approval_request_item(
.parsed_cmd
.iter()
.cloned()
.map(CommandAction::from)
.map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd))
.collect(),
aggregated_output: None,
exit_code: None,
@@ -98,7 +98,7 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th
.parsed_cmd
.iter()
.cloned()
.map(CommandAction::from)
.map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd))
.collect(),
aggregated_output: None,
exit_code: None,
@@ -125,7 +125,7 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread
.parsed_cmd
.iter()
.cloned()
.map(CommandAction::from)
.map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd))
.collect(),
aggregated_output,
exit_code: Some(payload.exit_code),
@@ -179,7 +179,10 @@ pub fn build_item_from_guardian_event(
command: command.clone(),
}]
} else {
parsed_cmd.into_iter().map(CommandAction::from).collect()
parsed_cmd
.into_iter()
.map(|parsed| CommandAction::from_core_with_cwd(parsed, cwd))
.collect()
};
Some(ThreadItem::CommandExecution {
id: id.clone(),
@@ -123,6 +123,20 @@ impl ThreadHistoryBuilder {
.or_else(|| self.turns.last().cloned())
}
/// Returns the index of the active turn snapshot within the finished turn list.
///
/// When a turn is still open, this is the index it will occupy after
/// `finish`. When no turn is open, it is the index of the last finished turn.
pub fn active_turn_position(&self) -> Option<usize> {
if self.current_turn.is_some() {
Some(self.turns.len())
} else if self.turns.is_empty() {
None
} else {
Some(self.turns.len() - 1)
}
}
pub fn has_active_turn(&self) -> bool {
self.current_turn.is_some()
}
@@ -502,6 +516,7 @@ impl ThreadHistoryBuilder {
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
result: None,
error: None,
duration_ms: None,
@@ -518,11 +533,11 @@ impl ThreadHistoryBuilder {
let duration_ms = i64::try_from(payload.duration.as_millis()).ok();
let (result, error) = match &payload.result {
Ok(value) => (
Some(McpToolCallResult {
Some(Box::new(McpToolCallResult {
content: value.content.clone(),
structured_content: value.structured_content.clone(),
meta: value.meta.clone(),
}),
})),
None,
),
Err(message) => (
@@ -542,6 +557,7 @@ impl ThreadHistoryBuilder {
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
result,
error,
duration_ms,
@@ -552,7 +568,7 @@ impl ThreadHistoryBuilder {
fn handle_view_image_tool_call(&mut self, payload: &ViewImageToolCallEvent) {
let item = ThreadItem::ImageView {
id: payload.call_id.clone(),
path: payload.path.to_string_lossy().into_owned(),
path: payload.path.clone(),
};
self.upsert_item_in_current_turn(item);
}
@@ -1193,6 +1209,8 @@ mod tests {
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::protocol::WebSearchEndEvent;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use std::time::Duration;
@@ -1397,7 +1415,7 @@ mod tests {
status: "completed".into(),
revised_prompt: Some("final prompt".into()),
result: "Zm9v".into(),
saved_path: Some("/tmp/ig_123.png".into()),
saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()),
})),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-image".into(),
@@ -1431,7 +1449,7 @@ mod tests {
status: "completed".into(),
revised_prompt: Some("final prompt".into()),
result: "Zm9v".into(),
saved_path: Some("/tmp/ig_123.png".into()),
saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()),
},
],
}
@@ -1786,7 +1804,7 @@ mod tests {
process_id: Some("pid-1".into()),
turn_id: "turn-1".into(),
command: vec!["echo".into(), "hello world".into()],
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo hello world".into(),
}],
@@ -1807,6 +1825,7 @@ mod tests {
tool: "lookup".into(),
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: None,
duration: Duration::from_millis(8),
result: Err("boom".into()),
}),
@@ -1835,7 +1854,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "exec-1".into(),
command: "echo 'hello world'".into(),
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
process_id: Some("pid-1".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
@@ -1855,6 +1874,7 @@ mod tests {
tool: "lookup".into(),
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"id":"123"}),
mcp_app_resource_uri: None,
result: None,
error: Some(McpToolCallError {
message: "boom".into(),
@@ -1880,6 +1900,7 @@ mod tests {
tool: "lookup".into(),
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
content: vec![serde_json::json!({
@@ -1909,7 +1930,8 @@ mod tests {
tool: "lookup".into(),
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"id":"123"}),
result: Some(McpToolCallResult {
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
result: Some(Box::new(McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
"text": "result"
@@ -1918,7 +1940,7 @@ mod tests {
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/lookup.html"
})),
}),
})),
error: None,
duration_ms: Some(8),
}
@@ -2005,7 +2027,7 @@ mod tests {
process_id: Some("pid-2".into()),
turn_id: "turn-1".into(),
command: vec!["ls".into()],
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }],
source: ExecCommandSource::Agent,
interaction_input: None,
@@ -2047,7 +2069,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "exec-declined".into(),
command: "ls".into(),
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
process_id: Some("pid-2".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Declined,
@@ -2101,7 +2123,7 @@ mod tests {
"type": "command",
"source": "shell",
"command": "rm -rf /tmp/guardian",
"cwd": "/tmp",
"cwd": test_path_buf("/tmp"),
}))
.expect("guardian action"),
}),
@@ -2120,7 +2142,7 @@ mod tests {
"type": "command",
"source": "shell",
"command": "rm -rf /tmp/guardian",
"cwd": "/tmp",
"cwd": test_path_buf("/tmp"),
}))
.expect("guardian action"),
}),
@@ -2138,7 +2160,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "guardian-exec".into(),
command: "rm -rf /tmp/guardian".into(),
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
process_id: None,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Declined,
@@ -2181,7 +2203,7 @@ mod tests {
"source": "shell",
"program": "/bin/rm",
"argv": ["/usr/bin/rm", "-f", "/tmp/file.sqlite"],
"cwd": "/tmp",
"cwd": test_path_buf("/tmp"),
}))
.expect("guardian action"),
}),
@@ -2199,7 +2221,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "guardian-execve".into(),
command: "/bin/rm -f /tmp/file.sqlite".into(),
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
process_id: None,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::InProgress,
@@ -2251,7 +2273,7 @@ mod tests {
process_id: Some("pid-42".into()),
turn_id: "turn-a".into(),
command: vec!["echo".into(), "done".into()],
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo done".into(),
}],
@@ -2288,7 +2310,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "exec-late".into(),
command: "echo done".into(),
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
process_id: Some("pid-42".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
@@ -2340,7 +2362,7 @@ mod tests {
process_id: Some("pid-42".into()),
turn_id: "turn-missing".into(),
command: vec!["echo".into(), "done".into()],
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo done".into(),
}],
+133 -53
View File
@@ -65,6 +65,7 @@ use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus;
use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary;
use codex_protocol::protocol::HookScope as CoreHookScope;
use codex_protocol::protocol::HookSource as CoreHookSource;
use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind;
@@ -80,7 +81,6 @@ use codex_protocol::protocol::RealtimeVoicesList;
use codex_protocol::protocol::ReviewDecision as CoreReviewDecision;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies;
use codex_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo;
use codex_protocol::protocol::SkillInterface as CoreSkillInterface;
use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata;
use codex_protocol::protocol::SkillScope as CoreSkillScope;
@@ -403,6 +403,23 @@ v2_enum_from_core!(
}
);
v2_enum_from_core!(
pub enum HookSource from CoreHookSource {
System,
User,
Project,
Mdm,
SessionFlags,
LegacyManagedConfigFile,
LegacyManagedConfigMdm,
Unknown,
}
);
fn default_hook_source() -> HookSource {
HookSource::Unknown
}
v2_enum_from_core!(
pub enum HookRunStatus from CoreHookRunStatus {
Running, Completed, Failed, Blocked, Stopped
@@ -449,7 +466,9 @@ pub struct HookRunSummary {
pub handler_type: HookHandlerType,
pub execution_mode: HookExecutionMode,
pub scope: HookScope,
pub source_path: PathBuf,
pub source_path: AbsolutePathBuf,
#[serde(default = "default_hook_source")]
pub source: HookSource,
pub display_order: i64,
pub status: HookRunStatus,
pub status_message: Option<String>,
@@ -468,6 +487,7 @@ impl From<CoreHookRunSummary> for HookRunSummary {
execution_mode: value.execution_mode.into(),
scope: value.scope.into(),
source_path: value.source_path,
source: value.source.into(),
display_order: value.display_order,
status: value.status.into(),
status_message: value.status_message,
@@ -945,11 +965,33 @@ pub enum ExternalAgentConfigMigrationItemType {
#[serde(rename = "SKILLS")]
#[ts(rename = "SKILLS")]
Skills,
#[serde(rename = "PLUGINS")]
#[ts(rename = "PLUGINS")]
Plugins,
#[serde(rename = "MCP_SERVER_CONFIG")]
#[ts(rename = "MCP_SERVER_CONFIG")]
McpServerConfig,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginsMigration {
#[serde(rename = "marketplaceName")]
#[ts(rename = "marketplaceName")]
pub marketplace_name: String,
#[serde(rename = "pluginNames")]
#[ts(rename = "pluginNames")]
pub plugin_names: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MigrationDetails {
pub plugins: Vec<PluginsMigration>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -958,6 +1000,7 @@ pub struct ExternalAgentConfigMigrationItem {
pub description: String,
/// Null or empty means home-scoped migration; non-empty means repo-scoped migration.
pub cwd: Option<PathBuf>,
pub details: Option<MigrationDetails>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -1467,7 +1510,7 @@ pub enum CommandAction {
Read {
command: String,
name: String,
path: PathBuf,
path: AbsolutePathBuf,
},
ListFiles {
command: String,
@@ -1545,7 +1588,11 @@ impl CommandAction {
command: cmd,
name,
path,
} => CoreParsedCommand::Read { cmd, name, path },
} => CoreParsedCommand::Read {
cmd,
name,
path: path.into_path_buf(),
},
CommandAction::ListFiles { command: cmd, path } => {
CoreParsedCommand::ListFiles { cmd, path }
}
@@ -1559,13 +1606,13 @@ impl CommandAction {
}
}
impl From<CoreParsedCommand> for CommandAction {
fn from(value: CoreParsedCommand) -> Self {
impl CommandAction {
pub fn from_core_with_cwd(value: CoreParsedCommand, cwd: &AbsolutePathBuf) -> Self {
match value {
CoreParsedCommand::Read { cmd, name, path } => CommandAction::Read {
command: cmd,
name,
path,
path: cwd.join(path),
},
CoreParsedCommand::ListFiles { cmd, path } => {
CommandAction::ListFiles { command: cmd, path }
@@ -2720,10 +2767,10 @@ pub struct ThreadStartResponse {
pub model: String,
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
pub cwd: AbsolutePathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
pub instruction_sources: Vec<AbsolutePathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -2809,10 +2856,10 @@ pub struct ThreadResumeResponse {
pub model: String,
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
pub cwd: AbsolutePathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
pub instruction_sources: Vec<AbsolutePathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -2889,10 +2936,10 @@ pub struct ThreadForkResponse {
pub model: String,
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
pub cwd: AbsolutePathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
pub instruction_sources: Vec<AbsolutePathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -3088,6 +3135,11 @@ pub struct ThreadMemoryModeSetParams {
#[ts(export_to = "v2/")]
pub struct ThreadMemoryModeSetResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MemoryResetResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -3436,9 +3488,9 @@ pub struct SkillInterface {
#[ts(optional)]
pub short_description: Option<String>,
#[ts(optional)]
pub icon_small: Option<PathBuf>,
pub icon_small: Option<AbsolutePathBuf>,
#[ts(optional)]
pub icon_large: Option<PathBuf>,
pub icon_large: Option<AbsolutePathBuf>,
#[ts(optional)]
pub brand_color: Option<String>,
#[ts(optional)]
@@ -3722,15 +3774,6 @@ impl From<CoreSkillScope> for SkillScope {
}
}
impl From<CoreSkillErrorInfo> for SkillErrorInfo {
fn from(value: CoreSkillErrorInfo) -> Self {
Self {
path: value.path,
message: value.message,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -3755,7 +3798,7 @@ pub struct Thread {
/// [UNSTABLE] Path to the thread on disk.
pub path: Option<PathBuf>,
/// Working directory captured for the thread.
pub cwd: PathBuf,
pub cwd: AbsolutePathBuf,
/// Version of the CLI that created the thread.
pub cli_version: String,
/// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).
@@ -4530,7 +4573,7 @@ pub enum ThreadItem {
/// The command to be executed.
command: String,
/// The command's working directory.
cwd: PathBuf,
cwd: AbsolutePathBuf,
/// Identifier for the underlying PTY process (when available).
process_id: Option<String>,
#[serde(default)]
@@ -4563,7 +4606,10 @@ pub enum ThreadItem {
tool: String,
status: McpToolCallStatus,
arguments: JsonValue,
result: Option<McpToolCallResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
mcp_app_resource_uri: Option<String>,
result: Option<Box<McpToolCallResult>>,
error: Option<McpToolCallError>,
/// The duration of the MCP tool call in milliseconds.
#[ts(type = "number | null")]
@@ -4614,7 +4660,7 @@ pub enum ThreadItem {
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ImageView { id: String, path: String },
ImageView { id: String, path: AbsolutePathBuf },
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ImageGeneration {
@@ -4624,7 +4670,7 @@ pub enum ThreadItem {
result: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
saved_path: Option<String>,
saved_path: Option<AbsolutePathBuf>,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
@@ -4786,7 +4832,7 @@ impl From<GuardianCommandSource> for CoreGuardianCommandSource {
pub struct GuardianCommandReviewAction {
pub source: GuardianCommandSource,
pub command: String,
pub cwd: PathBuf,
pub cwd: AbsolutePathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -4796,15 +4842,15 @@ pub struct GuardianExecveReviewAction {
pub source: GuardianCommandSource,
pub program: String,
pub argv: Vec<String>,
pub cwd: PathBuf,
pub cwd: AbsolutePathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GuardianApplyPatchReviewAction {
pub cwd: PathBuf,
pub files: Vec<PathBuf>,
pub cwd: AbsolutePathBuf,
pub files: Vec<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -4838,7 +4884,7 @@ pub enum GuardianApprovalReviewAction {
Command {
source: GuardianCommandSource,
command: String,
cwd: PathBuf,
cwd: AbsolutePathBuf,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
@@ -4846,11 +4892,14 @@ pub enum GuardianApprovalReviewAction {
source: GuardianCommandSource,
program: String,
argv: Vec<String>,
cwd: PathBuf,
cwd: AbsolutePathBuf,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ApplyPatch { cwd: PathBuf, files: Vec<PathBuf> },
ApplyPatch {
cwd: AbsolutePathBuf,
files: Vec<AbsolutePathBuf>,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
NetworkAccess {
@@ -5759,7 +5808,7 @@ pub struct CommandExecutionRequestApprovalParams {
/// The command's working directory.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
pub cwd: Option<AbsolutePathBuf>,
/// Best-effort parsed command actions for friendly display.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
@@ -6564,22 +6613,20 @@ mod tests {
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess;
use codex_protocol::user_input::UserInput as CoreUserInput;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
fn absolute_path_string(path: &str) -> String {
let trimmed = path.trim_start_matches('/');
if cfg!(windows) {
format!(r"C:\{}", trimmed.replace('/', "\\"))
} else {
format!("/{trimmed}")
}
let path = format!("/{}", path.trim_start_matches('/'));
test_path_buf(&path).display().to_string()
}
fn absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::from_absolute_path(absolute_path_string(path))
.expect("path must be absolute")
let path = format!("/{}", path.trim_start_matches('/'));
test_path_buf(&path).abs()
}
fn test_absolute_path() -> AbsolutePathBuf {
@@ -6597,6 +6644,39 @@ mod tests {
);
}
#[test]
fn external_agent_config_plugins_details_round_trip() {
let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({
"itemType": "PLUGINS",
"description": "Install supported plugins from Claude settings",
"cwd": absolute_path_string("repo"),
"details": {
"plugins": [
{
"marketplaceName": "team-marketplace",
"pluginNames": ["asana"]
}
]
}
}))
.expect("plugins migration item should deserialize");
assert_eq!(
item,
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: "Install supported plugins from Claude settings".to_string(),
cwd: Some(PathBuf::from(absolute_path_string("repo"))),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "team-marketplace".to_string(),
plugin_names: vec!["asana".to_string()],
}],
}),
}
);
}
#[test]
fn command_execution_request_approval_rejects_relative_additional_permission_paths() {
let err = serde_json::from_value::<CommandExecutionRequestApprovalParams>(json!({
@@ -6604,7 +6684,7 @@ mod tests {
"turnId": "turn_123",
"itemId": "call_123",
"command": "cat file",
"cwd": "/tmp",
"cwd": absolute_path_string("tmp"),
"commandActions": null,
"reason": null,
"networkApprovalContext": null,
@@ -8063,7 +8143,7 @@ mod tests {
"type": "command",
"source": "shell",
"command": "rm -rf /tmp/example.sqlite",
"cwd": "/tmp",
"cwd": absolute_path_string("tmp"),
});
let action: GuardianApprovalReviewAction =
serde_json::from_value(value.clone()).expect("guardian review action");
@@ -8073,7 +8153,7 @@ mod tests {
GuardianApprovalReviewAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -rf /tmp/example.sqlite".to_string(),
cwd: "/tmp".into(),
cwd: absolute_path("tmp"),
}
);
assert_eq!(
@@ -8647,7 +8727,7 @@ mod tests {
"updatedAt": 1,
"status": { "type": "idle" },
"path": null,
"cwd": "/tmp",
"cwd": absolute_path_string("tmp"),
"cliVersion": "0.0.0",
"source": "exec",
"agentNickname": null,
@@ -8659,7 +8739,7 @@ mod tests {
"model": "gpt-5",
"modelProvider": "openai",
"serviceTier": null,
"cwd": "/tmp",
"cwd": absolute_path_string("tmp"),
"approvalPolicy": "on-failure",
"approvalsReviewer": "user",
"sandbox": { "type": "dangerFullAccess" },
@@ -8673,9 +8753,9 @@ mod tests {
let fork: ThreadForkResponse =
serde_json::from_value(response).expect("thread/fork response");
assert_eq!(start.instruction_sources, Vec::<PathBuf>::new());
assert_eq!(resume.instruction_sources, Vec::<PathBuf>::new());
assert_eq!(fork.instruction_sources, Vec::<PathBuf>::new());
assert_eq!(start.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(resume.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(fork.instruction_sources, Vec::<AbsolutePathBuf>::new());
}
#[test]
+2
View File
@@ -34,6 +34,7 @@ codex-arg0 = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-config = { workspace = true }
codex-core = { workspace = true }
codex-core-plugins = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-git-utils = { workspace = true }
@@ -54,6 +55,7 @@ codex-rmcp-client = { workspace = true }
codex-rollout = { workspace = true }
codex-sandboxing = { workspace = true }
codex-state = { workspace = true }
codex-thread-store = { workspace = true }
codex-tools = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
+15 -4
View File
@@ -41,8 +41,11 @@ Security note:
- Non-loopback websocket listeners currently allow unauthenticated connections by default during rollout. If you expose one remotely, configure websocket auth explicitly now.
- Supported auth modes are app-server flags:
- `--ws-auth capability-token --ws-token-file /absolute/path`
- `--ws-auth capability-token --ws-token-sha256 HEX`
- `--ws-auth signed-bearer-token --ws-shared-secret-file /absolute/path` for HMAC-signed JWT/JWS bearer tokens, with optional `--ws-issuer`, `--ws-audience`, `--ws-max-clock-skew-seconds`
- Clients present the credential as `Authorization: Bearer <token>` during the websocket handshake. Auth is enforced before JSON-RPC `initialize`.
- When starting `codex app-server` manually, prefer `--ws-token-file` over passing raw bearer tokens on the command line. Store a high-entropy token in a file readable only by your user, then have your client present that token in the websocket `Authorization` header.
- `--ws-token-sha256` is intended for clients that keep the raw token in a separate local secret store and only need the server to know the SHA-256 verifier. The hash may appear in process listings, but it is not sufficient to authenticate; clients still need the original raw token. Only use this mode with randomly generated high-entropy tokens, not passwords or other guessable values.
Tracing/log output:
@@ -141,6 +144,7 @@ Example with notification opt-out:
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
- `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success.
- `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`).
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success and emits `thread/archived`.
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`.
@@ -196,8 +200,8 @@ Example with notification opt-out:
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `config/read` — fetch the effective config on disk after resolving config layering.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home).
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home).
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin migration items may additionally include structured `details` grouping plugin ids under each detected marketplace name.
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect.
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly`.
@@ -246,7 +250,7 @@ Start a fresh thread when you need a new Codex conversation.
Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `"none"` is selected, the personality placeholder is replaced with an empty string.
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`, and no additional notifications are emitted. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`.
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`.
By default, resume uses the latest persisted `model` and `reasoningEffort` values associated with the thread. Supplying any of `model`, `modelProvider`, `config.model`, or `config.model_reasoning_effort` disables that persisted fallback and uses the explicit overrides plus normal config resolution instead.
@@ -260,7 +264,7 @@ Example:
{ "id": 11, "result": { "thread": { "id": "thr_123", … } } }
```
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
```json
{ "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } }
@@ -413,6 +417,13 @@ Experimental: use `thread/memoryMode/set` to change whether a thread remains eli
{ "id": 26, "result": {} }
```
Experimental: use `memory/reset` to clear local memory artifacts and sqlite-backed memory stage data for the current Codex home. This preserves existing thread memory modes; use `thread/memoryMode/set` separately when a thread's future memory eligibility should change.
```json
{ "method": "memory/reset", "id": 27 }
{ "id": 27, "result": {} }
```
### Example: Archive a thread
Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory.
@@ -140,9 +140,9 @@ use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUse
use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse;
use codex_sandboxing::policy_transforms::intersect_permission_profiles;
use codex_shell_command::parse_command::shlex_join;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
@@ -159,7 +159,7 @@ enum CommandExecutionApprovalPresentation {
#[derive(Debug, PartialEq)]
struct CommandExecutionCompletionItem {
command: String,
cwd: PathBuf,
cwd: AbsolutePathBuf,
command_actions: Vec<V2ParsedCommand>,
}
@@ -644,7 +644,7 @@ pub(crate) async fn apply_bespoke_event_handling(
call_id: call_id.clone(),
approval_id,
command,
cwd,
cwd: cwd.to_path_buf(),
reason,
parsed_cmd,
};
@@ -666,7 +666,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let command_actions = parsed_cmd
.iter()
.cloned()
.map(V2ParsedCommand::from)
.map(|parsed| V2ParsedCommand::from_core_with_cwd(parsed, &cwd))
.collect::<Vec<_>>();
let presentation = if let Some(network_approval_context) =
network_approval_context.map(V2NetworkApprovalContext::from)
@@ -1463,7 +1463,7 @@ pub(crate) async fn apply_bespoke_event_handling(
EventMsg::ViewImageToolCall(view_image_event) => {
let item = ThreadItem::ImageView {
id: view_image_event.call_id.clone(),
path: view_image_event.path.to_string_lossy().into_owned(),
path: view_image_event.path.clone(),
};
let started = ItemStartedNotification {
thread_id: conversation_id.to_string(),
@@ -1648,13 +1648,13 @@ pub(crate) async fn apply_bespoke_event_handling(
return;
}
let item_id = exec_command_begin_event.call_id.clone();
let cwd = exec_command_begin_event.cwd.clone();
let command_actions = exec_command_begin_event
.parsed_cmd
.into_iter()
.map(V2ParsedCommand::from)
.map(|parsed| V2ParsedCommand::from_core_with_cwd(parsed, &cwd))
.collect::<Vec<_>>();
let command = shlex_join(&exec_command_begin_event.command);
let cwd = exec_command_begin_event.cwd;
let process_id = exec_command_begin_event.process_id;
let first_start = {
let mut state = thread_state.lock().await;
@@ -1834,7 +1834,8 @@ pub(crate) async fn apply_bespoke_event_handling(
.await
{
Ok(summary) => {
let mut thread = summary_to_thread(summary);
let fallback_cwd = conversation.config_snapshot().await.cwd;
let mut thread = summary_to_thread(summary, &fallback_cwd);
match read_rollout_items_from_rollout(rollout_path.as_path()).await {
Ok(items) => {
thread.turns = build_turns_from_rollout_items(&items);
@@ -2035,7 +2036,7 @@ async fn start_command_execution_item(
turn_id: String,
item_id: String,
command: String,
cwd: PathBuf,
cwd: AbsolutePathBuf,
command_actions: Vec<V2ParsedCommand>,
source: CommandExecutionSource,
outgoing: &ThreadScopedOutgoingMessageSender,
@@ -2078,7 +2079,7 @@ async fn complete_command_execution_item(
turn_id: String,
item_id: String,
command: String,
cwd: PathBuf,
cwd: AbsolutePathBuf,
process_id: Option<String>,
source: CommandExecutionSource,
command_actions: Vec<V2ParsedCommand>,
@@ -2909,6 +2910,7 @@ async fn construct_mcp_tool_call_notification(
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri: begin_event.mcp_app_resource_uri,
result: None,
error: None,
duration_ms: None,
@@ -2935,11 +2937,11 @@ async fn construct_mcp_tool_call_end_notification(
let (result, error) = match &end_event.result {
Ok(value) => (
Some(McpToolCallResult {
Some(Box::new(McpToolCallResult {
content: value.content.clone(),
structured_content: value.structured_content.clone(),
meta: value.meta.clone(),
}),
})),
None,
),
Err(message) => (
@@ -2956,6 +2958,7 @@ async fn construct_mcp_tool_call_end_notification(
tool: end_event.invocation.tool,
status,
arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri: end_event.mcp_app_resource_uri,
result,
error,
duration_ms,
@@ -3002,6 +3005,8 @@ mod tests {
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use core_test_support::load_default_config_for_test;
use pretty_assertions::assert_eq;
use rmcp::model::Content;
@@ -3054,7 +3059,7 @@ mod tests {
fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem {
CommandExecutionCompletionItem {
command: command.to_string(),
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
command_actions: vec![V2ParsedCommand::Unknown {
command: command.to_string(),
}],
@@ -3100,7 +3105,7 @@ mod tests {
"type": "command",
"source": "shell",
"command": format!("rm -f /tmp/{id}.sqlite"),
"cwd": "/tmp",
"cwd": test_path_buf("/tmp"),
}))
.expect("guardian action"),
}
@@ -3146,7 +3151,7 @@ mod tests {
let action = codex_protocol::protocol::GuardianAssessmentAction::Command {
source: codex_protocol::protocol::GuardianCommandSource::Shell,
command: "rm -rf /tmp/example.sqlite".to_string(),
cwd: "/tmp".into(),
cwd: test_path_buf("/tmp").abs(),
};
let notification = guardian_auto_approval_review_notification(
&conversation_id,
@@ -3189,7 +3194,7 @@ mod tests {
let action = codex_protocol::protocol::GuardianAssessmentAction::Command {
source: codex_protocol::protocol::GuardianCommandSource::Shell,
command: "rm -rf /tmp/example.sqlite".to_string(),
cwd: "/tmp".into(),
cwd: test_path_buf("/tmp").abs(),
};
let notification = guardian_auto_approval_review_notification(
&conversation_id,
@@ -4216,6 +4221,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: Some(serde_json::json!({"server": ""})),
},
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
};
let thread_id = ThreadId::new().to_string();
@@ -4236,6 +4242,7 @@ mod tests {
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: serde_json::json!({"server": ""}),
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
result: None,
error: None,
duration_ms: None,
@@ -4376,6 +4383,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: None,
},
mcp_app_resource_uri: None,
};
let thread_id = ThreadId::new().to_string();
@@ -4396,6 +4404,7 @@ mod tests {
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: None,
error: None,
duration_ms: None,
@@ -4427,6 +4436,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: Some(serde_json::json!({"server": ""})),
},
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
duration: Duration::from_nanos(92708),
result: Ok(result),
};
@@ -4449,13 +4459,14 @@ mod tests {
tool: end_event.invocation.tool,
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"server": ""}),
result: Some(McpToolCallResult {
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
result: Some(Box::new(McpToolCallResult {
content,
structured_content: None,
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/list-resources.html"
})),
}),
})),
error: None,
duration_ms: Some(0),
},
@@ -4473,6 +4484,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: None,
},
mcp_app_resource_uri: None,
duration: Duration::from_millis(1),
result: Err("boom".to_string()),
};
@@ -4495,6 +4507,7 @@ mod tests {
tool: end_event.invocation.tool,
status: McpToolCallStatus::Failed,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: None,
error: Some(McpToolCallError {
message: "boom".to_string(),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
//! Replays persisted token usage snapshots when a client attaches to an existing thread.
//!
//! The message processor decides when replay is allowed and preserves JSON-RPC response
//! ordering. This module owns notification construction and the attribution rules that
//! map the latest persisted `TokenCount` back to a v2 turn id.
//!
//! Rollout histories can contain explicit turn ids or generated turn ids. When explicit
//! ids do not match the rebuilt thread, replay falls back to the active turn position at
//! the time the `TokenCount` was persisted so the notification still targets the
//! corresponding rebuilt turn.
use std::path::Path;
use std::sync::Arc;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_core::CodexThread;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use crate::codex_message_processor::read_rollout_items_from_rollout;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingMessageSender;
/// Sends a restored token usage update to the connection that attached to a thread.
///
/// This is lifecycle replay rather than a model event: the rollout already contains
/// the original `TokenCount`, and emitting through `send_event` here would duplicate
/// persisted usage records. Keeping this helper connection-scoped also avoids
/// surprising other subscribers with a historical usage update while they may be
/// rendering live turn events.
pub(super) async fn send_thread_token_usage_update_to_connection(
outgoing: &Arc<OutgoingMessageSender>,
connection_id: ConnectionId,
thread_id: ThreadId,
thread: &Thread,
conversation: &CodexThread,
token_usage_turn_id: Option<String>,
) {
let Some(info) = conversation.token_usage_info().await else {
return;
};
let notification = ThreadTokenUsageUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: token_usage_turn_id.unwrap_or_else(|| latest_token_usage_turn_id(thread)),
token_usage: ThreadTokenUsage::from(info),
};
outgoing
.send_server_notification_to_connections(
&[connection_id],
ServerNotification::ThreadTokenUsageUpdated(notification),
)
.await;
}
pub(super) async fn latest_token_usage_turn_id_for_thread_path(thread: &Thread) -> Option<String> {
let rollout_path = thread.path.as_deref()?;
latest_token_usage_turn_id_from_rollout_path(rollout_path, thread).await
}
pub(super) async fn latest_token_usage_turn_id_from_rollout_path(
rollout_path: &Path,
thread: &Thread,
) -> Option<String> {
let rollout_items = read_rollout_items_from_rollout(rollout_path).await.ok()?;
latest_token_usage_turn_id_from_rollout_items(&rollout_items, thread)
}
/// Identifies the turn that was active when a `TokenCount` record appeared.
///
/// The id is preferred when it still appears in the rebuilt thread. The position is a
/// fallback for histories whose implicit turn ids are regenerated during reconstruction.
struct TokenUsageTurnOwner {
id: String,
position: Option<usize>,
}
pub(super) fn latest_token_usage_turn_id_from_rollout_items(
rollout_items: &[RolloutItem],
thread: &Thread,
) -> Option<String> {
let owner = latest_token_usage_turn_owner_from_rollout_items(rollout_items)?;
if thread.turns.iter().any(|turn| turn.id == owner.id) {
return Some(owner.id);
}
owner
.position
.and_then(|position| thread.turns.get(position))
.map(|turn| turn.id.clone())
}
fn latest_token_usage_turn_owner_from_rollout_items(
rollout_items: &[RolloutItem],
) -> Option<TokenUsageTurnOwner> {
let mut builder = ThreadHistoryBuilder::new();
let mut token_usage_turn_owner = None;
for item in rollout_items {
if matches!(item, RolloutItem::EventMsg(EventMsg::TokenCount(_))) {
token_usage_turn_owner =
builder
.active_turn_snapshot()
.map(|turn| TokenUsageTurnOwner {
id: turn.id,
position: builder.active_turn_position(),
});
}
builder.handle_rollout_item(item);
}
token_usage_turn_owner
}
/// Chooses a fallback turn id that should own a replayed token usage update.
///
/// Normal replay derives the owner from the rollout position of the latest
/// `TokenCount` event. This fallback only preserves a stable wire shape for
/// unusual histories where that rollout information cannot be read.
fn latest_token_usage_turn_id(thread: &Thread) -> String {
thread
.turns
.iter()
.rev()
.find(|turn| matches!(turn.status, TurnStatus::Completed | TurnStatus::Failed))
.or_else(|| thread.turns.last())
.map(|turn| turn.id.clone())
.unwrap_or_default()
}
+2 -2
View File
@@ -27,8 +27,8 @@ use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement;
use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::plugins::PluginId;
use codex_core::plugins::collect_plugin_enabled_candidates;
use codex_core::plugins::installed_plugin_telemetry_metadata;
use codex_core_plugins::loader::installed_plugin_telemetry_metadata;
use codex_core_plugins::toggles::collect_plugin_enabled_candidates;
use codex_features::canonical_feature_for_key;
use codex_features::feature_for_key;
use codex_protocol::config_types::WebSearchMode;
@@ -6,6 +6,8 @@ use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::MigrationDetails;
use codex_app_server_protocol::PluginsMigration;
use codex_core::external_agent_config::ExternalAgentConfigDetectOptions;
use codex_core::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use codex_core::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
@@ -35,6 +37,7 @@ impl ExternalAgentConfigApi {
include_home: params.include_home,
cwds: params.cwds,
})
.await
.map_err(map_io_error)?;
Ok(ExternalAgentConfigDetectResponse {
@@ -51,12 +54,25 @@ impl ExternalAgentConfigApi {
CoreMigrationItemType::AgentsMd => {
ExternalAgentConfigMigrationItemType::AgentsMd
}
CoreMigrationItemType::Plugins => {
ExternalAgentConfigMigrationItemType::Plugins
}
CoreMigrationItemType::McpServerConfig => {
ExternalAgentConfigMigrationItemType::McpServerConfig
}
},
description: migration_item.description,
cwd: migration_item.cwd,
details: migration_item.details.map(|details| MigrationDetails {
plugins: details
.plugins
.into_iter()
.map(|plugin| PluginsMigration {
marketplace_name: plugin.marketplace_name,
plugin_names: plugin.plugin_names,
})
.collect(),
}),
})
.collect(),
})
@@ -82,15 +98,33 @@ impl ExternalAgentConfigApi {
ExternalAgentConfigMigrationItemType::AgentsMd => {
CoreMigrationItemType::AgentsMd
}
ExternalAgentConfigMigrationItemType::Plugins => {
CoreMigrationItemType::Plugins
}
ExternalAgentConfigMigrationItemType::McpServerConfig => {
CoreMigrationItemType::McpServerConfig
}
},
description: migration_item.description,
cwd: migration_item.cwd,
details: migration_item.details.map(|details| {
codex_core::external_agent_config::MigrationDetails {
plugins: details
.plugins
.into_iter()
.map(|plugin| {
codex_core::external_agent_config::PluginsMigration {
marketplace_name: plugin.marketplace_name,
plugin_names: plugin.plugin_names,
}
})
.collect(),
}
}),
})
.collect(),
)
.await
.map_err(map_io_error)?;
Ok(ExternalAgentConfigImportResponse {})
+2 -3
View File
@@ -14,7 +14,6 @@ use codex_core::file_watcher::FileWatcherSubscriber;
use codex_core::file_watcher::Receiver;
use codex_core::file_watcher::WatchPath;
use codex_core::file_watcher::WatchRegistration;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::hash_map::Entry;
@@ -128,7 +127,7 @@ impl FsWatchManager {
};
let outgoing = self.outgoing.clone();
let (subscriber, rx) = self.file_watcher.add_subscriber();
let watch_root = params.path.to_path_buf().clone();
let watch_root = params.path.clone();
let registration = subscriber.register_paths(vec![WatchPath {
path: params.path.to_path_buf(),
recursive: false,
@@ -166,7 +165,7 @@ impl FsWatchManager {
let mut changed_paths = event
.paths
.into_iter()
.map(|path| AbsolutePathBuf::resolve_path_against_base(&path, &watch_root))
.map(|path| watch_root.join(path))
.collect::<Vec<_>>();
changed_paths.sort_by(|left, right| left.as_path().cmp(right.as_path()));
if !changed_paths.is_empty() {
+6 -1
View File
@@ -82,6 +82,7 @@ use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_login::AuthManager;
use codex_protocol::protocol::SessionSource;
pub use codex_state::log_db::LogDbLayer;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::time::timeout;
@@ -117,6 +118,8 @@ pub struct InProcessStartArgs {
pub cloud_requirements: CloudRequirementsLoader,
/// Feedback sink used by app-server/core telemetry and logs.
pub feedback: CodexFeedback,
/// SQLite tracing layer used to flush recently emitted logs before feedback upload.
pub log_db: Option<LogDbLayer>,
/// Environment manager used by core execution and filesystem operations.
pub environment_manager: Arc<EnvironmentManager>,
/// Startup warnings emitted after initialize succeeds.
@@ -395,7 +398,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
loader_overrides: args.loader_overrides,
cloud_requirements: args.cloud_requirements,
feedback: args.feedback,
log_db: None,
log_db: args.log_db,
config_warnings: args.config_warnings,
session_source: args.session_source,
auth_manager,
@@ -713,6 +716,7 @@ mod tests {
match ConfigBuilder::default().build().await {
Ok(config) => config,
Err(_) => Config::load_default_with_cli_overrides(Vec::new())
.await
.expect("default config should load"),
}
}
@@ -728,6 +732,7 @@ mod tests {
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)),
config_warnings: Vec::new(),
session_source,
+8 -6
View File
@@ -427,12 +427,14 @@ pub async fn run_main_with_transport(
Err(err) => {
let message = config_warning_from_error("Invalid configuration; using defaults.", &err);
config_warnings.push(message);
Config::load_default_with_cli_overrides(cli_kv_overrides.clone()).map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading default config after config error: {e}"),
)
})?
Config::load_default_with_cli_overrides(cli_kv_overrides.clone())
.await
.map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading default config after config error: {e}"),
)
})?
}
};
@@ -746,6 +746,7 @@ impl MessageProcessor {
self.outgoing.send_error(connection_request_id, error).await;
return;
}
let connection_id = connection_request_id.connection_id;
if self.config.features.enabled(Feature::GeneralAnalytics)
&& let ClientRequest::TurnStart { request_id, .. }
| ClientRequest::TurnSteer { request_id, .. } = &codex_request
+2 -1
View File
@@ -8,6 +8,7 @@ use codex_core::CodexThread;
use codex_core::ThreadConfigSnapshot;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
@@ -28,7 +29,7 @@ pub(crate) struct PendingThreadResumeRequest {
pub(crate) request_id: ConnectionRequestId,
pub(crate) rollout_path: PathBuf,
pub(crate) config_snapshot: ThreadConfigSnapshot,
pub(crate) instruction_sources: Vec<PathBuf>,
pub(crate) instruction_sources: Vec<AbsolutePathBuf>,
pub(crate) thread_summary: codex_app_server_protocol::Thread,
}
+3 -3
View File
@@ -10,8 +10,6 @@ use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_protocol::ThreadId;
use std::collections::HashMap;
#[cfg(test)]
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
#[cfg(test)]
@@ -455,6 +453,8 @@ fn loaded_thread_status(runtime: &RuntimeFacts) -> ThreadStatus {
#[cfg(test)]
mod tests {
use super::*;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use tokio::time::Duration;
use tokio::time::timeout;
@@ -895,7 +895,7 @@ mod tests {
updated_at: 0,
status: ThreadStatus::NotLoaded,
path: None,
cwd: PathBuf::from("/tmp"),
cwd: test_path_buf("/tmp").abs(),
cli_version: "test".to_string(),
agent_nickname: None,
agent_role: None,
+157 -18
View File
@@ -34,6 +34,10 @@ pub struct AppServerWebsocketAuthArgs {
#[arg(long = "ws-token-file", value_name = "PATH")]
pub ws_token_file: Option<PathBuf>,
/// Hex-encoded SHA-256 digest of the capability token.
#[arg(long = "ws-token-sha256", value_name = "HEX")]
pub ws_token_sha256: Option<String>,
/// Absolute path to the shared secret file for signed JWT bearer tokens.
#[arg(long = "ws-shared-secret-file", value_name = "PATH")]
pub ws_shared_secret_file: Option<PathBuf>,
@@ -65,7 +69,7 @@ pub struct AppServerWebsocketAuthSettings {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppServerWebsocketAuthConfig {
CapabilityToken {
token_file: AbsolutePathBuf,
source: AppServerWebsocketCapabilityTokenSource,
},
SignedBearerToken {
shared_secret_file: AbsolutePathBuf,
@@ -75,6 +79,12 @@ pub enum AppServerWebsocketAuthConfig {
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppServerWebsocketCapabilityTokenSource {
TokenFile { token_file: AbsolutePathBuf },
TokenSha256 { token_sha256: [u8; 32] },
}
#[derive(Clone, Debug, Default)]
pub(crate) struct WebsocketAuthPolicy {
pub(crate) mode: Option<WebsocketAuthMode>,
@@ -144,17 +154,34 @@ impl AppServerWebsocketAuthArgs {
"`--ws-shared-secret-file`, `--ws-issuer`, `--ws-audience`, and `--ws-max-clock-skew-seconds` require `--ws-auth signed-bearer-token`"
);
}
let token_file = self.ws_token_file.context(
"`--ws-token-file` is required when `--ws-auth capability-token` is set",
)?;
Some(AppServerWebsocketAuthConfig::CapabilityToken {
token_file: absolute_path_arg("--ws-token-file", token_file)?,
})
let source = match (self.ws_token_file, self.ws_token_sha256) {
(Some(_), Some(_)) => {
anyhow::bail!(
"`--ws-token-file` and `--ws-token-sha256` are mutually exclusive"
);
}
(Some(token_file), None) => {
AppServerWebsocketCapabilityTokenSource::TokenFile {
token_file: absolute_path_arg("--ws-token-file", token_file)?,
}
}
(None, Some(token_sha256)) => {
AppServerWebsocketCapabilityTokenSource::TokenSha256 {
token_sha256: sha256_digest_arg("--ws-token-sha256", &token_sha256)?,
}
}
(None, None) => {
anyhow::bail!(
"`--ws-token-file` or `--ws-token-sha256` is required when `--ws-auth capability-token` is set"
);
}
};
Some(AppServerWebsocketAuthConfig::CapabilityToken { source })
}
Some(WebsocketAuthCliMode::SignedBearerToken) => {
if self.ws_token_file.is_some() {
if self.ws_token_file.is_some() || self.ws_token_sha256.is_some() {
anyhow::bail!(
"`--ws-token-file` requires `--ws-auth capability-token`, not `signed-bearer-token`"
"`--ws-token-file` and `--ws-token-sha256` require `--ws-auth capability-token`, not `signed-bearer-token`"
);
}
let shared_secret_file = self.ws_shared_secret_file.context(
@@ -174,6 +201,7 @@ impl AppServerWebsocketAuthArgs {
}
None => {
if self.ws_token_file.is_some()
|| self.ws_token_sha256.is_some()
|| self.ws_shared_secret_file.is_some()
|| self.ws_issuer.is_some()
|| self.ws_audience.is_some()
@@ -195,12 +223,19 @@ pub(crate) fn policy_from_settings(
settings: &AppServerWebsocketAuthSettings,
) -> io::Result<WebsocketAuthPolicy> {
let mode = match settings.config.as_ref() {
Some(AppServerWebsocketAuthConfig::CapabilityToken { token_file }) => {
let token = read_trimmed_secret(token_file.as_ref())?;
Some(WebsocketAuthMode::CapabilityToken {
token_sha256: sha256_digest(token.as_bytes()),
})
}
Some(AppServerWebsocketAuthConfig::CapabilityToken { source }) => match source {
AppServerWebsocketCapabilityTokenSource::TokenFile { token_file } => {
let token = read_trimmed_secret(token_file.as_ref())?;
Some(WebsocketAuthMode::CapabilityToken {
token_sha256: sha256_digest(token.as_bytes()),
})
}
AppServerWebsocketCapabilityTokenSource::TokenSha256 { token_sha256 } => {
Some(WebsocketAuthMode::CapabilityToken {
token_sha256: *token_sha256,
})
}
},
Some(AppServerWebsocketAuthConfig::SignedBearerToken {
shared_secret_file,
issuer,
@@ -387,6 +422,30 @@ fn absolute_path_arg(flag_name: &str, path: PathBuf) -> anyhow::Result<AbsoluteP
AbsolutePathBuf::try_from(path).with_context(|| format!("{flag_name} must be an absolute path"))
}
fn sha256_digest_arg(flag_name: &str, value: &str) -> anyhow::Result<[u8; 32]> {
let trimmed = value.trim();
if trimmed.len() != 64 {
anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest");
}
let mut digest = [0u8; 32];
for (index, pair) in trimmed.as_bytes().chunks_exact(2).enumerate() {
let high = hex_nibble(flag_name, pair[0])?;
let low = hex_nibble(flag_name, pair[1])?;
digest[index] = (high << 4) | low;
}
Ok(digest)
}
fn hex_nibble(flag_name: &str, byte: u8) -> anyhow::Result<u8> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest"),
}
}
fn sha256_digest(input: &[u8]) -> [u8; 32] {
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(input));
@@ -403,6 +462,7 @@ fn unauthorized(message: &'static str) -> WebsocketAuthError {
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use hmac::Hmac;
@@ -443,19 +503,98 @@ mod tests {
}
#[test]
fn capability_token_args_require_token_file() {
fn capability_token_args_require_token_file_or_hash() {
let err = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
..Default::default()
}
.try_into_settings()
.expect_err("capability-token mode should require a token file");
.expect_err("capability-token mode should require a token source");
assert!(
err.to_string().contains("--ws-token-file"),
err.to_string().contains("--ws-token-file")
&& err.to_string().contains("--ws-token-sha256"),
"unexpected error: {err}"
);
}
#[test]
fn capability_token_args_accept_token_hash() {
let settings = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
ws_token_sha256: Some("ab".repeat(32)),
..Default::default()
}
.try_into_settings()
.expect("capability-token hash args should parse");
assert_eq!(
settings,
AppServerWebsocketAuthSettings {
config: Some(AppServerWebsocketAuthConfig::CapabilityToken {
source: AppServerWebsocketCapabilityTokenSource::TokenSha256 {
token_sha256: [0xab; 32],
},
}),
}
);
}
#[test]
fn capability_token_args_reject_multiple_token_sources() {
let err = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
ws_token_file: Some(PathBuf::from("/tmp/token")),
ws_token_sha256: Some("ab".repeat(32)),
..Default::default()
}
.try_into_settings()
.expect_err("capability-token mode should reject multiple token sources");
assert!(
err.to_string().contains("mutually exclusive"),
"unexpected error: {err}"
);
}
#[test]
fn capability_token_args_reject_malformed_token_hash() {
let err = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
ws_token_sha256: Some("not-a-sha256".to_string()),
..Default::default()
}
.try_into_settings()
.expect_err("capability-token mode should reject malformed token hashes");
assert!(
err.to_string().contains("64-character hex"),
"unexpected error: {err}"
);
}
#[test]
fn capability_token_hash_policy_authorizes_matching_bearer_token() {
let settings = AppServerWebsocketAuthSettings {
config: Some(AppServerWebsocketAuthConfig::CapabilityToken {
source: AppServerWebsocketCapabilityTokenSource::TokenSha256 {
token_sha256: sha256_digest(b"super-secret-token"),
},
}),
};
let policy = policy_from_settings(&settings).expect("hash policy should build");
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer super-secret-token"),
);
authorize_upgrade(&headers, &policy).expect("matching token should authorize");
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer wrong-token"),
);
let err = authorize_upgrade(&headers, &policy).expect_err("wrong token should fail");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn signed_bearer_args_require_mode_when_mode_specific_flags_are_set() {
let err = AppServerWebsocketAuthArgs {
+2 -3
View File
@@ -402,7 +402,6 @@ mod tests {
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
use tokio::time::Duration;
use tokio::time::timeout;
@@ -772,7 +771,7 @@ mod tests {
reason: Some("Need extra read access".to_string()),
network_approval_context: None,
command: Some("cat file".to_string()),
cwd: Some(PathBuf::from("/tmp")),
cwd: Some(absolute_path("/tmp")),
command_actions: None,
additional_permissions: Some(
codex_app_server_protocol::AdditionalPermissionProfile {
@@ -834,7 +833,7 @@ mod tests {
reason: Some("Need extra read access".to_string()),
network_approval_context: None,
command: Some("cat file".to_string()),
cwd: Some(PathBuf::from("/tmp")),
cwd: Some(absolute_path("/tmp")),
command_actions: None,
additional_permissions: Some(
codex_app_server_protocol::AdditionalPermissionProfile {
@@ -4,7 +4,6 @@ mod protocol;
mod websocket;
use crate::transport::remote_control::websocket::RemoteControlWebsocket;
use crate::transport::remote_control::websocket::load_remote_control_auth;
pub use self::protocol::ClientId;
use self::protocol::ServerEvent;
@@ -59,9 +58,6 @@ pub(crate) async fn start_remote_control(
} else {
None
};
if initial_enabled {
validate_remote_control_auth(&auth_manager).await?;
}
let (enabled_tx, enabled_rx) = watch::channel(initial_enabled);
let join_handle = tokio::spawn(async move {
@@ -86,15 +82,5 @@ pub(crate) async fn start_remote_control(
))
}
pub(crate) async fn validate_remote_control_auth(
auth_manager: &Arc<AuthManager>,
) -> io::Result<()> {
match load_remote_control_auth(auth_manager).await {
Ok(_) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::WouldBlock => Ok(()),
Err(err) => Err(err),
}
}
#[cfg(test)]
mod tests;
@@ -48,9 +48,8 @@ pub enum ClientEvent {
message: JSONRPCMessage,
},
/// Backend-generated acknowledgement for all server envelopes addressed to
/// `client_id` whose envelope `seq_id` is less than or equal to this ack's
/// `seq_id`. This cursor is client-scoped, not stream-scoped, so receivers
/// must not use `stream_id` to partition acks.
/// `client_id` and `stream_id` whose envelope `seq_id` is less than or equal
/// to this ack's `seq_id`. This cursor is stream-scoped.
Ack,
Ping,
ClientClosed,
@@ -65,7 +64,7 @@ pub(crate) struct ClientEnvelope {
pub(crate) client_id: ClientId,
#[serde(rename = "stream_id", skip_serializing_if = "Option::is_none")]
pub(crate) stream_id: Option<StreamId>,
/// For `Ack`, this is the backend-generated per-client cursor over
/// For `Ack`, this is the backend-generated per-stream cursor over
/// `ServerEnvelope.seq_id`.
#[serde(rename = "seq_id", skip_serializing_if = "Option::is_none")]
pub(crate) seq_id: Option<u64>,
@@ -5,6 +5,7 @@ use super::enroll::update_persisted_remote_control_enrollment;
use super::protocol::ClientEnvelope;
use super::protocol::ClientEvent;
use super::protocol::ClientId;
use super::protocol::StreamId;
use super::protocol::normalize_remote_control_url;
use super::websocket::REMOTE_CONTROL_PROTOCOL_VERSION;
use super::*;
@@ -96,6 +97,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson {
account_id: account_id.map(str::to_string),
}),
last_refresh: Some(chrono::Utc::now()),
agent_identity: None,
}
}
@@ -162,7 +164,7 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages()
json!({
"type": "pong",
"client_id": "client-1",
"seq_id": 0,
"seq_id": 1,
"status": "unknown",
})
);
@@ -368,7 +370,7 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages()
json!({
"type": "pong",
"client_id": "client-1",
"seq_id": 3,
"seq_id": 1,
"status": "unknown",
})
);
@@ -478,6 +480,44 @@ async fn remote_control_start_allows_remote_control_invalid_url_when_disabled()
.expect("remote control task should join");
}
#[tokio::test]
async fn remote_control_start_allows_missing_auth_when_enabled() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let codex_home = TempDir::new().expect("temp dir should create");
let auth_manager = AuthManager::shared(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
);
let (transport_event_tx, _transport_event_rx) =
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
let shutdown_token = CancellationToken::new();
let (remote_task, _remote_handle) = start_remote_control(
remote_control_url,
/*state_db*/ None,
auth_manager,
transport_event_tx,
shutdown_token.clone(),
/*app_server_client_name_rx*/ None,
/*initial_enabled*/ true,
)
.await
.expect("remote control should start before ChatGPT auth is available");
timeout(Duration::from_millis(100), listener.accept())
.await
.expect_err("remote control should wait for auth before connecting");
shutdown_token.cancel();
timeout(Duration::from_secs(1), remote_task)
.await
.expect("remote control task should stop")
.expect("remote control task should join");
}
#[tokio::test]
async fn remote_control_handle_set_enabled_stops_and_restarts_connections() {
let listener = TcpListener::bind("127.0.0.1:0")
@@ -617,12 +657,13 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() {
))
.await
.expect("remote writer should accept outgoing message");
let (server_event, stream_id) = read_server_event_with_stream_id(&mut first_websocket).await;
assert_eq!(
read_server_event(&mut first_websocket).await,
server_event,
json!({
"type": "server_message",
"client_id": "client-1",
"seq_id": 0,
"seq_id": 1,
"message": {
"method": "configWarning",
"params": {
@@ -638,8 +679,8 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() {
ClientEnvelope {
event: ClientEvent::Ack,
client_id: client_id.clone(),
stream_id: None,
seq_id: Some(0),
stream_id: Some(stream_id),
seq_id: Some(1),
cursor: None,
},
)
@@ -853,7 +894,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() {
json!({
"type": "server_message",
"client_id": backend_client_id.0.clone(),
"seq_id": 0,
"seq_id": 1,
"message": {
"id": 11,
"result": {
@@ -881,7 +922,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() {
json!({
"type": "server_message",
"client_id": backend_client_id.0.clone(),
"seq_id": 1,
"seq_id": 2,
"message": {
"method": "configWarning",
"params": {
@@ -1355,6 +1396,12 @@ async fn send_client_event(
}
async fn read_server_event(websocket: &mut WebSocketStream<TcpStream>) -> serde_json::Value {
read_server_event_with_stream_id(websocket).await.0
}
async fn read_server_event_with_stream_id(
websocket: &mut WebSocketStream<TcpStream>,
) -> (serde_json::Value, StreamId) {
loop {
let frame = timeout(Duration::from_secs(5), websocket.next())
.await
@@ -1365,13 +1412,15 @@ async fn read_server_event(websocket: &mut WebSocketStream<TcpStream>) -> serde_
tungstenite::Message::Text(text) => {
let mut event: serde_json::Value =
serde_json::from_str(text.as_ref()).expect("server event should deserialize");
if let Some(stream_id) = event
let stream_id = event
.as_object_mut()
.and_then(|event| event.remove("stream_id"))
{
assert!(stream_id.is_string(), "stream_id should be a string");
}
return event;
.expect("stream_id should be present");
let stream_id = stream_id
.as_str()
.expect("stream_id should be a string")
.to_string();
return (event, StreamId(stream_id));
}
tungstenite::Message::Ping(payload) => {
websocket
@@ -14,6 +14,7 @@ use super::protocol::ClientEvent;
use super::protocol::ClientId;
use super::protocol::RemoteControlTarget;
use super::protocol::ServerEnvelope;
use super::protocol::StreamId;
use axum::http::HeaderValue;
use base64::Engine;
use codex_core::util::backoff;
@@ -25,8 +26,8 @@ use futures::SinkExt;
use futures::StreamExt;
use futures::stream::SplitSink;
use futures::stream::SplitStream;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::io;
use std::io::ErrorKind;
use std::sync::Arc;
@@ -57,10 +58,7 @@ const REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL: std::time::Duration =
std::time::Duration::from_secs(1);
struct BoundedOutboundBuffer {
// Remote-control acks are generated by the backend at client scope, so
// retransmit retention is keyed by client_id only. stream_id stays on each
// envelope for routing, but it is not part of the ack cursor.
buffer_by_client: HashMap<ClientId, BTreeMap<u64, ServerEnvelope>>,
buffer_by_stream: HashMap<(ClientId, StreamId), VecDeque<ServerEnvelope>>,
used_tx: watch::Sender<usize>,
}
@@ -68,46 +66,50 @@ impl BoundedOutboundBuffer {
fn new() -> (Self, watch::Receiver<usize>) {
let (used_tx, used_rx) = watch::channel(0);
let buffer = Self {
buffer_by_client: HashMap::new(),
buffer_by_stream: HashMap::new(),
used_tx,
};
(buffer, used_rx)
}
fn insert(&mut self, server_envelope: &ServerEnvelope) {
self.buffer_by_client
.entry(server_envelope.client_id.clone())
self.buffer_by_stream
.entry((
server_envelope.client_id.clone(),
server_envelope.stream_id.clone(),
))
.or_default()
.insert(server_envelope.seq_id, server_envelope.clone());
.push_back(server_envelope.clone());
self.used_tx.send_modify(|used| *used += 1);
}
fn ack(&mut self, client_id: &ClientId, acked_seq_id: u64) {
let Some(buffer) = self.buffer_by_client.get_mut(client_id) else {
fn ack(&mut self, client_id: &ClientId, stream_id: &StreamId, acked_seq_id: u64) {
let key = (client_id.clone(), stream_id.clone());
let Some(buffer) = self.buffer_by_stream.get_mut(&key) else {
return;
};
while let Some(seq_id) = buffer.first_key_value().map(|(seq_id, _)| seq_id)
&& *seq_id <= acked_seq_id
while let Some(server_envelope) = buffer.front()
&& server_envelope.seq_id <= acked_seq_id
{
buffer.pop_first();
buffer.pop_front();
self.used_tx.send_modify(|used| *used -= 1);
}
if buffer.is_empty() {
self.buffer_by_client.remove(client_id);
self.buffer_by_stream.remove(&key);
}
}
fn server_envelopes(&self) -> impl Iterator<Item = &ServerEnvelope> {
self.buffer_by_client
self.buffer_by_stream
.values()
.flat_map(|buffer| buffer.values())
.flat_map(|buffer| buffer.iter())
}
}
struct WebsocketState {
outbound_buffer: BoundedOutboundBuffer,
subscribe_cursor: Option<String>,
next_seq_id: u64,
next_seq_id_by_stream: HashMap<(ClientId, StreamId), u64>,
}
pub(crate) struct RemoteControlWebsocket {
@@ -162,7 +164,7 @@ impl RemoteControlWebsocket {
state: Arc::new(Mutex::new(WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id: 0,
next_seq_id_by_stream: HashMap::new(),
})),
server_event_rx: Arc::new(Mutex::new(server_event_rx)),
used_rx,
@@ -445,10 +447,16 @@ impl RemoteControlWebsocket {
}
}
};
let (server_envelope, write_complete_tx) = {
let (payload, write_complete_tx) = {
let mut state = state.lock().await;
let seq_id = state.next_seq_id;
state.next_seq_id = state.next_seq_id.saturating_add(1);
let seq_key = (
queued_server_envelope.client_id.clone(),
queued_server_envelope.stream_id.clone(),
);
let seq_id = *state
.next_seq_id_by_stream
.entry(seq_key.clone())
.or_insert(1);
let server_envelope = ServerEnvelope {
event: queued_server_envelope.event,
@@ -456,17 +464,19 @@ impl RemoteControlWebsocket {
seq_id,
stream_id: queued_server_envelope.stream_id,
};
let payload = match serde_json::to_string(&server_envelope) {
Ok(payload) => payload,
Err(err) => {
error!("failed to serialize remote-control server event: {err}");
continue;
}
};
state
.next_seq_id_by_stream
.insert(seq_key, seq_id.saturating_add(1));
state.outbound_buffer.insert(&server_envelope);
(server_envelope, queued_server_envelope.write_complete_tx)
};
let payload = match serde_json::to_string(&server_envelope) {
Ok(payload) => payload,
Err(err) => {
error!("failed to serialize remote-control server event: {err}");
continue;
}
(payload, queued_server_envelope.write_complete_tx)
};
tokio::select! {
@@ -590,10 +600,13 @@ impl RemoteControlWebsocket {
}
if let ClientEvent::Ack = &client_envelope.event
&& let Some(acked_seq_id) = client_envelope.seq_id
&& let Some(stream_id) = client_envelope.stream_id.as_ref()
{
websocket_state
.outbound_buffer
.ack(&client_envelope.client_id, acked_seq_id);
websocket_state.outbound_buffer.ack(
&client_envelope.client_id,
stream_id,
acked_seq_id,
);
}
drop(websocket_state);
@@ -920,6 +933,13 @@ mod tests {
use tokio::time::timeout;
use tokio_tungstenite::accept_async;
// Windows Bazel CI can take longer than a few seconds for the websocket
// client connection attempt to reach the local test listener.
#[cfg(windows)]
const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(not(windows))]
const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5);
async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc<StateRuntime> {
StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string())
.await
@@ -971,6 +991,7 @@ mod tests {
account_id: Some("account_id".to_string()),
}),
last_refresh: Some(Utc::now()),
agent_identity: None,
}
}
@@ -1219,7 +1240,7 @@ mod tests {
let state = Arc::new(Mutex::new(WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id: 0,
next_seq_id_by_stream: HashMap::new(),
}));
let (_server_event_tx, server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY);
let server_event_rx = Arc::new(Mutex::new(server_event_rx));
@@ -1247,6 +1268,83 @@ mod tests {
.expect("writer should stop cleanly");
}
#[tokio::test]
async fn run_server_writer_inner_assigns_contiguous_seq_ids_per_stream() {
let (client_stream, mut server_stream) = connected_websocket_pair().await;
let (websocket_writer, _websocket_reader) = client_stream.split();
let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new();
let state = Arc::new(Mutex::new(WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
}));
let (server_event_tx, server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY);
let server_event_rx = Arc::new(Mutex::new(server_event_rx));
let shutdown_token = CancellationToken::new();
let writer_task = tokio::spawn(RemoteControlWebsocket::run_server_writer_inner(
state,
server_event_rx,
used_rx,
websocket_writer,
Duration::from_secs(60),
shutdown_token.clone(),
));
let client_id = ClientId("client-1".to_string());
let first_stream = StreamId("stream-1".to_string());
let second_stream = StreamId("stream-2".to_string());
for stream_id in [&first_stream, &second_stream, &first_stream] {
server_event_tx
.send(super::super::QueuedServerEnvelope {
event: ServerEvent::Pong {
status: crate::transport::remote_control::protocol::PongStatus::Active,
},
client_id: client_id.clone(),
stream_id: stream_id.clone(),
write_complete_tx: None,
})
.await
.expect("server event should queue");
}
assert_eq!(
read_server_text_event(&mut server_stream).await,
serde_json::json!({
"type": "pong",
"client_id": "client-1",
"stream_id": "stream-1",
"seq_id": 1,
"status": "active",
})
);
assert_eq!(
read_server_text_event(&mut server_stream).await,
serde_json::json!({
"type": "pong",
"client_id": "client-1",
"stream_id": "stream-2",
"seq_id": 1,
"status": "active",
})
);
assert_eq!(
read_server_text_event(&mut server_stream).await,
serde_json::json!({
"type": "pong",
"client_id": "client-1",
"stream_id": "stream-1",
"seq_id": 2,
"status": "active",
})
);
shutdown_token.cancel();
writer_task
.await
.expect("writer task should join")
.expect("writer should stop cleanly");
}
#[tokio::test]
async fn run_websocket_reader_inner_times_out_without_pong_frames() {
let (client_stream, _server_stream) = connected_websocket_pair().await;
@@ -1255,7 +1353,7 @@ mod tests {
let state = Arc::new(Mutex::new(WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id: 0,
next_seq_id_by_stream: HashMap::new(),
}));
let (server_event_tx, _server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY);
let (transport_event_tx, _transport_event_rx) =
@@ -1286,70 +1384,32 @@ mod tests {
}
#[test]
fn outbound_buffer_acks_by_client_id_across_stream_ids() {
fn outbound_buffer_acks_by_stream_id() {
let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new();
let client_1 = ClientId("client-1".to_string());
let client_2 = ClientId("client-2".to_string());
let stream_1 = StreamId("stream-1".to_string());
outbound_buffer.insert(&server_envelope(
&client_1,
"stream-1",
/*seq_id*/ 0,
/*seq_id*/ 1,
"first-client-old-stream",
));
outbound_buffer.insert(&server_envelope(
&client_2,
"stream-1",
/*seq_id*/ 1,
/*seq_id*/ 2,
"second-client",
));
outbound_buffer.insert(&server_envelope(
&client_1,
"stream-2",
/*seq_id*/ 2,
/*seq_id*/ 3,
"first-client-new-stream",
));
outbound_buffer.ack(&client_1, /*acked_seq_id*/ 2);
let mut retained = outbound_buffer
.server_envelopes()
.map(|server_envelope| {
(
server_envelope.client_id.0.as_str(),
server_envelope.stream_id.0.as_str(),
server_envelope.seq_id,
)
})
.collect::<Vec<_>>();
retained.sort_unstable();
assert_eq!(retained, vec![("client-2", "stream-1", 1)]);
assert_eq!(*used_rx.borrow(), 1);
}
#[test]
fn outbound_buffer_retains_unacked_messages_until_ack_advances() {
let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new();
let client_1 = ClientId("client-1".to_string());
let client_2 = ClientId("client-2".to_string());
outbound_buffer.insert(&server_envelope(
&client_1,
"stream-1",
/*seq_id*/ 0,
"first-old",
));
outbound_buffer.insert(&server_envelope(
&client_1,
"stream-2",
/*seq_id*/ 1,
"first-new",
));
outbound_buffer.insert(&server_envelope(
&client_2, "stream-1", /*seq_id*/ 2, "second",
));
outbound_buffer.ack(&client_1, /*acked_seq_id*/ 0);
outbound_buffer.ack(&client_1, &stream_1, /*acked_seq_id*/ 3);
let mut retained = outbound_buffer
.server_envelopes()
@@ -1364,7 +1424,50 @@ mod tests {
retained.sort_unstable();
assert_eq!(
retained,
vec![("client-1", "stream-2", 1), ("client-2", "stream-1", 2)]
vec![("client-1", "stream-2", 3), ("client-2", "stream-1", 2)]
);
assert_eq!(*used_rx.borrow(), 2);
}
#[test]
fn outbound_buffer_retains_unacked_messages_until_ack_advances() {
let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new();
let client_1 = ClientId("client-1".to_string());
let client_2 = ClientId("client-2".to_string());
let stream_1 = StreamId("stream-1".to_string());
outbound_buffer.insert(&server_envelope(
&client_1,
"stream-1",
/*seq_id*/ 1,
"first-old",
));
outbound_buffer.insert(&server_envelope(
&client_1,
"stream-2",
/*seq_id*/ 2,
"first-new",
));
outbound_buffer.insert(&server_envelope(
&client_2, "stream-1", /*seq_id*/ 3, "second",
));
outbound_buffer.ack(&client_1, &stream_1, /*acked_seq_id*/ 1);
let mut retained = outbound_buffer
.server_envelopes()
.map(|server_envelope| {
(
server_envelope.client_id.0.as_str(),
server_envelope.stream_id.0.as_str(),
server_envelope.seq_id,
)
})
.collect::<Vec<_>>();
retained.sort_unstable();
assert_eq!(
retained,
vec![("client-1", "stream-2", 2), ("client-2", "stream-1", 3)]
);
assert_eq!(*used_rx.borrow(), 2);
}
@@ -1393,7 +1496,7 @@ mod tests {
}
async fn accept_http_request(listener: &TcpListener) -> (TcpStream, String) {
let (stream, _) = timeout(Duration::from_secs(5), listener.accept())
let (stream, _) = timeout(TEST_HTTP_ACCEPT_TIMEOUT, listener.accept())
.await
.expect("HTTP request should arrive in time")
.expect("listener accept should succeed");
@@ -1449,6 +1552,20 @@ mod tests {
(client_stream, server_stream)
}
async fn read_server_text_event(
server_stream: &mut WebSocketStream<TcpStream>,
) -> serde_json::Value {
let message = timeout(Duration::from_secs(5), server_stream.next())
.await
.expect("server event should arrive in time")
.expect("server websocket should stay open")
.expect("server event should read");
let tungstenite::Message::Text(text) = message else {
panic!("expected text event, got {message:?}");
};
serde_json::from_str(text.as_ref()).expect("server event should deserialize")
}
async fn respond_with_status_and_headers(
mut stream: TcpStream,
status: &str,
@@ -163,6 +163,7 @@ pub fn write_chatgpt_auth(
openai_api_key: None,
tokens: Some(tokens),
last_refresh,
agent_identity: None,
};
save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json")
+3
View File
@@ -15,10 +15,12 @@ pub use auth_fixtures::write_chatgpt_auth;
use codex_app_server_protocol::JSONRPCResponse;
pub use config::write_mock_responses_config_toml;
pub use config::write_mock_responses_config_toml_with_chatgpt_base_url;
pub use core_test_support::PathBufExt;
pub use core_test_support::format_with_current_shell;
pub use core_test_support::format_with_current_shell_display;
pub use core_test_support::format_with_current_shell_display_non_login;
pub use core_test_support::format_with_current_shell_non_login;
pub use core_test_support::test_absolute_path;
pub use core_test_support::test_path_buf_with_windows;
pub use core_test_support::test_tmp_path;
pub use core_test_support::test_tmp_path_buf;
@@ -38,6 +40,7 @@ pub use responses::create_shell_command_sse_response;
pub use rollout::create_fake_rollout;
pub use rollout::create_fake_rollout_with_source;
pub use rollout::create_fake_rollout_with_text_elements;
pub use rollout::create_fake_rollout_with_token_usage;
pub use rollout::rollout_path;
use serde::de::DeserializeOwned;
@@ -1,9 +1,13 @@
use anyhow::Result;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use serde_json::json;
use std::fs;
use std::fs::FileTimes;
@@ -50,6 +54,61 @@ pub fn create_fake_rollout(
)
}
/// Creates a minimal rollout whose history includes a persisted token usage event.
///
/// Resume and fork tests use this fixture to verify lifecycle replay of restored
/// usage without starting a model turn. The exact token values are intentionally
/// non-zero and asymmetric so assertions catch swapped total/last fields and
/// dropped cached or reasoning counters.
pub fn create_fake_rollout_with_token_usage(
codex_home: &Path,
filename_ts: &str,
meta_rfc3339: &str,
preview: &str,
model_provider: Option<&str>,
) -> Result<String> {
let thread_id = create_fake_rollout(
codex_home,
filename_ts,
meta_rfc3339,
preview,
model_provider,
/*git_info*/ None,
)?;
let payload = serde_json::to_value(EventMsg::TokenCount(TokenCountEvent {
info: Some(TokenUsageInfo {
total_token_usage: TokenUsage {
input_tokens: 120,
cached_input_tokens: 20,
output_tokens: 30,
reasoning_output_tokens: 10,
total_tokens: 150,
},
last_token_usage: TokenUsage {
input_tokens: 70,
cached_input_tokens: 10,
output_tokens: 20,
reasoning_output_tokens: 5,
total_tokens: 90,
},
model_context_window: Some(200_000),
}),
rate_limits: None,
}))?;
let file_path = rollout_path(codex_home, filename_ts, &thread_id);
let line = json!({
"timestamp": meta_rfc3339,
"type": "event_msg",
"payload": payload
})
.to_string();
fs::write(
&file_path,
format!("{}{}\n", fs::read_to_string(&file_path)?, line),
)?;
Ok(thread_id)
}
/// Create a minimal rollout file with an explicit session source.
pub fn create_fake_rollout_with_source(
codex_home: &Path,
+9 -3
View File
@@ -24,7 +24,9 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing auth RPCs under load.
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
fn create_config_toml_custom_provider(
codex_home: &Path,
@@ -349,6 +351,8 @@ async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result
)?;
let server = MockServer::start().await;
// App-server startup may proactively read stale auth before this test sends
// getAuthStatus; require the refresh path without depending on that race.
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
@@ -356,7 +360,7 @@ async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result
"code": "refresh_token_reused"
}
})))
.expect(2)
.expect(1..=2)
.mount(&server)
.await;
@@ -416,6 +420,8 @@ async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Res
)?;
let server = MockServer::start().await;
// App-server startup may proactively read stale auth before this test sends
// getAuthStatus; require the refresh path without depending on that race.
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
@@ -423,7 +429,7 @@ async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Res
"code": "refresh_token_reused"
}
})))
.expect(2)
.expect(1..=2)
.mount(&server)
.await;
@@ -11,6 +11,11 @@ use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
// macOS arm64 and Windows Bazel CI can spend tens of seconds in app-server
// startup before the initialize response or fuzzy-search notifications arrive.
#[cfg(any(target_os = "macos", windows))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const SHORT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
const STOP_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(250);
@@ -56,7 +56,9 @@ use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing app-list RPCs under load.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::test]
async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> {
@@ -117,6 +119,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
openai_api_key: Some("test-api-key".to_string()),
tokens: None,
last_refresh: None,
agent_identity: None,
},
AuthCredentialsStoreMode::File,
)?;
@@ -18,7 +18,9 @@ use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing turn RPCs under load.
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
#[tokio::test]
async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result<()> {
@@ -21,7 +21,9 @@ use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing list RPCs under load.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
/// Confirms the server returns the default collaboration mode presets in a stable order.
#[tokio::test]
@@ -411,11 +411,14 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> {
})
.await?;
let delta = read_command_exec_delta(&mut mcp).await?;
assert_eq!(delta.process_id, process_id.as_str());
assert_eq!(delta.stream, CommandExecOutputStream::Stdout);
assert_eq!(STANDARD.decode(&delta.delta_base64)?, b"abcde");
assert!(delta.cap_reached);
let output = collect_command_exec_output_until(
CommandExecDeltaReader::Mcp(&mut mcp),
process_id.as_str(),
"capped stdout",
|_output, delta| delta.stream == CommandExecOutputStream::Stdout && delta.cap_reached,
)
.await?;
assert_eq!(output.stdout, "abcde");
let terminate_request_id = mcp
.send_command_exec_terminate_request(CommandExecTerminateParams {
process_id: process_id.clone(),
@@ -471,21 +474,13 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> {
})
.await?;
let first_stdout = read_command_exec_delta(&mut mcp).await?;
let first_stderr = read_command_exec_delta(&mut mcp).await?;
let seen = [first_stdout, first_stderr];
assert!(
seen.iter()
.all(|delta| delta.process_id == process_id.as_str())
);
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stdout
&& delta.delta_base64 == STANDARD.encode("out-start\n")
}));
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stderr
&& delta.delta_base64 == STANDARD.encode("err-start\n")
}));
wait_for_command_exec_outputs_contains(
&mut mcp,
process_id.as_str(),
"out-start\n",
"err-start\n",
)
.await?;
let write_request_id = mcp
.send_command_exec_write_request(CommandExecWriteParams {
@@ -499,21 +494,13 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> {
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let next_delta = read_command_exec_delta(&mut mcp).await?;
let final_delta = read_command_exec_delta(&mut mcp).await?;
let seen = [next_delta, final_delta];
assert!(
seen.iter()
.all(|delta| delta.process_id == process_id.as_str())
);
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stdout
&& delta.delta_base64 == STANDARD.encode("out:hello\n")
}));
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stderr
&& delta.delta_base64 == STANDARD.encode("err:hello\n")
}));
wait_for_command_exec_outputs_contains(
&mut mcp,
process_id.as_str(),
"out:hello\n",
"err:hello\n",
)
.await?;
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
@@ -562,17 +549,13 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<(
})
.await?;
let started_text = read_command_exec_output_until_contains(
wait_for_command_exec_output_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"tty\n",
)
.await?;
assert!(
started_text.contains("tty\n"),
"expected TTY startup output, got {started_text:?}"
);
let write_request_id = mcp
.send_command_exec_write_request(CommandExecWriteParams {
@@ -586,17 +569,13 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<(
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let echoed_text = read_command_exec_output_until_contains(
wait_for_command_exec_output_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"echo:world\n",
)
.await?;
assert!(
echoed_text.contains("echo:world\n"),
"expected TTY echo output, got {echoed_text:?}"
);
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
@@ -643,17 +622,13 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> {
})
.await?;
let started_text = read_command_exec_output_until_contains(
wait_for_command_exec_output_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"start:31 101\n",
)
.await?;
assert!(
started_text.contains("start:31 101\n"),
"unexpected initial size output: {started_text:?}"
);
let resize_request_id = mcp
.send_command_exec_resize_request(CommandExecResizeParams {
@@ -681,17 +656,13 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> {
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let resized_text = read_command_exec_output_until_contains(
wait_for_command_exec_output_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"after:45 132\n",
)
.await?;
assert!(
resized_text.contains("after:45 132\n"),
"unexpected resized output: {resized_text:?}"
);
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
@@ -744,11 +715,13 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
)
.await?;
let delta = read_command_exec_delta_ws(&mut ws1).await?;
assert_eq!(delta.process_id, "shared-process");
assert_eq!(delta.stream, CommandExecOutputStream::Stdout);
let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?;
assert!(delta_text.contains("ready"));
collect_command_exec_output_until(
CommandExecDeltaReader::Websocket(&mut ws1),
"shared-process",
"websocket ready output",
|output, _delta| output.stdout.contains("ready\n"),
)
.await?;
wait_for_process_marker(&marker, /*should_exist*/ true).await?;
send_request(
@@ -796,31 +769,98 @@ async fn read_command_exec_delta(
decode_delta_notification(notification)
}
async fn read_command_exec_output_until_contains(
async fn wait_for_command_exec_output_contains(
mcp: &mut McpProcess,
process_id: &str,
stream: CommandExecOutputStream,
expected: &str,
) -> Result<String> {
) -> Result<()> {
let stream_name = match stream {
CommandExecOutputStream::Stdout => "stdout",
CommandExecOutputStream::Stderr => "stderr",
};
collect_command_exec_output_until(
CommandExecDeltaReader::Mcp(mcp),
process_id,
format!("{stream_name} containing {expected:?}"),
|output, _delta| match stream {
CommandExecOutputStream::Stdout => output.stdout.contains(expected),
CommandExecOutputStream::Stderr => output.stderr.contains(expected),
},
)
.await?;
Ok(())
}
async fn wait_for_command_exec_outputs_contains(
mcp: &mut McpProcess,
process_id: &str,
stdout_expected: &str,
stderr_expected: &str,
) -> Result<()> {
collect_command_exec_output_until(
CommandExecDeltaReader::Mcp(mcp),
process_id,
format!("stdout containing {stdout_expected:?} and stderr containing {stderr_expected:?}"),
|output, _delta| {
output.stdout.contains(stdout_expected) && output.stderr.contains(stderr_expected)
},
)
.await?;
Ok(())
}
enum CommandExecDeltaReader<'a> {
Mcp(&'a mut McpProcess),
Websocket(&'a mut super::connection_handling_websocket::WsClient),
}
#[derive(Default)]
struct CollectedCommandExecOutput {
stdout: String,
stderr: String,
}
async fn collect_command_exec_output_until(
mut reader: CommandExecDeltaReader<'_>,
process_id: &str,
waiting_for: impl Into<String>,
mut should_stop: impl FnMut(
&CollectedCommandExecOutput,
&CommandExecOutputDeltaNotification,
) -> bool,
) -> Result<CollectedCommandExecOutput> {
let waiting_for = waiting_for.into();
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
let mut collected = String::new();
let mut output = CollectedCommandExecOutput::default();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let delta = timeout(remaining, read_command_exec_delta(mcp))
.await
.with_context(|| {
format!(
"timed out waiting for {expected:?} in command/exec output for {process_id}; collected {collected:?}"
)
})??;
let delta = timeout(remaining, async {
match &mut reader {
CommandExecDeltaReader::Mcp(mcp) => read_command_exec_delta(mcp).await,
CommandExecDeltaReader::Websocket(stream) => {
read_command_exec_delta_ws(stream).await
}
}
})
.await
.with_context(|| {
format!(
"timed out waiting for {waiting_for} in command/exec output for {process_id}; collected stdout={:?}, stderr={:?}",
output.stdout, output.stderr
)
})??;
assert_eq!(delta.process_id, process_id);
assert_eq!(delta.stream, stream);
let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?;
collected.push_str(&delta_text.replace('\r', ""));
if collected.contains(expected) {
return Ok(collected);
let delta_text = delta_text.replace('\r', "");
match delta.stream {
CommandExecOutputStream::Stdout => output.stdout.push_str(&delta_text),
CommandExecOutputStream::Stderr => output.stderr.push_str(&delta_text),
}
if should_stop(&output, &delta) {
return Ok(output);
}
}
}
@@ -38,6 +38,11 @@ use std::collections::BTreeMap;
use tempfile::TempDir;
use tokio::time::timeout;
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
// subprocesses or processing test RPCs under load.
#[cfg(any(target_os = "macos", windows))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const AUTO_COMPACT_LIMIT: i64 = 1_000;
const COMPACT_PROMPT: &str = "Summarize the conversation.";
@@ -33,7 +33,9 @@ use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing config RPCs under load.
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> {
Ok(std::fs::write(
@@ -47,6 +47,12 @@ use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
use tokio_tungstenite::tungstenite::http::header::ORIGIN;
// macOS and Windows CI can spend tens of seconds starting the app-server test
// binary under Bazel before it accepts JSON-RPC or reports its websocket bind
// address.
#[cfg(any(target_os = "macos", windows))]
pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
pub(super) type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
@@ -399,7 +405,7 @@ pub(super) async fn spawn_websocket_server_with_args(
.take()
.context("failed to capture websocket app-server stderr")?;
let mut stderr_reader = BufReader::new(stderr).lines();
let deadline = Instant::now() + Duration::from_secs(10);
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
let bind_addr = loop {
let line = timeout(
deadline.saturating_duration_since(Instant::now()),
@@ -457,7 +463,7 @@ pub(super) async fn connect_websocket_with_bearer(
) -> Result<WsClient> {
let url = format!("ws://{}", connectable_bind_addr(bind_addr));
let request = websocket_request(url.as_str(), bearer_token, /*origin*/ None)?;
let deadline = Instant::now() + Duration::from_secs(10);
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
loop {
match connect_async(request.clone()).await {
Ok((stream, _response)) => return Ok(stream),
@@ -524,7 +530,7 @@ async fn run_websocket_server_to_completion_with_args(
.stderr(Stdio::piped())
.env("CODEX_HOME", codex_home)
.env("RUST_LOG", "debug");
timeout(Duration::from_secs(10), cmd.output())
timeout(DEFAULT_READ_TIMEOUT, cmd.output())
.await
.context("timed out waiting for websocket app-server to exit")?
.context("failed to run websocket app-server")
@@ -536,7 +542,7 @@ async fn http_get(
path: &str,
) -> Result<reqwest::Response> {
let connectable_bind_addr = connectable_bind_addr(bind_addr);
let deadline = Instant::now() + Duration::from_secs(10);
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
loop {
match client
.get(format!("http://{connectable_bind_addr}{path}"))
@@ -34,6 +34,11 @@ use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::MockServer;
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
// subprocesses or processing test RPCs under load.
#[cfg(any(target_os = "macos", windows))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
/// Ensures dynamic tool specs are serialized into the model request payload.
+5
View File
@@ -27,6 +27,11 @@ use std::os::unix::fs::symlink;
#[cfg(unix)]
use std::process::Command;
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
// subprocesses or processing test RPCs under load.
#[cfg(any(target_os = "macos", windows))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
async fn initialized_mcp(codex_home: &TempDir) -> Result<McpProcess> {
@@ -1,7 +1,11 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::timeout;
@@ -9,8 +13,20 @@ use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn marketplace_add_rejects_local_directory_source() -> Result<()> {
async fn marketplace_add_local_directory_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source = codex_home.path().join("marketplace");
std::fs::create_dir_all(source.join(".agents/plugins"))?;
std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;
std::fs::write(
source.join(".agents/plugins/marketplace.json"),
r#"{"name":"debug","plugins":[]}"#,
)?;
std::fs::write(
source.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(source.join("plugins/sample/marker.txt"), "local ref")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -22,19 +38,24 @@ async fn marketplace_add_rejects_local_directory_source() -> Result<()> {
})
.await?;
let err = timeout(
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let MarketplaceAddResponse {
marketplace_name,
installed_root,
already_added,
} = to_response(response)?;
let expected_root = source.canonicalize()?;
assert_eq!(err.error.code, -32600);
assert!(
err.error.message.contains(
"local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo"
),
"unexpected error: {}",
err.error.message
assert_eq!(marketplace_name, "debug");
assert_eq!(installed_root.as_path(), expected_root.as_path());
assert!(!already_added);
assert_eq!(
std::fs::read_to_string(installed_root.as_path().join("plugins/sample/marker.txt"))?,
"local ref"
);
Ok(())
}
@@ -66,8 +66,9 @@ use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const CONNECTOR_ID: &str = "calendar";
const CONNECTOR_NAME: &str = "Calendar";
const TOOL_NAMESPACE: &str = "mcp__codex_apps__calendar";
const CALLABLE_TOOL_NAME: &str = "_confirm_action";
const TOOL_NAME: &str = "calendar_confirm_action";
const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar_confirm_action";
const TOOL_CALL_ID: &str = "call-calendar-confirm";
const ELICITATION_MESSAGE: &str = "Allow this request?";
@@ -85,9 +86,10 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> {
]),
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(
responses::ev_function_call_with_namespace(
TOOL_CALL_ID,
QUALIFIED_TOOL_NAME,
TOOL_NAMESPACE,
CALLABLE_TOOL_NAME,
&tool_call_arguments,
),
responses::ev_completed("resp-1"),
@@ -0,0 +1,145 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use chrono::Utc;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::MemoryResetResponse;
use codex_app_server_protocol::RequestId;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_state::Stage1JobClaimOutcome;
use codex_state::StateRuntime;
use codex_state::ThreadMetadataBuilder;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::time::timeout;
use uuid::Uuid;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn memory_reset_clears_memory_files_and_rows_preserves_threads() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
let state_db = init_state_db(codex_home.path()).await?;
let memory_root = codex_home.path().join("memories");
tokio::fs::create_dir_all(memory_root.join("rollout_summaries")).await?;
tokio::fs::write(memory_root.join("MEMORY.md"), "stale memory\n").await?;
tokio::fs::write(
memory_root.join("rollout_summaries").join("stale.md"),
"stale rollout summary\n",
)
.await?;
let thread_id = seed_stage1_output(&state_db, codex_home.path()).await?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request("memory/reset", /*params*/ None)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let _: MemoryResetResponse = to_response::<MemoryResetResponse>(response)?;
let stage1_outputs = state_db.list_stage1_outputs_for_global(/*n*/ 10).await?;
assert_eq!(stage1_outputs, Vec::new());
assert_eq!(
state_db.get_thread_memory_mode(thread_id).await?.as_deref(),
Some("enabled")
);
let mut remaining_entries = tokio::fs::read_dir(&memory_root).await?;
assert!(
remaining_entries.next_entry().await?.is_none(),
"memory root should be empty after reset"
);
Ok(())
}
async fn seed_stage1_output(state_db: &Arc<StateRuntime>, codex_home: &Path) -> Result<ThreadId> {
let now = Utc::now();
let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
let worker_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
let mut builder = ThreadMetadataBuilder::new(
thread_id,
codex_home.join("sessions").join("test.jsonl"),
now,
SessionSource::Cli,
);
builder.updated_at = Some(now);
builder.cwd = codex_home.to_path_buf();
let metadata = builder.build("mock_provider");
state_db.upsert_thread(&metadata).await?;
let claim = state_db
.try_claim_stage1_job(
thread_id,
worker_id,
now.timestamp(),
/*lease_seconds*/ 3600,
/*max_running_jobs*/ 64,
)
.await?;
let Stage1JobClaimOutcome::Claimed { ownership_token } = claim else {
anyhow::bail!("unexpected stage1 claim outcome: {claim:?}");
};
assert!(
state_db
.mark_stage1_job_succeeded(
thread_id,
ownership_token.as_str(),
now.timestamp(),
"raw memory",
"rollout summary",
/*rollout_slug*/ None,
)
.await?,
"stage1 success should be recorded"
);
state_db
.enqueue_global_consolidation(now.timestamp())
.await?;
Ok(thread_id)
}
async fn init_state_db(codex_home: &Path) -> Result<Arc<StateRuntime>> {
let state_db = StateRuntime::init(codex_home.to_path_buf(), "mock_provider".into()).await?;
state_db
.mark_backfill_complete(/*last_watermark*/ None)
.await?;
Ok(state_db)
}
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
suppress_unstable_features_warning = true
[features]
sqlite = true
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "http://127.0.0.1:9/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#,
)
}
@@ -20,6 +20,7 @@ mod mcp_resource;
mod mcp_server_elicitation;
mod mcp_server_status;
mod mcp_tool;
mod memory_reset;
mod model_list;
mod output_schema;
mod plan_item;
@@ -51,7 +51,9 @@ use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
// Plugin install tests wait on connector discovery after the install response path
// starts, which is noticeably slower on Windows CI.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::test]
async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> {
@@ -30,7 +30,8 @@ use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
// These tests start full app-server processes and can also run plugin startup warmers.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";

Some files were not shown because too many files have changed in this diff Show More