fix(codex): correct startup trust, history reads, and resume usage (#13110)

## Thinking Path

> - Paperclip runs Codex locally and in remote sandboxes.
> - The runner must preserve startup configuration and session identity.
> - Missing project trust can disable repository configuration.
> - Full-history requests use deprecated provider fields.
> - Resume usage describes old work and must not become new run usage.
> - This change corrects startup trust, state reads, and usage
classification.

## Linked Issues or Issue Description

**What happened?**

Normal Codex runs could show repository-trust and history-deprecation
warnings.
Resume could report the preceding turn's token snapshot as a late-turn
warning.
The historical last-usage value could also be attributed to the new run.

**Expected behavior**

Trust the server-selected startup root in isolated configuration. Read
lightweight
provider state and paginated evidence. Use historical cumulative usage
as a
baseline without a new charge or user-facing warning.

**Steps to reproduce**

1. Start a native Codex task in a selected repository.
2. Finish the turn and resume the provider thread.
3. Inspect provider notices, history requests, and per-run usage.
4. Repeat startup and cold resume inside a Daytona sandbox.

**Paperclip version or commit**

Codex CLI 0.153.4 is the pinned runtime and reproduced baseline.
Replayed onto master at 6abeb6733. Related authority work: Refs #13092.
This PR retains its startup cleanup and protocol-integrity checks.

**Deployment mode**

Local source checkout and disposable Daytona sandbox.

## What Changed

- Classify the exact historical resume usage event before the generic
stale-turn warning.
- Persist cumulative usage baselines across recovery of the same run.
- Use excludeTurns on resume and lightweight thread reads.
- Page turn metadata and selected turn items with cursor and identity
validation.
- Reject unsupported or incomplete history instead of guessing that
execution is idle.
- Trust the startup execution root on its host, including Git worktree
trust keys.
- Start Codex in that root and retain the selected sandbox profile on
later turns.
- Keep unrelated isolated configuration and Codex's separate hook trust
policy.
- Add Rust, TypeScript, accounting, native integration, and local
run-log documentation.

## Verification

- Codex and native-transport TypeScript: 333 passed before PR replay.
- Adjacent OpenCode/ACPX driver and accounting tests: 49 passed.
- Rust library, serialized: 226 passed. Native Codex integration: 72
passed, 1 ignored, plus two pagination regressions.
- Repository typecheck and build passed. All repository test groups have
passing coverage after fixture and resource retests; the initial
monolithic command was not clean.
- Fresh real Codex native browser tasks returned correct answers without
the three targeted notices. Answers persisted after refresh and restart.
- Real same-thread TypeScript driver tests passed locally and in
Daytona, including cold resume, configuration, skills, and an approved
harmless hook.
- Local usage summed to 64,607 tokens. Daytona usage summed to 42,737
tokens. Each sum matched its final session total exactly.
- See doc/plans/2026-09-09-codex-integration-acceptance.md for the scope
and limits of the live tests.
- After replay onto current master and review fixes: 334 Codex, backend,
and live-session tests passed, including checkpoint serialization and
real-runner process restart. TypeScript checks passed.
- The native Codex integration run passed 83 tests; the large lineage
test passed separately with the release runner (its debug build exceeded
the test deadline).
- All GitHub checks passed on the final PR head. Greptile is 5/5 with no
unresolved review threads. CI regenerates the lockfile for the added
TOML dependency, per repository policy.
- The first server shard hit a timing-dependent duplicate-key failure in
the unchanged artifact-document concurrency test. Its focused 11-test
suite passed locally. One CI retry on the same head passed all 103 files
and 1,405 tests (2 skipped): [retry
result](https://github.com/paperclipai/paperclip/actions/runs/34398832930/job/102631274667).

## Risks

- Trust applies only to the server-selected startup root and isolated
configuration. Sandbox and tool permissions remain authoritative.
- Codex still requires approval of individual hook hashes. This change
does not bypass that policy.
- Providers without the required history APIs fail explicitly.
- Daytona acceptance used the production TypeScript driver. Remote
Paperclip UI and remote Rust execution were not tested.
- No new public API, database state, recovery policy, or UI control is
included.

## Model Used

OpenAI Codex, GPT-6 (`gpt-6-astra`). Used for reasoning, code edits,
tool use,
and test execution. The exact context-window limit is not exposed in
this
session. Real-provider acceptance used Codex CLI 0.153.4 with
`gpt-5.6-sol`.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-09 15:35:18 -05:00 committed by GitHub
parent ca96e1eb0a
commit 3b550c80fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1675 additions and 48 deletions

View File

@ -815,6 +815,25 @@ Legacy adapters without a verified resume capability use the same automatic no-r
The server projection remains available for execution diagnostics. Normal working, finishing, and interaction waits add no badges or cards to task lists or feeds. A retry may briefly change the existing transcript header to Reconnecting; attempts, causes, and recovery decisions belong in the run log. There is no reconciliation dialog. Safe recovery remains automatic. If it cannot continue safely, the source-scoped recovery record resolves with a blocked no-replay disposition and the ordinary task status becomes blocked, preserving its owner. Resolving this record does not grant replay authority: dispatch continues enforcing the durable hold. Replacement history remains inspectable and the composer stays usable.
### Codex startup and provider state
Paperclip trusts the server-selected startup execution root in the isolated
Codex configuration. Resolve that root on the execution host, including the
main repository trust key for Git worktrees. Start the provider in that same
root. This does not change sandbox permissions, tool authorization, secret
access, or Codex's separate per-hook trust policy.
Codex retains the model conversation. Paperclip resumes with `excludeTurns: true`,
reads lightweight thread state, and fetches paginated turn metadata or specific
turn items only when execution reconciliation needs them. Unsupported
or incomplete history is an explicit error, not evidence of idle execution.
The root-thread usage snapshot sent during resume belongs to its reported
completed turn. Retain a bounded local diagnostic and use cumulative totals as
a baseline; do not emit a warning or charge its historical `last` usage to the
new run. Preserve the baseline across recovery of the same run and start a new
delta when attaching a new run. Other stale-event and authority checks remain.
### Explicit Recovery Action
Paperclip opens an explicit recovery action when the system can identify a problem but cannot safely complete the work itself.

View File

@ -0,0 +1,93 @@
# Codex integration acceptance — 2026-09-09
## Environment and scope
All live tests used the pinned Codex CLI 0.153.4 and gpt-5.6-sol.
Each fixture used an isolated Codex home. A repository contained known launch
notes, a configuration marker, a skill marker, and a harmless SessionStart hook.
The hook only appended a line to a fixture file.
Streaming and feed-display changes remain separate pull requests. These tests
used their combined implementation checkout. Each PR is also checked on the
current master before merge.
## Native browser acceptance
A fresh test-drive instance started without tasks or prior runs. The test agent
used paperclip_runner with Codex. Browser actions created a task in the fixture
project, requested the notes, and sent two follow-up messages. The test server
was restarted between the first answer and the follow-ups.
All three native runs succeeded. They returned the expected notes and markers,
then answered a date-change question and recalled the original reference.
Answers persisted after refresh. No provider notice appeared. The task composer
remained usable.
The task policy opened new provider threads for completed-task follow-ups.
This browser test does not prove same-thread resume. The next tests do.
## Same-thread local driver acceptance
The production TypeScript driver ran an initial repository read, a follow-up,
a provider shutdown, persisted-session recovery, and a second follow-up.
All answers used the same provider thread and retained the required context.
- No provider notices appeared.
- Configuration and skill markers loaded.
- The approved hook ran once at startup and once at cold resume.
- Run usage was 40,445 + 13,624 + 10,538 = 64,607 tokens.
- The sum equaled the final cumulative session usage. Historical usage was
not charged again.
- Resume usage produced only the bounded local diagnostic.
## Daytona driver acceptance
A disposable Daytona sandbox ran the production TypeScript driver bundle.
The test installed Codex 0.153.4 because the image had an older version.
Startup, warm follow-up, process shutdown, cold resume, and the second
follow-up all succeeded on the same provider thread.
- Configuration and skill markers loaded.
- The approved hook ran once at startup and once at cold resume.
- No trust, history-deprecation, or settled-turn usage warning appeared.
- Run usage was 23,006 + 11,611 + 8,120 = 42,737 tokens.
- The sum equaled the final cumulative session usage.
- A separate warning about missing system bubblewrap remained visible.
Codex used its bundled copy. The change does not suppress that warning.
The sandbox was deleted after evidence collection. Remote Paperclip UI and
remote Rust execution were not tested.
## Hook trust and experience limits
Codex reviews hook hashes separately from repository trust. The test queried
hooks/list and approved only the harmless fixture hash through config/batchWrite.
Product code does not bypass this policy or copy operator configuration.
The task feed worked correctly. Two separate issues remain outside this change:
a dashboard preview could retain old running text, and test-drive restart could
use a different database when its saved port did not match its actual port.
The fixture configuration was corrected before sending further messages.
Existing test data was preserved. Transitions were sampled rather than filmed.
## Automated verification before PR preparation
- Codex/native-transport TypeScript: 333 passed.
- Adjacent OpenCode/ACPX driver, accounting, and recovery tests: 49 passed.
- Same-run attach and usage baseline regression: 4 passed.
- Rust library, serialized: 226 passed.
- Rust Codex integration: 72 passed, 1 ignored; two new pagination tests passed.
- Production native server integration passed after rebuilding its stale fake
provider. The test now asks Cargo to check binary freshness.
- Repository typecheck and build passed.
- Repository test groups have passing coverage after environment retests.
General-server coverage was 7,217 passed and 30 skipped. Route coverage was
2,175 passed and 4 skipped across 144 files.
The initial monolithic test command did not pass cleanly. Parallel test loads
caused timeouts; isolated reruns passed. CLI database tests reached the macOS
shared-memory limit; they passed after unused disposable fixture servers were
stopped. No production behavior or timeout setting changed for those failures.
The default parallel Rust library run also had Claude fixture transport failures;
the complete serialized rerun passed. PR checks record validation after replaying
these changes onto current master.

View File

@ -0,0 +1,87 @@
# Correct Codex startup trust, history reads, and resume notices
Date: 2026-09-09. Approved scope: three Codex integration fixes. This supersedes
this document's earlier exploratory recommendations. Feed display and
full-answer streaming are separate preceding changes.
## Product rules
1. Trust the execution root that Paperclip selects at startup. Resolve it on
the execution host, including a Git worktree's main repository trust key.
Write only the isolated Codex configuration. Keep sandbox, tool, and secret
controls authoritative. Later directory changes do not grant new trust.
2. Codex retains model conversation context. Paperclip reads provider state to
establish execution authority or recover specific evidence. Normal resume
must not download historical message contents.
3. A resume usage snapshot describes completed work. It can establish a
cumulative baseline, but must not charge that work to a new run or produce
a warning. Other stale notifications and invalid authoritative events keep
their existing validation.
## Implementation
### Resume usage
Handle the exact root-thread `thread/tokenUsage/updated` snapshot before the
settled-turn warning branch. Keep its reported historical turn identity and a
bounded local `codex_resume_usage_snapshot` diagnostic. The Rust normalizer
must not emit a billable usage event for the snapshot. The TypeScript driver
persists its cumulative baseline with the existing checkpoint and reports a
monotonic run delta. Attachment begins a new delta at the last observed total;
recovery of the same run preserves its baseline. Repeated snapshots are not
additional receipts. Missing thread or turn identity does not gain authority.
### Supported state and history reads
Use `excludeTurns: true` for resume and `includeTurns: false` for thread state.
Request turn metadata with `thread/turns/list` and `itemsView: notLoaded`.
Only request `thread/items/list` content for a specific turn when reconciliation
needs its final answer, tool result, or completion evidence. Follow cursors,
keep stable order, deduplicate IDs, reject repeated cursors and incomplete
responses. A missing API reports a compatibility/read error; there is no
Codex full-history fallback.
Rust uses lightweight idle/active state, then paginated metadata when it needs
an active turn identity. The controller's runner transport serves targeted
recovery evidence from committed runner events; it rejects content reads
outside the retained turn window. Existing non-Codex proxy behavior stays
separate from Codex's protocol requirements.
### Startup trust
Before spawning Codex, canonicalize the selected root and add its trusted
project entry to the isolated config. Preserve unrelated settings and use a
private atomic replacement. Start the provider process in that same root,
so startup cannot load the Paperclip server checkout by accident. Persist the
startup directory in the existing optional session checkpoint for cold resume.
Remote roots are resolved on the execution host.
Retain the server-selected permission profile on subsequent TypeScript turns,
including Daytona's existing external sandbox profile. Do not change approval
policy or bypass the external sandbox boundary.
Repository trust loads hook definitions, but Codex 0.153.4 separately reviews
individual hook hashes. Preserve that policy. Acceptance explicitly approves
only the harmless fixture hook through Codex's supported config API; product
code does not bypass hook trust or invoke provider hooks itself.
## Verification and exclusions
Use Codex CLI 0.153.4, the pinned supported baseline. Test snapshot replay and
cold recovery accounting, cross-thread isolation, full-history avoidance,
metadata/item pagination, incomplete evidence, worktree/non-Git/canonical
trust, malformed config, and unchanged sandbox profiles. Run focused Rust,
TypeScript, server lifecycle/accounting tests, then repository typecheck,
tests, and build.
Use fresh local test-drive data and a disposable Daytona sandbox with real
Codex. Verify an initial repository read, two follow-ups, cold resume, config
and skill markers, one hook execution per startup/resume, exact cumulative
usage arithmetic, and absence of the three original warnings. Inspect the
browser answer after refresh. Record local/native and remote/TypeScript
proof separately, including any environment warnings or unverified behavior.
No new UI, task state, public API, database migration, retry policy, or session
replacement workflow. Preserve the Gmail handoff patch and existing test data.
See [acceptance evidence](2026-09-09-codex-integration-acceptance.md).

View File

@ -119,3 +119,13 @@ section in the Observability contract.
Provider identity diagnostics remain in the local run log. They record the notification method, expected and received thread/turn identifiers, and the classification (root, verified descendant, stale, unrelated informational, or invalid authoritative). They omit the original provider payload and credentials. Repeated informational notices are bounded.
Recovery lifecycle events retain the original structured failure code, retry attempt, next retry time, and predecessor/successor identifiers. Durable status delivery uses an idempotency marker; delivery grants no provider authority. Failed publication is retried without repeating provider work. These records are not first-party Telemetry.
## Codex resume usage snapshot
The native runner retains a bounded local `harness.diagnostic` event with code
`codex_resume_usage_snapshot`. It identifies `thread/tokenUsage/updated` as
`resume_usage_snapshot`, retains the reported thread and completed-turn IDs,
and records cumulative usage counters. It does not include provider credentials
or message content. The event establishes the accounting baseline; it is not a
new billable usage receipt or a user-facing provider warning. Other provider
identity checks remain in force.

View File

@ -165,7 +165,8 @@
"json-schema-to-ts": "^3.1.1",
"opencode-ai": "1.18.29",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"smol-toml": "^1.4.2"
},
"peerDependencies": {
"react": ">=18",

View File

@ -1770,6 +1770,7 @@ dependencies = [
"sha1 0.10.7",
"sha2 0.10.9",
"tokio",
"toml_edit",
"tungstenite",
"uuid",
]
@ -2677,6 +2678,30 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.5.3"
@ -3072,6 +3097,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"

View File

@ -32,5 +32,6 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
rustix = { version = "1.1", features = ["fs"] }
sha1 = "0.10"
tungstenite = { version = "0.28", default-features = false, features = ["handshake", "rustls-tls-native-roots"] }
toml_edit = "0.22"
tokio = { version = "1.50", features = ["rt-multi-thread", "sync", "time"] }
uuid = { version = "1.22", features = ["v4"] }

View File

@ -29,6 +29,7 @@ reqwest.workspace = true
rustix.workspace = true
sha1.workspace = true
tungstenite.workspace = true
toml_edit.workspace = true
tokio.workspace = true
uuid.workspace = true

View File

@ -1051,6 +1051,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
}}))?;
continue;
}
if args
.iter()
.any(|arg| arg == "--require-lightweight-history")
&& message.pointer("/params/excludeTurns") != Some(&json!(true))
{
return Err("thread/resume must exclude turns".into());
}
if require_external_sandbox
&& (message.pointer("/params/sandbox") != Some(&json!("danger-full-access"))
|| message.pointer("/params/permissions").is_some())
@ -1079,6 +1086,17 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
"id": id,
"result": {"thread": {"id": state.thread_id, "sessionId": "codex-account-session"}}
}))?;
if args.iter().any(|arg| arg == "--resume-usage-snapshot")
&& state.active_turn_id.is_none()
&& state.next_turn > 0
{
for _ in 0..2 {
send(json!({"method": "thread/tokenUsage/updated", "params": {
"threadId": state.thread_id, "turnId": format!("provider-turn-{}", state.next_turn),
"tokenUsage": {"total": {"inputTokens": 100, "outputTokens": 20}, "last": {"inputTokens": 50, "outputTokens": 10}}
}}))?;
}
}
if descendant_notifications {
// Restoration must retain lineage without a replay of thread/started.
send(json!({"method": "turn/completed", "params": {
@ -1101,7 +1119,34 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
}
}
}
"thread/turns/list" => {
if args
.iter()
.any(|arg| arg == "--require-lightweight-history")
&& message.pointer("/params/itemsView") != Some(&json!("notLoaded"))
{
return Err("turn metadata must not hydrate items".into());
}
if args.iter().any(|arg| arg == "--repeat-history-cursor")
|| (args.iter().any(|arg| arg == "--paginated-history")
&& message.pointer("/params/cursor").is_none_or(Value::is_null))
{
send(json!({"id": id, "result": {"data": [], "nextCursor": "next-page"}}))?;
continue;
}
let turns = state.active_turn_id.as_ref()
.map(|turn_id| vec![json!({"id": turn_id, "status": "inProgress", "items": [], "itemsView": "notLoaded"})])
.unwrap_or_default();
send(json!({"id": id, "result": {"data": turns, "nextCursor": null}}))?;
}
"thread/read" => {
if args
.iter()
.any(|arg| arg == "--require-lightweight-history")
&& message.pointer("/params/includeTurns") != Some(&json!(false))
{
return Err("thread/read must not hydrate turns".into());
}
let turns = state
.active_turn_id
.as_ref()
@ -1109,7 +1154,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
.unwrap_or_default();
send(json!({
"id": id,
"result": {"thread": {"id": state.thread_id, "turns": turns}}
"result": {"thread": {"id": state.thread_id, "status": {"type": if state.active_turn_id.is_some() { "active" } else { "idle" }}, "turns": turns}}
}))?;
if fail_after_thread_read {
return Err("configured failure after thread read".into());

View File

@ -771,6 +771,14 @@ impl CodexProvider {
observe: &mut dyn FnMut(ProviderStartupObservation) -> Result<(), LocalRunnerError>,
) -> Result<Self, LocalRunnerError> {
config.validate()?;
if config.provider == "codex" {
if let Some(home) = std::env::var_os("CODEX_HOME") {
crate::codex_startup_trust::trust_startup_root(
Path::new(&home),
Path::new(&config.cwd),
)?;
}
}
if process_generation == 0 {
return Err(LocalRunnerError::invalid(
"Codex process generation must be positive",
@ -834,12 +842,13 @@ impl CodexProvider {
&environment_keys,
)
} else {
SupervisedProcess::spawn_with_environment_keys(
SupervisedProcess::spawn_in_directory_with_environment_keys(
&config.command,
&config.args,
Duration::from_secs(2),
CODEX_APP_SERVER_MAX_FRAME_BYTES,
&environment_keys,
Path::new(&config.cwd),
)
}
})();
@ -961,6 +970,9 @@ impl CodexProvider {
}
let method = if let Some(thread_id) = resume_thread_id {
params_object.insert("threadId".to_owned(), json!(thread_id));
if config.provider == "codex" {
params_object.insert("excludeTurns".to_owned(), json!(true));
}
"thread/resume"
} else {
params_object.insert("experimentalRawEvents".to_owned(), json!(false));
@ -1737,10 +1749,99 @@ impl CodexProvider {
// It does prove the provider remained live after that terminal, so a
// subsequent nonzero exit is a separate idle-session failure.
self.completion_reconciliation_pending = false;
self.request(
if self.config.provider != "codex" {
return self.request(
"thread/read",
json!({"threadId": self.thread_id, "includeTurns": true}),
);
}
let mut snapshot = self.request(
"thread/read",
json!({"threadId": self.thread_id, "includeTurns": true}),
)
json!({"threadId": self.thread_id, "includeTurns": false}),
)?;
if snapshot.pointer("/thread/id").and_then(Value::as_str) != Some(self.thread_id.as_str()) {
return Err(LocalRunnerError::invalid(
"Codex thread/read returned a different thread",
));
}
// Only metadata is needed to establish live authority. Never hydrate
// message contents just to decide whether this thread has an active turn.
match snapshot
.pointer("/thread/status/type")
.and_then(Value::as_str)
{
Some("idle") => {
snapshot["thread"]["turns"] = json!([]);
return Ok(snapshot);
}
Some("active") => {}
_ => {
return Err(LocalRunnerError::invalid(
"codex_history_incomplete: unavailable thread status",
))
}
};
let mut cursor = Value::Null;
let mut cursors = BTreeSet::new();
let mut turns = BTreeMap::new();
for _ in 0..10_000 {
let page = self
.request(
"thread/turns/list",
json!({
"threadId": self.thread_id, "cursor": cursor, "limit": 100,
"sortDirection": "desc", "itemsView": "notLoaded",
}),
)
.map_err(|error| {
LocalRunnerError::invalid(format!(
"codex_history_read_failed: supported thread/turns/list is required: {error}"
))
})?;
let data = page.get("data").and_then(Value::as_array).ok_or_else(|| {
LocalRunnerError::invalid("codex_history_incomplete: turn page omitted data")
})?;
for turn in data {
let id = bounded_provider_turn_id(turn.get("id").and_then(Value::as_str))?;
if !matches!(
turn.get("status").and_then(Value::as_str),
Some("inProgress" | "completed" | "failed" | "interrupted" | "cancelled")
) {
return Err(LocalRunnerError::invalid(
"codex_history_incomplete: invalid turn status",
));
}
turns.insert(id, turn.clone());
}
let found_active = turns
.values()
.any(|turn| turn.get("status").and_then(Value::as_str) == Some("inProgress"));
let next = page.get("nextCursor").cloned().unwrap_or(Value::Null);
if next.is_null() || found_active {
if !found_active {
return Err(LocalRunnerError::invalid(
"codex_history_incomplete: active thread has no active turn",
));
}
snapshot["thread"]["turns"] = Value::Array(turns.into_values().collect());
return Ok(snapshot);
}
let next_text = next
.as_str()
.filter(|value| !value.is_empty())
.ok_or_else(|| {
LocalRunnerError::invalid("codex_history_incomplete: invalid turn cursor")
})?;
if !cursors.insert(next_text.to_owned()) {
return Err(LocalRunnerError::invalid(
"codex_history_incomplete: repeated turn cursor",
));
}
cursor = next;
}
Err(LocalRunnerError::invalid(
"codex_history_incomplete: turn page limit exceeded",
))
}
pub fn resolve_runtime_request(
@ -2338,6 +2439,21 @@ impl CodexProvider {
if self.notification_identity_diagnostics > 32 {
return Ok(None);
}
// Resume replays the cumulative usage of the last settled turn.
// Keep its identity and baseline, but never bill its `last`
// measurement to the newly attached run.
if method == "thread/tokenUsage/updated"
&& notification_thread_id(&params) == Some(self.thread_id.as_str())
{
return Ok(Some(CodexProviderEvent::Notification {
method: "paperclip/resumeUsageSnapshot".to_owned(),
params: json!({
"threadId": self.thread_id,
"turnId": notification_turn_id,
"total": params.pointer("/tokenUsage/total"),
}),
}));
}
return Ok(Some(CodexProviderEvent::Notification {
method: "warning".to_owned(),
params: json!({

View File

@ -0,0 +1,160 @@
use crate::local_runner::LocalRunnerError;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use toml_edit::{value, DocumentMut};
// Resolve on the provider host. In particular, a Daytona path must never be
// canonicalized against the controller's filesystem.
fn execution_root(cwd: &Path) -> Result<PathBuf, LocalRunnerError> {
let root = fs::canonicalize(cwd)
.map_err(|_| LocalRunnerError::invalid("codex_startup_trust_root_unavailable"))?;
// Resolve Git's indirection without running project hooks or Git commands.
for ancestor in root.ancestors() {
let git = ancestor.join(".git");
if git.is_dir() {
return Ok(ancestor.to_path_buf());
}
if git.is_file() {
let pointer = fs::read_to_string(&git)
.map_err(|_| LocalRunnerError::invalid("codex_startup_trust_git_unreadable"))?;
let directory = pointer
.trim()
.strip_prefix("gitdir: ")
.ok_or_else(|| LocalRunnerError::invalid("codex_startup_trust_git_invalid"))?;
let directory = fs::canonicalize(ancestor.join(directory))
.map_err(|_| LocalRunnerError::invalid("codex_startup_trust_git_unavailable"))?;
let common_file = directory.join("commondir");
if common_file.is_file() {
let common = fs::read_to_string(common_file).map_err(|_| {
LocalRunnerError::invalid("codex_startup_trust_common_unreadable")
})?;
let common = fs::canonicalize(directory.join(common.trim())).map_err(|_| {
LocalRunnerError::invalid("codex_startup_trust_common_unavailable")
})?;
if common.file_name().is_some_and(|name| name == ".git") {
return Ok(common.parent().expect(".git has a parent").to_path_buf());
}
}
return Ok(ancestor.to_path_buf());
}
}
Ok(root)
}
pub(crate) fn trust_startup_root(home: &Path, cwd: &Path) -> Result<(), LocalRunnerError> {
if !home.is_absolute() || !cwd.is_absolute() {
return Err(LocalRunnerError::invalid(
"codex_startup_trust_requires_absolute_paths",
));
}
let root = execution_root(cwd)?;
fs::create_dir_all(home)
.map_err(|_| LocalRunnerError::invalid("codex_startup_trust_home_unavailable"))?;
let path = home.join("config.toml");
let existing = match fs::read_to_string(&path) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(_) => {
return Err(LocalRunnerError::invalid(
"codex_startup_trust_config_unreadable",
))
}
};
let mut document = existing
.parse::<DocumentMut>()
.map_err(|_| LocalRunnerError::invalid("codex_startup_trust_config_invalid"))?;
let root_key = root
.to_str()
.ok_or_else(|| LocalRunnerError::invalid("codex_startup_trust_non_utf8_root"))?;
if let Some(projects) = document.get("projects") {
let projects = projects
.as_table_like()
.ok_or_else(|| LocalRunnerError::invalid("codex_startup_trust_invalid_projects"))?;
if projects
.get(root_key)
.is_some_and(|project| project.as_table_like().is_none())
{
return Err(LocalRunnerError::invalid(
"codex_startup_trust_invalid_project",
));
}
}
document["projects"][root_key]["trust_level"] = value("trusted");
let temporary = home.join(format!("config.toml.{}.tmp", uuid::Uuid::new_v4()));
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let result = (|| {
let mut file = options
.open(&temporary)
.map_err(|_| LocalRunnerError::invalid("codex_startup_trust_write_failed"))?;
file.write_all(document.to_string().as_bytes())
.and_then(|_| file.sync_all())
.map_err(|_| LocalRunnerError::invalid("codex_startup_trust_write_failed"))?;
fs::rename(&temporary, path)
.map_err(|_| LocalRunnerError::invalid("codex_startup_trust_commit_failed"))
})();
let _ = fs::remove_file(temporary);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_invalid_project_tables_without_overwriting_configuration() {
let temp = std::env::temp_dir().join(format!("codex-trust-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp).unwrap();
let original = "projects = false\n";
fs::write(temp.join("config.toml"), original).unwrap();
assert!(trust_startup_root(&temp, &temp).is_err());
assert_eq!(
fs::read_to_string(temp.join("config.toml")).unwrap(),
original
);
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn trusts_non_git_and_worktree_roots_without_replacing_configuration() {
let temp = std::env::temp_dir().join(format!("codex-trust-{}", uuid::Uuid::new_v4()));
let root = temp.join("project");
let home = temp.join("home");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&home).unwrap();
fs::write(home.join("config.toml"), "model = 'test'\n# retain this comment\n[mcp_servers.test]\nurl = 'http://localhost/example'\n").unwrap();
trust_startup_root(&home, &root).unwrap();
trust_startup_root(&home, &root).unwrap();
let config = fs::read_to_string(home.join("config.toml")).unwrap();
assert!(config.contains("# retain this comment"));
let parsed = config.parse::<DocumentMut>().unwrap();
assert_eq!(parsed["model"].as_str(), Some("test"));
assert_eq!(
parsed["projects"][fs::canonicalize(&root).unwrap().to_str().unwrap()]["trust_level"]
.as_str(),
Some("trusted")
);
let git = root.join(".git");
let metadata = git.join("worktrees/branch");
fs::create_dir_all(&metadata).unwrap();
let branch = temp.join("branch");
fs::create_dir_all(&branch).unwrap();
fs::write(
branch.join(".git"),
format!("gitdir: {}", metadata.display()),
)
.unwrap();
fs::write(metadata.join("commondir"), "../..").unwrap();
assert_eq!(
execution_root(&branch).unwrap(),
fs::canonicalize(&root).unwrap()
);
trust_startup_root(&home, &branch).unwrap();
fs::remove_dir_all(temp).unwrap();
}
}

View File

@ -10,6 +10,7 @@ pub mod acpx_sidecar_transport;
pub mod aws_agentcore_provider;
pub mod claude_managed_provider;
pub mod codex_provider;
mod codex_startup_trust;
pub mod durable;
pub mod fake_harness;
pub mod generated_acpx_sidecar_contract;

View File

@ -664,6 +664,26 @@ impl SupervisedProcess {
max_line_bytes,
additional_environment_keys,
None,
None,
)
}
pub fn spawn_in_directory_with_environment_keys(
program: &Path,
args: &[String],
shutdown_grace: Duration,
max_line_bytes: usize,
additional_environment_keys: &[&str],
cwd: &Path,
) -> Result<Self, LocalRunnerError> {
Self::spawn_command(
program,
args,
shutdown_grace,
max_line_bytes,
additional_environment_keys,
None,
Some(cwd),
)
}
@ -685,6 +705,7 @@ impl SupervisedProcess {
launch
.inherit_runtime_executable
.then_some(inherited.program.as_path()),
None,
);
#[cfg(target_os = "macos")]
if let Ok(process) = result.as_mut() {
@ -715,8 +736,12 @@ impl SupervisedProcess {
max_line_bytes: usize,
additional_environment_keys: &[&str],
verified_runtime_executable: Option<&Path>,
cwd: Option<&Path>,
) -> Result<Self, LocalRunnerError> {
let mut command = Command::new(program);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
command
.args(args)
.env_clear()

View File

@ -652,6 +652,19 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<Normali
}),
);
}
"paperclip/resumeUsageSnapshot" => push(
&mut events,
"harness.diagnostic",
EventPriority::P0,
json!({
"code": "codex_resume_usage_snapshot",
"method": "thread/tokenUsage/updated",
"classification": "resume_usage_snapshot",
"receivedThreadId": params.get("threadId"),
"receivedTurnId": params.get("turnId"),
"cumulative": measurement(params.get("total").unwrap_or(&Value::Null)),
}),
),
"thread/tokenUsage/updated" => {
let cumulative = params
.get("tokenUsage")

View File

@ -5944,3 +5944,151 @@ fn durable_descendant_lineage_survives_capacity_and_provider_restoration() {
restored.shutdown().unwrap();
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn resume_usage_is_a_historical_diagnostic_not_a_warning_or_charge() {
let directory = temporary_directory("resume-usage-snapshot");
let config = provider_config(
&directory,
&["--durable-turn-ids", "--resume-usage-snapshot"],
);
let runner_config = durable_config(&directory);
let mut first = CodexCommandExecutor::with_runner_config(&directory, &runner_config);
first
.execute(&command(
"prepare",
1,
"run.prepare",
json!({
"provider": config,
"authorizedTools": task_context_tool_set(),
"completionContract": {
"revision": "sha256:settled-attach-contract",
"criterionIds": ["criterion_settled_attach"]
},
}),
))
.expect("prepare the durable provider");
first
.execute(&command("open", 2, "session.open", json!({})))
.expect("open the durable provider session");
first
.execute(&command(
"turn",
3,
"turn.start",
json!({"text": "Settle before rotating run authority."}),
))
.expect("start the provider turn");
let mut saw_terminal = false;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline {
let events = poll_and_ack(&mut first).expect("drain the settled provider turn");
saw_terminal |= events
.iter()
.any(|event| event.event_type == "run.terminal");
if saw_terminal && events.is_empty() {
break;
}
if events.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
assert!(saw_terminal, "the first run must settle before attachment");
first.shutdown().expect("stop the first provider process");
drop(first);
let mut resumed = CodexCommandExecutor::with_runner_config(&directory, &runner_config);
let mut events = Vec::new();
for _ in 0..50 {
events.extend(poll_and_ack(&mut resumed).expect("poll resume snapshots"));
if events
.iter()
.filter(|event| event.payload["code"] == "codex_resume_usage_snapshot")
.count()
== 2
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let snapshots: Vec<_> = events
.iter()
.filter(|event| event.payload["code"] == "codex_resume_usage_snapshot")
.collect();
assert_eq!(snapshots.len(), 2);
assert_eq!(snapshots[0].payload["receivedTurnId"], "provider-turn-1");
assert_eq!(snapshots[0].payload["cumulative"]["inputTokens"], 100);
assert!(!events.iter().any(|event| matches!(
event.event_type.as_str(),
"provider.notice.recorded" | "usage.reported"
)));
resumed.shutdown().unwrap();
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn lightweight_history_pages_metadata_only_when_state_is_active() {
let directory = temporary_directory("lightweight-history");
let config = provider_config(
&directory,
&[
"--hold-turn",
"--require-lightweight-history",
"--paginated-history",
],
);
let mut provider = CodexProvider::start(&config, None).unwrap();
let thread_id = provider.thread_id().to_owned();
assert_eq!(
provider.read_thread().unwrap()["thread"]["turns"],
json!([])
);
assert!(!fs::read_to_string(directory.join("calls.log"))
.unwrap()
.contains("thread/turns/list"));
provider.start_turn("Keep working", &config.cwd).unwrap();
let active = provider.active_provider_turn_id().unwrap().to_owned();
let state = provider.read_thread().unwrap();
assert_eq!(state["thread"]["turns"][0]["id"], active);
assert_eq!(state["thread"]["turns"][0]["items"], json!([]));
assert_eq!(
fs::read_to_string(directory.join("calls.log"))
.unwrap()
.matches("thread/turns/list")
.count(),
2
);
provider.shutdown().unwrap();
let mut resumed = CodexProvider::start(&config, Some(&thread_id)).unwrap();
assert_eq!(
resumed.read_thread().unwrap()["thread"]["turns"][0]["id"],
active
);
resumed.shutdown().unwrap();
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn lightweight_history_repeated_cursor_is_not_idle_evidence() {
let directory = temporary_directory("repeated-history-cursor");
let config = provider_config(
&directory,
&[
"--hold-turn",
"--require-lightweight-history",
"--repeat-history-cursor",
],
);
let mut provider = CodexProvider::start(&config, None).unwrap();
provider.start_turn("Keep working", &config.cwd).unwrap();
assert!(provider
.read_thread()
.unwrap_err()
.to_string()
.contains("repeated turn cursor"));
assert!(provider.active_provider_turn_id().is_some());
provider.shutdown().unwrap();
fs::remove_dir_all(directory).unwrap();
}

View File

@ -97,6 +97,32 @@ const driver: HarnessDriver = {
};
describe("HarnessDriverBackend", () => {
it("retains Codex accounting and startup state through a serialized native checkpoint", async () => {
const fields = { workingDirectory: "/workspace/selected", codexUsageBaseline: {
baseline: { inputTokens: 100, outputTokens: 20 },
latest: { inputTokens: 150, outputTokens: 35 },
} };
class RecoveryFieldsSession extends FakeHarnessSession {
override async snapshot(): Promise<PersistedHarnessSession> {
return { ...(await super.snapshot()), ...fields, semanticResult: undefined, activeTurnId: null };
}
}
const original = new HarnessDriverBackend({ ...driver, openSession: async () => new RecoveryFieldsSession() });
const session = await original.openSession({ identity: {
runId: "run-1", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1",
}, workingDirectory: fields.workingDirectory });
const checkpoint = JSON.parse(JSON.stringify(await session.snapshot()));
expect(checkpoint).toMatchObject(fields);
const recover = vi.fn(async (_snapshot: PersistedHarnessSession) => ({ recovered: true, session: new RecoveryFieldsSession() }));
const restarted = new HarnessDriverBackend({ ...driver, recoverSession: recover });
const restored = await restarted.recoverSession(checkpoint, { signal: new AbortController().signal });
expect(restored.recovered).toBe(true);
expect(recover.mock.calls[0]![0]).toMatchObject(fields);
expect(await restored.session!.snapshot()).toMatchObject(fields);
checkpoint.codexUsageBaseline.latest.inputTokens = 999;
expect(recover.mock.calls[0]![0].codexUsageBaseline!.latest.inputTokens).toBe(150);
});
it("rejects and closes a provider session without a durable provider identity", async () => {
let closed = false;
class MissingProviderIdentitySession extends FakeHarnessSession {

View File

@ -120,6 +120,12 @@ export class HarnessDriverBackend implements NativeSessionBackend {
...(snapshot.providerIdentity === undefined
? {}
: { providerIdentity: structuredClone(snapshot.providerIdentity) }),
...(snapshot.workingDirectory === undefined
? {}
: { workingDirectory: snapshot.workingDirectory }),
...(snapshot.codexUsageBaseline === undefined
? {}
: { codexUsageBaseline: structuredClone(snapshot.codexUsageBaseline) }),
...(snapshot.providerRecoveryPolicy === undefined
? {}
: { providerRecoveryPolicy: snapshot.providerRecoveryPolicy }),
@ -774,6 +780,12 @@ class HarnessNativeSession implements NativeSession {
...(snapshot.providerIdentity === undefined
? {}
: { providerIdentity: structuredClone(snapshot.providerIdentity) }),
...(snapshot.workingDirectory === undefined
? {}
: { workingDirectory: snapshot.workingDirectory }),
...(snapshot.codexUsageBaseline === undefined
? {}
: { codexUsageBaseline: structuredClone(snapshot.codexUsageBaseline) }),
...(snapshot.providerRecoveryPolicy === undefined
? {}
: { providerRecoveryPolicy: snapshot.providerRecoveryPolicy }),

View File

@ -461,6 +461,8 @@ export type PersistedHarnessProviderIdentity = AcpxSessionIdentity;
export interface PersistedHarnessSession {
driverKind: string;
/** Execution-host startup root, revalidated before a cold provider launch. */
workingDirectory?: string;
driverSessionId: string;
providerSessionId?: string | null;
runId?: string;
@ -468,6 +470,7 @@ export interface PersistedHarnessSession {
activeTurnId?: string | null;
semanticResult?: PersistedHarnessSemanticResult | null;
terminalTurns?: PersistedHarnessTurnTerminal[];
codexUsageBaseline?: { baseline: Record<string, number>; latest: Record<string, number> };
/** A result-less terminal task may spend this fail-closed one-shot recovery allowance. */
dispositionOnlyRecoveryConsumed?: boolean;
/** Exact accepted provider turn that spent the disposition-only allowance. */

View File

@ -17,6 +17,7 @@ import type {
HarnessThreadLineageEntry,
NativeRuntimeContextCapabilities,
PersistedHarnessProviderIdentity,
PersistedHarnessSession,
PersistedHarnessTurnTerminal,
} from "./harness-driver.js";
@ -51,6 +52,8 @@ export interface PersistedNativeSession {
providerSessionId?: string | null;
/** Tagged provider-owned identity required for safe driver recovery. */
providerIdentity?: PersistedHarnessProviderIdentity;
workingDirectory?: string;
codexUsageBaseline?: PersistedHarnessSession["codexUsageBaseline"];
providerRecoveryPolicy?:
| "same_session_only"
| "allow_replacement_after_governed_wait"

View File

@ -338,6 +338,7 @@ interface PendingRequest {
}
export interface ProcessCodexTransportOptions {
workingDirectory?: string;
command?: string;
args?: string[];
environment?: NodeJS.ProcessEnv;
@ -431,6 +432,7 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
options.command ?? "codex",
options.args ?? ["app-server"],
{
cwd: options.workingDirectory,
env: options.environment ?? createSanitizedCodexEnvironment(),
stdio: "pipe",
detached: this.#processGroup,

View File

@ -1,3 +1,5 @@
import { trustCodexStartupRoot } from "./codex-startup-trust.js";
import { readCodexThreadState, readCodexTurnMetadata } from "./codex-history.js";
import { codexExecutableReadOnlyRoots } from "./codex-security-config.js";
import { resolve } from "node:path";
@ -36,8 +38,6 @@ import {
validateCodexWorkingDirectory as validateWorkingDirectory,
} from "./codex-boundaries.js";
import {
CODEX_PLANNING_PERMISSION_PROFILE as PLANNING_PERMISSION_PROFILE,
CODEX_SKILLLESS_PERMISSION_PROFILE as SKILLLESS_PERMISSION_PROFILE,
codexCommandEnvironment,
createIsolatedCodexAppServerArgs,
codexNetworkAccess,
@ -255,7 +255,7 @@ export class CodexAppServerDriver implements HarnessDriver {
this.#options.environment,
this.#options.workingDirectoryAuthority,
);
const transport = this.#transport();
const transport = this.#transport({ workingDirectory });
const cancellation = bootstrapCancellation(transport, input.signal);
try {
await cancellation.wait(this.#persistProcessOwnership(transport));
@ -359,6 +359,9 @@ export class CodexAppServerDriver implements HarnessDriver {
};
}
const transport = this.#transport({
workingDirectory: snapshot.workingDirectory
? validateWorkingDirectory(snapshot.workingDirectory, this.#options.environment, this.#options.workingDirectoryAuthority)
: undefined,
providerRecoveryPolicy: snapshot.providerRecoveryPolicy,
persistedSession: {
driverSessionId: snapshot.driverSessionId,
@ -372,13 +375,11 @@ export class CodexAppServerDriver implements HarnessDriver {
await cancellation.wait(this.#persistProcessOwnership(transport));
const initialize = await cancellation.wait(this.#initialize(transport));
const existing = await cancellation.wait(
transport.request("thread/read", {
threadId: snapshot.driverSessionId,
includeTurns: true,
}),
readCodexThreadState(transport, snapshot.driverSessionId),
);
await cancellation.wait(this.#persistProcessOwnership(transport));
const existingThread = record(existing.thread);
existingThread.turns = await cancellation.wait(readCodexTurnMetadata(transport, snapshot.driverSessionId));
if (text(existingThread.id) !== snapshot.driverSessionId) {
await cancellation.wait(cancellation.close());
return {
@ -393,6 +394,7 @@ export class CodexAppServerDriver implements HarnessDriver {
);
const response = await cancellation.wait(
transport.request("thread/resume", {
excludeTurns: true,
threadId: snapshot.driverSessionId,
...createSecuredCodexThreadParams(
workingDirectory,
@ -600,6 +602,7 @@ export class CodexAppServerDriver implements HarnessDriver {
activeTurnId: recoveredActiveTurnId,
semanticResult: snapshot.semanticResult ?? null,
terminalTurns: snapshot.terminalTurns ?? [],
codexUsageBaseline: snapshot.codexUsageBaseline,
dispositionOnlyRecoveryConsumed,
dispositionOnlyRecoveryTurnId,
stalePendingRuntimeRequests: snapshot.pendingRuntimeRequests ?? [],
@ -633,6 +636,7 @@ export class CodexAppServerDriver implements HarnessDriver {
}
#transport(context?: {
workingDirectory?: string;
providerRecoveryPolicy?: PersistedHarnessSession["providerRecoveryPolicy"];
persistedSession?: Pick<
PersistedHarnessSession,
@ -642,9 +646,14 @@ export class CodexAppServerDriver implements HarnessDriver {
| "activeTurnId"
>;
}): CodexAppServerTransport {
const workingDirectory = context?.workingDirectory ?? this.#options.environment?.PAPERCLIP_WORKSPACE_CWD;
if (!this.#options.transportFactory && this.#options.environment?.CODEX_HOME && workingDirectory) {
trustCodexStartupRoot(this.#options.environment.CODEX_HOME, workingDirectory);
}
return (
this.#options.transportFactory?.(context) ??
new ProcessCodexAppServerTransport({
workingDirectory,
args: createIsolatedCodexAppServerArgs(this.#options.environment, codexExecutableReadOnlyRoots(this.#options.environment ?? process.env)),
environment: createSanitizedCodexEnvironment(this.#options.environment),
onDiagnostic: this.#options.onDiagnostic,
@ -790,9 +799,7 @@ export class CodexAppServerDriver implements HarnessDriver {
const permissionProfileId = text(activePermissionProfile.id);
const requestedMode = this.#options.requestedCollaborationMode ?? "default";
const requiredPermissionProfile =
requestedMode === "plan"
? PLANNING_PERMISSION_PROFILE
: SKILLLESS_PERMISSION_PROFILE;
text(createSecuredCodexThreadParams(workingDirectory, requestedMode, true, false, this.#options.environment).permissions);
if (
permissionProfileId.length > 0 &&
permissionProfileId !== requiredPermissionProfile
@ -891,6 +898,7 @@ export class CodexAppServerDriver implements HarnessDriver {
activeTurnId?: string | null;
semanticResult?: PersistedHarnessSemanticResult | null;
terminalTurns?: PersistedHarnessTurnTerminal[];
codexUsageBaseline?: PersistedHarnessSession["codexUsageBaseline"];
dispositionOnlyRecoveryConsumed?: boolean;
dispositionOnlyRecoveryTurnId?: string | null;
stalePendingRuntimeRequests?: HarnessRuntimeRequest[];

View File

@ -370,6 +370,7 @@ describe("Codex app-server Codex driver", () => {
expect(recovered.recovered).toBe(true);
expect(transportFactory).toHaveBeenCalledWith({
workingDirectory: snapshot.workingDirectory,
providerRecoveryPolicy: snapshot.providerRecoveryPolicy,
persistedSession: {
driverSessionId: snapshot.driverSessionId,

View File

@ -91,7 +91,7 @@ describe("Codex app-server Codex driver", () => {
second.readResponse = { thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE, turns } };
await original.close({ reason: "controller lost" });
await expect(driver.recoverSession?.(snapshot)).resolves.toEqual({
recovered: false, reason: "provider exposed ambiguous autonomous goal turn history",
recovered: false, reason: expect.stringMatching(/ambiguous autonomous goal turn history|codex_history_incomplete/),
});
expect(second.calls.some((call) => call.method === "turn/start" || call.method === "thread/goal/set")).toBe(false);
});
@ -254,10 +254,13 @@ describe("Codex app-server Codex driver", () => {
expect(second.calls.map((call) => call.method)).toEqual([
"initialize",
"thread/read",
"thread/turns/list",
"thread/resume",
"thread/goal/get",
"thread/read",
"thread/turns/list",
"thread/read",
"thread/turns/list",
]);
expect((await recovery?.session?.snapshot())?.activeTurnId).toBe("turn-1");
});
@ -845,23 +848,10 @@ describe("Codex app-server Codex driver", () => {
dispositionOnlyRecoveryTurnId:
testCase.dispositionOnlyRecoveryTurnId,
});
expect(recovery).toMatchObject({ recovered: true });
await expect(recovery!.session!.snapshot()).resolves.toMatchObject({
activeTurnId: null,
dispositionOnlyRecoveryConsumed: true,
dispositionOnlyRecoveryTurnId:
testCase.dispositionOnlyRecoveryTurnId,
});
await expect(recovery!.session!.startTurn({
message: {
role: "user",
text: "Do not repeat the task while provider history is unknown.",
},
})).rejects.toThrow("session cannot start another turn");
expect(recovery).toMatchObject({ recovered: false, reason: expect.stringContaining("codex_history_incomplete") });
expect(
second.calls.filter((call) => call.method === "turn/start"),
).toHaveLength(0);
await recovery!.session!.close({ reason: "test complete" });
},
);
}

View File

@ -156,9 +156,9 @@ export class FakeCodexTransport implements CodexAppServerTransport {
cwd: WORKSPACE,
turns: [],
activePermissionProfile: {
id: planMode
id: params.permissions ?? (planMode
? "paperclip-runner-workspace-read-only"
: "paperclip-runner-workspace-only",
: "paperclip-runner-workspace-only"),
},
},
model: "gpt-test",
@ -199,8 +199,18 @@ export class FakeCodexTransport implements CodexAppServerTransport {
this.goalState = null;
return {};
}
if (method === "thread/turns/list") {
const snapshot = this.readResponse ?? { thread: { turns: [{ id: "turn-1", status: "inProgress", items: [] }] } };
const turns = (snapshot.thread as Record<string, unknown>).turns;
return { data: Array.isArray(turns) ? turns.map(turn => ({ ...turn, items: [], itemsView: "notLoaded" })) : turns, nextCursor: null };
}
if (method === "thread/items/list") {
const turns = ((this.readResponse?.thread as Record<string, unknown> | undefined)?.turns ?? []) as Array<Record<string, unknown>>;
const turn = turns.find(value => value.id === params.turnId);
return { data: ((turn?.items ?? []) as Array<Record<string, unknown>>).map(item => ({ turnId: params.turnId, item })), nextCursor: null };
}
if (method === "thread/read") {
return (
return structuredClone(
this.readResponse ?? {
thread: {
id: this.threadId,

View File

@ -155,9 +155,9 @@ class FakeCodexTransport implements CodexAppServerTransport {
cwd: TEST_WORKING_DIRECTORY,
turns: [],
activePermissionProfile: {
id: planMode
id: params.permissions ?? (planMode
? "paperclip-runner-workspace-read-only"
: "paperclip-runner-workspace-only",
: "paperclip-runner-workspace-only"),
},
},
model: "gpt-test",
@ -198,8 +198,18 @@ class FakeCodexTransport implements CodexAppServerTransport {
this.goalState = null;
return {};
}
if (method === "thread/turns/list") {
const snapshot = this.readResponse ?? { thread: { turns: [{ id: "turn-1", status: "inProgress", items: [] }] } };
const turns = (snapshot.thread as Record<string, unknown>).turns;
return { data: Array.isArray(turns) ? turns.map(turn => ({ ...turn, items: [], itemsView: "notLoaded" })) : turns, nextCursor: null };
}
if (method === "thread/items/list") {
const turns = ((this.readResponse?.thread as Record<string, unknown> | undefined)?.turns ?? []) as Array<Record<string, unknown>>;
const turn = turns.find(value => value.id === params.turnId);
return { data: ((turn?.items ?? []) as Array<Record<string, unknown>>).map(item => ({ turnId: params.turnId, item })), nextCursor: null };
}
if (method === "thread/read") {
return (
return structuredClone(
this.readResponse ?? {
thread: {
id: this.threadId,
@ -3370,10 +3380,13 @@ describe("Codex app-server Codex driver", () => {
expect(second.calls.map((call) => call.method)).toEqual([
"initialize",
"thread/read",
"thread/turns/list",
"thread/resume",
"thread/goal/get",
"thread/read",
"thread/turns/list",
"thread/read",
"thread/turns/list",
]);
expect((await recovery?.session?.snapshot())?.activeTurnId).toBe("turn-1");
});

View File

@ -1,3 +1,5 @@
import { readCodexThreadState, readCodexTurnMetadata, readCodexTurnItems } from "./codex-history.js";
import { codexRunUsage } from "./codex-usage-baseline.js";
import { randomUUID } from "node:crypto";
import { NativeProviderTerminalFailure } from "../../contracts/native-session-backend.js";
@ -105,6 +107,10 @@ export class CodexHarnessSession
this.pendingRuntimeRequestMap.clear();
this.eventQueue.clear();
}
if (this.codexUsageBaseline && input.runId !== this.runId) {
this.codexUsageBaseline = { baseline: { ...this.codexUsageBaseline.latest }, latest: { ...this.codexUsageBaseline.latest } };
this.usageSnapshot = codexRunUsage(this.codexUsageBaseline);
}
this.runId = input.runId;
this.result = null;
this.resultFingerprint = null;
@ -202,9 +208,9 @@ export class CodexHarnessSession
threadId: this.opened.threadId,
cwd: this.opened.context.workingDirectory,
permissions:
requestedMode === "plan"
text(record(record(this.opened.context.sandbox).permissionProfile).id) || (requestedMode === "plan"
? PLANNING_PERMISSION_PROFILE
: SKILLLESS_PERMISSION_PROFILE,
: SKILLLESS_PERMISSION_PROFILE),
runtimeWorkspaceRoots: [this.opened.context.workingDirectory],
...(this.opened.collaborationMode === null
? {}
@ -615,10 +621,7 @@ export class CodexHarnessSession
this.assertProtocolIntegrity();
this.requireCapability("read");
try {
const snapshot = await this.transport.request("thread/read", {
threadId: this.opened.threadId,
includeTurns: true,
});
const snapshot = await readCodexThreadState(this.transport, this.opened.threadId);
this.assertProtocolIntegrity();
return snapshot;
} catch (error) {
@ -646,7 +649,15 @@ export class CodexHarnessSession
"thread/read returned a different provider session",
);
}
const turns = Array.isArray(thread.turns) ? thread.turns.map(record) : [];
const turns = await readCodexTurnMetadata(this.transport, this.opened.threadId);
thread.turns = turns;
for (const turn of turns) {
if ((text(turn.id) === this.activeTurnId || this.terminalTurns.has(text(turn.id)))
&& text(turn.status) !== "inProgress") {
turn.items = await readCodexTurnItems(this.transport, this.opened.threadId, text(turn.id));
turn.itemsView = "full";
}
}
const reconciledUsage = boundedPayload(
record(thread.tokenUsage ?? snapshot.tokenUsage),
);
@ -730,6 +741,7 @@ export class CodexHarnessSession
this.assertProtocolIntegrity();
return {
driverKind: this.driverKind,
workingDirectory: this.opened.context.workingDirectory,
driverSessionId: this.opened.threadId,
providerSessionId: this.opened.providerSessionId,
...(this.opened.providerIdentity === undefined
@ -749,6 +761,7 @@ export class CodexHarnessSession
callId: this.resultCallId,
turnId: this.resultTurnId,
},
...(this.codexUsageBaseline ? { codexUsageBaseline: structuredClone(this.codexUsageBaseline) } : {}),
terminalTurns: [...this.terminalTurns].map(([turnId, fingerprint]) => ({
turnId,
fingerprint,

View File

@ -0,0 +1,112 @@
import { describe, it, expect, vi } from "vitest";
import {
readCodexThreadState,
readCodexTurnMetadata,
readCodexTurnItems,
} from "./codex-history.js";
describe("Codex paginated history", () => {
it("reads state without hydrating history", async () => {
const request = vi.fn().mockResolvedValue({ thread: { id: "thread" } });
await readCodexThreadState({ request }, "thread");
expect(request).toHaveBeenCalledExactlyOnceWith("thread/read", {
threadId: "thread",
includeTurns: false,
});
});
it("follows empty pages and preserves ordering while updating duplicate turn anchors", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({
data: [{ id: "one", status: "inProgress" }],
nextCursor: "a",
})
.mockResolvedValueOnce({ data: [], nextCursor: "b" })
.mockResolvedValueOnce({
data: [
{ id: "one", status: "completed" },
{ id: "two", status: "inProgress" },
],
nextCursor: null,
});
expect(await readCodexTurnMetadata({ request }, "thread")).toEqual([
{ id: "one", status: "completed" },
{ id: "two", status: "inProgress" },
]);
expect(request.mock.calls.map((call) => call[1].cursor)).toEqual([
undefined,
"a",
"b",
]);
expect(
request.mock.calls.every((call) => call[1].itemsView === "notLoaded"),
).toBe(true);
});
it("loads full items only for the requested turn and deduplicates stable IDs", async () => {
const command = {
turnId: "one",
item: { id: "cmd", type: "commandExecution", exitCode: 0 },
};
const answer = {
turnId: "one",
item: { id: "answer", type: "agentMessage", text: "saved answer" },
};
const request = vi
.fn()
.mockResolvedValueOnce({ data: [command], nextCursor: "a" })
.mockResolvedValueOnce({ data: [command, answer] });
expect(await readCodexTurnItems({ request }, "thread", "one")).toEqual([
command.item,
answer.item,
]);
expect(
request.mock.calls.every(
(call) => call[0] === "thread/items/list" && call[1].turnId === "one",
),
).toBe(true);
});
it.each([
[
{ data: [], nextCursor: "a" },
{ data: [], nextCursor: "a" },
],
[{ data: [], nextCursor: "a" }, {}],
[{ data: [{ id: "one" }] }],
])("rejects incomplete history", async (...pages) => {
const request = vi.fn();
for (const page of pages) request.mockResolvedValueOnce(page);
await expect(readCodexTurnMetadata({ request }, "thread")).rejects.toThrow(
"codex_history_incomplete",
);
});
it("does not turn an interrupted page or unsupported method into idle history", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({ data: [], nextCursor: "next" })
.mockRejectedValueOnce(new Error("method not found"));
await expect(readCodexTurnMetadata({ request }, "thread")).rejects.toThrow(
"supported paginated history",
);
});
it("rejects another thread or turn", async () => {
await expect(
readCodexThreadState(
{ request: vi.fn().mockResolvedValue({ thread: { id: "other" } }) },
"thread",
),
).rejects.toThrow("different driver session");
await expect(
readCodexTurnItems(
{
request: vi
.fn()
.mockResolvedValue({
data: [{ turnId: "other", item: { id: "a" } }],
}),
},
"thread",
"one",
),
).rejects.toThrow("different turn");
});
});

View File

@ -0,0 +1,119 @@
import type { CodexAppServerTransport } from "./app-server-transport.js";
import { HarnessReconciliationError } from "../../contracts/harness-driver.js";
import { record, text } from "./codex-driver-values.js";
type Requester = Pick<CodexAppServerTransport, "request">;
/** A partial or unsupported provider read is never evidence that work is idle. */
async function pages(
transport: Requester,
method: "thread/turns/list" | "thread/items/list",
params: Record<string, unknown>,
identity: (value: Record<string, unknown>) => string,
): Promise<Record<string, unknown>[]> {
const values = new Map<string, Record<string, unknown>>();
const cursors = new Set<string>();
let cursor: string | undefined;
for (let page = 0; page < 10_000; page += 1) {
let response: Record<string, unknown>;
try {
response = await transport.request(method, {
...params,
limit: 100,
...(cursor ? { cursor } : {}),
});
} catch (error) {
throw new HarnessReconciliationError(
`codex_history_read_failed: ${method} requires supported paginated history; ${String(error)}`,
);
}
if (!Array.isArray(response.data))
throw new HarnessReconciliationError(
`codex_history_incomplete: ${method} omitted data`,
);
for (const raw of response.data) {
const value = record(raw);
const id = identity(value);
if (!id)
throw new HarnessReconciliationError(
`codex_history_incomplete: ${method} omitted an identity`,
);
// Later pages can repeat the cursor anchor with its newly completed state.
values.set(id, value);
}
const next = response.nextCursor;
if (next == null) return [...values.values()];
if (typeof next !== "string" || !next || cursors.has(next)) {
throw new HarnessReconciliationError(
`codex_history_incomplete: ${method} repeated or invalid cursor`,
);
}
cursors.add(next);
cursor = next;
}
throw new HarnessReconciliationError(
`codex_history_incomplete: ${method} page limit exceeded`,
);
}
export async function readCodexThreadState(
transport: Requester,
threadId: string,
): Promise<Record<string, unknown>> {
const snapshot = await transport.request("thread/read", {
threadId,
includeTurns: false,
});
if (text(record(snapshot.thread).id) !== threadId)
throw new HarnessReconciliationError(
"thread/read returned a different driver session",
);
return snapshot;
}
export function readCodexTurnMetadata(
transport: Requester,
threadId: string,
): Promise<Record<string, unknown>[]> {
return pages(
transport,
"thread/turns/list",
{ threadId, sortDirection: "asc", itemsView: "notLoaded" },
(value) => {
if (
![
"inProgress",
"completed",
"failed",
"interrupted",
"cancelled",
].includes(text(value.status))
) {
throw new HarnessReconciliationError(
"codex_history_incomplete: invalid turn status",
);
}
return text(value.id);
},
);
}
export async function readCodexTurnItems(
transport: Requester,
threadId: string,
turnId: string,
): Promise<Record<string, unknown>[]> {
const entries = await pages(
transport,
"thread/items/list",
{ threadId, turnId, sortDirection: "asc" },
(value) => {
if (text(value.turnId) !== turnId)
throw new HarnessReconciliationError(
"thread/items/list returned a different turn",
);
return text(record(value.item).id);
},
);
return entries.map((entry) => record(entry.item));
}

View File

@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import {
FakeCodexTransport,
makeDriver,
WORKSPACE,
collectUntilTerminal,
} from "./codex-app-server-driver.test-support.js";
describe("Codex resume accounting through the production driver", () => {
it("retains a run delta across repeated historical snapshots and a cold resume", async () => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
const driver = makeDriver([first, second], { conversationMode: "direct" });
let session = await driver.openSession({
runId: "first",
normalizedSessionId: "session",
workingDirectory: WORKSPACE,
});
await session.startTurn({ message: { role: "user", text: "First" } });
first.push("thread/tokenUsage/updated", {
threadId: "thread-1",
turnId: "turn-1",
tokenUsage: {
total: { inputTokens: 100, outputTokens: 10 },
last: { inputTokens: 100, outputTokens: 10 },
},
});
first.push("turn/completed", {
threadId: "thread-1",
turn: { id: "turn-1", status: "completed", items: [] },
});
await collectUntilTerminal(session.events());
const persisted = JSON.parse(JSON.stringify(await session.snapshot!()));
await session.close();
second.readResponse = {
thread: {
id: "thread-1",
sessionId: "provider-session-1",
cwd: WORKSPACE,
turns: [{ id: "turn-1", status: "completed", items: [] }],
},
};
const recovered = await driver.recoverSession(persisted);
expect(recovered.recovered).toBe(true);
session = recovered.session!;
await session.attachRun!({ runId: "second" });
for (let i = 0; i < 3; i++)
second.push("thread/tokenUsage/updated", {
threadId: "thread-1",
turnId: "turn-1",
tokenUsage: {
total: { inputTokens: 100, outputTokens: 10 },
last: { inputTokens: 100, outputTokens: 10 },
},
});
// Consume the historical diagnostics before admitting the next turn.
const iterator = session.events()[Symbol.asyncIterator]();
let historical = 0;
while (historical < 3) {
const { value } = await iterator.next();
expect(value.eventType).not.toBe("provider.notice.recorded");
if (value.payload.code === "codex_resume_usage_snapshot") historical++;
expect(value.payload.kind).not.toBe("usage");
}
expect((await session.snapshot!()).codexUsageBaseline).toEqual({
baseline: { inputTokens: 100, outputTokens: 10 },
latest: { inputTokens: 100, outputTokens: 10 },
});
second.turnStartResponse = Promise.resolve({
turn: { id: "turn-2", status: "inProgress", items: [] },
});
await session.startTurn({ message: { role: "user", text: "Second" } });
for (let i = 0; i < 2; i++)
second.push("thread/tokenUsage/updated", {
threadId: "thread-1",
turnId: "turn-2",
tokenUsage: {
total: { inputTokens: 140, outputTokens: 16 },
last: { inputTokens: 20, outputTokens: 3 },
},
});
second.push("thread/tokenUsage/updated", {
threadId: "foreign-thread",
turnId: "turn-2",
tokenUsage: { total: { inputTokens: 9999 } },
});
second.push("turn/completed", {
threadId: "thread-1",
turn: { id: "turn-2", status: "completed", items: [] },
});
const events = await collectUntilTerminal(session.events());
const usages = events.filter((event) => event.payload.kind === "usage");
expect(usages).toHaveLength(2);
for (const event of usages)
expect(event.payload.usage).toMatchObject({
runDelta: { inputTokens: 40, outputTokens: 6 },
});
expect(
(await session.snapshot!()).codexUsageBaseline?.latest.inputTokens,
).toBe(140);
await session.attachRun!({ runId: "second" });
expect(
(await session.snapshot!()).codexUsageBaseline?.baseline.inputTokens,
).toBe(100);
expect(await session.usage!()).toMatchObject({
runDelta: { inputTokens: 40, outputTokens: 6 },
});
expect(
second.calls.filter((call) => call.method === "thread/resume")[0]?.params
.excludeTurns,
).toBe(true);
expect(
second.calls
.filter((call) => call.method === "thread/read")
.every((call) => call.params.includeTurns === false),
).toBe(true);
await session.close();
});
it("keeps the startup external sandbox profile on subsequent turns", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport], {
conversationMode: "direct",
environment: {
PATH: "/bin",
HOME: "/isolated/home",
CODEX_HOME: "/isolated/codex",
PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1",
},
}).openSession({
runId: "external",
normalizedSessionId: "external-session",
workingDirectory: WORKSPACE,
});
await session.startTurn({ message: { role: "user", text: "Read notes" } });
const start = transport.calls.find(
(call) => call.method === "thread/start",
)!;
expect(
transport.calls.find((call) => call.method === "turn/start")?.params
.permissions,
).toBe(start.params.permissions);
await session.close();
});
});

View File

@ -1,3 +1,4 @@
import { observeCodexUsage, codexRunUsage } from "./codex-usage-baseline.js";
import { classifyCodexNotification } from "./codex-notification-identity.js";
import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js";
import { canonicalProviderEventsFromCodex, isCanonicalProviderEventType } from "../../provider-events.js";
@ -112,6 +113,20 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex
rootThreadId: state.opened.threadId, activeTurnId: state.activeTurnId,
knownThreads: new Set(state.lineageByThread.keys()), settledTurns: new Set(state.terminalTurns.keys()),
});
if ((identity.classification === "root" || identity.classification === "stale_turn")
&& identity.threadId === state.opened.threadId
&& notification.method === "thread/tokenUsage/updated"
&& identity.turnId !== null
&& identity.turnId !== state.activeTurnId
&& (state.activeTurnId === null || (identity.turnId !== null && state.terminalTurns.has(identity.turnId)))) {
state.codexUsageBaseline = observeCodexUsage(state.codexUsageBaseline, record(notification.params.tokenUsage).total, true);
state.usageSnapshot = codexRunUsage(state.codexUsageBaseline);
if (state.notificationIdentityDiagnostics++ < 32) state.emit("harness.diagnostic", {
code: "codex_resume_usage_snapshot", classification: "resume_usage_snapshot",
receivedThreadId: identity.threadId, receivedTurnId: identity.turnId,
});
return;
}
if (identity.classification !== "root") {
if (state.notificationIdentityDiagnostics < 32) {
state.notificationIdentityDiagnostics += 1;
@ -140,7 +155,12 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex
state.failProtocol("provider_event_type_invalid", "Unknown canonical provider event type.");
return;
}
state.emit(params.eventType, record(params.payload), { turnId: turnId || undefined, itemId: itemId || undefined });
const canonicalPayload = record(params.payload);
if (params.eventType === "harness.diagnostic" && canonicalPayload.code === "codex_resume_usage_snapshot") {
state.codexUsageBaseline = observeCodexUsage(state.codexUsageBaseline, canonicalPayload.cumulative, true);
state.usageSnapshot = codexRunUsage(state.codexUsageBaseline);
}
state.emit(params.eventType, canonicalPayload, { turnId: turnId || undefined, itemId: itemId || undefined });
return;
}
if (notification.method === "paperclip/workspaceChange/updated") {
@ -513,6 +533,10 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex
}
if (notification.method === "thread/tokenUsage/updated") {
state.usageSnapshot = boundedPayload(record(params.tokenUsage));
if (state.driverKind === "codex_app_server" && Object.keys(record(record(params.tokenUsage).total)).length > 0) {
state.codexUsageBaseline = observeCodexUsage(state.codexUsageBaseline, record(params.tokenUsage).total, false);
state.usageSnapshot = { ...state.usageSnapshot, ...codexRunUsage(state.codexUsageBaseline) };
}
// Codex can replay a thread-scoped usage snapshot while a resumed thread
// is being attached, before the next turn has started. Keep the snapshot,
// but do not turn that benign replay into a fatal turn-binding violation.

View File

@ -1,3 +1,4 @@
import { type CodexUsageBaseline, codexRunUsage } from "./codex-usage-baseline.js";
import type {
HarnessRuntimeRequest,
HarnessThreadGoal,
@ -105,6 +106,7 @@ export class CodexSessionState {
sourceSequence: number;
activeTurnId: string | null;
usageSnapshot: Record<string, unknown> | null = null;
codexUsageBaseline: CodexUsageBaseline | null = null;
result: PrpStructuredRunResult | null = null;
resultFingerprint: string | null = null;
resultCallId: string | null = null;
@ -154,6 +156,7 @@ export class CodexSessionState {
activeTurnId?: string | null;
semanticResult?: PersistedHarnessSemanticResult | null;
terminalTurns?: PersistedHarnessTurnTerminal[];
codexUsageBaseline?: CodexUsageBaseline;
dispositionOnlyRecoveryConsumed?: boolean;
dispositionOnlyRecoveryTurnId?: string | null;
stalePendingRuntimeRequests?: HarnessRuntimeRequest[];
@ -171,6 +174,8 @@ export class CodexSessionState {
dynamicTools: readonly Readonly<Record<string, unknown>>[];
dynamicToolHandler?: CodexAppServerDriverOptions["dynamicToolHandler"];
}) {
this.codexUsageBaseline = input.codexUsageBaseline ?? null;
if (this.codexUsageBaseline) this.usageSnapshot = codexRunUsage(this.codexUsageBaseline);
this.transport = input.transport;
this.runId = input.runId;
this.sourceSequence = 0;

View File

@ -0,0 +1,129 @@
import {
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
realpathSync,
symlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { describe, it, expect } from "vitest";
import { parse } from "smol-toml";
import { trustCodexStartupRoot } from "./codex-startup-trust.js";
describe("isolated Codex startup trust", () => {
it.each(["missing", "table", "empty-table", "inline", "empty-inline", "inline-projects"])(
"preserves managed comments and unrelated content with %s project configuration", (kind) => {
const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-comments-")));
try {
const cwd = join(temp, "project"); const home = join(temp, "home");
mkdirSync(cwd); mkdirSync(home);
const root = JSON.stringify(cwd);
const project = {
missing: "",
table: `[projects.${root}]\ntrust_level = "untrusted" # retain trust comment\n`,
"empty-table": `[projects.${root}] # retain table comment\n`,
inline: `[projects]\n${root} = {trust_level = 'untrusted', label = "unchanged"}\n`,
"empty-inline": `[projects]\n${root} = {} # retain inline comment\n`,
"inline-projects": `projects = {${root} = {label = "unchanged"}}\n`,
}[kind]!;
const managed = '# BEGIN PAPERCLIP MANAGED MCP\n[mcp_servers.fixture]\nurl = "https://example.invalid/old"\n# END PAPERCLIP MANAGED MCP\n';
const misleading = 'text = """\n[projects.fake]\ntrust_level = "untrusted"\n"""\n';
const original = (kind === "inline-projects" ? project + misleading : misleading + project) + managed;
writeFileSync(join(home, "config.toml"), original);
const expected = parse(original);
expected.projects ??= {};
const projects = expected.projects as Record<string, Record<string, unknown>>;
projects[cwd] = { ...projects[cwd], trust_level: "trusted" };
trustCodexStartupRoot(home, cwd);
const updated = readFileSync(join(home, "config.toml"), "utf8");
expect(parse(updated)).toEqual(expected);
expect(updated).toContain(managed);
expect(updated).toContain(misleading);
trustCodexStartupRoot(home, cwd);
expect(readFileSync(join(home, "config.toml"), "utf8")).toBe(updated);
const refreshed = updated.replace(managed, managed.replace('/old', '/new'));
expect((parse(refreshed).mcp_servers as Record<string, {url: string}>).fixture.url).toBe('https://example.invalid/new');
} finally { rmSync(temp, { recursive: true, force: true }); }
},
);
it("trusts the exact non-Git root and preserves unrelated config", () => {
const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-")));
try {
const cwd = join(temp, "project");
const home = join(temp, "home");
mkdirSync(cwd);
mkdirSync(home);
writeFileSync(
join(home, "config.toml"),
'model = "test"\n[mcp_servers.fixture]\nurl = "http://localhost/example"\n',
);
trustCodexStartupRoot(home, cwd);
trustCodexStartupRoot(home, cwd);
expect(parse(readFileSync(join(home, "config.toml"), "utf8"))).toEqual({
model: "test",
mcp_servers: { fixture: { url: "http://localhost/example" } },
projects: { [cwd]: { trust_level: "trusted" } },
});
expect(readFileSync(join(home, "config.toml"), "utf8")).not.toContain(
`[projects."${temp}"]`,
);
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
it("uses the canonical main repository key for worktrees and symlinks", () => {
const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-")));
try {
const main = join(temp, "main");
const worktree = join(temp, "branch");
const home = join(temp, "home");
mkdirSync(main);
execFileSync("git", ["init", main], { stdio: "ignore" });
execFileSync(
"git",
[
"-C",
main,
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--allow-empty",
"-m",
"init",
],
{ stdio: "ignore" },
);
execFileSync(
"git",
["-C", main, "worktree", "add", "-b", "test", worktree],
{ stdio: "ignore" },
);
symlinkSync(worktree, join(temp, "alias"));
trustCodexStartupRoot(home, join(temp, "alias"));
expect(
parse(readFileSync(join(home, "config.toml"), "utf8")).projects,
).toEqual({ [main]: { trust_level: "trusted" } });
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
it("fails without replacing malformed configuration", () => {
const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-")));
try {
writeFileSync(join(temp, "config.toml"), "invalid = [");
expect(() => trustCodexStartupRoot(temp, temp)).toThrow();
expect(readFileSync(join(temp, "config.toml"), "utf8")).toBe(
"invalid = [",
);
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
});

View File

@ -0,0 +1,122 @@
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
existsSync,
realpathSync,
readFileSync,
writeFileSync,
mkdirSync,
renameSync,
rmSync,
} from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { parse, type TomlTable } from "smol-toml";
import { isDeepStrictEqual } from "node:util";
/** Keep managed-region comments and all unrelated bytes. The parser validates
* every candidate against the exact intended semantic change; text inside a
* comment or multiline string can never qualify as the target assignment. */
function editTrust(source: string, root: string, expected: TomlTable): string {
const matches = (candidate: string) => {
try { return isDeepStrictEqual(parse(candidate), expected); }
catch { return false; }
};
if (matches(source)) return source;
const appended = `${source}\n[projects.${JSON.stringify(root)}]\ntrust_level = "trusted"\n`;
if (matches(appended)) return appended;
const values = /(?:"trust_level"|'trust_level'|\btrust_level)\s*=\s*("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\])*"|'[^']*')/g;
for (const match of source.matchAll(values)) {
const end = match.index! + match[0].length;
const start = end - match[1]!.length;
const candidate = source.slice(0, start) + '"trusted"' + source.slice(end);
if (matches(candidate)) return candidate;
}
// An existing project table may not yet have a trust field.
for (const match of source.matchAll(/^[ \t]*\[(?!\[)[^\r\n]*\][^\r\n]*(?:\r?\n|$)/gm)) {
const end = match.index! + match[0].length;
const candidate = source.slice(0, end) + '\ntrust_level = "trusted"\n' + source.slice(end);
if (matches(candidate)) return candidate;
}
// Inline project tables are closed to appended table headers. Insert only
// when reparsing proves this is the intended object, not a brace in text.
const fields = ['trust_level = "trusted"', `${JSON.stringify(root)} = { trust_level = "trusted" }`];
for (const match of source.matchAll(/\{/g)) {
const end = match.index! + 1;
for (const field of fields) {
for (const separator of [', ', '']) {
const candidate = source.slice(0, end) + field + separator + source.slice(end);
if (matches(candidate)) return candidate;
}
}
}
throw new Error("codex_startup_trust_cannot_preserve_configuration");
}
/** Run on the execution host, before the provider process loads project config. */
export function trustCodexStartupRoot(codexHome: string, cwd: string): void {
if (!isAbsolute(codexHome) || !isAbsolute(cwd))
throw new Error("codex_startup_trust_requires_absolute_paths");
const startup = realpathSync(cwd);
let root = startup;
try {
const top = execFileSync(
"git",
["-C", startup, "rev-parse", "--show-toplevel"],
{ encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] },
).trim();
const common = execFileSync(
"git",
[
"-C",
startup,
"rev-parse",
"--path-format=absolute",
"--git-common-dir",
],
{ encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] },
).trim();
// Linked worktrees share Codex's trust key with the main checkout.
root = realpathSync(common.endsWith("/.git") ? dirname(common) : top);
} catch (error) {
// Non-Git folders have their own exact startup trust boundary. Failures
// inside a repository must not guess a different trust key.
for (let ancestor = startup; ; ancestor = dirname(ancestor)) {
if (existsSync(join(ancestor, ".git")))
throw new Error("codex_startup_trust_git_resolution_failed", {
cause: error,
});
if (dirname(ancestor) === ancestor) break;
}
}
mkdirSync(codexHome, { recursive: true, mode: 0o700 });
const path = join(codexHome, "config.toml");
const source = existsSync(path) ? readFileSync(path, "utf8") : "";
const config = parse(source);
const projects = config.projects ?? {};
if (
typeof projects !== "object" ||
Array.isArray(projects) ||
projects instanceof Date
)
throw new Error("codex_startup_trust_invalid_projects");
const project = projects[root] ?? {};
if (
typeof project !== "object" ||
Array.isArray(project) ||
project instanceof Date
)
throw new Error("codex_startup_trust_invalid_project");
config.projects = {
...projects,
[root]: { ...project, trust_level: "trusted" },
};
const updated = editTrust(source, root, config);
if (updated === source) return;
const temporary = resolve(codexHome, `config.toml.${randomUUID()}.tmp`);
try {
writeFileSync(temporary, updated, { mode: 0o600, flag: "wx" });
renameSync(temporary, path);
} finally {
rmSync(temporary, { force: true });
}
}

View File

@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { observeCodexUsage, codexRunUsage } from "./codex-usage-baseline.js";
describe("Codex resume usage accounting", () => {
it("never charges historical snapshots, including repetitions after restart", () => {
let state = observeCodexUsage(
null,
{ inputTokens: 100, outputTokens: 20 },
true,
);
state = observeCodexUsage(
JSON.parse(JSON.stringify(state)),
{ inputTokens: 100, outputTokens: 20 },
true,
);
expect(codexRunUsage(state).runDelta).toEqual({
inputTokens: 0,
outputTokens: 0,
});
state = observeCodexUsage(
state,
{ inputTokens: 140, outputTokens: 26 },
false,
);
state = observeCodexUsage(
state,
{ inputTokens: 140, outputTokens: 26 },
false,
);
state = observeCodexUsage(
state,
{ inputTokens: 100, outputTokens: 20 },
true,
);
expect(codexRunUsage(state).runDelta).toEqual({
inputTokens: 40,
outputTokens: 6,
});
});
it("retains all current usage for a fresh session", () => {
expect(
codexRunUsage(
observeCodexUsage(null, { inputTokens: 40, outputTokens: 6 }, false),
).runDelta,
).toEqual({ inputTokens: 40, outputTokens: 6 });
});
});

View File

@ -0,0 +1,45 @@
/** Session totals are monotonic observations, not additional billable receipts. */
export interface CodexUsageBaseline {
baseline: Record<string, number>;
latest: Record<string, number>;
}
export function codexUsageMeasurement(value: unknown): Record<string, number> {
if (value === null || typeof value !== "object") return {};
return Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, number] =>
typeof entry[1] === "number" &&
Number.isFinite(entry[1]) &&
entry[1] >= 0,
),
);
}
export function observeCodexUsage(
state: CodexUsageBaseline | null,
total: unknown,
historical: boolean,
): CodexUsageBaseline {
const measurement = codexUsageMeasurement(total);
const baseline = state?.baseline ?? (historical ? measurement : {});
const latest = { ...state?.latest };
for (const [key, value] of Object.entries(measurement)) {
latest[key] = Math.max(latest[key] ?? 0, value);
}
return { baseline, latest };
}
export function codexRunUsage(
state: CodexUsageBaseline,
): Record<string, unknown> {
return {
total: state.latest,
runDelta: Object.fromEntries(
Object.entries(state.latest).map(([key, value]) => [
key,
Math.max(0, value - (state.baseline[key] ?? 0)),
]),
),
};
}

View File

@ -3345,6 +3345,35 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
await this.#command("turn.interrupt", params);
return {};
}
if (method === "thread/turns/list" || method === "thread/items/list") {
if (params.threadId !== this.#threadId) throw new Error("codex_history_identity_mismatch");
const snapshot = await this.request("thread/read", { threadId: this.#threadId, includeTurns: false });
const turns = record(snapshot.thread).turns as Array<Record<string, unknown>>;
let data: Array<Record<string, unknown>>;
if (method === "thread/turns/list") {
data = turns.map(turn => ({ ...turn, items: [], itemsView: "notLoaded" }));
} else {
if (params.turnId !== this.#turnId) throw new Error("codex_history_unavailable: requested turn is outside the retained runner event window");
const items = new Map<string, Record<string, unknown>>();
let observedTurn = "";
let observedStart = false;
for (const event of this.#core?.store.state.committedEvents ?? []) {
const payload = record(record(event.envelope.payload).payload);
if (event.eventType === "turn.started") observedTurn = String(payload.providerTurnId ?? payload.turnId ?? record(payload.turn).id ?? "");
if (event.eventType === "turn.started" && observedTurn === params.turnId) observedStart = true;
if (event.eventType !== "item.completed" || observedTurn !== params.turnId) continue;
const item = record(rehydrateRunnerdItemNotification(payload, this.#threadId, observedTurn).item);
if (typeof item.id === "string") items.set(item.id, { turnId: observedTurn, item });
}
if (!observedStart) throw new Error("codex_history_incomplete: requested turn start is outside the retained runner event window");
data = [...items.values()];
}
if (params.sortDirection === "desc") data.reverse();
const offset = params.cursor == null ? 0 : Number(params.cursor);
if (!Number.isSafeInteger(offset) || offset < 0 || offset > data.length) throw new Error("codex_history_invalid_cursor");
const limit = typeof params.limit === "number" ? Math.max(1, Math.min(100, params.limit)) : 100;
return { data: data.slice(offset, offset + limit), nextCursor: offset + limit < data.length ? String(offset + limit) : null };
}
if (method === "thread/read") {
if (this.#core === null) {
this.#recoveryTurnBindingPending = true;

View File

@ -81,6 +81,19 @@ function handleRequest(message) {
});
return;
}
if (method === "thread/turns/list") {
const turns = Object.entries(state.turns).map(([turnId, status]) => ({
id: turnId, status, items: [], itemsView: "notLoaded",
}));
if (params.sortDirection === "desc") turns.reverse();
const offset = Number(params.cursor ?? 0);
const limit = params.limit ?? 100;
send({ id, result: {
data: turns.slice(offset, offset + limit),
nextCursor: offset + limit < turns.length ? String(offset + limit) : null,
} });
return;
}
if (method === "thread/read") {
send({
id,
@ -88,7 +101,10 @@ function handleRequest(message) {
thread: {
id: state.threadId,
sessionId: state.sessionId,
turns: Object.entries(state.turns).map(([turnId, status]) => ({ id: turnId, status })),
status: { type: Object.values(state.turns).includes("inProgress") ? "active" : "idle" },
...(params.includeTurns ? {
turns: Object.entries(state.turns).map(([turnId, status]) => ({ id: turnId, status })),
} : {}),
tokenUsage: {
total: {
inputTokens: state.nextTurn * 10,

View File

@ -1,6 +1,5 @@
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
@ -59,7 +58,8 @@ const fakeCodexBinary = resolve(
);
function ensureRunnerTestBinaries(): void {
if (existsSync(runnerBinary) && existsSync(fakeCodexBinary)) return;
// Cargo's freshness check is necessary even when the files exist: an older
// fake provider can otherwise exercise a different protocol than the source.
execFileSync("cargo", [
"build",
"--release",