mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
Merge branch 'main' into dev/cc/new-schema
This commit is contained in:
@@ -58,6 +58,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check rusty_v8 MODULE.bazel checksums
|
||||
@@ -152,6 +153,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare Bazel CI
|
||||
@@ -237,6 +239,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare Bazel CI
|
||||
@@ -326,6 +329,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare Bazel CI
|
||||
|
||||
@@ -10,6 +10,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -18,8 +19,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "base=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$(git rev-parse HEAD^2)" >> "$GITHUB_OUTPUT"
|
||||
echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
|
||||
echo "head=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check changed blob sizes
|
||||
env:
|
||||
|
||||
@@ -16,6 +16,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify codex-rs Cargo manifests inherit workspace settings
|
||||
|
||||
@@ -20,6 +20,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
- name: Annotate locations with typos
|
||||
uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1.1.0
|
||||
|
||||
@@ -44,10 +44,9 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
|
||||
with:
|
||||
tool: cargo-shear
|
||||
version: 1.11.2
|
||||
tool: cargo-shear@1.11.2
|
||||
- name: cargo shear
|
||||
run: cargo shear
|
||||
run: cargo shear --deny-warnings
|
||||
|
||||
argument_comment_lint_package:
|
||||
name: Argument comment lint package
|
||||
|
||||
@@ -16,6 +16,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Detect changed paths (no external action)
|
||||
@@ -64,6 +65,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
with:
|
||||
@@ -82,14 +84,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
|
||||
with:
|
||||
tool: cargo-shear
|
||||
version: 1.11.2
|
||||
tool: cargo-shear@1.11.2
|
||||
- name: cargo shear
|
||||
run: cargo shear
|
||||
run: cargo shear --deny-warnings
|
||||
|
||||
argument_comment_lint_package:
|
||||
name: Argument comment lint package
|
||||
@@ -102,6 +104,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
- name: Install nightly argument-comment-lint toolchain
|
||||
@@ -180,6 +183,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
if: ${{ steps.argument_comment_lint_gate.outputs.run == 'true' }}
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
- name: Run argument comment lint on codex-rs via Bazel
|
||||
if: ${{ steps.argument_comment_lint_gate.outputs.run == 'true' }}
|
||||
|
||||
@@ -15,6 +15,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Linux bwrap build dependencies
|
||||
|
||||
@@ -42,6 +42,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
@@ -78,6 +79,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Bazel
|
||||
|
||||
Generated
+37
-3
@@ -1900,6 +1900,7 @@ dependencies = [
|
||||
"codex-features",
|
||||
"codex-feedback",
|
||||
"codex-file-search",
|
||||
"codex-file-watcher",
|
||||
"codex-git-utils",
|
||||
"codex-hooks",
|
||||
"codex-login",
|
||||
@@ -1979,6 +1980,26 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-app-server-daemon"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codex-app-server-protocol",
|
||||
"codex-app-server-transport",
|
||||
"codex-core",
|
||||
"codex-uds",
|
||||
"futures",
|
||||
"libc",
|
||||
"pretty_assertions",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-app-server-protocol"
|
||||
version = "0.0.0"
|
||||
@@ -2206,6 +2227,7 @@ dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"codex-app-server",
|
||||
"codex-app-server-daemon",
|
||||
"codex-app-server-protocol",
|
||||
"codex-app-server-test-client",
|
||||
"codex-arg0",
|
||||
@@ -2441,7 +2463,11 @@ dependencies = [
|
||||
"codex-app-server-protocol",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
@@ -2526,7 +2552,6 @@ dependencies = [
|
||||
"insta",
|
||||
"libc",
|
||||
"maplit",
|
||||
"notify",
|
||||
"once_cell",
|
||||
"openssl-sys",
|
||||
"opentelemetry",
|
||||
@@ -2733,7 +2758,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"test-case",
|
||||
"thiserror 2.0.18",
|
||||
@@ -2870,6 +2894,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-file-watcher"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"notify",
|
||||
"pretty_assertions",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-git-utils"
|
||||
version = "0.0.0"
|
||||
@@ -3331,7 +3366,6 @@ dependencies = [
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-image",
|
||||
"codex-utils-string",
|
||||
"codex-utils-template",
|
||||
"encoding_rs",
|
||||
"globset",
|
||||
"http 1.4.0",
|
||||
|
||||
+4
-3
@@ -11,6 +11,7 @@ members = [
|
||||
"async-utils",
|
||||
"app-server",
|
||||
"app-server-transport",
|
||||
"app-server-daemon",
|
||||
"app-server-client",
|
||||
"app-server-protocol",
|
||||
"app-server-test-client",
|
||||
@@ -48,6 +49,7 @@ members = [
|
||||
"external-agent-sessions",
|
||||
"keyring-store",
|
||||
"file-search",
|
||||
"file-watcher",
|
||||
"linux-sandbox",
|
||||
"lmstudio",
|
||||
"login",
|
||||
@@ -131,6 +133,7 @@ codex-api = { path = "codex-api" }
|
||||
codex-aws-auth = { path = "aws-auth" }
|
||||
codex-app-server = { path = "app-server" }
|
||||
codex-app-server-transport = { path = "app-server-transport" }
|
||||
codex-app-server-daemon = { path = "app-server-daemon" }
|
||||
codex-app-server-client = { path = "app-server-client" }
|
||||
codex-app-server-protocol = { path = "app-server-protocol" }
|
||||
codex-app-server-test-client = { path = "app-server-test-client" }
|
||||
@@ -164,6 +167,7 @@ codex-features = { path = "features" }
|
||||
codex-feedback = { path = "feedback" }
|
||||
codex-install-context = { path = "install-context" }
|
||||
codex-file-search = { path = "file-search" }
|
||||
codex-file-watcher = { path = "file-watcher" }
|
||||
codex-git-utils = { path = "git-utils" }
|
||||
codex-hooks = { path = "hooks" }
|
||||
codex-keyring-store = { path = "keyring-store" }
|
||||
@@ -464,11 +468,8 @@ unwrap_used = "deny"
|
||||
[workspace.metadata.cargo-shear]
|
||||
ignored = [
|
||||
"codex-agent-graph-store",
|
||||
"codex-memories-mcp",
|
||||
"icu_provider",
|
||||
"openssl-sys",
|
||||
"codex-utils-readiness",
|
||||
"codex-utils-template",
|
||||
"codex-v8-poc",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
use crate::events::CodexAcceptedLineFingerprintsEventParams;
|
||||
use crate::events::CodexAcceptedLineFingerprintsEventRequest;
|
||||
use crate::events::TrackEventRequest;
|
||||
use crate::facts::AcceptedLineFingerprint;
|
||||
use codex_git_utils::canonicalize_git_remote_url;
|
||||
use codex_git_utils::get_git_remote_urls_assume_git_repo;
|
||||
use sha1::Digest;
|
||||
use std::path::Path;
|
||||
|
||||
const ACCEPTED_LINE_FINGERPRINT_EVENT_TARGET_BYTES: usize = 2 * 1024 * 1024;
|
||||
const ACCEPTED_LINE_FINGERPRINT_EVENT_FIXED_BYTES: usize = 1024;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AcceptedLineFingerprintSummary {
|
||||
pub accepted_added_lines: u64,
|
||||
pub accepted_deleted_lines: u64,
|
||||
pub line_fingerprints: Vec<AcceptedLineFingerprint>,
|
||||
}
|
||||
|
||||
pub(crate) struct AcceptedLineFingerprintEventInput {
|
||||
pub(crate) event_type: &'static str,
|
||||
pub(crate) turn_id: String,
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) product_surface: Option<String>,
|
||||
pub(crate) model_slug: Option<String>,
|
||||
pub(crate) completed_at: u64,
|
||||
pub(crate) repo_hash: Option<String>,
|
||||
pub(crate) accepted_added_lines: u64,
|
||||
pub(crate) accepted_deleted_lines: u64,
|
||||
pub(crate) line_fingerprints: Vec<AcceptedLineFingerprint>,
|
||||
}
|
||||
|
||||
pub fn accepted_line_fingerprints_from_unified_diff(
|
||||
unified_diff: &str,
|
||||
) -> AcceptedLineFingerprintSummary {
|
||||
let mut current_path: Option<String> = None;
|
||||
let mut in_hunk = false;
|
||||
let mut accepted_added_lines = 0;
|
||||
let mut accepted_deleted_lines = 0;
|
||||
let mut line_fingerprints = Vec::new();
|
||||
|
||||
for line in unified_diff.lines() {
|
||||
if line.starts_with("diff --git ") {
|
||||
current_path = None;
|
||||
in_hunk = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with("@@ ") {
|
||||
in_hunk = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_hunk && let Some(path) = line.strip_prefix("+++ ") {
|
||||
current_path = normalize_diff_path(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_hunk && line.starts_with("--- ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(added_line) = line.strip_prefix('+') {
|
||||
accepted_added_lines += 1;
|
||||
if let Some(path) = current_path.as_deref()
|
||||
&& let Some(normalized_line) = normalize_effective_line(added_line)
|
||||
{
|
||||
line_fingerprints.push(AcceptedLineFingerprint {
|
||||
path_hash: fingerprint_hash("path", path),
|
||||
line_hash: fingerprint_hash("line", &normalized_line),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with('-') {
|
||||
accepted_deleted_lines += 1;
|
||||
}
|
||||
}
|
||||
|
||||
AcceptedLineFingerprintSummary {
|
||||
accepted_added_lines,
|
||||
accepted_deleted_lines,
|
||||
line_fingerprints,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fingerprint_hash(domain: &str, value: &str) -> String {
|
||||
let mut hasher = sha1::Sha1::new();
|
||||
hasher.update(b"file-line-v1\0");
|
||||
hasher.update(domain.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(value.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub(crate) fn accepted_line_fingerprint_event_requests(
|
||||
input: AcceptedLineFingerprintEventInput,
|
||||
) -> Vec<TrackEventRequest> {
|
||||
let chunks = accepted_line_fingerprint_chunks(input.line_fingerprints);
|
||||
chunks
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, line_fingerprints)| {
|
||||
let is_first_chunk = index == 0;
|
||||
TrackEventRequest::AcceptedLineFingerprints(Box::new(
|
||||
CodexAcceptedLineFingerprintsEventRequest {
|
||||
event_type: "codex_accepted_line_fingerprints",
|
||||
event_params: CodexAcceptedLineFingerprintsEventParams {
|
||||
event_type: input.event_type,
|
||||
turn_id: input.turn_id.clone(),
|
||||
thread_id: input.thread_id.clone(),
|
||||
product_surface: input.product_surface.clone(),
|
||||
model_slug: input.model_slug.clone(),
|
||||
completed_at: input.completed_at,
|
||||
repo_hash: input.repo_hash.clone(),
|
||||
accepted_added_lines: if is_first_chunk {
|
||||
input.accepted_added_lines
|
||||
} else {
|
||||
0
|
||||
},
|
||||
accepted_deleted_lines: if is_first_chunk {
|
||||
input.accepted_deleted_lines
|
||||
} else {
|
||||
0
|
||||
},
|
||||
line_fingerprints,
|
||||
},
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn accepted_line_repo_hash_for_cwd(cwd: &Path) -> Option<String> {
|
||||
let remotes = get_git_remote_urls_assume_git_repo(cwd).await?;
|
||||
remotes
|
||||
.get("origin")
|
||||
.or_else(|| remotes.values().next())
|
||||
.map(|remote_url| {
|
||||
let canonical_remote_url =
|
||||
canonicalize_git_remote_url(remote_url).unwrap_or_else(|| remote_url.to_string());
|
||||
fingerprint_hash("repo", &canonical_remote_url)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_diff_path(path: &str) -> Option<String> {
|
||||
let path = path.trim();
|
||||
if path == "/dev/null" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
path.strip_prefix("b/")
|
||||
.or_else(|| path.strip_prefix("a/"))
|
||||
.unwrap_or(path)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_effective_line(line: &str) -> Option<String> {
|
||||
let normalized = line.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.len() <= 3 {
|
||||
return None;
|
||||
}
|
||||
if !normalized
|
||||
.chars()
|
||||
.any(|ch| ch.is_alphanumeric() || ch == '_')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
fn accepted_line_fingerprint_chunks(
|
||||
line_fingerprints: Vec<AcceptedLineFingerprint>,
|
||||
) -> Vec<Vec<AcceptedLineFingerprint>> {
|
||||
if line_fingerprints.is_empty() {
|
||||
return vec![Vec::new()];
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
let mut current = Vec::new();
|
||||
let mut current_bytes = ACCEPTED_LINE_FINGERPRINT_EVENT_FIXED_BYTES;
|
||||
|
||||
for fingerprint in line_fingerprints {
|
||||
let item_bytes = accepted_line_fingerprint_json_bytes(&fingerprint);
|
||||
let separator_bytes = usize::from(!current.is_empty());
|
||||
if !current.is_empty()
|
||||
&& current_bytes + separator_bytes + item_bytes
|
||||
> ACCEPTED_LINE_FINGERPRINT_EVENT_TARGET_BYTES
|
||||
{
|
||||
chunks.push(current);
|
||||
current = Vec::new();
|
||||
current_bytes = ACCEPTED_LINE_FINGERPRINT_EVENT_FIXED_BYTES;
|
||||
}
|
||||
current_bytes += usize::from(!current.is_empty()) + item_bytes;
|
||||
current.push(fingerprint);
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
chunks.push(current);
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
fn accepted_line_fingerprint_json_bytes(fingerprint: &AcceptedLineFingerprint) -> usize {
|
||||
// {"path_hash":"...","line_hash":"..."} plus one byte of array comma
|
||||
// accounted for by the caller when needed.
|
||||
32 + fingerprint.path_hash.len() + fingerprint.line_hash.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_counts_and_effective_added_fingerprints() {
|
||||
let diff = "\
|
||||
diff --git a/src/lib.rs b/src/lib.rs
|
||||
index 1111111..2222222
|
||||
--- a/src/lib.rs
|
||||
+++ b/src/lib.rs
|
||||
@@ -1,3 +1,5 @@
|
||||
-old line
|
||||
+fn useful() {
|
||||
+}
|
||||
+ return user.id;
|
||||
context
|
||||
";
|
||||
|
||||
let summary = accepted_line_fingerprints_from_unified_diff(diff);
|
||||
|
||||
assert_eq!(
|
||||
summary,
|
||||
AcceptedLineFingerprintSummary {
|
||||
accepted_added_lines: 3,
|
||||
accepted_deleted_lines: 1,
|
||||
line_fingerprints: vec![
|
||||
AcceptedLineFingerprint {
|
||||
path_hash: fingerprint_hash("path", "src/lib.rs"),
|
||||
line_hash: fingerprint_hash("line", "fn useful() {"),
|
||||
},
|
||||
AcceptedLineFingerprint {
|
||||
path_hash: fingerprint_hash("path", "src/lib.rs"),
|
||||
line_hash: fingerprint_hash("line", "return user.id;"),
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_added_file_metadata_headers() {
|
||||
let diff = "\
|
||||
diff --git a/new.py b/new.py
|
||||
new file mode 100644
|
||||
index 0000000..1111111
|
||||
--- /dev/null
|
||||
+++ b/new.py
|
||||
@@ -0,0 +1 @@
|
||||
+print('hello')
|
||||
";
|
||||
|
||||
let summary = accepted_line_fingerprints_from_unified_diff(diff);
|
||||
|
||||
assert_eq!(summary.accepted_added_lines, 1);
|
||||
assert_eq!(summary.accepted_deleted_lines, 0);
|
||||
assert_eq!(summary.line_fingerprints.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hunk_lines_that_look_like_file_headers() {
|
||||
let diff = "\
|
||||
diff --git a/src/lib.rs b/src/lib.rs
|
||||
index 1111111..2222222
|
||||
--- a/src/lib.rs
|
||||
+++ b/src/lib.rs
|
||||
@@ -1,2 +1,2 @@
|
||||
--- old value
|
||||
+++ new value
|
||||
";
|
||||
|
||||
let summary = accepted_line_fingerprints_from_unified_diff(diff);
|
||||
|
||||
assert_eq!(
|
||||
summary,
|
||||
AcceptedLineFingerprintSummary {
|
||||
accepted_added_lines: 1,
|
||||
accepted_deleted_lines: 1,
|
||||
line_fingerprints: vec![AcceptedLineFingerprint {
|
||||
path_hash: fingerprint_hash("path", "src/lib.rs"),
|
||||
line_hash: fingerprint_hash("line", "++ new value"),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::client::AnalyticsEventsQueue;
|
||||
use crate::events::AppServerRpcTransport;
|
||||
use crate::events::CodexAcceptedLineFingerprintsEventParams;
|
||||
use crate::events::CodexAcceptedLineFingerprintsEventRequest;
|
||||
use crate::events::CodexAppMentionedEventRequest;
|
||||
use crate::events::CodexAppServerClientMetadata;
|
||||
use crate::events::CodexAppUsedEventRequest;
|
||||
@@ -28,6 +30,7 @@ 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;
|
||||
use crate::facts::AcceptedLineFingerprint;
|
||||
use crate::facts::AnalyticsFact;
|
||||
use crate::facts::AnalyticsJsonRpcError;
|
||||
use crate::facts::AppInvocation;
|
||||
@@ -89,6 +92,7 @@ use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnDiffUpdatedNotification;
|
||||
use codex_app_server_protocol::TurnError as AppServerTurnError;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
@@ -636,6 +640,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact {
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
},
|
||||
@@ -827,6 +832,206 @@ fn app_used_event_serializes_expected_shape() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_line_fingerprints_event_serializes_expected_shape() {
|
||||
let event = TrackEventRequest::AcceptedLineFingerprints(Box::new(
|
||||
CodexAcceptedLineFingerprintsEventRequest {
|
||||
event_type: "codex_accepted_line_fingerprints",
|
||||
event_params: CodexAcceptedLineFingerprintsEventParams {
|
||||
event_type: "codex.accepted_line_fingerprints",
|
||||
turn_id: "turn-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
product_surface: Some("codex".to_string()),
|
||||
model_slug: Some("gpt-5.1-codex".to_string()),
|
||||
completed_at: 1710000000,
|
||||
repo_hash: Some("repo-hash-1".to_string()),
|
||||
accepted_added_lines: 42,
|
||||
accepted_deleted_lines: 40,
|
||||
line_fingerprints: vec![AcceptedLineFingerprint {
|
||||
path_hash: "path-hash-1".to_string(),
|
||||
line_hash: "line-hash-1".to_string(),
|
||||
}],
|
||||
},
|
||||
},
|
||||
));
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize accepted line fingerprints event");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"event_type": "codex_accepted_line_fingerprints",
|
||||
"event_params": {
|
||||
"event_type": "codex.accepted_line_fingerprints",
|
||||
"turn_id": "turn-1",
|
||||
"thread_id": "thread-1",
|
||||
"product_surface": "codex",
|
||||
"model_slug": "gpt-5.1-codex",
|
||||
"completed_at": 1710000000,
|
||||
"repo_hash": "repo-hash-1",
|
||||
"accepted_added_lines": 42,
|
||||
"accepted_deleted_lines": 40,
|
||||
"line_fingerprints": [
|
||||
{
|
||||
"path_hash": "path-hash-1",
|
||||
"line_hash": "line-hash-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reducer_chunks_large_accepted_line_fingerprint_events_without_repeating_counts() {
|
||||
let mut reducer = AnalyticsReducer::default();
|
||||
let mut events = Vec::new();
|
||||
|
||||
ingest_turn_prerequisites(
|
||||
&mut reducer,
|
||||
&mut events,
|
||||
/*include_initialize*/ true,
|
||||
/*include_resolved_config*/ true,
|
||||
/*include_started*/ true,
|
||||
/*include_token_usage*/ true,
|
||||
)
|
||||
.await;
|
||||
events.clear();
|
||||
|
||||
let mut diff = "\
|
||||
diff --git a/src/lib.rs b/src/lib.rs
|
||||
index 1111111..2222222
|
||||
--- a/src/lib.rs
|
||||
+++ b/src/lib.rs
|
||||
@@ -0,0 +1,20000 @@
|
||||
"
|
||||
.to_string();
|
||||
for index in 0..20_000 {
|
||||
diff.push_str(&format!("+let value_{index} = {index};\n"));
|
||||
}
|
||||
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Notification(Box::new(ServerNotification::TurnDiffUpdated(
|
||||
TurnDiffUpdatedNotification {
|
||||
thread_id: "thread-2".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
diff,
|
||||
},
|
||||
))),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
assert!(events.is_empty());
|
||||
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
|
||||
"thread-2",
|
||||
"turn-2",
|
||||
AppServerTurnStatus::Completed,
|
||||
/*codex_error_info*/ None,
|
||||
))),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
|
||||
let accepted_line_events = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
TrackEventRequest::AcceptedLineFingerprints(event) => Some(event),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(accepted_line_events.len() > 1);
|
||||
let mut total_fingerprints = 0;
|
||||
for (index, event) in accepted_line_events.iter().enumerate() {
|
||||
assert_eq!(event.event_params.turn_id, "turn-2");
|
||||
assert_eq!(event.event_params.thread_id, "thread-2");
|
||||
total_fingerprints += event.event_params.line_fingerprints.len();
|
||||
if index == 0 {
|
||||
assert_eq!(event.event_params.accepted_added_lines, 20_000);
|
||||
assert_eq!(event.event_params.accepted_deleted_lines, 0);
|
||||
} else {
|
||||
assert_eq!(event.event_params.accepted_added_lines, 0);
|
||||
assert_eq!(event.event_params.accepted_deleted_lines, 0);
|
||||
}
|
||||
assert!(serde_json::to_vec(event).expect("serialize chunk").len() < 2_100_000);
|
||||
}
|
||||
assert_eq!(total_fingerprints, 20_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reducer_emits_accepted_line_fingerprints_once_from_latest_turn_diff_on_completion() {
|
||||
let mut reducer = AnalyticsReducer::default();
|
||||
let mut events = Vec::new();
|
||||
|
||||
ingest_turn_prerequisites(
|
||||
&mut reducer,
|
||||
&mut events,
|
||||
/*include_initialize*/ true,
|
||||
/*include_resolved_config*/ true,
|
||||
/*include_started*/ true,
|
||||
/*include_token_usage*/ true,
|
||||
)
|
||||
.await;
|
||||
events.clear();
|
||||
|
||||
for line in ["let old_value = 1;", "let latest_value = 2;"] {
|
||||
let diff = format!(
|
||||
"\
|
||||
diff --git a/src/lib.rs b/src/lib.rs
|
||||
index 1111111..2222222
|
||||
--- a/src/lib.rs
|
||||
+++ b/src/lib.rs
|
||||
@@ -0,0 +1 @@
|
||||
+{line}
|
||||
"
|
||||
);
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Notification(Box::new(ServerNotification::TurnDiffUpdated(
|
||||
TurnDiffUpdatedNotification {
|
||||
thread_id: "thread-2".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
diff,
|
||||
},
|
||||
))),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert!(events.is_empty());
|
||||
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
|
||||
"thread-2",
|
||||
"turn-2",
|
||||
AppServerTurnStatus::Completed,
|
||||
/*codex_error_info*/ None,
|
||||
))),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
|
||||
let accepted_line_events = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
TrackEventRequest::AcceptedLineFingerprints(event) => Some(event),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(accepted_line_events.len(), 1);
|
||||
let event = accepted_line_events[0];
|
||||
assert_eq!(event.event_params.accepted_added_lines, 1);
|
||||
assert_eq!(event.event_params.line_fingerprints.len(), 1);
|
||||
assert_eq!(
|
||||
event.event_params.line_fingerprints[0].line_hash,
|
||||
crate::fingerprint_hash("line", "let latest_value = 2;")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_event_serializes_expected_shape() {
|
||||
let event = TrackEventRequest::Compaction(Box::new(CodexCompactionEventRequest {
|
||||
@@ -1122,6 +1327,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
},
|
||||
@@ -1269,6 +1475,7 @@ async fn compaction_event_ingests_custom_fact() {
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
},
|
||||
@@ -1382,6 +1589,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ServerResponse;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::create_client;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use std::collections::HashSet;
|
||||
@@ -352,6 +353,7 @@ impl AnalyticsEventsClient {
|
||||
notification,
|
||||
ServerNotification::TurnStarted(_)
|
||||
| ServerNotification::TurnCompleted(_)
|
||||
| ServerNotification::TurnDiffUpdated(_)
|
||||
| ServerNotification::ItemStarted(_)
|
||||
| ServerNotification::ItemCompleted(_)
|
||||
| ServerNotification::ItemGuardianApprovalReviewStarted(_)
|
||||
@@ -371,6 +373,7 @@ async fn send_track_events(
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(auth) = auth_manager.auth().await else {
|
||||
return;
|
||||
};
|
||||
@@ -380,12 +383,45 @@ async fn send_track_events(
|
||||
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/codex/analytics-events/events");
|
||||
for events in track_event_request_batches(events) {
|
||||
send_track_events_request(&auth, &url, events).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn track_event_request_batches(events: Vec<TrackEventRequest>) -> Vec<Vec<TrackEventRequest>> {
|
||||
let mut batches = Vec::new();
|
||||
let mut current_batch = Vec::new();
|
||||
|
||||
for event in events {
|
||||
if event.should_send_in_isolated_request() {
|
||||
if !current_batch.is_empty() {
|
||||
batches.push(current_batch);
|
||||
current_batch = Vec::new();
|
||||
}
|
||||
batches.push(vec![event]);
|
||||
} else {
|
||||
current_batch.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
if !current_batch.is_empty() {
|
||||
batches.push(current_batch);
|
||||
}
|
||||
|
||||
batches
|
||||
}
|
||||
|
||||
async fn send_track_events_request(auth: &CodexAuth, url: &str, events: Vec<TrackEventRequest>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = TrackEventsRequest { events };
|
||||
|
||||
let response = create_client()
|
||||
.post(&url)
|
||||
.post(url)
|
||||
.timeout(ANALYTICS_EVENTS_TIMEOUT)
|
||||
.headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers())
|
||||
.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers())
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
use super::AnalyticsEventsClient;
|
||||
use super::AnalyticsEventsQueue;
|
||||
use super::track_event_request_batches;
|
||||
use crate::events::CodexAcceptedLineFingerprintsEventParams;
|
||||
use crate::events::CodexAcceptedLineFingerprintsEventRequest;
|
||||
use crate::events::SkillInvocationEventParams;
|
||||
use crate::events::SkillInvocationEventRequest;
|
||||
use crate::events::TrackEventRequest;
|
||||
use crate::facts::AcceptedLineFingerprint;
|
||||
use crate::facts::AnalyticsFact;
|
||||
use crate::facts::InvocationType;
|
||||
use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer;
|
||||
use codex_app_server_protocol::AskForApproval as AppServerAskForApproval;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
@@ -31,6 +39,47 @@ use std::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
|
||||
fn sample_accepted_line_fingerprint_event(thread_id: &str) -> TrackEventRequest {
|
||||
TrackEventRequest::AcceptedLineFingerprints(Box::new(
|
||||
CodexAcceptedLineFingerprintsEventRequest {
|
||||
event_type: "codex_accepted_line_fingerprints",
|
||||
event_params: CodexAcceptedLineFingerprintsEventParams {
|
||||
event_type: "codex.accepted_line_fingerprints",
|
||||
turn_id: "turn-1".to_string(),
|
||||
thread_id: thread_id.to_string(),
|
||||
product_surface: Some("codex".to_string()),
|
||||
model_slug: Some("gpt-5.1-codex".to_string()),
|
||||
completed_at: 1,
|
||||
repo_hash: None,
|
||||
accepted_added_lines: 1,
|
||||
accepted_deleted_lines: 0,
|
||||
line_fingerprints: vec![AcceptedLineFingerprint {
|
||||
path_hash: "path-hash".to_string(),
|
||||
line_hash: "line-hash".to_string(),
|
||||
}],
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn sample_regular_track_event(thread_id: &str) -> TrackEventRequest {
|
||||
TrackEventRequest::SkillInvocation(SkillInvocationEventRequest {
|
||||
event_type: "skill_invocation",
|
||||
skill_id: format!("skill-{thread_id}"),
|
||||
skill_name: "doc".to_string(),
|
||||
event_params: SkillInvocationEventParams {
|
||||
product_client_id: None,
|
||||
skill_scope: None,
|
||||
plugin_id: None,
|
||||
repo_url: None,
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
invoke_type: Some(InvocationType::Explicit),
|
||||
model_slug: Some("gpt-5.1-codex".to_string()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn client_with_receiver() -> (AnalyticsEventsClient, mpsc::Receiver<AnalyticsFact>) {
|
||||
let (sender, receiver) = mpsc::channel(8);
|
||||
let queue = AnalyticsEventsQueue {
|
||||
@@ -222,3 +271,23 @@ fn track_response_only_enqueues_analytics_relevant_responses() {
|
||||
);
|
||||
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_event_request_batches_only_isolates_accepted_line_fingerprint_events() {
|
||||
let batches = track_event_request_batches(vec![
|
||||
sample_regular_track_event("thread-1"),
|
||||
sample_regular_track_event("thread-2"),
|
||||
sample_accepted_line_fingerprint_event("thread-3"),
|
||||
sample_accepted_line_fingerprint_event("thread-4"),
|
||||
sample_regular_track_event("thread-5"),
|
||||
sample_regular_track_event("thread-6"),
|
||||
]);
|
||||
|
||||
assert_eq!(batches.len(), 4);
|
||||
assert_eq!(batches[0].len(), 2);
|
||||
assert_eq!(batches[1].len(), 1);
|
||||
assert_eq!(batches[2].len(), 1);
|
||||
assert_eq!(batches[3].len(), 2);
|
||||
assert!(batches[1][0].should_send_in_isolated_request());
|
||||
assert!(batches[2][0].should_send_in_isolated_request());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::facts::AcceptedLineFingerprint;
|
||||
use crate::facts::AppInvocation;
|
||||
use crate::facts::CodexCompactionEvent;
|
||||
use crate::facts::CompactionImplementation;
|
||||
@@ -71,6 +72,7 @@ pub(crate) enum TrackEventRequest {
|
||||
CollabAgentToolCall(CodexCollabAgentToolCallEventRequest),
|
||||
WebSearch(CodexWebSearchEventRequest),
|
||||
ImageGeneration(CodexImageGenerationEventRequest),
|
||||
AcceptedLineFingerprints(Box<CodexAcceptedLineFingerprintsEventRequest>),
|
||||
#[allow(dead_code)]
|
||||
ReviewEvent(CodexReviewEventRequest),
|
||||
PluginUsed(CodexPluginUsedEventRequest),
|
||||
@@ -80,6 +82,32 @@ pub(crate) enum TrackEventRequest {
|
||||
PluginDisabled(CodexPluginEventRequest),
|
||||
}
|
||||
|
||||
impl TrackEventRequest {
|
||||
pub(crate) fn should_send_in_isolated_request(&self) -> bool {
|
||||
matches!(self, Self::AcceptedLineFingerprints(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexAcceptedLineFingerprintsEventParams {
|
||||
pub(crate) event_type: &'static str,
|
||||
pub(crate) turn_id: String,
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) product_surface: Option<String>,
|
||||
pub(crate) model_slug: Option<String>,
|
||||
pub(crate) completed_at: u64,
|
||||
pub(crate) repo_hash: Option<String>,
|
||||
pub(crate) accepted_added_lines: u64,
|
||||
pub(crate) accepted_deleted_lines: u64,
|
||||
pub(crate) line_fingerprints: Vec<AcceptedLineFingerprint>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexAcceptedLineFingerprintsEventRequest {
|
||||
pub(crate) event_type: &'static str,
|
||||
pub(crate) event_params: CodexAcceptedLineFingerprintsEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct SkillInvocationEventRequest {
|
||||
pub(crate) event_type: &'static str,
|
||||
|
||||
@@ -28,6 +28,12 @@ use codex_protocol::protocol::TokenUsage;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AcceptedLineFingerprint {
|
||||
pub path_hash: String,
|
||||
pub line_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TrackEventsContext {
|
||||
pub model_slug: String,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod accepted_lines;
|
||||
mod client;
|
||||
mod events;
|
||||
mod facts;
|
||||
@@ -6,6 +7,8 @@ mod reducer;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
pub use accepted_lines::accepted_line_fingerprints_from_unified_diff;
|
||||
pub use accepted_lines::fingerprint_hash;
|
||||
pub use client::AnalyticsEventsClient;
|
||||
pub use events::AppServerRpcTransport;
|
||||
pub use events::GuardianApprovalRequestSource;
|
||||
@@ -17,6 +20,7 @@ pub use events::GuardianReviewSessionKind;
|
||||
pub use events::GuardianReviewTerminalStatus;
|
||||
pub use events::GuardianReviewTrackContext;
|
||||
pub use events::GuardianReviewedAction;
|
||||
pub use facts::AcceptedLineFingerprint;
|
||||
pub use facts::AnalyticsJsonRpcError;
|
||||
pub use facts::AppInvocation;
|
||||
pub use facts::CodexCompactionEvent;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
use crate::accepted_lines::AcceptedLineFingerprintEventInput;
|
||||
use crate::accepted_lines::accepted_line_fingerprint_event_requests;
|
||||
use crate::accepted_lines::accepted_line_fingerprints_from_unified_diff;
|
||||
use crate::accepted_lines::accepted_line_repo_hash_for_cwd;
|
||||
use crate::events::AppServerRpcTransport;
|
||||
use crate::events::CodexAppMentionedEventRequest;
|
||||
use crate::events::CodexAppServerClientMetadata;
|
||||
@@ -104,6 +108,7 @@ use codex_protocol::protocol::TokenUsage;
|
||||
use sha1::Digest;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct AnalyticsReducer {
|
||||
@@ -264,6 +269,7 @@ struct TurnState {
|
||||
started_at: Option<u64>,
|
||||
token_usage: Option<TokenUsage>,
|
||||
completed: Option<CompletedTurnState>,
|
||||
latest_diff: Option<String>,
|
||||
steer_count: usize,
|
||||
}
|
||||
|
||||
@@ -305,7 +311,7 @@ impl AnalyticsReducer {
|
||||
response,
|
||||
} => {
|
||||
if let Some(response) = response.into_client_response(request_id) {
|
||||
self.ingest_response(connection_id, response, out);
|
||||
self.ingest_response(connection_id, response, out).await;
|
||||
}
|
||||
}
|
||||
AnalyticsFact::ErrorResponse {
|
||||
@@ -317,7 +323,7 @@ impl AnalyticsReducer {
|
||||
self.ingest_error_response(connection_id, request_id, error_type, out);
|
||||
}
|
||||
AnalyticsFact::Notification(notification) => {
|
||||
self.ingest_notification(*notification, out);
|
||||
self.ingest_notification(*notification, out).await;
|
||||
}
|
||||
AnalyticsFact::ServerRequest {
|
||||
connection_id: _connection_id,
|
||||
@@ -338,10 +344,10 @@ impl AnalyticsReducer {
|
||||
self.ingest_guardian_review(*input, out);
|
||||
}
|
||||
CustomAnalyticsFact::TurnResolvedConfig(input) => {
|
||||
self.ingest_turn_resolved_config(*input, out);
|
||||
self.ingest_turn_resolved_config(*input, out).await;
|
||||
}
|
||||
CustomAnalyticsFact::TurnTokenUsage(input) => {
|
||||
self.ingest_turn_token_usage(*input, out);
|
||||
self.ingest_turn_token_usage(*input, out).await;
|
||||
}
|
||||
CustomAnalyticsFact::SkillInvoked(input) => {
|
||||
self.ingest_skill_invoked(input, out).await;
|
||||
@@ -473,7 +479,7 @@ impl AnalyticsReducer {
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_turn_resolved_config(
|
||||
async fn ingest_turn_resolved_config(
|
||||
&mut self,
|
||||
input: TurnResolvedConfigFact,
|
||||
out: &mut Vec<TrackEventRequest>,
|
||||
@@ -489,15 +495,16 @@ impl AnalyticsReducer {
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
});
|
||||
turn_state.thread_id = Some(thread_id);
|
||||
turn_state.num_input_images = Some(num_input_images);
|
||||
turn_state.resolved_config = Some(input);
|
||||
self.maybe_emit_turn_event(&turn_id, out);
|
||||
self.maybe_emit_turn_event(&turn_id, out).await;
|
||||
}
|
||||
|
||||
fn ingest_turn_token_usage(
|
||||
async fn ingest_turn_token_usage(
|
||||
&mut self,
|
||||
input: TurnTokenUsageFact,
|
||||
out: &mut Vec<TrackEventRequest>,
|
||||
@@ -511,11 +518,12 @@ impl AnalyticsReducer {
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
});
|
||||
turn_state.thread_id = Some(input.thread_id);
|
||||
turn_state.token_usage = Some(input.token_usage);
|
||||
self.maybe_emit_turn_event(&turn_id, out);
|
||||
self.maybe_emit_turn_event(&turn_id, out).await;
|
||||
}
|
||||
|
||||
async fn ingest_skill_invoked(
|
||||
@@ -622,7 +630,7 @@ impl AnalyticsReducer {
|
||||
});
|
||||
}
|
||||
|
||||
fn ingest_response(
|
||||
async fn ingest_response(
|
||||
&mut self,
|
||||
connection_id: u64,
|
||||
response: ClientResponse,
|
||||
@@ -674,12 +682,13 @@ impl AnalyticsReducer {
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
});
|
||||
turn_state.connection_id = Some(connection_id);
|
||||
turn_state.thread_id = Some(pending_request.thread_id);
|
||||
turn_state.num_input_images = Some(pending_request.num_input_images);
|
||||
self.maybe_emit_turn_event(&turn_id, out);
|
||||
self.maybe_emit_turn_event(&turn_id, out).await;
|
||||
}
|
||||
ClientResponse::TurnSteer {
|
||||
request_id,
|
||||
@@ -741,7 +750,7 @@ impl AnalyticsReducer {
|
||||
);
|
||||
}
|
||||
|
||||
fn ingest_notification(
|
||||
async fn ingest_notification(
|
||||
&mut self,
|
||||
notification: ServerNotification,
|
||||
out: &mut Vec<TrackEventRequest>,
|
||||
@@ -812,6 +821,7 @@ impl AnalyticsReducer {
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
});
|
||||
turn_state.started_at = notification
|
||||
@@ -819,6 +829,24 @@ impl AnalyticsReducer {
|
||||
.started_at
|
||||
.and_then(|started_at| u64::try_from(started_at).ok());
|
||||
}
|
||||
ServerNotification::TurnDiffUpdated(notification) => {
|
||||
let turn_state =
|
||||
self.turns
|
||||
.entry(notification.turn_id.clone())
|
||||
.or_insert(TurnState {
|
||||
connection_id: None,
|
||||
thread_id: None,
|
||||
num_input_images: None,
|
||||
resolved_config: None,
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
});
|
||||
turn_state.thread_id = Some(notification.thread_id);
|
||||
turn_state.latest_diff = Some(notification.diff);
|
||||
}
|
||||
ServerNotification::TurnCompleted(notification) => {
|
||||
let turn_state =
|
||||
self.turns
|
||||
@@ -831,6 +859,7 @@ impl AnalyticsReducer {
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
});
|
||||
turn_state.completed = Some(CompletedTurnState {
|
||||
@@ -850,7 +879,7 @@ impl AnalyticsReducer {
|
||||
.and_then(|duration_ms| u64::try_from(duration_ms).ok()),
|
||||
});
|
||||
let turn_id = notification.turn.id;
|
||||
self.maybe_emit_turn_event(&turn_id, out);
|
||||
self.maybe_emit_turn_event(&turn_id, out).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -986,7 +1015,7 @@ impl AnalyticsReducer {
|
||||
}));
|
||||
}
|
||||
|
||||
fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec<TrackEventRequest>) {
|
||||
async fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec<TrackEventRequest>) {
|
||||
let Some(turn_state) = self.turns.get(turn_id) else {
|
||||
return;
|
||||
};
|
||||
@@ -1019,18 +1048,23 @@ impl AnalyticsReducer {
|
||||
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata);
|
||||
return;
|
||||
};
|
||||
out.push(TrackEventRequest::TurnEvent(Box::new(
|
||||
CodexTurnEventRequest {
|
||||
event_type: "codex_turn_event",
|
||||
event_params: codex_turn_event_params(
|
||||
connection_state.app_server_client.clone(),
|
||||
connection_state.runtime.clone(),
|
||||
turn_id.to_string(),
|
||||
turn_state,
|
||||
thread_metadata,
|
||||
),
|
||||
},
|
||||
)));
|
||||
let turn_event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest {
|
||||
event_type: "codex_turn_event",
|
||||
event_params: codex_turn_event_params(
|
||||
connection_state.app_server_client.clone(),
|
||||
connection_state.runtime.clone(),
|
||||
turn_id.to_string(),
|
||||
turn_state,
|
||||
thread_metadata,
|
||||
),
|
||||
}));
|
||||
let accepted_line_event = accepted_line_event_input(turn_id, turn_state);
|
||||
|
||||
out.push(turn_event);
|
||||
if let Some((mut input, cwd)) = accepted_line_event {
|
||||
input.repo_hash = accepted_line_repo_hash_for_cwd(cwd.as_path()).await;
|
||||
out.extend(accepted_line_fingerprint_event_requests(input));
|
||||
}
|
||||
self.turns.remove(turn_id);
|
||||
}
|
||||
|
||||
@@ -1642,6 +1676,36 @@ fn web_search_query_count(query: &str, action: Option<&WebSearchAction>) -> Opti
|
||||
}
|
||||
}
|
||||
|
||||
fn accepted_line_event_input(
|
||||
turn_id: &str,
|
||||
turn_state: &TurnState,
|
||||
) -> Option<(AcceptedLineFingerprintEventInput, PathBuf)> {
|
||||
let latest_diff = turn_state.latest_diff.as_deref()?;
|
||||
let summary = accepted_line_fingerprints_from_unified_diff(latest_diff);
|
||||
if summary.accepted_added_lines == 0 && summary.accepted_deleted_lines == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let thread_id = turn_state.thread_id.clone()?;
|
||||
let resolved_config = turn_state.resolved_config.clone()?;
|
||||
|
||||
Some((
|
||||
AcceptedLineFingerprintEventInput {
|
||||
event_type: "codex.accepted_line_fingerprints",
|
||||
turn_id: turn_id.to_string(),
|
||||
thread_id,
|
||||
product_surface: Some("codex".to_string()),
|
||||
model_slug: Some(resolved_config.model.clone()),
|
||||
completed_at: now_unix_seconds(),
|
||||
repo_hash: None,
|
||||
accepted_added_lines: summary.accepted_added_lines,
|
||||
accepted_deleted_lines: summary.accepted_deleted_lines,
|
||||
line_fingerprints: summary.line_fingerprints,
|
||||
},
|
||||
resolved_config.permission_profile_cwd,
|
||||
))
|
||||
}
|
||||
|
||||
fn codex_turn_event_params(
|
||||
app_server_client: CodexAppServerClientMetadata,
|
||||
runtime: CodexRuntimeMetadata,
|
||||
|
||||
@@ -374,6 +374,7 @@ impl InProcessClientStartArgs {
|
||||
pub fn initialize_params(&self) -> InitializeParams {
|
||||
let capabilities = InitializeCapabilities {
|
||||
experimental_api: self.experimental_api,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -73,6 +73,7 @@ impl RemoteAppServerConnectArgs {
|
||||
fn initialize_params(&self) -> InitializeParams {
|
||||
let capabilities = InitializeCapabilities {
|
||||
experimental_api: self.experimental_api,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "app-server-daemon",
|
||||
crate_name = "codex_app_server_daemon",
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "codex-app-server-daemon"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_app_server_daemon"
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-app-server-transport = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-uds = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["rustls-tls"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"fs",
|
||||
"io-util",
|
||||
"macros",
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"time",
|
||||
] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
@@ -0,0 +1,104 @@
|
||||
# codex-app-server-daemon
|
||||
|
||||
> `codex-app-server-daemon` is experimental and its lifecycle contract may
|
||||
> change while the remote-management flow is still being developed.
|
||||
|
||||
`codex-app-server-daemon` backs the machine-readable `codex app-server`
|
||||
lifecycle commands used by remote clients such as the desktop and mobile apps.
|
||||
It is intended for Codex instances launched over SSH, including fresh developer
|
||||
machines that should expose app-server with `remote_control` enabled.
|
||||
|
||||
## Platform support
|
||||
|
||||
The current daemon implementation is Unix-only. It uses pidfile-backed
|
||||
daemonization plus Unix process and file-locking primitives, and does not yet
|
||||
support Windows lifecycle management.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
codex app-server daemon start
|
||||
codex app-server daemon restart
|
||||
codex app-server daemon enable-remote-control
|
||||
codex app-server daemon disable-remote-control
|
||||
codex app-server daemon stop
|
||||
codex app-server daemon version
|
||||
codex app-server daemon bootstrap --remote-control
|
||||
```
|
||||
|
||||
On success, every command writes exactly one JSON object to stdout. Consumers
|
||||
should parse that JSON rather than relying on human-readable text. Lifecycle
|
||||
responses report the resolved backend, socket path, local CLI version, and
|
||||
running app-server version when applicable.
|
||||
|
||||
## Bootstrap flow
|
||||
|
||||
For a new remote machine:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://chatgpt.com/codex/install.sh | sh
|
||||
$HOME/.codex/packages/standalone/current/codex app-server daemon bootstrap --remote-control
|
||||
```
|
||||
|
||||
`bootstrap` requires the standalone managed install. It records the daemon
|
||||
settings under `CODEX_HOME/app-server-daemon/`, starts app-server as a
|
||||
pidfile-backed detached process, and launches a detached updater loop.
|
||||
|
||||
## Installation and update cases
|
||||
|
||||
The daemon assumes Codex is installed through `install.sh` and always launches
|
||||
the standalone managed binary under `CODEX_HOME`.
|
||||
|
||||
| Situation | What starts | Does this daemon fetch new binaries? | Does a running app-server eventually move to a newer binary on its own? |
|
||||
| --- | --- | --- | --- |
|
||||
| `install.sh` has run, but only `start` is used | `start` uses `CODEX_HOME/packages/standalone/current/codex` | No | No. The managed path is used when starting or restarting, but no updater is installed. |
|
||||
| `install.sh` has run, then `bootstrap` is used | The pidfile backend uses `CODEX_HOME/packages/standalone/current/codex` | Yes. Bootstrap launches a detached updater loop that runs `install.sh` hourly. | Yes, while that updater process is alive. After a successful fetch, it restarts a currently running app-server only when the managed binary reports a different version. |
|
||||
| Some other tool updates the managed binary path | The next fresh start or restart uses the updated file at that path | No | Not automatically. The existing process keeps the old executable image until an explicit `restart`. |
|
||||
|
||||
### Standalone installs
|
||||
|
||||
For installs created by `install.sh`:
|
||||
|
||||
- lifecycle commands always use the standalone managed binary path
|
||||
- `bootstrap` is supported
|
||||
- `bootstrap` starts a detached pid-backed updater loop that fetches via
|
||||
`install.sh`, then restarts app-server if it is running on a different version
|
||||
- the updater loop is not reboot-persistent; it must be started again by
|
||||
rerunning `bootstrap` after a reboot
|
||||
|
||||
### Out-of-band updates
|
||||
|
||||
This daemon does not watch arbitrary executable files for replacement. If some
|
||||
other tool updates a binary that the daemon would use on its next launch:
|
||||
|
||||
- a currently running app-server remains on the old executable image
|
||||
- `restart` will launch the updated binary
|
||||
- for bootstrapped daemons, the detached updater loop only reacts to updates it
|
||||
fetched itself; it does not watch arbitrary file replacement
|
||||
|
||||
## Lifecycle semantics
|
||||
|
||||
`start` is idempotent and returns after app-server is ready to answer the normal
|
||||
JSON-RPC initialize handshake on the Unix control socket.
|
||||
|
||||
`restart` stops any managed daemon and starts it again.
|
||||
|
||||
`enable-remote-control` and `disable-remote-control` persist the launch setting
|
||||
for future starts. If a managed app-server is already running, they restart it
|
||||
so the new setting takes effect immediately.
|
||||
|
||||
`stop` sends a graceful termination request first, then sends a second
|
||||
termination signal after the grace window if the process is still alive.
|
||||
|
||||
All mutating lifecycle commands are serialized per `CODEX_HOME`, so a concurrent
|
||||
`start`, `restart`, `enable-remote-control`, `disable-remote-control`, `stop`,
|
||||
or `bootstrap` does not race another in-flight lifecycle operation.
|
||||
|
||||
## State
|
||||
|
||||
The daemon stores its local state under `CODEX_HOME/app-server-daemon/`:
|
||||
|
||||
- `settings.json` for persisted launch settings
|
||||
- `app-server.pid` for the app-server process record
|
||||
- `app-server-updater.pid` for the pid-backed standalone updater loop
|
||||
- `daemon.lock` for daemon-wide lifecycle serialization
|
||||
@@ -0,0 +1,33 @@
|
||||
mod pid;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
pub(crate) use pid::PidBackend;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BackendKind {
|
||||
Pid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct BackendPaths {
|
||||
pub(crate) codex_bin: PathBuf,
|
||||
pub(crate) pid_file: PathBuf,
|
||||
pub(crate) update_pid_file: PathBuf,
|
||||
pub(crate) remote_control_enabled: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn pid_backend(paths: BackendPaths) -> PidBackend {
|
||||
PidBackend::new(
|
||||
paths.codex_bin,
|
||||
paths.pid_file,
|
||||
paths.remote_control_enabled,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn pid_update_loop_backend(paths: BackendPaths) -> PidBackend {
|
||||
PidBackend::new_update_loop(paths.codex_bin, paths.update_pid_file)
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(unix)]
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::fs;
|
||||
#[cfg(unix)]
|
||||
use tokio::process::Command;
|
||||
use tokio::time::sleep;
|
||||
|
||||
const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
const STOP_GRACE_PERIOD: Duration = Duration::from_secs(60);
|
||||
const STOP_TIMEOUT: Duration = Duration::from_secs(70);
|
||||
const START_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug)]
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) struct PidBackend {
|
||||
codex_bin: PathBuf,
|
||||
pid_file: PathBuf,
|
||||
lock_file: PathBuf,
|
||||
command_kind: PidCommandKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PidRecord {
|
||||
pid: u32,
|
||||
process_start_time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum PidFileState {
|
||||
Missing,
|
||||
Starting,
|
||||
Running(PidRecord),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
enum PidCommandKind {
|
||||
AppServer { remote_control_enabled: bool },
|
||||
UpdateLoop,
|
||||
}
|
||||
|
||||
impl PidBackend {
|
||||
pub(crate) fn new(codex_bin: PathBuf, pid_file: PathBuf, remote_control_enabled: bool) -> Self {
|
||||
let lock_file = pid_file.with_extension("pid.lock");
|
||||
Self {
|
||||
codex_bin,
|
||||
pid_file,
|
||||
lock_file,
|
||||
command_kind: PidCommandKind::AppServer {
|
||||
remote_control_enabled,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_update_loop(codex_bin: PathBuf, pid_file: PathBuf) -> Self {
|
||||
let lock_file = pid_file.with_extension("pid.lock");
|
||||
Self {
|
||||
codex_bin,
|
||||
pid_file,
|
||||
lock_file,
|
||||
command_kind: PidCommandKind::UpdateLoop,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn is_starting_or_running(&self) -> Result<bool> {
|
||||
loop {
|
||||
match self.read_pid_file_state().await? {
|
||||
PidFileState::Missing => return Ok(false),
|
||||
PidFileState::Starting => return Ok(true),
|
||||
PidFileState::Running(record) => {
|
||||
if self.record_is_active(&record).await? {
|
||||
return Ok(true);
|
||||
}
|
||||
match self.refresh_after_stale_record(&record).await? {
|
||||
PidFileState::Missing => return Ok(false),
|
||||
PidFileState::Starting | PidFileState::Running(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn start(&self) -> Result<Option<u32>> {
|
||||
if let Some(parent) = self.pid_file.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create pid directory {}", parent.display()))?;
|
||||
}
|
||||
let reservation_lock = self.acquire_reservation_lock().await?;
|
||||
let _pid_file = loop {
|
||||
match fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.pid_file)
|
||||
.await
|
||||
{
|
||||
Ok(pid_file) => break pid_file,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
match self.read_pid_file_state_with_lock_held().await? {
|
||||
PidFileState::Missing => continue,
|
||||
PidFileState::Running(record) => {
|
||||
if self.record_is_active(&record).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
continue;
|
||||
}
|
||||
PidFileState::Starting => {
|
||||
unreachable!("lock holder cannot observe starting")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to reserve pid file {}", self.pid_file.display())
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut command = Command::new(&self.codex_bin);
|
||||
command
|
||||
.args(self.command_args())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
if libc::setsid() == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
return Err(err).with_context(|| {
|
||||
format!(
|
||||
"failed to spawn detached app-server process using {}",
|
||||
self.codex_bin.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
};
|
||||
let pid = child
|
||||
.id()
|
||||
.context("spawned app-server process has no pid")?;
|
||||
let record = match read_process_start_time(pid).await {
|
||||
Ok(process_start_time) => PidRecord {
|
||||
pid,
|
||||
process_start_time,
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = self.terminate_process(pid);
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let contents = serde_json::to_vec(&record).context("failed to serialize pid record")?;
|
||||
let temp_pid_file = self.pid_file.with_extension("pid.tmp");
|
||||
if let Err(err) = fs::write(&temp_pid_file, &contents).await {
|
||||
let _ = self.terminate_process(pid);
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to write pid temp file {}", temp_pid_file.display())
|
||||
});
|
||||
}
|
||||
if let Err(err) = fs::rename(&temp_pid_file, &self.pid_file).await {
|
||||
let _ = self.terminate_process(pid);
|
||||
let _ = fs::remove_file(&temp_pid_file).await;
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to publish pid file {}", self.pid_file.display())
|
||||
});
|
||||
}
|
||||
drop(reservation_lock);
|
||||
Ok(Some(pid))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) async fn start(&self) -> Result<Option<u32>> {
|
||||
bail!("pid-managed app-server startup is unsupported on this platform")
|
||||
}
|
||||
|
||||
pub(crate) async fn stop(&self) -> Result<()> {
|
||||
loop {
|
||||
let Some(record) = self.wait_for_pid_start().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.record_is_active(&record).await? {
|
||||
match self.refresh_after_stale_record(&record).await? {
|
||||
PidFileState::Missing => return Ok(()),
|
||||
PidFileState::Starting | PidFileState::Running(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let pid = record.pid;
|
||||
self.terminate_process(pid)?;
|
||||
let started_at = tokio::time::Instant::now();
|
||||
let deadline = tokio::time::Instant::now() + STOP_TIMEOUT;
|
||||
let mut forced = false;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if !self.record_is_active(&record).await? {
|
||||
match self.refresh_after_stale_record(&record).await? {
|
||||
PidFileState::Missing => return Ok(()),
|
||||
PidFileState::Starting | PidFileState::Running(_) => break,
|
||||
}
|
||||
}
|
||||
if !forced && started_at.elapsed() >= STOP_GRACE_PERIOD {
|
||||
self.force_terminate_process(pid)?;
|
||||
forced = true;
|
||||
}
|
||||
sleep(STOP_POLL_INTERVAL).await;
|
||||
}
|
||||
|
||||
if self.record_is_active(&record).await? {
|
||||
bail!("timed out waiting for pid-managed app server {pid} to stop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_pid_start(&self) -> Result<Option<PidRecord>> {
|
||||
let deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
||||
loop {
|
||||
match self.read_pid_file_state().await? {
|
||||
PidFileState::Missing => return Ok(None),
|
||||
PidFileState::Running(record) => return Ok(Some(record)),
|
||||
PidFileState::Starting if tokio::time::Instant::now() < deadline => {
|
||||
sleep(STOP_POLL_INTERVAL).await;
|
||||
}
|
||||
PidFileState::Starting => {
|
||||
bail!(
|
||||
"timed out waiting for pid reservation in {} to finish initializing",
|
||||
self.pid_file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_pid_file_state(&self) -> Result<PidFileState> {
|
||||
let contents = match fs::read_to_string(&self.pid_file).await {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return if reservation_lock_is_active(&self.lock_file).await? {
|
||||
Ok(PidFileState::Starting)
|
||||
} else {
|
||||
Ok(PidFileState::Missing)
|
||||
};
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to read pid file {}", self.pid_file.display())
|
||||
});
|
||||
}
|
||||
};
|
||||
if contents.trim().is_empty() {
|
||||
match inspect_empty_pid_reservation(&self.pid_file, &self.lock_file).await? {
|
||||
EmptyPidReservation::Active => {
|
||||
return Ok(PidFileState::Starting);
|
||||
}
|
||||
EmptyPidReservation::Stale => {
|
||||
return Ok(PidFileState::Missing);
|
||||
}
|
||||
EmptyPidReservation::Record(record) => return Ok(PidFileState::Running(record)),
|
||||
}
|
||||
}
|
||||
let record = serde_json::from_str(&contents)
|
||||
.with_context(|| format!("invalid pid file contents in {}", self.pid_file.display()))?;
|
||||
Ok(PidFileState::Running(record))
|
||||
}
|
||||
|
||||
async fn read_pid_file_state_with_lock_held(&self) -> Result<PidFileState> {
|
||||
let contents = match fs::read_to_string(&self.pid_file).await {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(PidFileState::Missing);
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to read pid file {}", self.pid_file.display())
|
||||
});
|
||||
}
|
||||
};
|
||||
if contents.trim().is_empty() {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
return Ok(PidFileState::Missing);
|
||||
}
|
||||
let record = serde_json::from_str(&contents)
|
||||
.with_context(|| format!("invalid pid file contents in {}", self.pid_file.display()))?;
|
||||
Ok(PidFileState::Running(record))
|
||||
}
|
||||
|
||||
async fn refresh_after_stale_record(&self, expected: &PidRecord) -> Result<PidFileState> {
|
||||
let reservation_lock = self.acquire_reservation_lock().await?;
|
||||
let state = match self.read_pid_file_state_with_lock_held().await? {
|
||||
PidFileState::Running(record) if record == *expected => {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
PidFileState::Missing
|
||||
}
|
||||
state => state,
|
||||
};
|
||||
drop(reservation_lock);
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
async fn acquire_reservation_lock(&self) -> Result<fs::File> {
|
||||
let reservation_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&self.lock_file)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("failed to open pid lock file {}", self.lock_file.display())
|
||||
})?;
|
||||
let lock_deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
||||
while !try_lock_file(&reservation_lock)? {
|
||||
if tokio::time::Instant::now() >= lock_deadline {
|
||||
bail!(
|
||||
"timed out waiting for pid lock {}",
|
||||
self.lock_file.display()
|
||||
);
|
||||
}
|
||||
sleep(STOP_POLL_INTERVAL).await;
|
||||
}
|
||||
Ok(reservation_lock)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn command_args(&self) -> Vec<&'static str> {
|
||||
match self.command_kind {
|
||||
PidCommandKind::AppServer {
|
||||
remote_control_enabled: true,
|
||||
} => vec![
|
||||
"--enable",
|
||||
"remote_control",
|
||||
"app-server",
|
||||
"--listen",
|
||||
"unix://",
|
||||
],
|
||||
PidCommandKind::AppServer {
|
||||
remote_control_enabled: false,
|
||||
} => vec!["app-server", "--listen", "unix://"],
|
||||
PidCommandKind::UpdateLoop => vec!["app-server", "daemon", "pid-update-loop"],
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate_process(&self, pid: u32) -> Result<()> {
|
||||
match self.command_kind {
|
||||
PidCommandKind::AppServer { .. } => terminate_process(pid),
|
||||
PidCommandKind::UpdateLoop => terminate_process(pid),
|
||||
}
|
||||
}
|
||||
|
||||
fn force_terminate_process(&self, pid: u32) -> Result<()> {
|
||||
match self.command_kind {
|
||||
PidCommandKind::AppServer { .. } => force_terminate_process(pid),
|
||||
PidCommandKind::UpdateLoop => force_terminate_process_group(pid),
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_is_active(&self, record: &PidRecord) -> Result<bool> {
|
||||
process_matches_record(record).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn process_exists(pid: u32) -> bool {
|
||||
let Ok(pid) = libc::pid_t::try_from(pid) else {
|
||||
return false;
|
||||
};
|
||||
let result = unsafe { libc::kill(pid, 0) };
|
||||
result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn terminate_process(pid: u32) -> Result<()> {
|
||||
let raw_pid = libc::pid_t::try_from(pid)
|
||||
.with_context(|| format!("pid-managed app server pid {pid} is out of range"))?;
|
||||
let result = unsafe { libc::kill(raw_pid, libc::SIGTERM) };
|
||||
if result == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::ESRCH) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(err).with_context(|| format!("failed to terminate pid-managed app server {pid}"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn force_terminate_process(pid: u32) -> Result<()> {
|
||||
let raw_pid = libc::pid_t::try_from(pid)
|
||||
.with_context(|| format!("pid-managed app server pid {pid} is out of range"))?;
|
||||
let result = unsafe { libc::kill(raw_pid, libc::SIGKILL) };
|
||||
if result == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::ESRCH) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(err).with_context(|| format!("failed to force terminate pid-managed app server {pid}"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn force_terminate_process_group(pid: u32) -> Result<()> {
|
||||
let raw_pid = libc::pid_t::try_from(pid)
|
||||
.with_context(|| format!("pid-managed updater pid {pid} is out of range"))?;
|
||||
let result = unsafe { libc::kill(-raw_pid, libc::SIGKILL) };
|
||||
if result == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::ESRCH) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(err).with_context(|| format!("failed to force terminate pid-managed updater group {pid}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn terminate_process(_pid: u32) -> Result<()> {
|
||||
bail!("pid-managed app-server shutdown is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn force_terminate_process(_pid: u32) -> Result<()> {
|
||||
bail!("pid-managed app-server shutdown is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn force_terminate_process_group(_pid: u32) -> Result<()> {
|
||||
bail!("pid-managed updater shutdown is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn process_matches_record(record: &PidRecord) -> Result<bool> {
|
||||
if !process_exists(record.pid) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match read_process_start_time(record.pid).await {
|
||||
Ok(start_time) => Ok(start_time == record.process_start_time),
|
||||
Err(_err) if !process_exists(record.pid) => Ok(false),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn process_matches_record(_record: &PidRecord) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
enum EmptyPidReservation {
|
||||
Active,
|
||||
Stale,
|
||||
Record(PidRecord),
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn try_lock_file(file: &fs::File) -> Result<bool> {
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if result == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(err).context("failed to lock pid reservation")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn try_lock_file(_file: &fs::File) -> Result<bool> {
|
||||
bail!("pid-managed app-server startup is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn reservation_lock_is_active(path: &Path) -> Result<bool> {
|
||||
let file = match fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)
|
||||
.await
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err)
|
||||
.with_context(|| format!("failed to inspect pid lock file {}", path.display()));
|
||||
}
|
||||
};
|
||||
Ok(!try_lock_file(&file)?)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn reservation_lock_is_active(_path: &Path) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn inspect_empty_pid_reservation(
|
||||
pid_path: &Path,
|
||||
lock_path: &Path,
|
||||
) -> Result<EmptyPidReservation> {
|
||||
let file = match fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(lock_path)
|
||||
.await
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| {
|
||||
format!("failed to inspect pid lock file {}", lock_path.display())
|
||||
});
|
||||
}
|
||||
};
|
||||
if !try_lock_file(&file)? {
|
||||
return Ok(EmptyPidReservation::Active);
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(pid_path).await {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(EmptyPidReservation::Stale);
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err)
|
||||
.with_context(|| format!("failed to reread pid file {}", pid_path.display()));
|
||||
}
|
||||
};
|
||||
if contents.trim().is_empty() {
|
||||
let _ = fs::remove_file(pid_path).await;
|
||||
return Ok(EmptyPidReservation::Stale);
|
||||
}
|
||||
|
||||
let record = serde_json::from_str(&contents)
|
||||
.with_context(|| format!("invalid pid file contents in {}", pid_path.display()))?;
|
||||
Ok(EmptyPidReservation::Record(record))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn inspect_empty_pid_reservation(
|
||||
_pid_path: &Path,
|
||||
_lock_path: &Path,
|
||||
) -> Result<EmptyPidReservation> {
|
||||
Ok(EmptyPidReservation::Stale)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn read_process_start_time(pid: u32) -> Result<String> {
|
||||
let output = Command::new("ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "lstart="])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to invoke ps for pid-managed app server")?;
|
||||
if !output.status.success() {
|
||||
bail!("failed to read start time for pid-managed app server {pid}");
|
||||
}
|
||||
|
||||
let start_time = String::from_utf8(output.stdout)
|
||||
.context("pid-managed app server start time was not utf-8")?;
|
||||
let start_time = start_time.trim();
|
||||
if start_time.is_empty() {
|
||||
bail!("pid-managed app server {pid} has no recorded start time");
|
||||
}
|
||||
Ok(start_time.to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[path = "pid_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::PidBackend;
|
||||
use super::PidCommandKind;
|
||||
use super::PidFileState;
|
||||
use super::PidRecord;
|
||||
use super::try_lock_file;
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_empty_pid_file_is_treated_as_active_reservation() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let pid_file = temp_dir.path().join("app-server.pid");
|
||||
tokio::fs::write(&pid_file, "")
|
||||
.await
|
||||
.expect("write pid file");
|
||||
let backend = PidBackend::new(
|
||||
temp_dir.path().join("codex"),
|
||||
pid_file.clone(),
|
||||
/*remote_control_enabled*/ false,
|
||||
);
|
||||
let reservation = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&backend.lock_file)
|
||||
.await
|
||||
.expect("open pid lock file");
|
||||
assert!(try_lock_file(&reservation).expect("lock reservation"));
|
||||
|
||||
assert_eq!(
|
||||
backend.read_pid_file_state().await.expect("read pid"),
|
||||
PidFileState::Starting
|
||||
);
|
||||
assert!(pid_file.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlocked_empty_pid_file_is_treated_as_stale_reservation() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let pid_file = temp_dir.path().join("app-server.pid");
|
||||
tokio::fs::write(&pid_file, "")
|
||||
.await
|
||||
.expect("write pid file");
|
||||
let backend = PidBackend::new(
|
||||
temp_dir.path().join("codex"),
|
||||
pid_file.clone(),
|
||||
/*remote_control_enabled*/ false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
backend.read_pid_file_state().await.expect("read pid"),
|
||||
PidFileState::Missing
|
||||
);
|
||||
assert!(!pid_file.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_waits_for_live_reservation_to_resolve() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let pid_file = temp_dir.path().join("app-server.pid");
|
||||
tokio::fs::write(&pid_file, "")
|
||||
.await
|
||||
.expect("write pid file");
|
||||
let backend = PidBackend::new(
|
||||
temp_dir.path().join("codex"),
|
||||
pid_file.clone(),
|
||||
/*remote_control_enabled*/ false,
|
||||
);
|
||||
let reservation = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&backend.lock_file)
|
||||
.await
|
||||
.expect("open pid lock file");
|
||||
assert!(try_lock_file(&reservation).expect("lock reservation"));
|
||||
let cleanup = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
drop(reservation);
|
||||
tokio::fs::remove_file(pid_file)
|
||||
.await
|
||||
.expect("remove pid file");
|
||||
});
|
||||
|
||||
backend.stop().await.expect("stop");
|
||||
cleanup.await.expect("cleanup task");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_retries_stale_empty_pid_file_under_its_own_lock() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let pid_file = temp_dir.path().join("app-server.pid");
|
||||
tokio::fs::write(&pid_file, "")
|
||||
.await
|
||||
.expect("write pid file");
|
||||
let backend = PidBackend::new(
|
||||
temp_dir.path().join("missing-codex"),
|
||||
pid_file,
|
||||
/*remote_control_enabled*/ false,
|
||||
);
|
||||
|
||||
let err = backend.start().await.expect_err("start");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.starts_with("failed to spawn detached app-server process using ")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_record_cleanup_preserves_replacement_record() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let pid_file = temp_dir.path().join("app-server.pid");
|
||||
let backend = PidBackend::new(
|
||||
temp_dir.path().join("codex"),
|
||||
pid_file.clone(),
|
||||
/*remote_control_enabled*/ false,
|
||||
);
|
||||
let stale = PidRecord {
|
||||
pid: 1,
|
||||
process_start_time: "old".to_string(),
|
||||
};
|
||||
let replacement = PidRecord {
|
||||
pid: 2,
|
||||
process_start_time: "new".to_string(),
|
||||
};
|
||||
tokio::fs::write(
|
||||
&pid_file,
|
||||
serde_json::to_vec(&replacement).expect("serialize replacement"),
|
||||
)
|
||||
.await
|
||||
.expect("write replacement pid file");
|
||||
|
||||
assert_eq!(
|
||||
backend
|
||||
.refresh_after_stale_record(&stale)
|
||||
.await
|
||||
.expect("cleanup"),
|
||||
PidFileState::Running(replacement)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_loop_uses_hidden_app_server_subcommand() {
|
||||
let backend = PidBackend {
|
||||
codex_bin: "codex".into(),
|
||||
pid_file: "updater.pid".into(),
|
||||
lock_file: "updater.pid.lock".into(),
|
||||
command_kind: PidCommandKind::UpdateLoop,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
backend.command_args(),
|
||||
vec!["app-server", "daemon", "pid-update-loop"]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::InitializeResponse;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_uds::UnixStream;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use tokio::time::timeout;
|
||||
use tokio_tungstenite::client_async;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const CLIENT_NAME: &str = "codex_app_server_daemon";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ProbeInfo {
|
||||
pub(crate) app_server_version: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn probe(socket_path: &Path) -> Result<ProbeInfo> {
|
||||
timeout(PROBE_TIMEOUT, probe_inner(socket_path))
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"timed out probing app-server control socket {}",
|
||||
socket_path.display()
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
async fn probe_inner(socket_path: &Path) -> Result<ProbeInfo> {
|
||||
let stream = UnixStream::connect(socket_path)
|
||||
.await
|
||||
.with_context(|| format!("failed to connect to {}", socket_path.display()))?;
|
||||
let (mut websocket, _response) = client_async("ws://localhost/", stream)
|
||||
.await
|
||||
.with_context(|| format!("failed to upgrade {}", socket_path.display()))?;
|
||||
|
||||
let initialize = JSONRPCMessage::Request(JSONRPCRequest {
|
||||
id: RequestId::Integer(1),
|
||||
method: "initialize".to_string(),
|
||||
params: Some(serde_json::to_value(InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: CLIENT_NAME.to_string(),
|
||||
title: Some("Codex App Server Daemon".to_string()),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
capabilities: None,
|
||||
})?),
|
||||
trace: None,
|
||||
});
|
||||
websocket
|
||||
.send(Message::Text(serde_json::to_string(&initialize)?.into()))
|
||||
.await
|
||||
.context("failed to send initialize request")?;
|
||||
|
||||
let response = loop {
|
||||
let frame = websocket
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("app-server closed before initialize response"))??;
|
||||
let Message::Text(payload) = frame else {
|
||||
continue;
|
||||
};
|
||||
let message = serde_json::from_str::<JSONRPCMessage>(&payload)?;
|
||||
if let JSONRPCMessage::Response(response) = message
|
||||
&& response.id == RequestId::Integer(1)
|
||||
{
|
||||
break response;
|
||||
}
|
||||
};
|
||||
let initialize_response = serde_json::from_value::<InitializeResponse>(response.result)?;
|
||||
|
||||
let initialized = JSONRPCMessage::Notification(JSONRPCNotification {
|
||||
method: "initialized".to_string(),
|
||||
params: None,
|
||||
});
|
||||
websocket
|
||||
.send(Message::Text(serde_json::to_string(&initialized)?.into()))
|
||||
.await
|
||||
.context("failed to send initialized notification")?;
|
||||
websocket.close(None).await.ok();
|
||||
|
||||
Ok(ProbeInfo {
|
||||
app_server_version: parse_version_from_user_agent(&initialize_response.user_agent)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_version_from_user_agent(user_agent: &str) -> Result<String> {
|
||||
let (_originator, rest) = user_agent
|
||||
.split_once('/')
|
||||
.ok_or_else(|| anyhow!("app-server user-agent omitted version separator"))?;
|
||||
let version = rest
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.filter(|version| !version.is_empty())
|
||||
.ok_or_else(|| anyhow!("app-server user-agent omitted version"))?;
|
||||
Ok(version.to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::parse_version_from_user_agent;
|
||||
|
||||
#[test]
|
||||
fn parses_version_from_codex_user_agent() {
|
||||
assert_eq!(
|
||||
parse_version_from_user_agent(
|
||||
"codex_app_server_daemon/1.2.3 (Linux 6.8.0; x86_64) codex_cli_rs/1.2.3",
|
||||
)
|
||||
.expect("version"),
|
||||
"1.2.3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_user_agent_without_version() {
|
||||
assert!(parse_version_from_user_agent("codex_app_server_daemon").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
mod backend;
|
||||
mod client;
|
||||
mod managed_install;
|
||||
mod settings;
|
||||
mod update_loop;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
pub use backend::BackendKind;
|
||||
use backend::BackendPaths;
|
||||
use codex_app_server_transport::app_server_control_socket_path;
|
||||
use codex_core::config::find_codex_home;
|
||||
use managed_install::managed_codex_bin;
|
||||
#[cfg(unix)]
|
||||
use managed_install::managed_codex_version;
|
||||
use serde::Serialize;
|
||||
use settings::DaemonSettings;
|
||||
use tokio::time::sleep;
|
||||
|
||||
const START_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
const START_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const OPERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(75);
|
||||
const PID_FILE_NAME: &str = "app-server.pid";
|
||||
const UPDATE_PID_FILE_NAME: &str = "app-server-updater.pid";
|
||||
const OPERATION_LOCK_FILE_NAME: &str = "daemon.lock";
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
const STATE_DIR_NAME: &str = "app-server-daemon";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LifecycleCommand {
|
||||
Start,
|
||||
Restart,
|
||||
Stop,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LifecycleStatus {
|
||||
AlreadyRunning,
|
||||
Started,
|
||||
Restarted,
|
||||
Stopped,
|
||||
NotRunning,
|
||||
Running,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LifecycleOutput {
|
||||
pub status: LifecycleStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backend: Option<BackendKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pid: Option<u32>,
|
||||
pub socket_path: PathBuf,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cli_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app_server_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BootstrapOptions {
|
||||
pub remote_control_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BootstrapStatus {
|
||||
Bootstrapped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BootstrapOutput {
|
||||
pub status: BootstrapStatus,
|
||||
pub backend: BackendKind,
|
||||
pub auto_update_enabled: bool,
|
||||
pub remote_control_enabled: bool,
|
||||
pub managed_codex_path: PathBuf,
|
||||
pub socket_path: PathBuf,
|
||||
pub cli_version: String,
|
||||
pub app_server_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteControlMode {
|
||||
Enabled,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl RemoteControlMode {
|
||||
fn is_enabled(self) -> bool {
|
||||
matches!(self, Self::Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RemoteControlStatus {
|
||||
Enabled,
|
||||
Disabled,
|
||||
AlreadyEnabled,
|
||||
AlreadyDisabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteControlOutput {
|
||||
pub status: RemoteControlStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backend: Option<BackendKind>,
|
||||
pub remote_control_enabled: bool,
|
||||
pub socket_path: PathBuf,
|
||||
pub cli_version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app_server_version: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) enum RestartIfRunningOutcome {
|
||||
Completed,
|
||||
Busy,
|
||||
}
|
||||
|
||||
pub async fn run(command: LifecycleCommand) -> Result<LifecycleOutput> {
|
||||
ensure_supported_platform()?;
|
||||
Daemon::from_environment()?.run(command).await
|
||||
}
|
||||
|
||||
pub async fn bootstrap(options: BootstrapOptions) -> Result<BootstrapOutput> {
|
||||
ensure_supported_platform()?;
|
||||
Daemon::from_environment()?.bootstrap(options).await
|
||||
}
|
||||
|
||||
pub async fn set_remote_control(mode: RemoteControlMode) -> Result<RemoteControlOutput> {
|
||||
ensure_supported_platform()?;
|
||||
Daemon::from_environment()?.set_remote_control(mode).await
|
||||
}
|
||||
|
||||
pub async fn run_pid_update_loop() -> Result<()> {
|
||||
ensure_supported_platform()?;
|
||||
update_loop::run().await
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ensure_supported_platform() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn ensure_supported_platform() -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"codex app-server daemon lifecycle is only supported on Unix platforms"
|
||||
))
|
||||
}
|
||||
|
||||
struct Daemon {
|
||||
socket_path: PathBuf,
|
||||
pid_file: PathBuf,
|
||||
update_pid_file: PathBuf,
|
||||
operation_lock_file: PathBuf,
|
||||
settings_file: PathBuf,
|
||||
managed_codex_bin: PathBuf,
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
fn from_environment() -> Result<Self> {
|
||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||
let socket_path = app_server_control_socket_path(codex_home.as_path())?
|
||||
.as_path()
|
||||
.to_path_buf();
|
||||
let state_dir = codex_home.as_path().join(STATE_DIR_NAME);
|
||||
Ok(Self {
|
||||
socket_path,
|
||||
pid_file: state_dir.join(PID_FILE_NAME),
|
||||
update_pid_file: state_dir.join(UPDATE_PID_FILE_NAME),
|
||||
operation_lock_file: state_dir.join(OPERATION_LOCK_FILE_NAME),
|
||||
settings_file: state_dir.join(SETTINGS_FILE_NAME),
|
||||
managed_codex_bin: managed_codex_bin(codex_home.as_path()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn run(&self, command: LifecycleCommand) -> Result<LifecycleOutput> {
|
||||
match command {
|
||||
LifecycleCommand::Start => {
|
||||
let _operation_lock = self.acquire_operation_lock().await?;
|
||||
self.start().await
|
||||
}
|
||||
LifecycleCommand::Restart => {
|
||||
let _operation_lock = self.acquire_operation_lock().await?;
|
||||
self.restart().await
|
||||
}
|
||||
LifecycleCommand::Stop => {
|
||||
let _operation_lock = self.acquire_operation_lock().await?;
|
||||
self.stop().await
|
||||
}
|
||||
LifecycleCommand::Version => self.version().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn start(&self) -> Result<LifecycleOutput> {
|
||||
let settings = self.load_settings().await?;
|
||||
if let Ok(info) = client::probe(&self.socket_path).await {
|
||||
return Ok(self.output(
|
||||
LifecycleStatus::AlreadyRunning,
|
||||
self.running_backend(&settings).await?,
|
||||
/*pid*/ None,
|
||||
Some(info.app_server_version),
|
||||
));
|
||||
}
|
||||
|
||||
if self.running_backend_instance(&settings).await?.is_some() {
|
||||
let info = self.wait_until_ready().await?;
|
||||
return Ok(self.output(
|
||||
LifecycleStatus::AlreadyRunning,
|
||||
Some(BackendKind::Pid),
|
||||
/*pid*/ None,
|
||||
Some(info.app_server_version),
|
||||
));
|
||||
}
|
||||
|
||||
self.ensure_managed_codex_bin()?;
|
||||
let pid = self.start_managed_backend(&settings).await?;
|
||||
let info = self.wait_until_ready().await?;
|
||||
Ok(self.output(
|
||||
LifecycleStatus::Started,
|
||||
Some(BackendKind::Pid),
|
||||
pid,
|
||||
Some(info.app_server_version),
|
||||
))
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<LifecycleOutput> {
|
||||
let settings = self.load_settings().await?;
|
||||
if client::probe(&self.socket_path).await.is_ok()
|
||||
&& self.running_backend(&settings).await?.is_none()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"app server is running but is not managed by codex app-server daemon"
|
||||
));
|
||||
}
|
||||
|
||||
self.ensure_managed_codex_bin()?;
|
||||
if let Some(backend) = self.running_backend_instance(&settings).await? {
|
||||
backend.stop().await?;
|
||||
}
|
||||
|
||||
let pid = self.start_managed_backend(&settings).await?;
|
||||
let info = self.wait_until_ready().await?;
|
||||
Ok(self.output(
|
||||
LifecycleStatus::Restarted,
|
||||
Some(BackendKind::Pid),
|
||||
pid,
|
||||
Some(info.app_server_version),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn try_restart_if_running(&self) -> Result<RestartIfRunningOutcome> {
|
||||
let operation_lock = self.open_operation_lock_file().await?;
|
||||
if !try_lock_file(&operation_lock)? {
|
||||
return Ok(RestartIfRunningOutcome::Busy);
|
||||
}
|
||||
let settings = self.load_settings().await?;
|
||||
if let Some(backend) = self.running_backend_instance(&settings).await? {
|
||||
let Ok(info) = client::probe(&self.socket_path).await else {
|
||||
return Ok(RestartIfRunningOutcome::Completed);
|
||||
};
|
||||
let managed_version = managed_codex_version(&self.managed_codex_bin).await?;
|
||||
if info.app_server_version == managed_version {
|
||||
return Ok(RestartIfRunningOutcome::Completed);
|
||||
}
|
||||
backend.stop().await?;
|
||||
let _ = self.start_managed_backend(&settings).await?;
|
||||
self.wait_until_ready().await?;
|
||||
return Ok(RestartIfRunningOutcome::Completed);
|
||||
}
|
||||
|
||||
if client::probe(&self.socket_path).await.is_ok() {
|
||||
return Err(anyhow!(
|
||||
"app server is running but is not managed by codex app-server daemon"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(RestartIfRunningOutcome::Completed)
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<LifecycleOutput> {
|
||||
let settings = self.load_settings().await?;
|
||||
if let Some(backend) = self.running_backend_instance(&settings).await? {
|
||||
backend.stop().await?;
|
||||
return Ok(self.output(
|
||||
LifecycleStatus::Stopped,
|
||||
Some(BackendKind::Pid),
|
||||
/*pid*/ None,
|
||||
/*app_server_version*/ None,
|
||||
));
|
||||
}
|
||||
|
||||
if client::probe(&self.socket_path).await.is_ok() {
|
||||
return Err(anyhow!(
|
||||
"app server is running but is not managed by codex app-server daemon"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(self.output(
|
||||
LifecycleStatus::NotRunning,
|
||||
/*backend*/ None,
|
||||
/*pid*/ None,
|
||||
/*app_server_version*/ None,
|
||||
))
|
||||
}
|
||||
|
||||
async fn version(&self) -> Result<LifecycleOutput> {
|
||||
let settings = self.load_settings().await?;
|
||||
let info = client::probe(&self.socket_path).await?;
|
||||
Ok(self.output(
|
||||
LifecycleStatus::Running,
|
||||
self.running_backend(&settings).await?,
|
||||
/*pid*/ None,
|
||||
Some(info.app_server_version),
|
||||
))
|
||||
}
|
||||
|
||||
async fn wait_until_ready(&self) -> Result<client::ProbeInfo> {
|
||||
let deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
||||
loop {
|
||||
match client::probe(&self.socket_path).await {
|
||||
Ok(info) => return Ok(info),
|
||||
Err(err) if tokio::time::Instant::now() < deadline => {
|
||||
let _ = err;
|
||||
sleep(START_POLL_INTERVAL).await;
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| {
|
||||
format!(
|
||||
"app server did not become ready on {}",
|
||||
self.socket_path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn bootstrap(&self, options: BootstrapOptions) -> Result<BootstrapOutput> {
|
||||
let _operation_lock = self.acquire_operation_lock().await?;
|
||||
self.bootstrap_locked(options).await
|
||||
}
|
||||
|
||||
async fn set_remote_control(&self, mode: RemoteControlMode) -> Result<RemoteControlOutput> {
|
||||
let _operation_lock = self.acquire_operation_lock().await?;
|
||||
let previous_settings = self.load_settings().await?;
|
||||
let mut settings = previous_settings.clone();
|
||||
let remote_control_enabled = mode.is_enabled();
|
||||
let backend = self.running_backend_instance(&previous_settings).await?;
|
||||
|
||||
if backend.is_none() && client::probe(&self.socket_path).await.is_ok() {
|
||||
return Err(anyhow!(
|
||||
"app server is running but is not managed by codex app-server daemon"
|
||||
));
|
||||
}
|
||||
|
||||
if settings.remote_control_enabled == remote_control_enabled {
|
||||
let info = if backend.is_some() {
|
||||
Some(self.wait_until_ready().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return Ok(self.remote_control_output(
|
||||
already_remote_control_status(mode),
|
||||
backend.map(|_| BackendKind::Pid),
|
||||
remote_control_enabled,
|
||||
info.map(|info| info.app_server_version),
|
||||
));
|
||||
}
|
||||
|
||||
settings.remote_control_enabled = remote_control_enabled;
|
||||
settings.save(&self.settings_file).await?;
|
||||
|
||||
let app_server_version = if let Some(backend) = backend {
|
||||
self.ensure_managed_codex_bin()?;
|
||||
backend.stop().await?;
|
||||
let _ = self.start_managed_backend(&settings).await?;
|
||||
Some(self.wait_until_ready().await?.app_server_version)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(self.remote_control_output(
|
||||
remote_control_status(mode),
|
||||
app_server_version.as_ref().map(|_| BackendKind::Pid),
|
||||
remote_control_enabled,
|
||||
app_server_version,
|
||||
))
|
||||
}
|
||||
|
||||
async fn bootstrap_locked(&self, options: BootstrapOptions) -> Result<BootstrapOutput> {
|
||||
self.ensure_managed_codex_bin()?;
|
||||
|
||||
let settings = DaemonSettings {
|
||||
remote_control_enabled: options.remote_control_enabled,
|
||||
};
|
||||
if client::probe(&self.socket_path).await.is_ok()
|
||||
&& self.running_backend(&settings).await?.is_none()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"app server is running but is not managed by codex app-server daemon"
|
||||
));
|
||||
}
|
||||
settings.save(&self.settings_file).await?;
|
||||
|
||||
if let Some(backend) = self.running_backend_instance(&settings).await? {
|
||||
backend.stop().await?;
|
||||
}
|
||||
|
||||
let backend = backend::pid_backend(self.backend_paths(&settings));
|
||||
backend.start().await?;
|
||||
let updater = backend::pid_update_loop_backend(self.backend_paths(&settings));
|
||||
if updater.is_starting_or_running().await? {
|
||||
updater.stop().await?;
|
||||
}
|
||||
updater.start().await?;
|
||||
|
||||
let info = self.wait_until_ready().await?;
|
||||
Ok(BootstrapOutput {
|
||||
status: BootstrapStatus::Bootstrapped,
|
||||
backend: BackendKind::Pid,
|
||||
auto_update_enabled: true,
|
||||
remote_control_enabled: settings.remote_control_enabled,
|
||||
managed_codex_path: self.managed_codex_bin.clone(),
|
||||
socket_path: self.socket_path.clone(),
|
||||
cli_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
app_server_version: info.app_server_version,
|
||||
})
|
||||
}
|
||||
|
||||
async fn running_backend(&self, settings: &DaemonSettings) -> Result<Option<BackendKind>> {
|
||||
Ok(self
|
||||
.running_backend_instance(settings)
|
||||
.await?
|
||||
.map(|_| BackendKind::Pid))
|
||||
}
|
||||
|
||||
async fn running_backend_instance(
|
||||
&self,
|
||||
settings: &DaemonSettings,
|
||||
) -> Result<Option<backend::PidBackend>> {
|
||||
let backend = backend::pid_backend(self.backend_paths(settings));
|
||||
if backend.is_starting_or_running().await? {
|
||||
return Ok(Some(backend));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn start_managed_backend(&self, settings: &DaemonSettings) -> Result<Option<u32>> {
|
||||
let backend = backend::pid_backend(self.backend_paths(settings));
|
||||
backend.start().await
|
||||
}
|
||||
|
||||
fn ensure_managed_codex_bin(&self) -> Result<()> {
|
||||
if self.managed_codex_bin.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"managed standalone Codex install not found at {}; install Codex first",
|
||||
self.managed_codex_bin.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn backend_paths(&self, settings: &DaemonSettings) -> BackendPaths {
|
||||
BackendPaths {
|
||||
codex_bin: self.managed_codex_bin.clone(),
|
||||
pid_file: self.pid_file.clone(),
|
||||
update_pid_file: self.update_pid_file.clone(),
|
||||
remote_control_enabled: settings.remote_control_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_settings(&self) -> Result<DaemonSettings> {
|
||||
DaemonSettings::load(&self.settings_file).await
|
||||
}
|
||||
|
||||
async fn acquire_operation_lock(&self) -> Result<tokio::fs::File> {
|
||||
let operation_lock = self.open_operation_lock_file().await?;
|
||||
let deadline = tokio::time::Instant::now() + OPERATION_LOCK_TIMEOUT;
|
||||
while !try_lock_file(&operation_lock)? {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"timed out waiting for daemon operation lock {}",
|
||||
self.operation_lock_file.display()
|
||||
));
|
||||
}
|
||||
sleep(START_POLL_INTERVAL).await;
|
||||
}
|
||||
Ok(operation_lock)
|
||||
}
|
||||
|
||||
async fn open_operation_lock_file(&self) -> Result<tokio::fs::File> {
|
||||
if let Some(parent) = self.operation_lock_file.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.with_context(|| {
|
||||
format!(
|
||||
"failed to create daemon state directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&self.operation_lock_file)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to open daemon operation lock {}",
|
||||
self.operation_lock_file.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn output(
|
||||
&self,
|
||||
status: LifecycleStatus,
|
||||
backend: Option<BackendKind>,
|
||||
pid: Option<u32>,
|
||||
app_server_version: Option<String>,
|
||||
) -> LifecycleOutput {
|
||||
LifecycleOutput {
|
||||
status,
|
||||
backend,
|
||||
pid,
|
||||
socket_path: self.socket_path.clone(),
|
||||
cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||
app_server_version,
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_control_output(
|
||||
&self,
|
||||
status: RemoteControlStatus,
|
||||
backend: Option<BackendKind>,
|
||||
remote_control_enabled: bool,
|
||||
app_server_version: Option<String>,
|
||||
) -> RemoteControlOutput {
|
||||
RemoteControlOutput {
|
||||
status,
|
||||
backend,
|
||||
remote_control_enabled,
|
||||
socket_path: self.socket_path.clone(),
|
||||
cli_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
app_server_version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_control_status(mode: RemoteControlMode) -> RemoteControlStatus {
|
||||
match mode {
|
||||
RemoteControlMode::Enabled => RemoteControlStatus::Enabled,
|
||||
RemoteControlMode::Disabled => RemoteControlStatus::Disabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn already_remote_control_status(mode: RemoteControlMode) -> RemoteControlStatus {
|
||||
match mode {
|
||||
RemoteControlMode::Enabled => RemoteControlStatus::AlreadyEnabled,
|
||||
RemoteControlMode::Disabled => RemoteControlStatus::AlreadyDisabled,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn try_lock_file(file: &tokio::fs::File) -> Result<bool> {
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if result == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(err).context("failed to lock daemon operation")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn try_lock_file(_file: &tokio::fs::File) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::BootstrapStatus;
|
||||
use super::LifecycleStatus;
|
||||
use super::RemoteControlStatus;
|
||||
|
||||
#[test]
|
||||
fn lifecycle_status_uses_camel_case_json() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&LifecycleStatus::AlreadyRunning).expect("serialize"),
|
||||
"\"alreadyRunning\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_status_uses_camel_case_json() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&BootstrapStatus::Bootstrapped).expect("serialize"),
|
||||
"\"bootstrapped\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_control_status_uses_camel_case_json() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&RemoteControlStatus::AlreadyEnabled).expect("serialize"),
|
||||
"\"alreadyEnabled\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context;
|
||||
#[cfg(unix)]
|
||||
use anyhow::Result;
|
||||
#[cfg(unix)]
|
||||
use anyhow::anyhow;
|
||||
#[cfg(unix)]
|
||||
use tokio::process::Command;
|
||||
|
||||
pub(crate) fn managed_codex_bin(codex_home: &Path) -> PathBuf {
|
||||
codex_home
|
||||
.join("packages")
|
||||
.join("standalone")
|
||||
.join("current")
|
||||
.join(managed_codex_file_name())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn managed_codex_version(codex_bin: &Path) -> Result<String> {
|
||||
let output = Command::new(codex_bin)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to invoke managed Codex binary {}",
|
||||
codex_bin.display()
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"managed Codex binary {} exited with status {}",
|
||||
codex_bin.display(),
|
||||
output.status
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).with_context(|| {
|
||||
format!(
|
||||
"managed Codex version was not utf-8: {}",
|
||||
codex_bin.display()
|
||||
)
|
||||
})?;
|
||||
parse_codex_version(&stdout)
|
||||
}
|
||||
|
||||
fn managed_codex_file_name() -> &'static str {
|
||||
if cfg!(windows) { "codex.exe" } else { "codex" }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn parse_codex_version(output: &str) -> Result<String> {
|
||||
let version = output
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.filter(|version| !version.is_empty())
|
||||
.ok_or_else(|| anyhow!("managed Codex version output was malformed"))?;
|
||||
Ok(version.to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[path = "managed_install_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,16 @@
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::parse_codex_version;
|
||||
|
||||
#[test]
|
||||
fn parses_codex_cli_version_output() {
|
||||
assert_eq!(
|
||||
parse_codex_version("codex 1.2.3\n").expect("version"),
|
||||
"1.2.3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_codex_cli_version_output() {
|
||||
assert!(parse_codex_version("codex\n").is_err());
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DaemonSettings {
|
||||
pub(crate) remote_control_enabled: bool,
|
||||
}
|
||||
|
||||
impl DaemonSettings {
|
||||
pub(crate) async fn load(path: &Path) -> Result<Self> {
|
||||
let contents = match fs::read_to_string(path).await {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
|
||||
Err(err) => {
|
||||
return Err(err)
|
||||
.with_context(|| format!("failed to read daemon settings {}", path.display()));
|
||||
}
|
||||
};
|
||||
|
||||
serde_json::from_str(&contents)
|
||||
.with_context(|| format!("failed to parse daemon settings {}", path.display()))
|
||||
}
|
||||
|
||||
pub(crate) async fn save(&self, path: &Path) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await.with_context(|| {
|
||||
format!(
|
||||
"failed to create daemon settings directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let contents = serde_json::to_vec_pretty(self).context("failed to serialize settings")?;
|
||||
fs::write(path, contents)
|
||||
.await
|
||||
.with_context(|| format!("failed to write daemon settings {}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::DaemonSettings;
|
||||
|
||||
#[test]
|
||||
fn daemon_settings_use_camel_case_json() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&DaemonSettings {
|
||||
remote_control_enabled: true,
|
||||
})
|
||||
.expect("serialize"),
|
||||
r#"{"remoteControlEnabled":true}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
#[cfg(unix)]
|
||||
use std::process::Stdio;
|
||||
#[cfg(unix)]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
#[cfg(not(unix))]
|
||||
use anyhow::bail;
|
||||
#[cfg(unix)]
|
||||
use futures::FutureExt;
|
||||
#[cfg(unix)]
|
||||
use tokio::io::AsyncWriteExt;
|
||||
#[cfg(unix)]
|
||||
use tokio::process::Command;
|
||||
#[cfg(unix)]
|
||||
use tokio::signal::unix::Signal;
|
||||
#[cfg(unix)]
|
||||
use tokio::signal::unix::SignalKind;
|
||||
#[cfg(unix)]
|
||||
use tokio::signal::unix::signal;
|
||||
#[cfg(unix)]
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[cfg(unix)]
|
||||
use crate::Daemon;
|
||||
#[cfg(unix)]
|
||||
use crate::RestartIfRunningOutcome;
|
||||
|
||||
#[cfg(unix)]
|
||||
const INITIAL_UPDATE_DELAY: Duration = Duration::from_secs(5 * 60);
|
||||
#[cfg(unix)]
|
||||
const RESTART_RETRY_INTERVAL: Duration = Duration::from_millis(50);
|
||||
#[cfg(unix)]
|
||||
const UPDATE_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn run() -> Result<()> {
|
||||
let mut terminate =
|
||||
signal(SignalKind::terminate()).context("failed to install updater shutdown handler")?;
|
||||
if sleep_or_terminate(INITIAL_UPDATE_DELAY, &mut terminate).await {
|
||||
return Ok(());
|
||||
}
|
||||
loop {
|
||||
match update_once(&mut terminate).await {
|
||||
Ok(UpdateLoopControl::Continue) | Err(_) => {}
|
||||
Ok(UpdateLoopControl::Stop) => return Ok(()),
|
||||
}
|
||||
if sleep_or_terminate(UPDATE_INTERVAL, &mut terminate).await {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) async fn run() -> Result<()> {
|
||||
bail!("pid-managed updater loop is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn sleep_or_terminate(duration: Duration, terminate: &mut Signal) -> bool {
|
||||
tokio::select! {
|
||||
_ = sleep(duration) => false,
|
||||
_ = terminate.recv() => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
enum UpdateLoopControl {
|
||||
Continue,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn update_once(terminate: &mut Signal) -> Result<UpdateLoopControl> {
|
||||
install_latest_standalone().await?;
|
||||
|
||||
let daemon = Daemon::from_environment()?;
|
||||
loop {
|
||||
if terminate.recv().now_or_never().flatten().is_some() {
|
||||
return Ok(UpdateLoopControl::Stop);
|
||||
}
|
||||
match daemon.try_restart_if_running().await? {
|
||||
RestartIfRunningOutcome::Completed => return Ok(UpdateLoopControl::Continue),
|
||||
RestartIfRunningOutcome::Busy => {
|
||||
if sleep_or_terminate(RESTART_RETRY_INTERVAL, terminate).await {
|
||||
return Ok(UpdateLoopControl::Stop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn install_latest_standalone() -> Result<()> {
|
||||
let script = reqwest::get("https://chatgpt.com/codex/install.sh")
|
||||
.await
|
||||
.context("failed to fetch standalone Codex updater")?
|
||||
.error_for_status()
|
||||
.context("standalone Codex updater request failed")?
|
||||
.bytes()
|
||||
.await
|
||||
.context("failed to read standalone Codex updater")?;
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.arg("-s")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.context("failed to invoke standalone Codex updater")?;
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.context("standalone Codex updater stdin was unavailable")?;
|
||||
stdin
|
||||
.write_all(&script)
|
||||
.await
|
||||
.context("failed to pass standalone Codex updater to shell")?;
|
||||
drop(stdin);
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.context("failed to wait for standalone Codex updater")?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("standalone Codex updater exited with status {status}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "AttestationGenerateParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"token": {
|
||||
"description": "Opaque client attestation token.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"token"
|
||||
],
|
||||
"title": "AttestationGenerateResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -1259,6 +1259,11 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"requestAttestation": {
|
||||
"default": false,
|
||||
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -121,6 +121,9 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AttestationGenerateParams": {
|
||||
"type": "object"
|
||||
},
|
||||
"ChatgptAuthTokensRefreshParams": {
|
||||
"properties": {
|
||||
"previousAccountId": {
|
||||
@@ -1918,6 +1921,31 @@
|
||||
"title": "Account/chatgptAuthTokens/refreshRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Generate a fresh upstream attestation result on demand.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"attestation/generate"
|
||||
],
|
||||
"title": "Attestation/generateRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/AttestationGenerateParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Attestation/generateRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "DEPRECATED APIs below Request to approve a patch. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).",
|
||||
"properties": {
|
||||
|
||||
+49
@@ -83,6 +83,25 @@
|
||||
"title": "ApplyPatchApprovalResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"AttestationGenerateParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "AttestationGenerateParams",
|
||||
"type": "object"
|
||||
},
|
||||
"AttestationGenerateResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"token": {
|
||||
"description": "Opaque client attestation token.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"token"
|
||||
],
|
||||
"title": "AttestationGenerateResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ChatgptAuthTokensRefreshParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -2620,6 +2639,11 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"requestAttestation": {
|
||||
"default": false,
|
||||
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -5207,6 +5231,31 @@
|
||||
"title": "Account/chatgptAuthTokens/refreshRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Generate a fresh upstream attestation result on demand.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"attestation/generate"
|
||||
],
|
||||
"title": "Attestation/generateRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/AttestationGenerateParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Attestation/generateRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "DEPRECATED APIs below Request to approve a patch. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).",
|
||||
"properties": {
|
||||
|
||||
+5
@@ -6409,6 +6409,11 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"requestAttestation": {
|
||||
"default": false,
|
||||
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"requestAttestation": {
|
||||
"default": false,
|
||||
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -10,6 +10,10 @@ export type InitializeCapabilities = {
|
||||
* Opt into receiving experimental API methods and fields.
|
||||
*/
|
||||
experimentalApi: boolean,
|
||||
/**
|
||||
* Opt into `attestation/generate` requests for upstream `x-oai-attestation`.
|
||||
*/
|
||||
requestAttestation: boolean,
|
||||
/**
|
||||
* Exact notification method names that should be suppressed for this
|
||||
* connection (for example `thread/started`).
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams";
|
||||
import type { ExecCommandApprovalParams } from "./ExecCommandApprovalParams";
|
||||
import type { RequestId } from "./RequestId";
|
||||
import type { AttestationGenerateParams } from "./v2/AttestationGenerateParams";
|
||||
import type { ChatgptAuthTokensRefreshParams } from "./v2/ChatgptAuthTokensRefreshParams";
|
||||
import type { CommandExecutionRequestApprovalParams } from "./v2/CommandExecutionRequestApprovalParams";
|
||||
import type { DynamicToolCallParams } from "./v2/DynamicToolCallParams";
|
||||
@@ -15,4 +16,4 @@ import type { ToolRequestUserInputParams } from "./v2/ToolRequestUserInputParams
|
||||
/**
|
||||
* Request initiated from the server and sent to the client.
|
||||
*/
|
||||
export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, };
|
||||
export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "attestation/generate", id: RequestId, params: AttestationGenerateParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, };
|
||||
|
||||
+5
@@ -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 AttestationGenerateParams = Record<string, never>;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type AttestationGenerateResponse = {
|
||||
/**
|
||||
* Opaque client attestation token.
|
||||
*/
|
||||
token: string, };
|
||||
@@ -28,6 +28,8 @@ export type { AppsDefaultConfig } from "./AppsDefaultConfig";
|
||||
export type { AppsListParams } from "./AppsListParams";
|
||||
export type { AppsListResponse } from "./AppsListResponse";
|
||||
export type { AskForApproval } from "./AskForApproval";
|
||||
export type { AttestationGenerateParams } from "./AttestationGenerateParams";
|
||||
export type { AttestationGenerateResponse } from "./AttestationGenerateResponse";
|
||||
export type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource";
|
||||
export type { ByteRange } from "./ByteRange";
|
||||
export type { CancelLoginAccountParams } from "./CancelLoginAccountParams";
|
||||
|
||||
@@ -808,6 +808,13 @@ client_request_definitions! {
|
||||
serialization: None,
|
||||
response: v2::MockExperimentalMethodResponse,
|
||||
},
|
||||
#[experimental("environment/add")]
|
||||
/// Adds or replaces a remote environment by id for later selection.
|
||||
EnvironmentAdd => "environment/add" {
|
||||
params: v2::EnvironmentAddParams,
|
||||
serialization: global("environment"),
|
||||
response: v2::EnvironmentAddResponse,
|
||||
},
|
||||
|
||||
McpServerOauthLogin => "mcpServer/oauth/login" {
|
||||
params: v2::McpServerOauthLoginParams,
|
||||
@@ -1312,6 +1319,12 @@ server_request_definitions! {
|
||||
response: v2::ChatgptAuthTokensRefreshResponse,
|
||||
},
|
||||
|
||||
/// Generate a fresh upstream attestation result on demand.
|
||||
AttestationGenerate => "attestation/generate" {
|
||||
params: v2::AttestationGenerateParams,
|
||||
response: v2::AttestationGenerateResponse,
|
||||
},
|
||||
|
||||
/// DEPRECATED APIs below
|
||||
/// Request to approve a patch.
|
||||
/// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).
|
||||
@@ -1790,6 +1803,18 @@ mod tests {
|
||||
add_credits_nudge.serialization_scope(),
|
||||
Some(ClientRequestSerializationScope::Global("account-auth"))
|
||||
);
|
||||
|
||||
let environment_add = ClientRequest::EnvironmentAdd {
|
||||
request_id: request_id(),
|
||||
params: v2::EnvironmentAddParams {
|
||||
environment_id: "remote-a".to_string(),
|
||||
exec_server_url: "ws://127.0.0.1:8765".to_string(),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
environment_add.serialization_scope(),
|
||||
Some(ClientRequestSerializationScope::Global("environment"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1910,6 +1935,7 @@ mod tests {
|
||||
},
|
||||
capabilities: Some(v1::InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
opt_out_notification_methods: Some(vec![
|
||||
"thread/started".to_string(),
|
||||
"item/agentMessage/delta".to_string(),
|
||||
@@ -1930,6 +1956,7 @@ mod tests {
|
||||
},
|
||||
"capabilities": {
|
||||
"experimentalApi": true,
|
||||
"requestAttestation": true,
|
||||
"optOutNotificationMethods": [
|
||||
"thread/started",
|
||||
"item/agentMessage/delta"
|
||||
@@ -1955,6 +1982,7 @@ mod tests {
|
||||
},
|
||||
"capabilities": {
|
||||
"experimentalApi": true,
|
||||
"requestAttestation": true,
|
||||
"optOutNotificationMethods": [
|
||||
"thread/started",
|
||||
"item/agentMessage/delta"
|
||||
@@ -1975,6 +2003,7 @@ mod tests {
|
||||
},
|
||||
capabilities: Some(v1::InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
opt_out_notification_methods: Some(vec![
|
||||
"thread/started".to_string(),
|
||||
"item/agentMessage/delta".to_string(),
|
||||
@@ -2091,6 +2120,28 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_attestation_generate_request() -> Result<()> {
|
||||
let params = v2::AttestationGenerateParams {};
|
||||
let request = ServerRequest::AttestationGenerate {
|
||||
request_id: RequestId::Integer(9),
|
||||
params: params.clone(),
|
||||
};
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "attestation/generate",
|
||||
"id": 9,
|
||||
"params": {}
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
);
|
||||
|
||||
let payload = ServerRequestPayload::AttestationGenerate(params);
|
||||
assert_eq!(request.id(), &RequestId::Integer(9));
|
||||
assert_eq!(payload.request_with_id(RequestId::Integer(9)), request);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_server_response() -> Result<()> {
|
||||
let response = ServerResponse::CommandExecutionRequestApproval {
|
||||
@@ -2546,10 +2597,33 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_environment_add() -> Result<()> {
|
||||
let request = ClientRequest::EnvironmentAdd {
|
||||
request_id: RequestId::Integer(9),
|
||||
params: v2::EnvironmentAddParams {
|
||||
environment_id: "remote-a".to_string(),
|
||||
exec_server_url: "ws://127.0.0.1:8765".to_string(),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "environment/add",
|
||||
"id": 9,
|
||||
"params": {
|
||||
"environmentId": "remote-a",
|
||||
"execServerUrl": "ws://127.0.0.1:8765"
|
||||
}
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_fs_get_metadata() -> Result<()> {
|
||||
let request = ClientRequest::FsGetMetadata {
|
||||
request_id: RequestId::Integer(9),
|
||||
request_id: RequestId::Integer(10),
|
||||
params: v2::FsGetMetadataParams {
|
||||
path: absolute_path("tmp/example"),
|
||||
},
|
||||
@@ -2557,7 +2631,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "fs/getMetadata",
|
||||
"id": 9,
|
||||
"id": 10,
|
||||
"params": {
|
||||
"path": absolute_path_string("tmp/example")
|
||||
}
|
||||
@@ -2818,6 +2892,19 @@ mod tests {
|
||||
assert_eq!(reason, Some("mock/experimentalMethod"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_add_is_marked_experimental() {
|
||||
let request = ClientRequest::EnvironmentAdd {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: v2::EnvironmentAddParams {
|
||||
environment_id: "remote-a".to_string(),
|
||||
exec_server_url: "ws://127.0.0.1:8765".to_string(),
|
||||
},
|
||||
};
|
||||
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
|
||||
assert_eq!(reason, Some("environment/add"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_exec_permission_profile_is_marked_experimental() {
|
||||
let request = ClientRequest::OneOffCommandExec {
|
||||
|
||||
@@ -46,6 +46,9 @@ pub struct InitializeCapabilities {
|
||||
/// Opt into receiving experimental API methods and fields.
|
||||
#[serde(default)]
|
||||
pub experimental_api: bool,
|
||||
/// Opt into `attestation/generate` requests for upstream `x-oai-attestation`.
|
||||
#[serde(default)]
|
||||
pub request_attestation: bool,
|
||||
/// Exact notification method names that should be suppressed for this
|
||||
/// connection (for example `thread/started`).
|
||||
#[ts(optional = nullable)]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct AttestationGenerateParams {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct AttestationGenerateResponse {
|
||||
/// Opaque client attestation token.
|
||||
pub token: String,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct EnvironmentAddParams {
|
||||
pub environment_id: String,
|
||||
pub exec_server_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct EnvironmentAddResponse {}
|
||||
@@ -2,9 +2,11 @@ mod shared;
|
||||
|
||||
mod account;
|
||||
mod apps;
|
||||
mod attestation;
|
||||
mod collaboration_mode;
|
||||
mod command_exec;
|
||||
mod config;
|
||||
mod environment;
|
||||
mod experimental_feature;
|
||||
mod feedback;
|
||||
mod fs;
|
||||
@@ -26,9 +28,11 @@ mod windows_sandbox;
|
||||
|
||||
pub use account::*;
|
||||
pub use apps::*;
|
||||
pub use attestation::*;
|
||||
pub use collaboration_mode::*;
|
||||
pub use command_exec::*;
|
||||
pub use config::*;
|
||||
pub use environment::*;
|
||||
pub use experimental_feature::*;
|
||||
pub use feedback::*;
|
||||
pub use fs::*;
|
||||
|
||||
@@ -1551,6 +1551,7 @@ impl CodexClient {
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: Some(
|
||||
NOTIFICATIONS_TO_OPT_OUT
|
||||
.iter()
|
||||
|
||||
@@ -41,6 +41,7 @@ codex-external-agent-migration = { workspace = true }
|
||||
codex-external-agent-sessions = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-file-watcher = { workspace = true }
|
||||
codex-hooks = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
|
||||
@@ -202,6 +202,7 @@ Example with notification opt-out:
|
||||
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
|
||||
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
|
||||
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
|
||||
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment.
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
- `hooks/list` — list discovered hooks for one or more `cwd` values.
|
||||
@@ -1337,6 +1338,10 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives.
|
||||
|
||||
When the client responds to `item/tool/requestUserInput`, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup.
|
||||
|
||||
### Attestation generation
|
||||
|
||||
Desktop hosts that provide upstream attestation should set `capabilities.requestAttestation` during `initialize` and handle the server-initiated `attestation/generate` request. App-server issues it just in time before ChatGPT Codex requests that forward `x-oai-attestation`; the client responds with `{ "token": "v1.<opaque>" }`, where `token` is an opaque client-owned value. When app-server receives a client response, it forwards a consistent outer envelope such as `{ "v": 1, "s": 0, "t": "v1.<opaque>" }`, where `t` contains the client token unchanged. If app-server attempts attestation but fails within its own boundary, it sends the same envelope shape with an app-server status code and without `t` (`1 = timeout`, `2 = request failed`, `3 = request canceled`, `4 = malformed response`). If no initialized client opted into attestation, app-server omits `x-oai-attestation` for that upstream request.
|
||||
|
||||
### MCP server elicitations
|
||||
|
||||
MCP servers can interrupt a turn and ask the client for structured input via `mcpServer/elicitation/request`.
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::HeaderValue;
|
||||
use codex_app_server_protocol::AttestationGenerateParams;
|
||||
use codex_app_server_protocol::AttestationGenerateResponse;
|
||||
use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_core::AttestationContext;
|
||||
use codex_core::AttestationProvider;
|
||||
use codex_core::GenerateAttestationFuture;
|
||||
use serde::Serialize;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::thread_state::ThreadStateManager;
|
||||
|
||||
const ATTESTATION_GENERATE_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
pub(crate) fn app_server_attestation_provider(
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
thread_state_manager: ThreadStateManager,
|
||||
) -> Arc<dyn AttestationProvider> {
|
||||
Arc::new(AppServerAttestationProvider {
|
||||
outgoing,
|
||||
thread_state_manager,
|
||||
})
|
||||
}
|
||||
|
||||
struct AppServerAttestationProvider {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
thread_state_manager: ThreadStateManager,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AppServerAttestationProvider {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AppServerAttestationProvider")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AttestationProvider for AppServerAttestationProvider {
|
||||
fn header_for_request(&self, context: AttestationContext) -> GenerateAttestationFuture<'_> {
|
||||
let outgoing = self.outgoing.clone();
|
||||
let thread_state_manager = self.thread_state_manager.clone();
|
||||
Box::pin(async move {
|
||||
request_attestation_header_value_with_timeout(
|
||||
outgoing,
|
||||
thread_state_manager,
|
||||
context.thread_id,
|
||||
ATTESTATION_GENERATE_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.and_then(|value| HeaderValue::from_bytes(value.as_bytes()).ok())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_attestation_header_value_with_timeout(
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
thread_state_manager: ThreadStateManager,
|
||||
thread_id: codex_protocol::ThreadId,
|
||||
timeout_duration: Duration,
|
||||
) -> Option<String> {
|
||||
let connection_id = thread_state_manager
|
||||
.first_attestation_capable_connection_for_thread(thread_id)
|
||||
.await?;
|
||||
|
||||
let connection_ids = [connection_id];
|
||||
let (request_id, rx) = outgoing
|
||||
.send_request_to_connections(
|
||||
Some(&connection_ids),
|
||||
ServerRequestPayload::AttestationGenerate(AttestationGenerateParams {}),
|
||||
/*thread_id*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let result = match timeout(timeout_duration, rx).await {
|
||||
Ok(Ok(Ok(result))) => result,
|
||||
Ok(Ok(Err(err))) => {
|
||||
warn!(
|
||||
code = err.code,
|
||||
message = %err.message,
|
||||
"attestation generation request failed"
|
||||
);
|
||||
return app_server_attestation_header_value(
|
||||
AppServerAttestationStatus::RequestFailed,
|
||||
/*token*/ None,
|
||||
);
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
warn!("attestation generation request canceled: {err}");
|
||||
return app_server_attestation_header_value(
|
||||
AppServerAttestationStatus::RequestCanceled,
|
||||
/*token*/ None,
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
let _canceled = outgoing.cancel_request(&request_id).await;
|
||||
warn!(
|
||||
timeout_seconds = timeout_duration.as_secs(),
|
||||
"attestation generation request timed out"
|
||||
);
|
||||
return app_server_attestation_header_value(
|
||||
AppServerAttestationStatus::Timeout,
|
||||
/*token*/ None,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::from_value::<AttestationGenerateResponse>(result) {
|
||||
Ok(response) => app_server_attestation_header_value(
|
||||
AppServerAttestationStatus::Ok,
|
||||
Some(&response.token),
|
||||
),
|
||||
Err(err) => {
|
||||
warn!("failed to deserialize attestation generation response: {err}");
|
||||
app_server_attestation_header_value(
|
||||
AppServerAttestationStatus::MalformedResponse,
|
||||
/*token*/ None,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AppServerAttestationStatus {
|
||||
Ok,
|
||||
Timeout,
|
||||
RequestFailed,
|
||||
RequestCanceled,
|
||||
MalformedResponse,
|
||||
}
|
||||
|
||||
impl AppServerAttestationStatus {
|
||||
const fn code(self) -> u8 {
|
||||
match self {
|
||||
Self::Ok => 0,
|
||||
Self::Timeout => 1,
|
||||
Self::RequestFailed => 2,
|
||||
Self::RequestCanceled => 3,
|
||||
Self::MalformedResponse => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AppServerAttestationEnvelope<'a> {
|
||||
v: u8,
|
||||
s: u8,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
t: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn app_server_attestation_header_value(
|
||||
status: AppServerAttestationStatus,
|
||||
token: Option<&str>,
|
||||
) -> Option<String> {
|
||||
serde_json::to_string(&AppServerAttestationEnvelope {
|
||||
v: 1,
|
||||
s: status.code(),
|
||||
t: token,
|
||||
})
|
||||
.map_err(|err| warn!("failed to serialize app-server attestation envelope: {err}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppServerAttestationStatus;
|
||||
use super::app_server_attestation_header_value;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn app_server_attestation_header_value_wraps_opaque_client_payloads() {
|
||||
assert_eq!(
|
||||
app_server_attestation_header_value(
|
||||
AppServerAttestationStatus::Ok,
|
||||
Some("v1.opaque-client-payload"),
|
||||
),
|
||||
Some(r#"{"v":1,"s":0,"t":"v1.opaque-client-payload"}"#.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_server_attestation_header_value_reports_app_server_failures() {
|
||||
assert_eq!(
|
||||
app_server_attestation_header_value(
|
||||
AppServerAttestationStatus::Timeout,
|
||||
/*token*/ None,
|
||||
),
|
||||
Some(r#"{"v":1,"s":1}"#.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
app_server_attestation_header_value(
|
||||
AppServerAttestationStatus::RequestFailed,
|
||||
/*token*/ None,
|
||||
),
|
||||
Some(r#"{"v":1,"s":2}"#.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
app_server_attestation_header_value(
|
||||
AppServerAttestationStatus::RequestCanceled,
|
||||
/*token*/ None,
|
||||
),
|
||||
Some(r#"{"v":1,"s":3}"#.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
app_server_attestation_header_value(
|
||||
AppServerAttestationStatus::MalformedResponse,
|
||||
/*token*/ None
|
||||
),
|
||||
Some(r#"{"v":1,"s":4}"#.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,6 @@ use codex_app_server_protocol::RawResponseItemCompletedNotification;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_app_server_protocol::SkillsChangedNotification;
|
||||
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadRealtimeClosedNotification;
|
||||
@@ -194,13 +193,6 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
EventMsg::SkillsUpdateAvailable => {
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::SkillsChanged(
|
||||
SkillsChangedNotification {},
|
||||
))
|
||||
.await;
|
||||
}
|
||||
EventMsg::McpStartupUpdate(update) => {
|
||||
let (status, error) = match update.status {
|
||||
codex_protocol::protocol::McpStartupStatus::Starting => {
|
||||
|
||||
@@ -8,12 +8,12 @@ use codex_app_server_protocol::FsWatchParams;
|
||||
use codex_app_server_protocol::FsWatchResponse;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_core::file_watcher::FileWatcher;
|
||||
use codex_core::file_watcher::FileWatcherEvent;
|
||||
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_file_watcher::FileWatcher;
|
||||
use codex_file_watcher::FileWatcherEvent;
|
||||
use codex_file_watcher::FileWatcherSubscriber;
|
||||
use codex_file_watcher::Receiver;
|
||||
use codex_file_watcher::WatchPath;
|
||||
use codex_file_watcher::WatchRegistration;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
@@ -74,6 +74,7 @@ use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
mod analytics_utils;
|
||||
mod app_server_tracing;
|
||||
mod attestation;
|
||||
mod bespoke_event_handling;
|
||||
mod command_exec;
|
||||
mod config;
|
||||
@@ -93,6 +94,7 @@ mod outgoing_message;
|
||||
mod request_processors;
|
||||
mod request_serialization;
|
||||
mod server_request_error;
|
||||
mod skills_watcher;
|
||||
mod thread_state;
|
||||
mod thread_status;
|
||||
mod transport;
|
||||
@@ -159,22 +161,33 @@ enum ShutdownAction {
|
||||
Finish,
|
||||
}
|
||||
|
||||
async fn shutdown_signal() -> IoResult<()> {
|
||||
#[derive(Clone, Copy)]
|
||||
enum ShutdownSignal {
|
||||
Forceable,
|
||||
#[cfg(unix)]
|
||||
GracefulOnly,
|
||||
}
|
||||
|
||||
async fn shutdown_signal() -> IoResult<ShutdownSignal> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::SignalKind;
|
||||
use tokio::signal::unix::signal;
|
||||
|
||||
let mut term = signal(SignalKind::terminate())?;
|
||||
let mut hangup = signal(SignalKind::hangup())?;
|
||||
tokio::select! {
|
||||
ctrl_c_result = tokio::signal::ctrl_c() => ctrl_c_result,
|
||||
_ = term.recv() => Ok(()),
|
||||
ctrl_c_result = tokio::signal::ctrl_c() => ctrl_c_result.map(|_| ShutdownSignal::Forceable),
|
||||
_ = term.recv() => Ok(ShutdownSignal::Forceable),
|
||||
_ = hangup.recv() => Ok(ShutdownSignal::GracefulOnly),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::signal::ctrl_c().await
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.map(|_| ShutdownSignal::Forceable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,9 +200,16 @@ impl ShutdownState {
|
||||
self.forced
|
||||
}
|
||||
|
||||
fn on_signal(&mut self, connection_count: usize, running_turn_count: usize) {
|
||||
fn on_signal(
|
||||
&mut self,
|
||||
signal: ShutdownSignal,
|
||||
connection_count: usize,
|
||||
running_turn_count: usize,
|
||||
) {
|
||||
if self.requested {
|
||||
self.forced = true;
|
||||
if matches!(signal, ShutdownSignal::Forceable) {
|
||||
self.forced = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -813,11 +833,15 @@ pub async fn run_main_with_transport_options(
|
||||
|
||||
tokio::select! {
|
||||
shutdown_signal_result = shutdown_signal(), if graceful_signal_restart_enabled && !shutdown_state.forced() => {
|
||||
if let Err(err) = shutdown_signal_result {
|
||||
warn!("failed to listen for shutdown signal during graceful restart drain: {err}");
|
||||
}
|
||||
let signal = match shutdown_signal_result {
|
||||
Ok(signal) => signal,
|
||||
Err(err) => {
|
||||
warn!("failed to listen for shutdown signal during graceful restart drain: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let running_turn_count = *running_turn_count_rx.borrow();
|
||||
shutdown_state.on_signal(connections.len(), running_turn_count);
|
||||
shutdown_state.on_signal(signal, connections.len(), running_turn_count);
|
||||
}
|
||||
changed = running_turn_count_rx.changed(), if graceful_signal_restart_enabled && shutdown_state.requested() => {
|
||||
if changed.is_err() {
|
||||
@@ -938,7 +962,14 @@ pub async fn run_main_with_transport_options(
|
||||
),
|
||||
)
|
||||
.await;
|
||||
processor.connection_initialized(connection_id).await;
|
||||
processor
|
||||
.connection_initialized(
|
||||
connection_id,
|
||||
connection_state
|
||||
.session
|
||||
.request_attestation(),
|
||||
)
|
||||
.await;
|
||||
connection_state
|
||||
.outbound_initialized
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
|
||||
@@ -187,6 +187,7 @@ mod tests {
|
||||
thread_store,
|
||||
Some(state_db.clone()),
|
||||
"11111111-1111-4111-8111-111111111111".to_string(),
|
||||
/*attestation_provider*/ None,
|
||||
));
|
||||
thread_manager.start_thread(good_config).await?;
|
||||
thread_manager.start_thread(bad_config).await?;
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use crate::attestation::app_server_attestation_provider;
|
||||
use crate::config_manager::ConfigManager;
|
||||
use crate::connection_rpc_gate::ConnectionRpcGate;
|
||||
use crate::error_code::invalid_request;
|
||||
@@ -17,6 +18,7 @@ use crate::request_processors::AppsRequestProcessor;
|
||||
use crate::request_processors::CatalogRequestProcessor;
|
||||
use crate::request_processors::CommandExecRequestProcessor;
|
||||
use crate::request_processors::ConfigRequestProcessor;
|
||||
use crate::request_processors::EnvironmentRequestProcessor;
|
||||
use crate::request_processors::ExternalAgentConfigRequestProcessor;
|
||||
use crate::request_processors::FeedbackRequestProcessor;
|
||||
use crate::request_processors::FsRequestProcessor;
|
||||
@@ -34,6 +36,8 @@ use crate::request_processors::WindowsSandboxRequestProcessor;
|
||||
use crate::request_serialization::QueuedInitializedRequest;
|
||||
use crate::request_serialization::RequestSerializationQueueKey;
|
||||
use crate::request_serialization::RequestSerializationQueues;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::thread_state::ConnectionCapabilities;
|
||||
use crate::thread_state::ThreadStateManager;
|
||||
use crate::transport::AppServerTransport;
|
||||
use crate::transport::RemoteControlHandle;
|
||||
@@ -82,6 +86,7 @@ use tokio::time::timeout;
|
||||
use tracing::Instrument;
|
||||
|
||||
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExternalAuthRefreshBridge {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
@@ -158,6 +163,7 @@ pub(crate) struct MessageProcessor {
|
||||
command_exec_processor: CommandExecRequestProcessor,
|
||||
process_exec_processor: ProcessExecRequestProcessor,
|
||||
config_processor: ConfigRequestProcessor,
|
||||
environment_processor: EnvironmentRequestProcessor,
|
||||
external_agent_config_processor: ExternalAgentConfigRequestProcessor,
|
||||
feedback_processor: FeedbackRequestProcessor,
|
||||
fs_processor: FsRequestProcessor,
|
||||
@@ -186,6 +192,7 @@ pub(crate) struct InitializedConnectionSessionState {
|
||||
pub(crate) opted_out_notification_methods: HashSet<String>,
|
||||
pub(crate) app_server_client_name: String,
|
||||
pub(crate) client_version: String,
|
||||
pub(crate) request_attestation: bool,
|
||||
}
|
||||
|
||||
impl Default for ConnectionSessionState {
|
||||
@@ -231,6 +238,12 @@ impl ConnectionSessionState {
|
||||
.map(|session| session.client_version.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn request_attestation(&self) -> bool {
|
||||
self.initialized
|
||||
.get()
|
||||
.is_some_and(|session| session.request_attestation)
|
||||
}
|
||||
|
||||
pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> {
|
||||
self.initialized.set(session).map_err(|_| ())
|
||||
}
|
||||
@@ -280,6 +293,7 @@ impl MessageProcessor {
|
||||
auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge {
|
||||
outgoing: outgoing.clone(),
|
||||
}));
|
||||
let thread_state_manager = ThreadStateManager::new();
|
||||
// The thread store is intentionally process-scoped. Config reloads can
|
||||
// affect per-thread behavior, but they must not move newly started,
|
||||
// resumed, or forked threads to a different persistence backend/root.
|
||||
@@ -293,13 +307,17 @@ impl MessageProcessor {
|
||||
Arc::clone(&thread_store),
|
||||
state_db.clone(),
|
||||
installation_id,
|
||||
Some(app_server_attestation_provider(
|
||||
outgoing.clone(),
|
||||
thread_state_manager.clone(),
|
||||
)),
|
||||
));
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.set_analytics_events_client(analytics_events_client.clone());
|
||||
let skills_watcher = SkillsWatcher::new(thread_manager.skills_manager(), outgoing.clone());
|
||||
|
||||
let pending_thread_unloads = Arc::new(Mutex::new(HashSet::new()));
|
||||
let thread_state_manager = ThreadStateManager::new();
|
||||
let thread_watch_manager =
|
||||
crate::thread_status::ThreadWatchManager::new_with_outgoing(outgoing.clone());
|
||||
let thread_list_state_permit = Arc::new(Semaphore::new(/*permits*/ 1));
|
||||
@@ -389,6 +407,7 @@ impl MessageProcessor {
|
||||
Arc::clone(&thread_list_state_permit),
|
||||
thread_goal_processor.clone(),
|
||||
state_db,
|
||||
Arc::clone(&skills_watcher),
|
||||
);
|
||||
let turn_processor = TurnRequestProcessor::new(
|
||||
auth_manager.clone(),
|
||||
@@ -402,6 +421,7 @@ impl MessageProcessor {
|
||||
thread_state_manager,
|
||||
thread_watch_manager,
|
||||
thread_list_state_permit,
|
||||
Arc::clone(&skills_watcher),
|
||||
);
|
||||
if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) {
|
||||
// Keep plugin startup warmups aligned at app-server startup.
|
||||
@@ -432,6 +452,8 @@ impl MessageProcessor {
|
||||
arg0_paths,
|
||||
config.codex_home.to_path_buf(),
|
||||
);
|
||||
let environment_processor =
|
||||
EnvironmentRequestProcessor::new(thread_manager.environment_manager());
|
||||
let fs_processor = FsRequestProcessor::new(
|
||||
thread_manager
|
||||
.environment_manager()
|
||||
@@ -453,6 +475,7 @@ impl MessageProcessor {
|
||||
command_exec_processor,
|
||||
process_exec_processor,
|
||||
config_processor,
|
||||
environment_processor,
|
||||
external_agent_config_processor,
|
||||
feedback_processor,
|
||||
fs_processor,
|
||||
@@ -620,9 +643,18 @@ impl MessageProcessor {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) {
|
||||
pub(crate) async fn connection_initialized(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
request_attestation: bool,
|
||||
) {
|
||||
self.thread_processor
|
||||
.connection_initialized(connection_id)
|
||||
.connection_initialized(
|
||||
connection_id,
|
||||
ConnectionCapabilities {
|
||||
request_attestation,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -718,7 +750,12 @@ impl MessageProcessor {
|
||||
.await?;
|
||||
if connection_initialized {
|
||||
self.thread_processor
|
||||
.connection_initialized(connection_id)
|
||||
.connection_initialized(
|
||||
connection_id,
|
||||
ConnectionCapabilities {
|
||||
request_attestation: session.request_attestation(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Ok(());
|
||||
@@ -850,6 +887,9 @@ impl MessageProcessor {
|
||||
.config_requirements_read()
|
||||
.await
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::EnvironmentAdd { params, .. } => {
|
||||
self.environment_processor.environment_add(params).await
|
||||
}
|
||||
ClientRequest::FsReadFile { params, .. } => self
|
||||
.fs_processor
|
||||
.read_file(params)
|
||||
|
||||
@@ -267,7 +267,7 @@ impl OutgoingMessageSender {
|
||||
RequestId::Integer(self.next_server_request_id.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
async fn send_request_to_connections(
|
||||
pub(crate) async fn send_request_to_connections(
|
||||
&self,
|
||||
connection_ids: Option<&[ConnectionId]>,
|
||||
request: ServerRequestPayload,
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::outgoing_message::ConnectionRequestId;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::outgoing_message::RequestContext;
|
||||
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::thread_status::ThreadWatchManager;
|
||||
use crate::thread_status::resolve_thread_status;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
@@ -49,6 +50,8 @@ use codex_app_server_protocol::ConversationGitInfo;
|
||||
use codex_app_server_protocol::ConversationSummary;
|
||||
use codex_app_server_protocol::DeprecationNoticeNotification;
|
||||
use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec;
|
||||
use codex_app_server_protocol::EnvironmentAddParams;
|
||||
use codex_app_server_protocol::EnvironmentAddResponse;
|
||||
use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature;
|
||||
use codex_app_server_protocol::ExperimentalFeatureListParams;
|
||||
use codex_app_server_protocol::ExperimentalFeatureListResponse;
|
||||
@@ -434,6 +437,7 @@ mod apps_processor;
|
||||
mod catalog_processor;
|
||||
mod command_exec_processor;
|
||||
mod config_processor;
|
||||
mod environment_processor;
|
||||
mod external_agent_config_processor;
|
||||
mod feedback_processor;
|
||||
mod fs_processor;
|
||||
@@ -454,6 +458,7 @@ pub(crate) use apps_processor::AppsRequestProcessor;
|
||||
pub(crate) use catalog_processor::CatalogRequestProcessor;
|
||||
pub(crate) use command_exec_processor::CommandExecRequestProcessor;
|
||||
pub(crate) use config_processor::ConfigRequestProcessor;
|
||||
pub(crate) use environment_processor::EnvironmentRequestProcessor;
|
||||
pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessor;
|
||||
pub(crate) use feedback_processor::FeedbackRequestProcessor;
|
||||
pub(crate) use fs_processor::FsRequestProcessor;
|
||||
@@ -473,6 +478,7 @@ use crate::error_code::internal_error;
|
||||
use crate::error_code::invalid_request;
|
||||
use crate::filters::compute_source_filters;
|
||||
use crate::filters::source_kind_matches;
|
||||
use crate::thread_state::ConnectionCapabilities;
|
||||
use crate::thread_state::ThreadListenerCommand;
|
||||
use crate::thread_state::ThreadState;
|
||||
use crate::thread_state::ThreadStateManager;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct EnvironmentRequestProcessor {
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
}
|
||||
|
||||
impl EnvironmentRequestProcessor {
|
||||
pub(crate) fn new(environment_manager: Arc<EnvironmentManager>) -> Self {
|
||||
Self {
|
||||
environment_manager,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn environment_add(
|
||||
&self,
|
||||
params: EnvironmentAddParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.environment_manager
|
||||
.upsert_environment(params.environment_id, params.exec_server_url)
|
||||
.map_err(|err| invalid_request(err.to_string()))?;
|
||||
Ok(Some(EnvironmentAddResponse {}.into()))
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ use super::*;
|
||||
use crate::message_processor::ConnectionSessionState;
|
||||
use crate::message_processor::InitializedConnectionSessionState;
|
||||
|
||||
const DAEMON_PROBE_CLIENT_NAME: &str = "codex_app_server_daemon";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct InitializeRequestProcessor {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
@@ -65,15 +67,17 @@ impl InitializeRequestProcessor {
|
||||
// experimental API). Proposed direction is instance-global first-write-wins
|
||||
// with initialize-time mismatch rejection.
|
||||
let analytics_initialize_params = params.clone();
|
||||
let (experimental_api_enabled, opt_out_notification_methods) = match params.capabilities {
|
||||
Some(capabilities) => (
|
||||
capabilities.experimental_api,
|
||||
capabilities
|
||||
.opt_out_notification_methods
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
None => (false, Vec::new()),
|
||||
};
|
||||
let (experimental_api_enabled, request_attestation, opt_out_notification_methods) =
|
||||
match params.capabilities {
|
||||
Some(capabilities) => (
|
||||
capabilities.experimental_api,
|
||||
capabilities.request_attestation,
|
||||
capabilities
|
||||
.opt_out_notification_methods
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
None => (false, false, Vec::new()),
|
||||
};
|
||||
let ClientInfo {
|
||||
name,
|
||||
title: _title,
|
||||
@@ -88,6 +92,7 @@ impl InitializeRequestProcessor {
|
||||
}
|
||||
let originator = name.clone();
|
||||
let user_agent_suffix = format!("{name}; {version}");
|
||||
let mutates_global_identity = name != DAEMON_PROBE_CLIENT_NAME;
|
||||
let codex_home = self.config.codex_home.clone();
|
||||
if session
|
||||
.initialize(InitializedConnectionSessionState {
|
||||
@@ -95,27 +100,29 @@ impl InitializeRequestProcessor {
|
||||
opted_out_notification_methods: opt_out_notification_methods.into_iter().collect(),
|
||||
app_server_client_name: name.clone(),
|
||||
client_version: version,
|
||||
request_attestation,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return Err(invalid_request("Already initialized"));
|
||||
}
|
||||
|
||||
// Only the request that wins session initialization may mutate
|
||||
// process-global client metadata.
|
||||
if let Err(error) = set_default_originator(originator.clone()) {
|
||||
match error {
|
||||
SetOriginatorError::InvalidHeaderValue => {
|
||||
tracing::warn!(
|
||||
client_info_name = %name,
|
||||
"validated clientInfo.name was rejected while setting originator"
|
||||
);
|
||||
}
|
||||
SetOriginatorError::AlreadyInitialized => {
|
||||
// No-op. This is expected to happen if the originator is already set via env var.
|
||||
// TODO(owen): Once we remove support for CODEX_INTERNAL_ORIGINATOR_OVERRIDE,
|
||||
// this will be an unexpected state and we can return a JSON-RPC error indicating
|
||||
// internal server error.
|
||||
if mutates_global_identity {
|
||||
// Only real client initialization may mutate process-global client metadata.
|
||||
if let Err(error) = set_default_originator(originator.clone()) {
|
||||
match error {
|
||||
SetOriginatorError::InvalidHeaderValue => {
|
||||
tracing::warn!(
|
||||
client_info_name = %name,
|
||||
"validated clientInfo.name was rejected while setting originator"
|
||||
);
|
||||
}
|
||||
SetOriginatorError::AlreadyInitialized => {
|
||||
// No-op. This is expected to happen if the originator is already set via env var.
|
||||
// TODO(owen): Once we remove support for CODEX_INTERNAL_ORIGINATOR_OVERRIDE,
|
||||
// this will be an unexpected state and we can return a JSON-RPC error indicating
|
||||
// internal server error.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,7 +133,7 @@ impl InitializeRequestProcessor {
|
||||
self.rpc_transport,
|
||||
);
|
||||
set_default_client_residency_requirement(self.config.enforce_residency.value());
|
||||
if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() {
|
||||
if mutates_global_identity && let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() {
|
||||
*suffix = Some(user_agent_suffix);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) struct ListenerTaskContext {
|
||||
pub(super) thread_list_state_permit: Arc<Semaphore>,
|
||||
pub(super) fallback_model_provider: String,
|
||||
pub(super) codex_home: PathBuf,
|
||||
pub(super) skills_watcher: Arc<SkillsWatcher>,
|
||||
}
|
||||
|
||||
struct UnloadingState {
|
||||
@@ -226,12 +227,22 @@ pub(super) async fn ensure_listener_task_running(
|
||||
"thread {conversation_id} is closing; retry after the thread is closed"
|
||||
)));
|
||||
};
|
||||
let config = conversation.config().await;
|
||||
let environments = conversation.environment_selections().await;
|
||||
let watch_registration = listener_task_context
|
||||
.skills_watcher
|
||||
.register_thread_config(
|
||||
config.as_ref(),
|
||||
listener_task_context.thread_manager.as_ref(),
|
||||
&environments,
|
||||
)
|
||||
.await;
|
||||
let (mut listener_command_rx, listener_generation) = {
|
||||
let mut thread_state = thread_state.lock().await;
|
||||
if thread_state.listener_matches(&conversation) {
|
||||
return Ok(());
|
||||
}
|
||||
thread_state.set_listener(cancel_tx, &conversation)
|
||||
thread_state.set_listener(cancel_tx, &conversation, watch_registration)
|
||||
};
|
||||
let ListenerTaskContext {
|
||||
outgoing,
|
||||
@@ -242,6 +253,7 @@ pub(super) async fn ensure_listener_task_running(
|
||||
thread_list_state_permit,
|
||||
fallback_model_provider,
|
||||
codex_home,
|
||||
..
|
||||
} = listener_task_context;
|
||||
let outgoing_for_task = Arc::clone(&outgoing);
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -317,6 +317,7 @@ pub(crate) struct ThreadRequestProcessor {
|
||||
pub(super) thread_goal_processor: ThreadGoalRequestProcessor,
|
||||
pub(super) state_db: Option<StateDbHandle>,
|
||||
pub(super) background_tasks: TaskTracker,
|
||||
pub(super) skills_watcher: Arc<SkillsWatcher>,
|
||||
}
|
||||
|
||||
impl ThreadRequestProcessor {
|
||||
@@ -335,6 +336,7 @@ impl ThreadRequestProcessor {
|
||||
thread_list_state_permit: Arc<Semaphore>,
|
||||
thread_goal_processor: ThreadGoalRequestProcessor,
|
||||
state_db: Option<StateDbHandle>,
|
||||
skills_watcher: Arc<SkillsWatcher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
@@ -351,6 +353,7 @@ impl ThreadRequestProcessor {
|
||||
thread_goal_processor,
|
||||
state_db,
|
||||
background_tasks: TaskTracker::new(),
|
||||
skills_watcher,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,6 +755,7 @@ impl ThreadRequestProcessor {
|
||||
thread_list_state_permit: self.thread_list_state_permit.clone(),
|
||||
fallback_model_provider: self.config.model_provider_id.clone(),
|
||||
codex_home: self.config.codex_home.to_path_buf(),
|
||||
skills_watcher: Arc::clone(&self.skills_watcher),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -849,6 +853,7 @@ impl ThreadRequestProcessor {
|
||||
thread_list_state_permit: self.thread_list_state_permit.clone(),
|
||||
fallback_model_provider: self.config.model_provider_id.clone(),
|
||||
codex_home: self.config.codex_home.to_path_buf(),
|
||||
skills_watcher: Arc::clone(&self.skills_watcher),
|
||||
};
|
||||
let request_trace = request_context.request_trace();
|
||||
let config_manager = self.config_manager.clone();
|
||||
@@ -1049,7 +1054,6 @@ impl ThreadRequestProcessor {
|
||||
.collect()
|
||||
};
|
||||
let core_dynamic_tool_count = core_dynamic_tools.len();
|
||||
|
||||
let NewThread {
|
||||
thread_id,
|
||||
thread,
|
||||
@@ -2230,9 +2234,13 @@ impl ThreadRequestProcessor {
|
||||
self.thread_manager.subscribe_thread_created()
|
||||
}
|
||||
|
||||
pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) {
|
||||
pub(crate) async fn connection_initialized(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
capabilities: ConnectionCapabilities,
|
||||
) {
|
||||
self.thread_state_manager
|
||||
.connection_initialized(connection_id)
|
||||
.connection_initialized(connection_id, capabilities)
|
||||
.await;
|
||||
}
|
||||
|
||||
|
||||
@@ -1115,7 +1115,9 @@ mod thread_processor_behavior_tests {
|
||||
let connection = ConnectionId(1);
|
||||
let (cancel_tx, cancel_rx) = oneshot::channel();
|
||||
|
||||
manager.connection_initialized(connection).await;
|
||||
manager
|
||||
.connection_initialized(connection, ConnectionCapabilities::default())
|
||||
.await;
|
||||
manager
|
||||
.try_ensure_connection_subscribed(
|
||||
thread_id, connection, /*experimental_raw_events*/ false,
|
||||
@@ -1158,8 +1160,12 @@ mod thread_processor_behavior_tests {
|
||||
let connection_b = ConnectionId(2);
|
||||
let (cancel_tx, mut cancel_rx) = oneshot::channel();
|
||||
|
||||
manager.connection_initialized(connection_a).await;
|
||||
manager.connection_initialized(connection_b).await;
|
||||
manager
|
||||
.connection_initialized(connection_a, ConnectionCapabilities::default())
|
||||
.await;
|
||||
manager
|
||||
.connection_initialized(connection_b, ConnectionCapabilities::default())
|
||||
.await;
|
||||
manager
|
||||
.try_ensure_connection_subscribed(
|
||||
thread_id,
|
||||
@@ -1203,8 +1209,12 @@ mod thread_processor_behavior_tests {
|
||||
let connection_a = ConnectionId(1);
|
||||
let connection_b = ConnectionId(2);
|
||||
|
||||
manager.connection_initialized(connection_a).await;
|
||||
manager.connection_initialized(connection_b).await;
|
||||
manager
|
||||
.connection_initialized(connection_a, ConnectionCapabilities::default())
|
||||
.await;
|
||||
manager
|
||||
.connection_initialized(connection_b, ConnectionCapabilities::default())
|
||||
.await;
|
||||
manager
|
||||
.try_ensure_connection_subscribed(
|
||||
thread_id,
|
||||
@@ -1249,7 +1259,9 @@ mod thread_processor_behavior_tests {
|
||||
let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?;
|
||||
let connection = ConnectionId(1);
|
||||
|
||||
manager.connection_initialized(connection).await;
|
||||
manager
|
||||
.connection_initialized(connection, ConnectionCapabilities::default())
|
||||
.await;
|
||||
let threads_to_unload = manager.remove_connection(connection).await;
|
||||
assert_eq!(threads_to_unload, Vec::<ThreadId>::new());
|
||||
|
||||
@@ -1264,4 +1276,79 @@ mod thread_processor_behavior_tests {
|
||||
assert!(!manager.has_subscribers(thread_id).await);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_attestation_capable_connection_for_thread_only_uses_thread_subscribers()
|
||||
-> Result<()> {
|
||||
let manager = ThreadStateManager::new();
|
||||
let thread_id = ThreadId::from_string("dfbd9a95-2f44-470a-8bd8-1cfc04efc243")?;
|
||||
let other_thread_id = ThreadId::from_string("6c9a74e4-5e59-479e-90bf-5c5798bb50aa")?;
|
||||
let unrelated_supported_connection = ConnectionId(1);
|
||||
let earlier_supported_connection = ConnectionId(2);
|
||||
let later_supported_connection = ConnectionId(3);
|
||||
let unsupported_connection = ConnectionId(4);
|
||||
|
||||
manager
|
||||
.connection_initialized(
|
||||
unrelated_supported_connection,
|
||||
ConnectionCapabilities {
|
||||
request_attestation: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
manager
|
||||
.connection_initialized(
|
||||
earlier_supported_connection,
|
||||
ConnectionCapabilities {
|
||||
request_attestation: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
manager
|
||||
.connection_initialized(
|
||||
later_supported_connection,
|
||||
ConnectionCapabilities {
|
||||
request_attestation: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
manager
|
||||
.connection_initialized(unsupported_connection, ConnectionCapabilities::default())
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
manager
|
||||
.try_add_connection_to_thread(other_thread_id, unrelated_supported_connection)
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
manager
|
||||
.try_add_connection_to_thread(thread_id, later_supported_connection)
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
manager
|
||||
.try_add_connection_to_thread(thread_id, earlier_supported_connection)
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
manager
|
||||
.try_add_connection_to_thread(thread_id, unsupported_connection)
|
||||
.await
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
manager
|
||||
.first_attestation_capable_connection_for_thread(thread_id)
|
||||
.await,
|
||||
Some(earlier_supported_connection)
|
||||
);
|
||||
assert_eq!(
|
||||
manager
|
||||
.first_attestation_capable_connection_for_thread(other_thread_id)
|
||||
.await,
|
||||
Some(unrelated_supported_connection)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub(crate) struct TurnRequestProcessor {
|
||||
thread_state_manager: ThreadStateManager,
|
||||
thread_watch_manager: ThreadWatchManager,
|
||||
thread_list_state_permit: Arc<Semaphore>,
|
||||
skills_watcher: Arc<SkillsWatcher>,
|
||||
}
|
||||
|
||||
impl TurnRequestProcessor {
|
||||
@@ -29,6 +30,7 @@ impl TurnRequestProcessor {
|
||||
thread_state_manager: ThreadStateManager,
|
||||
thread_watch_manager: ThreadWatchManager,
|
||||
thread_list_state_permit: Arc<Semaphore>,
|
||||
skills_watcher: Arc<SkillsWatcher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
@@ -42,6 +44,7 @@ impl TurnRequestProcessor {
|
||||
thread_state_manager,
|
||||
thread_watch_manager,
|
||||
thread_list_state_permit,
|
||||
skills_watcher,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,6 +1090,7 @@ impl TurnRequestProcessor {
|
||||
thread_list_state_permit: self.thread_list_state_permit.clone(),
|
||||
fallback_model_provider: self.config.model_provider_id.clone(),
|
||||
codex_home: self.config.codex_home.to_path_buf(),
|
||||
skills_watcher: Arc::clone(&self.skills_watcher),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::SkillsChangedNotification;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::skills::SkillsLoadInput;
|
||||
use codex_core::skills::SkillsManager;
|
||||
use codex_file_watcher::FileWatcher;
|
||||
use codex_file_watcher::FileWatcherSubscriber;
|
||||
use codex_file_watcher::Receiver;
|
||||
use codex_file_watcher::ThrottledWatchReceiver;
|
||||
use codex_file_watcher::WatchPath;
|
||||
use codex_file_watcher::WatchRegistration;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use tracing::warn;
|
||||
|
||||
#[cfg(not(test))]
|
||||
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
#[cfg(test)]
|
||||
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
pub(crate) struct SkillsWatcher {
|
||||
subscriber: FileWatcherSubscriber,
|
||||
}
|
||||
|
||||
impl SkillsWatcher {
|
||||
pub(crate) fn new(
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
) -> Arc<Self> {
|
||||
let file_watcher = match FileWatcher::new() {
|
||||
Ok(file_watcher) => Arc::new(file_watcher),
|
||||
Err(err) => {
|
||||
warn!("failed to initialize skills file watcher: {err}");
|
||||
Arc::new(FileWatcher::noop())
|
||||
}
|
||||
};
|
||||
let (subscriber, rx) = file_watcher.add_subscriber();
|
||||
Self::spawn_event_loop(rx, skills_manager, outgoing);
|
||||
Arc::new(Self { subscriber })
|
||||
}
|
||||
|
||||
pub(crate) async fn register_thread_config(
|
||||
&self,
|
||||
config: &Config,
|
||||
thread_manager: &ThreadManager,
|
||||
environments: &[TurnEnvironmentSelection],
|
||||
) -> WatchRegistration {
|
||||
let Some(environment_selection) = environments.first() else {
|
||||
return WatchRegistration::default();
|
||||
};
|
||||
let Some(environment) = thread_manager
|
||||
.environment_manager()
|
||||
.get_environment(&environment_selection.environment_id)
|
||||
else {
|
||||
warn!(
|
||||
"failed to register skills watcher for unknown environment `{}`",
|
||||
environment_selection.environment_id
|
||||
);
|
||||
return WatchRegistration::default();
|
||||
};
|
||||
if environment.is_remote() {
|
||||
return WatchRegistration::default();
|
||||
}
|
||||
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let plugins_manager = thread_manager.plugins_manager();
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
let skills_input = SkillsLoadInput::new(
|
||||
config.cwd.clone(),
|
||||
plugin_outcome.effective_plugin_skill_roots(),
|
||||
config.config_layer_stack.clone(),
|
||||
config.bundled_skills_enabled(),
|
||||
);
|
||||
let roots = thread_manager
|
||||
.skills_manager()
|
||||
.skill_roots_for_config(&skills_input, Some(environment.get_filesystem()))
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|root| WatchPath {
|
||||
path: root.path.into_path_buf(),
|
||||
recursive: true,
|
||||
})
|
||||
.collect();
|
||||
self.subscriber.register_paths(roots)
|
||||
}
|
||||
|
||||
fn spawn_event_loop(
|
||||
rx: Receiver,
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
) {
|
||||
let mut rx = ThrottledWatchReceiver::new(rx, WATCHER_THROTTLE_INTERVAL);
|
||||
let Ok(handle) = tokio::runtime::Handle::try_current() else {
|
||||
warn!("skills watcher listener skipped: no Tokio runtime available");
|
||||
return;
|
||||
};
|
||||
handle.spawn(async move {
|
||||
while rx.recv().await.is_some() {
|
||||
skills_manager.clear_cache();
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::SkillsChanged(
|
||||
SkillsChangedNotification {},
|
||||
))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnError;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::ThreadConfigSnapshot;
|
||||
use codex_file_watcher::WatchRegistration;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
@@ -77,6 +78,7 @@ pub(crate) struct ThreadState {
|
||||
listener_command_tx: Option<mpsc::UnboundedSender<ThreadListenerCommand>>,
|
||||
current_turn_history: ThreadHistoryBuilder,
|
||||
listener_thread: Option<Weak<CodexThread>>,
|
||||
watch_registration: WatchRegistration,
|
||||
}
|
||||
|
||||
impl ThreadState {
|
||||
@@ -91,6 +93,7 @@ impl ThreadState {
|
||||
&mut self,
|
||||
cancel_tx: oneshot::Sender<()>,
|
||||
conversation: &Arc<CodexThread>,
|
||||
watch_registration: WatchRegistration,
|
||||
) -> (mpsc::UnboundedReceiver<ThreadListenerCommand>, u64) {
|
||||
if let Some(previous) = self.cancel_tx.replace(cancel_tx) {
|
||||
let _ = previous.send(());
|
||||
@@ -99,6 +102,7 @@ impl ThreadState {
|
||||
let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel();
|
||||
self.listener_command_tx = Some(listener_command_tx);
|
||||
self.listener_thread = Some(Arc::downgrade(conversation));
|
||||
self.watch_registration = watch_registration;
|
||||
(listener_command_rx, self.listener_generation)
|
||||
}
|
||||
|
||||
@@ -109,6 +113,7 @@ impl ThreadState {
|
||||
self.listener_command_tx = None;
|
||||
self.current_turn_history.reset();
|
||||
self.listener_thread = None;
|
||||
self.watch_registration = WatchRegistration::default();
|
||||
}
|
||||
|
||||
pub(crate) fn set_experimental_raw_events(&mut self, enabled: bool) {
|
||||
@@ -199,11 +204,16 @@ impl ThreadEntry {
|
||||
|
||||
#[derive(Default)]
|
||||
struct ThreadStateManagerInner {
|
||||
live_connections: HashSet<ConnectionId>,
|
||||
live_connections: HashMap<ConnectionId, ConnectionCapabilities>,
|
||||
threads: HashMap<ThreadId, ThreadEntry>,
|
||||
thread_ids_by_connection: HashMap<ConnectionId, HashSet<ThreadId>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct ConnectionCapabilities {
|
||||
pub(crate) request_attestation: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct ThreadStateManager {
|
||||
state: Arc<Mutex<ThreadStateManagerInner>>,
|
||||
@@ -214,12 +224,36 @@ impl ThreadStateManager {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) {
|
||||
pub(crate) async fn connection_initialized(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
capabilities: ConnectionCapabilities,
|
||||
) {
|
||||
self.state
|
||||
.lock()
|
||||
.await
|
||||
.live_connections
|
||||
.insert(connection_id);
|
||||
.insert(connection_id, capabilities);
|
||||
}
|
||||
|
||||
pub(crate) async fn first_attestation_capable_connection_for_thread(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
) -> Option<ConnectionId> {
|
||||
let state = self.state.lock().await;
|
||||
state
|
||||
.threads
|
||||
.get(&thread_id)?
|
||||
.connection_ids
|
||||
.iter()
|
||||
.filter_map(|connection_id| {
|
||||
state
|
||||
.live_connections
|
||||
.get(connection_id)?
|
||||
.request_attestation
|
||||
.then_some(*connection_id)
|
||||
})
|
||||
.min_by_key(|connection_id| connection_id.0)
|
||||
}
|
||||
|
||||
pub(crate) async fn subscribed_connection_ids(&self, thread_id: ThreadId) -> Vec<ConnectionId> {
|
||||
@@ -338,7 +372,7 @@ impl ThreadStateManager {
|
||||
) -> Option<Arc<Mutex<ThreadState>>> {
|
||||
let thread_state = {
|
||||
let mut state = self.state.lock().await;
|
||||
if !state.live_connections.contains(&connection_id) {
|
||||
if !state.live_connections.contains_key(&connection_id) {
|
||||
return None;
|
||||
}
|
||||
state
|
||||
@@ -366,7 +400,7 @@ impl ThreadStateManager {
|
||||
connection_id: ConnectionId,
|
||||
) -> bool {
|
||||
let mut state = self.state.lock().await;
|
||||
if !state.live_connections.contains(&connection_id) {
|
||||
if !state.live_connections.contains_key(&connection_id) {
|
||||
return false;
|
||||
}
|
||||
state
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::AttestationGenerateResponse;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::InitializeCapabilities;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::WebSocketConnectionConfig;
|
||||
use core_test_support::responses::start_websocket_server_with_headers;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const ATTESTATION_HEADER: &str = "v1.integration-test";
|
||||
const APP_SERVER_ATTESTATION_HEADER: &str = r#"{"v":1,"s":0,"t":"v1.integration-test"}"#;
|
||||
|
||||
#[tokio::test]
|
||||
async fn attestation_generate_round_trip_adds_header_to_responses_websocket_handshake() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let websocket_server = start_websocket_server_with_headers(vec![
|
||||
// App-server refreshes `/models` over HTTP during thread startup. It points at the same
|
||||
// local test base URL, so let that non-websocket probe consume one connection before the
|
||||
// websocket handshake under test arrives.
|
||||
WebSocketConnectionConfig {
|
||||
requests: Vec::new(),
|
||||
response_headers: Vec::new(),
|
||||
accept_delay: None,
|
||||
close_after_requests: true,
|
||||
},
|
||||
WebSocketConnectionConfig {
|
||||
requests: vec![
|
||||
vec![
|
||||
responses::ev_response_created("warm-1"),
|
||||
responses::ev_completed("warm-1"),
|
||||
],
|
||||
vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
],
|
||||
],
|
||||
response_headers: Vec::new(),
|
||||
accept_delay: None,
|
||||
close_after_requests: true,
|
||||
},
|
||||
])
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_chatgpt_websocket_config(
|
||||
codex_home.path(),
|
||||
&websocket_server.uri().replacen("ws://", "http://", 1),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
let initialized = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.initialize_with_capabilities(
|
||||
ClientInfo {
|
||||
name: "codex_desktop".to_string(),
|
||||
title: Some("Codex Desktop".to_string()),
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await??;
|
||||
let JSONRPCMessage::Response(_) = initialized else {
|
||||
bail!("expected initialize response, got {initialized:?}");
|
||||
};
|
||||
|
||||
let thread_request_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams::default())
|
||||
.await?;
|
||||
let thread_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response(thread_response)?;
|
||||
|
||||
let turn_request_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: TurnStartResponse = to_response(turn_response)?;
|
||||
|
||||
let mut attestation_requests = 0;
|
||||
timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
match mcp.read_next_message().await? {
|
||||
JSONRPCMessage::Request(request) => {
|
||||
let request = ServerRequest::try_from(request)?;
|
||||
let ServerRequest::AttestationGenerate { request_id, .. } = request else {
|
||||
bail!("expected attestation/generate request, got {request:?}");
|
||||
};
|
||||
attestation_requests += 1;
|
||||
mcp.send_response(
|
||||
request_id,
|
||||
serde_json::to_value(AttestationGenerateResponse {
|
||||
token: ATTESTATION_HEADER.to_string(),
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
JSONRPCMessage::Notification(notification)
|
||||
if notification.method == "turn/completed" =>
|
||||
{
|
||||
break Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
assert!(attestation_requests > 0);
|
||||
|
||||
assert!(
|
||||
websocket_server
|
||||
.wait_for_handshakes(/*expected*/ 1, DEFAULT_READ_TIMEOUT)
|
||||
.await
|
||||
);
|
||||
let handshake = websocket_server.single_handshake();
|
||||
assert_eq!(
|
||||
handshake.header("x-oai-attestation").as_deref(),
|
||||
Some(APP_SERVER_ATTESTATION_HEADER)
|
||||
);
|
||||
|
||||
websocket_server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_chatgpt_websocket_config(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock ChatGPT provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
requires_openai_auth = true
|
||||
supports_websockets = true
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -133,6 +133,34 @@ async fn websocket_transport_second_sigterm_forces_exit_while_turn_running() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_transport_repeated_sighup_keeps_waiting_for_running_turn() -> Result<()> {
|
||||
let GracefulCtrlCFixture {
|
||||
_codex_home,
|
||||
_server,
|
||||
mut process,
|
||||
mut ws,
|
||||
} = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?;
|
||||
|
||||
send_sighup(&process)?;
|
||||
assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?;
|
||||
|
||||
send_sighup(&process)?;
|
||||
assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?;
|
||||
|
||||
let status = wait_for_process_exit_within(
|
||||
&mut process,
|
||||
Duration::from_secs(10),
|
||||
"timed out waiting for graceful repeated SIGHUP restart shutdown",
|
||||
)
|
||||
.await?;
|
||||
assert!(status.success(), "expected graceful exit, got {status}");
|
||||
|
||||
expect_websocket_disconnect(&mut ws).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct GracefulCtrlCFixture {
|
||||
_codex_home: TempDir,
|
||||
_server: wiremock::MockServer,
|
||||
@@ -236,6 +264,10 @@ fn send_sigterm(process: &Child) -> Result<()> {
|
||||
send_signal(process, "-TERM")
|
||||
}
|
||||
|
||||
fn send_sighup(process: &Child) -> Result<()> {
|
||||
send_signal(process, "-HUP")
|
||||
}
|
||||
|
||||
fn send_signal(process: &Child, signal: &str) -> Result<()> {
|
||||
let pid = process
|
||||
.id()
|
||||
|
||||
@@ -36,6 +36,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
@@ -66,6 +67,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
@@ -103,6 +105,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
@@ -136,6 +139,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
@@ -177,6 +181,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
@@ -214,6 +219,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
@@ -250,6 +256,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -62,6 +62,33 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_probe_does_not_override_originator() -> Result<()> {
|
||||
let responses = Vec::new();
|
||||
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri(), "never")?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
|
||||
let message = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.initialize_with_client_info(ClientInfo {
|
||||
name: "codex_app_server_daemon".to_string(),
|
||||
title: Some("Codex App Server Daemon".to_string()),
|
||||
version: "0.1.0".to_string(),
|
||||
}),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let JSONRPCMessage::Response(response) = message else {
|
||||
anyhow::bail!("expected initialize response, got {message:?}");
|
||||
};
|
||||
let InitializeResponse { user_agent, .. } = to_response::<InitializeResponse>(response)?;
|
||||
|
||||
assert!(user_agent.starts_with("codex_cli_rs/"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_respects_originator_override_env_var() -> Result<()> {
|
||||
let responses = Vec::new();
|
||||
@@ -158,6 +185,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: Some(vec!["thread/started".to_string()]),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod account;
|
||||
mod analytics;
|
||||
mod app_list;
|
||||
mod attestation;
|
||||
mod client_metadata;
|
||||
mod collaboration_mode_list;
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -4,8 +4,10 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_mock_responses_server_repeating_assistant;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
@@ -573,11 +575,39 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> {
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
write_mock_responses_config_toml_with_chatgpt_base_url(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
&server.uri(),
|
||||
)?;
|
||||
write_skill(&codex_home, "demo")?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
let mut mcp =
|
||||
McpProcess::new_with_env(codex_home.path(), &[(CODEX_EXEC_SERVER_URL_ENV_VAR, None)])
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
let initial_skills_request_id = mcp
|
||||
.send_skills_list_request(SkillsListParams {
|
||||
cwds: vec![codex_home.path().to_path_buf()],
|
||||
force_reload: true,
|
||||
})
|
||||
.await?;
|
||||
let initial_skills_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(initial_skills_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let SkillsListResponse { data } = to_response(initial_skills_response)?;
|
||||
assert_eq!(data.len(), 1);
|
||||
assert!(
|
||||
data[0]
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| { skill.name == "demo" && skill.description == "demo description" })
|
||||
);
|
||||
|
||||
let thread_start_request_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: None,
|
||||
@@ -630,5 +660,24 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
|
||||
let notification: SkillsChangedNotification = serde_json::from_value(params)?;
|
||||
|
||||
assert_eq!(notification, SkillsChangedNotification {});
|
||||
let updated_skills_request_id = mcp
|
||||
.send_skills_list_request(SkillsListParams {
|
||||
cwds: vec![codex_home.path().to_path_buf()],
|
||||
force_reload: false,
|
||||
})
|
||||
.await?;
|
||||
let updated_skills_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(updated_skills_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let SkillsListResponse { data } = to_response(updated_skills_response)?;
|
||||
assert_eq!(data.len(), 1);
|
||||
assert!(
|
||||
data[0]
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| skill.name == "demo" && skill.description == "updated")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> {
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1991,7 +1991,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_resolves_sticky_thread_environments_and_turn_overrides() -> Result<()> {
|
||||
async fn turn_start_resolves_sticky_thread_local_environment_and_turn_overrides() -> Result<()> {
|
||||
let tmp = TempDir::new()?;
|
||||
let codex_home = tmp.path().join("codex_home");
|
||||
std::fs::create_dir(&codex_home)?;
|
||||
@@ -2000,12 +2000,16 @@ async fn turn_start_resolves_sticky_thread_environments_and_turn_overrides() ->
|
||||
|
||||
let server = create_mock_responses_server_repeating_assistant("done").await;
|
||||
create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?;
|
||||
std::fs::write(
|
||||
codex_home.join("environments.toml"),
|
||||
r#"
|
||||
[[environments]]
|
||||
id = "remote"
|
||||
url = "ws://127.0.0.1:1"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
&codex_home,
|
||||
&[("CODEX_EXEC_SERVER_URL", Some("http://127.0.0.1:1"))],
|
||||
)
|
||||
.await?;
|
||||
let mut mcp = McpProcess::new(&codex_home).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
for case in [
|
||||
@@ -2024,16 +2028,6 @@ async fn turn_start_resolves_sticky_thread_environments_and_turn_overrides() ->
|
||||
sticky: Some(&["local"]),
|
||||
turn: None,
|
||||
},
|
||||
EnvironmentSelectionCase {
|
||||
name: "sticky_remote_turn_unset",
|
||||
sticky: Some(&["remote"]),
|
||||
turn: None,
|
||||
},
|
||||
EnvironmentSelectionCase {
|
||||
name: "sticky_local_remote_turn_unset",
|
||||
sticky: Some(&["local", "remote"]),
|
||||
turn: None,
|
||||
},
|
||||
EnvironmentSelectionCase {
|
||||
name: "sticky_local_turn_empty",
|
||||
sticky: Some(&["local"]),
|
||||
@@ -2044,21 +2038,6 @@ async fn turn_start_resolves_sticky_thread_environments_and_turn_overrides() ->
|
||||
sticky: Some(&[]),
|
||||
turn: Some(&["local"]),
|
||||
},
|
||||
EnvironmentSelectionCase {
|
||||
name: "sticky_local_turn_remote",
|
||||
sticky: Some(&["local"]),
|
||||
turn: Some(&["remote"]),
|
||||
},
|
||||
EnvironmentSelectionCase {
|
||||
name: "sticky_remote_turn_local",
|
||||
sticky: Some(&["remote"]),
|
||||
turn: Some(&["local"]),
|
||||
},
|
||||
EnvironmentSelectionCase {
|
||||
name: "sticky_unset_turn_local_remote",
|
||||
sticky: None,
|
||||
turn: Some(&["local", "remote"]),
|
||||
},
|
||||
] {
|
||||
run_environment_selection_case(&mut mcp, &workspace, case).await?;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ use std::time::Duration;
|
||||
use crate::chatgpt_client::chatgpt_get_request_with_timeout;
|
||||
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_connectors::AllConnectorsCacheKey;
|
||||
use codex_connectors::ConnectorDirectoryCacheContext;
|
||||
use codex_connectors::ConnectorDirectoryCacheKey;
|
||||
use codex_connectors::DirectoryListResponse;
|
||||
use codex_connectors::filter::filter_disallowed_connectors;
|
||||
use codex_connectors::merge::merge_connectors;
|
||||
@@ -75,8 +76,8 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option<Vec<AppInfo>>
|
||||
}
|
||||
|
||||
let auth = connector_auth(config).await.ok()?;
|
||||
let cache_key = all_connectors_cache_key(config, &auth);
|
||||
let connectors = codex_connectors::cached_all_connectors(&cache_key)?;
|
||||
let cache_context = connector_directory_cache_context(config, &auth);
|
||||
let connectors = codex_connectors::cached_directory_connectors(&cache_context)?;
|
||||
let connectors = merge_plugin_connectors(
|
||||
connectors,
|
||||
plugin_apps_for_config(config)
|
||||
@@ -98,9 +99,9 @@ pub async fn list_all_connectors_with_options(
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let auth = connector_auth(config).await?;
|
||||
let cache_key = all_connectors_cache_key(config, &auth);
|
||||
let cache_context = connector_directory_cache_context(config, &auth);
|
||||
let connectors = codex_connectors::list_all_connectors_with_options(
|
||||
cache_key,
|
||||
cache_context,
|
||||
auth.is_workspace_account(),
|
||||
force_refetch,
|
||||
|path| async move {
|
||||
@@ -126,12 +127,18 @@ pub async fn list_all_connectors_with_options(
|
||||
))
|
||||
}
|
||||
|
||||
fn all_connectors_cache_key(config: &Config, auth: &CodexAuth) -> AllConnectorsCacheKey {
|
||||
AllConnectorsCacheKey::new(
|
||||
config.chatgpt_base_url.clone(),
|
||||
auth.get_account_id(),
|
||||
auth.get_chatgpt_user_id(),
|
||||
auth.is_workspace_account(),
|
||||
fn connector_directory_cache_context(
|
||||
config: &Config,
|
||||
auth: &CodexAuth,
|
||||
) -> ConnectorDirectoryCacheContext {
|
||||
ConnectorDirectoryCacheContext::new(
|
||||
config.codex_home.to_path_buf(),
|
||||
ConnectorDirectoryCacheKey::new(
|
||||
config.chatgpt_base_url.clone(),
|
||||
auth.get_account_id(),
|
||||
auth.get_chatgpt_user_id(),
|
||||
auth.is_workspace_account(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ anyhow = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
clap_complete = { workspace = true }
|
||||
codex-app-server = { workspace = true }
|
||||
codex-app-server-daemon = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-app-server-test-client = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
|
||||
@@ -3,6 +3,9 @@ use clap::CommandFactory;
|
||||
use clap::Parser;
|
||||
use clap_complete::Shell;
|
||||
use clap_complete::generate;
|
||||
use codex_app_server_daemon::BootstrapOptions as AppServerBootstrapOptions;
|
||||
use codex_app_server_daemon::LifecycleCommand as AppServerLifecycleCommand;
|
||||
use codex_app_server_daemon::RemoteControlMode as AppServerRemoteControlMode;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_arg0::arg0_dispatch_or_else;
|
||||
use codex_chatgpt::apply_command::ApplyCommand;
|
||||
@@ -469,6 +472,9 @@ struct ExecServerCommand {
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
enum AppServerSubcommand {
|
||||
/// Manage the local app-server daemon.
|
||||
Daemon(AppServerDaemonCommand),
|
||||
|
||||
/// Proxy stdio bytes to the running app-server control socket.
|
||||
Proxy(AppServerProxyCommand),
|
||||
|
||||
@@ -483,6 +489,40 @@ enum AppServerSubcommand {
|
||||
GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct AppServerDaemonCommand {
|
||||
#[command(subcommand)]
|
||||
subcommand: AppServerDaemonSubcommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
enum AppServerDaemonSubcommand {
|
||||
/// Install durable local app-server management for SSH-driven use.
|
||||
Bootstrap(AppServerBootstrapCommand),
|
||||
|
||||
/// Start the local app server daemon if it is not already running.
|
||||
Start,
|
||||
|
||||
/// Restart the local app server daemon.
|
||||
Restart,
|
||||
|
||||
/// Enable remote_control for future starts and a currently running managed daemon.
|
||||
EnableRemoteControl,
|
||||
|
||||
/// Disable remote_control for future starts and a currently running managed daemon.
|
||||
DisableRemoteControl,
|
||||
|
||||
/// Stop the local app server daemon.
|
||||
Stop,
|
||||
|
||||
/// Print local CLI and running app-server versions as JSON.
|
||||
Version,
|
||||
|
||||
/// [internal] Run the detached pid-backed standalone updater loop.
|
||||
#[clap(hide = true)]
|
||||
PidUpdateLoop,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct AppServerProxyCommand {
|
||||
/// Path to the app-server Unix domain socket to connect to.
|
||||
@@ -490,6 +530,13 @@ struct AppServerProxyCommand {
|
||||
socket_path: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct AppServerBootstrapCommand {
|
||||
/// Launch the managed app-server with remote_control enabled.
|
||||
#[arg(long = "remote-control")]
|
||||
remote_control: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct GenerateTsCommand {
|
||||
/// Output directory where .ts files will be written
|
||||
@@ -875,6 +922,41 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Some(AppServerSubcommand::Daemon(daemon_cli)) => match daemon_cli.subcommand {
|
||||
AppServerDaemonSubcommand::Start => {
|
||||
print_app_server_daemon_output(AppServerLifecycleCommand::Start).await?;
|
||||
}
|
||||
AppServerDaemonSubcommand::Bootstrap(bootstrap_cli) => {
|
||||
let output =
|
||||
codex_app_server_daemon::bootstrap(AppServerBootstrapOptions {
|
||||
remote_control_enabled: bootstrap_cli.remote_control,
|
||||
})
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
}
|
||||
AppServerDaemonSubcommand::Restart => {
|
||||
print_app_server_daemon_output(AppServerLifecycleCommand::Restart).await?;
|
||||
}
|
||||
AppServerDaemonSubcommand::EnableRemoteControl => {
|
||||
print_app_server_remote_control_output(AppServerRemoteControlMode::Enabled)
|
||||
.await?;
|
||||
}
|
||||
AppServerDaemonSubcommand::DisableRemoteControl => {
|
||||
print_app_server_remote_control_output(
|
||||
AppServerRemoteControlMode::Disabled,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
AppServerDaemonSubcommand::Stop => {
|
||||
print_app_server_daemon_output(AppServerLifecycleCommand::Stop).await?;
|
||||
}
|
||||
AppServerDaemonSubcommand::Version => {
|
||||
print_app_server_daemon_output(AppServerLifecycleCommand::Version).await?;
|
||||
}
|
||||
AppServerDaemonSubcommand::PidUpdateLoop => {
|
||||
codex_app_server_daemon::run_pid_update_loop().await?;
|
||||
}
|
||||
},
|
||||
Some(AppServerSubcommand::Proxy(proxy_cli)) => {
|
||||
let socket_path = match proxy_cli.socket_path {
|
||||
Some(socket_path) => socket_path,
|
||||
@@ -1547,6 +1629,20 @@ fn reject_remote_mode_for_app_server_subcommand(
|
||||
) -> anyhow::Result<()> {
|
||||
let subcommand_name = match subcommand {
|
||||
None => "app-server",
|
||||
Some(AppServerSubcommand::Daemon(daemon)) => match daemon.subcommand {
|
||||
AppServerDaemonSubcommand::Bootstrap(_) => "app-server daemon bootstrap",
|
||||
AppServerDaemonSubcommand::Start => "app-server daemon start",
|
||||
AppServerDaemonSubcommand::Restart => "app-server daemon restart",
|
||||
AppServerDaemonSubcommand::EnableRemoteControl => {
|
||||
"app-server daemon enable-remote-control"
|
||||
}
|
||||
AppServerDaemonSubcommand::DisableRemoteControl => {
|
||||
"app-server daemon disable-remote-control"
|
||||
}
|
||||
AppServerDaemonSubcommand::Stop => "app-server daemon stop",
|
||||
AppServerDaemonSubcommand::Version => "app-server daemon version",
|
||||
AppServerDaemonSubcommand::PidUpdateLoop => "app-server daemon pid-update-loop",
|
||||
},
|
||||
Some(AppServerSubcommand::Proxy(_)) => "app-server proxy",
|
||||
Some(AppServerSubcommand::GenerateTs(_)) => "app-server generate-ts",
|
||||
Some(AppServerSubcommand::GenerateJsonSchema(_)) => "app-server generate-json-schema",
|
||||
@@ -1557,6 +1653,20 @@ fn reject_remote_mode_for_app_server_subcommand(
|
||||
reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name)
|
||||
}
|
||||
|
||||
async fn print_app_server_daemon_output(command: AppServerLifecycleCommand) -> anyhow::Result<()> {
|
||||
let output = codex_app_server_daemon::run(command).await?;
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn print_app_server_remote_control_output(
|
||||
mode: AppServerRemoteControlMode,
|
||||
) -> anyhow::Result<()> {
|
||||
let output = codex_app_server_daemon::set_remote_control(mode).await?;
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_remote_auth_token_from_env_var_with<F>(
|
||||
env_var_name: &str,
|
||||
get_var: F,
|
||||
@@ -2524,6 +2634,70 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_server_daemon_subcommands_parse() {
|
||||
assert!(matches!(
|
||||
app_server_from_args(
|
||||
[
|
||||
"codex",
|
||||
"app-server",
|
||||
"daemon",
|
||||
"bootstrap",
|
||||
"--remote-control"
|
||||
]
|
||||
.as_ref()
|
||||
)
|
||||
.subcommand,
|
||||
Some(AppServerSubcommand::Daemon(AppServerDaemonCommand {
|
||||
subcommand: AppServerDaemonSubcommand::Bootstrap(AppServerBootstrapCommand {
|
||||
remote_control: true
|
||||
})
|
||||
}))
|
||||
));
|
||||
assert!(matches!(
|
||||
app_server_from_args(["codex", "app-server", "daemon", "start"].as_ref()).subcommand,
|
||||
Some(AppServerSubcommand::Daemon(AppServerDaemonCommand {
|
||||
subcommand: AppServerDaemonSubcommand::Start
|
||||
}))
|
||||
));
|
||||
assert!(matches!(
|
||||
app_server_from_args(["codex", "app-server", "daemon", "restart"].as_ref()).subcommand,
|
||||
Some(AppServerSubcommand::Daemon(AppServerDaemonCommand {
|
||||
subcommand: AppServerDaemonSubcommand::Restart
|
||||
}))
|
||||
));
|
||||
assert!(matches!(
|
||||
app_server_from_args(
|
||||
["codex", "app-server", "daemon", "enable-remote-control"].as_ref()
|
||||
)
|
||||
.subcommand,
|
||||
Some(AppServerSubcommand::Daemon(AppServerDaemonCommand {
|
||||
subcommand: AppServerDaemonSubcommand::EnableRemoteControl
|
||||
}))
|
||||
));
|
||||
assert!(matches!(
|
||||
app_server_from_args(
|
||||
["codex", "app-server", "daemon", "disable-remote-control"].as_ref()
|
||||
)
|
||||
.subcommand,
|
||||
Some(AppServerSubcommand::Daemon(AppServerDaemonCommand {
|
||||
subcommand: AppServerDaemonSubcommand::DisableRemoteControl
|
||||
}))
|
||||
));
|
||||
assert!(matches!(
|
||||
app_server_from_args(["codex", "app-server", "daemon", "stop"].as_ref()).subcommand,
|
||||
Some(AppServerSubcommand::Daemon(AppServerDaemonCommand {
|
||||
subcommand: AppServerDaemonSubcommand::Stop
|
||||
}))
|
||||
));
|
||||
assert!(matches!(
|
||||
app_server_from_args(["codex", "app-server", "daemon", "version"].as_ref()).subcommand,
|
||||
Some(AppServerSubcommand::Daemon(AppServerDaemonCommand {
|
||||
subcommand: AppServerDaemonSubcommand::Version
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_server_proxy_sock_path_parses() {
|
||||
let app_server =
|
||||
@@ -2552,6 +2726,20 @@ mod tests {
|
||||
assert!(err.to_string().contains("app-server proxy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_remote_auth_token_env_for_app_server_version() {
|
||||
let subcommand = AppServerSubcommand::Daemon(AppServerDaemonCommand {
|
||||
subcommand: AppServerDaemonSubcommand::Version,
|
||||
});
|
||||
let err = reject_remote_mode_for_app_server_subcommand(
|
||||
/*remote*/ None,
|
||||
Some("CODEX_REMOTE_AUTH_TOKEN"),
|
||||
Some(&subcommand),
|
||||
)
|
||||
.expect_err("app-server daemon version should reject --remote-auth-token-env");
|
||||
assert!(err.to_string().contains("app-server daemon version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_server_capability_token_flags_parse() {
|
||||
let app_server = app_server_from_args(
|
||||
|
||||
@@ -22,6 +22,3 @@ workspace = true
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_with = "3"
|
||||
|
||||
[package.metadata.cargo-shear]
|
||||
ignored = ["serde_with"]
|
||||
|
||||
@@ -93,6 +93,18 @@ fn model_tool_names(tools: &[ToolInfo]) -> HashSet<ToolName> {
|
||||
.collect::<HashSet<_>>()
|
||||
}
|
||||
|
||||
fn model_tool_name_len(name: &ToolName) -> usize {
|
||||
name.namespace.as_deref().map_or(0, str::len) + name.name.len()
|
||||
}
|
||||
|
||||
fn is_code_mode_compatible_tool_name(name: &ToolName) -> bool {
|
||||
name.namespace
|
||||
.as_deref()
|
||||
.into_iter()
|
||||
.chain(std::iter::once(name.name.as_str()))
|
||||
.flat_map(str::chars)
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
#[test]
|
||||
fn declared_openai_file_fields_treat_names_literally() {
|
||||
let meta = serde_json::json!({
|
||||
@@ -334,17 +346,14 @@ fn test_normalize_tools_long_names_same_server() {
|
||||
|
||||
let names = model_tool_names(&model_tools);
|
||||
|
||||
assert!(names.iter().all(|name| name.display().len() == 64));
|
||||
assert!(names.iter().all(|name| model_tool_name_len(name) == 64));
|
||||
assert!(
|
||||
names
|
||||
.iter()
|
||||
.all(|name| name.namespace.as_deref() == Some("mcp__my_server__"))
|
||||
);
|
||||
assert!(
|
||||
names.iter().all(|name| name
|
||||
.display()
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')),
|
||||
names.iter().all(is_code_mode_compatible_tool_name),
|
||||
"model-visible names must be code-mode compatible: {names:?}"
|
||||
);
|
||||
}
|
||||
@@ -363,10 +372,9 @@ fn test_normalize_tools_sanitizes_invalid_characters() {
|
||||
ToolName::namespaced("mcp__server_one__", "tool_two_three")
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{}{}", tool.callable_namespace, tool.callable_name),
|
||||
model_name.display()
|
||||
ToolName::namespaced(tool.callable_namespace.clone(), tool.callable_name.clone()),
|
||||
model_name
|
||||
);
|
||||
|
||||
// The callable parts are sanitized for model-visible tool calls, but the raw
|
||||
// MCP name is preserved for the actual MCP call.
|
||||
assert_eq!(tool.server_name, "server.one");
|
||||
@@ -375,10 +383,7 @@ fn test_normalize_tools_sanitizes_invalid_characters() {
|
||||
assert_eq!(tool.tool.name, "tool.two-three");
|
||||
|
||||
assert!(
|
||||
model_name
|
||||
.display()
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||
is_code_mode_compatible_tool_name(&model_name),
|
||||
"model-visible name must be code-mode compatible: {model_name:?}"
|
||||
);
|
||||
}
|
||||
@@ -425,10 +430,7 @@ fn test_normalize_tools_disambiguates_sanitized_namespace_collisions() {
|
||||
assert_eq!(raw_servers, HashSet::from(["basic-server", "basic_server"]));
|
||||
let model_names = model_tool_names(&model_tools);
|
||||
assert!(
|
||||
model_names.iter().all(|name| name
|
||||
.display()
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')),
|
||||
model_names.iter().all(is_code_mode_compatible_tool_name),
|
||||
"model-visible names must be code-mode compatible: {model_names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,14 @@ workspace = true
|
||||
anyhow = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use sha1::Digest;
|
||||
use sha1::Sha1;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ConnectorDirectoryCacheKey;
|
||||
|
||||
pub(crate) const CONNECTOR_DIRECTORY_DISK_CACHE_SCHEMA_VERSION: u8 = 1;
|
||||
const CONNECTOR_DIRECTORY_DISK_CACHE_DIR: &str = "cache/codex_app_directory";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConnectorDirectoryCacheContext {
|
||||
pub(crate) codex_home: PathBuf,
|
||||
pub(crate) cache_key: ConnectorDirectoryCacheKey,
|
||||
}
|
||||
|
||||
impl ConnectorDirectoryCacheContext {
|
||||
pub fn new(codex_home: PathBuf, cache_key: ConnectorDirectoryCacheKey) -> Self {
|
||||
Self {
|
||||
codex_home,
|
||||
cache_key,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cache_path(&self) -> PathBuf {
|
||||
let cache_key_json = serde_json::to_string(&self.cache_key).unwrap_or_default();
|
||||
let cache_key_hash = sha1_hex(&cache_key_json);
|
||||
self.codex_home
|
||||
.join(CONNECTOR_DIRECTORY_DISK_CACHE_DIR)
|
||||
.join(format!("{cache_key_hash}.json"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum CachedConnectorDirectoryDiskLoad {
|
||||
Hit { connectors: Vec<AppInfo> },
|
||||
Missing,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
pub(crate) fn load_cached_directory_connectors_from_disk(
|
||||
cache_context: &ConnectorDirectoryCacheContext,
|
||||
) -> CachedConnectorDirectoryDiskLoad {
|
||||
let cache_path = cache_context.cache_path();
|
||||
let bytes = match std::fs::read(&cache_path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return CachedConnectorDirectoryDiskLoad::Missing;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
cache_path = %cache_path.display(),
|
||||
"failed to read connector directory disk cache: {err}"
|
||||
);
|
||||
return CachedConnectorDirectoryDiskLoad::Invalid;
|
||||
}
|
||||
};
|
||||
let cache: ConnectorDirectoryDiskCache = match serde_json::from_slice(&bytes) {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
cache_path = %cache_path.display(),
|
||||
"failed to parse connector directory disk cache: {err}"
|
||||
);
|
||||
let _ = std::fs::remove_file(cache_path);
|
||||
return CachedConnectorDirectoryDiskLoad::Invalid;
|
||||
}
|
||||
};
|
||||
if cache.schema_version != CONNECTOR_DIRECTORY_DISK_CACHE_SCHEMA_VERSION {
|
||||
let _ = std::fs::remove_file(cache_path);
|
||||
return CachedConnectorDirectoryDiskLoad::Invalid;
|
||||
}
|
||||
|
||||
CachedConnectorDirectoryDiskLoad::Hit {
|
||||
connectors: cache.connectors,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_cached_directory_connectors_to_disk(
|
||||
cache_context: &ConnectorDirectoryCacheContext,
|
||||
connectors: &[AppInfo],
|
||||
) {
|
||||
let cache_path = cache_context.cache_path();
|
||||
if let Some(parent) = cache_path.parent()
|
||||
&& std::fs::create_dir_all(parent).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Ok(bytes) = serde_json::to_vec_pretty(&ConnectorDirectoryDiskCache {
|
||||
schema_version: CONNECTOR_DIRECTORY_DISK_CACHE_SCHEMA_VERSION,
|
||||
connectors: connectors.to_vec(),
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let _ = std::fs::write(cache_path, bytes);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ConnectorDirectoryDiskCache {
|
||||
schema_version: u8,
|
||||
connectors: Vec<AppInfo>,
|
||||
}
|
||||
|
||||
fn sha1_hex(value: &str) -> String {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(value.as_bytes());
|
||||
let sha1 = hasher.finalize();
|
||||
format!("{sha1:x}")
|
||||
}
|
||||
+230
-37
@@ -9,23 +9,27 @@ use codex_app_server_protocol::AppBranding;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppMetadata;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
pub mod accessible;
|
||||
mod directory_cache;
|
||||
pub mod filter;
|
||||
pub mod merge;
|
||||
pub mod metadata;
|
||||
|
||||
pub use directory_cache::ConnectorDirectoryCacheContext;
|
||||
|
||||
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AllConnectorsCacheKey {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConnectorDirectoryCacheKey {
|
||||
chatgpt_base_url: String,
|
||||
account_id: Option<String>,
|
||||
chatgpt_user_id: Option<String>,
|
||||
is_workspace_account: bool,
|
||||
}
|
||||
|
||||
impl AllConnectorsCacheKey {
|
||||
impl ConnectorDirectoryCacheKey {
|
||||
pub fn new(
|
||||
chatgpt_base_url: String,
|
||||
account_id: Option<String>,
|
||||
@@ -42,13 +46,13 @@ impl AllConnectorsCacheKey {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedAllConnectors {
|
||||
key: AllConnectorsCacheKey,
|
||||
struct CachedConnectorDirectory {
|
||||
key: ConnectorDirectoryCacheKey,
|
||||
expires_at: Instant,
|
||||
connectors: Vec<AppInfo>,
|
||||
}
|
||||
|
||||
static ALL_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAllConnectors>>> =
|
||||
static CONNECTOR_DIRECTORY_CACHE: LazyLock<StdMutex<Option<CachedConnectorDirectory>>> =
|
||||
LazyLock::new(|| StdMutex::new(None));
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -76,26 +80,54 @@ pub struct DirectoryApp {
|
||||
visibility: Option<String>,
|
||||
}
|
||||
|
||||
pub fn cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option<Vec<AppInfo>> {
|
||||
let mut cache_guard = ALL_CONNECTORS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let now = Instant::now();
|
||||
|
||||
if let Some(cached) = cache_guard.as_ref() {
|
||||
if now < cached.expires_at && cached.key == *cache_key {
|
||||
return Some(cached.connectors.clone());
|
||||
}
|
||||
if now >= cached.expires_at {
|
||||
*cache_guard = None;
|
||||
}
|
||||
pub fn cached_directory_connectors(
|
||||
cache_context: &ConnectorDirectoryCacheContext,
|
||||
) -> Option<Vec<AppInfo>> {
|
||||
if let Some(cached_connectors) = cached_directory_connectors_in_memory(&cache_context.cache_key)
|
||||
{
|
||||
return Some(cached_connectors);
|
||||
}
|
||||
|
||||
let directory_cache::CachedConnectorDirectoryDiskLoad::Hit { connectors } =
|
||||
directory_cache::load_cached_directory_connectors_from_disk(cache_context)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
write_cached_directory_connectors_in_memory(
|
||||
cache_context.cache_key.clone(),
|
||||
&connectors,
|
||||
Duration::ZERO,
|
||||
);
|
||||
Some(connectors)
|
||||
}
|
||||
|
||||
fn cached_directory_connectors_in_memory(
|
||||
cache_key: &ConnectorDirectoryCacheKey,
|
||||
) -> Option<Vec<AppInfo>> {
|
||||
let cache_guard = CONNECTOR_DIRECTORY_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache_guard
|
||||
.as_ref()
|
||||
.filter(|cached| cached.key == *cache_key)
|
||||
.map(|cached| cached.connectors.clone())
|
||||
}
|
||||
|
||||
fn unexpired_directory_connectors_in_memory(
|
||||
cache_key: &ConnectorDirectoryCacheKey,
|
||||
) -> Option<Vec<AppInfo>> {
|
||||
let cache_guard = CONNECTOR_DIRECTORY_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let cached = cache_guard.as_ref()?;
|
||||
if cached.key == *cache_key && Instant::now() < cached.expires_at {
|
||||
return Some(cached.connectors.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn list_all_connectors_with_options<F, Fut>(
|
||||
cache_key: AllConnectorsCacheKey,
|
||||
cache_context: ConnectorDirectoryCacheContext,
|
||||
is_workspace_account: bool,
|
||||
force_refetch: bool,
|
||||
mut fetch_page: F,
|
||||
@@ -104,7 +136,10 @@ where
|
||||
F: FnMut(String) -> Fut,
|
||||
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
|
||||
{
|
||||
if !force_refetch && let Some(cached_connectors) = cached_all_connectors(&cache_key) {
|
||||
if !force_refetch
|
||||
&& let Some(cached_connectors) =
|
||||
unexpired_directory_connectors_in_memory(&cache_context.cache_key)
|
||||
{
|
||||
return Ok(cached_connectors);
|
||||
}
|
||||
|
||||
@@ -132,17 +167,33 @@ where
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
write_cached_all_connectors(cache_key, &connectors);
|
||||
write_cached_directory_connectors(&cache_context, &connectors);
|
||||
Ok(connectors)
|
||||
}
|
||||
|
||||
fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) {
|
||||
let mut cache_guard = ALL_CONNECTORS_CACHE
|
||||
fn write_cached_directory_connectors(
|
||||
cache_context: &ConnectorDirectoryCacheContext,
|
||||
connectors: &[AppInfo],
|
||||
) {
|
||||
write_cached_directory_connectors_in_memory(
|
||||
cache_context.cache_key.clone(),
|
||||
connectors,
|
||||
CONNECTORS_CACHE_TTL,
|
||||
);
|
||||
directory_cache::write_cached_directory_connectors_to_disk(cache_context, connectors);
|
||||
}
|
||||
|
||||
fn write_cached_directory_connectors_in_memory(
|
||||
cache_key: ConnectorDirectoryCacheKey,
|
||||
connectors: &[AppInfo],
|
||||
ttl: Duration,
|
||||
) {
|
||||
let mut cache_guard = CONNECTOR_DIRECTORY_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*cache_guard = Some(CachedAllConnectors {
|
||||
*cache_guard = Some(CachedConnectorDirectory {
|
||||
key: cache_key,
|
||||
expires_at: Instant::now() + CONNECTORS_CACHE_TTL,
|
||||
expires_at: Instant::now() + ttl,
|
||||
connectors: connectors.to_vec(),
|
||||
});
|
||||
}
|
||||
@@ -417,12 +468,13 @@ mod tests {
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tempfile::TempDir;
|
||||
|
||||
static ALL_CONNECTORS_CACHE_TEST_LOCK: LazyLock<tokio::sync::Mutex<()>> =
|
||||
static CONNECTOR_DIRECTORY_CACHE_TEST_LOCK: LazyLock<tokio::sync::Mutex<()>> =
|
||||
LazyLock::new(|| tokio::sync::Mutex::new(()));
|
||||
|
||||
fn cache_key(id: &str) -> AllConnectorsCacheKey {
|
||||
AllConnectorsCacheKey::new(
|
||||
fn cache_key(id: &str) -> ConnectorDirectoryCacheKey {
|
||||
ConnectorDirectoryCacheKey::new(
|
||||
"https://chatgpt.example".to_string(),
|
||||
Some(format!("account-{id}")),
|
||||
Some(format!("user-{id}")),
|
||||
@@ -430,6 +482,17 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn cache_context(codex_home: &TempDir, id: &str) -> ConnectorDirectoryCacheContext {
|
||||
ConnectorDirectoryCacheContext::new(codex_home.path().to_path_buf(), cache_key(id))
|
||||
}
|
||||
|
||||
fn clear_directory_memory_cache() {
|
||||
let mut cache_guard = CONNECTOR_DIRECTORY_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*cache_guard = None;
|
||||
}
|
||||
|
||||
fn app(id: &str, name: &str) -> DirectoryApp {
|
||||
DirectoryApp {
|
||||
id: id.to_string(),
|
||||
@@ -450,15 +513,16 @@ mod tests {
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "test serializes access to the shared connector cache for its full duration"
|
||||
)]
|
||||
async fn list_all_connectors_uses_shared_cache() -> anyhow::Result<()> {
|
||||
let _cache_guard = ALL_CONNECTORS_CACHE_TEST_LOCK.lock().await;
|
||||
async fn list_all_connectors_uses_shared_directory_cache() -> anyhow::Result<()> {
|
||||
let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await;
|
||||
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let call_counter = Arc::clone(&calls);
|
||||
let key = cache_key("shared");
|
||||
let codex_home = TempDir::new()?;
|
||||
let cache_context = cache_context(&codex_home, "shared");
|
||||
|
||||
let first = list_all_connectors_with_options(
|
||||
key.clone(),
|
||||
cache_context.clone(),
|
||||
/*is_workspace_account*/ false,
|
||||
/*force_refetch*/ false,
|
||||
move |_path| {
|
||||
@@ -475,7 +539,7 @@ mod tests {
|
||||
.await?;
|
||||
|
||||
let second = list_all_connectors_with_options(
|
||||
key,
|
||||
cache_context,
|
||||
/*is_workspace_account*/ false,
|
||||
/*force_refetch*/ false,
|
||||
move |_path| async move {
|
||||
@@ -495,14 +559,15 @@ mod tests {
|
||||
reason = "test serializes access to the shared connector cache for its full duration"
|
||||
)]
|
||||
async fn list_all_connectors_merges_and_normalizes_directory_apps() -> anyhow::Result<()> {
|
||||
let _cache_guard = ALL_CONNECTORS_CACHE_TEST_LOCK.lock().await;
|
||||
let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await;
|
||||
|
||||
let key = cache_key("merged");
|
||||
let codex_home = TempDir::new()?;
|
||||
let cache_context = cache_context(&codex_home, "merged");
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let call_counter = Arc::clone(&calls);
|
||||
|
||||
let connectors = list_all_connectors_with_options(
|
||||
key,
|
||||
cache_context,
|
||||
/*is_workspace_account*/ true,
|
||||
/*force_refetch*/ true,
|
||||
move |path| {
|
||||
@@ -566,6 +631,134 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "test serializes access to the shared connector cache for its full duration"
|
||||
)]
|
||||
async fn cached_directory_connectors_reads_directory_disk_cache() -> anyhow::Result<()> {
|
||||
let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let cache_context = cache_context(&codex_home, "disk");
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let call_counter = Arc::clone(&calls);
|
||||
|
||||
let first = list_all_connectors_with_options(
|
||||
cache_context.clone(),
|
||||
/*is_workspace_account*/ false,
|
||||
/*force_refetch*/ false,
|
||||
move |_path| {
|
||||
let call_counter = Arc::clone(&call_counter);
|
||||
async move {
|
||||
call_counter.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(DirectoryListResponse {
|
||||
apps: vec![app("alpha", "Alpha")],
|
||||
next_token: None,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
clear_directory_memory_cache();
|
||||
|
||||
let second = cached_directory_connectors(&cache_context).expect("disk cache should load");
|
||||
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(first, second);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "test serializes access to the shared connector cache for its full duration"
|
||||
)]
|
||||
async fn list_all_connectors_refreshes_when_only_directory_disk_cache_exists()
|
||||
-> anyhow::Result<()> {
|
||||
let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let cache_context = cache_context(&codex_home, "disk-refresh");
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let call_counter = Arc::clone(&calls);
|
||||
|
||||
list_all_connectors_with_options(
|
||||
cache_context.clone(),
|
||||
/*is_workspace_account*/ false,
|
||||
/*force_refetch*/ false,
|
||||
move |_path| {
|
||||
let call_counter = Arc::clone(&call_counter);
|
||||
async move {
|
||||
call_counter.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(DirectoryListResponse {
|
||||
apps: vec![app("alpha", "Alpha")],
|
||||
next_token: None,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
clear_directory_memory_cache();
|
||||
let mut cached_expected = directory_app_to_app_info(app("alpha", "Alpha"));
|
||||
cached_expected.install_url = Some(connector_install_url(
|
||||
&cached_expected.name,
|
||||
&cached_expected.id,
|
||||
));
|
||||
assert_eq!(
|
||||
cached_directory_connectors(&cache_context),
|
||||
Some(vec![cached_expected])
|
||||
);
|
||||
let refreshed_calls = Arc::clone(&calls);
|
||||
|
||||
let refreshed = list_all_connectors_with_options(
|
||||
cache_context,
|
||||
/*is_workspace_account*/ false,
|
||||
/*force_refetch*/ false,
|
||||
move |_path| {
|
||||
let call_counter = Arc::clone(&refreshed_calls);
|
||||
async move {
|
||||
call_counter.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(DirectoryListResponse {
|
||||
apps: vec![app("beta", "Beta")],
|
||||
next_token: None,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut expected = directory_app_to_app_info(app("beta", "Beta"));
|
||||
expected.install_url = Some(connector_install_url(&expected.name, &expected.id));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(refreshed, vec![expected]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_directory_connectors_drops_stale_disk_schema() -> anyhow::Result<()> {
|
||||
let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await;
|
||||
|
||||
clear_directory_memory_cache();
|
||||
let codex_home = TempDir::new()?;
|
||||
let cache_context = cache_context(&codex_home, "stale-schema");
|
||||
let cache_path = cache_context.cache_path();
|
||||
std::fs::create_dir_all(cache_path.parent().expect("cache parent"))?;
|
||||
std::fs::write(
|
||||
&cache_path,
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"schema_version": 0,
|
||||
"connectors": [],
|
||||
}))?,
|
||||
)?;
|
||||
|
||||
assert_eq!(cached_directory_connectors(&cache_context), None);
|
||||
assert!(!cache_path.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_directory_connectors_omits_tier_for_all_pages() -> anyhow::Result<()> {
|
||||
let requested_paths: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
@@ -84,7 +84,6 @@ iana-time-zone = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
||||
indexmap = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use http::HeaderValue;
|
||||
|
||||
pub(crate) const X_OAI_ATTESTATION_HEADER: &str = "x-oai-attestation";
|
||||
|
||||
pub type GenerateAttestationFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Option<HeaderValue>> + Send + 'a>>;
|
||||
|
||||
/// Request context that host integrations can use when deciding whether to
|
||||
/// generate an attestation header value.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct AttestationContext {
|
||||
/// Thread whose upstream request is being prepared.
|
||||
pub thread_id: ThreadId,
|
||||
}
|
||||
|
||||
/// Host integration boundary for just-in-time attestation header values.
|
||||
///
|
||||
/// Implementations own the policy for when attestation should be attempted and
|
||||
/// return the upstream `x-oai-attestation` header value when one should be sent.
|
||||
pub trait AttestationProvider: std::fmt::Debug + Send + Sync {
|
||||
fn header_for_request(&self, context: AttestationContext) -> GenerateAttestationFuture<'_>;
|
||||
}
|
||||
@@ -105,6 +105,9 @@ use tracing::instrument;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::attestation::AttestationContext;
|
||||
use crate::attestation::AttestationProvider;
|
||||
use crate::attestation::X_OAI_ATTESTATION_HEADER;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::client_common::ResponseStream;
|
||||
@@ -170,6 +173,8 @@ struct ModelClientState {
|
||||
enable_request_compression: bool,
|
||||
include_timing_metrics: bool,
|
||||
beta_features_header: Option<String>,
|
||||
include_attestation: bool,
|
||||
attestation_provider: Option<Arc<dyn AttestationProvider>>,
|
||||
disable_websockets: AtomicBool,
|
||||
cached_websocket_session: StdMutex<WebsocketSession>,
|
||||
}
|
||||
@@ -314,6 +319,7 @@ impl ModelClient {
|
||||
enable_request_compression: bool,
|
||||
include_timing_metrics: bool,
|
||||
beta_features_header: Option<String>,
|
||||
attestation_provider: Option<Arc<dyn AttestationProvider>>,
|
||||
) -> Self {
|
||||
let model_provider = create_model_provider(provider_info, auth_manager);
|
||||
let codex_api_key_env_enabled = model_provider
|
||||
@@ -322,6 +328,7 @@ impl ModelClient {
|
||||
.is_some_and(|manager| manager.codex_api_key_env_enabled());
|
||||
let auth_env_telemetry =
|
||||
collect_auth_env_telemetry(model_provider.info(), codex_api_key_env_enabled);
|
||||
let include_attestation = model_provider.supports_attestation();
|
||||
Self {
|
||||
state: Arc::new(ModelClientState {
|
||||
session_id,
|
||||
@@ -335,6 +342,8 @@ impl ModelClient {
|
||||
enable_request_compression,
|
||||
include_timing_metrics,
|
||||
beta_features_header,
|
||||
include_attestation,
|
||||
attestation_provider,
|
||||
disable_websockets: AtomicBool::new(false),
|
||||
cached_websocket_session: StdMutex::new(WebsocketSession::default()),
|
||||
}),
|
||||
@@ -463,9 +472,6 @@ impl ModelClient {
|
||||
text,
|
||||
..
|
||||
} = request;
|
||||
let client =
|
||||
ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth)
|
||||
.with_telemetry(Some(request_telemetry));
|
||||
let payload = ApiCompactionInput {
|
||||
model: &model,
|
||||
input: &input,
|
||||
@@ -492,6 +498,12 @@ impl ModelClient {
|
||||
Some(self.state.session_id.to_string()),
|
||||
Some(self.state.thread_id.to_string()),
|
||||
));
|
||||
if let Some(header_value) = self.generate_attestation_header_for().await {
|
||||
extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
let client =
|
||||
ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth)
|
||||
.with_telemetry(Some(request_telemetry));
|
||||
let trace_attempt = compaction_trace.start_attempt(&payload);
|
||||
let result = client
|
||||
.compact_input(&payload, extra_headers)
|
||||
@@ -505,11 +517,14 @@ impl ModelClient {
|
||||
&self,
|
||||
sdp: String,
|
||||
session_config: ApiRealtimeSessionConfig,
|
||||
extra_headers: ApiHeaderMap,
|
||||
mut extra_headers: ApiHeaderMap,
|
||||
) -> Result<RealtimeWebrtcCallStart> {
|
||||
// Create the media call over HTTP first, then retain matching auth so realtime can attach
|
||||
// the server-side control WebSocket to the call id from that HTTP response.
|
||||
let client_setup = self.current_client_setup().await?;
|
||||
if let Some(header_value) = self.generate_attestation_header_for().await {
|
||||
extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
let mut sideband_headers = extra_headers.clone();
|
||||
sideband_headers.extend(sideband_websocket_auth_headers(
|
||||
client_setup.api_auth.as_ref(),
|
||||
@@ -640,6 +655,20 @@ impl ModelClient {
|
||||
client_metadata
|
||||
}
|
||||
|
||||
async fn generate_attestation_header_for(&self) -> Option<HeaderValue> {
|
||||
if !self.state.include_attestation {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.state
|
||||
.attestation_provider
|
||||
.as_ref()?
|
||||
.header_for_request(AttestationContext {
|
||||
thread_id: self.state.thread_id,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Builds request telemetry for unary API calls (e.g., Compact endpoint).
|
||||
fn build_request_telemetry(
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -779,7 +808,9 @@ impl ModelClient {
|
||||
auth_context: AuthRequestTelemetryContext,
|
||||
request_route_telemetry: RequestRouteTelemetry,
|
||||
) -> std::result::Result<ApiWebSocketConnection, ApiError> {
|
||||
let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header);
|
||||
let headers = self
|
||||
.build_websocket_headers(turn_state.as_ref(), turn_metadata_header)
|
||||
.await;
|
||||
let websocket_telemetry = ModelClientSession::build_websocket_telemetry(
|
||||
session_telemetry,
|
||||
auth_context,
|
||||
@@ -856,7 +887,7 @@ impl ModelClient {
|
||||
///
|
||||
/// Callers should pass the current turn-state lock when available so sticky-routing state is
|
||||
/// replayed on reconnect within the same turn.
|
||||
fn build_websocket_headers(
|
||||
async fn build_websocket_headers(
|
||||
&self,
|
||||
turn_state: Option<&Arc<OnceLock<String>>>,
|
||||
turn_metadata_header: Option<&str>,
|
||||
@@ -874,6 +905,9 @@ impl ModelClient {
|
||||
}
|
||||
headers.extend(build_session_headers(Some(session_id), Some(thread_id)));
|
||||
headers.extend(self.build_responses_identity_headers());
|
||||
if let Some(header_value) = self.generate_attestation_header_for().await {
|
||||
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
headers.insert(
|
||||
OPENAI_BETA_HEADER,
|
||||
HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE),
|
||||
@@ -922,7 +956,7 @@ impl ModelClientSession {
|
||||
///
|
||||
/// Keeping option construction in one place ensures request-scoped headers are consistent
|
||||
/// regardless of transport choice.
|
||||
fn build_responses_options(
|
||||
async fn build_responses_options(
|
||||
&self,
|
||||
turn_metadata_header: Option<&str>,
|
||||
compression: Compression,
|
||||
@@ -941,6 +975,9 @@ impl ModelClientSession {
|
||||
turn_metadata_header.as_ref(),
|
||||
);
|
||||
headers.extend(self.client.build_responses_identity_headers());
|
||||
if let Some(header_value) = self.client.generate_attestation_header_for().await {
|
||||
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
headers
|
||||
},
|
||||
compression,
|
||||
@@ -1217,7 +1254,9 @@ impl ModelClientSession {
|
||||
self.client.state.auth_env_telemetry.clone(),
|
||||
);
|
||||
let compression = self.responses_request_compression(client_setup.auth.as_ref());
|
||||
let options = self.build_responses_options(turn_metadata_header, compression);
|
||||
let options = self
|
||||
.build_responses_options(turn_metadata_header, compression)
|
||||
.await;
|
||||
|
||||
let request = self.client.build_responses_request(
|
||||
&client_setup.api_provider,
|
||||
@@ -1324,7 +1363,9 @@ impl ModelClientSession {
|
||||
);
|
||||
let compression = self.responses_request_compression(client_setup.auth.as_ref());
|
||||
|
||||
let options = self.build_responses_options(turn_metadata_header, compression);
|
||||
let options = self
|
||||
.build_responses_options(turn_metadata_header, compression)
|
||||
.await;
|
||||
let request = self.client.build_responses_request(
|
||||
&client_setup.api_provider,
|
||||
prompt,
|
||||
|
||||
@@ -7,13 +7,21 @@ use super::X_CODEX_PARENT_THREAD_ID_HEADER;
|
||||
use super::X_CODEX_TURN_METADATA_HEADER;
|
||||
use super::X_CODEX_WINDOW_ID_HEADER;
|
||||
use super::X_OPENAI_SUBAGENT_HEADER;
|
||||
use crate::AttestationContext;
|
||||
use crate::AttestationProvider;
|
||||
use crate::GenerateAttestationFuture;
|
||||
use codex_api::ApiError;
|
||||
use codex_api::ResponseEvent;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_model_provider::BearerAuthProvider;
|
||||
use codex_model_provider_info::CHATGPT_CODEX_BASE_URL;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use codex_model_provider_info::create_oss_provider_with_base_url;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::SessionId;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -36,6 +44,8 @@ use std::collections::VecDeque;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::task::Context;
|
||||
use std::task::Poll;
|
||||
use std::time::Duration;
|
||||
@@ -64,6 +74,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient {
|
||||
/*enable_request_compression*/ false,
|
||||
/*include_timing_metrics*/ false,
|
||||
/*beta_features_header*/ None,
|
||||
/*attestation_provider*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -466,3 +477,107 @@ fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() {
|
||||
assert_eq!(auth_context.recovery_mode, Some("managed"));
|
||||
assert_eq!(auth_context.recovery_phase, Some("refresh_token"));
|
||||
}
|
||||
|
||||
fn model_client_with_counting_attestation(
|
||||
include_attestation: bool,
|
||||
) -> (ModelClient, Arc<AtomicUsize>) {
|
||||
#[derive(Debug)]
|
||||
struct CountingAttestationProvider {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl AttestationProvider for CountingAttestationProvider {
|
||||
fn header_for_request(
|
||||
&self,
|
||||
_context: AttestationContext,
|
||||
) -> GenerateAttestationFuture<'_> {
|
||||
let calls = self.calls.clone();
|
||||
Box::pin(async move {
|
||||
let call = calls.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
Some(http::HeaderValue::from_bytes(format!("v1.header-{call}").as_bytes()).unwrap())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let attestation_calls = Arc::new(AtomicUsize::new(0));
|
||||
let (auth_manager, provider) = if include_attestation {
|
||||
(
|
||||
Some(AuthManager::from_auth_for_testing(
|
||||
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
)),
|
||||
ModelProviderInfo::create_openai_provider(Some(CHATGPT_CODEX_BASE_URL.to_string())),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
None,
|
||||
create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses),
|
||||
)
|
||||
};
|
||||
let model_client = ModelClient::new(
|
||||
auth_manager,
|
||||
SessionId::new(),
|
||||
ThreadId::new(),
|
||||
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
|
||||
provider,
|
||||
SessionSource::Exec,
|
||||
/*model_verbosity*/ None,
|
||||
/*enable_request_compression*/ false,
|
||||
/*include_timing_metrics*/ false,
|
||||
/*beta_features_header*/ None,
|
||||
Some(Arc::new(CountingAttestationProvider {
|
||||
calls: attestation_calls.clone(),
|
||||
})),
|
||||
);
|
||||
(model_client, attestation_calls)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_handshake_includes_attestation_for_chatgpt_codex_responses() {
|
||||
let (model_client, attestation_calls) =
|
||||
model_client_with_counting_attestation(/*include_attestation*/ true);
|
||||
|
||||
let headers = model_client
|
||||
.build_websocket_headers(/*turn_state*/ None, /*turn_metadata_header*/ None)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(crate::attestation::X_OAI_ATTESTATION_HEADER)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("v1.header-1"),
|
||||
);
|
||||
assert_eq!(attestation_calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_chatgpt_codex_endpoints_omit_attestation_generation() {
|
||||
let (model_client, attestation_calls) =
|
||||
model_client_with_counting_attestation(/*include_attestation*/ false);
|
||||
let mut response_headers = http::HeaderMap::new();
|
||||
|
||||
if let Some(header_value) = model_client.generate_attestation_header_for().await {
|
||||
response_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
let mut compaction_headers = http::HeaderMap::new();
|
||||
if let Some(header_value) = model_client.generate_attestation_header_for().await {
|
||||
compaction_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
let mut realtime_headers = http::HeaderMap::new();
|
||||
if let Some(header_value) = model_client.generate_attestation_header_for().await {
|
||||
realtime_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
response_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
realtime_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER),
|
||||
None,
|
||||
);
|
||||
assert_eq!(attestation_calls.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,6 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
skills_manager: Arc::clone(&parent_session.services.skills_manager),
|
||||
plugins_manager: Arc::clone(&parent_session.services.plugins_manager),
|
||||
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
|
||||
skills_watcher: Arc::clone(&parent_session.services.skills_watcher),
|
||||
conversation_history: initial_history.unwrap_or(InitialHistory::New),
|
||||
session_source: SessionSource::SubAgent(subagent_source.clone()),
|
||||
thread_source: Some(ThreadSource::Subagent),
|
||||
@@ -99,6 +98,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
environment_selections: parent_ctx.environments.clone(),
|
||||
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
|
||||
thread_store: Arc::clone(&parent_session.services.thread_store),
|
||||
attestation_provider: parent_session.services.attestation_provider.clone(),
|
||||
}))
|
||||
.or_cancel(&cancel_token)
|
||||
.await??;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::file_watcher::WatchRegistration;
|
||||
use crate::goals::ExternalGoalSet;
|
||||
use crate::goals::GoalRuntimeEvent;
|
||||
use crate::session::Codex;
|
||||
@@ -31,6 +30,7 @@ use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_thread_store::StoredThread;
|
||||
@@ -101,7 +101,6 @@ pub struct CodexThread {
|
||||
session_configured: SessionConfiguredEvent,
|
||||
rollout_path: Option<PathBuf>,
|
||||
out_of_band_elicitation_count: Mutex<u64>,
|
||||
_watch_registration: WatchRegistration,
|
||||
}
|
||||
|
||||
/// Conduit for the bidirectional stream of messages that compose a thread
|
||||
@@ -112,7 +111,6 @@ impl CodexThread {
|
||||
session_configured: SessionConfiguredEvent,
|
||||
rollout_path: Option<PathBuf>,
|
||||
session_source: SessionSource,
|
||||
watch_registration: WatchRegistration,
|
||||
) -> Self {
|
||||
Self {
|
||||
codex,
|
||||
@@ -120,7 +118,6 @@ impl CodexThread {
|
||||
session_configured,
|
||||
rollout_path,
|
||||
out_of_band_elicitation_count: Mutex::new(0),
|
||||
_watch_registration: watch_registration,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +468,10 @@ impl CodexThread {
|
||||
self.codex.session.refresh_runtime_config(next_config).await;
|
||||
}
|
||||
|
||||
pub async fn environment_selections(&self) -> Vec<TurnEnvironmentSelection> {
|
||||
self.codex.thread_environment_selections().await
|
||||
}
|
||||
|
||||
pub async fn read_mcp_resource(
|
||||
&self,
|
||||
server: &str,
|
||||
|
||||
@@ -6,21 +6,18 @@ use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Context;
|
||||
use async_channel::unbounded;
|
||||
use codex_api::SharedAuthProvider;
|
||||
pub use codex_app_server_protocol::AppBranding;
|
||||
pub use codex_app_server_protocol::AppInfo;
|
||||
pub use codex_app_server_protocol::AppMetadata;
|
||||
use codex_connectors::AllConnectorsCacheKey;
|
||||
use codex_connectors::DirectoryListResponse;
|
||||
use codex_connectors::ConnectorDirectoryCacheContext;
|
||||
use codex_connectors::ConnectorDirectoryCacheKey;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::ExecServerRuntimePaths;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
@@ -35,7 +32,6 @@ use codex_core_plugins::PluginsManager;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::create_client;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::McpConnectionManager;
|
||||
@@ -48,7 +44,6 @@ use codex_mcp::host_owned_codex_apps_enabled;
|
||||
use codex_mcp::with_codex_apps_mcp;
|
||||
|
||||
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
|
||||
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct AppToolPolicy {
|
||||
@@ -119,9 +114,11 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
auth: Option<&CodexAuth>,
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> anyhow::Result<Vec<DiscoverableTool>> {
|
||||
let directory_connectors =
|
||||
list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?;
|
||||
let connector_ids = tool_suggest_connector_ids(config).await;
|
||||
let directory_connectors = codex_connectors::merge::merge_plugin_connectors(
|
||||
cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await,
|
||||
connector_ids.iter().cloned(),
|
||||
);
|
||||
let discoverable_connectors =
|
||||
codex_connectors::filter::filter_tool_suggest_discoverable_connectors(
|
||||
directory_connectors,
|
||||
@@ -435,12 +432,12 @@ async fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
|
||||
connector_ids
|
||||
}
|
||||
|
||||
async fn list_directory_connectors_for_tool_suggest_with_auth(
|
||||
async fn cached_directory_connectors_for_tool_suggest_with_auth(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> anyhow::Result<Vec<AppInfo>> {
|
||||
) -> Vec<AppInfo> {
|
||||
if !config.features.enabled(Feature::Apps) {
|
||||
return Ok(Vec::new());
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let loaded_auth;
|
||||
@@ -453,67 +450,25 @@ async fn list_directory_connectors_for_tool_suggest_with_auth(
|
||||
loaded_auth.as_ref()
|
||||
};
|
||||
let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else {
|
||||
return Ok(Vec::new());
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let account_id = match auth.get_account_id() {
|
||||
Some(account_id) if !account_id.is_empty() => account_id,
|
||||
_ => return Ok(Vec::new()),
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
let auth_provider = codex_model_provider::auth_provider_from_auth(auth);
|
||||
let is_workspace_account = auth.is_workspace_account();
|
||||
let cache_key = AllConnectorsCacheKey::new(
|
||||
config.chatgpt_base_url.clone(),
|
||||
Some(account_id.clone()),
|
||||
auth.get_chatgpt_user_id(),
|
||||
is_workspace_account,
|
||||
let cache_context = ConnectorDirectoryCacheContext::new(
|
||||
config.codex_home.to_path_buf(),
|
||||
ConnectorDirectoryCacheKey::new(
|
||||
config.chatgpt_base_url.clone(),
|
||||
Some(account_id),
|
||||
auth.get_chatgpt_user_id(),
|
||||
is_workspace_account,
|
||||
),
|
||||
);
|
||||
|
||||
codex_connectors::list_all_connectors_with_options(
|
||||
cache_key,
|
||||
is_workspace_account,
|
||||
/*force_refetch*/ false,
|
||||
|path| {
|
||||
let auth_provider = auth_provider.clone();
|
||||
async move {
|
||||
chatgpt_get_request_with_auth_provider::<DirectoryListResponse>(
|
||||
config,
|
||||
path,
|
||||
auth_provider,
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn chatgpt_get_request_with_auth_provider<T: DeserializeOwned>(
|
||||
config: &Config,
|
||||
path: String,
|
||||
auth_provider: SharedAuthProvider,
|
||||
) -> anyhow::Result<T> {
|
||||
let client = create_client();
|
||||
let url = format!("{}{}", config.chatgpt_base_url, path);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.headers(auth_provider.to_auth_headers())
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(DIRECTORY_CONNECTORS_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send request")?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse JSON response")
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("request failed with status {status}: {body}");
|
||||
}
|
||||
codex_connectors::cached_directory_connectors(&cache_context).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec<AppInfo> {
|
||||
|
||||
@@ -19,6 +19,7 @@ use codex_connectors::metadata::connector_install_url;
|
||||
use codex_connectors::metadata::connector_mention_slug;
|
||||
use codex_connectors::metadata::sanitize_name;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -1120,6 +1121,42 @@ disabled_tools = [
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_uses_connector_id_fallback_when_directory_cache_is_empty() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[features]
|
||||
apps = true
|
||||
|
||||
[tool_suggest]
|
||||
discoverables = [
|
||||
{ type = "connector", id = "connector_gmail" }
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
let discoverable_tools =
|
||||
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[])
|
||||
.await
|
||||
.expect("discoverable tools should load");
|
||||
|
||||
assert_eq!(
|
||||
discoverable_tools,
|
||||
vec![DiscoverableTool::from(plugin_connector_to_app_info(
|
||||
"connector_gmail".to_string(),
|
||||
))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() {
|
||||
let filtered = filter_tool_suggest_discoverable_connectors(
|
||||
|
||||
@@ -85,6 +85,7 @@ pub(crate) fn resolve_environment_selections(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_exec_server::ExecServerRuntimePaths;
|
||||
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
|
||||
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -109,15 +110,41 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
default_thread_environment_selections(&manager, &cwd),
|
||||
vec![TurnEnvironmentSelection {
|
||||
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
||||
cwd,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn toml_default_thread_environment_selections_include_local_and_remote() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(
|
||||
temp_dir.path().join("environments.toml"),
|
||||
r#"
|
||||
[[environments]]
|
||||
id = "remote"
|
||||
url = "ws://127.0.0.1:8765"
|
||||
"#,
|
||||
)
|
||||
.expect("write environments.toml");
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
||||
let manager = EnvironmentManager::from_codex_home(temp_dir.path(), test_runtime_paths())
|
||||
.await
|
||||
.expect("environment manager");
|
||||
|
||||
assert_eq!(
|
||||
default_thread_environment_selections(&manager, &cwd),
|
||||
vec![
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
||||
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
cwd: cwd.clone(),
|
||||
},
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: "local".to_string(),
|
||||
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
||||
cwd,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -23,6 +23,7 @@ pub use codex_thread::CodexThread;
|
||||
pub use codex_thread::CodexThreadTurnContextOverrides;
|
||||
pub use codex_thread::ThreadConfigSnapshot;
|
||||
mod agent;
|
||||
mod attestation;
|
||||
mod codex_delegate;
|
||||
mod command_canonicalization;
|
||||
mod commit_attribution;
|
||||
@@ -34,7 +35,6 @@ mod environment_selection;
|
||||
pub mod exec;
|
||||
pub mod exec_env;
|
||||
mod exec_policy;
|
||||
pub mod file_watcher;
|
||||
mod flags;
|
||||
#[cfg(test)]
|
||||
mod git_info_tests;
|
||||
@@ -99,7 +99,6 @@ pub(crate) use skills::manager;
|
||||
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
|
||||
pub(crate) use skills::resolve_skill_dependencies_for_turn;
|
||||
pub(crate) use skills::skills_load_input_from_config;
|
||||
mod skills_watcher;
|
||||
mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
mod unified_exec;
|
||||
@@ -177,6 +176,9 @@ mod tasks;
|
||||
mod user_shell_command;
|
||||
pub mod util;
|
||||
|
||||
pub use attestation::AttestationContext;
|
||||
pub use attestation::AttestationProvider;
|
||||
pub use attestation::GenerateAttestationFuture;
|
||||
pub use client::ModelClient;
|
||||
pub use client::ModelClientSession;
|
||||
pub use client::X_CODEX_INSTALLATION_ID_HEADER;
|
||||
@@ -191,7 +193,6 @@ pub use exec_policy::ExecPolicyError;
|
||||
pub use exec_policy::check_execpolicy_for_warnings;
|
||||
pub use exec_policy::format_exec_policy_error_with_source;
|
||||
pub use exec_policy::load_exec_policy;
|
||||
pub use file_watcher::FileWatcherEvent;
|
||||
pub use installation_id::resolve_installation_id;
|
||||
pub use turn_metadata::build_turn_metadata_header;
|
||||
pub mod compact;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::flat_tool_name;
|
||||
use crate::tools::handlers::unified_exec::ExecCommandArgs;
|
||||
use codex_memories_read::usage::MEMORIES_USAGE_METRIC;
|
||||
use codex_memories_read::usage::memories_usage_kinds_from_command;
|
||||
@@ -17,14 +18,14 @@ pub(crate) async fn emit_metric_for_tool_read(invocation: &ToolInvocation, succe
|
||||
}
|
||||
|
||||
let success = if success { "true" } else { "false" };
|
||||
let tool_name = invocation.tool_name.display();
|
||||
let tool_name = flat_tool_name(&invocation.tool_name);
|
||||
for kind in kinds {
|
||||
invocation.turn.session_telemetry.counter(
|
||||
MEMORIES_USAGE_METRIC,
|
||||
/*inc*/ 1,
|
||||
&[
|
||||
("kind", kind.as_tag()),
|
||||
("tool", &tool_name),
|
||||
("tool", tool_name.as_ref()),
|
||||
("success", success),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -53,6 +53,7 @@ pub async fn build_prompt_input(
|
||||
thread_store,
|
||||
state_db.clone(),
|
||||
installation_id,
|
||||
/*attestation_provider*/ None,
|
||||
);
|
||||
let thread = thread_manager.start_thread(config).await?;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::agent::Mailbox;
|
||||
use crate::agent::MailboxReceiver;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::attestation::AttestationProvider;
|
||||
use crate::build_available_skills;
|
||||
use crate::commit_attribution::commit_message_trailer_instruction;
|
||||
use crate::compact;
|
||||
@@ -112,6 +113,7 @@ use codex_protocol::protocol::ThreadSource;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::TurnContextNetworkItem;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
@@ -282,8 +284,6 @@ use crate::rollout::map_session_init_error;
|
||||
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
|
||||
use crate::shell;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::skills_watcher::SkillsWatcherEvent;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::MailboxDeliveryPhase;
|
||||
use crate::state::PendingRequestPermissions;
|
||||
@@ -392,7 +392,6 @@ pub(crate) struct CodexSpawnArgs {
|
||||
pub(crate) skills_manager: Arc<SkillsManager>,
|
||||
pub(crate) plugins_manager: Arc<PluginsManager>,
|
||||
pub(crate) mcp_manager: Arc<McpManager>,
|
||||
pub(crate) skills_watcher: Arc<SkillsWatcher>,
|
||||
pub(crate) conversation_history: InitialHistory,
|
||||
pub(crate) session_source: SessionSource,
|
||||
pub(crate) thread_source: Option<ThreadSource>,
|
||||
@@ -412,6 +411,7 @@ pub(crate) struct CodexSpawnArgs {
|
||||
pub(crate) environment_selections: ResolvedTurnEnvironments,
|
||||
pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
|
||||
pub(crate) thread_store: Arc<dyn ThreadStore>,
|
||||
pub(crate) attestation_provider: Option<Arc<dyn AttestationProvider>>,
|
||||
}
|
||||
|
||||
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
|
||||
@@ -455,7 +455,6 @@ impl Codex {
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
skills_watcher,
|
||||
conversation_history,
|
||||
session_source,
|
||||
thread_source,
|
||||
@@ -471,6 +470,7 @@ impl Codex {
|
||||
environment_selections,
|
||||
analytics_events_client,
|
||||
thread_store,
|
||||
attestation_provider,
|
||||
} = args;
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
@@ -650,12 +650,12 @@ impl Codex {
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager.clone(),
|
||||
skills_watcher,
|
||||
agent_control,
|
||||
environment_manager,
|
||||
analytics_events_client,
|
||||
thread_store,
|
||||
parent_rollout_thread_trace,
|
||||
attestation_provider,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -784,6 +784,11 @@ impl Codex {
|
||||
state.session_configuration.thread_config_snapshot()
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_environment_selections(&self) -> Vec<TurnEnvironmentSelection> {
|
||||
let state = self.session.state.lock().await;
|
||||
state.session_configuration.environments.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn state_db(&self) -> Option<state_db::StateDbHandle> {
|
||||
self.session.state_db()
|
||||
}
|
||||
@@ -1017,29 +1022,6 @@ impl Session {
|
||||
self.out_of_band_elicitation_paused.send_replace(paused);
|
||||
}
|
||||
|
||||
fn start_skills_watcher_listener(self: &Arc<Self>) {
|
||||
let mut rx = self.services.skills_watcher.subscribe();
|
||||
let weak_sess = Arc::downgrade(self);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(SkillsWatcherEvent::SkillsChanged { .. }) => {
|
||||
let Some(sess) = weak_sess.upgrade() else {
|
||||
break;
|
||||
};
|
||||
let event = Event {
|
||||
id: sess.next_internal_sub_id(),
|
||||
msg: EventMsg::SkillsUpdateAvailable,
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn get_tx_event(&self) -> Sender<Event> {
|
||||
self.tx_event.clone()
|
||||
}
|
||||
|
||||
@@ -364,12 +364,12 @@ impl Session {
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
mcp_manager: Arc<McpManager>,
|
||||
skills_watcher: Arc<SkillsWatcher>,
|
||||
agent_control: AgentControl,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
analytics_events_client: Option<AnalyticsEventsClient>,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
parent_rollout_thread_trace: ThreadTraceContext,
|
||||
attestation_provider: Option<Arc<dyn AttestationProvider>>,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
debug!(
|
||||
"Configuring session: model={}; provider={:?}",
|
||||
@@ -845,13 +845,13 @@ impl Session {
|
||||
skills_manager,
|
||||
plugins_manager: Arc::clone(&plugins_manager),
|
||||
mcp_manager: Arc::clone(&mcp_manager),
|
||||
skills_watcher,
|
||||
agent_control,
|
||||
network_proxy,
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
state_db: state_db_ctx.clone(),
|
||||
live_thread: live_thread_init.as_ref().cloned(),
|
||||
thread_store: Arc::clone(&thread_store),
|
||||
attestation_provider: attestation_provider.clone(),
|
||||
model_client: ModelClient::new(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
session_id,
|
||||
@@ -863,6 +863,7 @@ impl Session {
|
||||
config.features.enabled(Feature::EnableRequestCompression),
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
Self::build_model_client_beta_features_header(config.as_ref()),
|
||||
attestation_provider,
|
||||
),
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(),
|
||||
environment_manager,
|
||||
@@ -932,8 +933,6 @@ impl Session {
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
|
||||
// Start the watcher after SessionConfigured so it cannot emit earlier events.
|
||||
sess.start_skills_watcher_listener();
|
||||
let mut required_mcp_servers: Vec<String> = mcp_servers
|
||||
.iter()
|
||||
.filter(|(_, server)| server.enabled() && server.required())
|
||||
|
||||
@@ -407,6 +407,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
|
||||
/*enable_request_compression*/ false,
|
||||
/*include_timing_metrics*/ false,
|
||||
/*beta_features_header*/ None,
|
||||
/*attestation_provider*/ None,
|
||||
)
|
||||
.new_session()
|
||||
}
|
||||
@@ -1025,7 +1026,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn),
|
||||
call_id: "probe-call".to_string(),
|
||||
tool_name: "probe".to_string(),
|
||||
tool_name: codex_tools::ToolName::plain("probe"),
|
||||
};
|
||||
|
||||
orchestrator
|
||||
@@ -3724,7 +3725,6 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
Arc::new(SkillsWatcher::noop()),
|
||||
AgentControl::default(),
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
@@ -3733,6 +3733,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
/*state_db*/ None,
|
||||
)),
|
||||
codex_rollout_trace::ThreadTraceContext::disabled(),
|
||||
/*attestation_provider*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -3835,7 +3836,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
.expect("create environment"),
|
||||
);
|
||||
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized(
|
||||
&config.permissions.approval_policy,
|
||||
@@ -3871,7 +3871,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
skills_watcher,
|
||||
agent_control,
|
||||
network_proxy: None,
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
@@ -3881,6 +3880,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
|
||||
/*state_db*/ None,
|
||||
)),
|
||||
attestation_provider: None,
|
||||
model_client: ModelClient::new(
|
||||
Some(auth_manager.clone()),
|
||||
thread_id.into(),
|
||||
@@ -3892,6 +3892,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
config.features.enabled(Feature::EnableRequestCompression),
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
Session::build_model_client_beta_features_header(config.as_ref()),
|
||||
/*attestation_provider*/ None,
|
||||
),
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(),
|
||||
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
@@ -4060,7 +4061,6 @@ async fn make_session_with_config_and_rx(
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
Arc::new(SkillsWatcher::noop()),
|
||||
AgentControl::default(),
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
@@ -4069,6 +4069,7 @@ async fn make_session_with_config_and_rx(
|
||||
/*state_db*/ None,
|
||||
)),
|
||||
codex_rollout_trace::ThreadTraceContext::disabled(),
|
||||
/*attestation_provider*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -4162,7 +4163,6 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
Arc::new(SkillsWatcher::noop()),
|
||||
agent_control,
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
@@ -4178,6 +4178,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
|
||||
),
|
||||
)),
|
||||
codex_rollout_trace::ThreadTraceContext::disabled(),
|
||||
/*attestation_provider*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -5550,7 +5551,6 @@ where
|
||||
.expect("create environment"),
|
||||
);
|
||||
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized(
|
||||
&config.permissions.approval_policy,
|
||||
@@ -5586,7 +5586,6 @@ where
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
skills_watcher,
|
||||
agent_control,
|
||||
network_proxy: None,
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
@@ -5596,6 +5595,7 @@ where
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
|
||||
state_db,
|
||||
)),
|
||||
attestation_provider: None,
|
||||
model_client: ModelClient::new(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
thread_id.into(),
|
||||
@@ -5607,6 +5607,7 @@ where
|
||||
config.features.enabled(Feature::EnableRequestCompression),
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
Session::build_model_client_beta_features_header(config.as_ref()),
|
||||
/*attestation_provider*/ None,
|
||||
),
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(),
|
||||
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user