From ec6c5beda090eedda4d7d3fd87a65797ee054b39 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 00:34:50 +0900 Subject: [PATCH 01/29] test: connect Drive publication to client and resource lifetimes --- .../tests/ticket_worker_launch_tests.rs | 56 ++- .../src/server/tests/worker_drive_tests.rs | 422 +++++++++++++++++- 2 files changed, 461 insertions(+), 17 deletions(-) diff --git a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs index ee8dba11d..304e18160 100644 --- a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs @@ -132,6 +132,16 @@ impl WorkerExecutionBackend for LaunchExecutionBackend { self.execution.create_working_directory(request) } + fn cleanup_working_directory( + &self, + id: &str, + ) -> std::result::Result< + worker_runtime::catalog::WorkingDirectoryStatus, + worker_runtime::working_directory::WorkingDirectoryDiagnostic, + > { + self.execution.cleanup_working_directory(id) + } + fn spawn_worker(&self, mut request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { self.creates.lock().unwrap().push(request.request.clone()); for claim in &request.request.workdir_attachments { @@ -164,9 +174,9 @@ impl WorkerExecutionBackend for LaunchExecutionBackend { } } -struct LaunchFixture { +pub(super) struct LaunchFixture { _temp: tempfile::TempDir, - api: WorkspaceApi, + pub(super) api: WorkspaceApi, backend: Arc, runtime_id: String, runtime_server: Option>, @@ -182,11 +192,51 @@ impl Drop for LaunchFixture { } impl LaunchFixture { + // Reuse the authenticated Runtime HTTP harness for Drive/source lifetime + // assertions without duplicating its trust/bootstrap/listener lifecycle. + pub(super) async fn with_source_workdir() -> ( + Self, + worker_runtime::working_directory::WorkingDirectoryBinding, + ) { + let fixture = Self::new(true).await; + init_clean_git_workspace(fixture._temp.path()); + let (_, Json(created)) = create_workspace_working_directory( + &fixture.api, + TEST_WORKSPACE_ID, + Some(&fixture.runtime_id), + BrowserWorkingDirectoryCreateRequest { + runtime_id: Some(fixture.runtime_id.clone()), + display_name: None, + repository_key: "test-repository".into(), + selector: Some("HEAD".into()), + operation_id: Some("drive-source-workdir".into()), + }, + ) + .await + .unwrap(); + let binding = fixture + .backend + .execution + .materializer + .bind_working_directory(&created.item.working_directory_id, None) + .unwrap(); + (fixture, binding) + } + async fn new(remote: bool) -> Self { let temp = tempfile::tempdir().unwrap(); let config = test_server_config(temp.path()); let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); - let backend = Arc::new(LaunchExecutionBackend::default()); + let backend = Arc::new(LaunchExecutionBackend { + execution: DeterministicExecutionBackend { + materializer: worker_runtime::working_directory::RuntimeGitMaterializer::new( + temp.path() + .join(".test-embedded-runtime-store/source-workdirs"), + ), + ..Default::default() + }, + ..Default::default() + }); let api = WorkspaceApi::new_with_execution_backend(config, Arc::new(store), backend.clone()) .await diff --git a/crates/workspace-server/src/server/tests/worker_drive_tests.rs b/crates/workspace-server/src/server/tests/worker_drive_tests.rs index e951a6308..609cbd4ab 100644 --- a/crates/workspace-server/src/server/tests/worker_drive_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_drive_tests.rs @@ -1,6 +1,10 @@ -//! Worker tools -> signed WorkspaceClient -> real isolated Server authority. -//! No Git checkout, live DB, network service, or DTO-only assertions. Workdir -//! import tests attach an explicitly read-scoped temporary source session. +//! Tools/native WIP -> signed WorkspaceClient -> isolated Server DB and LocalFileSystem. +//! Default fixtures have no Repository/Workdir and substitute Runtime execution. +//! Lifetime cases use real embedded Runtime stop/removal or the shared authenticated +//! Runtime HTTP harness for actual Git Workdir removal; controller execution remains +//! deterministic. Import sessions have explicit read scope (and real materializer +//! leases in the removal case). No external model, live data, or browser is used. +//! Run with the normal Server lib target and the `worker_drive_tests` filter. use super::*; use agen::tool::{Attachment, ToolError, ToolExecutionContext, ToolOutput}; use std::result::Result; @@ -148,28 +152,46 @@ struct Fixture { } impl Fixture { async fn new(access: [&str; 2]) -> Self { + Self::with_runtime(access, false).await + } + async fn with_runtime(access: [&str; 2], embedded: bool) -> Self { let temp = tempfile::tempdir().unwrap(); let mut config = test_server_config(temp.path()); config.repositories.clear(); let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); - let mut api = WorkspaceApi::new_with_execution_backend( - config, - store, - Arc::new(DeterministicExecutionBackend::default()), - ) - .await - .unwrap(); + let execution = Arc::new(DeterministicExecutionBackend { + materializer: worker_runtime::working_directory::RuntimeGitMaterializer::new( + temp.path().join("runtime-workdirs"), + ), + ..Default::default() + }); + let api = WorkspaceApi::new_with_execution_backend(config, store, execution) + .await + .unwrap(); + Self::on_api(api, temp, access, embedded).await + } + async fn on_api( + mut api: WorkspaceApi, + temp: tempfile::TempDir, + access: [&str; 2], + embedded: bool, + ) -> Self { let owner = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); - register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; - api.runtime - .register_or_replace(WorkdirlessFixtureRuntime::default()); + let runtime_id = if embedded { + EMBEDDED_RUNTIME_ID + } else { + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + api.runtime + .register_or_replace(WorkdirlessFixtureRuntime::default()); + WorkdirlessFixtureRuntime::RUNTIME_ID + }; let mut workers = Vec::new(); for name in ["Drive first", "Drive second"] { let Json(created) = create_workspace_worker( State(api.clone()), HeaderMap::new(), Json(CreateWorkspaceWorkerRequest { - runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.into(), + runtime_id: runtime_id.into(), display_name: name.into(), singleton_key: None, profile: Some("builtin:coder".into()), @@ -248,6 +270,45 @@ impl Fixture { .unwrap(), ) } + async fn upload_text(&self, name: &str, text: &str) -> Value { + use sha2::{Digest, Sha256}; + let root = self.root(0).await; + let digest: String = Sha256::digest(text.as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("operation", "create") + .append_pair("request_id", &format!("user-upload-{name}")) + .append_pair("entry_workspace_id", TEST_WORKSPACE_ID) + .append_pair("parent_id", root["node_id"].as_str().unwrap()) + .append_pair("name", name) + .append_pair("content_type", "text/markdown") + .append_pair("size", &text.len().to_string()) + .append_pair("sha256", &digest) + .finish(); + let response = build_router(self.api.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("{}/upload?{query}", base())) + .header("authorization", format!("Bearer {}", self.owner)) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(text.to_owned())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&bytes) + ); + serde_json::from_slice(&bytes).unwrap() + } fn importer(&self, worker: usize) -> DriveFeature { let root = self._temp.path().join("import-source"); fs::create_dir_all(root.join("public")).unwrap(); @@ -415,6 +476,339 @@ async fn workdirless_workers_share_markdown_through_current_read_and_write_grant ); } +#[tokio::test] +async fn user_uploaded_markdown_roundtrips_through_workdirless_tools_and_native_wip_at_one_identity() + { + let f = Fixture::new(["read_write", "read_only"]).await; + let original = "# ユーザー資料\n未処理\n"; + let updated_text = "# ユーザー資料\n処理済み ✓\n"; + for native in [false, true] { + let uploaded = f + .upload_text(if native { "native.md" } else { "tools.md" }, original) + .await; + let id = uploaded["entry"]["entry"].clone(); + let updated = if native { + let tools = native_tools(&f.features[0]); + // Resolve the production path projection, not an invented node path. + let metadata = value( + tool(&f.features[0], "DriveMetadata", json!({"entry":id})) + .await + .unwrap(), + ); + let path = metadata["path"].as_str().unwrap(); + let inspected = value( + native_tool(&tools, "Inspect", json!({"path":path,"refresh":true})) + .await + .unwrap(), + ); + let reference = inspected["interfaces"][0]["reference"].clone(); + let read = value( + native_tool( + &tools, + "Invoke", + json!({"path":path,"interface":reference,"operation":"read","arguments":{}}), + ) + .await + .unwrap(), + ); + assert_eq!(read["text"], original); + assert_eq!(read["entry"]["metadata"]["entry"], id); + assert_eq!( + read["entry"]["metadata"]["last_mutation_id"], + uploaded["entry"]["last_mutation_id"] + ); + value(native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":updated_text}})).await.unwrap()) + } else { + let read = value( + tool(&f.features[0], "DriveRead", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!(read["text"], original); + assert_eq!(read["entry"]["metadata"]["entry"], id); + assert_eq!( + read["entry"]["metadata"]["last_mutation_id"], + uploaded["entry"]["last_mutation_id"] + ); + value( + tool( + &f.features[0], + "DriveWrite", + json!({"entry":id,"content":updated_text}), + ) + .await + .unwrap(), + ) + }; + assert_eq!(entry(&updated), id); + let mutation = &updated["entry"]["metadata"]["last_mutation_id"]; + assert_ne!(*mutation, uploaded["entry"]["last_mutation_id"]); + assert_eq!(*mutation, updated["request_id"]); + let independent = value( + tool(&f.features[1], "DriveRead", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!(independent["text"], updated_text); + assert_eq!(independent["entry"], updated["entry"]); + let metadata = owner_call( + &f.api, + &f.owner, + "GET", + &format!( + "{}/metadata?entry_workspace_id={TEST_WORKSPACE_ID}&id={}", + base(), + id["node_id"].as_str().unwrap() + ), + None, + ) + .await; + assert_eq!(metadata, updated["entry"]["metadata"]); + let receipt = owner_call( + &f.api, + &f.owner, + "GET", + &format!( + "{}/requests/{}", + base(), + updated["request_id"].as_str().unwrap() + ), + None, + ) + .await; + assert_eq!(receipt["state"], "committed"); + assert_eq!(receipt["response"]["entry"], metadata); + let response = build_router(f.api.clone()).oneshot(Request::builder() + .uri(format!("{}/download?entry_workspace_id={TEST_WORKSPACE_ID}&id={}&expected_mutation_id={}", base(), id["node_id"].as_str().unwrap(), mutation.as_str().unwrap())) + .header("authorization", format!("Bearer {}", f.owner)) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK, "native={native}"); + assert_eq!( + to_bytes(response.into_body(), 65536) + .await + .unwrap() + .as_ref(), + updated_text.as_bytes() + ); + } +} + +#[tokio::test] +async fn source_workdir_removal_keeps_published_drive_bytes_and_receipt() { + let (source, binding) = + super::ticket_worker_launch_tests::LaunchFixture::with_source_workdir().await; + let workdir_id = binding.working_directory.id.clone(); + let f = Fixture::on_api( + source.api.clone(), + tempfile::tempdir().unwrap(), + ["read_write", "read_only"], + false, + ) + .await; + let bytes = b"clean\n"; + let root_path = binding.root().to_owned(); + let materialization_root = binding.working_directory_root().to_owned(); + let scope = manifest::Scope::from_config(&manifest::ScopeConfig { + allow: vec![manifest::ScopeRule { + target: root_path.clone(), + permission: manifest::Permission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }], + deny: vec![], + }) + .unwrap(); + let sessions = Arc::new(WorkdirSessionRouter::default()); + sessions + .attach( + workdir::WorkdirAttachmentAlias::new("source").unwrap(), + Arc::new( + workdir::LocalWorkdirSession::materialized_bound_with_environment( + workdir::Workdir::new(workdir_id.clone()), + root_path.clone(), + root_path.clone(), + manifest::SharedScope::new(scope), + workdir::WorkdirSessionCapabilities::READ_ONLY, + binding.command_environment(), + binding.session_resources().unwrap(), + ), + ), + ) + .unwrap(); + let importer = DriveFeature::new(f.clients[0].clone(), sessions.clone()); + let root = f.root(0).await; + let saved = value(tool(&importer, "DriveSaveWorkdir", json!({"target_workdir":"source","path":"README.md","parent":root,"name":"retained.md","content_type":"text/markdown"})).await.unwrap()); + sessions.close_all().await.unwrap(); + drop(importer); + drop(sessions); + drop(binding); + assert!(root_path.join("README.md").exists()); + let api = source.api.clone(); + let removal_id = workdir_id.clone(); + let removed = tokio::task::spawn_blocking(move || { + execute_workdir_removal( + &api, + &removal_id, + "account:drive-test", + "source no longer needed", + ) + }) + .await + .unwrap() + .unwrap(); + assert_eq!( + removed.disposition, + server_api::WorkingDirectoryRemovalDisposition::Removed, + "{removed:?}" + ); + assert!(!removed.retryable); + assert!( + !materialization_root.exists(), + "actual source materialization must be gone" + ); + assert!( + f.api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, &workdir_id) + .unwrap() + .is_none() + ); + let fresh = DriveFeature::new( + f.clients[1].clone(), + Arc::new(WorkdirSessionRouter::default()), + ); + let read = value( + tool(&fresh, "DriveRead", json!({"entry":entry(&saved)})) + .await + .unwrap(), + ); + assert_eq!(read["entry"], saved["entry"]); + assert_eq!(read["text"].as_str().unwrap().as_bytes(), bytes); + let receipt = value( + tool( + &fresh, + "DriveRequestStatus", + json!({"request_id":saved["request_id"]}), + ) + .await + .unwrap(), + ); + assert_eq!(receipt["state"], "committed"); + assert_eq!(receipt["response"]["entry"], saved["entry"]["metadata"]); +} + +#[tokio::test] +async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replacement() { + // Real embedded Runtime lifecycle/retention; only controller execution is + // deterministic. No Workdir or external model is involved in this case. + let f = Fixture::with_runtime(["read_write", "read_only"], true).await; + let saved = f.create("retained.md", "# Beyond the Worker\n").await; + let id = entry(&saved); + let old = &f.clients[0].worker; + let worker = WorkspaceWorker::resolve_async(&f.api, &old.runtime_id, &old.worker_id) + .await + .unwrap(); + let stopped = worker + .stop( + &WorkerOperationContext::Backend, + WorkerLifecycleRequest { + reason: Some("Drive lifetime test".into()), + ticket_assignment: None, + }, + ) + .await + .unwrap(); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); + assert_eq!(f.api.runtime.worker(old).unwrap().state, "stopped"); + seed_worker_session_for_cleanup(f._temp.path(), old); + let plan = build_runtime_cleanup_plan(&f.api, &old.runtime_id).unwrap(); + let candidate = plan + .workers + .iter() + .find(|c| c.runtime_worker_id == old.worker_id) + .unwrap(); + assert_eq!(candidate.blocking_reason, None); + worker + .delete_from_plan(&WorkerOperationContext::Backend, candidate) + .await + .unwrap(); + assert!(f.api.runtime.worker(old).is_err()); + assert!( + f.api + .store + .get_worker_registry(TEST_WORKSPACE_ID, old) + .unwrap() + .is_none() + ); + denied(tool(&f.features[0], "DriveRead", json!({"entry":id})).await); + let Json(created) = create_workspace_worker( + State(f.api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: EMBEDDED_RUNTIME_ID.into(), + display_name: "Replacement reader".into(), + singleton_key: None, + profile: Some("builtin:coder".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: Default::default(), + control_operation_id: None, + }), + ) + .await + .unwrap(); + assert_ne!(created.worker_id, old.worker_id); + let client = Arc::new(RouterClient { + api: f.api.clone(), + identity: f.clients[0].identity.clone(), + worker: RuntimeWorkerRef::new(created.runtime_id, created.worker_id), + handle: tokio::runtime::Handle::current(), + lose_next_mutation_response: AtomicBool::new(false), + lose_next_upload_response: AtomicBool::new(false), + requests: Mutex::new(vec![]), + }); + let replacement = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::default())); + denied(tool(&replacement, "DriveRead", json!({"entry":id})).await); + owner_call(&f.api, &f.owner, "POST", &format!("{}/grants", base()), Some(json!({"runtime_id":client.worker.runtime_id,"worker_id":client.worker.worker_id,"access":"read_only"}))).await; + let read = value( + tool(&replacement, "DriveRead", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!(read["entry"], saved["entry"]); + assert_eq!(read["text"], "# Beyond the Worker\n"); + let receipt = owner_call( + &f.api, + &f.owner, + "GET", + &format!( + "{}/requests/{}", + base(), + saved["request_id"].as_str().unwrap() + ), + None, + ) + .await; + assert_eq!(receipt["state"], "committed"); + assert_eq!(receipt["response"]["entry"], saved["entry"]["metadata"]); + // Stop remaining deterministic controllers before dropping isolated resources. + for target in [&f.clients[1].worker, &client.worker] { + let stopped = f + .api + .runtime + .stop_worker( + target, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); + } +} + #[tokio::test] async fn simultaneous_worker_edits_publish_exactly_one_last_mutation_id_and_preserve_the_loser_conflict() { From 387b5ff7df9e55cf46955544b155524caa193bfe Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 00:35:11 +0900 Subject: [PATCH 02/29] docs: record Drive connection coverage and fault evidence --- docs/development/regression-test-design.md | 2 + docs/report/t725-drive-connections.md | 75 ++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 docs/report/t725-drive-connections.md diff --git a/docs/development/regression-test-design.md b/docs/development/regression-test-design.md index 255d260f1..91164df56 100644 --- a/docs/development/regression-test-design.md +++ b/docs/development/regression-test-design.md @@ -95,6 +95,8 @@ T-744では既存Rust代表8件が成功(上表以外の公開reissue/case/Ans T-725現行本文・保留trackingを読んだ。`workspace-drive/tests/storage.rs::receipts_survive_response_loss_and_restart_without_double_mutation` は実DB/blob再open後のreceipt一致、同request再送一致、別actor競合拒否、list件数1をassertする。`workspace-server/src/server/tests/worker_drive_tests.rs::lost_mutation_response_is_resolved_by_receipt_without_resending_effect` は本番DriveFeature→署名router/storeを通し、receipt committed・POST mutate数1・別Worker listのlast mutation一致をassertするがRuntime起動はfixtureである。これらの保存/応答喪失規則を再設計しない。実Web adapter接続は `drive_web_roundtrip_tests.rs::repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_reference_lifecycle` の既存ignore/明示実行であり、通常suiteの成功へ含めない。Worker寿命・Web画面までの不足と必要コストはT-725再開時の判断で、今回未確認とする。 +**2026-10-19、T-725再開後の候補実装**は[Drive接続の調査・検証記録](../report/t725-drive-connections.md)へ分離した。通常Server libにuser upload→WorkdirなしTools/WIP→owner/別Workerの同一node・mutation・bytes・receipt、実Runtime Worker停止/削除→明示grant付き交換Worker、実Runtime HTTP経由のsource Workdir削除→Drive資料保持の3件を追加した。保存/応答喪失/GC/backupは既存を維持し、Ticket一巡も複製しない。3件の単独libtest実測合計5.54秒(Cargo wall合計6.324秒、build温存時)であり、通常runner全体の増分・並列負荷は未測定。代表的な本文投影欠落とcleanupによる誤ったDrive blob削除のmutantを検出し、復元後Server全crateを成功確認した。既存ignored Web adapterも別途1件成功したがbrowser/実モデル/全Workspace runner/CIの成功には含めない。上の保留記述はT-761設計時の観測であり、この候補を対象developへ採用・統合したという記録ではない。 + ## 設計時の代表試行と残った検証 全て専用checkoutで逐次実行、Rust buildはjobs 2。表の本体はlibtest/Deno表示、外側は`time -p`の1回測定であり、通常suite全体の増分やCI中央値ではない。 diff --git a/docs/report/t725-drive-connections.md b/docs/report/t725-drive-connections.md new file mode 100644 index 000000000..7017c62c0 --- /dev/null +++ b/docs/report/t725-drive-connections.md @@ -0,0 +1,75 @@ +# Drive共有の接続不足と検証(T-725、2026-10-19) + +## 判断の基準 + +基準 `adfa42297352c5a64fe8100969aad6fda4df8a81` と着手HEAD `cea0225a6e2d9e23c59a7d4a453f99bc2e1eba97` を比較した。差分81 filesはSession保存、Journal、Ticket公開操作、復元親の子操作等であり、以下のDrive保存/API/Worker/Webテストに差分はない。Cargo.lockの `wip-{client,http,protocol,text-view}` はすべてcrates.ioの0.2.0で、ローカル差替えはしていない。 + +検証したテスト実装は専用branch `work/T-725-drive-connections` の `ec6c5bed`。以下の結果はこの実装に対する証拠であり、対象developへの統合判断とは分ける。 + +T-721〜724の保存・公開操作を再実装せず、**別入口の返却値が次の操作へ渡る接続と、cleanupの所有境界**だけを追加した。製品修正は不要だった。Ticket一巡は実装・採用済みT-744の `ticket-regression-test-design.md`、通常suiteの判断はT-761の `regression-test-design.md` と分担する。関連Ticketの変更・再開はしていない。 + +## 優先順位と追加した契約 + +配置は `crates/workspace-server/src/server/tests/worker_drive_tests.rs`。以下はすべて通常のServer lib targetであり、ignoreやテスト専用featureは追加しない。 + +| 優先 | 利用結果/具体故障・コード根拠 | 既存証拠の限界/採否 | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P1 | ユーザーuploadの返却node/mutationをWorkdirなしTools/WIPが読み更新し、owner/別Workerが同一資料を取得する。`server_drive::upload/read_text/public_node`、Worker `drive/backend.rs::read/mutate` とWIPの投影がずれると、個別成功でも本文や観測が別物になる。 | `workdirless_workers_share_markdown_through_current_read_and_write_grants` はWorker作成から別Worker読取り。`image_attachment_retains_original_bytes_after_drive_update_and_revocation` はuser uploadから画像添付。`native_create_and_write_use_client_managed_committed_requests_and_reject_stale_worker` はWIP作成から更新。この間の**user Markdown binary upload→Tools/WIP更新**を新規接続。 | +| P2 | Workerを止め、保持規則に従い削除・交換してもDrive文書の参照と内容が残る。`server/worker_operations.rs::stop/delete_from_plan`、`WorkerRemovalService` と `store::commit_worker_catalog_removal` はWorker権限を終え、Drive資料を削除してはならない。 | `cleanup_service_deletes_stopped_singleton_worker` は実Runtime停止・削除・交換、storeの `drive_grants_migrate_and_preserve_audit_through_revoke_worker_delete_and_restart` はgrant取消監査を確認するが、Drive資料の再取得はない。**元参照→交換Workerの明示grant後読取り**を新規接続。停止だけでgrantが失効するという別契約は追加しない。 | +| P2 | source Workdirを本当に削除した後も保存した資料を使える。`drive.rs` のSaveWorkdirはbytesをAPIへ渡し、`execute_workdir_removal`→Runtime→`working_directory/cleanup.rs` はmaterializationだけを削除する。誤ったroot削除やsource依存を検出する。 | `scoped_workdir_import_publishes_document_and_image_bytes_with_bounded_reads` は独立metadata、完全download bytes、画像添付を確認するがsourceが残る。**実provider削除の後に同node/mutation/bytes/receiptを読む**接続を追加。保存crateのbackup/GC行列は増やさない。 | + +### 入力・fixture・観測点 + +- `user_uploaded_markdown_roundtrips_through_workdirless_tools_and_native_wip_at_one_identity`:既存Fixtureの実SQLite/FeatureStorage/LocalFileSystem、2 Workerの空Workdir links、明示read_write/read_only grantを使用。owner binary PUTはsize/SHA256と日本語Markdown bytesを渡す。Toolsとnative WIPの2経路で読取り・writeを行う。返却Workspace/node、uploadのlast_mutation_id、更新request ID=last_mutation_id、別Worker readのentry/本文、owner metadata、committed receiptのentry、mutation固定downloadの完全bytesを照合する。WIPは本番install/Inspect/Invoke、外部LLMは呼ぶとpanicする。Runtime起動とHTTP transportはfixtureだが、毎要求の署名・router・現grant・storeは本番。 +- `worker_removal_preserves_drive_reference_for_explicitly_granted_replacement`:real embedded Runtime+既存deterministic controller backendでRepository/WorkdirなしWorkerが文書を作る。本番WorkspaceWorker stop→Runtime stopped→cleanup plan→delete_from_planを通し、Runtime/catalog除去、旧Workerの拒否を確認。別IDの交換Workerはgrant前拒否、read_only付与後に**元参照**で完全entry(latest_url、last_mutation_idを含む)と本文が一致し、元receiptもcommitted。保持対象Sessionは既存fixtureで準備する。lifecycle admissionは内部Backend test contextであり、browser/Worker-control認証の新証拠ではない。実controllerのshutdown/モデル挙動は代替されている。 +- `source_workdir_removal_keeps_published_drive_bytes_and_receipt`:`ticket_worker_launch_tests::LaunchFixture` の既存認証付きloopback Runtimeを共有する。通常のServer Workdir createでlocal Git sourceからcleanなREADME.mdをmaterializeする。source sessionはLocalWorkdirSessionでread scopeと**実materializer lease**を渡す(Runtime session transportは代替)。DriveSaveWorkdirはalias `source`+論理path `README.md`、元bytes `clean\n`。session close/drop後、Server removal→認証付きRuntime HTTP→実materializer unlinkを通し、Removed/nonretryable・物理root不存在・registry不存在をassert。その後、新しいWorkdirなしDriveFeatureから元entry/bytes/receiptを照合する。`on_api`のcloneは同じstore/drive_host/Runtimeを共有し、別DBを作らない。 + +Workdir用に新しい起動基盤は作らず、既存Launch backendにcleanupの実materializerへの委譲を足し、materializer配置をfixtureのTempDir配下へ閉じた。listenerは既存Dropでabort、通信は既存有界timeoutを利用する。embedded Server adapterは仕様上Workdir create/status/cleanup非対応(`hosts.rs`)。最初のembedded試行はAttentionRequiredとなったため不採用とし、成功fakeへ変えず既存HTTP harnessへ切り替えた。 + +## 追加しなかった保証と既存assertion + +パスは、断りがなければServer `src/server/tests/` 配下。 + +| 保証/採否 | 主な実在テストと観測、代替境界 | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| stale先勝ち:維持 | `simultaneous_worker_edits_publish_exactly_one_last_mutation_id_and_preserve_the_loser_conflict` は成功1件・敗者StructuredConflict・勝者本文/metadata、`stale_worker_observations_cannot_write_edit_relocate_or_delete_newer_last_mutation_id` はwrite/edit/move/delete拒否と本文/名前保持。nativeは前掲create/write testでvalidator conflictと勝者bytes。Tools/WIP→実署名router、Runtimeのみfixture。 | +| 現grant取消:維持 | `revocation_invalidates_cached_metadata_reads_writes_and_receipt_queries` はread/write/list/search/status等のdeniedと別Worker本文保持。`native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_current_grants` はcached invoke/refreshed inspect拒否。`drive_api_tests::drive_verified_upload_revoked_during_reception_cannot_commit_or_replay` はbody frameで取消、403/denied、uncommitted、list空。最後の注入はverified-source Extensionを使用し、署名ingress自体の証拠とは分ける。 | +| commit後応答喪失:維持 | `lost_mutation_response_is_resolved_by_receipt_without_resending_effect` と `lost_binary_upload_reply_reconciles_same_request_without_duplicate_publication` は本番router成功後の返答だけをtransportで落とす。committed/元node、POSTまたはPUT数1、元requestのGET照会、別Worker一覧1件とmutation一致をassert。単なる失敗応答fakeではない。 | +| Web接続:維持/明示実行 | `drive_web_roundtrip_tests::repositoryless_drive_shares_worker_bytes_and_enforces_current_read_only_grants_over_http` はRepositoryなしbootstrap、実HTTP、Worker grant/revokeの403、一覧entryと実blob bytesを確認。`repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_reference_lifecycle` は実HTTPと本番Deno adapter(parser/operation/grant)を接続する既存ignored test。Worker起動はfixture、browser DOM/Passkey loginは対象外。画面draft/取消等は別のsynthetic API browser suiteを重複しない。 | +| 保存/reopen/backup/GC:所有crateを維持 | `workspace-drive/tests/storage.rs::markdown_image_empty_and_folders_survive_restart_with_stable_identity` はDrive再open後のmetadata/本文/画像。`receipts_survive_response_loss_and_restart_without_double_mutation` はreceipt/replay一致・別actor拒否・list1件。`stopped_writer_backup_restores_ids_last_mutation_ids_receipts_and_sequence_with_blobs` は一致するDB+blobsの復元、元metadata/bytes/receiptと新ID採番。`src/tests.rs::saved_blob_before_metadata_failure_is_not_published_and_restart_collects_it` は未公開・uncommitted・再open回収、`commit_failure_rolls_back_metadata_and_receipt_together` はmetadata/receipt同時rollback。`collection_cannot_remove_in_flight_upload_or_selected_read` はin-flight保護。これらをServer全体再起動や実電源断保証に拡張せず、新たな保存障害行列は不採用。 | + +## 故障検出試行と復元 + +以下は意図的なmutantであり、製品不具合の発見ではない。patchとログは `target/t725/` に保持し、製品sourceは復元した。 + +| 試行 | 注入と検出 | 実行結果(外側wall/libtest) | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `mutant-read` | `server_drive::read_text` の返却本文を `String::new()` にする。P1 filterの元upload本文比較が空文字≠日本語Markdownで失敗。 | exit101、64.156秒/0.41秒(build込み) | +| `mutant-cleanup` | `execute_workdir_removal` に誤ったDrive blob rootのremove_dir_allを挿入。実source削除後のfresh DriveReadが公開error「Drive storage or transport unavailable」で失敗。 | exit101、88.103秒/0.59秒(build込み) | +| 復元 | `server.rs` と `server_drive.rs` に差分がないことを検査後、Server全crateと3つの個別filterを実行。 | 下表の全成功。故障をテストから避ける製品修正はない。 | + +開発中にはsource fixtureの未定義git helperによるcompile error、準備用Repository FK不足、embedded adapter非対応の試行失敗もあった。helperを増やさず既存clean READMEを使用し、最終fixtureは本番Server create+既存HTTP Runtimeを利用した。これらを製品regressionや故障検出成功には数えない。いずれも `cargo test --locked -p yoi-workspace-server --lib source_workdir_removal_keeps` の試行で、`source-removal-first` はcompile exit101/wall6.875秒、`source-removal` はFK失敗exit101/wall84.053秒/libtest1.64秒、`source-removal-fixed` は非対応判定exit101/wall56.519秒/libtest2.14秒、HTTP経路への修正後 `source-removal-http` は1 pass/wall83.709秒/libtest0.61秒。時間採取は `/usr/bin/time` がないためBash `TIMEFORMAT` を使用し、出力のなかった背景起動の試行はbuild成功に数えなかった。 + +## 実行区分・費用とT-761への接続 + +すべて専用checkoutで実行した単発実測であり、CIや全通常suiteの速度ではない。`wall` はCargo起動/build込み、libtest時間は実行のみ。既存dead_code warningsは継続。 + +| command(root) | 結果 | wall/実行秒 | +| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------- | +| `cargo test --locked -p yoi-workspace-server --lib worker_drive_tests --no-run` | 初回build成功 | 234.981/実行なし | +| `cargo test --locked -p yoi-workspace-server --lib worker_drive_tests -- --test-threads=4` | P1+Worker寿命追加時20 pass | 71.131/9.69 | +| `cargo test --locked -p yoi-workspace-server -- --test-threads=4` | 最終実装1210 lib+12 bin pass、既存3 ignored、doctest対象0 | 400.021/291.85(lib) | +| `cargo test --locked -p yoi-workspace-server --lib user_uploaded_markdown_roundtrips` | 復元後1 pass(Tools/WIP両方) | 0.994/0.72 | +| `cargo test --locked -p yoi-workspace-server --lib worker_removal_preserves_drive_reference` | 復元後1 pass | 4.458/4.20 | +| `cargo test --locked -p yoi-workspace-server --lib source_workdir_removal_keeps` | 復元後1 pass | 0.872/0.62 | +| `cargo test --locked -p workspace-drive` | 40 unit+13 storage pass、doctest対象0 | 4.934/0.21+0.09 | +| `cargo check --locked` | root通常compile closure成功 | 75.368 | +| `cargo test --locked -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored` | **明示**Web adapter1 pass | 1.051/0.75 | + +最終module境界コメントの補正後にも同じ `worker_drive_tests -- --test-threads=4` を実行し21 pass(wall71.296秒、libtest9.75秒)を確認した。`cargo doc --locked -p yoi-workspace-server --no-deps` は成功(wall70.312秒)。`cargo fmt --all -- --check`(3.824秒)、`git diff --check HEAD`(0.004秒)、`env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts`(0.189秒、0 unapproved/0 stale exceptions)、2文書の `deno fmt --check` も成功した。文書の相対参照先は実在する。 + +T-761の通常runnerはCargo通常lib targetを列挙するため、追加3件は同targetのままで選択される。3件の逐次libtest実測合計5.54秒(単独Cargo wall合計6.324秒)。既存runnerのshard/並列負荷やcold build増分は未測定であり、全体時間へ単純加算しない。最も重いWorker寿命4.20秒は実永続Runtime停止・保持・再作成を検出する費用で、既存DB単体や共有2Workerの成功では代替できない。通常suiteへの追加はこの3件だけとし、Web adapterの既存ignoreは変更しない。 + +全Workspace `deno task test`、browser、製品全体E2E、実モデル判断、物理電源断、Server/Runtime全体再起動、CI実行は未実施。新規CI jobは追加しておらず、CIへの自動配線・実行済みとは扱わない。実モデルの調査・待機・完了判断を固定LLM応答で証明せず、追加したのは機構の接続保証である。 + +証拠は `target/t725/*.log`・`*.time` と2つのmutant patch。報告書へ生ログ/秘密は転載しない。稼働環境・対象develop・Ticket caseは変更していない。 From 38d906963280d5689b4758152b1e84d6ad10971f Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 01:46:30 +0900 Subject: [PATCH 03/29] feat: publish stable root file contracts and target-owned dispatch --- crates/tools/src/checkout.rs | 33 +++ crates/worker/src/checkout.rs | 257 ++++++----------- crates/worker/src/file_operation.rs | 418 ++++++++++++++++++++++++++++ crates/worker/src/wip.rs | 60 +++- 4 files changed, 589 insertions(+), 179 deletions(-) diff --git a/crates/tools/src/checkout.rs b/crates/tools/src/checkout.rs index 24e2dde31..88fe32df9 100644 --- a/crates/tools/src/checkout.rs +++ b/crates/tools/src/checkout.rs @@ -403,3 +403,36 @@ fn check_pattern(pattern: &str) -> Result<(), ToolError> { #[cfg(test)] #[path = "checkout_tests.rs"] mod tests; + +/// Read a bounded native text window through the same checked target and attachment tracker. +/// Search callers do not establish the prior-Read precondition for mutations. +#[allow(clippy::too_many_arguments)] +pub async fn read_checkout_text( + router: Arc, + tracker: Tracker, + alias: &str, + generation: u64, + path: WorkdirPath, + validator: Vec, + offset: usize, + limit: usize, + max_bytes: usize, + record_read: bool, +) -> Result<(workdir::ReadResult, Option>), ToolError> { + let selected = + crate::routing::resolve_session(&router, Some(alias), WorkdirSessionCapability::Read)?; + if selected.generation != generation { + return Err(stale_checkout("checkout connection changed")); + } + let tracker = tracker.scoped_attachment(&selected.alias, selected.generation); + let target = FileTarget { + session: selected.session.clone(), + path: path.clone(), + validator: Some(validator), + }; + let (result, validator) = target.read(offset, limit, max_bytes).await?; + if record_read { + tracker.record_workdir_observation(&path, result.content_hash.clone(), result.total_lines); + } + Ok((result, validator)) +} diff --git a/crates/worker/src/checkout.rs b/crates/worker/src/checkout.rs index 752c28688..6bf7e797e 100644 --- a/crates/worker/src/checkout.rs +++ b/crates/worker/src/checkout.rs @@ -7,7 +7,7 @@ //! empty children mean "no indexable children", not "empty directory". List/Glob/Grep discover //! content; Inspect checks selected coordinates. //! -//! Interfaces/validators bind the projection incarnation, attachment generation, and provider state +//! Object validators bind the projection incarnation, attachment generation, and provider state //! so alias reuse after restore or detach cannot transfer old observations to a new connection. //! Publication also checks the generation recorded at acquisition. Possessing an Interface grants //! no authority: route-reconstructed permission inputs and current provider capability/scope are @@ -28,9 +28,8 @@ use manifest::{ToolPermissionAction, ToolPermissionConfig}; use serde_json::{Value as Json, json}; use sha2::{Digest, Sha256}; use wip_protocol::{ - Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, - OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode, - ReturnDeclaration, TypeExpr, Value, + Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration, + ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value, }; use workdir::{ CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap, @@ -236,45 +235,17 @@ impl Provider { return Ok(None); } let workdir = selected.session.workdir().id().as_str().to_owned(); - let root = checkout_root(&alias); - // The router guard pins this connection across both observations. Root - // lifetime is attachment lifetime, not directory-content validator state. - let scope = if target.is_root() { - None - } else { - let root_observation = - match selected.session.checkout_observe(WorkdirPath::root()).await { - Ok(o) => o, - Err(_) => return Ok(None), - }; - self.entry_projection( - &root, - &alias, - selected.generation, - &workdir, - WorkdirPath::root(), - root_observation, - ) - }; - let Some(projection) = self.entry_projection( + let projection = self.entry_projection( path, &alias, selected.generation, &workdir, target, observation, - ) else { - return Ok(None); - }; - if path == root { - return WipPublication::self_scoped(projection).map(Some); - } - let Some(scope) = scope else { - return Ok(None); - }; - Ok(Some(WipPublication { + ); + Ok(projection.map(|projection| WipPublication { projection, - scope: Some(scope.object), + scope: None, })) } fn entry_projection( @@ -286,15 +257,36 @@ impl Provider { target: WorkdirPath, observation: CheckoutObservation, ) -> Option { - let ops = self.operations(&observation); - if ops.is_empty() { - return None; - } + use crate::file_operation::Contract as C; + let has = |cap, name| observation.capabilities.supports(cap) && self.may_allow(name); + let mut contracts = Vec::new(); let kind = if observation.kind == EntryKind::File { + if has(Cap::Read, "Read") { + contracts.extend([C::ReadLines, C::SearchText]); + } + if has(Cap::Edit, "Edit") { + contracts.push(C::Edit); + } + if has(Cap::Write, "Write") { + contracts.push(C::Write); + } "file" } else { + if has(Cap::Read, "List") { + contracts.push(C::List); + } + if has(Cap::Glob, "Glob") { + contracts.push(C::Glob); + } + if has(Cap::Grep, "Grep") { + contracts.push(C::Grep); + } + if has(Cap::Write, "Write") { + contracts.push(C::CreateFile); + } "directory" }; + let primary = *contracts.first()?; let validator = self.wrap_validator(alias, generation, workdir, &observation.validator); let handler = Arc::new(FileHandler { provider: self.clone(), @@ -304,86 +296,17 @@ impl Provider { target, observation, }); - let family = format!("{kind}-g{generation}"); - let mut projection = self.project(path, descriptor(ops), validator, &family, handler); - // Keep path-qualified local names (permissions/capabilities may differ - // per target) while binding every content Interface to its live entrance. - projection.interface.scope = checkout_root(alias); - projection.interface.name.push_str("#target/"); - projection.interface.name.push_str(&encode_identity(path)); - projection.object.interfaces = vec![projection.interface.clone()]; + let mut projection = self.project(path, primary.descriptor(), validator, kind, handler); + projection.interface = primary.reference(); + projection.interface_validator = Some(primary.validator()); + projection.object.interfaces = Vec::new(); + crate::file_operation::expose(&mut projection, contracts); Some(projection) } - - fn operations(&self, o: &CheckoutObservation) -> Vec { - let mut ops = Vec::new(); - let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name); - if o.kind == EntryKind::File { - if has(Cap::Read, "Read") { - ops.push(operation( - "read", - "Read text with line offsets and numbered output", - crate::file_operation::parameters("read", true).unwrap(), - )); - } - if has(Cap::Edit, "Edit") { - ops.push(operation( - "edit", - "Replace a unique string (requires prior Read)", - crate::file_operation::parameters("edit", true).unwrap(), - )); - } - if has(Cap::Write, "Write") { - ops.push(operation( - "write", - "Overwrite this existing file (requires prior Read)", - crate::file_operation::parameters("write", true).unwrap(), - )); - } - } else { - if has(Cap::Read, "List") { - ops.push(operation( - "list", - "List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; pass returned after to continue. Limit defaults to 100, maximum 1000. List permission and provider Read/enumeration authority apply. Includes hidden/ignored names per provider List policy, unlike Glob/Grep.", - vec![parameter("limit", false, TypeExpr::Integer), parameter("after", false, cursor_type())], - )); - } - if has(Cap::Glob, "Glob") { - ops.push(operation( - "glob", - "Glob within this directory", - vec![ - parameter("pattern", true, TypeExpr::String), - parameter("path", false, TypeExpr::String), - ], - )); - } - if has(Cap::Grep, "Grep") { - ops.push(operation( - "grep", - "Search within this directory with the existing Grep contract", - grep_parameters(), - )); - } - if has(Cap::Write, "Write") { - ops.push(operation("create_file", "Create a new relative file, creating permitted missing parents; never overwrite", vec![parameter("path",true,TypeExpr::String),parameter("content",true,TypeExpr::String)])); - } - } - ops - } } #[async_trait] impl WipSubtreeProvider for Arc { - fn interface_target(&self, reference: &wip_protocol::InterfaceReference) -> Option { - if reference.scope == ROOT { - return Some(ROOT.into()); - } - let (_, encoded) = reference.name.rsplit_once("#target/")?; - let path = decode_identity(encoded)?; - let (alias, _) = self.parse(&path)?; - (reference.scope == checkout_root(&alias)).then_some(path) - } async fn publication(&self, path: &str) -> Result, ProtocolError> { let _permit = tokio::time::timeout(self.deadline, self.permits.acquire()) .await @@ -604,6 +527,54 @@ impl WipOperationHandler for FileHandler { arguments: &BTreeMap, context: WipCallContext, ) -> Result { + if matches!(operation, "read_lines" | "search_text") { + let (offset, limit) = + crate::file_operation::window(arguments, operation == "search_text")?; + if !self.provider.allowed("Read",&json!({"target_workdir":self.alias,"file_path":self.target.as_str(),"offset":offset,"limit":limit})) { + return Err(protocol(ProtocolErrorCode::PermissionDenied,"text read denied")); + } + let result = tokio::time::timeout( + self.provider.deadline, + tools::checkout::read_checkout_text( + self.provider.router.clone(), + self.provider.tracker.clone(), + &self.alias, + self.generation, + self.target.clone(), + self.observation.validator.clone(), + offset, + limit, + crate::file_operation::MAX_BYTES, + operation == "read_lines", + ), + ) + .await + .map_err(|_| { + protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "text read deadline", + ) + })? + .map_err(map_tool_error)?; + let (read, validator) = result; + self.check_connection()?; + let text = String::from_utf8_lossy(&read.bytes); + let value = crate::file_operation::page_result( + operation, + arguments, + &text, + offset, + read.truncated, + )?; + return Ok(match validator { + Some(raw) => WipOperationOutput::native_with_validator( + value, + self.provider + .wrap_validator(&self.alias, self.generation, &self.workdir, &raw), + ), + None => WipOperationOutput::native(value), + }); + } let tool = match operation { "list" => "List", "read" => "Read", @@ -774,53 +745,10 @@ fn operation( parameters, returns: ReturnDeclaration { documentation: None, - r#type: result_type(name == "list"), + r#type: TypeExpr::Json, }, } } -fn field(name: &str, required: bool, r#type: TypeExpr) -> FieldDeclaration { - FieldDeclaration { - name: name.into(), - required, - documentation: None, - r#type, - } -} -fn cursor_type() -> TypeExpr { - TypeExpr::Record { - fields: vec![ - field("kind", true, TypeExpr::String), - field("entry", true, TypeExpr::Entry), - ], - } -} -fn result_type(list: bool) -> TypeExpr { - let mut item = vec![field("entry", true, TypeExpr::Entry)]; - if list { - item.extend([ - field("kind", true, TypeExpr::String), - field("size", true, TypeExpr::Integer), - ]); - } - let mut fields = vec![ - field("summary", true, TypeExpr::String), - field("content", false, TypeExpr::String), - field( - "items", - true, - TypeExpr::List { - items: Box::new(TypeExpr::Record { fields: item }), - }, - ), - ]; - if list { - fields.extend([ - field("truncated", true, TypeExpr::Boolean), - field("after", false, cursor_type()), - ]); - } - TypeExpr::Record { fields } -} fn kind_name(kind: EntryKind) -> &'static str { match kind { EntryKind::Directory => "directory", @@ -841,19 +769,6 @@ fn cursor_value(alias: &str, cursor: &workdir::ListCursor) -> Value { fn descriptor(operations: Vec) -> InterfaceDescriptor { InterfaceDescriptor{format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation{summary:"AI-oriented attached Workdir operations".into(),details:Some("The route binds attachment and target. Line offsets are zero-based; text output numbers lines from one. Read-before-write and provider validators are both required. Paths are Worldspace references, never host paths.".into())}),types:Vec::new(),operations} } -fn grep_parameters() -> Vec { - let mut p = vec![parameter("pattern", true, TypeExpr::String)]; - for name in ["path", "glob", "type", "output_mode"] { - p.push(parameter(name, false, TypeExpr::String)); - } - for name in ["-A", "-B", "-C", "head_limit", "offset"] { - p.push(parameter(name, false, TypeExpr::Integer)); - } - for name in ["case_insensitive", "multiline"] { - p.push(parameter(name, false, TypeExpr::Boolean)); - } - p -} fn error(code: ProtocolErrorCode, message: &str) -> ProtocolError { ProtocolError { code, diff --git a/crates/worker/src/file_operation.rs b/crates/worker/src/file_operation.rs index 57a5f359f..b5bfb800a 100644 --- a/crates/worker/src/file_operation.rs +++ b/crates/worker/src/file_operation.rs @@ -41,3 +41,421 @@ fn schema_parameters(schema: &schemars::Schema) -> Vec { }) .collect() } + +use crate::wip::{ + WipCallContext, WipOperationError, WipOperationHandler, WipOperationOutput, WipProjection, +}; +use async_trait::async_trait; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeMap, sync::Arc}; +use wip_protocol::{ + Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceReference, + OperationDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, Value, +}; + +pub(crate) const MAX_LINES: usize = 2000; +pub(crate) const MAX_BYTES: usize = 64 * 1024; + +/// Contracts have independent capability membership and no target-dependent descriptor fields. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Contract { + ReadLines, + SearchText, + Edit, + Write, + List, + Glob, + Grep, + CreateFile, +} +impl Contract { + pub(crate) fn reference(self) -> InterfaceReference { + crate::wip::root_reference(match self { + Self::ReadLines => "yoi.file/read-lines/v1", + Self::SearchText => "yoi.file/search-text/v1", + Self::Edit => "yoi.file/edit-text/v1", + Self::Write => "yoi.file/write-text/v1", + Self::List => "yoi.directory/list/v1", + Self::Glob => "yoi.directory/glob/v1", + Self::Grep => "yoi.directory/grep/v1", + Self::CreateFile => "yoi.directory/create-file/v1", + }) + } + pub(crate) fn from_reference(r: &InterfaceReference) -> Option { + [ + Self::ReadLines, + Self::SearchText, + Self::Edit, + Self::Write, + Self::List, + Self::Glob, + Self::Grep, + Self::CreateFile, + ] + .into_iter() + .find(|c| c.reference() == *r) + } + pub(crate) fn operation(self) -> &'static str { + match self { + Self::ReadLines => "read", + Self::SearchText => "search", + Self::Edit => "edit", + Self::Write => "write", + Self::List => "list", + Self::Glob => "glob", + Self::Grep => "grep", + Self::CreateFile => "create_file", + } + } + pub(crate) fn handler_operation(self) -> &'static str { + match self { + Self::ReadLines => "read_lines", + Self::SearchText => "search_text", + _ => self.operation(), + } + } + pub(crate) fn descriptor(self) -> InterfaceDescriptor { + let (summary, parameters, result) = match self { + Self::ReadLines => ( + "Read a bounded text line window", + parameters("read", true).unwrap(), + page_type(false), + ), + Self::SearchText => { + let mut p = parameters("read", true).unwrap(); + p.push(parameter("pattern", true, TypeExpr::String)); + ( + "Search a bounded text line window for a literal", + p, + page_type(true), + ) + } + Self::Edit | Self::Write => ( + "Change existing text under provider preconditions", + parameters(self.operation(), true).unwrap(), + mutation_type(), + ), + Self::List => ( + "List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; limit defaults to 100, maximum 1000; after continues by kind/entry. Includes hidden/ignored names under provider List policy, unlike Glob/Grep.", + vec![ + parameter("limit", false, TypeExpr::Integer), + parameter("after", false, cursor_type()), + ], + result_type(true), + ), + Self::Glob => ( + "Glob within this directory", + vec![ + parameter("pattern", true, TypeExpr::String), + parameter("path", false, TypeExpr::String), + ], + result_type(false), + ), + Self::Grep => ( + "Regex search within this directory", + grep_parameters(), + result_type(false), + ), + Self::CreateFile => ( + "Create a new relative file and permitted missing parents; never overwrite", + vec![ + parameter("path", true, TypeExpr::String), + parameter("content", true, TypeExpr::String), + ], + mutation_type(), + ), + }; + InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),types:vec![],documentation:Some(Documentation {summary:summary.into(),details:Some(match self { + Self::ReadLines|Self::SearchText=>"UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry.", + Self::Edit|Self::Write|Self::CreateFile=>"UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry.", + _=>"Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry.", + }.into())}),operations:vec![OperationDeclaration {name:self.operation().into(),documentation:None,parameters,returns:ReturnDeclaration {documentation:None,r#type:result}}]} + } + pub(crate) fn validator(self) -> Vec { + Sha256::digest(format!("{:?}", self.descriptor())).to_vec() + } +} +pub(crate) fn expose(p: &mut WipProjection, contracts: impl IntoIterator) { + for c in contracts { + let r = c.reference(); + if !p.object.interfaces.contains(&r) { + p.object.interfaces.push(r); + } + } +} + +/// Select only from current provider membership; the selected handler still owns all authority. +pub(crate) fn select(p: &mut WipProjection, r: &InterfaceReference) -> bool { + let Some(c) = Contract::from_reference(r) else { + return false; + }; + if !p.object.interfaces.contains(r) { + return false; + } + p.interface = r.clone(); + p.descriptor = c.descriptor(); + p.interface_validator = Some(c.validator()); + p.handler = Arc::new(Selected { + contract: c, + inner: p.handler.clone(), + }); + true +} +struct Selected { + contract: Contract, + inner: Arc, +} +#[async_trait] +impl WipOperationHandler for Selected { + async fn call( + &self, + _: &str, + args: &BTreeMap, + context: WipCallContext, + ) -> Result { + let mut out = self + .inner + .call(self.contract.handler_operation(), args, context) + .await?; + if matches!( + self.contract, + Contract::Edit | Contract::Write | Contract::CreateFile + ) { + out.value = Value::Record(BTreeMap::from([("metadata".into(), out.value)])); + } + Ok(out) + } + async fn cancel_operation( + &self, + _: &str, + ctx: &agen::tool::ToolExecutionContext, + ) -> Result<(), agen::tool::ToolError> { + self.inner + .cancel_operation(self.contract.handler_operation(), ctx) + .await + } +} +fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError { + WipOperationError::Protocol(ProtocolError { + code, + message: message.into(), + }) +} +pub(crate) fn window( + args: &BTreeMap, + search: bool, +) -> Result<(usize, usize), WipOperationError> { + if args + .keys() + .any(|k| !["offset", "limit"].contains(&k.as_str()) && !(search && k == "pattern")) + { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "unknown text argument", + )); + } + let integer = |name: &str, default: usize| match args.get(name) { + None => Ok(default), + Some(Value::Integer(i)) => usize::try_from(*i) + .map_err(|_| failure(ProtocolErrorCode::InvalidArguments, "negative text range")), + _ => Err(failure( + ProtocolErrorCode::InvalidArguments, + "invalid text range", + )), + }; + let offset = integer("offset", 0)?; + let limit = integer("limit", MAX_LINES)?; + if !(1..=MAX_LINES).contains(&limit) || offset > i64::MAX as usize - MAX_LINES { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "text range exceeds bounds", + )); + } + if search { + match args.get("pattern") { + Some(Value::String(p)) if !p.is_empty() && p.len() <= 4096 => {} + _ => { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "search requires a nonempty literal pattern up to 4096 bytes", + )); + } + } + } + Ok((offset, limit)) +} +pub(crate) fn text_result( + op: &str, + args: &BTreeMap, + content: &str, +) -> Result { + let (offset, limit) = window(args, op == "search_text")?; + let mut lines = content.lines().skip(offset); + let mut text = String::new(); + for line in lines.by_ref().take(limit) { + if text.len().saturating_add(line.len()).saturating_add(1) >= MAX_BYTES { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "text window exceeds source byte bound", + )); + } + text.push_str(line); + text.push('\n'); + } + page_result(op, args, &text, offset, lines.next().is_some()) +} +/// A checked provider can supply only the selected window, without copying whole source files. +pub(crate) fn page_result( + op: &str, + args: &BTreeMap, + text: &str, + offset: usize, + more: bool, +) -> Result { + let (_, limit) = window(args, op == "search_text")?; + if text.len() >= MAX_BYTES { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "text window exceeds source byte bound", + )); + } + let count = text.lines().count(); + if count > limit { + return Err(failure( + ProtocolErrorCode::Internal, + "provider returned an invalid line window", + )); + } + let mut fields = BTreeMap::from([ + ( + "next_offset".into(), + Value::Integer((offset + count) as i64), + ), + ("truncated".into(), Value::Boolean(more)), + ]); + if op == "search_text" { + let Some(Value::String(pattern)) = args.get("pattern") else { + unreachable!("window checked pattern") + }; + let matches = text + .lines() + .enumerate() + .filter(|(_, l)| l.contains(pattern)) + .map(|(i, l)| { + Value::Record(BTreeMap::from([ + ("line".into(), Value::Integer((offset + i + 1) as i64)), + ("text".into(), Value::String(l.into())), + ])) + }) + .collect(); + fields.insert("matches".into(), Value::List(matches)); + } else { + let full_len = text + .lines() + .enumerate() + .map(|(i, l)| format!("{:>6}\t", offset + i + 1).len() + l.len() + 1) + .sum::(); + if full_len > MAX_BYTES.saturating_sub(64) { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "numbered window exceeds output byte bound", + )); + } + let rendered = + fs_operation::text::render_numbered(text, offset, offset + count, false, MAX_BYTES); + fields.insert("content".into(), Value::String(rendered.body)); + } + Ok(Value::Record(fields)) +} +fn parameter(n: &str, r: bool, t: TypeExpr) -> ParameterDeclaration { + ParameterDeclaration { + name: n.into(), + required: r, + documentation: None, + r#type: t, + } +} +fn field(n: &str, r: bool, t: TypeExpr) -> FieldDeclaration { + FieldDeclaration { + name: n.into(), + required: r, + documentation: None, + r#type: t, + } +} +fn mutation_type() -> TypeExpr { + TypeExpr::Record { + fields: vec![field("metadata", true, TypeExpr::Json)], + } +} +fn page_type(search: bool) -> TypeExpr { + let mut fields = vec![ + field("next_offset", true, TypeExpr::Integer), + field("truncated", true, TypeExpr::Boolean), + ]; + fields.push(if search { + field( + "matches", + true, + TypeExpr::List { + items: Box::new(TypeExpr::Record { + fields: vec![ + field("line", true, TypeExpr::Integer), + field("text", true, TypeExpr::String), + ], + }), + }, + ) + } else { + field("content", true, TypeExpr::String) + }); + TypeExpr::Record { fields } +} +fn cursor_type() -> TypeExpr { + TypeExpr::Record { + fields: vec![ + field("kind", true, TypeExpr::String), + field("entry", true, TypeExpr::Entry), + ], + } +} +fn result_type(list: bool) -> TypeExpr { + let mut item = vec![field("entry", true, TypeExpr::Entry)]; + if list { + item.extend([ + field("kind", true, TypeExpr::String), + field("size", true, TypeExpr::Integer), + ]); + } + let mut fields = vec![ + field("summary", true, TypeExpr::String), + field("content", false, TypeExpr::String), + field( + "items", + true, + TypeExpr::List { + items: Box::new(TypeExpr::Record { fields: item }), + }, + ), + ]; + if list { + fields.extend([ + field("truncated", true, TypeExpr::Boolean), + field("after", false, cursor_type()), + ]); + } + TypeExpr::Record { fields } +} +fn grep_parameters() -> Vec { + let mut p = vec![parameter("pattern", true, TypeExpr::String)]; + for n in ["path", "glob", "type", "output_mode"] { + p.push(parameter(n, false, TypeExpr::String)); + } + for n in ["-A", "-B", "-C", "head_limit", "offset"] { + p.push(parameter(n, false, TypeExpr::Integer)); + } + for n in ["case_insensitive", "multiline"] { + p.push(parameter(n, false, TypeExpr::Boolean)); + } + p +} diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 8c4a0849e..e2e95f0d9 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -418,13 +418,22 @@ struct MountedProjection { impl MountedProjection { fn resolved(&self) -> WipProjection { let mut projection = self.projection.clone(); + let mut handlers: BTreeMap<_, _> = self + .operation_handlers + .iter() + .map(|(op, (_, h))| (op.clone(), h.clone())) + .collect(); + for reference in &projection.object.interfaces { + if let Some(contract) = crate::file_operation::Contract::from_reference(reference) { + handlers.insert( + contract.handler_operation().into(), + self.projection.handler.clone(), + ); + } + } projection.handler = Arc::new(OperationDispatchHandler { owner: Arc::clone(&self.projection.handler), - handlers: self - .operation_handlers - .iter() - .map(|(operation, (_, handler))| (operation.clone(), Arc::clone(handler))) - .collect(), + handlers, }); projection } @@ -1188,10 +1197,24 @@ fn validate_projection(projection: &WipProjection) -> Result<(), WipMountError> route: projection.route.clone(), message: error.to_string(), })?; - if projection.object.interfaces != [projection.interface.clone()] { + if !projection.object.interfaces.contains(&projection.interface) + || projection.object.interfaces.iter().any(|reference| { + *reference != projection.interface + && crate::file_operation::Contract::from_reference(reference).is_none() + }) + { return Err(WipMountError::InvalidProjection { route: projection.route.clone(), - message: "projected object must expose exactly its declared interface".into(), + message: "projected object must expose its declared interface and only known common contracts".into(), + }); + } + if let Some(contract) = crate::file_operation::Contract::from_reference(&projection.interface) + && (projection.descriptor != contract.descriptor() + || projection.interface_validator.as_deref() != Some(contract.validator().as_slice())) + { + return Err(WipMountError::InvalidProjection { + route: projection.route.clone(), + message: "common contract descriptor/validator differs from its definition".into(), }); } projection @@ -1304,9 +1327,13 @@ impl WipHost { path: &str, reference: &InterfaceReference, ) -> Result, ProtocolError> { - let Some(owner) = self.publication_live(path).await? else { + let Some(mut owner) = self.publication_live(path).await? else { return Ok(None); }; + if crate::file_operation::select(&mut owner.projection, reference) { + owner.scope = self.object_at(&reference.scope); + return Ok(Some(owner)); + } // A live provider owns all its publication metadata; static additional // Interface registrations must not survive its scope lifetime changes. if owner.projection.interface == *reference || self.subtree(path).is_some() { @@ -1333,6 +1360,20 @@ impl WipHost { reference .validate() .map_err(|e| protocol_error(ProtocolErrorCode::InvalidRequest, e.to_string()))?; + if let Some(contract) = crate::file_operation::Contract::from_reference(reference) { + let scope = self.object_at("/").ok_or_else(|| { + protocol_error( + ProtocolErrorCode::InterfaceNotFound, + "root scope unavailable", + ) + })?; + return Ok(FetchInterfaceResponse { + interface: reference.clone(), + scope_ref: scope.r#ref, + descriptor: contract.descriptor(), + validator: Some(contract.validator()), + }); + } let target = if let Some(mount) = self.subtree(&reference.scope) { let target = mount.provider.interface_target(reference).ok_or_else(|| { protocol_error( @@ -1496,6 +1537,9 @@ impl WipHost { &self, reference: &InterfaceReference, ) -> Option<(InterfaceDescriptor, Option>)> { + if let Some(contract) = crate::file_operation::Contract::from_reference(reference) { + return Some((contract.descriptor(), Some(contract.validator()))); + } for (path, all) in &self.registry.additional_interfaces { if self.projection(path).is_none() { continue; From bfb51b7ca9f7a4f72ad76026975d25f7432fcbf4 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 01:47:15 +0900 Subject: [PATCH 04/29] fix: bound encoded common text results including escape and record overhead --- crates/worker/src/file_operation.rs | 97 ++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/crates/worker/src/file_operation.rs b/crates/worker/src/file_operation.rs index b5bfb800a..c8910a101 100644 --- a/crates/worker/src/file_operation.rs +++ b/crates/worker/src/file_operation.rs @@ -265,7 +265,9 @@ pub(crate) fn window( }; let offset = integer("offset", 0)?; let limit = integer("limit", MAX_LINES)?; - if !(1..=MAX_LINES).contains(&limit) || offset > i64::MAX as usize - MAX_LINES { + if !(1..=MAX_LINES).contains(&limit) + || (offset as u64) > 9_007_199_254_740_991u64 - MAX_LINES as u64 + { return Err(failure( ProtocolErrorCode::InvalidArguments, "text range exceeds bounds", @@ -365,7 +367,19 @@ pub(crate) fn page_result( fs_operation::text::render_numbered(text, offset, offset + count, false, MAX_BYTES); fields.insert("content".into(), Value::String(rendered.body)); } - Ok(Value::Record(fields)) + let value = Value::Record(fields); + let json = crate::wip::wip_to_json(&value) + .map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?; + // The model receives pretty JSON, so budget escapes and record/indentation overhead too. + let encoded = serde_json::to_vec_pretty(&json) + .map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?; + if encoded.len() > MAX_BYTES { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "serialized text window exceeds output byte bound", + )); + } + Ok(value) } fn parameter(n: &str, r: bool, t: TypeExpr) -> ParameterDeclaration { ParameterDeclaration { @@ -459,3 +473,82 @@ fn grep_parameters() -> Vec { } p } + +#[cfg(test)] +mod tests { + use super::*; + fn args(value: serde_json::Value) -> BTreeMap { + let Value::Record(args) = crate::wip::json_to_wip(&value).unwrap() else { + panic!("record") + }; + args + } + #[test] + fn text_window_preserves_unicode_crlf_offsets_and_literal_search_continuation() { + let input = "first\r\n雪.*\r\nnot a match\n雪.*\n"; + let arguments = args(serde_json::json!({"offset":1,"limit":2,"pattern":".*"})); + let whole = text_result("search_text", &arguments, input) + .unwrap_or_else(|_| panic!("text result refused")); + let partial = page_result("search_text", &arguments, "雪.*\r\nnot a match\n", 1, true) + .unwrap_or_else(|_| panic!("page refused")); + assert_eq!(whole, partial); + let result = crate::wip::wip_to_json(&whole).unwrap(); + assert_eq!( + result, + serde_json::json!({"matches":[{"line":2,"text":"雪.*"}],"next_offset":3,"truncated":true}) + ); + let read = text_result( + "read_lines", + &args(serde_json::json!({"offset":1,"limit":1})), + input, + ) + .unwrap_or_else(|_| panic!("read refused")); + assert_eq!( + crate::wip::wip_to_json(&read).unwrap(), + serde_json::json!({"content":" 2\t雪.*\n","next_offset":2,"truncated":true}) + ); + } + #[test] + fn text_window_refuses_invalid_ranges_patterns_source_and_encoded_output_bounds() { + for value in [ + serde_json::json!({"limit":0}), + serde_json::json!({"limit":2001}), + serde_json::json!({"offset":-1}), + serde_json::json!({"offset":9_007_199_254_740_991i64}), + serde_json::json!({"file_path":"elsewhere"}), + ] { + assert!( + matches!(text_result("read_lines",&args(value),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments) + ); + } + for pattern in [String::new(), "x".repeat(4097)] { + assert!( + matches!(text_result("search_text",&args(serde_json::json!({"pattern":pattern})),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments) + ); + } + for (operation, arguments, source) in [ + ( + "read_lines", + serde_json::json!({}), + "雪".repeat(MAX_BYTES / 3 + 1), + ), + ("read_lines", serde_json::json!({}), "\u{1}".repeat(16000)), + ( + "search_text", + serde_json::json!({"pattern":"x"}), + "x\n".repeat(MAX_LINES), + ), + ( + "search_text", + serde_json::json!({"pattern":"\""}), + format!("{}\n", "\"".repeat(40000)), + ), + ] { + assert!( + matches!(text_result(operation,&args(arguments),&source),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ResourceLimitExceeded), + "{operation}: {} source bytes", + source.len() + ); + } + } +} From 434ce34da79bf51ac02e9bac60d2cbbd5662f9c1 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 02:01:22 +0900 Subject: [PATCH 05/29] refactor: bind checkout target lifetimes to Object observations --- crates/worker/src/checkout.rs | 49 ++-- crates/worker/src/checkout_http_tests.rs | 6 +- crates/worker/src/checkout_race_tests.rs | 7 +- crates/worker/src/checkout_wip_tests.rs | 299 ++++++++++++++++++++--- 4 files changed, 315 insertions(+), 46 deletions(-) diff --git a/crates/worker/src/checkout.rs b/crates/worker/src/checkout.rs index 6bf7e797e..4071d38e6 100644 --- a/crates/worker/src/checkout.rs +++ b/crates/worker/src/checkout.rs @@ -116,13 +116,21 @@ impl Provider { } Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?)) } - fn wrap_validator(&self, alias: &str, generation: u64, workdir: &str, raw: &[u8]) -> Vec { + fn wrap_validator( + &self, + alias: &str, + generation: u64, + workdir: &str, + target: &WorkdirPath, + raw: &[u8], + ) -> Vec { let mut hash = Sha256::new(); for value in [ self.incarnation.as_bytes(), alias.as_bytes(), &generation.to_be_bytes(), workdir.as_bytes(), + target.as_str().as_bytes(), raw, ] { hash.update(value.len().to_be_bytes()); @@ -287,7 +295,8 @@ impl Provider { "directory" }; let primary = *contracts.first()?; - let validator = self.wrap_validator(alias, generation, workdir, &observation.validator); + let validator = + self.wrap_validator(alias, generation, workdir, &target, &observation.validator); let handler = Arc::new(FileHandler { provider: self.clone(), alias: alias.into(), @@ -511,8 +520,13 @@ impl FileHandler { if let Some(raw) = result.validator { Ok(WipOperationOutput::native_with_validator( value, - self.provider - .wrap_validator(&self.alias, self.generation, &self.workdir, &raw), + self.provider.wrap_validator( + &self.alias, + self.generation, + &self.workdir, + &self.target, + &raw, + ), )) } else { Ok(WipOperationOutput::native(value)) @@ -533,6 +547,16 @@ impl WipOperationHandler for FileHandler { if !self.provider.allowed("Read",&json!({"target_workdir":self.alias,"file_path":self.target.as_str(),"offset":offset,"limit":limit})) { return Err(protocol(ProtocolErrorCode::PermissionDenied,"text read denied")); } + let _permit = + tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire()) + .await + .map_err(|_| { + protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "text read capacity deadline", + ) + })? + .map_err(|_| protocol(ProtocolErrorCode::Internal, "text read unavailable"))?; let result = tokio::time::timeout( self.provider.deadline, tools::checkout::read_checkout_text( @@ -569,8 +593,13 @@ impl WipOperationHandler for FileHandler { return Ok(match validator { Some(raw) => WipOperationOutput::native_with_validator( value, - self.provider - .wrap_validator(&self.alias, self.generation, &self.workdir, &raw), + self.provider.wrap_validator( + &self.alias, + self.generation, + &self.workdir, + &self.target, + &raw, + ), ), None => WipOperationOutput::native(value), }); @@ -723,14 +752,6 @@ fn join_under(parent: &WorkdirPath, relative: &str) -> Result ParameterDeclaration { - ParameterDeclaration { - name: name.into(), - required, - documentation: None, - r#type, - } -} fn operation( name: &str, summary: &str, diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index f7df26e26..b3d85bfc6 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -358,7 +358,8 @@ async fn observe_interface(runtime: &WipRuntime, path: &str) -> wip_protocol::In } async fn native_call(runtime: &WipRuntime, path: &str, operation: &str, args: Json) -> Value { - let interface = observe_interface(runtime, path).await; + observe_interface(runtime, path).await; + let interface = super::checkout_wip_tests::contract(operation).reference(); runtime .call( path.into(), @@ -620,7 +621,8 @@ async fn committed_write_unknown(fault: WriteResponseFault) { let (runtime, router) = native_runtime(remote); let file = "/checkouts/main/src/deep/a.txt"; native_call(&runtime, file, "read", json!({})).await; - let interface = observe_interface(&runtime, file).await; + observe_interface(&runtime, file).await; + let interface = super::checkout_wip_tests::contract("write").reference(); *fixture.state.write_fault.lock().unwrap() = Some(fault); let error = runtime .call( diff --git a/crates/worker/src/checkout_race_tests.rs b/crates/worker/src/checkout_race_tests.rs index da58fc64c..3e585db69 100644 --- a/crates/worker/src/checkout_race_tests.rs +++ b/crates/worker/src/checkout_race_tests.rs @@ -683,7 +683,12 @@ async fn committed_checkout_mutation_losing_connection_is_unknown_not_rejected_o let calls = Arc::new(AtomicUsize::new(0)); let route = object_path("main", &file.target); let mut projection = p.node(&route).await.unwrap().unwrap(); - let interface = projection.interface.clone(); + let interface = match operation { + "write" => crate::file_operation::Contract::Write, + "edit" => crate::file_operation::Contract::Edit, + _ => crate::file_operation::Contract::CreateFile, + } + .reference(); projection.handler = Arc::new(DetachBeforePublication { file, calls: calls.clone(), diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs index c4ca3698f..dd3da478c 100644 --- a/crates/worker/src/checkout_wip_tests.rs +++ b/crates/worker/src/checkout_wip_tests.rs @@ -41,13 +41,28 @@ async fn interface(r: &WipRuntime, path: &str) -> wip_protocol::InterfaceReferen .unwrap(); p.interface } +pub(super) fn contract(operation: &str) -> crate::file_operation::Contract { + use crate::file_operation::Contract as C; + match operation { + "read" => C::ReadLines, + "search" => C::SearchText, + "edit" => C::Edit, + "write" => C::Write, + "list" => C::List, + "glob" => C::Glob, + "grep" => C::Grep, + "create_file" => C::CreateFile, + _ => panic!("unknown file operation {operation}"), + } +} async fn call( r: &WipRuntime, path: &str, operation: &str, args: Json, ) -> Result { - let i = interface(r, path).await; + interface(r, path).await; + let i = contract(operation).reference(); r.call(path.into(), i, operation.into(), args, Default::default()) .await } @@ -336,12 +351,26 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() { let old = r.host.projection_live(path).await.unwrap().unwrap(); router.detach(&alias).await.unwrap(); assert!(r.host.projection_live(path).await.unwrap().is_none()); - assert!(r.host.fetch_interface_live(&old.interface).await.is_err()); + assert_eq!( + r.host + .fetch_interface_live(&old.interface) + .await + .unwrap() + .validator, + old.interface_validator + ); router.attach(alias, session(&b, false)).unwrap(); let new = r.host.projection_live(path).await.unwrap().unwrap(); - assert_ne!(old.interface, new.interface); + assert_eq!(old.interface, new.interface); assert_ne!(old.object.validator, new.object.validator); - assert!(r.host.fetch_interface_live(&old.interface).await.is_err()); + assert_eq!( + r.host + .fetch_interface_live(&old.interface) + .await + .unwrap() + .validator, + old.interface_validator + ); assert!( call( &r, @@ -354,14 +383,16 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() { ); let restored = runtime(router, None); let next = restored.host.projection_live(path).await.unwrap().unwrap(); - assert_ne!(new.interface, next.interface); + assert_eq!(new.interface, next.interface); assert_ne!(new.object.validator, next.object.validator); - assert!( + assert_eq!( restored .host .fetch_interface_live(&new.interface) .await - .is_err() + .unwrap() + .validator, + new.interface_validator ); assert_eq!( std::fs::read_to_string(b.path().join("file.txt")).unwrap(), @@ -497,7 +528,7 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() { .unwrap(); let r = runtime(router, None); let path = "/checkouts/main/file.txt"; - let i = interface(&r, path).await; + interface(&r, path).await; // No rediscovery or Interface refresh between any of these operations. for (op, args) in [ ("read", json!({})), @@ -505,16 +536,23 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() { ("write", json!({"content":"saved\n"})), ("read", json!({})), ] { - r.call(path.into(), i.clone(), op.into(), args, Default::default()) - .await - .unwrap(); + r.call( + path.into(), + contract(op).reference(), + op.into(), + args, + Default::default(), + ) + .await + .unwrap(); } assert_eq!( std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), "saved\n" ); let parent = "/checkouts/main"; - let i = interface(&r, parent).await; + interface(&r, parent).await; + let i = contract("create_file").reference(); for name in ["one.txt", "nested/two.txt"] { r.call( parent.into(), @@ -617,11 +655,12 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() { .is_none() ); let file = "/checkouts/child/a.txt"; - let i = interface(&child, file).await; + interface(&child, file).await; + let i = contract("write").reference(); child .call( file.into(), - i.clone(), + contract("read").reference(), "read".into(), json!({}), Default::default(), @@ -686,7 +725,21 @@ pub(super) async fn model_invoke( operation: &str, arguments: Json, ) -> Result { - model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":inspected["interfaces"][0]["reference"], "operation":operation, "arguments":arguments})).await + let reference = inspected["interfaces"] + .as_array() + .unwrap() + .iter() + .map(|item| &item["reference"]) + .find(|r| { + Some(wip_protocol::InterfaceReference { + scope: r["scope"].as_str().unwrap().into(), + name: r["name"].as_str().unwrap().into(), + }) + .and_then(|r| crate::file_operation::Contract::from_reference(&r)) + .is_some_and(|c| c.operation() == operation) + }) + .unwrap_or(&inspected["interfaces"][0]["reference"]); + model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":reference, "operation":operation, "arguments":arguments})).await } #[tokio::test] @@ -752,10 +805,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed let r = Arc::new(runtime(router, None)); let entrance = model_inspect(r.clone(), "/checkouts/main").await; assert_eq!(entrance["path"], "/checkouts/main"); - assert_eq!( - entrance["interfaces"][0]["reference"]["scope"], - "/checkouts/main" - ); + assert_eq!(entrance["interfaces"][0]["reference"]["scope"], "/"); let projection = r .host .projection_live("/checkouts/main") @@ -829,10 +879,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed json!([]) ); let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await; - assert_eq!( - deep["interfaces"][0]["reference"]["scope"], - "/checkouts/main" - ); + assert_eq!(deep["interfaces"][0]["reference"]["scope"], "/"); for (operation, args) in [ ("list", json!({})), ("glob", json!({"pattern":"*.txt"})), @@ -926,7 +973,7 @@ async fn checkout_list_permission_cannot_be_bypassed_with_glob_or_command_identi } #[tokio::test] -async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connection_before_read() { +async fn common_scope_rejects_wrong_namespace_scope_ref_and_old_target_authority() { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("a.txt"), "same\n").unwrap(); let router = Arc::new(WorkdirSessionRouter::new()); @@ -943,8 +990,8 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti let p = r.host.projection_live(path).await.unwrap().unwrap(); let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap(); assert!( - fetched.scope_ref.is_none(), - "ref-less publication is legitimate" + fetched.scope_ref.is_some(), + "common namespace uses the Host root scope" ); // Ordinary content changes do not terminate the entrance's Interface lifetime. std::fs::write(dir.path().join("new.txt"), "fixture").unwrap(); @@ -959,12 +1006,12 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti let request = |reference, scope_ref| CallOperationRequest { target: wip_protocol::Target { path: path.into(), - validator: None, + validator: p.object.validator.clone(), }, interface: wip_protocol::InterfaceTarget { reference, scope_ref, - validator: None, + validator: fetched.validator.clone(), }, operation: "read".into(), arguments: BTreeMap::new(), @@ -996,6 +1043,200 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti router.attach(main, session(&dir, true)).unwrap(); let forged = request(p.interface.clone(), None); assert!( - matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch) + matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::ValidatorMismatch) + ); +} + +fn observed_common_request( + path: &str, + validator: Option>, + fetched: &FetchInterfaceResponse, + operation: &str, +) -> CallOperationRequest { + CallOperationRequest { + target: wip_protocol::Target { + path: path.into(), + validator, + }, + interface: wip_protocol::InterfaceTarget { + reference: fetched.interface.clone(), + scope_ref: fetched.scope_ref.clone(), + validator: fetched.validator.clone(), + }, + operation: operation.into(), + arguments: BTreeMap::new(), + } +} +fn file_context() -> WipCallContext { + WipCallContext { + execution: Default::default(), + security_context: "file-contract-test".into(), + } +} + +#[tokio::test] +async fn shared_contract_survives_one_attachment_update_but_old_object_authority_never_transfers() { + let left = TempDir::new().unwrap(); + let right = TempDir::new().unwrap(); + for root in [&left, &right] { + std::fs::write(root.path().join("a.txt"), "same\n").unwrap(); + } + std::fs::write(left.path().join("b.txt"), "same\n").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + let alias = WorkdirAttachmentAlias::new("left").unwrap(); + router.attach(alias.clone(), session(&left, false)).unwrap(); + router + .attach( + WorkdirAttachmentAlias::new("right").unwrap(), + session(&right, true), + ) + .unwrap(); + let r = runtime(router.clone(), None); + let path = "/checkouts/left/a.txt"; + let other = "/checkouts/right/a.txt"; + let first = r.host.projection_live(path).await.unwrap().unwrap(); + let same_bytes = r + .host + .projection_live("/checkouts/left/b.txt") + .await + .unwrap() + .unwrap(); + let independent = r.host.projection_live(other).await.unwrap().unwrap(); + assert_eq!(first.interface, independent.interface); + assert_eq!(first.descriptor, independent.descriptor); + assert_ne!(first.object.validator, same_bytes.object.validator); + let definition = r.host.fetch_interface_live(&first.interface).await.unwrap(); + let request = + observed_common_request(path, first.object.validator.clone(), &definition, "read"); + assert!(r.host.call(request.clone(), file_context()).await.is_ok()); + // Replace the same path with a different inode containing exactly the same bytes. + std::fs::write(left.path().join("replacement"), "same\n").unwrap(); + std::fs::rename(left.path().join("replacement"), left.path().join("a.txt")).unwrap(); + assert!( + matches!(r.host.call(request.clone(),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch) + ); + assert_eq!( + r.host + .fetch_interface_live(&first.interface) + .await + .unwrap() + .validator, + definition.validator + ); + assert!( + r.host + .call( + observed_common_request( + other, + independent.object.validator.clone(), + &definition, + "read" + ), + file_context() + ) + .await + .is_ok() + ); + call(&r, path, "read", json!({})).await.unwrap(); + router.detach(&alias).await.unwrap(); + router.attach(alias, session(&left, false)).unwrap(); + assert!( + matches!(r.host.call(request,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch) + ); + assert!( + r.host + .call( + observed_common_request(other, independent.object.validator, &definition, "read"), + file_context() + ) + .await + .is_ok() + ); + call(&r, path, "read", json!({})).await.unwrap(); + let before_restore = r.host.projection_live(path).await.unwrap().unwrap(); + let restored = runtime(router, None); + let restored_definition = restored + .host + .fetch_interface_live(&first.interface) + .await + .unwrap(); + assert_eq!(definition.descriptor, restored_definition.descriptor); + assert_eq!(definition.validator, restored_definition.validator); + assert!( + matches!(restored.host.call(observed_common_request(path,before_restore.object.validator,&restored_definition,"read"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch) + ); + call(&restored, path, "read", json!({})).await.unwrap(); +} + +#[tokio::test] +async fn shared_readonly_contract_rejects_wrong_membership_operation_and_interface_validator() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a.txt"), "雪.*\r\nother\n").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, true), + ) + .unwrap(); + let r = Arc::new(runtime(router, None)); + let path = "/checkouts/main/a.txt"; + let observed = model_inspect(r.clone(), path).await; + let read = json_content( + model_invoke(r.clone(), &observed, "read", json!({"limit":1})) + .await + .unwrap(), + ); + assert_eq!( + read, + json!({"content":" 1\t雪.*\n","next_offset":1,"truncated":true}) + ); + let searched = json_content( + model_invoke( + r.clone(), + &observed, + "search", + json!({"pattern":".*","limit":1}), + ) + .await + .unwrap(), + ); + assert_eq!( + searched, + json!({"matches":[{"line":1,"text":"雪.*"}],"next_offset":1,"truncated":true}) + ); + let p = r.host.projection_live(path).await.unwrap().unwrap(); + let read = r + .host + .fetch_interface_live(&contract("read").reference()) + .await + .unwrap(); + let write = r + .host + .fetch_interface_live(&contract("write").reference()) + .await + .unwrap(); + assert!( + matches!(r.host.call(observed_common_request(path,p.object.validator.clone(),&write,"write"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceMismatch) + ); + assert!( + matches!(r.host.call(observed_common_request(path,p.object.validator.clone(),&read,"missing"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::OperationNotFound) + ); + let mut stale = observed_common_request(path, p.object.validator.clone(), &read, "read"); + stale.interface.validator = Some(vec![0]); + assert!( + matches!(r.host.call(stale,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceValidatorMismatch) + ); + let mut legacy = observed_common_request(path, p.object.validator, &read, "read"); + legacy.interface.reference = InterfaceReference { + scope: "/checkouts/main".into(), + name: "yoi.checkout/old/file/v1#target/old".into(), + }; + assert!( + matches!(r.host.call(legacy,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceMismatch) + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("a.txt")).unwrap(), + "雪.*\r\nother\n" ); } From e8dfb3e68dc80b34da2e42511b16e4ad7d02fc07 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 02:02:28 +0900 Subject: [PATCH 06/29] refactor: notify system input producers after durable commit --- crates/worker/src/feature/builtin/memory.rs | 56 ++++++++-- crates/worker/src/ipc/interceptor.rs | 112 +++++++++++++++++--- crates/worker/src/worker.rs | 94 ++++++++++++++-- 3 files changed, 225 insertions(+), 37 deletions(-) diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index c4973cea5..32155ea04 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -691,6 +691,13 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { } impl WorkspaceSubjektivResidentSummarySource { + fn confirm_resident_context_behavior(&self, behavior_md: &str) { + self.behavior_state + .lock() + .expect("subject behavior state poisoned") + .applied = Some(behavior_md.to_owned()); + } + async fn fetch( &self, ) -> Result { @@ -788,11 +795,14 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource })) } - fn confirm_resident_context_behavior(&self, behavior_md: &str) { - self.behavior_state - .lock() - .expect("subject behavior state poisoned") - .applied = Some(behavior_md.to_owned()); + fn system_item_committed( + &self, + item: &session_store::SystemItem, + _metadata: &crate::session_history::SessionHistoryMetadata, + ) { + if let session_store::SystemItem::SubjectBehaviorRefresh { behavior_md, .. } = item { + self.confirm_resident_context_behavior(behavior_md); + } } fn invalidate_resident_context_representation(&self) { @@ -1741,7 +1751,14 @@ permission = "write" .expect("changed behavior must produce one refresh"); assert_eq!(refresh.behavior_md, "Ask when uncertain."); assert!(refresh.body.contains("Ask when uncertain.")); - source.confirm_resident_context_behavior(&refresh.behavior_md); + source.system_item_committed( + &session_store::SystemItem::SubjectBehaviorRefresh { + body: refresh.body.clone(), + behavior_md: refresh.behavior_md.clone(), + prompt_provenance: None, + }, + &session_store::LoggedSessionHistoryMetadata::legacy_unknown(), + ); assert!( source .load_changed_resident_context() @@ -1758,7 +1775,14 @@ permission = "write" .expect("cleared behavior must produce one refresh"); assert_eq!(cleared.behavior_md, ""); assert!(cleared.body.contains("No user-managed behavior is set")); - source.confirm_resident_context_behavior(&cleared.behavior_md); + source.system_item_committed( + &session_store::SystemItem::SubjectBehaviorRefresh { + body: cleared.body.clone(), + behavior_md: cleared.behavior_md.clone(), + prompt_provenance: None, + }, + &session_store::LoggedSessionHistoryMetadata::legacy_unknown(), + ); assert!( source .load_changed_resident_context() @@ -1816,7 +1840,14 @@ permission = "write" .await .unwrap() .unwrap(); - source.confirm_resident_context_behavior(&first.behavior_md); + source.system_item_committed( + &session_store::SystemItem::SubjectBehaviorRefresh { + body: first.body.clone(), + behavior_md: first.behavior_md.clone(), + prompt_provenance: None, + }, + &session_store::LoggedSessionHistoryMetadata::legacy_unknown(), + ); assert_eq!( source .load_changed_resident_context() @@ -1825,7 +1856,14 @@ permission = "write" .unwrap(), second ); - source.confirm_resident_context_behavior(&second.behavior_md); + source.system_item_committed( + &session_store::SystemItem::SubjectBehaviorRefresh { + body: second.body.clone(), + behavior_md: second.behavior_md.clone(), + prompt_provenance: None, + }, + &session_store::LoggedSessionHistoryMetadata::legacy_unknown(), + ); assert!( source .load_changed_resident_context() diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index be4f18dda..cbb500796 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -216,15 +216,6 @@ impl WorkerInterceptor { self } - fn confirm_resident_context_item(&self, item: &SystemItem) { - let Some(source) = self.resident_context_source.as_ref() else { - return; - }; - if let SystemItem::SubjectBehaviorRefresh { behavior_md, .. } = item { - source.confirm_resident_context_behavior(behavior_md); - } - } - /// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem` /// entry through the attached writer (no-op when no writer is /// wired). Sync — writes complete before the matching @@ -243,16 +234,20 @@ impl WorkerInterceptor { return Ok(()); }; for (item, extensions, history_provenance) in items { - let entry = writer.commit_system_item_with_extensions( + writer.commit_system_item_with_extensions_then( item.clone(), extensions.clone(), history_provenance.clone(), + &mut |entry| { + self.pending_committed_history + .lock() + .expect("pending committed history poisoned") + .push_back(entry.clone()); + if let Some(source) = self.resident_context_source.as_ref() { + source.system_item_committed(item, &entry.annotation); + } + }, )?; - self.pending_committed_history - .lock() - .expect("pending committed history poisoned") - .push_back(entry); - self.confirm_resident_context_item(item); } Ok(()) } @@ -1003,8 +998,14 @@ mod tests { SystemPromptContribution::Unavailable } - fn confirm_resident_context_behavior(&self, behavior_md: &str) { - self.confirmed.lock().unwrap().push(behavior_md.to_owned()); + fn system_item_committed( + &self, + item: &SystemItem, + _metadata: &crate::session_history::SessionHistoryMetadata, + ) { + if let SystemItem::SubjectBehaviorRefresh { behavior_md, .. } = item { + self.confirmed.lock().unwrap().push(behavior_md.to_owned()); + } } } @@ -1556,6 +1557,83 @@ mod tests { )); } + #[test] + fn system_input_completion_reports_only_durable_batch_prefix() { + struct PrefixCommitter(Arc>>); + impl SystemItemCommitter for PrefixCommitter { + fn commit_log_entry( + &self, + entry: session_store::LogEntry, + ) -> Result<(), session_store::StoreError> { + let mut committed = self.0.lock().unwrap(); + if !committed.is_empty() { + return Err(std::io::Error::other("injected second append failure").into()); + } + committed.push(entry); + Ok(()) + } + } + struct Observer { + saved: Arc>>, + receipts: Arc>>, + } + #[async_trait] + impl SystemPromptContributionSource for Observer { + async fn load(&self) -> SystemPromptContribution { + SystemPromptContribution::Unavailable + } + fn system_item_committed( + &self, + item: &SystemItem, + metadata: &crate::session_history::SessionHistoryMetadata, + ) { + let saved = self.saved.lock().unwrap(); + let Some(session_store::LogEntry::AnnotatedSystemItem { entry, .. }) = saved.last() + else { + panic!("completion must follow persistence"); + }; + assert_eq!(&entry.metadata, metadata); + assert_eq!(entry.item.history_text(), item.history_text()); + self.receipts.lock().unwrap().push(metadata.clone()); + } + } + let saved = Arc::new(Mutex::new(Vec::new())); + let receipts = Arc::new(Mutex::new(Vec::new())); + let pending = Arc::new(Mutex::new(Vec::new())); + let interceptor = WorkerInterceptor::new( + Arc::new(HookRegistryBuilder::new().build()), + None, + None, + NotifyBuffer::new(), + pending.clone(), + test_prompts(), + Some(Arc::new(PrefixCommitter(saved.clone()))), + ) + .with_resident_context_source(Some(Arc::new(Observer { + saved: saved.clone(), + receipts: receipts.clone(), + }))); + let items = [ + SystemItem::Notification { + message: "first".into(), + body: "first saved input".into(), + prompt_provenance: None, + }, + SystemItem::FileAttachment { + path: "test.rs".into(), + body: "second unsaved input".into(), + }, + ]; + assert!(interceptor.commit_system_items(&items).is_err()); + assert_eq!(saved.lock().unwrap().len(), 1); + let receipts = receipts.lock().unwrap(); + assert_eq!(receipts.len(), 1); + let pending = interceptor.pending_committed_history.lock().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].annotation, receipts[0]); + assert_eq!(pending[0].item.as_text(), Some("first saved input")); + } + #[test] fn behavior_ack_uses_raw_content_including_clear_not_rendered_context() { let committed = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 6af8d337b..a60803948 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -2733,22 +2733,40 @@ pub trait SystemItemCommitter: Send + Sync { item: SystemItem, extensions: Vec, history_provenance: Option, + ) -> Result, StoreError> { + self.commit_system_item_with_extensions_then( + item, + extensions, + history_provenance, + &mut |_| {}, + ) + } + + /// Notify the producer inside the publication barrier, after the durable + /// append and before a rewrite can invalidate its representation. + fn commit_system_item_with_extensions_then( + &self, + item: SystemItem, + extensions: Vec, + history_provenance: Option, + after_commit: &mut dyn FnMut(&HistoryEntry), ) -> Result, StoreError> { let metadata = new_history_metadata( history_provenance .unwrap_or(WorkerHistoryProvenance::BackendInstruction { operation_id: None }), None, ); - let history_item = item.to_history_item(); - self.commit_log_entry(LogEntry::AnnotatedSystemItem { - ts: segment_log::now_millis(), - entry: session_store::LoggedSystemHistoryEntry { - item, - metadata: metadata.clone(), + let history = HistoryEntry::new(item.to_history_item(), metadata.clone()); + self.commit_usage_boundary( + LogEntry::AnnotatedSystemItem { + ts: segment_log::now_millis(), + entry: session_store::LoggedSystemHistoryEntry { item, metadata }, + extensions, }, - extensions, - })?; - Ok(HistoryEntry::new(history_item, metadata)) + &mut || after_commit(&history), + &[], + )?; + Ok(history) } } @@ -2937,8 +2955,11 @@ pub(crate) trait SystemPromptContributionSource: Send + Sync { Ok(None) } - /// Acknowledges the exact behavior represented by a durably committed refresh. - fn confirm_resident_context_behavior(&self, _behavior_md: &str) {} + /// Reports a live durable system-input commit to its producer. Interpretation + /// belongs to the Feature; restoration never invokes this notification. + /// Runs under the publication barrier: only bounded local state updates are + /// allowed, without Host I/O or reentry into history mutation. + fn system_item_committed(&self, _item: &SystemItem, _metadata: &SessionHistoryMetadata) {} /// Invalidates the in-memory representation fence before history may be /// compacted or rewound. @@ -17436,6 +17457,57 @@ permission = "write" } } + #[test] + fn system_input_completion_observes_durable_identity_under_publication_barrier() { + let temp = tempfile::tempdir().unwrap(); + let store = session_store::FsStore::new(temp.path()).unwrap(); + let session_id = SessionId::now_v7(); + let segment_id = SegmentId::now_v7(); + let start = LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id, + system_prompt: None, + config: agen::llm_client::RequestConfig::default(), + history: vec![], + forked_from: None, + compacted_from: None, + }; + store + .create_segment(session_id, segment_id, std::slice::from_ref(&start)) + .unwrap(); + let writer = LogWriterHandle { + store: store.clone(), + state: SegmentState::new(session_id, segment_id, 1), + sink: SegmentLogSink::with_initial(vec![start]), + in_flight: None, + }; + let mut receipt = None; + let committed = writer + .commit_system_item_with_extensions_then( + SystemItem::Notification { + message: "message".into(), + body: "exact committed system body".into(), + prompt_provenance: None, + }, + vec![], + None, + &mut |history| { + assert!(writer.state.append_lock.try_lock().is_err()); + let durable = store.read_all(session_id, segment_id).unwrap(); + let Some(LogEntry::AnnotatedSystemItem { entry, .. }) = durable.last() else { + panic!("completion must follow actual storage append"); + }; + assert_eq!(entry.metadata, history.annotation); + assert_eq!(entry.item.to_history_item(), history.item); + assert_eq!(history.item.as_text(), Some("exact committed system body")); + receipt = Some(history.annotation.entry_id.clone()); + }, + ) + .unwrap(); + assert_eq!(receipt, Some(committed.annotation.entry_id)); + assert!(writer.state.append_lock.try_lock().is_ok()); + } + fn minimal_manifest() -> WorkerManifest { let toml_str = r#" [worker] From cd0eb5eb004f7c5ae77504d0eb20d797553b5a62 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 02:02:28 +0900 Subject: [PATCH 07/29] fix: allowlist public system input projections --- crates/protocol/src/lib.rs | 10 +-- crates/session-store/src/lib.rs | 3 +- crates/session-store/src/public_snapshot.rs | 16 ++-- crates/session-store/src/system_item.rs | 76 +++++++++++++++++++ crates/tui/src/app.rs | 54 ++++++++----- crates/worker/src/ipc/protocol_session.rs | 71 ++++++++++++++++- crates/worker/tests/controller_test.rs | 2 +- .../src/lib/generated/protocol-validator.ts | 2 +- .../src/lib/workspace/console/model.test.ts | 1 + 9 files changed, 194 insertions(+), 41 deletions(-) diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index f7baece98..94f04a182 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -1180,12 +1180,10 @@ pub enum Event { }, /// One agent-injected system item committed to history. /// - /// Carries the JSON form of `session_store::SystemItem`. Covers - /// `Method::Notify` echoes, child-Worker lifecycle events from - /// `Method::WorkerEvent`, `@` resolution payloads, and any future - /// Clients dispatch on the `kind` tag for typed rendering instead - /// of parsing free-text prefixes like `[Notification] …` or - /// `[File: …]`. + /// Carries the allowlisted JSON form of `session_store::PublicSystemItem`, + /// not the storage record. Its descriptive `kind` is independent from the + /// typed `presentation` used for notification, reminder, or context rendering. + /// Private Feature payloads and prompt provenance are never event data. /// /// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and /// wire-side are 1:1. diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index a164159d9..ef8d8111c 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -98,7 +98,8 @@ pub use segment::{ pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state}; pub use store::{Store, StoreError}; pub use system_item::{ - PromptRenderProvenance, SystemItem, SystemReminder, SystemReminderSource, render_worker_event, + PromptRenderProvenance, PublicSystemItem, PublicSystemPresentation, SystemItem, SystemReminder, + SystemReminderSource, render_worker_event, }; pub use uploaded_file::{ DEFAULT_MAX_FILES_PER_SUBMISSION, DEFAULT_MAX_SESSION_ARTIFACT_BYTES, diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index 824d7dffd..6a1d94b0e 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -1992,16 +1992,10 @@ fn system_entry( provenance: SessionEntryProvenance, derived_from: Vec, ) -> SessionSnapshotEntry { - let mut data = serde_json::to_value(item).ok(); - if let Some(serde_json::Value::Object(object)) = data.as_mut() { - object.remove("prompt_provenance"); - } - let item_kind = data - .as_ref() - .and_then(|value| value.get("kind")) - .and_then(serde_json::Value::as_str) - .unwrap_or("system_item") - .to_owned(); + let public = item.public_item(); + let data = Some(serde_json::to_value(&public).expect("public system input is serializable")); + let item_kind = public.kind; + let content = public.body; SessionSnapshotEntry { entry_id, timestamp, @@ -2009,7 +2003,7 @@ fn system_entry( derived_from, data: SessionSnapshotEntryData::SystemItem { item_kind, - content: item.history_text(), + content, data, }, } diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index 54b815bda..be201e5cc 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -197,7 +197,58 @@ pub enum SystemItem { }, } +/// Allowlisted presentation data shared by live events and retained snapshots. +/// Private Feature payloads and render provenance never cross this boundary. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicSystemItem { + pub kind: String, + pub body: String, + pub presentation: PublicSystemPresentation, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PublicSystemPresentation { + Notification { message: String }, + WorkerEvent { event: WorkerEvent }, + Context, + TaskReminder, + Ignored, +} + impl SystemItem { + pub fn public_item(&self) -> PublicSystemItem { + let presentation = match self { + Self::Notification { message, .. } => PublicSystemPresentation::Notification { + message: message.clone(), + }, + Self::WorkerEvent { event, .. } => PublicSystemPresentation::WorkerEvent { + event: event.clone(), + }, + Self::TaskReminder { .. } => PublicSystemPresentation::TaskReminder, + Self::LegacyKnowledgeIgnored { .. } | Self::LegacyIgnored { .. } => { + PublicSystemPresentation::Ignored + } + Self::JournalStartup { .. } + | Self::ResidentSummaryRefresh { .. } + | Self::SubjectBehaviorRefresh { .. } + | Self::SkillActivation { .. } + | Self::FeatureInvocationResult { .. } + | Self::FileAttachment { .. } + | Self::Interrupt { .. } => PublicSystemPresentation::Context, + }; + PublicSystemItem { + kind: self.kind_label().to_owned(), + body: if matches!(presentation, PublicSystemPresentation::Ignored) { + String::new() + } else { + self.history_text() + }, + presentation, + } + } + /// Free-text body the LLM sees inside its `role:system` message /// for this item. Returns the variant's stored `body` verbatim. pub fn history_text(&self) -> String { @@ -311,6 +362,31 @@ mod tests { ); } + #[test] + fn public_system_input_decoder_rejects_private_fields_and_unknown_presentations() { + let valid = serde_json::json!({ + "kind": "new-feature-label", + "body": "explicit public content", + "presentation": { "kind": "context" }, + }); + let public: PublicSystemItem = serde_json::from_value(valid.clone()).unwrap(); + assert_eq!(public.body, "explicit public content"); + for private_field in [ + "payload", + "prompt_provenance", + "policy", + "snapshot", + "behavior_md", + ] { + let mut malformed = valid.clone(); + malformed[private_field] = serde_json::json!({ "private": true }); + assert!(serde_json::from_value::(malformed).is_err()); + } + let mut unknown = valid; + unknown["presentation"] = serde_json::json!({ "kind": "execute_feature_payload" }); + assert!(serde_json::from_value::(unknown).is_err()); + } + #[test] fn frozen_subject_behavior_log_reads_old_counter_without_republishing_it() { let old = r#"{"kind":"subject_behavior_refresh","body":"exact archived body","behavior_revision":7}"#; diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index a24aaf339..306368070 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -3013,7 +3013,8 @@ impl App { /// `[File: …]` parsing path: each kind maps directly to a typed /// block (`Block::Notify`, `Block::WorkerEvent`, …). fn apply_system_item(&mut self, value: &serde_json::Value) { - let Ok(item) = serde_json::from_value::(value.clone()) else { + let Ok(item) = serde_json::from_value::(value.clone()) + else { // Unknown / forward-compat shape: fall back to rendering the // raw text payload (if any) as a generic system message. if let Some(text) = value.get("body").and_then(|b| b.as_str()) { @@ -3024,29 +3025,23 @@ impl App { } return; }; - match item { - session_store::SystemItem::Notification { message, .. } => { + let body = item.body; + match item.presentation { + session_store::PublicSystemPresentation::Notification { message } => { self.blocks.push(Block::Notify { message }); } - session_store::SystemItem::WorkerEvent { event, .. } => { + session_store::PublicSystemPresentation::WorkerEvent { event } => { self.blocks.push(Block::WorkerEvent { event }); } - session_store::SystemItem::FeatureInvocationResult { body, .. } - | session_store::SystemItem::FileAttachment { body, .. } - | session_store::SystemItem::SkillActivation { body, .. } - | session_store::SystemItem::JournalStartup { body, .. } - | session_store::SystemItem::ResidentSummaryRefresh { body, .. } - | session_store::SystemItem::SubjectBehaviorRefresh { body, .. } - | session_store::SystemItem::Interrupt { body, .. } => { + session_store::PublicSystemPresentation::Context => { self.task_store.apply_system_message_text(&body); self.blocks.push(Block::SystemMessage { text: body }); } - session_store::SystemItem::TaskReminder { body, .. } => { + session_store::PublicSystemPresentation::TaskReminder => { self.task_store.apply_system_message_text(&body); self.blocks.push(Block::TaskReminder { text: body }); } - session_store::SystemItem::LegacyIgnored { .. } => {} - session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {} + session_store::PublicSystemPresentation::Ignored => {} } } @@ -4450,12 +4445,12 @@ mod completion_flow_tests { } #[test] - fn live_legacy_workflow_system_item_is_ignored() { + fn retired_system_input_is_ignored() { let mut app = App::new("test".into()); let item = serde_json::json!({ - "kind": "workflow", - "slug": "build", - "body": "[Workflow /build]\nRun the build", + "kind": "retired_input", + "body": "", + "presentation": { "kind": "ignored" }, }); app.handle_worker_event(Event::SystemItem { entry_id: None, @@ -4861,12 +4856,30 @@ mod completion_flow_tests { ); } + #[test] + fn feature_label_does_not_select_system_input_presentation() { + for label in ["journal_startup", "new-unregistered-feature"] { + let mut app = App::new("test".into()); + app.handle_worker_event(Event::SystemItem { + entry_id: None, + item: serde_json::json!({ + "kind": label, + "body": "explicit public content", + "presentation": { "kind": "context" }, + }), + }); + assert!( + matches!(app.blocks.as_slice(), [Block::SystemMessage { text }] if text == "explicit public content") + ); + } + } + #[test] fn live_system_item_notification_appends_notify_block() { let mut app = App::new("test".into()); let item = serde_json::json!({ "kind": "notification", - "message": "hi", + "presentation": { "kind": "notification", "message": "hi" }, "body": "[Notification] hi", }); app.handle_worker_event(Event::SystemItem { @@ -4884,7 +4897,7 @@ mod completion_flow_tests { let mut app = App::new("test".into()); let item = serde_json::json!({ "kind": "worker_event", - "event": { "kind": "turn_ended", "worker_name": "child" }, + "presentation": { "kind": "worker_event", "event": { "kind": "turn_ended", "worker_name": "child" } }, "body": "[Notification] worker `child` finished a turn", }); app.handle_worker_event(Event::SystemItem { @@ -5285,6 +5298,7 @@ mod completion_flow_tests { item: serde_json::json!({ "kind": "task_reminder", "body": snapshot, + "presentation": { "kind": "task_reminder" }, }), }); diff --git a/crates/worker/src/ipc/protocol_session.rs b/crates/worker/src/ipc/protocol_session.rs index 6735afdcf..759c9c411 100644 --- a/crates/worker/src/ipc/protocol_session.rs +++ b/crates/worker/src/ipc/protocol_session.rs @@ -90,7 +90,8 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option { } LogEntry::AnnotatedSystemItem { entry, .. } => { let entry_id = Some(entry.metadata.entry_id.0.clone()); - let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize"); + let value = serde_json::to_value(entry.item.public_item()) + .expect("public system input is serializable"); Some(Event::SystemItem { entry_id, item: value, @@ -370,6 +371,74 @@ mod tests { } } + #[test] + fn live_and_snapshot_system_projection_exclude_private_feature_fields() { + let provenance = session_store::PromptRenderProvenance { + workspace_id: Some("private-workspace".into()), + source_digest: "private-source".into(), + projection_digest: "private-projection".into(), + logical_name: "private-template".into(), + }; + let items = [ + session_store::SystemItem::SubjectBehaviorRefresh { + body: "displayed context".into(), + behavior_md: "private-raw-behavior".into(), + prompt_provenance: Some(provenance.clone()), + }, + session_store::SystemItem::JournalStartup { + body: "displayed reference".into(), + snapshot: serde_json::json!({ "internal": "private-snapshot" }), + snapshot_sha256: "private-snapshot-digest".into(), + prompt_provenance: provenance, + }, + ]; + for item in items { + let entry = LogEntry::AnnotatedSystemItem { + ts: 7, + entry: session_store::LoggedSystemHistoryEntry { + item, + metadata: session_store::LoggedSessionHistoryMetadata::legacy_unknown(), + }, + extensions: vec![], + }; + let persisted: LogEntry = + serde_json::from_slice(&serde_json::to_vec(&entry).unwrap()).unwrap(); + let snapshot = session_store::public_snapshot::project_current_session_snapshot(&[ + persisted.clone(), + ]); + let Event::SystemItem { + entry_id, + item: live, + } = live_log_entry_event(persisted).unwrap() + else { + panic!("expected live system input"); + }; + let snapshot_entry = &snapshot.entries[0]; + assert_eq!(entry_id.as_deref(), Some(snapshot_entry.entry_id.as_str())); + let protocol::SessionSnapshotEntryData::SystemItem { + data: Some(saved), + content, + .. + } = &snapshot_entry.data + else { + panic!("expected canonical public system input"); + }; + assert_eq!(&live, saved); + let public: session_store::PublicSystemItem = + serde_json::from_value(live.clone()).unwrap(); + assert_eq!(&public.body, content); + assert!(matches!( + public.presentation, + session_store::PublicSystemPresentation::Context + )); + let encoded = serde_json::to_string(&snapshot).unwrap(); + assert!(!encoded.contains("private-")); + assert!(live.get("prompt_provenance").is_none()); + assert!(live.get("snapshot").is_none()); + assert!(live.get("behavior_md").is_none()); + } + } + #[test] fn user_input_log_entry_maps_to_user_message_event() { let segments = vec![protocol::Segment::text("hello from log")]; diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 1938f0b18..43c1fdfb6 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -3087,7 +3087,7 @@ async fn socket_worker_event_turn_ended_while_idle_auto_starts_turn() { Ok(Some(Event::SystemItem { ref item, .. })) if item.get("kind").and_then(|k| k.as_str()) == Some("worker_event") && item - .pointer("/event/worker_name") + .pointer("/presentation/event/worker_name") .and_then(|v| v.as_str()) == Some("child") => { saw_worker_event_echo = true; diff --git a/web/workspace/src/lib/generated/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts index b75fa7738..2a2da1ea5 100644 --- a/web/workspace/src/lib/generated/protocol-validator.ts +++ b/web/workspace/src/lib/generated/protocol-validator.ts @@ -21,7 +21,7 @@ const MAX_CORRELATION_ID_BYTES = 128; const MAX_RESOURCE_ID_BYTES = 256; const MAX_WORKER_IDS_PER_SELECTOR = 256; const MAX_REJECTION_MESSAGE_BYTES = 1024; -const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"id":{"description":"Stable only within the emitting live Worker; not durable alert history.","type":["string","null"]},"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"position":{"anyOf":[{"$ref":"#/$defs/AlertPosition"},{"type":"null"}]},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertPosition":{"description":"Display anchor captured at emission, resolved when pending text is committed.","oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"start","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"entry_id":{"type":"string"},"kind":{"const":"after_entry","type":"string"}},"required":["kind","entry_id"],"type":"object"},{"additionalProperties":false,"properties":{"call_id":{"type":"string"},"kind":{"const":"after_tool_call","type":"string"}},"required":["kind","call_id"],"type":"object"},{"additionalProperties":false,"properties":{"block_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"after_in_flight","type":"string"}},"required":["kind","block_id"],"type":"object"}]},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionContext":{"additionalProperties":false,"properties":{"argument":{"type":["string","null"]},"invocation":{"$ref":"#/$defs/FeatureInvocationIdentity"}},"required":["invocation"],"type":"object"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.","properties":{"description":{"type":["string","null"]},"invocation":{"anyOf":[{"$ref":"#/$defs/FeatureInvocationDescriptor"},{"type":"null"}]},"is_dir":{"default":false,"type":"boolean"},"usage":{"type":["string","null"]},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\n`File` is the existing `@` path lane. `Feature` lists installed chat\ninvocations. `FeatureArgument` resolves a declared argument using the\nsource-qualified [`CompletionContext`].","enum":["file","feature","feature_argument"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Complete FIFO replacement following enqueue, activation, cancel, or clear.\nClients replace their pending view in stream order; reconnect uses the snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Synthetic compatibility events omit it.","type":["string","null"]},"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"},"timestamp_ms":{"description":"Timestamp of the persisted Invoke, not the client's attach time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"timestamp_ms":{"description":"Present for usage delivered from the committed session-log lane.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Authoritative current-context occupancy after a non-request boundary.\n\nUnlike [`Event::Usage`], this is not cumulative request traffic. It lets\nconnected clients converge with reconnect snapshots after run completion,\ncompaction, rewind, and other history rewrites. `None` explicitly means\nthe current value is unavailable and stale occupancy must be cleared.","properties":{"data":{"additionalProperties":false,"properties":{"usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}]}},"type":"object"},"event":{"const":"context_usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity. Apply in arrival order on the parent's serialized stream; a\nreconnect snapshot replaces the projection before live events resume.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"default":"","type":"string"},"request_id":{"type":["string","null"]}},"required":["kind","entries","prefix"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"FeatureInvocation":{"additionalProperties":false,"properties":{"arguments":{"items":{"$ref":"#/$defs/InvocationArgumentValue"},"type":"array"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"invocation_id":{"description":"Stable client-generated id retained across queueing and transport retry.","type":"string"},"name":{"description":"Selected public name, retained for exact draft/history restoration. Host\nresolution is always by `identity`.","type":"string"}},"required":["invocation_id","identity","name","arguments"],"type":"object"},"FeatureInvocationDescriptor":{"additionalProperties":false,"properties":{"aliases":{"default":[],"items":{"type":"string"},"type":"array"},"arguments":{"default":[],"items":{"$ref":"#/$defs/InvocationArgumentDescriptor"},"type":"array"},"client_adapter":{"anyOf":[{"$ref":"#/$defs/InvocationClientAdapter"},{"type":"null"}]},"description":{"type":"string"},"display_name":{"type":"string"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"name":{"type":"string"},"syntax":{"$ref":"#/$defs/FeatureInvocationSyntax"}},"required":["identity","name","display_name","description","syntax","aliases","arguments"],"type":"object"},"FeatureInvocationIdentity":{"description":"Source-qualified identity of one invocation contribution, for example\n`builtin:attachments/attach`. Display names and aliases are never authority.","type":"string"},"FeatureInvocationSyntax":{"oneOf":[{"const":"parenthesized","description":"`/name(value, key=value)` with JSON-style quoted strings and `)` as the\nexplicit boundary before following natural language.","type":"string"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"block_ids":{"description":"Live-Worker identities parallel to the blocks actually included below.","items":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"type":"array"},"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The parent serializes\nsnapshot publication and subsequent live events on the same ordered stream.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","session","status"],"type":"object"},"InvocationArgumentDescriptor":{"additionalProperties":false,"properties":{"completion":{"$ref":"#/$defs/InvocationCompletion","default":{"kind":"none"}},"description":{"type":["string","null"]},"name":{"type":"string"},"position":{"description":"Zero-based positional index. `None` means named-only. A positional\nargument may also be supplied by name; this makes chip editing stable.","format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"required":{"default":false,"type":"boolean"},"value_type":{"$ref":"#/$defs/InvocationArgumentType"}},"required":["name","value_type","completion","required"],"type":"object"},"InvocationArgumentType":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"enum","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"description":"A Worker-readable path. This does not grant filesystem authority.","properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"A resource selected and staged by the client. It must never be resolved\nas a path on the Worker host.","properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"}]},"InvocationArgumentValue":{"additionalProperties":false,"properties":{"name":{"type":"string"},"value":{"$ref":"#/$defs/InvocationValue"}},"required":["name","value"],"type":"object"},"InvocationClientAdapter":{"oneOf":[{"const":"attachment","description":"The client stages a local file and replaces the draft invocation with an\n`UploadedFile` chip. Local paths are not sent to or persisted by Worker.","type":"string"}]},"InvocationCompletion":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"none","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"static","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"Host-owned provider selected by source-qualified id. Metadata lookup is\nside-effect free; implementations never cross this wire boundary.","properties":{"kind":{"const":"provider","type":"string"},"provider":{"type":"string"}},"required":["kind","provider"],"type":"object"}]},"InvocationValue":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"},"value":{"type":"string"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"},"value":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"},"value":{"type":"boolean"}},"required":["kind","value"],"type":"object"}]},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"submission_id":{"type":"string"}},"required":["submission_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove exactly the observed ordered set of submissions, preserving the\nactive run. Reject if the FIFO content changed before admission.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_submission_ids":{"items":{"type":"string"},"type":"array"}},"required":["expected_submission_ids"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"}},"required":["expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Rewind to just before a user input identified by its public Session entry ID.\nValidate the target against the current segment at execution time.\nLater appended history is discarded too; tool side effects are not undone.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"entry_id":{"type":"string"}},"required":["entry_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"},"request_id":{"description":"Client query identity. Echoed verbatim so identical-prefix ABA\nresponses cannot cross edits, target switches, or permission changes.","type":["string","null"]}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Session-owned FIFO content projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Explicitly selected, source-qualified Feature invocation. The host never\npromotes free text to this variant and validates it against the installed\ndeclaration before execution.","properties":{"invocation":{"$ref":"#/$defs/FeatureInvocation"},"kind":{"const":"feature_invoke","type":"string"}},"required":["kind","invocation"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Snapshot-only replay of persisted invocation/usage records. These are\naccounting metadata, never model context or conversation rows.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"invoke","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/InvokeKind"}},"required":["kind","trigger","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"usage","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","input_tokens","cache_read_input_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Display-only accounting reconstructed from persisted run/usage records.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_stats","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"requests":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"upload_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","elapsed_ms","requests","upload_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"attachment_id":{"description":"Stable, bounded identity derived from the owning Session entry, attachment\nposition, and immutable image bytes. It is a locator, not a bearer secret.","type":"string"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"type":"string"}},"required":["attachment_id","media_type","byte_len"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"restore_observation_token":{"description":"Opaque Runtime-owned Restore observation identity, never a display-state hash.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nCommands operate on the controller state at admission time, not a caller's\npreviously observed Run. A controller accepts command ids in strictly\nincreasing order; retrying an accepted id cannot apply it to a later Run.\nExact retries must retain the same command kind. Removed preconditions are\nrejected at the wire boundary rather than treated as arrival-time commands.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"last_finished_submission_request_id":{"description":"Most recent Submit request whose controller Run has returned and whose\npost-Run state transition has been published. This is a teardown fence,\nnot a successful-result claim; cancelled and failed Runs may also finish.","type":["string","null"]},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; +const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"id":{"description":"Stable only within the emitting live Worker; not durable alert history.","type":["string","null"]},"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"position":{"anyOf":[{"$ref":"#/$defs/AlertPosition"},{"type":"null"}]},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertPosition":{"description":"Display anchor captured at emission, resolved when pending text is committed.","oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"start","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"entry_id":{"type":"string"},"kind":{"const":"after_entry","type":"string"}},"required":["kind","entry_id"],"type":"object"},{"additionalProperties":false,"properties":{"call_id":{"type":"string"},"kind":{"const":"after_tool_call","type":"string"}},"required":["kind","call_id"],"type":"object"},{"additionalProperties":false,"properties":{"block_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"after_in_flight","type":"string"}},"required":["kind","block_id"],"type":"object"}]},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionContext":{"additionalProperties":false,"properties":{"argument":{"type":["string","null"]},"invocation":{"$ref":"#/$defs/FeatureInvocationIdentity"}},"required":["invocation"],"type":"object"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.","properties":{"description":{"type":["string","null"]},"invocation":{"anyOf":[{"$ref":"#/$defs/FeatureInvocationDescriptor"},{"type":"null"}]},"is_dir":{"default":false,"type":"boolean"},"usage":{"type":["string","null"]},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\n`File` is the existing `@` path lane. `Feature` lists installed chat\ninvocations. `FeatureArgument` resolves a declared argument using the\nsource-qualified [`CompletionContext`].","enum":["file","feature","feature_argument"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Complete FIFO replacement following enqueue, activation, cancel, or clear.\nClients replace their pending view in stream order; reconnect uses the snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the allowlisted JSON form of `session_store::PublicSystemItem`,\nnot the storage record. Its descriptive `kind` is independent from the\ntyped `presentation` used for notification, reminder, or context rendering.\nPrivate Feature payloads and prompt provenance are never event data.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Synthetic compatibility events omit it.","type":["string","null"]},"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"},"timestamp_ms":{"description":"Timestamp of the persisted Invoke, not the client's attach time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"timestamp_ms":{"description":"Present for usage delivered from the committed session-log lane.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Authoritative current-context occupancy after a non-request boundary.\n\nUnlike [`Event::Usage`], this is not cumulative request traffic. It lets\nconnected clients converge with reconnect snapshots after run completion,\ncompaction, rewind, and other history rewrites. `None` explicitly means\nthe current value is unavailable and stale occupancy must be cleared.","properties":{"data":{"additionalProperties":false,"properties":{"usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}]}},"type":"object"},"event":{"const":"context_usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity. Apply in arrival order on the parent's serialized stream; a\nreconnect snapshot replaces the projection before live events resume.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"default":"","type":"string"},"request_id":{"type":["string","null"]}},"required":["kind","entries","prefix"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"FeatureInvocation":{"additionalProperties":false,"properties":{"arguments":{"items":{"$ref":"#/$defs/InvocationArgumentValue"},"type":"array"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"invocation_id":{"description":"Stable client-generated id retained across queueing and transport retry.","type":"string"},"name":{"description":"Selected public name, retained for exact draft/history restoration. Host\nresolution is always by `identity`.","type":"string"}},"required":["invocation_id","identity","name","arguments"],"type":"object"},"FeatureInvocationDescriptor":{"additionalProperties":false,"properties":{"aliases":{"default":[],"items":{"type":"string"},"type":"array"},"arguments":{"default":[],"items":{"$ref":"#/$defs/InvocationArgumentDescriptor"},"type":"array"},"client_adapter":{"anyOf":[{"$ref":"#/$defs/InvocationClientAdapter"},{"type":"null"}]},"description":{"type":"string"},"display_name":{"type":"string"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"name":{"type":"string"},"syntax":{"$ref":"#/$defs/FeatureInvocationSyntax"}},"required":["identity","name","display_name","description","syntax","aliases","arguments"],"type":"object"},"FeatureInvocationIdentity":{"description":"Source-qualified identity of one invocation contribution, for example\n`builtin:attachments/attach`. Display names and aliases are never authority.","type":"string"},"FeatureInvocationSyntax":{"oneOf":[{"const":"parenthesized","description":"`/name(value, key=value)` with JSON-style quoted strings and `)` as the\nexplicit boundary before following natural language.","type":"string"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"block_ids":{"description":"Live-Worker identities parallel to the blocks actually included below.","items":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"type":"array"},"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The parent serializes\nsnapshot publication and subsequent live events on the same ordered stream.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","session","status"],"type":"object"},"InvocationArgumentDescriptor":{"additionalProperties":false,"properties":{"completion":{"$ref":"#/$defs/InvocationCompletion","default":{"kind":"none"}},"description":{"type":["string","null"]},"name":{"type":"string"},"position":{"description":"Zero-based positional index. `None` means named-only. A positional\nargument may also be supplied by name; this makes chip editing stable.","format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"required":{"default":false,"type":"boolean"},"value_type":{"$ref":"#/$defs/InvocationArgumentType"}},"required":["name","value_type","completion","required"],"type":"object"},"InvocationArgumentType":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"enum","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"description":"A Worker-readable path. This does not grant filesystem authority.","properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"A resource selected and staged by the client. It must never be resolved\nas a path on the Worker host.","properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"}]},"InvocationArgumentValue":{"additionalProperties":false,"properties":{"name":{"type":"string"},"value":{"$ref":"#/$defs/InvocationValue"}},"required":["name","value"],"type":"object"},"InvocationClientAdapter":{"oneOf":[{"const":"attachment","description":"The client stages a local file and replaces the draft invocation with an\n`UploadedFile` chip. Local paths are not sent to or persisted by Worker.","type":"string"}]},"InvocationCompletion":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"none","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"static","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"Host-owned provider selected by source-qualified id. Metadata lookup is\nside-effect free; implementations never cross this wire boundary.","properties":{"kind":{"const":"provider","type":"string"},"provider":{"type":"string"}},"required":["kind","provider"],"type":"object"}]},"InvocationValue":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"},"value":{"type":"string"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"},"value":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"},"value":{"type":"boolean"}},"required":["kind","value"],"type":"object"}]},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"submission_id":{"type":"string"}},"required":["submission_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove exactly the observed ordered set of submissions, preserving the\nactive run. Reject if the FIFO content changed before admission.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_submission_ids":{"items":{"type":"string"},"type":"array"}},"required":["expected_submission_ids"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"}},"required":["expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Rewind to just before a user input identified by its public Session entry ID.\nValidate the target against the current segment at execution time.\nLater appended history is discarded too; tool side effects are not undone.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"entry_id":{"type":"string"}},"required":["entry_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"},"request_id":{"description":"Client query identity. Echoed verbatim so identical-prefix ABA\nresponses cannot cross edits, target switches, or permission changes.","type":["string","null"]}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Session-owned FIFO content projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Explicitly selected, source-qualified Feature invocation. The host never\npromotes free text to this variant and validates it against the installed\ndeclaration before execution.","properties":{"invocation":{"$ref":"#/$defs/FeatureInvocation"},"kind":{"const":"feature_invoke","type":"string"}},"required":["kind","invocation"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Snapshot-only replay of persisted invocation/usage records. These are\naccounting metadata, never model context or conversation rows.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"invoke","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/InvokeKind"}},"required":["kind","trigger","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"usage","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","input_tokens","cache_read_input_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Display-only accounting reconstructed from persisted run/usage records.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_stats","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"requests":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"upload_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","elapsed_ms","requests","upload_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"attachment_id":{"description":"Stable, bounded identity derived from the owning Session entry, attachment\nposition, and immutable image bytes. It is a locator, not a bearer secret.","type":"string"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"type":"string"}},"required":["attachment_id","media_type","byte_len"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"restore_observation_token":{"description":"Opaque Runtime-owned Restore observation identity, never a display-state hash.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nCommands operate on the controller state at admission time, not a caller's\npreviously observed Run. A controller accepts command ids in strictly\nincreasing order; retrying an accepted id cannot apply it to a later Run.\nExact retries must retain the same command kind. Removed preconditions are\nrejected at the wire boundary rather than treated as arrival-time commands.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"last_finished_submission_request_id":{"description":"Most recent Submit request whose controller Run has returned and whose\npost-Run state transition has been published. This is a teardown fence,\nnot a successful-result claim; cancelled and failed Runs may also finish.","type":["string","null"]},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = [{"protocol_version":2,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot":{"topic":"workers","data":{"workers":[{"restore_observation_token":"fixture-restore-observation","worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"notification_count":0,"head_id":null,"submissions":[]}}}}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"snapshot","data":{"session":{"pending_submissions":{"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"state":{"last_command_id":0,"state":{"kind":"idle"}},"internal_workers":[{"worker":{"session_id":"fixture-child-session","name":"fixture-child","parent_session_id":"fixture-parent-session","kind":"sub_worker"},"session":{"pending_submissions":{"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"status":"idle"}]}}}}}}}]; const utf8 = new TextEncoder(); diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index ad4ec795d..11cfe60ad 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -3943,6 +3943,7 @@ Deno.test("live system items reconcile with committed history by entry identity" const item = { kind: "resident_summary_refresh", body: "Resident memory refresh", + presentation: { kind: "context" }, }; const event: Event = { event: "system_item", From 887184796e4e5ba02ce1b7e3f889a3bffcb863e5 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 02:05:02 +0900 Subject: [PATCH 08/29] docs: distinguish commit completion from Feature authority --- crates/worker/src/worker.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index a60803948..fdde548d2 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -2742,8 +2742,9 @@ pub trait SystemItemCommitter: Send + Sync { ) } - /// Notify the producer inside the publication barrier, after the durable - /// append and before a rewrite can invalidate its representation. + /// Notify after durable append through the writer's publication boundary. + /// Completion is a storage fact, not a grant of continuing Feature authority; + /// producers must separately fence their representation lifetime. fn commit_system_item_with_extensions_then( &self, item: SystemItem, From 19514a5d6a2777ed0bad64443ed057b73f31a2e5 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 02:14:52 +0900 Subject: [PATCH 09/29] feat: define bounded Feature-owned system input envelopes --- crates/protocol/src/lib.rs | 2 +- crates/session-store/src/lib.rs | 5 + crates/session-store/src/system_input.rs | 530 +++++++++++++++++++++++ crates/session-store/src/system_item.rs | 4 +- 4 files changed, 538 insertions(+), 3 deletions(-) create mode 100644 crates/session-store/src/system_input.rs diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 94f04a182..8055193d5 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -408,7 +408,7 @@ pub enum Method { /// Transport is fire-and-forget; receivers must tolerate out-of-order /// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same /// child Worker). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index ef8d8111c..7750f87e0 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -51,6 +51,7 @@ pub mod retention; pub mod segment; pub mod segment_log; pub mod store; +pub mod system_input; pub mod system_item; pub mod uploaded_file; pub mod worker_metadata; @@ -97,6 +98,10 @@ pub use segment::{ }; pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state}; pub use store::{Store, StoreError}; +pub use system_input::{ + FeatureInputPayload, InputCompactionUse, InputEvidenceUse, InputObservationUse, + SystemInputEnvelope, SystemInputError, SystemInputMetadata, SystemInputUsePolicy, +}; pub use system_item::{ PromptRenderProvenance, PublicSystemItem, PublicSystemPresentation, SystemItem, SystemReminder, SystemReminderSource, render_worker_event, diff --git a/crates/session-store/src/system_input.rs b/crates/session-store/src/system_input.rs new file mode 100644 index 000000000..fc9613941 --- /dev/null +++ b/crates/session-store/src/system_input.rs @@ -0,0 +1,530 @@ +//! Validated, Feature-neutral system-input data. +//! +//! The enclosing history annotation owns identity, origin and derivation. None of +//! those descriptive facts selects a use policy here. Model text, independent use +//! policies, private Feature data and an explicit public projection travel as one +//! value. Restoring this value does not load a Feature or invoke domain behavior. +//! +//! Payload validation bounds the decoded value; enclosing log readers must also +//! enforce their record allocation limit before decoding untrusted persisted input. + +use agen::Item; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::PublicSystemItem; + +pub const MAX_FEATURE_PAYLOAD_BYTES: usize = 128 * 1024; +pub const MAX_SYSTEM_INPUT_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_SYSTEM_PUBLIC_BYTES: usize = 256 * 1024; +const MAX_SCHEMA_BYTES: usize = 128; +const MAX_JSON_DEPTH: usize = 16; +const MAX_JSON_NODES: usize = 8192; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum SystemInputError { + #[error("system input payload schema is invalid")] + InvalidSchema, + #[error("system input payload version must be nonzero")] + InvalidVersion, + #[error("system input resource limit exceeded")] + ResourceLimit, + #[error("system input payload could not be serialized")] + Serialization, + #[error("system input payload schema or version is unsupported by this consumer")] + UnsupportedPayload, + #[error("system input payload does not match the owner's typed contract")] + InvalidTypedPayload, +} + +/// One private payload owned by a Feature's versioned typed decoder. +/// Unknown valid schemas/versions remain data, never execution instructions. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "PayloadWire")] +pub struct FeatureInputPayload { + schema: String, + version: u32, + value: Value, +} + +impl std::fmt::Debug for FeatureInputPayload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FeatureInputPayload") + .field("schema", &self.schema) + .field("version", &self.version) + .finish_non_exhaustive() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PayloadWire { + schema: String, + version: u32, + value: Value, +} + +impl TryFrom for FeatureInputPayload { + type Error = SystemInputError; + + fn try_from(wire: PayloadWire) -> Result { + if wire.schema.is_empty() + || wire.schema.len() > MAX_SCHEMA_BYTES + || !wire.schema.as_bytes()[0].is_ascii_lowercase() + || !wire.schema.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"-_.:/".contains(&byte) + }) + { + return Err(SystemInputError::InvalidSchema); + } + if wire.version == 0 { + return Err(SystemInputError::InvalidVersion); + } + validate_json(&wire.value, MAX_FEATURE_PAYLOAD_BYTES)?; + Ok(Self { + schema: wire.schema, + version: wire.version, + value: wire.value, + }) + } +} + +impl FeatureInputPayload { + pub fn encode( + schema: impl Into, + version: u32, + value: &T, + ) -> Result { + PayloadWire { + schema: schema.into(), + version, + value: serde_json::to_value(value).map_err(|_| SystemInputError::Serialization)?, + } + .try_into() + } + + pub fn schema(&self) -> &str { + &self.schema + } + + pub fn version(&self) -> u32 { + self.version + } + + /// The owner explicitly selects its contract before decoding. A valid unknown + /// payload can be retained and round-tripped without calling this operation. + pub fn decode( + &self, + schema: &str, + version: u32, + ) -> Result { + if self.schema != schema || self.version != version { + return Err(SystemInputError::UnsupportedPayload); + } + serde_json::from_value(self.value.clone()) + .map_err(|_| SystemInputError::InvalidTypedPayload) + } +} + +/// Eligibility is supplied independently for recording and Memory evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InputEvidenceUse { + Include, + Exclude, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InputObservationUse { + Readable, + Hidden, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InputCompactionUse { + /// Ordinary history eligible for the selected retained tail. + Retain, + /// A full opening summary consumed separately and superseded by compaction. + OpeningSummary, + /// Full historical context for the compactor, not an accumulating retained tail. + ReferenceInput, +} + +/// No permissive defaults: malformed/unknown policies cannot turn into eligibility. +/// Public presentation is deliberately not one of these use decisions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SystemInputUsePolicy { + pub recording_material: InputEvidenceUse, + pub memory_evidence: InputEvidenceUse, + pub observation: InputObservationUse, + pub compaction: InputCompactionUse, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(try_from = "MetadataWire")] +pub struct SystemInputMetadata { + usage: SystemInputUsePolicy, + feature_payload: Option, + public: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MetadataWire { + usage: SystemInputUsePolicy, + feature_payload: Option, + public: Option, +} + +impl TryFrom for SystemInputMetadata { + type Error = SystemInputError; + + fn try_from(wire: MetadataWire) -> Result { + if let Some(public) = &wire.public { + // Kind is a descriptive label, not a Feature dispatch or authority key. + if public.kind.is_empty() + || public.kind.len() > MAX_SCHEMA_BYTES + || public.kind.chars().any(char::is_control) + { + return Err(SystemInputError::InvalidSchema); + } + let value = + serde_json::to_value(public).map_err(|_| SystemInputError::Serialization)?; + validate_json(&value, MAX_SYSTEM_PUBLIC_BYTES)?; + } + Ok(Self { + usage: wire.usage, + feature_payload: wire.feature_payload, + public: wire.public, + }) + } +} + +impl SystemInputMetadata { + pub fn new( + usage: SystemInputUsePolicy, + feature_payload: Option, + public: Option, + ) -> Result { + MetadataWire { + usage, + feature_payload, + public, + } + .try_into() + } + + pub fn usage(&self) -> SystemInputUsePolicy { + self.usage + } + + pub fn feature_payload(&self) -> Option<&FeatureInputPayload> { + self.feature_payload.as_ref() + } + + pub fn public_item(&self) -> Option<&PublicSystemItem> { + self.public.as_ref() + } +} + +/// System role is invariant; provenance and use policies are attached data. +/// An absent public projection never implicitly publishes the model body. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(try_from = "InputWire")] +pub struct SystemInputEnvelope { + body: String, + metadata: SystemInputMetadata, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct InputWire { + body: String, + metadata: SystemInputMetadata, +} + +impl TryFrom for SystemInputEnvelope { + type Error = SystemInputError; + + fn try_from(wire: InputWire) -> Result { + if wire.body.len() > MAX_SYSTEM_INPUT_BYTES { + return Err(SystemInputError::ResourceLimit); + } + Ok(Self { + body: wire.body, + metadata: wire.metadata, + }) + } +} + +impl SystemInputEnvelope { + pub fn new( + body: impl Into, + metadata: SystemInputMetadata, + ) -> Result { + InputWire { + body: body.into(), + metadata, + } + .try_into() + } + + pub fn body(&self) -> &str { + &self.body + } + + pub fn metadata(&self) -> &SystemInputMetadata { + &self.metadata + } + + pub fn to_history_item(&self) -> Item { + Item::system_message(self.body.clone()) + } + + pub fn into_parts(self) -> (String, SystemInputMetadata) { + (self.body, self.metadata) + } +} + +fn validate_json(value: &Value, max_bytes: usize) -> Result<(), SystemInputError> { + fn walk(value: &Value, depth: usize, remaining: &mut usize) -> Result<(), SystemInputError> { + if depth > MAX_JSON_DEPTH || *remaining == 0 { + return Err(SystemInputError::ResourceLimit); + } + *remaining -= 1; + match value { + Value::Array(values) => { + for value in values { + walk(value, depth + 1, remaining)?; + } + } + Value::Object(values) => { + for value in values.values() { + walk(value, depth + 1, remaining)?; + } + } + _ => {} + } + Ok(()) + } + let mut remaining = MAX_JSON_NODES; + walk(value, 0, &mut remaining)?; + let bytes = serde_json::to_vec(value).map_err(|_| SystemInputError::Serialization)?; + if bytes.len() > max_bytes { + return Err(SystemInputError::ResourceLimit); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{LoggedItem, LoggedRole, PublicSystemPresentation}; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + struct OwnerPayload { + source_digest: String, + omitted: bool, + } + + fn reference_policy() -> SystemInputUsePolicy { + SystemInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::ReferenceInput, + } + } + + #[test] + fn unknown_valid_payload_round_trips_without_domain_decode_or_public_exposure() { + let payload = FeatureInputPayload::encode( + "future-owner:context", + 99, + &OwnerPayload { + source_digest: "private-digest".into(), + omitted: true, + }, + ) + .unwrap(); + let envelope = SystemInputEnvelope::new( + "private model context", + SystemInputMetadata::new(reference_policy(), Some(payload), None).unwrap(), + ) + .unwrap(); + let restored: SystemInputEnvelope = + serde_json::from_slice(&serde_json::to_vec(&envelope).unwrap()).unwrap(); + assert_eq!(restored.body(), "private model context"); + assert!(restored.metadata().public_item().is_none()); + let saved_payload = restored.metadata().feature_payload().unwrap(); + assert_eq!(saved_payload.schema(), "future-owner:context"); + assert_eq!(saved_payload.version(), 99); + assert_eq!( + saved_payload.decode::("known-owner:context", 1), + Err(SystemInputError::UnsupportedPayload) + ); + assert_eq!( + serde_json::to_value(&restored).unwrap(), + serde_json::to_value(&envelope).unwrap() + ); + assert!(!format!("{saved_payload:?}").contains("private-digest")); + } + + #[test] + fn owner_decodes_typed_payload_only_for_its_explicit_contract() { + let input = OwnerPayload { + source_digest: "rev".into(), + omitted: false, + }; + let payload = FeatureInputPayload::encode("owner:context", 1, &input).unwrap(); + assert_eq!( + payload.decode::("owner:context", 1).unwrap(), + input + ); + let invalid = FeatureInputPayload::encode( + "owner:context", + 1, + &serde_json::json!({ "omitted": "not a bool" }), + ) + .unwrap(); + assert_eq!( + invalid.decode::("owner:context", 1), + Err(SystemInputError::InvalidTypedPayload) + ); + } + + #[test] + fn malformed_or_oversized_payload_is_rejected_at_construction_and_restore() { + for (schema, version, value) in [ + ("", 1, Value::Null), + ("invalid schema", 1, Value::Null), + ("owner:context", 0, Value::Null), + ( + "owner:context", + 1, + Value::String("x".repeat(MAX_FEATURE_PAYLOAD_BYTES)), + ), + ( + "owner:context", + 1, + Value::Array(vec![Value::Null; MAX_JSON_NODES]), + ), + ( + "owner:context", + 1, + (0..=MAX_JSON_DEPTH).fold(Value::Null, |inner, _| Value::Array(vec![inner])), + ), + ] { + assert!(FeatureInputPayload::encode(schema, version, &value).is_err()); + assert!( + serde_json::from_value::( + serde_json::json!({ "schema": schema, "version": version, "value": value }) + ) + .is_err() + ); + } + } + + #[test] + fn policy_is_required_and_unknown_or_malformed_fields_fail_closed() { + let valid = serde_json::to_value(reference_policy()).unwrap(); + for field in [ + "recording_material", + "memory_evidence", + "observation", + "compaction", + ] { + let mut absent = valid.clone(); + absent.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(absent).is_err()); + let mut unknown = valid.clone(); + unknown[field] = Value::String("future-allow".into()); + assert!(serde_json::from_value::(unknown).is_err()); + } + let mut extra = valid; + extra["feature_name"] = Value::String("infer-policy-from-me".into()); + assert!(serde_json::from_value::(extra).is_err()); + } + + #[test] + fn feature_name_and_model_body_do_not_select_policies_or_public_projection() { + for (schema, body) in [ + ("owner:journal", "past context"), + ("unrelated:skill", "do not extract this; legitimate wording"), + ] { + let public = PublicSystemItem { + kind: "context-label".into(), + body: "explicit public text".into(), + presentation: PublicSystemPresentation::Context, + }; + let metadata = SystemInputMetadata::new( + reference_policy(), + Some( + FeatureInputPayload::encode( + schema, + 1, + &OwnerPayload { + source_digest: "private".into(), + omitted: false, + }, + ) + .unwrap(), + ), + Some(public), + ) + .unwrap(); + let input = SystemInputEnvelope::new(body, metadata).unwrap(); + assert_eq!(input.metadata().usage(), reference_policy()); + let history: LoggedItem = input.to_history_item().into(); + assert!(matches!( + history, + LoggedItem::Message { + role: LoggedRole::System, + .. + } + )); + assert_eq!(input.to_history_item().as_text(), Some(body)); + let public = serde_json::to_string(input.metadata().public_item().unwrap()).unwrap(); + assert!(!public.contains("private")); + assert!(!public.contains(schema)); + assert!(!public.contains(body)); + } + } + + #[test] + fn use_policies_remain_independent_of_each_other_and_of_public_display() { + let usage = SystemInputUsePolicy { + recording_material: InputEvidenceUse::Include, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Readable, + compaction: InputCompactionUse::Retain, + }; + let metadata = SystemInputMetadata::new(usage, None, None).unwrap(); + let restored: SystemInputMetadata = + serde_json::from_value(serde_json::to_value(&metadata).unwrap()).unwrap(); + assert_eq!(restored.usage(), usage); + assert!(restored.public_item().is_none()); + assert!(restored.feature_payload().is_none()); + } + + #[test] + fn model_body_and_public_projection_have_independent_resource_limits() { + let metadata = SystemInputMetadata::new(reference_policy(), None, None).unwrap(); + assert!( + SystemInputEnvelope::new("x".repeat(MAX_SYSTEM_INPUT_BYTES + 1), metadata).is_err() + ); + let public = PublicSystemItem { + kind: "context".into(), + body: "x".repeat(MAX_SYSTEM_PUBLIC_BYTES), + presentation: PublicSystemPresentation::Context, + }; + assert!(SystemInputMetadata::new(reference_policy(), None, Some(public)).is_err()); + let invalid = serde_json::json!({ "body": "small", "metadata": { "public": null, "feature_payload": null } }); + assert!(serde_json::from_value::(invalid).is_err()); + } +} diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index be201e5cc..376a3cd8f 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -199,7 +199,7 @@ pub enum SystemItem { /// Allowlisted presentation data shared by live events and retained snapshots. /// Private Feature payloads and render provenance never cross this boundary. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct PublicSystemItem { pub kind: String, @@ -207,7 +207,7 @@ pub struct PublicSystemItem { pub presentation: PublicSystemPresentation, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum PublicSystemPresentation { Notification { message: String }, From 32a409c15d0178a72d86d06963c7534c27b3553c Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 02:18:49 +0900 Subject: [PATCH 10/29] feat: share virtual file contracts and checked binary byte ranges --- crates/server-api/src/drive.rs | 10 + crates/server-api/src/lib.rs | 6 + crates/tools/src/checkout.rs | 35 + crates/workdir/src/checkout.rs | 7 +- crates/workdir/src/external.rs | 12 + crates/workdir/src/http.rs | 27 +- crates/workdir/src/local.rs | 16 +- crates/workdir/src/scope.rs | 3 + crates/worker/src/checkout.rs | 48 +- .../src/feature/builtin/drive/backend.rs | 3 + .../worker/src/feature/builtin/drive/tests.rs | 676 +++++++++++++++++- .../worker/src/feature/builtin/drive/wip.rs | 212 +++++- .../builtin/workspace_config/content.rs | 106 ++- .../workspace_config/production_tests.rs | 211 +++++- .../feature/builtin/workspace_config/wip.rs | 1 + crates/worker/src/file_operation.rs | 96 ++- crates/worker/src/wip.rs | 20 + crates/workspace-server/src/server.rs | 8 + crates/workspace-server/src/server_drive.rs | 22 + openapi/server-api.json | 122 +++- web/workspace/src/lib/generated/drive-api.ts | 2 + .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/workdir-api.ts | 2 +- 23 files changed, 1593 insertions(+), 54 deletions(-) diff --git a/crates/server-api/src/drive.rs b/crates/server-api/src/drive.rs index 7ec7a729a..3167faa98 100644 --- a/crates/server-api/src/drive.rs +++ b/crates/server-api/src/drive.rs @@ -134,6 +134,14 @@ pub enum DriveEntryKind { File, } +/// Current caller's advisory publication ceiling, never a grant or execution credential. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveCapabilities { + pub write: bool, +} + /// Metadata only: content is returned through bounded text or binary routes. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -491,6 +499,7 @@ impl api_macros::HttpRequestError for DriveApiError { impl api_macros::openapi::OpenApiSchema for DriveApiError {} impl api_macros::openapi::OpenApiSchema for DriveEntry {} +impl api_macros::openapi::OpenApiSchema for DriveCapabilities {} impl api_macros::openapi::OpenApiSchema for DriveEntryQuery {} impl api_macros::openapi::OpenApiSchema for DriveListQuery {} impl api_macros::openapi::OpenApiSchema for DriveSearchQuery {} @@ -518,6 +527,7 @@ pub fn drive_api_typescript() -> String { DriveEntryRef::decl(&config), DriveEntryKind::decl(&config), DriveEntry::decl(&config), + DriveCapabilities::decl(&config), DriveEntryQuery::decl(&config), DriveListQuery::decl(&config), DriveSearchQuery::decl(&config), diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 1e30192d7..bed58d4ef 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -2762,6 +2762,12 @@ pub trait ServerApi { #[extension] context: ServerRequestContext, #[path] workspace_id: String, ) -> Result; + #[get("/api/w/{workspace_id}/drive/capabilities", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] + async fn drive_capabilities( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; #[get("/api/w/{workspace_id}/drive/metadata", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] async fn drive_metadata( &self, diff --git a/crates/tools/src/checkout.rs b/crates/tools/src/checkout.rs index 88fe32df9..622dd1b3d 100644 --- a/crates/tools/src/checkout.rs +++ b/crates/tools/src/checkout.rs @@ -436,3 +436,38 @@ pub async fn read_checkout_text( } Ok((result, validator)) } + +/// Binary-safe checked byte window. This does not establish a text prior-Read observation. +#[allow(clippy::too_many_arguments)] +pub async fn read_checkout_bytes( + router: Arc, + alias: &str, + generation: u64, + path: WorkdirPath, + validator: Vec, + offset: u64, + max_bytes: usize, +) -> Result<(workdir::ReadBytesResult, Vec), ToolError> { + let selected = + crate::routing::resolve_session(&router, Some(alias), WorkdirSessionCapability::Read)?; + if selected.generation != generation { + return Err(stale_checkout("checkout connection changed")); + } + let result = selected + .session + .checkout_execute(workdir::CheckoutRequest { + target: path.clone(), + validator, + operation: workdir::CheckoutOperation::ReadBytes { offset, max_bytes }, + }) + .await + .map_err(crate::file_target::checked_error)?; + match result.output { + workdir::CheckoutOutput::ReadBytes(read) + if read.path == path && read.offset == offset && read.bytes.len() <= max_bytes => + { + Ok((read, result.observation.validator)) + } + _ => Err(crate::file_target::readonly_result_error("ReadBytes")), + } +} diff --git a/crates/workdir/src/checkout.rs b/crates/workdir/src/checkout.rs index f61c29210..2b7c243fd 100644 --- a/crates/workdir/src/checkout.rs +++ b/crates/workdir/src/checkout.rs @@ -48,6 +48,10 @@ pub struct CheckoutRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] pub enum CheckoutOperation { + ReadBytes { + offset: u64, + max_bytes: usize, + }, Read { offset: usize, limit: usize, @@ -71,7 +75,7 @@ pub enum CheckoutOperation { impl CheckoutOperation { pub fn capability(&self) -> WorkdirSessionCapability { match self { - Self::Read { .. } => WorkdirSessionCapability::Read, + Self::Read { .. } | Self::ReadBytes { .. } => WorkdirSessionCapability::Read, Self::Edit { .. } => WorkdirSessionCapability::Edit, Self::Write { .. } | Self::Create { .. } => WorkdirSessionCapability::Write, } @@ -86,6 +90,7 @@ impl CheckoutOperation { )] pub enum CheckoutOutput { Read(ReadResult), + ReadBytes(crate::ReadBytesResult), Write(WriteResult), Edit(EditResult), } diff --git a/crates/workdir/src/external.rs b/crates/workdir/src/external.rs index 203701bc0..ce708ddf5 100644 --- a/crates/workdir/src/external.rs +++ b/crates/workdir/src/external.rs @@ -185,6 +185,11 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<() && !request.validator.is_empty() && request.validator.len() <= 256 && match &request.operation { + crate::CheckoutOperation::ReadBytes { offset, max_bytes } => { + *offset <= 9_007_199_254_740_991 + && *max_bytes > 0 + && *max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + } crate::CheckoutOperation::Read { limit, max_bytes, .. } => { @@ -375,6 +380,13 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<() WorkdirSessionOperationResult::CheckoutExecute(result) => { valid_checkout_observation(&result.observation) && match &result.output { + crate::CheckoutOutput::ReadBytes(read) => { + read.path == result.observation.path + && validate_external_result(&WorkdirSessionOperationResult::ReadBytes( + read.clone(), + )) + .is_ok() + } crate::CheckoutOutput::Read(read) => { read.path == result.observation.path && validate_external_result(&WorkdirSessionOperationResult::Read( diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index f9ff246db..8457ff7e0 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -693,6 +693,12 @@ mod client { let mutation = request.operation.capability() != crate::WorkdirSessionCapability::Read; let expected_path = request.target.clone(); let expected_output = request.operation.capability(); + let byte_range = match &request.operation { + crate::CheckoutOperation::ReadBytes { offset, max_bytes } => { + Some((*offset, *max_bytes)) + } + _ => None, + }; match self .operate(WorkdirSessionOperation::CheckoutExecute(request)) .await @@ -702,11 +708,30 @@ mod client { && !result.observation.validator.is_empty() && result.observation.validator.len() <= 256 && match (&result.output, expected_output) { + ( + crate::CheckoutOutput::ReadBytes(read), + crate::WorkdirSessionCapability::Read, + ) => byte_range.is_some_and(|(offset, max)| { + read.path == expected_path + && read.offset == offset + && read.bytes.len() <= max + && read + .offset + .checked_add(read.bytes.len() as u64) + .is_some_and(|end| { + end <= read.total_bytes + || (read.bytes.is_empty() && read.eof) + }) + && read.eof + == (read.offset.saturating_add(read.bytes.len() as u64) + >= read.total_bytes) + }), ( crate::CheckoutOutput::Read(read), crate::WorkdirSessionCapability::Read, ) => { - read.path == expected_path + byte_range.is_none() + && read.path == expected_path && read.bytes.len() <= crate::BoundedReadLimits::EXTERNAL_DEFAULT .max_response_bytes diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index f750bf0ee..813056001 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -1587,6 +1587,20 @@ impl WorkdirSession for LocalWorkdirSession { } let is_create = matches!(&request.operation, Op::Create { .. }); let output = match request.operation { + Op::ReadBytes { offset, max_bytes } => fs_operation::run_read_bytes_bounded( + &self.inner.root, + ReadBytesRequest { + path: path.clone(), + offset, + max_bytes, + expected_hash: None, + }, + &pinned, + self.inner + .read_limits + .unwrap_or(BoundedReadLimits::EXTERNAL_DEFAULT), + ) + .map(Output::ReadBytes), Op::Read { offset, limit, @@ -1679,7 +1693,7 @@ impl WorkdirSession for LocalWorkdirSession { checkout_execute_error(&path, WorkdirError::from(error)) } })?; - if matches!(&output, Output::Read(_)) { + if matches!(&output, Output::Read(_) | Output::ReadBytes(_)) { pinned.confirm().map_err(|e| checkout_io(&path, e))?; return Ok(crate::CheckoutResult { observation: self diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index 6fd71cfd3..a47d5b600 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -1222,6 +1222,9 @@ impl WorkdirSession for ScopedWorkdirSession { .intersection(WorkdirSessionCapabilities::READ_ONLY); } if let crate::CheckoutOutput::Read(read) = &mut result.output { + read.path = result_path.clone(); + } + if let crate::CheckoutOutput::ReadBytes(read) = &mut result.output { read.path = result_path; } Ok(result) diff --git a/crates/worker/src/checkout.rs b/crates/worker/src/checkout.rs index 4071d38e6..c84afae30 100644 --- a/crates/worker/src/checkout.rs +++ b/crates/worker/src/checkout.rs @@ -270,7 +270,7 @@ impl Provider { let mut contracts = Vec::new(); let kind = if observation.kind == EntryKind::File { if has(Cap::Read, "Read") { - contracts.extend([C::ReadLines, C::SearchText]); + contracts.extend([C::ReadLines, C::SearchText, C::ReadBytes]); } if has(Cap::Edit, "Edit") { contracts.push(C::Edit); @@ -541,6 +541,52 @@ impl WipOperationHandler for FileHandler { arguments: &BTreeMap, context: WipCallContext, ) -> Result { + if operation == "read_bytes" { + let (offset, limit) = crate::file_operation::byte_range(arguments)?; + if !self.provider.allowed("Read",&json!({"target_workdir":self.alias,"file_path":self.target.as_str(),"offset":offset,"limit":limit})) {return Err(protocol(ProtocolErrorCode::PermissionDenied,"byte read denied"));} + let _permit = + tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire()) + .await + .map_err(|_| { + protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "byte read capacity deadline", + ) + })? + .map_err(|_| protocol(ProtocolErrorCode::Internal, "byte read unavailable"))?; + let (read, raw) = tokio::time::timeout( + self.provider.deadline, + tools::checkout::read_checkout_bytes( + self.provider.router.clone(), + &self.alias, + self.generation, + self.target.clone(), + self.observation.validator.clone(), + offset, + limit, + ), + ) + .await + .map_err(|_| { + protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "byte read deadline", + ) + })? + .map_err(map_tool_error)?; + self.check_connection()?; + let value = crate::file_operation::bytes_result(arguments, &read.bytes, read.eof)?; + return Ok(WipOperationOutput::native_with_validator( + value, + self.provider.wrap_validator( + &self.alias, + self.generation, + &self.workdir, + &self.target, + &raw, + ), + )); + } if matches!(operation, "read_lines" | "search_text") { let (offset, limit) = crate::file_operation::window(arguments, operation == "search_text")?; diff --git a/crates/worker/src/feature/builtin/drive/backend.rs b/crates/worker/src/feature/builtin/drive/backend.rs index c4ec3e986..4c799d1ef 100644 --- a/crates/worker/src/feature/builtin/drive/backend.rs +++ b/crates/worker/src/feature/builtin/drive/backend.rs @@ -138,6 +138,9 @@ impl DriveBackend { ) .await } + pub async fn capabilities(&self) -> Result { + self.get("/capabilities", &serde_json::json!({})).await + } pub async fn root(&self) -> Result { let entry: DriveEntry = self .json( diff --git a/crates/worker/src/feature/builtin/drive/tests.rs b/crates/worker/src/feature/builtin/drive/tests.rs index dffefc3ce..10098a7f0 100644 --- a/crates/worker/src/feature/builtin/drive/tests.rs +++ b/crates/worker/src/feature/builtin/drive/tests.rs @@ -7,12 +7,14 @@ use std::collections::VecDeque; struct Client { responses: Mutex>>, requests: Mutex>, + capabilities: Mutex, } impl Client { fn new(responses: Vec) -> Arc { Arc::new(Self { responses: Mutex::new(responses.into_iter().map(Ok).collect()), requests: Mutex::new(Vec::new()), + capabilities: Mutex::new(response(DriveCapabilities { write: false })), }) } } @@ -30,7 +32,15 @@ impl WorkspaceClient for Client { &self, request: WorkspaceRequest, ) -> Result { + let capabilities = request.path == "/api/w/workspace/drive/capabilities"; self.requests.lock().unwrap().push(request); + if capabilities { + let response = self.capabilities.lock().unwrap(); + return Ok(WorkspaceResponse { + status: response.status, + body: response.body.clone(), + }); + } self.responses .lock() .unwrap() @@ -479,7 +489,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds let feature = feature(client.clone()); let mut mounts = crate::wip::WipMountRegistry::new(); wip::mount_drive_wip(&mut mounts, &feature).unwrap(); - let provider = wip::DriveProvider(feature); + let provider = wip::DriveProvider::new(feature); let path = entry_path(&entry.entry); let publication = provider.publication(&path).await.unwrap().unwrap(); assert_eq!(publication.projection.route, path); @@ -487,7 +497,8 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds publication.scope.as_ref().unwrap().r#ref, publication.projection.object.r#ref ); - assert_eq!(publication.projection.object.validator, Some(b"3".to_vec())); + assert!(publication.projection.object.validator.is_some()); + assert_ne!(publication.projection.object.validator, Some(b"3".to_vec())); assert!(provider.children("/drive").await.unwrap().is_empty()); assert!( provider @@ -504,7 +515,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds .flat_map(|op| &op.parameters) .any(|parameter| parameter.name == "expected_mutation_id") ); - assert_eq!(client.requests.lock().unwrap().len(), 1); + assert_eq!(client.requests.lock().unwrap().len(), 2); } #[derive(Debug)] @@ -656,3 +667,662 @@ async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() { ); assert_eq!(client.json.requests.lock().unwrap().len(), 1); } + +fn native_context() -> crate::wip::WipCallContext { + crate::wip::WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "drive-test".into(), + } +} +fn native_args(value: Value) -> std::collections::BTreeMap { + let wip_protocol::Value::Record(args) = crate::wip::json_to_wip(&value).unwrap() else { + panic!("named arguments required") + }; + args +} + +#[tokio::test] +async fn drive_common_text_reads_use_bounded_complete_source_and_literal_window_search() { + use crate::file_operation::Contract; + use crate::wip::WipSubtreeProvider; + let text = "α\r\nneedle.*\nNeedle.*\nlast\n"; + let mut entry = entry("2", "3"); + entry.size = Some(text.len() as u32); + let read = DriveReadTextResponse { + entry: entry.clone(), + text: text.into(), + truncated: false, + }; + let client = Client::new(vec![ + response(entry.clone()), + response(&read), + response(&read), + ]); + let feature = feature(client.clone()); + let publication = wip::DriveProvider::new(feature) + .publication(&entry_path(&entry.entry)) + .await + .unwrap() + .unwrap(); + let mut projection = publication.projection; + assert!( + projection + .object + .interfaces + .contains(&Contract::ReadLines.reference()) + ); + assert!( + projection + .object + .interfaces + .contains(&Contract::SearchText.reference()) + ); + assert!( + !projection + .object + .interfaces + .contains(&Contract::Write.reference()) + ); + assert!( + !projection + .object + .interfaces + .contains(&Contract::Edit.reference()) + ); + assert!( + projection + .descriptor + .operations + .iter() + .any(|op| op.name == "read" && op.parameters.iter().any(|p| p.name == "max_bytes")) + ); + let original_handler = projection.handler.clone(); + assert!(crate::file_operation::select( + &mut projection, + &Contract::ReadLines.reference() + )); + let read = projection + .handler + .call( + "read", + &native_args(json!({"offset":1,"limit":2})), + native_context(), + ) + .await + .unwrap_or_else(|_| panic!("common read failed")); + let value = crate::wip::wip_to_json(&read.value).unwrap(); + assert_eq!(value["next_offset"], 3); + assert_eq!(value["truncated"], true); + assert!(value["content"].as_str().unwrap().contains("needle.*")); + // The common selector wraps a provider handler once; reuse the original + // publication for the independent SearchText contract. + projection.handler = original_handler; + let request = client.requests.lock().unwrap().last().unwrap().clone(); + assert!(request.path.contains("max_bytes=65536")); + assert!(crate::file_operation::select( + &mut projection, + &Contract::SearchText.reference() + )); + let search = projection + .handler + .call( + "search", + &native_args(json!({"pattern":"needle.*","limit":3})), + native_context(), + ) + .await + .unwrap_or_else(|_| panic!("common search failed")); + let value = crate::wip::wip_to_json(&search.value).unwrap(); + assert_eq!(value["matches"], json!([{"line":2,"text":"needle.*"}])); + assert_eq!(value["next_offset"], 3); + assert_eq!(value["truncated"], true); +} + +fn read_response(entry: &DriveEntry, text: &str) -> DriveReadTextResponse { + DriveReadTextResponse { + entry: entry.clone(), + text: text.into(), + truncated: false, + } +} + +#[tokio::test] +async fn drive_common_text_refuses_truncated_mismatched_stale_and_denied_reads() { + use crate::wip::WipSubtreeProvider; + use wip_protocol::ProtocolErrorCode; + for (case, expected) in [ + ("truncated", ProtocolErrorCode::ResourceLimitExceeded), + ("size", ProtocolErrorCode::ResourceLimitExceeded), + ("stale", ProtocolErrorCode::ValidatorMismatch), + ("denied", ProtocolErrorCode::PermissionDenied), + ] { + let entry = entry("2", "3"); + let mut read = read_response(&entry, "hello"); + match case { + "truncated" => read.truncated = true, + "size" => read.text = "hell".into(), + "stale" => read.entry.last_mutation_id = "4".into(), + _ => {} + } + let read = if case == "denied" { + WorkspaceResponse { + status: 403, + body: "denied".into(), + } + } else { + response(read) + }; + let client = Client::new(vec![response(entry.clone()), read]); + let projection = wip::DriveProvider::new(feature(client.clone())) + .publication(&entry_path(&entry.entry)) + .await + .unwrap() + .unwrap() + .projection; + let result = projection + .handler + .call("read_lines", &native_args(json!({})), native_context()) + .await; + assert!( + matches!(result, Err(crate::wip::WipOperationError::Protocol(ref error)) if error.code == expected), + "{case}" + ); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 3, "{case}: no retries"); + assert!( + requests + .iter() + .all(|request| request.method == crate::worker::WorkspaceRequestMethod::Get) + ); + } +} + +#[tokio::test] +async fn drive_common_text_rejects_oversized_source_before_read_and_remount_changes_object_validator() + { + use crate::wip::WipSubtreeProvider; + let mut entry = entry("2", "3"); + entry.size = Some(server_api::DRIVE_CHUNK_MAX_BYTES + 1); + let client = Client::new(vec![response(entry.clone()), response(entry.clone())]); + let first = feature(client.clone()); + let second = first.clone(); + let path = entry_path(&entry.entry); + let first = wip::DriveProvider::new(first) + .publication(&path) + .await + .unwrap() + .unwrap() + .projection; + let second = wip::DriveProvider::new(second) + .publication(&path) + .await + .unwrap() + .unwrap() + .projection; + assert_ne!(first.object.validator, second.object.validator); + assert_eq!(first.object.interfaces, second.object.interfaces); + let result = first + .handler + .call("read_lines", &native_args(json!({})), native_context()) + .await; + assert!( + matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::ResourceLimitExceeded) + ); + assert_eq!(client.requests.lock().unwrap().len(), 4); +} + +#[tokio::test] +async fn drive_common_text_reads_keep_tool_permission_denial_before_backend_read() { + use crate::wip::WipSubtreeProvider; + let entry = entry("2", "3"); + let client = Client::new(vec![response(entry.clone())]); + let feature = feature(client.clone()).with_permissions(Some(manifest::ToolPermissionConfig { + default_action: manifest::ToolPermissionAction::Deny, + rules: Vec::new(), + })); + let projection = wip::DriveProvider::new(feature) + .publication(&entry_path(&entry.entry)) + .await + .unwrap() + .unwrap() + .projection; + let result = projection + .handler + .call("read_lines", &native_args(json!({})), native_context()) + .await; + assert!( + matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::PermissionDenied) + ); + assert_eq!(client.requests.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn drive_read_only_capability_omits_file_and_folder_mutation_contracts() { + use crate::file_operation::Contract; + use crate::wip::WipSubtreeProvider; + for (kind, root) in [ + (DriveEntryKind::File, false), + (DriveEntryKind::Folder, false), + (DriveEntryKind::Folder, true), + ] { + let mut entry = entry("2", "3"); + entry.kind = kind; + if root { + entry.parent = None; + } + let path = if root { + "/drive".into() + } else { + entry_path(&entry.entry) + }; + let client = Client::new(vec![response(entry.clone())]); + let projection = wip::DriveProvider::new(feature(client.clone())) + .publication(&path) + .await + .unwrap() + .unwrap() + .projection; + assert!( + !projection + .object + .interfaces + .contains(&Contract::Edit.reference()) + ); + assert!( + !projection + .object + .interfaces + .contains(&Contract::Write.reference()) + ); + assert!(!projection.descriptor.operations.iter().any(|op| matches!( + op.name.as_str(), + "write" + | "edit" + | "delete" + | "relocate" + | "create_folder" + | "create_text" + | "save_workdir" + ))); + let result = projection + .handler + .call( + "write", + &native_args(json!({"content":"denied"})), + native_context(), + ) + .await; + assert!( + matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::OperationNotFound) + ); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[1].path, "/api/w/workspace/drive/capabilities"); + assert_eq!( + requests[1].method, + crate::worker::WorkspaceRequestMethod::Get + ); + assert!(requests[1].body.is_none()); + } +} + +#[tokio::test] +async fn drive_capability_failure_never_publishes_an_assumed_write_ceiling() { + use crate::wip::WipSubtreeProvider; + for (status, body) in [ + (403, "denied"), + (500, "unavailable"), + (200, "{}"), + (200, "{\"write\":true,\"grant_id\":\"spoof\"}"), + ] { + let entry = entry("2", "3"); + let client = Client::new(vec![response(entry.clone())]); + *client.capabilities.lock().unwrap() = WorkspaceResponse { + status, + body: body.into(), + }; + let result = wip::DriveProvider::new(feature(client.clone())) + .publication(&entry_path(&entry.entry)) + .await; + assert!(!matches!(result, Ok(Some(_))), "{status}: {body}"); + assert_eq!(client.requests.lock().unwrap().len(), 2); + } +} + +#[tokio::test] +async fn drive_common_mutation_publication_intersects_tool_permission_ceiling() { + use crate::file_operation::Contract; + use crate::wip::WipSubtreeProvider; + let entry = entry("2", "3"); + let client = Client::new(vec![response(entry.clone())]); + *client.capabilities.lock().unwrap() = response(DriveCapabilities { write: true }); + let feature = feature(client).with_permissions(Some(manifest::ToolPermissionConfig { + default_action: manifest::ToolPermissionAction::Allow, + rules: vec![manifest::ToolPermissionRule { + tool: "DriveWrite".into(), + pattern: "*".into(), + action: manifest::ToolPermissionAction::Deny, + }], + })); + let projection = wip::DriveProvider::new(feature) + .publication(&entry_path(&entry.entry)) + .await + .unwrap() + .unwrap() + .projection; + assert!( + !projection + .object + .interfaces + .contains(&Contract::Write.reference()) + ); + assert!( + projection + .object + .interfaces + .contains(&Contract::Edit.reference()) + ); + assert!( + !projection + .descriptor + .operations + .iter() + .any(|op| matches!(op.name.as_str(), "write" | "edit")) + ); +} + +/// Narrow transport fixture for native mutation chaining. The real provider, +/// Client/Host, permission checks and Backend CAS adapter remain in the path; +/// only canonical Drive persistence is replaced by an entry and text value. +#[derive(Debug)] +struct NativeMutations(Mutex<(DriveEntry, String, Vec)>); +impl WorkspaceClient for NativeMutations { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn kind(&self) -> &str { + "drive-native-mutations" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let mut state = self.0.lock().unwrap(); + let suffix = request + .path + .strip_prefix("/api/w/workspace/drive/") + .unwrap(); + Ok(if suffix == "capabilities" { + response(DriveCapabilities { write: true }) + } else if suffix.starts_with("metadata?") { + response(&state.0) + } else if suffix.starts_with("read-text?") { + response(read_response(&state.0, &state.1)) + } else if suffix == "mutate" { + let request: DriveMutationRequest = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + let DriveMutation::UpdateText { + id, + expected_mutation_id, + text, + .. + } = &request.mutation + else { + panic!("unexpected mutation") + }; + assert_eq!(id, &state.0.entry); + assert_eq!(expected_mutation_id, &state.0.last_mutation_id); + state.1 = text.clone(); + state.0.size = Some(text.len() as u32); + state.0.last_mutation_id = request.request_id.clone(); + let output = response(DriveMutationResponse { + request_id: request.request_id.clone(), + entry: Some(state.0.clone()), + }); + state.2.push(request); + output + } else { + panic!("unexpected request: {}", request.path) + }) + } +} + +#[tokio::test] +async fn drive_common_edit_then_write_reuses_exact_post_operation_object_validator() { + use crate::file_operation::Contract; + use crate::wip::{WipMountRegistry, WipRuntime}; + let entry = entry("2", "3"); + let path = entry_path(&entry.entry); + let client = Arc::new(NativeMutations(Mutex::new(( + entry, + "hello".into(), + Vec::new(), + )))); + let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new())); + let mut mounts = WipMountRegistry::new(); + wip::mount_drive_wip(&mut mounts, &feature).unwrap(); + let runtime = WipRuntime::from_mounts(mounts, "drive-test".into()).unwrap(); + runtime + .prepare_call_observations(path.clone(), true) + .await + .unwrap(); + let edit = runtime + .call( + path.clone(), + Contract::Edit.reference(), + "edit".into(), + json!({"old_string":"hello","new_string":"updated"}), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let edit: Value = serde_json::from_str(edit.content.as_deref().unwrap()).unwrap(); + assert!(edit["metadata"]["entry"]["metadata"]["last_mutation_id"].is_string()); + assert_eq!(client.0.lock().unwrap().1, "updated"); + // No intervening Inspect/prepare_call_observations: success depends on the + // exact nonce-bound post-commit validator returned by the first operation. + let write = runtime + .call( + path, + Contract::Write.reference(), + "write".into(), + json!({"content":"final"}), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let write: Value = serde_json::from_str(write.content.as_deref().unwrap()).unwrap(); + assert!(write["metadata"]["entry"]["metadata"]["last_mutation_id"].is_string()); + let state = client.0.lock().unwrap(); + assert_eq!(state.1, "final"); + assert_eq!(state.2.len(), 2); +} + +#[tokio::test] +async fn drive_argument_dependent_write_policy_is_rechecked_and_ask_is_not_published() { + use crate::file_operation::Contract; + use crate::wip::WipSubtreeProvider; + for (default_action, pattern, action, published, code) in [ + ( + manifest::ToolPermissionAction::Deny, + "*permitted*", + manifest::ToolPermissionAction::Allow, + true, + wip_protocol::ProtocolErrorCode::PermissionDenied, + ), + ( + manifest::ToolPermissionAction::Allow, + "*", + manifest::ToolPermissionAction::Ask, + false, + wip_protocol::ProtocolErrorCode::OperationNotFound, + ), + ] { + let entry = entry("2", "3"); + let client = Client::new(vec![response(entry.clone())]); + *client.capabilities.lock().unwrap() = response(DriveCapabilities { write: true }); + let feature = + feature(client.clone()).with_permissions(Some(manifest::ToolPermissionConfig { + default_action, + rules: vec![manifest::ToolPermissionRule { + tool: "DriveWrite".into(), + pattern: pattern.into(), + action, + }], + })); + let projection = wip::DriveProvider::new(feature) + .publication(&entry_path(&entry.entry)) + .await + .unwrap() + .unwrap() + .projection; + assert_eq!( + projection + .object + .interfaces + .contains(&Contract::Write.reference()), + published + ); + let result = projection + .handler + .call( + "write", + &native_args(json!({"content":"denied"})), + native_context(), + ) + .await; + assert!( + matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == code) + ); + assert_eq!(client.requests.lock().unwrap().len(), 2); + } +} + +// A single retained Object observation models a restored Client cache. All later +// publication and execution use the real new-mount provider; the new Host supplies +// the common contract's root scope independently, without replaying its old ref. +struct RetainedObjectOnce { + current: wip::DriveProvider, + retained: Mutex>, +} +#[async_trait] +impl crate::wip::WipSubtreeProvider for RetainedObjectOnce { + async fn publication( + &self, + path: &str, + ) -> Result, wip_protocol::ProtocolError> { + let Some(mut publication) = self.current.publication(path).await? else { + return Ok(None); + }; + if let Some(retained) = self.retained.lock().unwrap().take() { + publication.projection.object = retained; + publication = crate::wip::WipPublication::self_scoped(publication.projection)?; + } + Ok(Some(publication)) + } + async fn children(&self, _: &str) -> Result, wip_protocol::ProtocolError> { + Ok(Vec::new()) + } +} + +#[tokio::test] +async fn drive_same_feature_remount_rejects_retained_object_with_fresh_common_scope_then_reobservation_succeeds() + { + use crate::file_operation::Contract; + use crate::wip::{WipMountRegistry, WipRuntime, WipSubtreeMount, WipSubtreeProvider}; + let entry = entry("2", "3"); + let path = entry_path(&entry.entry); + let client = Arc::new(NativeMutations(Mutex::new(( + entry, + "hello".into(), + Vec::new(), + )))); + let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new())); + // mount_drive_wip constructs exactly one of these providers per mount. Reuse + // the same Feature, whose mutation request identity deliberately does not rotate. + let first = wip::DriveProvider::new(feature.clone()); + let old = first.publication(&path).await.unwrap().unwrap().projection; + let cloned = first + .clone() + .publication(&path) + .await + .unwrap() + .unwrap() + .projection; + assert_eq!(old.object.validator, cloned.object.validator); + let second = wip::DriveProvider::new(feature.clone()); + let current = second.publication(&path).await.unwrap().unwrap().projection; + assert_ne!(old.object.validator, current.object.validator); + let mut old_contract = old.clone(); + let mut current_contract = current.clone(); + assert!(crate::file_operation::select( + &mut old_contract, + &Contract::ReadLines.reference() + )); + assert!(crate::file_operation::select( + &mut current_contract, + &Contract::ReadLines.reference() + )); + assert_eq!(old_contract.interface, current_contract.interface); + assert_eq!(old_contract.descriptor, current_contract.descriptor); + assert_eq!( + old_contract.interface_validator, + current_contract.interface_validator + ); + + // Mount only the selected entry for this cache-restoration fixture. The real + // Host still selects the root common contract and validates target membership, + // scope_ref and Object/Interface validators before running the Drive handler. + let mut mounts = WipMountRegistry::new(); + mounts.allocate_namespace("drive", "drive").unwrap(); + mounts.mount(current).unwrap(); + mounts + .mount_subtree(WipSubtreeMount { + root: path.clone(), + provider: Arc::new(RetainedObjectOnce { + current: second, + retained: Mutex::new(Some(old.object)), + }), + }) + .unwrap(); + let runtime = WipRuntime::from_mounts(mounts, "drive-remount".into()).unwrap(); + runtime.inspect(path.clone(), true).await.unwrap(); + let interface_path = format!( + "{}::{}", + serde_json::to_string("/").unwrap(), + serde_json::to_string(&Contract::ReadLines.reference().name).unwrap() + ); + // This fetch obtains the *new* Host's root scope_ref and the unchanged common + // Interface validator; only the retained target observation remains stale. + runtime.inspect(interface_path, true).await.unwrap(); + let error = runtime + .call( + path.clone(), + Contract::ReadLines.reference(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("ValidatorMismatch"), "{error}"); + assert!(!error.contains("InterfaceMismatch"), "{error}"); + runtime.inspect(path.clone(), true).await.unwrap(); + let result = runtime + .call( + path, + Contract::ReadLines.reference(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let result: Value = serde_json::from_str(result.content.as_deref().unwrap()).unwrap(); + assert!(result["content"].as_str().unwrap().contains("hello")); + assert!(client.0.lock().unwrap().2.is_empty()); +} diff --git a/crates/worker/src/feature/builtin/drive/wip.rs b/crates/worker/src/feature/builtin/drive/wip.rs index fd11101ab..d322439f9 100644 --- a/crates/worker/src/feature/builtin/drive/wip.rs +++ b/crates/worker/src/feature/builtin/drive/wip.rs @@ -40,7 +40,11 @@ pub fn mount_drive_wip( registry.allocate_namespace("drive", "drive")?; // Registration is only a namespace entrance. The subtree provides the // current target/scope/descriptor/handler in a single self-scoped snapshot. - let names = root_specs(); + // This unresolved entrance has no live capability evidence. + let names: Vec<_> = root_specs() + .into_iter() + .filter(|spec| !mutation(spec)) + .collect(); let descriptor = descriptor(&names, true); registry.mount(WipProjection { route: ROOT.into(), @@ -63,7 +67,7 @@ pub fn mount_drive_wip( })?; registry.mount_subtree(WipSubtreeMount { root: ROOT.into(), - provider: Arc::new(DriveProvider(feature.clone())), + provider: Arc::new(DriveProvider::new(feature.clone())), })?; registry.replace_compatibility_tools(ROOT, SPECS.iter().map(|spec| spec.name))?; Ok(()) @@ -80,7 +84,19 @@ impl WipOperationHandler for Unresolved { Err(map_error(DriveError::Denied)) } } -pub(super) struct DriveProvider(pub(super) DriveFeature); +#[derive(Clone)] +pub(super) struct DriveProvider { + feature: DriveFeature, + observation_nonce: Arc, +} +impl DriveProvider { + pub(super) fn new(feature: DriveFeature) -> Self { + Self { + feature, + observation_nonce: uuid::Uuid::now_v7().to_string().into(), + } + } +} #[async_trait] impl WipSubtreeProvider for DriveProvider { // Arbitrary tree depth is safe: there are deliberately no indexable entries. @@ -92,27 +108,50 @@ impl WipSubtreeProvider for DriveProvider { } async fn publication(&self, path: &str) -> Result, ProtocolError> { let result = if path == ROOT { - self.0.backend.root().await + self.feature.backend.root().await } else { - let Some(entry) = parse_entry(&self.0, path).map_err(protocol)? else { + let Some(entry) = parse_entry(&self.feature, path).map_err(protocol)? else { return Ok(None); }; - self.0.backend.metadata(&entry).await + self.feature.backend.metadata(&entry).await }; let entry = match result { Ok(entry) => entry, Err(DriveError::Denied | DriveError::NotFound) => return Ok(None), Err(error) => return Err(protocol(error)), }; + let capabilities = match self.feature.backend.capabilities().await { + Ok(capabilities) => capabilities, + Err(DriveError::Denied | DriveError::NotFound) => return Ok(None), + Err(error) => return Err(protocol(error)), + }; let root = path == ROOT; - let specs = if root { + let specs: Vec<_> = if root { root_specs() } else { entry_specs(&entry) - }; + } + .into_iter() + .filter(|spec| { + !mutation(spec) || (capabilities.write && publication_may_allow(&self.feature, spec)) + }) + .collect(); + let common_mutations: Vec<_> = specs + .iter() + .filter_map(|spec| match spec.operation { + "edit" => Some(crate::file_operation::Contract::Edit), + "write" => Some(crate::file_operation::Contract::Write), + _ => None, + }) + .collect(); let descriptor = descriptor(&specs, root); let reference = contextual_reference(INTERFACE, path); - let validator = entry.last_mutation_id.as_bytes().to_vec(); + let validator = object_validator( + &self.observation_nonce, + &entry, + &entry.last_mutation_id, + &specs, + ); let object = Object { name: if root { "drive".into() @@ -130,7 +169,8 @@ impl WipSubtreeProvider for DriveProvider { )), validator: Some(validator), }; - let projection = WipProjection { + let common_read = entry.kind == DriveEntryKind::File; + let mut projection = WipProjection { route: path.into(), capability: "drive:content".into(), kind: WipProjectionKind::Native, @@ -139,18 +179,80 @@ impl WipSubtreeProvider for DriveProvider { interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()), descriptor, handler: Arc::new(DriveHandler { - feature: self.0.clone(), + feature: self.feature.clone(), + observation_nonce: self.observation_nonce.clone(), entry, specs, root, }), }; + // This is publication evidence only. Backend execution rechecks current + // authority and the captured commit precondition on every mutation. + crate::file_operation::expose(&mut projection, common_mutations); + if common_read { + crate::file_operation::expose( + &mut projection, + [ + crate::file_operation::Contract::ReadLines, + crate::file_operation::Contract::SearchText, + ], + ); + } WipPublication::self_scoped(projection).map(Some) } async fn children(&self, _: &str) -> Result, ProtocolError> { Ok(Vec::new()) } } +fn object_validator( + observation_nonce: &str, + entry: &DriveEntry, + mutation: &str, + specs: &[Spec], +) -> Vec { + // Object observations belong to one mount, even when a restored Host reuses + // the same Feature instance. Feature.nonce separately identifies mutations. + // Current membership is part of Object representation, not the shared Interface lifetime. + let operations: Vec<_> = specs.iter().map(|spec| spec.operation).collect(); + digest( + format!( + "{observation_nonce:?}:{:?}:{:?}:{mutation:?}:{operations:?}", + entry.entry.workspace_id, entry.entry.node_id + ) + .as_bytes(), + ) + .into_bytes() +} + +fn mutation(spec: &Spec) -> bool { + matches!( + spec.operation, + "write" | "edit" | "delete" | "relocate" | "create_folder" | "create_text" | "save_workdir" + ) +} + +fn publication_may_allow(feature: &DriveFeature, spec: &Spec) -> bool { + let Some(permissions) = &feature.permissions else { + return true; + }; + // Like checkout publication, retain potentially allowed argument-dependent + // operations but hide an unconditional Deny/Ask ceiling. This is not an + // authorization decision: exact arguments are still checked at execution. + for rule in permissions + .rules + .iter() + .filter(|rule| rule.tool.eq_ignore_ascii_case(spec.name)) + { + if rule.action == manifest::ToolPermissionAction::Allow { + return true; + } + if rule.pattern == "*" { + return false; + } + } + permissions.default_action == manifest::ToolPermissionAction::Allow +} + fn root_specs() -> Vec { SPECS .iter() @@ -194,11 +296,10 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati } } fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor { - InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation { + InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().filter(|spec| !matches!(spec.operation,"edit"|"write")).map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation { "list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)], "search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)], "read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)], - "write"|"edit"=>crate::file_operation::parameters(spec.operation,false).expect("shared text arguments"), "create_text"=>vec![parameter("name",true,TypeExpr::String),parameter("content",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)], "create_folder"=>vec![parameter("name",true,TypeExpr::String)], "relocate"=>vec![parameter("parent",true,TypeExpr::Entry),parameter("name",true,TypeExpr::String)], @@ -209,6 +310,7 @@ fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor { } struct DriveHandler { feature: DriveFeature, + observation_nonce: Arc, entry: DriveEntry, specs: Vec, root: bool, @@ -221,21 +323,34 @@ impl WipOperationHandler for DriveHandler { arguments: &BTreeMap, context: WipCallContext, ) -> Result { + let common_text = matches!(operation, "read_lines" | "search_text"); + let backend_operation = if common_text { "read" } else { operation }; let spec = self .specs .iter() - .find(|spec| spec.operation == operation) + .find(|spec| spec.operation == backend_operation) .ok_or_else(|| { failure( ProtocolErrorCode::OperationNotFound, "Drive operation is not published", ) })?; - let declaration = descriptor(&self.specs, self.root) - .operations - .into_iter() - .find(|op| op.name == operation) - .unwrap(); + let contract = match operation { + "read_lines" => Some(crate::file_operation::Contract::ReadLines), + "search_text" => Some(crate::file_operation::Contract::SearchText), + "edit" => Some(crate::file_operation::Contract::Edit), + "write" => Some(crate::file_operation::Contract::Write), + _ => None, + }; + let declaration = if let Some(contract) = contract { + contract.descriptor().operations.into_iter().next().unwrap() + } else { + descriptor(&self.specs, self.root) + .operations + .into_iter() + .find(|op| op.name == operation) + .unwrap() + }; if arguments .keys() .any(|key| !declaration.parameters.iter().any(|p| p.name == *key)) @@ -244,8 +359,22 @@ impl WipOperationHandler for DriveHandler { "unrecognized arguments; committed-request observations are Client-managed".into(), ))); } - let mut input = wip_to_json(&Value::Record(arguments.clone())) - .map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?; + let mut input = if common_text { + // Validate the common input before performing any source read. Its + // line window is evaluated only after the complete bounded read. + crate::file_operation::text_result(operation, arguments, "")?; + if self + .entry + .size + .is_some_and(|size| size > server_api::DRIVE_CHUNK_MAX_BYTES) + { + return Err(map_error(DriveError::Limit)); + } + json!({"entry": self.entry.entry, "max_bytes": server_api::DRIVE_CHUNK_MAX_BYTES}) + } else { + wip_to_json(&Value::Record(arguments.clone())) + .map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))? + }; let fields = input .as_object_mut() .ok_or_else(|| map_error(DriveError::Invalid("named arguments required".into())))?; @@ -298,6 +427,19 @@ impl WipOperationHandler for DriveHandler { ) .await .map_err(map_error)?; + if common_text { + let content = complete_text(&output.value)?; + let value = crate::file_operation::text_result(operation, arguments, content)?; + return Ok(WipOperationOutput::native_with_validator( + value, + object_validator( + &self.observation_nonce, + &self.entry, + &self.entry.last_mutation_id, + &self.specs, + ), + )); + } let value = json_to_wip(&output.value).map_err(|_| { WipOperationError::OutcomeUnknown( "Completed Drive result unavailable; query request_status before another mutation" @@ -314,13 +456,39 @@ impl WipOperationHandler for DriveHandler { "Committed Drive request identity unavailable".into(), ) })?; - WipOperationOutput::native_with_validator(value, last_mutation_id.as_bytes().to_vec()) + WipOperationOutput::native_with_validator( + value, + object_validator( + &self.observation_nonce, + &self.entry, + last_mutation_id, + &self.specs, + ), + ) } else { WipOperationOutput::native(value) }; Ok(result.with_attachments(output.attachments)) } } +/// A bounded line/search contract cannot claim that a partial blob is the full +/// source. Retain Drive's separate whole-text read with its truncation flag. +fn complete_text(value: &serde_json::Value) -> Result<&str, WipOperationError> { + let content = value["text"] + .as_str() + .ok_or_else(|| map_error(DriveError::Unavailable))?; + let size = value["entry"]["metadata"]["size"] + .as_u64() + .ok_or_else(|| map_error(DriveError::Unavailable))?; + if value["truncated"].as_bool() != Some(false) + || size != content.len() as u64 + || content.len() > server_api::DRIVE_CHUNK_MAX_BYTES as usize + { + return Err(map_error(DriveError::Limit)); + } + Ok(content) +} + fn parse_entry(feature: &DriveFeature, path: &str) -> Result, DriveError> { let workspace = feature.backend.workspace()?; let prefix = format!( diff --git a/crates/worker/src/feature/builtin/workspace_config/content.rs b/crates/worker/src/feature/builtin/workspace_config/content.rs index 234b97eeb..b2f67d342 100644 --- a/crates/worker/src/feature/builtin/workspace_config/content.rs +++ b/crates/worker/src/feature/builtin/workspace_config/content.rs @@ -47,6 +47,12 @@ use crate::wip::{ pub(super) struct ConfigProvider { pub feature: WorkspaceConfigFeature, pub backend: Arc, + // A fresh mount after restore invalidates observations from its predecessor. + pub observation_nonce: Arc, +} + +fn object_validator(nonce: &str, connection: &str, validator: &str) -> Vec { + Sha256::digest(format!("{nonce}:{connection}:{validator}")).to_vec() } /// Backend tree root is empty; source names are canonical relative config paths. @@ -240,7 +246,7 @@ impl ConfigProvider { let descriptor = descriptor(&operations); let interface = contextual_reference("yoi.workspace-config/node/v1", path); let interface_validator = Some(Sha256::digest(format!("{descriptor:?}")).to_vec()); - Ok(Some(WipProjection { + let mut projection = WipProjection { route: path.into(), capability: "workspace-config:content".into(), kind: WipProjectionKind::Native, @@ -252,7 +258,11 @@ impl ConfigProvider { )), interfaces: vec![interface.clone()], r#ref: None, - validator: Some(node.validator.as_bytes().to_vec()), + validator: Some(object_validator( + &self.observation_nonce, + &attachment.connection_id, + &node.validator, + )), }, interface, descriptor, @@ -263,9 +273,12 @@ impl ConfigProvider { attachment, snapshot, node, - operations, + operations: operations.clone(), + observation_nonce: self.observation_nonce.clone(), }), - })) + }; + crate::file_operation::expose(&mut projection, common_contracts(&operations)); + Ok(Some(projection)) } } @@ -335,12 +348,28 @@ fn operations( names.push(name.into()); } } + if names.iter().any(|name| name == "read") { + names.extend(["read_lines".into(), "search_text".into()]); + } if root { names.push("attach".into()); } names } +fn common_contracts(names: &[String]) -> Vec { + use crate::file_operation::Contract; + [ + ("read_lines", Contract::ReadLines), + ("search_text", Contract::SearchText), + ("edit", Contract::Edit), + ("write", Contract::Write), + ] + .into_iter() + .filter_map(|(name, contract)| names.iter().any(|n| n == name).then_some(contract)) + .collect() +} + fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration { ParameterDeclaration { name: name.into(), @@ -356,11 +385,11 @@ fn descriptor(names: &[String]) -> InterfaceDescriptor { summary: "Logical Workspace configuration file operations".into(), details: Some("Backend grants, semantic validation, atomic whole-tree CAS and activation apply. Validators and change digests are managed automatically. apply_changes atomically accepts create/update/delete/rename changes without validators or expected_digest fields. Saving updates configuration for future operation boundaries, not already-running Worker manifests. No OS or command access.".into()), }), - operations: names.iter().map(|name| OperationDeclaration { + operations: names.iter().filter(|name| !matches!(name.as_str(), "read_lines" | "search_text" | "edit" | "write")).map(|name| OperationDeclaration { name: name.clone(), documentation: None, parameters: match name.as_str() { "attach" => vec![parameter("access", false, TypeExpr::String)], - "read" | "write" | "edit" => crate::file_operation::parameters(name, false).unwrap(), + "read" => crate::file_operation::parameters(name, false).unwrap(), "delete" => vec![], "create" => vec![parameter("path", false, TypeExpr::String), parameter("content", true, TypeExpr::String), parameter("content_type", false, TypeExpr::String)], "apply_changes" => vec![parameter("changes", true, TypeExpr::Json)], @@ -378,6 +407,7 @@ struct ConfigHandler { snapshot: WorkspaceConfigObserveResponse, node: WorkspaceConfigNode, operations: Vec, + observation_nonce: Arc, } #[derive(Debug, Deserialize)] @@ -476,13 +506,18 @@ impl ConfigHandler { || response.validator != self.node.validator || Some(&response.digest) != self.node.digest.as_ref() || Some(&response.content_type) != self.node.content_type.as_ref() - || response.content.len() > MAX_TEXT_BYTES { return Err(failure( ProtocolErrorCode::Internal, "Invalid Workspace config read response", )); } + if response.content.len() > MAX_TEXT_BYTES { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "Workspace config source exceeds text limit", + )); + } let mut response = response; response.content = fs_operation::text::read(response.content, text_limits()) .map_err(text_error)? @@ -628,11 +663,33 @@ impl ConfigHandler { )); } // Direct handler calls also enforce parameter shape; no model validators accepted. - let declaration = descriptor(&self.operations) - .operations - .into_iter() - .find(|op| op.name == operation) - .ok_or_else(invalid)?; + let declaration = match operation { + "read_lines" => crate::file_operation::Contract::ReadLines + .descriptor() + .operations + .into_iter() + .next(), + "search_text" => crate::file_operation::Contract::SearchText + .descriptor() + .operations + .into_iter() + .next(), + "edit" => crate::file_operation::Contract::Edit + .descriptor() + .operations + .into_iter() + .next(), + "write" => crate::file_operation::Contract::Write + .descriptor() + .operations + .into_iter() + .next(), + _ => descriptor(&self.operations) + .operations + .into_iter() + .find(|op| op.name == operation), + } + .ok_or_else(invalid)?; if arguments .keys() .any(|name| !declaration.parameters.iter().any(|p| p.name == *name)) @@ -646,6 +703,19 @@ impl ConfigHandler { .call(operation, arguments, context) .await; } + if matches!(operation, "read_lines" | "search_text") { + crate::file_operation::text_result(operation, arguments, "")?; + let result = self.read().await?; + let value = crate::file_operation::text_result(operation, arguments, &result.content)?; + return Ok(WipOperationOutput::native_with_validator( + value, + object_validator( + &self.observation_nonce, + &self.attachment.connection_id, + &result.validator, + ), + )); + } if operation == "read" { let _: fs_operation::text::ReadArgs = decode_text(arguments)?; let result = self.read().await?; @@ -657,7 +727,11 @@ impl ConfigHandler { .map_err(|_| invalid())?; return Ok(WipOperationOutput::native_with_validator( value, - self.node.validator.as_bytes().to_vec(), + object_validator( + &self.observation_nonce, + &self.attachment.connection_id, + &self.node.validator, + ), )); } if self.attachment.access != WorkspaceConfigAccess::ReadWrite { @@ -762,7 +836,11 @@ impl ConfigHandler { "activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?; Ok(WipOperationOutput::native_with_validator( value, - node.validator.as_bytes().to_vec(), + object_validator( + &self.observation_nonce, + &self.attachment.connection_id, + &node.validator, + ), )) } } diff --git a/crates/worker/src/feature/builtin/workspace_config/production_tests.rs b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs index 9c9fd9d63..71649555c 100644 --- a/crates/worker/src/feature/builtin/workspace_config/production_tests.rs +++ b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs @@ -466,8 +466,19 @@ async fn call( runtime .call( path.into(), - interface(path, true), - op.into(), + match op { + "edit" => crate::file_operation::Contract::Edit.reference(), + "write" => crate::file_operation::Contract::Write.reference(), + "read_lines" => crate::file_operation::Contract::ReadLines.reference(), + "search_text" => crate::file_operation::Contract::SearchText.reference(), + _ => interface(path, true), + }, + match op { + "read_lines" => "read", + "search_text" => "search", + _ => op, + } + .into(), args, ToolExecutionContext::direct(), ) @@ -825,11 +836,11 @@ async fn workspace_config_production_reattach_new_lifetime_rejects_old_same_byte client.state.lock().unwrap().active = None; let second = feature.attach("second", None).await.unwrap(); assert_ne!(first.connection_id, second.connection_id); - assert!( - call(&runtime, path, "write", json!({"content":"bad"})) - .await - .is_err() - ); + let error = call(&runtime, path, "write", json!({"content":"bad"})) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("ValidatorMismatch"), "{error}"); assert!(client.state.lock().unwrap().commits.is_empty()); observe_interface(&runtime, path, true).await; call(&runtime, path, "write", json!({"content":"new-lifetime"})) @@ -958,3 +969,189 @@ async fn workspace_config_production_ungranted_root_does_not_poison_worldspace_d .all(|r| r.method == WorkspaceRequestMethod::Get) ); } + +#[tokio::test] +async fn common_read_only_text_windows_search_and_rejections_preserve_backend_authority() { + let client = Router::new(); + { + let mut state = client.state.lock().unwrap(); + state.read_only = true; + state.entries.get_mut("main.dcdl").unwrap().1 = "α\r\nneedle.*\nNeedle.*\nlast\n".into(); + } + let (feature, runtime) = runtime(client.clone()); + feature.attach("read-only", None).await.unwrap(); + let path = "/workspace-config/main.dcdl"; + observe_interface(&runtime, path, true).await; + let object = runtime + .inspect(path.into(), true) + .await + .unwrap() + .content + .unwrap(); + assert!(object.contains(&crate::file_operation::Contract::ReadLines.reference().name)); + assert!(object.contains(&crate::file_operation::Contract::SearchText.reference().name)); + assert!(!object.contains(&crate::file_operation::Contract::Write.reference().name)); + assert!(!object.contains(&crate::file_operation::Contract::Edit.reference().name)); + let read = call(&runtime, path, "read_lines", json!({"offset":1,"limit":2})) + .await + .unwrap(); + let read: Json = serde_json::from_str(read.content.as_deref().unwrap()).unwrap(); + assert!(read["content"].as_str().unwrap().contains("needle.*")); + assert!(read["content"].as_str().unwrap().contains("Needle.*")); + assert!(!read["content"].as_str().unwrap().contains('α')); + assert_eq!(read["next_offset"], 3); + assert_eq!(read["truncated"], true); + let search = call( + &runtime, + path, + "search_text", + json!({"pattern":"needle.*","limit":3}), + ) + .await + .unwrap(); + let search: Json = serde_json::from_str(search.content.as_deref().unwrap()).unwrap(); + assert_eq!(search["matches"], json!([{"line":2,"text":"needle.*"}])); + assert_eq!(search["next_offset"], 3); + assert_eq!(search["truncated"], true); + for (op, arguments) in [ + ("read_lines", json!({"limit":0})), + ("read_lines", json!({"limit":2001})), + ("search_text", json!({"pattern":""})), + ( + "search_text", + json!({"pattern":"x","case_insensitive":true}), + ), + ] { + let before = client + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|r| r.path.ends_with("/read")) + .count(); + assert!(call(&runtime, path, op, arguments).await.is_err()); + let after = client + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|r| r.path.ends_with("/read")) + .count(); + assert_eq!(before, after); + } + assert!( + call(&runtime, path, "write", json!({"content":"denied"})) + .await + .is_err() + ); + client.state.lock().unwrap().granted = false; + assert!(call(&runtime, path, "read_lines", json!({})).await.is_err()); + assert!(client.state.lock().unwrap().commits.is_empty()); +} + +#[tokio::test] +async fn common_text_read_refuses_oversized_rendered_window() { + let client = Router::new(); + let (feature, runtime) = runtime(client.clone()); + feature.attach("bounded", None).await.unwrap(); + let path = "/workspace-config/main.dcdl"; + client + .state + .lock() + .unwrap() + .entries + .get_mut("main.dcdl") + .unwrap() + .1 = "x".repeat(64 * 1024); + observe_interface(&runtime, path, true).await; + let error = call(&runtime, path, "read_lines", json!({})) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("ResourceLimitExceeded"), "{error}"); + assert!(client.state.lock().unwrap().commits.is_empty()); +} + +#[tokio::test] +async fn workspace_config_backend_validators_reject_path_and_connection_transplant_even_for_identical_sources() + { + use crate::wip::WipOperationError; + use wip_protocol::ProtocolErrorCode; + let client = Router::new(); + let path = "profiles/team/deep/model.dcdl"; + client + .state + .lock() + .unwrap() + .entries + .get_mut(path) + .unwrap() + .1 = "model = old".into(); + let (feature, runtime) = runtime(client.clone()); + let first = feature.attach("path-binding", None).await.unwrap(); + let backend = backend::WorkspaceConfigBackend::new(client.clone()); + let (main_validator, deep_validator) = { + let state = client.state.lock().unwrap(); + (state.validator("main.dcdl"), state.validator(path)) + }; + assert_ne!(main_validator, deep_validator); + let read = |connection_id: String, validator: String| WorkspaceConfigReadRequest { + connection_id, + path: path.into(), + validator, + }; + let result = backend + .post::( + "/read", + &read(first.connection_id.clone(), main_validator), + false, + ) + .await; + assert!( + matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch) + ); + let result: WorkspaceConfigReadResponse = backend + .post( + "/read", + &read(first.connection_id.clone(), deep_validator.clone()), + false, + ) + .await + .unwrap_or_else(|_| panic!("correct path validator rejected")); + assert_eq!(result.content, "model = old"); + assert_eq!(result.validator, deep_validator); + client.state.lock().unwrap().active = None; + let second = feature.attach("connection-binding", None).await.unwrap(); + assert_ne!(first.connection_id, second.connection_id); + for connection in [first.connection_id, second.connection_id.clone()] { + let result = backend + .post::( + "/read", + &read(connection, deep_validator.clone()), + false, + ) + .await; + assert!( + matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch) + ); + } + // Fresh path/connection observation restores native common mutation access, + // and the returned exact post-operation validator also supports common reads. + let route = format!("/workspace-config/{path}"); + observe_interface(&runtime, &route, true).await; + call( + &runtime, + &route, + "write", + json!({"content":"model = updated"}), + ) + .await + .unwrap(); + let result = call(&runtime, &route, "read_lines", json!({})) + .await + .unwrap(); + assert!(result.content.unwrap().contains("model = updated")); + assert_eq!(client.state.lock().unwrap().commits.len(), 1); +} diff --git a/crates/worker/src/feature/builtin/workspace_config/wip.rs b/crates/worker/src/feature/builtin/workspace_config/wip.rs index d3aa9d2c8..e03c90db3 100644 --- a/crates/worker/src/feature/builtin/workspace_config/wip.rs +++ b/crates/worker/src/feature/builtin/workspace_config/wip.rs @@ -45,6 +45,7 @@ pub fn mount_workspace_config_wip( provider: Arc::new(super::content::ConfigProvider { feature: feature.clone(), backend: backend.clone(), + observation_nonce: uuid::Uuid::now_v7().to_string().into(), }), })?; } diff --git a/crates/worker/src/file_operation.rs b/crates/worker/src/file_operation.rs index c8910a101..308647f5c 100644 --- a/crates/worker/src/file_operation.rs +++ b/crates/worker/src/file_operation.rs @@ -59,6 +59,7 @@ pub(crate) const MAX_BYTES: usize = 64 * 1024; /// Contracts have independent capability membership and no target-dependent descriptor fields. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Contract { + ReadBytes, ReadLines, SearchText, Edit, @@ -72,6 +73,7 @@ impl Contract { pub(crate) fn reference(self) -> InterfaceReference { crate::wip::root_reference(match self { Self::ReadLines => "yoi.file/read-lines/v1", + Self::ReadBytes => "yoi.file/read-bytes/v1", Self::SearchText => "yoi.file/search-text/v1", Self::Edit => "yoi.file/edit-text/v1", Self::Write => "yoi.file/write-text/v1", @@ -84,6 +86,7 @@ impl Contract { pub(crate) fn from_reference(r: &InterfaceReference) -> Option { [ Self::ReadLines, + Self::ReadBytes, Self::SearchText, Self::Edit, Self::Write, @@ -97,7 +100,7 @@ impl Contract { } pub(crate) fn operation(self) -> &'static str { match self { - Self::ReadLines => "read", + Self::ReadLines | Self::ReadBytes => "read", Self::SearchText => "search", Self::Edit => "edit", Self::Write => "write", @@ -110,12 +113,29 @@ impl Contract { pub(crate) fn handler_operation(self) -> &'static str { match self { Self::ReadLines => "read_lines", + Self::ReadBytes => "read_bytes", Self::SearchText => "search_text", _ => self.operation(), } } pub(crate) fn descriptor(self) -> InterfaceDescriptor { let (summary, parameters, result) = match self { + Self::ReadBytes => ( + "Read a bounded binary byte range", + vec![ + parameter("offset", false, TypeExpr::Integer), + parameter("limit", false, TypeExpr::Integer), + ], + TypeExpr::Record { + fields: vec![ + field("encoding", true, TypeExpr::String), + field("data", true, TypeExpr::String), + field("offset", true, TypeExpr::Integer), + field("next_offset", true, TypeExpr::Integer), + field("eof", true, TypeExpr::Boolean), + ], + }, + ), Self::ReadLines => ( "Read a bounded text line window", parameters("read", true).unwrap(), @@ -166,6 +186,7 @@ impl Contract { ), }; InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),types:vec![],documentation:Some(Documentation {summary:summary.into(),details:Some(match self { + Self::ReadBytes=>"Binary-safe byte range, independent of UTF-8 and line boundaries. Offset defaults to 0; limit defaults to 16384 and must be 1..=32768 bytes. Offset must be <=9007199254708223. Returns encoding=base64 with RFC4648 standard padded data, offset and next_offset in bytes, eof when no later bytes remain. At EOF returns empty data, eof=true and next_offset=offset; offsets beyond EOF are InvalidArguments. Encoded pretty JSON result is bounded to 64 KiB. Does not establish text prior-Read for mutation. InvalidArguments rejects ranges/unknown fields, ResourceLimitExceeded bounds, ValidatorMismatch stale source, PermissionDenied authority. Provider source bounds apply; no automatic retry.", Self::ReadLines|Self::SearchText=>"UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry.", Self::Edit|Self::Write|Self::CreateFile=>"UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry.", _=>"Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry.", @@ -552,3 +573,76 @@ mod tests { } } } + +/// Bounds reserve room for base64 expansion, JSON encoding, and exact lossless integer offsets. +pub(crate) fn byte_range( + args: &BTreeMap, +) -> Result<(u64, usize), WipOperationError> { + if args + .keys() + .any(|k| !["offset", "limit"].contains(&k.as_str())) + { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "unknown byte argument", + )); + } + let integer = |name: &str, default: u64| match args.get(name) { + None => Ok(default), + Some(Value::Integer(i)) => u64::try_from(*i) + .map_err(|_| failure(ProtocolErrorCode::InvalidArguments, "negative byte range")), + _ => Err(failure( + ProtocolErrorCode::InvalidArguments, + "invalid byte range", + )), + }; + let offset = integer("offset", 0)?; + let limit = integer("limit", 16384)?; + if offset > 9_007_199_254_708_223 || !(1..=32768).contains(&limit) { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "byte range exceeds bounds", + )); + } + Ok((offset, limit as usize)) +} +pub(crate) fn bytes_result( + args: &BTreeMap, + bytes: &[u8], + eof: bool, +) -> Result { + use base64::Engine as _; + let (offset, limit) = byte_range(args)?; + if bytes.len() > limit || (bytes.is_empty() && !eof) { + return Err(failure( + ProtocolErrorCode::Internal, + "provider returned an invalid byte range", + )); + } + let value = Value::Record(BTreeMap::from([ + ("encoding".into(), Value::String("base64".into())), + ( + "data".into(), + Value::String(base64::engine::general_purpose::STANDARD.encode(bytes)), + ), + ("offset".into(), Value::Integer(offset as i64)), + ( + "next_offset".into(), + Value::Integer((offset + bytes.len() as u64) as i64), + ), + ("eof".into(), Value::Boolean(eof)), + ])); + let json = crate::wip::wip_to_json(&value) + .map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))?; + if serde_json::to_vec_pretty(&json) + .map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))? + .len() + > MAX_BYTES + { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "encoded byte result exceeds bound", + )); + } + Ok(value) +} diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index e2e95f0d9..2929aba0b 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -1026,6 +1026,10 @@ impl WipMountRegistry { descriptor: &InterfaceDescriptor, validator: Option<&[u8]>, ) -> bool { + if let Some(contract) = crate::file_operation::Contract::from_reference(interface) { + return *descriptor != contract.descriptor() + || validator != Some(contract.validator().as_slice()); + } self.mounts.values().any(|mounted| { mounted.projection.interface == *interface && (mounted.projection.descriptor != *descriptor @@ -1091,6 +1095,10 @@ impl WipMountRegistry { validator: Option<&[u8]>, replacing_route: Option<&str>, ) -> bool { + if let Some(contract) = crate::file_operation::Contract::from_reference(interface) { + return *descriptor != contract.descriptor() + || validator != Some(contract.validator().as_slice()); + } self.additional_interfaces.iter().any(|(route, all)| { Some(route.as_str()) != replacing_route && all.iter().any(|p| { @@ -1604,6 +1612,18 @@ impl WipHost { if let Some(validator) = projection.handler.object_validator() { projection.object.validator = Some(validator); } + if let Some(contract) = + crate::file_operation::Contract::from_reference(&projection.interface) + { + if !projection + .handler + .operation_available(contract.handler_operation()) + { + return None; + } + // Common descriptor identity never varies with subject or capability filtering. + return Some(projection); + } let contextual = contextual_family || projection.handler.contextual_interface(); let original_len = projection.descriptor.operations.len(); projection diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index dd256fea9..1418b0ba1 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -12534,6 +12534,14 @@ impl server_api::ServerApi for ServerApiContractService { let api = self.workspace_api().map_err(|_| drive::failure(404))?; drive::root(api, &context, &workspace_id).await } + async fn drive_capabilities( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::capabilities(api, &context, &workspace_id).await + } async fn drive_metadata( &self, context: server_api::ServerRequestContext, diff --git a/crates/workspace-server/src/server_drive.rs b/crates/workspace-server/src/server_drive.rs index b055c3b1b..52581e416 100644 --- a/crates/workspace-server/src/server_drive.rs +++ b/crates/workspace-server/src/server_drive.rs @@ -238,6 +238,25 @@ pub(super) async fn root( let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; blocking(false, move || drive.root()).await.map(public_node) } +/// Query only the authenticated caller's current ceiling using the operation authorizer. +/// This is admission/publication evidence, never authority retained for a later mutation. +pub(super) async fn capabilities( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, +) -> DriveResult { + let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; + let write = blocking(false, move || match drive.check_authorized(true) { + Ok(()) => Ok(true), + Err(workspace_drive::Error::Denied) => { + drive.check_authorized(false)?; + Ok(false) + } + Err(error) => Err(error), + }) + .await?; + Ok(DriveCapabilities { write }) +} pub(super) async fn metadata( api: &WorkspaceApi, context: &ServerRequestContext, @@ -581,6 +600,9 @@ pub(super) fn router(service: Arc) -> Router { .merge(streaming_router(service.clone())) .merge(server_api::server_api_axum::drive_root(service.clone())) .merge(server_api::server_api_axum::drive_metadata(service.clone())) + .merge(server_api::server_api_axum::drive_capabilities( + service.clone(), + )) .merge(server_api::server_api_axum::drive_list(service.clone())) .merge(server_api::server_api_axum::drive_search(service.clone())) .merge(server_api::server_api_axum::drive_read_text( diff --git a/openapi/server-api.json b/openapi/server-api.json index e601f843e..8a8391a30 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -1951,6 +1951,19 @@ ], "type": "string" }, + "DriveCapabilities": { + "additionalProperties": false, + "description": "Current caller's advisory publication ceiling, never a grant or execution credential.", + "properties": { + "write": { + "type": "boolean" + } + }, + "required": [ + "write" + ], + "type": "object" + }, "DriveDownloadQuery": { "additionalProperties": false, "properties": { @@ -17831,7 +17844,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d" + "x-yoi-source-digest": "sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb" }, "openapi": "3.1.0", "paths": { @@ -22228,6 +22241,113 @@ ] } }, + "/api/w/{workspace_id}/drive/capabilities": { + "get": { + "operationId": "drive_capabilities", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveCapabilities" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/drive/download": { "get": { "operationId": "drive_download", diff --git a/web/workspace/src/lib/generated/drive-api.ts b/web/workspace/src/lib/generated/drive-api.ts index fa6a379dd..01fecdce2 100644 --- a/web/workspace/src/lib/generated/drive-api.ts +++ b/web/workspace/src/lib/generated/drive-api.ts @@ -31,6 +31,8 @@ export type DriveEntry = { latest_url: string; }; +export type DriveCapabilities = { write: boolean }; + export type DriveEntryQuery = { entry_workspace_id: string; id: string }; export type DriveListQuery = { diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 74a8c9547..c3274a7df 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index ae8584b56..bf13dfcb5 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a) +// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check From bb5c50f9ab0318dc118ce9c3099b7559e0d0e512 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 02:44:28 +0900 Subject: [PATCH 11/29] feat: persist System input policies through public consumers --- crates/protocol/src/lib.rs | 14 ++ crates/session-store/src/fs_store.rs | 4 + crates/session-store/src/history.rs | 192 +++++++++++++++ .../session-store/src/legacy_session_log.rs | 1 + .../src/legacy_session_log/tests.rs | 1 + crates/session-store/src/public_index.rs | 2 + crates/session-store/src/public_material.rs | 119 +++++++-- .../src/public_material/tests.rs | 164 +++++++++++++ crates/session-store/src/public_snapshot.rs | 55 ++++- .../src/public_snapshot/run_stats.rs | 1 + crates/session-store/src/segment_log.rs | 18 ++ crates/session-store/src/store.rs | 3 + crates/session-store/src/system_input.rs | 21 ++ crates/session-store/src/system_item.rs | 11 +- .../session-store/src/worker_session_store.rs | 5 + crates/session-store/tests/fs_store_test.rs | 176 ++++++++++++++ crates/session-store/tests/session_test.rs | 1 + .../standalone/src/subjektiv/journal/tests.rs | 1 + crates/standalone/src/subjektiv_sessions.rs | 1 + crates/tui/src/app.rs | 2 + .../tests/session_material_tests.rs | 1 + crates/worker-runtime/src/retention.rs | 2 + crates/worker-runtime/src/runtime.rs | 1 + crates/worker-runtime/src/session_public.rs | 1 + crates/worker-runtime/src/worker_backend.rs | 1 + .../src/feature/builtin/session_explore.rs | 2 +- .../src/feature/builtin/worker_observation.rs | 1 + crates/worker/src/internal_worker.rs | 1 + crates/worker/src/ipc/protocol_session.rs | 3 +- crates/worker/src/session_capture.rs | 227 +++++++++++++++--- crates/worker/src/session_history.rs | 4 + crates/worker/src/spawn/comm_tools.rs | 1 + crates/worker/src/spawn/registry.rs | 1 + crates/worker/src/wip.rs | 1 + crates/worker/tests/compact_events_test.rs | 1 + crates/workspace-server/src/server.rs | 2 + .../server/tests/subjektiv_journal_tests.rs | 2 + .../tests/subjektiv_memory_journal_tests.rs | 1 + .../src/lib/generated/protocol-validator.ts | 2 +- web/workspace/src/lib/generated/protocol.ts | 2 +- 40 files changed, 988 insertions(+), 61 deletions(-) diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 8055193d5..8be8c3d9a 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -982,6 +982,17 @@ pub enum SessionEntryProvenance { LegacyUnknown, } +/// Public use decisions contain no Feature identity or private payload. Absence +/// denotes the older projection contract, not permission to expose System input. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[serde(deny_unknown_fields)] +pub struct SessionEntryUsePolicy { + pub observation_readable: bool, + pub memory_evidence: bool, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] @@ -994,6 +1005,8 @@ pub struct SessionSnapshotEntry { pub provenance: SessionEntryProvenance, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub derived_from: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub use_policy: Option, #[serde(flatten)] pub data: SessionSnapshotEntryData, } @@ -2619,6 +2632,7 @@ mod tests { session: SessionSnapshot { pending_submissions: PendingSubmissionsSnapshot::default(), entries: vec![SessionSnapshotEntry { + use_policy: None, entry_id: "entry-1".into(), timestamp: 1, provenance: SessionEntryProvenance::HumanInput, diff --git a/crates/session-store/src/fs_store.rs b/crates/session-store/src/fs_store.rs index b6cade6b5..aa9988e8f 100644 --- a/crates/session-store/src/fs_store.rs +++ b/crates/session-store/src/fs_store.rs @@ -361,6 +361,7 @@ impl Store for FsStore { segment_id: SegmentId, entry: &LogEntry, ) -> Result<(), StoreError> { + entry.validate_system_inputs()?; let _retention = self.retention().lock_live(session_id)?; let line = serde_json::to_string(entry)?; self.append_line(&self.log_path(session_id, segment_id), &line) @@ -448,6 +449,9 @@ impl Store for FsStore { segment_id: SegmentId, entries: &[LogEntry], ) -> Result<(), StoreError> { + for entry in entries { + entry.validate_system_inputs()?; + } let _retention = self.retention().lock_live(session_id)?; let path = self.log_path(session_id, segment_id); if let Some(parent) = path.parent() { diff --git a/crates/session-store/src/history.rs b/crates/session-store/src/history.rs index 4997a3b83..f25617b1d 100644 --- a/crates/session-store/src/history.rs +++ b/crates/session-store/src/history.rs @@ -67,6 +67,10 @@ pub enum LoggedSessionHistoryOrigin { ToolOutput { worker: LoggedWorkerSubject, }, + /// Descriptive producer identity; use policies live in separate metadata. + Provided { + producer: String, + }, /// Host-produced opening context summary; not auto-read or TaskStore material. CompactionSummary, /// Frozen Host Journal reference, never independent observation evidence. @@ -86,14 +90,26 @@ pub struct LoggedSessionHistoryMetadata { pub origin: LoggedSessionHistoryOrigin, #[serde(default, skip_serializing_if = "Option::is_none")] pub derivation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_input: Option, } impl LoggedSessionHistoryMetadata { + fn validate_system_input(&self) -> Result<(), crate::SystemInputError> { + if matches!(self.origin, LoggedSessionHistoryOrigin::Provided { .. }) + && self.system_input.is_none() + { + return Err(crate::SystemInputError::MissingMetadata); + } + Ok(()) + } + pub fn legacy_unknown() -> Self { Self { entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, + system_input: None, } } } @@ -101,20 +117,140 @@ impl LoggedSessionHistoryMetadata { /// Persisted item and metadata are one value so transforms cannot reorder or /// truncate one without the other. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "HistoryEntryWire")] pub struct LoggedHistoryEntry { pub item: LoggedItem, pub metadata: LoggedSessionHistoryMetadata, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct HistoryEntryWire { + item: LoggedItem, + metadata: LoggedSessionHistoryMetadata, +} + +impl TryFrom for LoggedHistoryEntry { + type Error = crate::SystemInputError; + + fn try_from(wire: HistoryEntryWire) -> Result { + let entry = Self { + item: wire.item, + metadata: wire.metadata, + }; + entry.validate_system_input()?; + Ok(entry) + } +} + +impl LoggedHistoryEntry { + pub fn validate_system_input(&self) -> Result<(), crate::SystemInputError> { + self.metadata.validate_system_input()?; + if self.metadata.system_input.is_some() { + let LoggedItem::Message { + role: crate::LoggedRole::System, + content, + } = &self.item + else { + return Err(crate::SystemInputError::InconsistentRecord); + }; + let [crate::LoggedContentPart::Text { text }] = content.as_slice() else { + return Err(crate::SystemInputError::InconsistentRecord); + }; + if text.len() > crate::system_input::MAX_SYSTEM_INPUT_BYTES { + return Err(crate::SystemInputError::ResourceLimit); + } + } + Ok(()) + } +} + /// Typed system-item history record. The typed system event remains available /// to client replay while its model-visible projection carries the same stable /// metadata used by live history. #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(try_from = "SystemEntryWire")] pub struct LoggedSystemHistoryEntry { pub item: crate::SystemItem, pub metadata: LoggedSessionHistoryMetadata, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SystemEntryWire { + item: crate::SystemItem, + metadata: LoggedSessionHistoryMetadata, +} + +impl TryFrom for LoggedSystemHistoryEntry { + type Error = crate::SystemInputError; + + fn try_from(wire: SystemEntryWire) -> Result { + let entry = Self { + item: wire.item, + metadata: wire.metadata, + }; + entry.validate_system_input()?; + Ok(entry) + } +} + +impl LoggedSystemHistoryEntry { + pub fn from_input( + origin: LoggedSessionHistoryOrigin, + derivation: Option, + input: crate::SystemInputEnvelope, + ) -> Self { + let (body, system_input) = input.into_parts(); + Self { + item: crate::SystemItem::Input { body }, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId::new(), + origin, + derivation, + system_input: Some(system_input), + }, + } + } + + pub fn validate_system_input(&self) -> Result<(), crate::SystemInputError> { + self.metadata.validate_system_input()?; + match (&self.item, &self.metadata.system_input) { + (crate::SystemItem::Input { body }, Some(_)) => { + if body.len() > crate::system_input::MAX_SYSTEM_INPUT_BYTES { + return Err(crate::SystemInputError::ResourceLimit); + } + } + (crate::SystemItem::Input { .. }, None) => { + return Err(crate::SystemInputError::MissingMetadata); + } + (_, Some(_)) => return Err(crate::SystemInputError::InconsistentRecord), + (_, None) => {} + } + Ok(()) + } + + pub fn to_history_entry(&self) -> LoggedHistoryEntry { + LoggedHistoryEntry { + item: self.item.to_history_item().into(), + metadata: self.metadata.clone(), + } + } + + pub fn public_item(&self) -> crate::PublicSystemItem { + if matches!(self.item, crate::SystemItem::Input { .. }) { + return self + .metadata + .system_input + .as_ref() + .and_then(crate::SystemInputMetadata::public_item) + .cloned() + .unwrap_or_else(|| self.item.public_item()); + } + self.item.public_item() + } +} + #[cfg(test)] mod tests { use super::*; @@ -132,6 +268,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::HumanInput { account_id: "account-1".into(), @@ -150,6 +287,61 @@ mod tests { ); } + #[test] + fn external_system_records_reject_missing_policy_role_mismatch_and_overlimit_body() { + let valid = serde_json::json!({ + "item": { "kind": "input", "body": "context" }, + "metadata": { + "entry_id": "stable-entry", "origin": { "kind": "provided", "producer": "owner" }, + "system_input": { "usage": { + "recording_material": "exclude", "memory_evidence": "exclude", + "observation": "hidden", "compaction": "reference_input" + } } + } + }); + let restored: LoggedSystemHistoryEntry = serde_json::from_value(valid.clone()).unwrap(); + let flat = serde_json::to_value(restored.to_history_entry()).unwrap(); + assert!(serde_json::from_value::(flat.clone()).is_ok()); + for generic in [true, false] { + let wire = if generic { &valid } else { &flat }; + let decode = |wire| { + if generic { + serde_json::from_value::(wire).is_err() + } else { + serde_json::from_value::(wire).is_err() + } + }; + let mut missing = wire.clone(); + missing["metadata"] + .as_object_mut() + .unwrap() + .remove("system_input"); + assert!(decode(missing)); + let mut absent_policy = wire.clone(); + absent_policy["metadata"]["system_input"] + .as_object_mut() + .unwrap() + .remove("usage"); + assert!(decode(absent_policy)); + let mut invalid_role = wire.clone(); + invalid_role["item"] = + serde_json::to_value(LoggedItem::from(agen::Item::user_message("not system"))) + .unwrap(); + assert!(decode(invalid_role)); + let mut oversized = wire.clone(); + if generic { + oversized["item"]["body"] = "x" + .repeat(crate::system_input::MAX_SYSTEM_INPUT_BYTES + 1) + .into(); + } else { + oversized["item"]["content"][0]["text"] = "x" + .repeat(crate::system_input::MAX_SYSTEM_INPUT_BYTES + 1) + .into(); + } + assert!(decode(oversized)); + } + } + #[test] fn annotated_segment_start_is_restore_visible_without_projecting_metadata() { let session_id = uuid::Uuid::now_v7(); diff --git a/crates/session-store/src/legacy_session_log.rs b/crates/session-store/src/legacy_session_log.rs index 5c465d0b9..987a53984 100644 --- a/crates/session-store/src/legacy_session_log.rs +++ b/crates/session-store/src/legacy_session_log.rs @@ -167,6 +167,7 @@ fn legacy_metadata( identity.extend_from_slice(&(line_index as u64).to_be_bytes()); identity.extend_from_slice(&(item_index as u64).to_be_bytes()); LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId(format!( "l-{}", base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, identity) diff --git a/crates/session-store/src/legacy_session_log/tests.rs b/crates/session-store/src/legacy_session_log/tests.rs index 3c72462c5..314ee6d5f 100644 --- a/crates/session-store/src/legacy_session_log/tests.rs +++ b/crates/session-store/src/legacy_session_log/tests.rs @@ -22,6 +22,7 @@ fn seed( content: vec![crate::LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId("summary".into()), origin, derivation: Some(crate::LoggedHistoryDerivation { diff --git a/crates/session-store/src/public_index.rs b/crates/session-store/src/public_index.rs index 24932e397..cba29bc87 100644 --- a/crates/session-store/src/public_index.rs +++ b/crates/session-store/src/public_index.rs @@ -1296,6 +1296,7 @@ mod tests { LoggedHistoryEntry { item, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId(id.to_owned()), origin, derivation: None, @@ -1537,6 +1538,7 @@ mod tests { prompt_provenance: None, }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId("system-item".into()), origin: LoggedSessionHistoryOrigin::BackendInstruction { operation_id: None, diff --git a/crates/session-store/src/public_material.rs b/crates/session-store/src/public_material.rs index 6487bdade..f27795928 100644 --- a/crates/session-store/src/public_material.rs +++ b/crates/session-store/src/public_material.rs @@ -148,15 +148,24 @@ struct SegmentHeader { file_len: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PublicMaterialUse { + Recording, + MemoryEvidence, +} + /// A fixed material catalog. Opening a new catalog observes newly appended /// records; reading a body through this catalog verifies its saved digest and /// byte length again. Missing/corrected/deleted sources are explicit failures. +/// Explicit evidence permission applies to model text, not the private payload; +/// Web display and observation instead use the producer's public projection. pub struct SessionPublicMaterialReader { session_id: SessionId, active_segment_id: SegmentId, layout: Layout, limits: PublicMaterialLimits, materials: Vec, + purpose: PublicMaterialUse, retention: Option, } @@ -180,7 +189,13 @@ impl SessionPublicMaterialReader { active_segment_id: SegmentId, limits: PublicMaterialLimits, ) -> Result { - Self::open_worker_guarded(session_root, active_segment_id, limits, None) + Self::open_worker_guarded( + session_root, + active_segment_id, + limits, + None, + PublicMaterialUse::Recording, + ) } /// Uses the Host's aggregate-external control root for the whole scan and every body read. @@ -191,7 +206,30 @@ impl SessionPublicMaterialReader { limits: PublicMaterialLimits, retention: crate::retention::SessionRetentionStore, ) -> Result { - Self::open_worker_guarded(session_root, active_segment_id, limits, Some(retention)) + Self::open_worker_for_use( + session_root, + active_segment_id, + limits, + retention, + PublicMaterialUse::Recording, + ) + } + + /// Selects the evidence purpose independently of public display and observation. + pub fn open_worker_for_use( + session_root: &Path, + active_segment_id: SegmentId, + limits: PublicMaterialLimits, + retention: crate::retention::SessionRetentionStore, + purpose: PublicMaterialUse, + ) -> Result { + Self::open_worker_guarded( + session_root, + active_segment_id, + limits, + Some(retention), + purpose, + ) } fn open_worker_guarded( @@ -199,6 +237,7 @@ impl SessionPublicMaterialReader { active_segment_id: SegmentId, limits: PublicMaterialLimits, retention: Option, + purpose: PublicMaterialUse, ) -> Result { let store = WorkerSessionStore::open_read_only(session_root) .map_err(|_| PublicMaterialError::Unavailable)?; @@ -212,6 +251,7 @@ impl SessionPublicMaterialReader { Layout::Worker(session_root.into()), limits, retention, + purpose, ) } @@ -237,6 +277,25 @@ impl SessionPublicMaterialReader { active_segment_id: SegmentId, limits: PublicMaterialLimits, retention: crate::retention::SessionRetentionStore, + ) -> Result { + Self::open_fs_for_use( + sessions_root, + session_id, + active_segment_id, + limits, + retention, + PublicMaterialUse::Recording, + ) + } + + /// Uses retained originals with the independently selected evidence policy. + pub fn open_fs_for_use( + sessions_root: &Path, + session_id: SessionId, + active_segment_id: SegmentId, + limits: PublicMaterialLimits, + retention: crate::retention::SessionRetentionStore, + purpose: PublicMaterialUse, ) -> Result { Self::open( session_id, @@ -244,6 +303,7 @@ impl SessionPublicMaterialReader { Layout::Fs(sessions_root.join(session_id.to_string())), limits, Some(retention), + purpose, ) } @@ -263,6 +323,7 @@ impl SessionPublicMaterialReader { layout: Layout, limits: PublicMaterialLimits, retention: Option, + purpose: PublicMaterialUse, ) -> Result { let _guard = retention .as_ref() @@ -435,23 +496,19 @@ impl SessionPublicMaterialReader { } => turn = *total_turn_count, _ => {} } + let system_history; let entries: &[LoggedHistoryEntry] = match &record { LogEntry::AnnotatedSegmentStart { history, .. } | LogEntry::AnnotatedUserInput { history, .. } => history, LogEntry::AnnotatedAssistantItem { entry, .. } | LogEntry::AnnotatedToolResult { entry, .. } | LogEntry::ToolResultCorrected { entry, .. } => std::slice::from_ref(entry), + LogEntry::AnnotatedSystemItem { entry, .. } => { + system_history = entry.to_history_entry(); + std::slice::from_ref(&system_history) + } _ => &[], }; - if let LogEntry::AnnotatedSystemItem { entry, .. } = &record { - charge(&mut examined, limits.max_entries)?; - if entry.metadata.entry_id.0.is_empty() - || !history_seen.insert(entry.metadata.entry_id.0.clone()) - { - return Err(PublicMaterialError::Inconsistent); - } - history_ids.push(entry.metadata.entry_id.0.clone()); - } for (history_index, entry) in entries.iter().enumerate() { charge(&mut examined, limits.max_entries)?; let id = &entry.metadata.entry_id.0; @@ -468,7 +525,7 @@ impl SessionPublicMaterialReader { if in_run && ai_materialized(&entry.item) { safe = true; } - let Some((sha256, total_bytes)) = canonical_metadata(entry)? else { + let Some((sha256, total_bytes)) = canonical_metadata(entry, purpose)? else { continue; }; let location = Location { @@ -621,6 +678,7 @@ impl SessionPublicMaterialReader { layout, limits, materials, + purpose, retention: retention.clone(), }) } @@ -660,6 +718,7 @@ impl SessionPublicMaterialReader { let Some((_, record)) = reader.next()? else { return Err(PublicMaterialError::Missing); }; + let system_history; let (body, origin) = if let Some(boundary) = run_boundary(&record) { if boundary_reference( &record, @@ -683,12 +742,16 @@ impl SessionPublicMaterialReader { LogEntry::AnnotatedAssistantItem { entry, .. } | LogEntry::AnnotatedToolResult { entry, .. } | LogEntry::ToolResultCorrected { entry, .. } => Some(entry), + LogEntry::AnnotatedSystemItem { entry, .. } => { + system_history = entry.to_history_entry(); + Some(&system_history) + } _ => None, } .filter(|e| format!("E{}", e.metadata.entry_id.0) == descriptor.identity.entry_ref) .ok_or(PublicMaterialError::Changed)?; ( - canonical_body(entry).ok_or(PublicMaterialError::Changed)?, + canonical_body(entry, self.purpose).ok_or(PublicMaterialError::Changed)?, PublicMaterialOrigin::History { origin: entry.metadata.origin.clone(), }, @@ -828,7 +891,32 @@ fn boundary_reference( .unwrap_or_else(|| format!("El-run-{session}-{segment}-{offset}")) } -fn canonical_body(entry: &LoggedHistoryEntry) -> Option> { +fn canonical_body( + entry: &LoggedHistoryEntry, + purpose: PublicMaterialUse, +) -> Option> { + if let Some(input) = &entry.metadata.system_input { + let usage = input.usage(); + let allowed = match purpose { + PublicMaterialUse::Recording => usage.recording_material, + PublicMaterialUse::MemoryEvidence => usage.memory_evidence, + }; + if allowed == crate::InputEvidenceUse::Exclude { + return None; + } + if let LoggedItem::Message { + role: LoggedRole::System, + content, + } = &entry.item + { + return Some(CanonicalBody::Message { + origin: &entry.metadata.origin, + role: LoggedRole::System, + content, + }); + } + return None; + } if matches!( entry.metadata.origin, LoggedSessionHistoryOrigin::DerivedSummary @@ -880,8 +968,9 @@ fn canonical_body(entry: &LoggedHistoryEntry) -> Option> { fn canonical_metadata( entry: &LoggedHistoryEntry, + purpose: PublicMaterialUse, ) -> Result, PublicMaterialError> { - let Some(body) = canonical_body(entry) else { + let Some(body) = canonical_body(entry, purpose) else { return Ok(None); }; let mut sink = BodySink::new(0, 0); diff --git a/crates/session-store/src/public_material/tests.rs b/crates/session-store/src/public_material/tests.rs index b77642840..0376af0bc 100644 --- a/crates/session-store/src/public_material/tests.rs +++ b/crates/session-store/src/public_material/tests.rs @@ -9,6 +9,7 @@ fn entry(id: &str, item: LoggedItem) -> LoggedHistoryEntry { LoggedHistoryEntry { item, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId(id.into()), origin: LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, @@ -591,6 +592,7 @@ fn reasoning_proves_rollback_safety_but_private_and_derived_items_are_not_materi }, }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId("startup-journal".into()), origin: LoggedSessionHistoryOrigin::DerivedSummary, derivation: None, @@ -1243,3 +1245,165 @@ fn compaction_without_exact_derivation_or_with_invalid_turn_is_rejected() { )); } } + +#[test] +fn generic_system_material_uses_explicit_evidence_purpose_not_producer_or_body() { + use crate::{ + FeatureInputPayload, InputCompactionUse, InputEvidenceUse, InputObservationUse, + LoggedSystemHistoryEntry, SystemInputEnvelope, SystemInputMetadata, SystemInputUsePolicy, + }; + for worker_layout in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let session = crate::new_session_id(); + let segment = crate::new_segment_id(); + let fork = crate::new_segment_id(); + let make = |producer: &str, recording_material, memory_evidence, body: &str| { + LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: producer.into(), + }, + None, + SystemInputEnvelope::new( + body, + SystemInputMetadata::new( + SystemInputUsePolicy { + recording_material, + memory_evidence, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::Retain, + }, + Some( + FeatureInputPayload::encode( + "unknown:future", + 99, + &serde_json::json!({"private": "not-public"}), + ) + .unwrap(), + ), + None, + ) + .unwrap(), + ) + .unwrap(), + ) + }; + let inputs = [ + make( + "same-owner", + InputEvidenceUse::Include, + InputEvidenceUse::Exclude, + "do not extract this; original material", + ), + make( + "same-owner", + InputEvidenceUse::Exclude, + InputEvidenceUse::Include, + "memory-only original", + ), + make( + "renamed-owner", + InputEvidenceUse::Exclude, + InputEvidenceUse::Exclude, + "reference without warning text", + ), + ]; + let root = if worker_layout { + temp.path().join("worker") + } else { + temp.path().join(session.to_string()) + }; + let store: Box = if worker_layout { + Box::new(WorkerSessionStore::new(&root).unwrap()) + } else { + Box::new(FsStore::new(temp.path()).unwrap()) + }; + store + .create_segment(session, segment, &[seed(session, vec![])]) + .unwrap(); + for entry in &inputs { + store + .append( + session, + segment, + &LogEntry::AnnotatedSystemItem { + ts: 2, + entry: entry.clone(), + extensions: vec![], + }, + ) + .unwrap(); + } + store + .append( + session, + segment, + &LogEntry::TurnEnd { + ts: 3, + turn_count: 1, + }, + ) + .unwrap(); + let mut fork_seed = seed( + session, + inputs + .iter() + .map(LoggedSystemHistoryEntry::to_history_entry) + .collect(), + ); + if let LogEntry::AnnotatedSegmentStart { forked_from, .. } = &mut fork_seed { + *forked_from = Some(crate::SegmentOrigin { + segment_id: segment, + at_turn_index: 1, + }); + } + store.create_segment(session, fork, &[fork_seed]).unwrap(); + let open = |active, purpose| { + if worker_layout { + SessionPublicMaterialReader::open_worker_for_use( + &root, + active, + Default::default(), + crate::retention::fs_retention(temp.path()), + purpose, + ) + .unwrap() + } else { + SessionPublicMaterialReader::open_fs_for_use( + temp.path(), + session, + active, + Default::default(), + crate::retention::fs_retention(temp.path()), + purpose, + ) + .unwrap() + } + }; + for (purpose, selected, expected_text) in [ + ( + PublicMaterialUse::Recording, + 0, + "do not extract this; original material", + ), + (PublicMaterialUse::MemoryEvidence, 1, "memory-only original"), + ] { + let original = open(segment, purpose); + let inherited = open(fork, purpose); + assert_eq!( + ids(&original), + vec![format!("E{}", inputs[selected].metadata.entry_id.0)] + ); + assert_eq!(ids(&inherited), ids(&original)); + let descriptor = inherited.descriptors().next().unwrap(); + let body = inherited + .read_content(descriptor, 0, 16 * 1024) + .unwrap() + .content; + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(value["role"], "system"); + assert_eq!(value["content"][0]["text"], expected_text); + assert!(!body.contains("not-public")); + assert!(!body.contains("unknown:future")); + } + } +} diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index 6a1d94b0e..1baa121c8 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -27,7 +27,7 @@ use thiserror::Error; use crate::{ LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, - LoggedSessionHistoryOrigin, SegmentId, SegmentOrigin, SessionId, StoreError, SystemItem, + LoggedSessionHistoryOrigin, SegmentId, SegmentOrigin, SessionId, StoreError, WorkerAggregateStore, WorkerSessionStore, WorkerStoreError, }; @@ -1165,7 +1165,7 @@ fn capture_tool_correction( let LogEntry::ToolResultCorrected { entry, .. } = record else { return false; }; - if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) { + if let Some(data) = project_history_item(entry) { // We read backwards: the first correction seen is the latest one. corrections .entry(entry.metadata.entry_id.0.clone()) @@ -1600,12 +1600,12 @@ pub(crate) fn project_session_snapshot_for_segment( } => extend_history(&mut entries, history, Some(segments), *ts), LogEntry::AnnotatedAssistantItem { ts, entry } | LogEntry::AnnotatedToolResult { ts, entry } => { - if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) { + if let Some(data) = project_history_item(entry) { entries.push(history_entry(entry, *ts, data)); } } LogEntry::ToolResultCorrected { ts, entry } => { - if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) { + if let Some(data) = project_history_item(entry) { if let Some(existing) = entries .iter_mut() .find(|existing| existing.entry_id == entry.metadata.entry_id.0) @@ -1618,11 +1618,16 @@ pub(crate) fn project_session_snapshot_for_segment( } } LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry( - &entry.item, + &entry.public_item(), entry.metadata.entry_id.0.clone(), *ts, provenance(&entry.metadata.origin), derivation_ids(entry), + entry + .metadata + .system_input + .as_ref() + .map(crate::SystemInputMetadata::public_use_policy), )), LogEntry::RunYielded { ts, @@ -1775,7 +1780,7 @@ fn extend_history( segments: input_segments.cloned().unwrap_or_default(), } } else { - let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) else { + let Some(data) = project_history_item(entry) else { continue; }; data @@ -1790,6 +1795,11 @@ fn history_entry( data: SessionSnapshotEntryData, ) -> SessionSnapshotEntry { SessionSnapshotEntry { + use_policy: entry + .metadata + .system_input + .as_ref() + .map(crate::SystemInputMetadata::public_use_policy), entry_id: entry.metadata.entry_id.0.clone(), timestamp, provenance: provenance(&entry.metadata.origin), @@ -1833,6 +1843,7 @@ fn run_transition_entry( data: SessionSnapshotEntryData, ) -> SessionSnapshotEntry { SessionSnapshotEntry { + use_policy: None, entry_id: persisted_entry_id .map(|entry_id| entry_id.0.clone()) .unwrap_or_else(|| legacy_entry_id(session_key, segment_id, log_index, 0)), @@ -1852,6 +1863,7 @@ fn legacy_entry( data: SessionSnapshotEntryData, ) -> SessionSnapshotEntry { SessionSnapshotEntry { + use_policy: None, entry_id: legacy_entry_id(session_key, segment_id, log_index, item_index), timestamp, provenance: SessionEntryProvenance::LegacyUnknown, @@ -1884,9 +1896,8 @@ fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance { LoggedSessionHistoryOrigin::FlowInstruction { .. } => { SessionEntryProvenance::FlowInstruction } - LoggedSessionHistoryOrigin::BackendInstruction { .. } => { - SessionEntryProvenance::BackendInstruction - } + LoggedSessionHistoryOrigin::BackendInstruction { .. } + | LoggedSessionHistoryOrigin::Provided { .. } => SessionEntryProvenance::BackendInstruction, LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput, LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput, LoggedSessionHistoryOrigin::CompactionSummary @@ -1914,6 +1925,18 @@ pub fn session_tool_attachment_id( URL_SAFE_NO_PAD.encode(hash.finalize()) } +fn project_history_item(entry: &LoggedHistoryEntry) -> Option { + if let Some(input) = &entry.metadata.system_input { + let public = input.public_projection(); + return Some(SessionSnapshotEntryData::SystemItem { + item_kind: public.kind.clone(), + content: public.body.clone(), + data: Some(serde_json::to_value(public).expect("public system input is serializable")), + }); + } + project_item(&entry.metadata.entry_id.0, &entry.item) +} + fn project_item(entry_id: &str, item: &LoggedItem) -> Option { match item { LoggedItem::Message { role, content } => { @@ -1986,17 +2009,19 @@ fn project_item(entry_id: &str, item: &LoggedItem) -> Option, + use_policy: Option, ) -> SessionSnapshotEntry { - let public = item.public_item(); + let public = item.clone(); let data = Some(serde_json::to_value(&public).expect("public system input is serializable")); let item_kind = public.kind; let content = public.body; SessionSnapshotEntry { + use_policy, entry_id, timestamp, provenance, @@ -2285,6 +2310,7 @@ mod tests { content: vec![LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId(entry_id.into()), origin, derivation: None, @@ -3583,6 +3609,7 @@ mod tests { .map(|item| LoggedHistoryEntry { item, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -3637,6 +3664,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: user_entry_id, origin: LoggedSessionHistoryOrigin::HumanInput { account_id: "account-1".into(), @@ -3706,6 +3734,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::FlowInstruction { selector: "builtin:coder-review".into(), @@ -3725,6 +3754,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: user_entry_id.clone(), origin: origin.clone(), derivation: Some(LoggedHistoryDerivation { @@ -3755,6 +3785,7 @@ mod tests { fn annotated_projection_preserves_identity_and_provenance() { let session_id = crate::new_session_id(); let metadata = LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::ModelOutput { worker: LoggedWorkerSubject { @@ -4317,6 +4348,7 @@ mod tests { is_error: false, }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId("ancestor-image".into()), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -4411,6 +4443,7 @@ mod tests { is_error: false, }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId("tool-images".into()), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/session-store/src/public_snapshot/run_stats.rs b/crates/session-store/src/public_snapshot/run_stats.rs index 2d5b49902..4806e1da2 100644 --- a/crates/session-store/src/public_snapshot/run_stats.rs +++ b/crates/session-store/src/public_snapshot/run_stats.rs @@ -103,6 +103,7 @@ fn finish( stats.insert( anchor.clone(), SessionSnapshotEntry { + use_policy: None, entry_id: format!("run-stats:{anchor}"), timestamp: ended_at, provenance: SessionEntryProvenance::LegacyUnknown, diff --git a/crates/session-store/src/segment_log.rs b/crates/session-store/src/segment_log.rs index 97f347dad..f76ea783c 100644 --- a/crates/session-store/src/segment_log.rs +++ b/crates/session-store/src/segment_log.rs @@ -254,6 +254,23 @@ pub struct SegmentOrigin { } impl LogEntry { + /// Validate generic System records before a Store publishes any bytes. + pub fn validate_system_inputs(&self) -> Result<(), crate::SystemInputError> { + match self { + Self::AnnotatedSegmentStart { history, .. } + | Self::AnnotatedUserInput { history, .. } => { + for entry in history { + entry.validate_system_input()?; + } + } + Self::AnnotatedAssistantItem { entry, .. } + | Self::AnnotatedToolResult { entry, .. } + | Self::ToolResultCorrected { entry, .. } => entry.validate_system_input()?, + Self::AnnotatedSystemItem { entry, .. } => entry.validate_system_input()?, + _ => {} + } + Ok(()) + } /// Allocate the durable public identity for a newly committed Run /// transition. Calling this repeatedly is idempotent so the exact value /// written to storage is also the value published to live subscribers. @@ -537,6 +554,7 @@ mod tests { LoggedHistoryEntry { item: LoggedItem::from(item), metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/session-store/src/store.rs b/crates/session-store/src/store.rs index a876b7429..bf29ccb63 100644 --- a/crates/session-store/src/store.rs +++ b/crates/session-store/src/store.rs @@ -27,6 +27,9 @@ pub enum StoreError { #[error("serialization error: {0}")] Serde(#[from] serde_json::Error), + #[error("invalid system input: {0}")] + InvalidSystemInput(#[from] crate::SystemInputError), + #[error("segment not found: {0}")] NotFound(SegmentId), diff --git a/crates/session-store/src/system_input.rs b/crates/session-store/src/system_input.rs index fc9613941..3595c7bdb 100644 --- a/crates/session-store/src/system_input.rs +++ b/crates/session-store/src/system_input.rs @@ -36,6 +36,10 @@ pub enum SystemInputError { UnsupportedPayload, #[error("system input payload does not match the owner's typed contract")] InvalidTypedPayload, + #[error("generic system input is missing explicit metadata")] + MissingMetadata, + #[error("system input metadata does not match its logical record")] + InconsistentRecord, } /// One private payload owned by a Feature's versioned typed decoder. @@ -229,6 +233,23 @@ impl SystemInputMetadata { pub fn public_item(&self) -> Option<&PublicSystemItem> { self.public.as_ref() } + + pub fn public_use_policy(&self) -> protocol::SessionEntryUsePolicy { + let has_public = self.public.is_some(); + protocol::SessionEntryUsePolicy { + observation_readable: has_public + && self.usage.observation == InputObservationUse::Readable, + memory_evidence: has_public && self.usage.memory_evidence == InputEvidenceUse::Include, + } + } + + pub fn public_projection(&self) -> PublicSystemItem { + self.public.clone().unwrap_or_else(|| PublicSystemItem { + kind: "input".into(), + body: String::new(), + presentation: crate::PublicSystemPresentation::Ignored, + }) + } } /// System role is invariant; provenance and use policies are attached data. diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index 376a3cd8f..6d3d6ce43 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -92,6 +92,9 @@ pub struct PromptRenderProvenance { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SystemItem { + /// Generic System body; private metadata and public presentation are supplied + /// independently by the producer in the same annotated history record. + Input { body: String }, /// One-time derived Journal reference context. The bounded Host snapshot is /// retained as JSON independently of its rendered body and prompt projection. JournalStartup { @@ -227,9 +230,9 @@ impl SystemItem { event: event.clone(), }, Self::TaskReminder { .. } => PublicSystemPresentation::TaskReminder, - Self::LegacyKnowledgeIgnored { .. } | Self::LegacyIgnored { .. } => { - PublicSystemPresentation::Ignored - } + Self::Input { .. } + | Self::LegacyKnowledgeIgnored { .. } + | Self::LegacyIgnored { .. } => PublicSystemPresentation::Ignored, Self::JournalStartup { .. } | Self::ResidentSummaryRefresh { .. } | Self::SubjectBehaviorRefresh { .. } @@ -253,6 +256,7 @@ impl SystemItem { /// for this item. Returns the variant's stored `body` verbatim. pub fn history_text(&self) -> String { match self { + SystemItem::Input { body } => body.clone(), SystemItem::JournalStartup { body, .. } => body.clone(), SystemItem::Notification { body, .. } => body.clone(), SystemItem::WorkerEvent { body, .. } => body.clone(), @@ -280,6 +284,7 @@ impl SystemItem { /// wire — keep flexible. pub fn kind_label(&self) -> &'static str { match self { + SystemItem::Input { .. } => "input", SystemItem::JournalStartup { .. } => "journal_startup", SystemItem::Notification { .. } => "notification", SystemItem::WorkerEvent { .. } => "worker_event", diff --git a/crates/session-store/src/worker_session_store.rs b/crates/session-store/src/worker_session_store.rs index b19180b3f..bb6ffe34f 100644 --- a/crates/session-store/src/worker_session_store.rs +++ b/crates/session-store/src/worker_session_store.rs @@ -742,6 +742,7 @@ impl Store for WorkerSessionStore { segment_id: SegmentId, entry: &LogEntry, ) -> Result<(), StoreError> { + entry.validate_system_inputs()?; let _retention = self.lock_publication(session_id)?; self.ensure_session(session_id, true)?; self.append_log_entry(&self.log_path(segment_id), entry) @@ -800,6 +801,9 @@ impl Store for WorkerSessionStore { segment_id: SegmentId, entries: &[LogEntry], ) -> Result<(), StoreError> { + for entry in entries { + entry.validate_system_inputs()?; + } let _retention = self.lock_publication(session_id)?; self.ensure_session(session_id, true)?; let mut content = Vec::new(); @@ -1430,6 +1434,7 @@ mod tests { LoggedHistoryEntry { item: LoggedItem::from(item), metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/session-store/tests/fs_store_test.rs b/crates/session-store/tests/fs_store_test.rs index 1ecb81d01..af4e940ec 100644 --- a/crates/session-store/tests/fs_store_test.rs +++ b/crates/session-store/tests/fs_store_test.rs @@ -11,6 +11,7 @@ fn annotated(item: Item) -> LoggedHistoryEntry { LoggedHistoryEntry { item: LoggedItem::from(item), metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -421,3 +422,178 @@ fn lookup_session_of_finds_owning_session() { assert_eq!(store.lookup_session_of(segid).unwrap(), Some(sid)); } + +// Storage/projection component test: both production layouts, temporary originals, +// no Feature, model, runtime or Host authority is substituted into this boundary. +#[test] +fn generic_system_append_and_fork_preserve_metadata_without_public_payload_leak() { + use session_store::{ + FeatureInputPayload, InputCompactionUse, InputEvidenceUse, InputObservationUse, + LoggedHistoryDerivation, LoggedSystemHistoryEntry, PublicSystemItem, + PublicSystemPresentation, SystemInputEnvelope, SystemInputMetadata, SystemInputUsePolicy, + WorkerSessionStore, + }; + for worker_layout in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let sid = new_session_id(); + let segment = new_segment_id(); + let fork = new_segment_id(); + let store: Box = if worker_layout { + Box::new(WorkerSessionStore::new(dir.path()).unwrap()) + } else { + Box::new(FsStore::new(dir.path()).unwrap()) + }; + store + .create_segment(sid, segment, &[nil_session_start(1, sid)]) + .unwrap(); + let entry = LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: "new-feature".into(), + }, + Some(LoggedHistoryDerivation { + sources: vec![LoggedSessionHistoryEntryId("source-entry".into())], + }), + SystemInputEnvelope::new( + "private model body", + SystemInputMetadata::new( + SystemInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::ReferenceInput, + }, + Some( + FeatureInputPayload::encode( + "new-feature:context", + 101, + &serde_json::json!({"private": "secret-detail"}), + ) + .unwrap(), + ), + Some(PublicSystemItem { + kind: "context".into(), + body: "explicit public body".into(), + presentation: PublicSystemPresentation::Context, + }), + ) + .unwrap(), + ) + .unwrap(), + ); + store + .append( + sid, + segment, + &LogEntry::AnnotatedSystemItem { + ts: 2, + entry: entry.clone(), + extensions: vec![], + }, + ) + .unwrap(); + let saved = store.read_all(sid, segment).unwrap(); + let LogEntry::AnnotatedSystemItem { + entry: restored, .. + } = &saved[1] + else { + panic!("missing system input") + }; + assert_eq!(restored.to_history_entry(), entry.to_history_entry()); + assert_eq!( + collect_state(&saved).history[0].as_text(), + Some("private model body") + ); + let mut fork_seed = nil_session_start(3, sid); + if let LogEntry::AnnotatedSegmentStart { + history, + forked_from, + .. + } = &mut fork_seed + { + history.push(restored.to_history_entry()); + *forked_from = Some(session_store::SegmentOrigin { + segment_id: segment, + at_turn_index: 0, + }); + } + store.create_segment(sid, fork, &[fork_seed]).unwrap(); + let fork_saved = store.read_all(sid, fork).unwrap(); + let LogEntry::AnnotatedSegmentStart { history, .. } = &fork_saved[0] else { + unreachable!() + }; + assert_eq!(history, &[entry.to_history_entry()]); + let original = session_store::public_snapshot::project_session_snapshot(sid, &saved); + let inherited = session_store::public_snapshot::project_session_snapshot(sid, &fork_saved); + let public = serde_json::to_string(&original.entries).unwrap(); + assert!(public.contains("explicit public body")); + for private in ["private model body", "secret-detail", "new-feature:context"] { + assert!(!public.contains(private), "private field leaked: {private}"); + } + assert_eq!(original.entries.len(), 1); + assert_eq!(inherited.entries.len(), 1); + assert_eq!(original.entries[0].entry_id, inherited.entries[0].entry_id); + assert_eq!( + original.entries[0].derived_from, + inherited.entries[0].derived_from + ); + assert_eq!( + original.entries[0].provenance, + inherited.entries[0].provenance + ); + assert_eq!( + serde_json::to_value(&original.entries[0].data).unwrap(), + serde_json::to_value(&inherited.entries[0].data).unwrap() + ); + } +} + +#[test] +fn invalid_generic_system_input_rejects_append_create_and_external_json_without_changing_originals() +{ + use session_store::{StoreError, SystemItem, WorkerSessionStore}; + for worker_layout in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let sid = new_session_id(); + let segment = new_segment_id(); + let store: Box = if worker_layout { + Box::new(WorkerSessionStore::new(dir.path()).unwrap()) + } else { + Box::new(FsStore::new(dir.path()).unwrap()) + }; + let seed = nil_session_start(1, sid); + store + .create_segment(sid, segment, std::slice::from_ref(&seed)) + .unwrap(); + let path = if worker_layout { + dir.path().join("segments").join(format!("{segment}.jsonl")) + } else { + dir.path() + .join(sid.to_string()) + .join(format!("{segment}.jsonl")) + }; + let before = std::fs::read(&path).unwrap(); + let invalid = LogEntry::AnnotatedSystemItem { + ts: 2, + extensions: vec![], + entry: session_store::LoggedSystemHistoryEntry { + item: SystemItem::Input { + body: "no policy".into(), + }, + metadata: LoggedSessionHistoryMetadata::legacy_unknown(), + }, + }; + assert!(matches!( + store.append(sid, segment, &invalid), + Err(StoreError::InvalidSystemInput(_)) + )); + assert!(matches!( + store.create_segment(sid, segment, &[seed, invalid.clone()]), + Err(StoreError::InvalidSystemInput(_)) + )); + assert!( + serde_json::from_value::(serde_json::to_value(invalid).unwrap()).is_err() + ); + assert_eq!(std::fs::read(path).unwrap(), before); + assert_eq!(store.read_all(sid, segment).unwrap().len(), 1); + } +} diff --git a/crates/session-store/tests/session_test.rs b/crates/session-store/tests/session_test.rs index 901c0578f..e12d3ff29 100644 --- a/crates/session-store/tests/session_test.rs +++ b/crates/session-store/tests/session_test.rs @@ -24,6 +24,7 @@ fn annotated(items: &[Item]) -> Vec { .map(|item| session_store::LoggedHistoryEntry { item: session_store::LoggedItem::from(item), metadata: session_store::LoggedSessionHistoryMetadata { + system_input: None, entry_id: session_store::LoggedSessionHistoryEntryId::new(), origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/standalone/src/subjektiv/journal/tests.rs b/crates/standalone/src/subjektiv/journal/tests.rs index 3eacfeeb0..bd24ef67d 100644 --- a/crates/standalone/src/subjektiv/journal/tests.rs +++ b/crates/standalone/src/subjektiv/journal/tests.rs @@ -212,6 +212,7 @@ fn message(id: &str, text: &str) -> session_store::LoggedHistoryEntry { content: vec![session_store::LoggedContentPart::Text { text: text.into() }], }, metadata: session_store::LoggedSessionHistoryMetadata { + system_input: None, entry_id: session_store::LoggedSessionHistoryEntryId(id.into()), origin: session_store::LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, diff --git a/crates/standalone/src/subjektiv_sessions.rs b/crates/standalone/src/subjektiv_sessions.rs index 36a75df06..71dc48631 100644 --- a/crates/standalone/src/subjektiv_sessions.rs +++ b/crates/standalone/src/subjektiv_sessions.rs @@ -1621,6 +1621,7 @@ allow = [] let history = |id: &str, item: LoggedItem| LoggedHistoryEntry { item, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId(id.into()), origin: LoggedSessionHistoryOrigin::BackendInstruction { operation_id: None }, derivation: None, diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 306368070..e537c5385 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -3567,6 +3567,7 @@ mod completion_flow_tests { session_store::LoggedHistoryEntry { item: session_store::LoggedItem::from(item), metadata: session_store::LoggedSessionHistoryMetadata { + system_input: None, entry_id: session_store::LoggedSessionHistoryEntryId::new(), origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -4324,6 +4325,7 @@ mod completion_flow_tests { })); app.handle_worker_event(Event::SessionEntryCommitted { entry: protocol::SessionSnapshotEntry { + use_policy: None, entry_id: "run-failure".into(), timestamp: 2, provenance: protocol::SessionEntryProvenance::LegacyUnknown, diff --git a/crates/worker-runtime/src/http_server/tests/session_material_tests.rs b/crates/worker-runtime/src/http_server/tests/session_material_tests.rs index 33f8555dc..9c0e5f9e2 100644 --- a/crates/worker-runtime/src/http_server/tests/session_material_tests.rs +++ b/crates/worker-runtime/src/http_server/tests/session_material_tests.rs @@ -35,6 +35,7 @@ fn message(id: &str, role: LoggedRole, text: &str) -> LoggedHistoryEntry { content: vec![LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId(id.into()), origin: LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, diff --git a/crates/worker-runtime/src/retention.rs b/crates/worker-runtime/src/retention.rs index bc7994de9..9777e7390 100644 --- a/crates/worker-runtime/src/retention.rs +++ b/crates/worker-runtime/src/retention.rs @@ -1831,6 +1831,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId("original".into()), origin: LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, @@ -1844,6 +1845,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId("summary".into()), origin: LoggedSessionHistoryOrigin::DerivedSummary, derivation: Some(session_store::LoggedHistoryDerivation { diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 2b671c6e5..c9307a5dc 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -12422,6 +12422,7 @@ mod tests { session: protocol::SessionSnapshot { pending_submissions: protocol::PendingSubmissionsSnapshot::default(), entries: vec![protocol::SessionSnapshotEntry { + use_policy: None, entry_id: "restored-log-entry".to_owned(), timestamp: 1, provenance: protocol::SessionEntryProvenance::LegacyUnknown, diff --git a/crates/worker-runtime/src/session_public.rs b/crates/worker-runtime/src/session_public.rs index dac5d21f0..93f337f6d 100644 --- a/crates/worker-runtime/src/session_public.rs +++ b/crates/worker-runtime/src/session_public.rs @@ -398,6 +398,7 @@ mod tests { content: vec![LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId(id.to_string()), origin: LoggedSessionHistoryOrigin::HumanInput { account_id: "account-1".to_string(), diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 74044cde9..ca598cd80 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -5033,6 +5033,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id, origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/worker/src/feature/builtin/session_explore.rs b/crates/worker/src/feature/builtin/session_explore.rs index 69d4ced54..aacc16081 100644 --- a/crates/worker/src/feature/builtin/session_explore.rs +++ b/crates/worker/src/feature/builtin/session_explore.rs @@ -371,7 +371,7 @@ fn parse_entry_ref(value: &str) -> Result { fn parse_kind(value: &str) -> Result { ReferenceKind::parse(value).ok_or_else(|| { ToolError::InvalidArgument(format!( - "invalid kind {value:?}; expected user, assistant, or tool" + "invalid kind {value:?}; expected user, assistant, system, or tool" )) }) } diff --git a/crates/worker/src/feature/builtin/worker_observation.rs b/crates/worker/src/feature/builtin/worker_observation.rs index 325f89615..9b6d5488c 100644 --- a/crates/worker/src/feature/builtin/worker_observation.rs +++ b/crates/worker/src/feature/builtin/worker_observation.rs @@ -813,6 +813,7 @@ mod tests { _ => return None, }; Some(protocol::SessionSnapshotEntry { + use_policy: None, entry_id: format!("fake-{index:08}"), timestamp: index as u64, provenance: protocol::SessionEntryProvenance::LegacyUnknown, diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index 7ffc44b42..46400d52d 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -1499,6 +1499,7 @@ permission = "write" is_error: false, }, metadata: session_store::LoggedSessionHistoryMetadata { + system_input: None, entry_id: session_store::LoggedSessionHistoryEntryId(entry_id.into()), origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/worker/src/ipc/protocol_session.rs b/crates/worker/src/ipc/protocol_session.rs index 759c9c411..038770284 100644 --- a/crates/worker/src/ipc/protocol_session.rs +++ b/crates/worker/src/ipc/protocol_session.rs @@ -90,7 +90,7 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option { } LogEntry::AnnotatedSystemItem { entry, .. } => { let entry_id = Some(entry.metadata.entry_id.0.clone()); - let value = serde_json::to_value(entry.item.public_item()) + let value = serde_json::to_value(entry.public_item()) .expect("public system input is serializable"); Some(Event::SystemItem { entry_id, @@ -348,6 +348,7 @@ mod tests { prompt_provenance: None, }, metadata: session_store::LoggedSessionHistoryMetadata { + system_input: None, entry_id: entry_id.clone(), origin: session_store::LoggedSessionHistoryOrigin::BackendInstruction { operation_id: None, diff --git a/crates/worker/src/session_capture.rs b/crates/worker/src/session_capture.rs index c89570146..7e78a12fc 100644 --- a/crates/worker/src/session_capture.rs +++ b/crates/worker/src/session_capture.rs @@ -74,6 +74,7 @@ impl std::fmt::Display for SessionEntryRef { pub(crate) enum ReferenceKind { User, Assistant, + System, Tool, } @@ -82,6 +83,7 @@ impl ReferenceKind { match self { Self::User => "user", Self::Assistant => "assistant", + Self::System => "system", Self::Tool => "tool", } } @@ -90,6 +92,7 @@ impl ReferenceKind { match value { "user" => Some(Self::User), "assistant" | "agent" => Some(Self::Assistant), + "system" => Some(Self::System), "tool" => Some(Self::Tool), _ => None, } @@ -131,6 +134,7 @@ pub(crate) struct OverviewItem { #[derive(Debug, Clone)] pub(crate) struct ReferenceEntry { + use_policy: Option, pub id: SessionEntryRef, pub origin: SessionEntryProvenance, pub entry_range: [u64; 2], @@ -242,6 +246,7 @@ pub(crate) struct SessionEntryEvidence { #[derive(Debug, Clone)] struct CapturedHistoryEntry { + use_policy: Option, item: Item, entry_id: session_store::LoggedSessionHistoryEntryId, origin: SessionEntryProvenance, @@ -302,10 +307,12 @@ impl SessionCapture { is_error, attachments: _, } => Item::tool_result_item(call_id, summary, content, is_error), - // Observation deliberately excludes system items and - // controller errors from model-visible session evidence. - SessionSnapshotEntryData::SystemItem { .. } - | SessionSnapshotEntryData::Invoke { .. } + SessionSnapshotEntryData::SystemItem { content, .. } => { + entry.use_policy?; + Item::system_message(content) + } + // Controller errors are not model-visible evidence. + SessionSnapshotEntryData::Invoke { .. } | SessionSnapshotEntryData::Usage { .. } | SessionSnapshotEntryData::RunStats { .. } | SessionSnapshotEntryData::RunYielded { .. } @@ -314,6 +321,7 @@ impl SessionCapture { | SessionSnapshotEntryData::RunError { .. } => return None, }; Some(CapturedHistoryEntry { + use_policy: entry.use_policy, item, entry_id: session_store::LoggedSessionHistoryEntryId(entry.entry_id), origin: entry.provenance, @@ -328,6 +336,7 @@ impl SessionCapture { .into_iter() .enumerate() .map(|(index, item)| CapturedHistoryEntry { + use_policy: None, item, entry_id: session_store::LoggedSessionHistoryEntryId(format!("{index:08}")), origin: SessionEntryProvenance::LegacyUnknown, @@ -342,10 +351,21 @@ impl SessionCapture { ) -> Self { let entries = entries .into_iter() - .map(|entry| CapturedHistoryEntry { - item: entry.item, - entry_id: entry.annotation.entry_id, - origin: public_provenance(&entry.annotation.origin), + .map(|entry| { + let (item, use_policy) = if let Some(input) = &entry.annotation.system_input { + ( + Item::system_message(input.public_projection().body), + Some(input.public_use_policy()), + ) + } else { + (entry.item, None) + }; + CapturedHistoryEntry { + item, + use_policy, + entry_id: entry.annotation.entry_id, + origin: public_provenance(&entry.annotation.origin), + } }) .collect(); Self::from_captured_entries(segment_id, entries) @@ -364,7 +384,8 @@ impl SessionCapture { let entry_range = [idx as u64, idx as u64]; match item { Item::Message { role, content, .. } => { - let Some(kind) = message_reference_kind(&entry.origin, role) else { + let Some(kind) = message_reference_kind(&entry.origin, role, entry.use_policy) + else { continue; }; let text = content @@ -376,6 +397,7 @@ impl SessionCapture { let summary = truncate_chars(&text, 240); let id = SessionEntryRef::from_history_entry_id(&entry.entry_id); index.push(ReferenceEntry { + use_policy: entry.use_policy, id: id.clone(), origin: entry.origin.clone(), entry_range, @@ -386,7 +408,9 @@ impl SessionCapture { summary: summary.clone(), search_text: text.clone(), }); - if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) { + if entry.use_policy.is_none_or(|p| p.observation_readable) + && matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) + { overview.push(OverviewItem { id: id.clone(), origin: entry.origin.clone(), @@ -403,6 +427,7 @@ impl SessionCapture { } => { let text = format!("{name}\n{arguments}"); index.push(ReferenceEntry { + use_policy: entry.use_policy, id: SessionEntryRef::from_history_entry_id(&entry.entry_id), origin: entry.origin.clone(), entry_range, @@ -430,6 +455,7 @@ impl SessionCapture { content.as_deref().unwrap_or_default(), ); index.push(ReferenceEntry { + use_policy: entry.use_policy, id: SessionEntryRef::from_history_entry_id(&entry.entry_id), origin: entry.origin.clone(), entry_range, @@ -510,6 +536,9 @@ impl SessionCapture { let mut hits = Vec::new(); for entry in &self.index { + if entry.use_policy.is_some_and(|p| !p.observation_readable) { + continue; + } if entry.entry_range[0] < min_entry_index || entry.entry_range[0] > max_entry_index { continue; } @@ -581,6 +610,9 @@ impl SessionCapture { }; for entry in selected { + if entry.use_policy.is_some_and(|p| !p.observation_readable) { + continue; + } if entries.len() >= max_items { truncated = true; break; @@ -695,21 +727,11 @@ impl SessionCapture { pub(crate) fn evidence_for(&self, id: &str) -> Option { let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?; - let excerpt = self - .read( - ReadSelector::Id(id), - ReadOptions { - include_tools: true, - tool_part: ToolPart::Both, - detail: ReadDetail::Compact, - max_items: 1, - max_bytes: 2 * 1024, - }, - ) - .entries - .first() - .map(|entry| entry.text.clone()) - .unwrap_or_else(|| entry.summary.clone()); + if entry.use_policy.is_some_and(|p| !p.memory_evidence) { + return None; + } + let item = &self.entries.get(entry.entry_range[0] as usize)?.item; + let excerpt = render_item(item, entry, ReadDetail::Compact, 2 * 1024); Some(SessionEntryEvidence { segment_id: self.segment_id.clone(), entry_ref: entry.id.clone(), @@ -731,9 +753,8 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance } WorkerHistoryProvenance::WorkerInput { .. } => SessionEntryProvenance::WorkerInput, WorkerHistoryProvenance::FlowInstruction { .. } => SessionEntryProvenance::FlowInstruction, - WorkerHistoryProvenance::BackendInstruction { .. } => { - SessionEntryProvenance::BackendInstruction - } + WorkerHistoryProvenance::BackendInstruction { .. } + | WorkerHistoryProvenance::Provided { .. } => SessionEntryProvenance::BackendInstruction, WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput, WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput, WorkerHistoryProvenance::CompactionSummary @@ -746,7 +767,15 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance fn message_reference_kind( origin: &SessionEntryProvenance, provider_role: &Role, + use_policy: Option, ) -> Option { + if use_policy.is_some() { + return match provider_role { + Role::System => Some(ReferenceKind::System), + Role::User => Some(ReferenceKind::User), + Role::Assistant => Some(ReferenceKind::Assistant), + }; + } match origin { SessionEntryProvenance::HumanInput | SessionEntryProvenance::WorkerInput => { Some(ReferenceKind::User) @@ -950,6 +979,148 @@ mod tests { } } + #[test] + fn generic_public_input_observation_and_memory_policies_survive_store_restore_independently() { + use session_store::{ + FeatureInputPayload, FsStore, InputCompactionUse, InputEvidenceUse, + InputObservationUse, LogEntry, LoggedSystemHistoryEntry, PublicSystemItem, + PublicSystemPresentation, Store, SystemInputEnvelope, SystemInputMetadata, + SystemInputUsePolicy, + }; + let root = tempfile::tempdir().unwrap(); + let store = FsStore::new(root.path()).unwrap(); + let session = session_store::new_session_id(); + let segment = session_store::new_segment_id(); + store + .create_segment( + session, + segment, + &[LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id: session, + system_prompt: None, + config: Default::default(), + history: vec![], + forked_from: None, + compacted_from: None, + }], + ) + .unwrap(); + let mut inputs = Vec::new(); + for (producer, observation, memory_evidence, public) in [ + ( + "same", + InputObservationUse::Readable, + InputEvidenceUse::Exclude, + true, + ), + ( + "same", + InputObservationUse::Hidden, + InputEvidenceUse::Include, + true, + ), + ( + "renamed", + InputObservationUse::Readable, + InputEvidenceUse::Include, + true, + ), + ( + "renamed", + InputObservationUse::Hidden, + InputEvidenceUse::Exclude, + true, + ), + ( + "private", + InputObservationUse::Readable, + InputEvidenceUse::Include, + false, + ), + ] { + let entry = LoggedSystemHistoryEntry::from_input( + WorkerHistoryProvenance::Provided { + producer: producer.into(), + }, + None, + SystemInputEnvelope::new( + "private model text", + SystemInputMetadata::new( + SystemInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence, + observation, + compaction: InputCompactionUse::ReferenceInput, + }, + Some( + FeatureInputPayload::encode( + "future:owner", + 99, + &serde_json::json!({ "secret": "private metadata" }), + ) + .unwrap(), + ), + public.then(|| PublicSystemItem { + kind: "supplied-context".into(), + body: "public context: do not extract this".into(), + presentation: PublicSystemPresentation::Context, + }), + ) + .unwrap(), + ) + .unwrap(), + ); + store + .append( + session, + segment, + &LogEntry::AnnotatedSystemItem { + ts: 2, + entry: entry.clone(), + extensions: vec![], + }, + ) + .unwrap(); + inputs.push(entry); + } + let saved = store.read_all(session, segment).unwrap(); + let restored = + crate::session_history::restore_history_entries(session, segment, &saved).unwrap(); + let snapshot = session_store::public_snapshot::project_session_snapshot(session, &saved); + let snapshot: SessionSnapshot = + serde_json::from_value(serde_json::to_value(snapshot).unwrap()).unwrap(); + for capture in [ + SessionCapture::from_history_entries(segment.to_string(), restored), + SessionCapture::from_session_snapshot(segment.to_string(), snapshot), + ] { + let hits = capture.search(&SearchOptions { + kind: Some(ReferenceKind::System), + ..Default::default() + }); + assert_eq!(hits.len(), 2); + for (index, input) in inputs.iter().enumerate() { + let reference = SessionEntryRef::from_history_entry_id(&input.metadata.entry_id); + let read = + capture.read(ReadSelector::Id(reference.as_str()), ReadOptions::default()); + assert_eq!(!read.entries.is_empty(), matches!(index, 0 | 2)); + let evidence = capture.evidence_for(reference.as_str()); + assert_eq!(evidence.is_some(), matches!(index, 1 | 2)); + for text in read + .entries + .iter() + .map(|entry| entry.text.as_str()) + .chain(evidence.iter().map(|entry| entry.excerpt.as_str())) + { + assert!(text.contains("public context: do not extract this")); + assert!(!text.contains("private model text")); + assert!(!text.contains("private metadata")); + assert!(!text.contains("future:owner")); + } + } + } + } + #[test] fn overview_contains_user_and_assistant_only() { let view = SessionCapture::new( diff --git a/crates/worker/src/session_history.rs b/crates/worker/src/session_history.rs index d416ccef1..232a8c92d 100644 --- a/crates/worker/src/session_history.rs +++ b/crates/worker/src/session_history.rs @@ -24,6 +24,7 @@ pub(crate) fn test_logged_history_entry(item: impl Into) -> LoggedHistoryE LoggedHistoryEntry { item: session_store::LoggedItem::from(item.into()), metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -38,6 +39,7 @@ pub(crate) fn test_logged_system_entry( session_store::LoggedSystemHistoryEntry { item, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -58,6 +60,7 @@ pub(crate) fn metadata( derivation: Option, ) -> SessionHistoryMetadata { SessionHistoryMetadata { + system_input: None, entry_id: SessionHistoryEntryId::new(), origin, derivation, @@ -80,6 +83,7 @@ pub(crate) fn history_entry_with_id( HistoryEntry::new( item, SessionHistoryMetadata { + system_input: None, entry_id, origin, derivation: None, diff --git a/crates/worker/src/spawn/comm_tools.rs b/crates/worker/src/spawn/comm_tools.rs index 9fca770de..59ca3c167 100644 --- a/crates/worker/src/spawn/comm_tools.rs +++ b/crates/worker/src/spawn/comm_tools.rs @@ -77,6 +77,7 @@ mod tests { .into_iter() .enumerate() .map(|(index, value)| protocol::SessionSnapshotEntry { + use_policy: None, entry_id: format!("test-{index}"), timestamp: index as u64, provenance: protocol::SessionEntryProvenance::LegacyUnknown, diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index 05317f491..1d5b7dfb3 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -1338,6 +1338,7 @@ mod tests { is_error: false, }, metadata: session_store::LoggedSessionHistoryMetadata { + system_input: None, entry_id: session_store::LoggedSessionHistoryEntryId(entry_id.into()), origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 8c4a0849e..1f12478d3 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -5502,6 +5502,7 @@ mod tests { entry: session_store::LoggedHistoryEntry { item: session_store::LoggedItem::from(saved), metadata: session_store::LoggedSessionHistoryMetadata { + system_input: None, entry_id: session_store::LoggedSessionHistoryEntryId::new(), origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index 2aeb8f521..d72150e71 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -119,6 +119,7 @@ fn annotated(item: Item) -> session_store::LoggedHistoryEntry { session_store::LoggedHistoryEntry { item: session_store::LoggedItem::from(item), metadata: session_store::LoggedSessionHistoryMetadata { + system_input: None, entry_id: session_store::LoggedSessionHistoryEntryId::new(), origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index dd256fea9..4bdf8a7f6 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -44108,6 +44108,7 @@ mod tests { content: vec![LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId(id.to_string()), origin: LoggedSessionHistoryOrigin::HumanInput { account_id: "account-1".to_string(), @@ -44297,6 +44298,7 @@ mod tests { content: vec![LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + system_input: None, entry_id: LoggedSessionHistoryEntryId(id.to_string()), origin: LoggedSessionHistoryOrigin::HumanInput { account_id: "account-1".to_string(), diff --git a/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs index 8f83a29e0..fd0c89fea 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs @@ -479,6 +479,7 @@ fn seed_session( vec![session_store::LoggedHistoryEntry { item, metadata: session_store::LoggedSessionHistoryMetadata { + system_input: None, entry_id: session_store::LoggedSessionHistoryEntryId("read-source-original".into()), origin, derivation: None, @@ -1706,6 +1707,7 @@ impl SubjectRemovalFixture { .map(|text| session_store::LoggedHistoryEntry { item: message(text), metadata: session_store::LoggedSessionHistoryMetadata { + system_input: None, entry_id: session_store::LoggedSessionHistoryEntryId(text.into()), origin: session_store::LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, diff --git a/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs index abcc72632..efe44156f 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs @@ -67,6 +67,7 @@ impl Fixture { content: vec![session_store::LoggedContentPart::Text { text: text.into() }], }, metadata: session_store::LoggedSessionHistoryMetadata { + system_input: None, entry_id: session_store::LoggedSessionHistoryEntryId(format!("original-{index}")), origin: session_store::LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, diff --git a/web/workspace/src/lib/generated/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts index 2a2da1ea5..7b58a3b4b 100644 --- a/web/workspace/src/lib/generated/protocol-validator.ts +++ b/web/workspace/src/lib/generated/protocol-validator.ts @@ -21,7 +21,7 @@ const MAX_CORRELATION_ID_BYTES = 128; const MAX_RESOURCE_ID_BYTES = 256; const MAX_WORKER_IDS_PER_SELECTOR = 256; const MAX_REJECTION_MESSAGE_BYTES = 1024; -const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"id":{"description":"Stable only within the emitting live Worker; not durable alert history.","type":["string","null"]},"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"position":{"anyOf":[{"$ref":"#/$defs/AlertPosition"},{"type":"null"}]},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertPosition":{"description":"Display anchor captured at emission, resolved when pending text is committed.","oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"start","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"entry_id":{"type":"string"},"kind":{"const":"after_entry","type":"string"}},"required":["kind","entry_id"],"type":"object"},{"additionalProperties":false,"properties":{"call_id":{"type":"string"},"kind":{"const":"after_tool_call","type":"string"}},"required":["kind","call_id"],"type":"object"},{"additionalProperties":false,"properties":{"block_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"after_in_flight","type":"string"}},"required":["kind","block_id"],"type":"object"}]},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionContext":{"additionalProperties":false,"properties":{"argument":{"type":["string","null"]},"invocation":{"$ref":"#/$defs/FeatureInvocationIdentity"}},"required":["invocation"],"type":"object"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.","properties":{"description":{"type":["string","null"]},"invocation":{"anyOf":[{"$ref":"#/$defs/FeatureInvocationDescriptor"},{"type":"null"}]},"is_dir":{"default":false,"type":"boolean"},"usage":{"type":["string","null"]},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\n`File` is the existing `@` path lane. `Feature` lists installed chat\ninvocations. `FeatureArgument` resolves a declared argument using the\nsource-qualified [`CompletionContext`].","enum":["file","feature","feature_argument"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Complete FIFO replacement following enqueue, activation, cancel, or clear.\nClients replace their pending view in stream order; reconnect uses the snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the allowlisted JSON form of `session_store::PublicSystemItem`,\nnot the storage record. Its descriptive `kind` is independent from the\ntyped `presentation` used for notification, reminder, or context rendering.\nPrivate Feature payloads and prompt provenance are never event data.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Synthetic compatibility events omit it.","type":["string","null"]},"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"},"timestamp_ms":{"description":"Timestamp of the persisted Invoke, not the client's attach time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"timestamp_ms":{"description":"Present for usage delivered from the committed session-log lane.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Authoritative current-context occupancy after a non-request boundary.\n\nUnlike [`Event::Usage`], this is not cumulative request traffic. It lets\nconnected clients converge with reconnect snapshots after run completion,\ncompaction, rewind, and other history rewrites. `None` explicitly means\nthe current value is unavailable and stale occupancy must be cleared.","properties":{"data":{"additionalProperties":false,"properties":{"usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}]}},"type":"object"},"event":{"const":"context_usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity. Apply in arrival order on the parent's serialized stream; a\nreconnect snapshot replaces the projection before live events resume.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"default":"","type":"string"},"request_id":{"type":["string","null"]}},"required":["kind","entries","prefix"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"FeatureInvocation":{"additionalProperties":false,"properties":{"arguments":{"items":{"$ref":"#/$defs/InvocationArgumentValue"},"type":"array"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"invocation_id":{"description":"Stable client-generated id retained across queueing and transport retry.","type":"string"},"name":{"description":"Selected public name, retained for exact draft/history restoration. Host\nresolution is always by `identity`.","type":"string"}},"required":["invocation_id","identity","name","arguments"],"type":"object"},"FeatureInvocationDescriptor":{"additionalProperties":false,"properties":{"aliases":{"default":[],"items":{"type":"string"},"type":"array"},"arguments":{"default":[],"items":{"$ref":"#/$defs/InvocationArgumentDescriptor"},"type":"array"},"client_adapter":{"anyOf":[{"$ref":"#/$defs/InvocationClientAdapter"},{"type":"null"}]},"description":{"type":"string"},"display_name":{"type":"string"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"name":{"type":"string"},"syntax":{"$ref":"#/$defs/FeatureInvocationSyntax"}},"required":["identity","name","display_name","description","syntax","aliases","arguments"],"type":"object"},"FeatureInvocationIdentity":{"description":"Source-qualified identity of one invocation contribution, for example\n`builtin:attachments/attach`. Display names and aliases are never authority.","type":"string"},"FeatureInvocationSyntax":{"oneOf":[{"const":"parenthesized","description":"`/name(value, key=value)` with JSON-style quoted strings and `)` as the\nexplicit boundary before following natural language.","type":"string"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"block_ids":{"description":"Live-Worker identities parallel to the blocks actually included below.","items":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"type":"array"},"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The parent serializes\nsnapshot publication and subsequent live events on the same ordered stream.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","session","status"],"type":"object"},"InvocationArgumentDescriptor":{"additionalProperties":false,"properties":{"completion":{"$ref":"#/$defs/InvocationCompletion","default":{"kind":"none"}},"description":{"type":["string","null"]},"name":{"type":"string"},"position":{"description":"Zero-based positional index. `None` means named-only. A positional\nargument may also be supplied by name; this makes chip editing stable.","format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"required":{"default":false,"type":"boolean"},"value_type":{"$ref":"#/$defs/InvocationArgumentType"}},"required":["name","value_type","completion","required"],"type":"object"},"InvocationArgumentType":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"enum","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"description":"A Worker-readable path. This does not grant filesystem authority.","properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"A resource selected and staged by the client. It must never be resolved\nas a path on the Worker host.","properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"}]},"InvocationArgumentValue":{"additionalProperties":false,"properties":{"name":{"type":"string"},"value":{"$ref":"#/$defs/InvocationValue"}},"required":["name","value"],"type":"object"},"InvocationClientAdapter":{"oneOf":[{"const":"attachment","description":"The client stages a local file and replaces the draft invocation with an\n`UploadedFile` chip. Local paths are not sent to or persisted by Worker.","type":"string"}]},"InvocationCompletion":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"none","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"static","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"Host-owned provider selected by source-qualified id. Metadata lookup is\nside-effect free; implementations never cross this wire boundary.","properties":{"kind":{"const":"provider","type":"string"},"provider":{"type":"string"}},"required":["kind","provider"],"type":"object"}]},"InvocationValue":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"},"value":{"type":"string"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"},"value":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"},"value":{"type":"boolean"}},"required":["kind","value"],"type":"object"}]},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"submission_id":{"type":"string"}},"required":["submission_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove exactly the observed ordered set of submissions, preserving the\nactive run. Reject if the FIFO content changed before admission.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_submission_ids":{"items":{"type":"string"},"type":"array"}},"required":["expected_submission_ids"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"}},"required":["expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Rewind to just before a user input identified by its public Session entry ID.\nValidate the target against the current segment at execution time.\nLater appended history is discarded too; tool side effects are not undone.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"entry_id":{"type":"string"}},"required":["entry_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"},"request_id":{"description":"Client query identity. Echoed verbatim so identical-prefix ABA\nresponses cannot cross edits, target switches, or permission changes.","type":["string","null"]}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Session-owned FIFO content projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Explicitly selected, source-qualified Feature invocation. The host never\npromotes free text to this variant and validates it against the installed\ndeclaration before execution.","properties":{"invocation":{"$ref":"#/$defs/FeatureInvocation"},"kind":{"const":"feature_invoke","type":"string"}},"required":["kind","invocation"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Snapshot-only replay of persisted invocation/usage records. These are\naccounting metadata, never model context or conversation rows.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"invoke","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/InvokeKind"}},"required":["kind","trigger","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"usage","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","input_tokens","cache_read_input_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Display-only accounting reconstructed from persisted run/usage records.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_stats","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"requests":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"upload_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","elapsed_ms","requests","upload_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"attachment_id":{"description":"Stable, bounded identity derived from the owning Session entry, attachment\nposition, and immutable image bytes. It is a locator, not a bearer secret.","type":"string"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"type":"string"}},"required":["attachment_id","media_type","byte_len"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"restore_observation_token":{"description":"Opaque Runtime-owned Restore observation identity, never a display-state hash.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nCommands operate on the controller state at admission time, not a caller's\npreviously observed Run. A controller accepts command ids in strictly\nincreasing order; retrying an accepted id cannot apply it to a later Run.\nExact retries must retain the same command kind. Removed preconditions are\nrejected at the wire boundary rather than treated as arrival-time commands.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"last_finished_submission_request_id":{"description":"Most recent Submit request whose controller Run has returned and whose\npost-Run state transition has been published. This is a teardown fence,\nnot a successful-result claim; cancelled and failed Runs may also finish.","type":["string","null"]},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; +const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"id":{"description":"Stable only within the emitting live Worker; not durable alert history.","type":["string","null"]},"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"position":{"anyOf":[{"$ref":"#/$defs/AlertPosition"},{"type":"null"}]},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertPosition":{"description":"Display anchor captured at emission, resolved when pending text is committed.","oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"start","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"entry_id":{"type":"string"},"kind":{"const":"after_entry","type":"string"}},"required":["kind","entry_id"],"type":"object"},{"additionalProperties":false,"properties":{"call_id":{"type":"string"},"kind":{"const":"after_tool_call","type":"string"}},"required":["kind","call_id"],"type":"object"},{"additionalProperties":false,"properties":{"block_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"after_in_flight","type":"string"}},"required":["kind","block_id"],"type":"object"}]},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionContext":{"additionalProperties":false,"properties":{"argument":{"type":["string","null"]},"invocation":{"$ref":"#/$defs/FeatureInvocationIdentity"}},"required":["invocation"],"type":"object"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.","properties":{"description":{"type":["string","null"]},"invocation":{"anyOf":[{"$ref":"#/$defs/FeatureInvocationDescriptor"},{"type":"null"}]},"is_dir":{"default":false,"type":"boolean"},"usage":{"type":["string","null"]},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\n`File` is the existing `@` path lane. `Feature` lists installed chat\ninvocations. `FeatureArgument` resolves a declared argument using the\nsource-qualified [`CompletionContext`].","enum":["file","feature","feature_argument"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Complete FIFO replacement following enqueue, activation, cancel, or clear.\nClients replace their pending view in stream order; reconnect uses the snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the allowlisted JSON form of `session_store::PublicSystemItem`,\nnot the storage record. Its descriptive `kind` is independent from the\ntyped `presentation` used for notification, reminder, or context rendering.\nPrivate Feature payloads and prompt provenance are never event data.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Synthetic compatibility events omit it.","type":["string","null"]},"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"},"timestamp_ms":{"description":"Timestamp of the persisted Invoke, not the client's attach time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"timestamp_ms":{"description":"Present for usage delivered from the committed session-log lane.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Authoritative current-context occupancy after a non-request boundary.\n\nUnlike [`Event::Usage`], this is not cumulative request traffic. It lets\nconnected clients converge with reconnect snapshots after run completion,\ncompaction, rewind, and other history rewrites. `None` explicitly means\nthe current value is unavailable and stale occupancy must be cleared.","properties":{"data":{"additionalProperties":false,"properties":{"usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}]}},"type":"object"},"event":{"const":"context_usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity. Apply in arrival order on the parent's serialized stream; a\nreconnect snapshot replaces the projection before live events resume.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"default":"","type":"string"},"request_id":{"type":["string","null"]}},"required":["kind","entries","prefix"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"FeatureInvocation":{"additionalProperties":false,"properties":{"arguments":{"items":{"$ref":"#/$defs/InvocationArgumentValue"},"type":"array"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"invocation_id":{"description":"Stable client-generated id retained across queueing and transport retry.","type":"string"},"name":{"description":"Selected public name, retained for exact draft/history restoration. Host\nresolution is always by `identity`.","type":"string"}},"required":["invocation_id","identity","name","arguments"],"type":"object"},"FeatureInvocationDescriptor":{"additionalProperties":false,"properties":{"aliases":{"default":[],"items":{"type":"string"},"type":"array"},"arguments":{"default":[],"items":{"$ref":"#/$defs/InvocationArgumentDescriptor"},"type":"array"},"client_adapter":{"anyOf":[{"$ref":"#/$defs/InvocationClientAdapter"},{"type":"null"}]},"description":{"type":"string"},"display_name":{"type":"string"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"name":{"type":"string"},"syntax":{"$ref":"#/$defs/FeatureInvocationSyntax"}},"required":["identity","name","display_name","description","syntax","aliases","arguments"],"type":"object"},"FeatureInvocationIdentity":{"description":"Source-qualified identity of one invocation contribution, for example\n`builtin:attachments/attach`. Display names and aliases are never authority.","type":"string"},"FeatureInvocationSyntax":{"oneOf":[{"const":"parenthesized","description":"`/name(value, key=value)` with JSON-style quoted strings and `)` as the\nexplicit boundary before following natural language.","type":"string"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"block_ids":{"description":"Live-Worker identities parallel to the blocks actually included below.","items":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"type":"array"},"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The parent serializes\nsnapshot publication and subsequent live events on the same ordered stream.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","session","status"],"type":"object"},"InvocationArgumentDescriptor":{"additionalProperties":false,"properties":{"completion":{"$ref":"#/$defs/InvocationCompletion","default":{"kind":"none"}},"description":{"type":["string","null"]},"name":{"type":"string"},"position":{"description":"Zero-based positional index. `None` means named-only. A positional\nargument may also be supplied by name; this makes chip editing stable.","format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"required":{"default":false,"type":"boolean"},"value_type":{"$ref":"#/$defs/InvocationArgumentType"}},"required":["name","value_type","completion","required"],"type":"object"},"InvocationArgumentType":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"enum","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"description":"A Worker-readable path. This does not grant filesystem authority.","properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"A resource selected and staged by the client. It must never be resolved\nas a path on the Worker host.","properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"}]},"InvocationArgumentValue":{"additionalProperties":false,"properties":{"name":{"type":"string"},"value":{"$ref":"#/$defs/InvocationValue"}},"required":["name","value"],"type":"object"},"InvocationClientAdapter":{"oneOf":[{"const":"attachment","description":"The client stages a local file and replaces the draft invocation with an\n`UploadedFile` chip. Local paths are not sent to or persisted by Worker.","type":"string"}]},"InvocationCompletion":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"none","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"static","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"Host-owned provider selected by source-qualified id. Metadata lookup is\nside-effect free; implementations never cross this wire boundary.","properties":{"kind":{"const":"provider","type":"string"},"provider":{"type":"string"}},"required":["kind","provider"],"type":"object"}]},"InvocationValue":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"},"value":{"type":"string"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"},"value":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"},"value":{"type":"boolean"}},"required":["kind","value"],"type":"object"}]},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"submission_id":{"type":"string"}},"required":["submission_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove exactly the observed ordered set of submissions, preserving the\nactive run. Reject if the FIFO content changed before admission.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_submission_ids":{"items":{"type":"string"},"type":"array"}},"required":["expected_submission_ids"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"}},"required":["expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Rewind to just before a user input identified by its public Session entry ID.\nValidate the target against the current segment at execution time.\nLater appended history is discarded too; tool side effects are not undone.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"entry_id":{"type":"string"}},"required":["entry_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"},"request_id":{"description":"Client query identity. Echoed verbatim so identical-prefix ABA\nresponses cannot cross edits, target switches, or permission changes.","type":["string","null"]}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Session-owned FIFO content projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Explicitly selected, source-qualified Feature invocation. The host never\npromotes free text to this variant and validates it against the installed\ndeclaration before execution.","properties":{"invocation":{"$ref":"#/$defs/FeatureInvocation"},"kind":{"const":"feature_invoke","type":"string"}},"required":["kind","invocation"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionEntryUsePolicy":{"additionalProperties":false,"description":"Public use decisions contain no Feature identity or private payload. Absence\ndenotes the older projection contract, not permission to expose System input.","properties":{"memory_evidence":{"type":"boolean"},"observation_readable":{"type":"boolean"}},"required":["observation_readable","memory_evidence"],"type":"object"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Snapshot-only replay of persisted invocation/usage records. These are\naccounting metadata, never model context or conversation rows.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"invoke","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/InvokeKind"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","trigger","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"usage","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","input_tokens","cache_read_input_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Display-only accounting reconstructed from persisted run/usage records.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_stats","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"requests":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"upload_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","elapsed_ms","requests","upload_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"attachment_id":{"description":"Stable, bounded identity derived from the owning Session entry, attachment\nposition, and immutable image bytes. It is a locator, not a bearer secret.","type":"string"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"type":"string"}},"required":["attachment_id","media_type","byte_len"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"restore_observation_token":{"description":"Opaque Runtime-owned Restore observation identity, never a display-state hash.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nCommands operate on the controller state at admission time, not a caller's\npreviously observed Run. A controller accepts command ids in strictly\nincreasing order; retrying an accepted id cannot apply it to a later Run.\nExact retries must retain the same command kind. Removed preconditions are\nrejected at the wire boundary rather than treated as arrival-time commands.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"last_finished_submission_request_id":{"description":"Most recent Submit request whose controller Run has returned and whose\npost-Run state transition has been published. This is a teardown fence,\nnot a successful-result claim; cancelled and failed Runs may also finish.","type":["string","null"]},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = [{"protocol_version":2,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot":{"topic":"workers","data":{"workers":[{"restore_observation_token":"fixture-restore-observation","worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"notification_count":0,"head_id":null,"submissions":[]}}}}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"snapshot","data":{"session":{"pending_submissions":{"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"state":{"last_command_id":0,"state":{"kind":"idle"}},"internal_workers":[{"worker":{"session_id":"fixture-child-session","name":"fixture-child","parent_session_id":"fixture-parent-session","kind":"sub_worker"},"session":{"pending_submissions":{"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"status":"idle"}]}}}}}}}]; const utf8 = new TextEncoder(); diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 8b6870f39..8223baf07 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -206,7 +206,7 @@ entry_id: string, /** * Timestamp copied from the durable log record that commits this entry. */ -timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array, } & ({ "kind": "user_input", segments: Array, } | { "kind": "message", role: SessionMessageRole, content: Array, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_yielded", reason: RunYieldReason, active_run_turn_count: number, } | { "kind": "run_resumed", source: RunResumeSource, active_run_turn_count: number, } | { "kind": "invoke", trigger: InvokeKind, } | { "kind": "usage", input_tokens: number, cache_read_input_tokens: number, output_tokens: number, } | { "kind": "run_stats", elapsed_ms: number, requests: number, upload_tokens: number, output_tokens: number, } | { "kind": "run_cancelled" } | { "kind": "run_error", message: string, failure?: RunFailureKind | null, }); +timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array, use_policy?: SessionEntryUsePolicy | null, } & ({ "kind": "user_input", segments: Array, } | { "kind": "message", role: SessionMessageRole, content: Array, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_yielded", reason: RunYieldReason, active_run_turn_count: number, } | { "kind": "run_resumed", source: RunResumeSource, active_run_turn_count: number, } | { "kind": "invoke", trigger: InvokeKind, } | { "kind": "usage", input_tokens: number, cache_read_input_tokens: number, output_tokens: number, } | { "kind": "run_stats", elapsed_ms: number, requests: number, upload_tokens: number, output_tokens: number, } | { "kind": "run_cancelled" } | { "kind": "run_error", message: string, failure?: RunFailureKind | null, }); export type SessionConversationTurn = { turn_id: string, entries: Array, }; From 47b32ef6abb8ba8bbd43c837710681ae07d73683 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 02:53:21 +0900 Subject: [PATCH 12/29] fix: bind capability changes to Object freshness and update contract consumers --- crates/worker/src/checkout.rs | 15 +- crates/worker/src/checkout_http_tests.rs | 41 +++ crates/worker/src/checkout_wip_tests.rs | 296 ++++++++++++++++++ crates/worker/src/controller.rs | 9 +- crates/worker/src/file_operation.rs | 69 +++- .../src/server/tests/drive_api_tests.rs | 104 ++++++ .../src/server/tests/worker_drive_tests.rs | 187 ++++++++++- .../src/server_workspace_config_wip_tests.rs | 2 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 12 files changed, 711 insertions(+), 20 deletions(-) diff --git a/crates/worker/src/checkout.rs b/crates/worker/src/checkout.rs index c84afae30..74666982a 100644 --- a/crates/worker/src/checkout.rs +++ b/crates/worker/src/checkout.rs @@ -122,6 +122,7 @@ impl Provider { generation: u64, workdir: &str, target: &WorkdirPath, + capabilities: workdir::WorkdirSessionCapabilities, raw: &[u8], ) -> Vec { let mut hash = Sha256::new(); @@ -131,6 +132,7 @@ impl Provider { &generation.to_be_bytes(), workdir.as_bytes(), target.as_str().as_bytes(), + format!("{capabilities:?}").as_bytes(), raw, ] { hash.update(value.len().to_be_bytes()); @@ -295,8 +297,14 @@ impl Provider { "directory" }; let primary = *contracts.first()?; - let validator = - self.wrap_validator(alias, generation, workdir, &target, &observation.validator); + let validator = self.wrap_validator( + alias, + generation, + workdir, + &target, + observation.capabilities, + &observation.validator, + ); let handler = Arc::new(FileHandler { provider: self.clone(), alias: alias.into(), @@ -525,6 +533,7 @@ impl FileHandler { self.generation, &self.workdir, &self.target, + self.observation.capabilities, &raw, ), )) @@ -583,6 +592,7 @@ impl WipOperationHandler for FileHandler { self.generation, &self.workdir, &self.target, + self.observation.capabilities, &raw, ), )); @@ -644,6 +654,7 @@ impl WipOperationHandler for FileHandler { self.generation, &self.workdir, &self.target, + self.observation.capabilities, &raw, ), ), diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index b3d85bfc6..a7871239b 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -800,3 +800,44 @@ async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_wit "refusal was retried" ); } + +#[tokio::test] +async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundaries() { + use base64::Engine as _; + let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await; + let remote = Arc::new(fixture.open(TOKEN).await.unwrap()); + let (runtime, _) = native_runtime(remote); + let path = "/checkouts/main/src/deep/a.txt"; + observe_interface(&runtime, path).await; + let output = runtime + .invoke( + path.into(), + crate::file_operation::Contract::ReadBytes.reference(), + "read".into(), + json!({"offset":1,"limit":3}), + Default::default(), + ) + .await + .unwrap(); + let json: Json = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(json["encoding"], "base64"); + assert_eq!(json["offset"], 1); + assert_eq!(json["next_offset"], 4); + let source = fixture + .state + .session + .read_bytes(workdir::ReadBytesRequest { + path: WorkdirPath::new("src/deep/a.txt").unwrap(), + offset: 1, + max_bytes: 3, + expected_hash: None, + }) + .await + .unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(json["data"].as_str().unwrap()) + .unwrap(), + source.bytes + ); +} diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs index dd3da478c..9460924f1 100644 --- a/crates/worker/src/checkout_wip_tests.rs +++ b/crates/worker/src/checkout_wip_tests.rs @@ -1240,3 +1240,299 @@ async fn shared_readonly_contract_rejects_wrong_membership_operation_and_interfa "雪.*\r\nother\n" ); } + +// The fixture substitutes only a binary source. Real Host/Client/model tools, fixed common +// descriptors, membership, validators and output decoding stay on their production path. +struct ReadonlyBytes { + bytes: Arc>, + version: Arc, + captured: u64, +} +impl ReadonlyBytes { + fn projection(&self) -> WipProjection { + let contract = crate::file_operation::Contract::ReadBytes; + WipProjection { + route: "/virtual".into(), + capability: "virtual:bytes".into(), + kind: WipProjectionKind::Native, + object: Object { + name: "virtual".into(), + description: None, + interfaces: vec![contract.reference()], + r#ref: None, + validator: Some(self.captured.to_be_bytes().to_vec()), + }, + interface: contract.reference(), + descriptor: contract.descriptor(), + interface_validator: Some(contract.validator()), + handler: Arc::new(Self { + bytes: self.bytes.clone(), + version: self.version.clone(), + captured: self.captured, + }), + } + } +} +#[async_trait] +impl WipOperationHandler for ReadonlyBytes { + async fn call( + &self, + op: &str, + args: &BTreeMap, + _: WipCallContext, + ) -> Result { + if op != "read_bytes" { + return Err(WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::OperationNotFound, + "readonly bytes", + ))); + } + if self.captured != self.version.load(Ordering::SeqCst) { + return Err(WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::ValidatorMismatch, + "virtual source replaced", + ))); + } + let (offset, limit) = crate::file_operation::byte_range(args)?; + if offset > self.bytes.len() as u64 { + return Err(WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::InvalidArguments, + "past EOF", + ))); + } + let end = (offset as usize + limit).min(self.bytes.len()); + Ok(WipOperationOutput::native( + crate::file_operation::bytes_result( + args, + &self.bytes[offset as usize..end], + end == self.bytes.len(), + )?, + )) + } +} +#[async_trait] +impl WipSubtreeProvider for ReadonlyBytes { + async fn publication(&self, path: &str) -> Result, ProtocolError> { + Ok((path == "/virtual").then(|| { + let current = Self { + bytes: self.bytes.clone(), + version: self.version.clone(), + captured: self.version.load(Ordering::SeqCst), + }; + WipPublication { + projection: current.projection(), + scope: None, + } + })) + } + async fn children(&self, _: &str) -> Result, ProtocolError> { + Ok(vec![]) + } +} +#[tokio::test] +async fn common_bytes_read_long_lines_invalid_utf8_arbitrary_boundaries_and_eof_without_write_authority() + { + use base64::Engine as _; + let mut bytes = "雪".as_bytes().to_vec(); + bytes.extend_from_slice(&[0xff, 0, 0xc0]); + bytes.extend(vec![b'x'; 100_000]); + let bytes = Arc::new(bytes); + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("raw"), bytes.as_ref()).unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, true), + ) + .unwrap(); + let source = Arc::new(ReadonlyBytes { + bytes: bytes.clone(), + version: Arc::new(std::sync::atomic::AtomicU64::new(1)), + captured: 1, + }); + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), None).unwrap(); + registry.allocate_namespace("virtual", "virtual").unwrap(); + registry.mount(source.projection()).unwrap(); + registry + .mount_subtree(WipSubtreeMount { + root: "/virtual".into(), + provider: source.clone(), + }) + .unwrap(); + let r = Arc::new(WipRuntime::from_mounts(registry, "bytes-test".into()).unwrap()); + let virtual_inspect = model_inspect(r.clone(), "/virtual").await; + let reference = virtual_inspect["interfaces"][0]["reference"].clone(); + let checkout = model_inspect(r.clone(), "/checkouts/main/raw").await; + assert!( + checkout["interfaces"] + .as_array() + .unwrap() + .iter() + .any(|i| i["reference"] == reference) + ); + for path in ["/virtual", "/checkouts/main/raw"] { + for offset in [0, 1, 2, 3, 5, 80_000, bytes.len()] { + let output=json_content(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":{"offset":offset,"limit":17}})).await.unwrap()); + let end = (offset + 17).min(bytes.len()); + assert_eq!(output["encoding"], "base64"); + assert_eq!(output["offset"], offset); + assert_eq!(output["next_offset"], end); + assert_eq!(output["eof"], end == bytes.len()); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(output["data"].as_str().unwrap()) + .unwrap(), + bytes[offset..end] + ); + } + for arguments in [ + json!({"limit":0}), + json!({"limit":32769}), + json!({"offset":-1}), + json!({"offset":9_007_199_254_740_991i64}), + json!({"offset":bytes.len()+1}), + json!({"file_path":"other"}), + ] { + assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":arguments})).await.is_err()); + } + assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":{"scope":"/","name":"yoi.file/write-text/v1"},"operation":"write","arguments":{"content":"lost"}})).await.is_err()); + } + let fetched = r + .host + .fetch_interface_live(&crate::file_operation::Contract::ReadBytes.reference()) + .await + .unwrap(); + let mut request = observed_common_request( + "/virtual", + Some(1u64.to_be_bytes().to_vec()), + &fetched, + "read", + ); + source.version.store(2, Ordering::SeqCst); + assert!( + matches!(r.host.call(request.clone(),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch) + ); + request.target.validator = Some(2u64.to_be_bytes().to_vec()); + assert!(r.host.call(request, file_context()).await.is_ok()); + assert_eq!( + std::fs::read(dir.path().join("raw")).unwrap(), + bytes.as_ref().clone() + ); +} + +#[tokio::test] +async fn scope_downgrade_changes_object_membership_not_common_contract_and_read_remains_available() +{ + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a"), "same").unwrap(); + let scope = manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()); + let session = Arc::new(LocalWorkdirSession::materialized( + dir.path().into(), + dir.path().into(), + scope.clone(), + WorkdirSessionCapabilities::READ_WRITE, + )); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), session) + .unwrap(); + let r = Arc::new(runtime(router, None)); + let path = "/checkouts/main/a"; + let old = model_inspect(r.clone(), path).await; + let current = r.host.projection_live(path).await.unwrap().unwrap(); + let definition = r + .host + .fetch_interface_live(&contract("read").reference()) + .await + .unwrap(); + scope + .update(|_| { + manifest::Scope::from_config(&manifest::ScopeConfig { + allow: vec![manifest::ScopeRule { + target: dir.path().into(), + permission: manifest::Permission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }], + deny: vec![], + }) + }) + .unwrap(); + assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":contract_json("write"),"operation":"write","arguments":{"content":"lost"}})).await.is_err()); + let new = json_content( + model_operation(r.clone(), "Inspect", json!({"path":path,"refresh":true})) + .await + .unwrap(), + ); + assert_ne!(old["object_signature"], new["object_signature"]); + assert!( + !new["interfaces"] + .as_array() + .unwrap() + .iter() + .any(|i| i["reference"]["name"] == "yoi.file/write-text/v1") + ); + assert_eq!( + r.host + .fetch_interface_live(&contract("read").reference()) + .await + .unwrap() + .validator, + definition.validator + ); + assert!( + matches!(r.host.call(observed_common_request(path,current.object.validator,&definition,"read"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch) + ); + assert_eq!( + json_content( + model_invoke(r.clone(), &new, "read", json!({})) + .await + .unwrap() + )["content"], + " 1\tsame\n" + ); +} +fn contract_json(operation: &str) -> Json { + let r = contract(operation).reference(); + json!({"scope":r.scope,"name":r.name}) +} + +#[test] +fn common_contract_registration_rejects_descriptor_redefinition_and_domain_operation_injection() { + let source = ReadonlyBytes { + bytes: Arc::new(vec![]), + version: Arc::new(std::sync::atomic::AtomicU64::new(1)), + captured: 1, + }; + let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("virtual", "virtual").unwrap(); + let mut invalid = source.projection(); + invalid.descriptor.operations[0] + .parameters + .push(ParameterDeclaration { + name: "target_override".into(), + required: false, + documentation: None, + r#type: TypeExpr::String, + }); + assert!(matches!( + registry.mount(invalid), + Err(WipMountError::InvalidProjection { .. }) + )); + let projection = source.projection(); + registry.mount(projection.clone()).unwrap(); + let mut extension = projection.descriptor.clone(); + extension.operations[0].name = "erase".into(); + assert!(matches!( + registry.contribute_operations(WipOperationContribution { + route: "/virtual".into(), + contributor: "other".into(), + interface: projection.interface, + descriptor: extension, + handler: projection.handler + }), + Err(WipMountError::InterfaceCollision { .. }) + )); +} diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index d8be6da1a..994a8f665 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -4139,7 +4139,14 @@ mod tests { request: crate::worker::WorkspaceRequest, ) -> Result { - // Native discovery must not enumerate or perform any mutation. + // Native discovery reads metadata and the current caller's ceiling, never inventory or mutations. + assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Get); + if request.path == "/api/w/workspace-1/drive/capabilities" { + return Ok(crate::worker::WorkspaceResponse { + status: 200, + body: serde_json::json!({"write":true}).to_string(), + }); + } assert_eq!(request.path, "/api/w/workspace-1/drive/root"); Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({ "entry": {"workspace_id": "workspace-1", "node_id": "1"}, diff --git a/crates/worker/src/file_operation.rs b/crates/worker/src/file_operation.rs index 308647f5c..00f91eb08 100644 --- a/crates/worker/src/file_operation.rs +++ b/crates/worker/src/file_operation.rs @@ -1,5 +1,15 @@ -//! WIP presentation of the provider-neutral file argument contracts. Providers -//! choose which read surface to publish; routing and preconditions are not args. +//! Stable root file contracts, independent of provider and target lifetimes. +//! +//! Each capability has one versioned reference and complete descriptor. Providers publish only +//! supported combinations. Root scope is a namespace, never a file grant. Current Object +//! membership selects the provider handler; target observations, checked operations, prior Read, +//! CAS and grants remain provider-owned. Contract validators contain no target or connection state. +//! +//! Line windows, byte ranges and whole-text domain reads are different meanings, not aliases. +//! Settings atomic commits and Drive management/cursor pagination remain domain extensions. +//! Common mutations return one metadata envelope while providers retain their commit details. +//! Old contextual references in history are neither rewritten nor accepted as aliases; callers +//! reobserve Objects and common definitions after migration or restore. use fs_operation::text::TextOperation; use wip_protocol::{ParameterDeclaration, TypeExpr}; @@ -185,12 +195,37 @@ impl Contract { mutation_type(), ), }; - InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),types:vec![],documentation:Some(Documentation {summary:summary.into(),details:Some(match self { - Self::ReadBytes=>"Binary-safe byte range, independent of UTF-8 and line boundaries. Offset defaults to 0; limit defaults to 16384 and must be 1..=32768 bytes. Offset must be <=9007199254708223. Returns encoding=base64 with RFC4648 standard padded data, offset and next_offset in bytes, eof when no later bytes remain. At EOF returns empty data, eof=true and next_offset=offset; offsets beyond EOF are InvalidArguments. Encoded pretty JSON result is bounded to 64 KiB. Does not establish text prior-Read for mutation. InvalidArguments rejects ranges/unknown fields, ResourceLimitExceeded bounds, ValidatorMismatch stale source, PermissionDenied authority. Provider source bounds apply; no automatic retry.", - Self::ReadLines|Self::SearchText=>"UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry.", - Self::Edit|Self::Write|Self::CreateFile=>"UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry.", - _=>"Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry.", - }.into())}),operations:vec![OperationDeclaration {name:self.operation().into(),documentation:None,parameters,returns:ReturnDeclaration {documentation:None,r#type:result}}]} + let details = match self { + Self::ReadBytes => { + "Binary-safe byte range, independent of UTF-8 and line boundaries. Offset defaults to 0; limit defaults to 16384 and must be 1..=32768 bytes. Offset must be <=9007199254708223. Returns encoding=base64 with RFC4648 standard padded data, offset and next_offset in bytes, eof when no later bytes remain. At EOF returns empty data, eof=true and next_offset=offset; offsets beyond EOF are InvalidArguments. Encoded pretty JSON result is bounded to 64 KiB. Does not establish text prior-Read for mutation. InvalidArguments rejects ranges/unknown fields, ResourceLimitExceeded bounds, ValidatorMismatch stale source, PermissionDenied authority. Provider source bounds apply; no automatic retry." + } + Self::ReadLines | Self::SearchText => { + "UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry." + } + Self::Edit | Self::Write | Self::CreateFile => { + "UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry." + } + _ => { + "Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry." + } + }; + InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + types: vec![], + documentation: Some(Documentation { + summary: summary.into(), + details: Some(details.into()), + }), + operations: vec![OperationDeclaration { + name: self.operation().into(), + documentation: None, + parameters, + returns: ReturnDeclaration { + documentation: None, + r#type: result, + }, + }], + } } pub(crate) fn validator(self) -> Vec { Sha256::digest(format!("{:?}", self.descriptor())).to_vec() @@ -646,3 +681,21 @@ pub(crate) fn bytes_result( } Ok(value) } + +#[cfg(test)] +mod byte_tests { + use super::*; + #[test] + fn maximum_raw_byte_page_reserves_base64_and_complete_json_budget() { + let args = BTreeMap::from([("limit".into(), Value::Integer(32768))]); + let result = bytes_result(&args, &vec![0xff; 32768], false) + .unwrap_or_else(|_| panic!("valid bounded bytes")); + let json = crate::wip::wip_to_json(&result).unwrap(); + assert!(serde_json::to_vec_pretty(&json).unwrap().len() <= MAX_BYTES); + assert_eq!(json["next_offset"], 32768); + assert_eq!(json["eof"], false); + assert!( + matches!(bytes_result(&args,&vec![0;32769],false),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::Internal) + ); + } +} diff --git a/crates/workspace-server/src/server/tests/drive_api_tests.rs b/crates/workspace-server/src/server/tests/drive_api_tests.rs index a7ef41151..4b994fbd5 100644 --- a/crates/workspace-server/src/server/tests/drive_api_tests.rs +++ b/crates/workspace-server/src/server/tests/drive_api_tests.rs @@ -777,6 +777,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id); let routes = [ format!("{}/root", base()), + format!("{}/capabilities", base()), format!( "{}/metadata?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}", base() @@ -1007,3 +1008,106 @@ async fn drive_download_stream_expires_instead_of_mixing_last_mutation_ids() { "next chunk must expire, not deliver generation b" ); } + +#[tokio::test] +async fn drive_capabilities_expose_only_authenticated_current_caller_ceiling() { + let temp = tempfile::tempdir().unwrap(); + let mut api = test_api(temp.path()).await; + let owner = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let (_runtime, workers) = workers(&api).await; + let identity = RuntimeIdentityMaterial::generate(&workers[0].runtime_id).unwrap(); + configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id); + let path = format!("{}/capabilities", base()); + let unsigned = build_router(api.clone()) + .oneshot(Request::builder().uri(&path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert!(matches!( + unsigned.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + )); + signed( + &api, + &identity, + None, + "GET", + &path, + None, + StatusCode::FORBIDDEN, + ) + .await; + let mut grants = vec![]; + for (worker, access, write) in [ + (&workers[0], "read_only", false), + (&workers[1], "read_write", true), + ] { + grants.push(user_call(&api,&owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":worker.runtime_id,"worker_id":worker.worker_id,"access":access})),StatusCode::OK).await); + assert_eq!( + signed( + &api, + &identity, + Some(&worker.worker_id), + "GET", + &path, + None, + StatusCode::OK + ) + .await, + json!({"write":write}) + ); + } + // Altering a signed source cannot acquire the other Worker's larger ceiling. + let mut forged = + runtime_source_request(&identity, Some(&workers[0].worker_id), "GET", &path, vec![]); + forged + .headers_mut() + .insert("x-yoi-worker-id", workers[1].worker_id.parse().unwrap()); + let response = build_router(api.clone()).oneshot(forged).await.unwrap(); + assert!(matches!( + response.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + )); + let foreign = path.replace(&format!("/w/{TEST_WORKSPACE_ID}/"), "/w/other-workspace/"); + let response = build_router(api.clone()) + .oneshot(runtime_source_request( + &identity, + Some(&workers[1].worker_id), + "GET", + &foreign, + vec![], + )) + .await + .unwrap(); + assert!(!response.status().is_success()); + signed( + &api, + &identity, + Some("nonexistent-worker"), + "GET", + &path, + None, + StatusCode::UNAUTHORIZED, + ) + .await; + for (worker, grant) in workers.iter().zip(&grants) { + user_call( + &api, + &owner, + "DELETE", + &format!("{}/grants/{}", base(), grant["grant_id"].as_str().unwrap()), + None, + StatusCode::OK, + ) + .await; + signed( + &api, + &identity, + Some(&worker.worker_id), + "GET", + &path, + None, + StatusCode::FORBIDDEN, + ) + .await; + } +} diff --git a/crates/workspace-server/src/server/tests/worker_drive_tests.rs b/crates/workspace-server/src/server/tests/worker_drive_tests.rs index 609cbd4ab..af3bf4ab2 100644 --- a/crates/workspace-server/src/server/tests/worker_drive_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_drive_tests.rs @@ -517,7 +517,7 @@ async fn user_uploaded_markdown_roundtrips_through_workdirless_tools_and_native_ read["entry"]["metadata"]["last_mutation_id"], uploaded["entry"]["last_mutation_id"] ); - value(native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":updated_text}})).await.unwrap()) + value(native_tool(&tools, "Invoke", json!({"path":path,"interface":file_write_reference(&inspected),"operation":"write","arguments":{"content":updated_text}})).await.unwrap())["metadata"].clone() } else { let read = value( tool(&f.features[0], "DriveRead", json!({"entry":id})) @@ -733,6 +733,19 @@ async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replace .await .unwrap(); assert!(f.api.runtime.worker(old).is_err()); + let client = f.clients[0].clone(); + let capability = tokio::task::spawn_blocking(move || { + client.request( + WorkspaceRequestMethod::Get, + format!("{}/capabilities", base()), + vec![], + 65536, + ) + }) + .await + .unwrap() + .unwrap(); + assert!(!(200..300).contains(&capability.status)); assert!( f.api .store @@ -1571,11 +1584,30 @@ impl agen::llm_client::LlmClient for NoLlm { panic!("native tool fixture must not call an LLM") } } +fn file_write_reference(observed: &Value) -> Value { + observed["interfaces"] + .as_array() + .unwrap() + .iter() + .find(|i| i["reference"]["name"] == "yoi.file/write-text/v1") + .expect("write capability")["reference"] + .clone() +} fn native_tools( feature: &DriveFeature, +) -> std::collections::BTreeMap> { + native_tools_with_checkouts(feature, None) +} +fn native_tools_with_checkouts( + feature: &DriveFeature, + router: Option>, ) -> std::collections::BTreeMap> { let mut mounts = worker::wip::WipMountRegistry::new(); worker::feature::builtin::drive::wip::mount_drive_wip(&mut mounts, feature).unwrap(); + if let Some(router) = router { + worker::checkout::mount_checkouts(&mut mounts, router, tools::Tracker::new(), None) + .unwrap(); + } let mut engine = agen::Engine::<_, agen::state::Mutable, ()>::new_annotated(NoLlm); engine.register_tools( feature @@ -1651,7 +1683,14 @@ async fn native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_cu ); assert_eq!(read["text"], "native content"); let denied_write = native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":"lost"}})).await; - denied(denied_write); + assert!(denied_write.is_err()); + assert!( + !inspected["interfaces"] + .as_array() + .unwrap() + .iter() + .any(|i| i["reference"]["name"] == "yoi.file/write-text/v1") + ); f.revoke(1).await; assert!( native_tool( @@ -1705,12 +1744,12 @@ async fn native_create_and_write_use_client_managed_committed_requests_and_rejec .await .unwrap(), ); - let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":observed_first["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"committed"}})).await.unwrap()); + let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":file_write_reference(&observed_first),"operation":"write","arguments":{"content":"committed"}})).await.unwrap())["metadata"].clone(); assert_ne!( updated["entry"]["metadata"]["last_mutation_id"], created["entry"]["metadata"]["last_mutation_id"] ); - let error = native_tool(&second, "Invoke", json!({"path":path,"interface":observed_second["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err(); + let error = native_tool(&second, "Invoke", json!({"path":path,"interface":file_write_reference(&observed_second),"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err(); assert!( error.to_string().to_lowercase().contains("validator"), "stale native mutation must report validator conflict: {error:?}" @@ -1833,3 +1872,143 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati "history attachment must not fetch latest Drive content" ); } + +#[tokio::test] +async fn common_file_inspect_invoke_crosses_drive_and_checkout_without_transferring_authority() { + let f = Fixture::new(["read_write", "read_only"]).await; + let created = f.create("common.md", "first\n雪 needle\nlast\n").await; + let path = created["entry"]["path"].as_str().unwrap(); + let checkout = f._temp.path().join("checkout"); + fs::create_dir(&checkout).unwrap(); + fs::write(checkout.join("file"), "first\n雪 needle\nlast\n").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + workdir::WorkdirAttachmentAlias::new("main").unwrap(), + Arc::new(workdir::LocalWorkdirSession::materialized( + checkout.clone(), + checkout.clone(), + manifest::SharedScope::new(manifest::Scope::writable(&checkout).unwrap()), + workdir::WorkdirSessionCapabilities::READ_WRITE, + )), + ) + .unwrap(); + let tools = native_tools_with_checkouts(&f.features[0], Some(router)); + let drive = value( + native_tool(&tools, "Inspect", json!({"path":path})) + .await + .unwrap(), + ); + let local = value( + native_tool(&tools, "Inspect", json!({"path":"/checkouts/main/file"})) + .await + .unwrap(), + ); + for name in [ + "yoi.file/read-lines/v1", + "yoi.file/search-text/v1", + "yoi.file/edit-text/v1", + "yoi.file/write-text/v1", + ] { + let selected = drive["interfaces"] + .as_array() + .unwrap() + .iter() + .find(|i| i["reference"]["name"] == name) + .unwrap(); + let reference = &selected["reference"]; + assert_eq!(reference["scope"], "/"); + assert!( + local["interfaces"] + .as_array() + .unwrap() + .iter() + .any(|i| i["reference"] == *reference) + ); + let signature = value( + native_tool(&tools, "Inspect", json!({"path":selected["path"]})) + .await + .unwrap(), + ); + assert!( + signature["interface_signature"] + .as_str() + .unwrap() + .contains(name) + ); + let (operation, arguments) = match name { + "yoi.file/read-lines/v1" => ("read", json!({"offset":1,"limit":1})), + "yoi.file/search-text/v1" => ("search", json!({"pattern":"needle","limit":2})), + "yoi.file/edit-text/v1" => { + ("edit", json!({"old_string":"needle","new_string":"found"})) + } + _ => ("write", json!({"content":"saved\n"})), + }; + let mut results = vec![]; + for target in [path, "/checkouts/main/file"] { + results.push(value(native_tool(&tools,"Invoke",json!({"path":target,"interface":reference,"operation":operation,"arguments":arguments})).await.unwrap())); + } + if matches!(operation, "read" | "search") { + assert_eq!(results[0], results[1]); + } else { + assert!(results.iter().all(|v| v.get("metadata").is_some())); + } + } + assert_eq!( + fs::read_to_string(checkout.join("file")).unwrap(), + "saved\n" + ); + assert_eq!( + value( + tool( + &f.features[1], + "DriveRead", + json!({"entry":entry(&created)}) + ) + .await + .unwrap() + )["text"], + "saved\n" + ); + // A capability query is not a grant: drop the writer to readonly after the common write was cached. + f.revoke(0).await; + owner_call(&f.api,&f.owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":f.clients[0].worker.runtime_id,"worker_id":f.clients[0].worker.worker_id,"access":"read_only"}))).await; + assert!(native_tool(&tools,"Invoke",json!({"path":path,"interface":file_write_reference(&drive),"operation":"write","arguments":{"content":"lost"}})).await.is_err()); + let refreshed = value( + native_tool(&tools, "Inspect", json!({"path":path,"refresh":true})) + .await + .unwrap(), + ); + assert!( + !refreshed["interfaces"] + .as_array() + .unwrap() + .iter() + .any(|i| i["reference"]["name"] == "yoi.file/write-text/v1") + ); + let common_read = json!({"scope":"/","name":"yoi.file/read-lines/v1"}); + assert_eq!( + value( + native_tool( + &tools, + "Invoke", + json!({"path":path,"interface":common_read,"operation":"read","arguments":{}}) + ) + .await + .unwrap() + )["content"], + " 1\tsaved\n" + ); + assert_eq!( + value( + tool( + &f.features[1], + "DriveRead", + json!({"entry":entry(&created)}) + ) + .await + .unwrap() + )["text"], + "saved\n" + ); +} diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs index 1e989edd8..f6f407943 100644 --- a/crates/workspace-server/src/server_workspace_config_wip_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -187,7 +187,7 @@ async fn config_call( runtime .invoke( path.into(), - config_interface(path), + if matches!(operation,"edit"|"write") {wip_protocol::InterfaceReference {scope:"/".into(),name:format!("yoi.file/{operation}-text/v1")}} else {config_interface(path)}, operation.into(), args, agen::tool::ToolExecutionContext::direct(), diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 146d0ce28..8f09d3f79 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 138ed0fd1..a07539f4d 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 2dbcf6109..aeef96d93 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 8fff5d2ea..446685098 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check From 4555aace28fb744c16be559438556c778c05456e Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 03:01:22 +0900 Subject: [PATCH 13/29] feat: capture bounded Bash output over replayable byte transport --- Cargo.lock | 1 + crates/standalone/src/host.rs | 16 +- .../journal/tests/connected_compaction.rs | 2 +- crates/tools/Cargo.toml | 3 +- crates/tools/src/bash.rs | 478 +++++---- crates/tools/src/bash_output.rs | 599 +++++++++++ crates/tools/src/lib.rs | 19 +- crates/tools/tests/edge_cases.rs | 46 +- crates/tools/tests/integration.rs | 502 ++++++--- crates/workdir/Cargo.toml | 2 +- crates/workdir/src/command_test_support.rs | 43 + crates/workdir/src/external.rs | 174 +++- crates/workdir/src/external_local_tests.rs | 20 +- crates/workdir/src/http.rs | 84 +- crates/workdir/src/lib.rs | 3 + crates/workdir/src/local.rs | 975 ++---------------- crates/workdir/src/local/command_transport.rs | 377 +++++++ .../src/local/command_transport/tests.rs | 490 +++++++++ crates/workdir/src/operation.rs | 181 +++- crates/workdir/src/router.rs | 19 +- crates/workdir/src/scope.rs | 333 +++++- .../tests/command_denial_diagnostics.rs | 5 +- crates/workdir/tests/denial_reasons.rs | 5 +- crates/worker-runtime/Cargo.toml | 1 + crates/worker-runtime/src/http_server.rs | 13 +- .../tests/command_transport_tests.rs | 408 ++++++++ crates/worker-runtime/src/worker_backend.rs | 60 +- crates/worker/examples/worker_protocol.rs | 4 +- crates/worker/src/bootstrap.rs | 65 +- crates/worker/src/checkout_wip_tests.rs | 6 +- crates/worker/src/controller.rs | 95 +- .../src/controller_subject_release_tests.rs | 1 - crates/worker/src/entrypoint.rs | 3 +- .../src/feature/builtin/manage_workdir.rs | 18 +- crates/worker/src/lib.rs | 2 +- crates/worker/src/spawn/registry.rs | 34 +- crates/worker/src/spawn/tool.rs | 75 +- crates/worker/src/subjektiv.rs | 4 - crates/worker/src/worker.rs | 2 - crates/worker/tests/compact_events_test.rs | 27 +- crates/worker/tests/controller_test.rs | 82 +- crates/workspace-server/src/server.rs | 576 ++++++++--- .../src/server/tests/api_logging_tests.rs | 19 +- .../server/tests/command_transport_tests.rs | 223 ++++ .../src/server_assignment_attention_tests.rs | 1 - .../src/server_assignment_delivery_tests.rs | 1 - 46 files changed, 4183 insertions(+), 1914 deletions(-) create mode 100644 crates/tools/src/bash_output.rs create mode 100644 crates/workdir/src/command_test_support.rs create mode 100644 crates/workdir/src/local/command_transport.rs create mode 100644 crates/workdir/src/local/command_transport/tests.rs create mode 100644 crates/worker-runtime/src/http_server/tests/command_transport_tests.rs create mode 100644 crates/workspace-server/src/server/tests/command_transport_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 122e578dc..c0de6ab86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5266,6 +5266,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "uuid", "workdir", ] diff --git a/crates/standalone/src/host.rs b/crates/standalone/src/host.rs index 60c239b91..6d55edb67 100644 --- a/crates/standalone/src/host.rs +++ b/crates/standalone/src/host.rs @@ -23,9 +23,7 @@ use session_store::{ CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore, }; use thiserror::Error; -use worker::bootstrap::{ - WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, bash_output_dir_for_worker_id, -}; +use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout}; use worker::controller::WorkerControllerTransport; use worker::ipc::protocol_session::{ WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event, @@ -216,7 +214,6 @@ impl StandaloneHost { WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone()); let workspace_context = WorkerWorkspaceContext::local_filesystem(None); let runtime_base = store.runtime_dir(worker_id); - let bash_output_dir = bash_output_dir_for_worker_id(worker_id); let mut bootstrap = WorkerBootstrap::new( bootstrap_manifest, @@ -224,10 +221,7 @@ impl StandaloneHost { launch.prompt_catalog, workspace_context, filesystem_authority, - WorkerBootstrapLayout::Direct { - runtime_base, - bash_output_dir, - }, + WorkerBootstrapLayout::Direct { runtime_base }, WorkerControllerTransport::InProcess, ); if let Some(subject) = &subject { @@ -428,7 +422,6 @@ impl StandaloneHost { ); let workspace_context = WorkerWorkspaceContext::local_filesystem(None); let runtime_base = store.runtime_dir(worker_id); - let bash_output_dir = bash_output_dir_for_worker_id(worker_id); let mut bootstrap = WorkerBootstrap::new( manifest, @@ -436,10 +429,7 @@ impl StandaloneHost { worker::PromptCatalogSource::builtins_only(), workspace_context, filesystem_authority, - WorkerBootstrapLayout::Direct { - runtime_base, - bash_output_dir, - }, + WorkerBootstrapLayout::Direct { runtime_base }, WorkerControllerTransport::InProcess, ); if let Some(subject) = &subject { diff --git a/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs b/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs index 04032a8d7..7dc575f6d 100644 --- a/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs +++ b/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs @@ -126,7 +126,7 @@ async fn local_host_compaction_freezes_pending_journal_through_request_and_durab worker::PromptCatalogSource::builtins_only(), worker::WorkerWorkspaceContext::no_workspace(), worker::WorkerFilesystemAuthority::None, - worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime"), bash_output_dir: fixture._temp.path().join("bash") }, + worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime") }, worker::WorkerControllerTransport::InProcess, ).with_model_client(model.clone()).with_subjektiv_host(fixture.host().clone()).prepare_restored("journal-test").await.unwrap(); assert_eq!(prepared.worker().session_id().to_string(), fixture.context.session_id); diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index f10a68197..22edee14c 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -23,7 +23,8 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["process", "rt", "sync", "time"] } tracing = { workspace = true } workdir = { workspace = true } +uuid = { workspace = true, features = ["v7"] } [dev-dependencies] filetime = "0.2.27" -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } diff --git a/crates/tools/src/bash.rs b/crates/tools/src/bash.rs index 801507c0b..ae911bb71 100644 --- a/crates/tools/src/bash.rs +++ b/crates/tools/src/bash.rs @@ -1,19 +1,19 @@ //! Bash Tool using WorkdirSession command capability. //! -//! The selected session owns cwd, process launch, bounded output, timeout, and cancellation. +//! Workdir owns launch and byte transport; Bash owns bounded preview and retained evidence. //! Bash is not a sandbox; the Runtime process and OS user define the execution boundary. //! Per-execution handles and nonces prevent cancellation of another command. Dropping a future -//! does not confirm termination; active registrations remain until cleanup requests cancellation -//! and observes terminal output. +//! does not confirm termination. Registrations remain during bounded cancellation/drain cleanup; +//! an unconfirmed provider outcome leaves partial evidence, never a fabricated terminal fence. use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use crate::bash_output::{BashOutputMetadata, BashOutputView, BashOutputWriter}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use async_trait::async_trait; use schemars::JsonSchema; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use workdir::{ CommandHandle, CommandOutputRequest, CommandRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter, @@ -35,9 +35,9 @@ struct BashParams { timeout: Option, } -pub(crate) struct BashTool { +pub struct BashTool { router: Arc, - output_dir: PathBuf, + output: BashOutputView, state: Arc>, } @@ -63,6 +63,8 @@ struct CommandGuard { execution_id: String, execution_nonce: u64, handle: Option, + cursor: u64, + retained: Option, } impl Drop for CommandGuard { @@ -74,20 +76,66 @@ impl Drop for CommandGuard { let state = Arc::clone(&self.state); let execution_id = self.execution_id.clone(); let execution_nonce = self.execution_nonce; + let mut retained = self.retained.take(); + let cursor = self.cursor; // A dropped provider future is not terminal confirmation. Keep the live // execution registered until cleanup has both requested cancellation and // observed terminal command output, so cancellation/session teardown // cannot race with an apparently empty registry. tokio::spawn(async move { - let _ = workdir.cancel_command(handle.clone()).await; - let _ = workdir - .command_output(CommandOutputRequest { - handle, - cursor: 0, - limit: INLINE_BYTE_BUDGET, - wait: true, - }) - .await; + let result = tokio::time::timeout(std::time::Duration::from_secs(10), async { + let _ = workdir.cancel_command(handle.clone()).await; + let mut cursor = cursor; + loop { + let page = command_page( + &workdir, + CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }, + ) + .await?; + if let Some(writer) = &mut retained { + let mut next = cursor; + for chunk in &page.chunks { + if chunk.sequence != next { + return Err(workdir::WorkdirError::Unavailable( + "command cleanup sequence gap".into(), + )); + } + writer + .append_chunk(chunk) + .map_err(workdir::WorkdirError::Unavailable)?; + next = next.checked_add(chunk.bytes.len() as u64).ok_or_else(|| { + workdir::WorkdirError::Unavailable( + "command cleanup cursor overflow".into(), + ) + })?; + } + if page.next_cursor != next { + return Err(workdir::WorkdirError::Unavailable( + "command cleanup cursor gap".into(), + )); + } + } + cursor = page.next_cursor; + if let Some(terminal) = page.terminal { + if let Some(writer) = &mut retained { + writer + .finish_transport(&terminal) + .map_err(workdir::WorkdirError::Unavailable)?; + } + return Ok::<(), workdir::WorkdirError>(()); + } + } + }) + .await; + // Bounded cleanup can end with an unknown provider outcome. The writer then + // terminalizes partial evidence on drop; retaining a dead collector in `active` + // would leak registrations and falsely suggest it could still collect output. + let _ = result; let mut state = state.lock().unwrap(); if state .active @@ -101,15 +149,45 @@ impl Drop for CommandGuard { } } -#[async_trait] -impl Tool for BashTool { - async fn execute( +// Output pages are replayable only with the exact same command/cursor/limit. +// Retry read transport failures, never command start, and cap both attempts and +// backoff. The caller's deadline bounds a disconnected or unresponsive provider. +async fn command_page( + session: &workdir::WorkdirSessionHandle, + request: CommandOutputRequest, +) -> Result { + for attempt in 0..3 { + match session.command_output(request.clone()).await { + Err( + workdir::WorkdirError::Transport(_) + | workdir::WorkdirError::Unavailable(_) + | workdir::WorkdirError::OutcomeUnknown(_), + ) if attempt < 2 => { + tokio::time::sleep(std::time::Duration::from_millis(100 << attempt)).await; + } + result => return result, + } + } + unreachable!("bounded retry loop returns its last result") +} + +impl BashTool { + pub fn new(router: Arc, output: BashOutputView) -> Self { + Self { + router, + output, + state: Arc::new(Mutex::new(BashExecutionState::default())), + } + } + + pub async fn execute_result( &self, input_json: &str, ctx: agen::tool::ToolExecutionContext, - ) -> Result { + ) -> Result { let params: BashParams = serde_json::from_str(input_json) .map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?; + let retained = self.output.begin().map_err(ToolError::ExecutionFailed)?; let selected = crate::routing::resolve_session( &self.router, params.target_workdir.as_deref(), @@ -134,14 +212,15 @@ impl Tool for BashTool { execution_id: execution_id.clone(), execution_nonce, handle: None, + cursor: 0, + retained: Some(retained), }; let handle = session .start_command(CommandRequest { command: params.command, timeout_secs, - output_limit: INLINE_BYTE_BUDGET, + transport_byte_budget: self.output.byte_budget(), cwd: params.cwd, - spill_dir: Some(self.output_dir.clone()), tool_call_id: Some(call_id.clone()), }) .await @@ -167,15 +246,101 @@ impl Tool for BashTool { .await .map_err(crate::ToolsError::from)?; } - let output = session - .command_output(CommandOutputRequest { - handle, - cursor: 0, - limit: INLINE_BYTE_BUDGET, - wait: true, - }) + let mut cursor = 0; + let mut stdout_preview = Vec::new(); + let mut stderr_preview = Vec::new(); + let mut seen = [0_u64; 2]; + let mut empty_polls = 0; + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs + 30); + let terminal = loop { + let page = tokio::time::timeout_at( + deadline, + command_page( + &session, + CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }, + ), + ) .await + .map_err(|_| { + ToolError::ExecutionFailed( + "command byte transport deadline exceeded; terminal outcome unknown".into(), + ) + })? .map_err(crate::ToolsError::from)?; + let mut next = cursor; + for chunk in &page.chunks { + if chunk.sequence != next || chunk.bytes.is_empty() { + return Err(ToolError::ExecutionFailed( + "command byte transport sequence gap".into(), + )); + } + next = next + .checked_add(chunk.bytes.len() as u64) + .filter(|n| *n <= self.output.byte_budget()) + .ok_or_else(|| { + ToolError::ExecutionFailed( + "command byte transport exceeds delivery budget".into(), + ) + })?; + let index = if chunk.stream == workdir::CommandStream::Stdout { + 0 + } else { + 1 + }; + if chunk.offset != seen[index] { + return Err(ToolError::ExecutionFailed( + "command byte transport offset gap".into(), + )); + } + seen[index] += chunk.bytes.len() as u64; + guard + .retained + .as_mut() + .unwrap() + .append_chunk(chunk) + .map_err(ToolError::ExecutionFailed)?; + let available = + INLINE_BYTE_BUDGET.saturating_sub(stdout_preview.len() + stderr_preview.len()); + let preview = if index == 0 { + &mut stdout_preview + } else { + &mut stderr_preview + }; + preview.extend_from_slice(&chunk.bytes[..available.min(chunk.bytes.len())]); + } + if page.next_cursor != next { + return Err(ToolError::ExecutionFailed( + "command byte transport cursor does not fence the page".into(), + )); + } + if page.chunks.is_empty() && page.terminal.is_none() { + empty_polls += 1; + if empty_polls > 32 { + return Err(ToolError::ExecutionFailed( + "command byte transport made no progress after bounded polls".into(), + )); + } + } else { + empty_polls = 0; + } + cursor = page.next_cursor; + guard.cursor = cursor; + if let Some(terminal) = page.terminal { + break terminal; + } + }; + let metadata = guard + .retained + .as_mut() + .unwrap() + .finish_transport(&terminal) + .map_err(ToolError::ExecutionFailed)?; let cancellation_requested = { let mut state = self.state.lock().unwrap(); let owns_registration = state @@ -193,41 +358,78 @@ impl Tool for BashTool { }; guard.handle = None; - let timed_out = output.timed_out; - let summary = if cancellation_requested { - format!("$ {cmd_summary} (cancelled)") - } else if output.timed_out { - format!("$ {cmd_summary} (timed out after {timeout_secs}s)") - } else { - match output.exit_code { - Some(0) => format!("$ {cmd_summary}"), - Some(code) => format!("$ {cmd_summary} (exit {code})"), - None => format!("$ {cmd_summary} (terminated)"), - } - }; - let content = if output.content.is_empty() { - None - } else if output.truncated { - let notice = match output.output_path { - Some(path) => format!( - "[showing bounded WorkdirSession command output; full output saved to {}]", - path.display() - ), - None => "[showing bounded WorkdirSession command output; additional output was truncated]" - .to_owned(), + let timed_out = terminal.status == workdir::CommandStatus::TimedOut; + let summary = + if cancellation_requested || terminal.status == workdir::CommandStatus::Cancelled { + format!("$ {cmd_summary} (cancelled)") + } else if timed_out { + format!("$ {cmd_summary} (timed out after {timeout_secs}s)") + } else { + match terminal.exit_code { + Some(0) => format!("$ {cmd_summary}"), + Some(code) => format!("$ {cmd_summary} (exit {code})"), + None => format!("$ {cmd_summary} (terminated)"), + } }; - Some(format!("{notice}\n{}", output.content)) - } else { - Some(output.content) - }; - let output = ToolOutput { + Ok(BashResult { summary, - content, - attachments: Vec::new(), - }; - if cancellation_requested { + stdout_preview: String::from_utf8_lossy(&stdout_preview).into_owned(), + stderr_preview: String::from_utf8_lossy(&stderr_preview).into_owned(), + preview_omitted: terminal.stdout_bytes.saturating_add(terminal.stderr_bytes) + > (stdout_preview.len() + stderr_preview.len()) as u64, + metadata, + cancelled: cancellation_requested + || terminal.status == workdir::CommandStatus::Cancelled, + }) + } +} + +#[derive(Debug, Serialize)] +pub struct BashResult { + pub summary: String, + pub stdout_preview: String, + pub stderr_preview: String, + pub preview_omitted: bool, + pub metadata: BashOutputMetadata, + pub cancelled: bool, +} + +impl BashResult { + pub fn stdout_entry(&self) -> String { + format!("/bash-output/{}/stdout", self.metadata.id) + } + pub fn stderr_entry(&self) -> String { + format!("/bash-output/{}/stderr", self.metadata.id) + } + pub fn order_entry(&self) -> String { + format!("/bash-output/{}/order", self.metadata.id) + } + pub fn tool_output(&self) -> ToolOutput { + let content = serde_json::json!({ + "stdout_preview": self.stdout_preview, "stderr_preview": self.stderr_preview, + "preview_omitted": self.preview_omitted, "output": self.metadata, + "stdout_entry": self.stdout_entry(), "stderr_entry": self.stderr_entry(), "order_entry": self.order_entry(), + }); + ToolOutput { + summary: self.summary.clone(), + content: Some(content.to_string()), + attachments: vec![], + } + } +} + +#[async_trait] +impl Tool for BashTool { + async fn execute( + &self, + input: &str, + ctx: agen::tool::ToolExecutionContext, + ) -> Result { + let result = self.execute_result(input, ctx).await?; + let output = result.tool_output(); + if result.cancelled { Err(ToolError::Cancelled(output)) - } else if timed_out { + } else if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) { Err(ToolError::Interrupted(output)) } else { Ok(output) @@ -288,164 +490,20 @@ fn truncate_for_summary(command: &str) -> String { summary } -pub fn bash_tool(session: WorkdirSessionHandle, output_dir: PathBuf) -> ToolDefinition { - routed_bash_tool(crate::routing::singleton_router(session), output_dir) +pub fn bash_tool(session: WorkdirSessionHandle, output: BashOutputView) -> ToolDefinition { + routed_bash_tool(crate::routing::singleton_router(session), output) } pub(crate) fn routed_bash_tool( router: Arc, - output_dir: PathBuf, + output: BashOutputView, ) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(BashParams); let meta = ToolMeta::new("Bash") - .description("Execute a shell command in one selected Workdir. Process start, bounded inline output, full-output spill, timeout and cancellation are owned by that WorkdirSession provider. This is not a sandbox.") + .description("Execute a shell command in one selected Workdir. Workdir owns launch, lossless bounded byte transport, timeout and cancel; Bash owns preview, quota-limited ephemeral output and read-only virtual references. This is not a sandbox.") .input_schema(serde_json::to_value(schema).expect("Bash schema serialization")); - let tool: Arc = Arc::new(BashTool { - router: router.clone(), - output_dir: output_dir.clone(), - state: Arc::new(Mutex::new(BashExecutionState::default())), - }); + let tool: Arc = Arc::new(BashTool::new(router.clone(), output.clone())); (meta, tool) }) } - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; - use tempfile::TempDir; - use workdir::{LocalWorkdirSession, WorkdirSessionHandle}; - - use super::bash_tool; - use crate::{grep::grep_tool, read::read_tool, tracker::Tracker}; - - fn session_with_output_scope(root: &TempDir, output: &TempDir) -> WorkdirSessionHandle { - let scope = Scope::from_config(&ScopeConfig { - allow: vec![ - ScopeRule { - target: root.path().to_path_buf(), - permission: Permission::Write, - recursive: true, - symlink_policy: Default::default(), - }, - ScopeRule { - target: output.path().to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }, - ], - deny: Vec::new(), - }) - .unwrap(); - Arc::new(LocalWorkdirSession::new(scope, root.path().to_path_buf())) - } - - #[tokio::test] - async fn long_output_is_spilled_and_available_to_read_and_grep() { - let root = TempDir::new().unwrap(); - let output = TempDir::new().unwrap(); - let session = session_with_output_scope(&root, &output); - let (_, bash) = bash_tool(session.clone(), output.path().to_path_buf())(); - let command = "i=0; while [ $i -lt 2000 ]; do printf 'line-%04d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'"; - let result = bash - .execute( - &serde_json::json!({ "command": command, "cwd": "." }).to_string(), - Default::default(), - ) - .await - .unwrap(); - let rendered = result.content.expect("bounded Bash output"); - let artifact = std::fs::read_dir(output.path()) - .unwrap() - .next() - .expect("artifact entry") - .unwrap() - .path(); - - assert!(rendered.contains("full output saved to")); - assert!(rendered.contains(&artifact.display().to_string())); - let retained = std::fs::read_to_string(&artifact).unwrap(); - assert!(retained.starts_with("line-0000\n")); - assert!(retained.ends_with("FINAL-NEEDLE\n")); - assert_eq!(retained.lines().count(), 2001); - - let (_, read) = read_tool(session.clone(), Tracker::new())(); - let read_result = read - .execute( - &serde_json::json!({ - "file_path": artifact, - "offset": 2000, - "limit": 1, - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - assert!( - read_result - .content - .expect("Read content") - .contains("FINAL-NEEDLE") - ); - - let (_, grep) = grep_tool(session)(); - let grep_result = grep - .execute( - &serde_json::json!({ - "pattern": "FINAL-NEEDLE", - "path": artifact, - "output_mode": "content", - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - let grep_content = grep_result.content.expect("Grep content"); - assert!( - grep_content.contains("FINAL-NEEDLE"), - "unexpected Grep content: {grep_content:?}" - ); - } - - #[tokio::test] - async fn missing_cwd_is_rejected_before_command_start() { - let root = TempDir::new().unwrap(); - let output = TempDir::new().unwrap(); - let session = session_with_output_scope(&root, &output); - let (_, bash) = bash_tool(session, output.path().to_path_buf())(); - - let error = bash - .execute( - &serde_json::json!({ "command": "printf hidden" }).to_string(), - Default::default(), - ) - .await - .unwrap_err(); - - assert!(error.to_string().contains("missing field `cwd`")); - } - - #[tokio::test] - async fn short_output_does_not_leave_a_spill_artifact() { - let root = TempDir::new().unwrap(); - let output = TempDir::new().unwrap(); - let session = session_with_output_scope(&root, &output); - let (_, bash) = bash_tool(session, output.path().to_path_buf())(); - - let result = bash - .execute( - &serde_json::json!({ "command": "printf short", "cwd": "." }).to_string(), - Default::default(), - ) - .await - .unwrap(); - - assert_eq!(result.content.as_deref(), Some("short")); - assert_eq!(std::fs::read_dir(output.path()).unwrap().count(), 0); - } -} diff --git a/crates/tools/src/bash_output.rs b/crates/tools/src/bash_output.rs new file mode 100644 index 000000000..3f31fcb44 --- /dev/null +++ b/crates/tools/src/bash_output.rs @@ -0,0 +1,599 @@ +//! Bash-owned bounded output, independent of Workdir transport and Console subscribers. +//! +//! Output is ephemeral evidence, not a filesystem grant. Every lookup checks the live view; +//! children can see their own subtree and ancestors can retain evidence after child revocation. +//! Root teardown clears all bytes. A new root (including restore) has a new incarnation and +//! never reconstructs grants from history. Expiry is measured from admission, enforced on +//! lookup and by a weak reaper once commands run in Tokio. Parent and all descendants share +//! one pool: admission reserves a whole command budget, evicts the oldest terminal evidence +//! first, and refuses rather than evicting a live collector. Revoking a child denies access +//! but leaves ancestor evidence until pool eviction/expiry; root teardown clears the pool. + +use std::collections::VecDeque; +use std::sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, Ordering}, +}; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use workdir::{CommandStatus, CommandStream}; + +const COMMAND_BYTES: usize = 4 * 1024 * 1024; +const STORE_BYTES: usize = 32 * 1024 * 1024; +const STORE_COUNT: usize = 64; +const ORDER_COUNT: usize = 4096; +const RETENTION: Duration = Duration::from_secs(3600); + +#[derive(Clone)] +pub struct BashOutputView { + store: Arc>, + lineage: Vec, + active: Arc, + authority: Arc bool + Send + Sync>, +} + +struct Store { + records: VecDeque>>, + reserved: usize, + retention: Duration, + reaper_started: bool, + _shutdown: tokio::sync::watch::Sender<()>, +} + +struct Record { + id: String, + owner: Vec, + created: Instant, + stdout: Vec, + stderr: Vec, + order: Vec, + stdout_seen: u64, + stderr_seen: u64, + truncated: bool, + limit: Option, + transport_counts: Option<(u64, u64)>, + transport_truncation: Option, + terminal: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OutputRange { + pub stream: CommandStream, + pub start: u64, + pub end: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OutputTerminal { + pub status: Option, + pub exit_code: Option, + /// False on a forcibly dropped collector or failed transport: received bytes are retained, + /// but they are not a claim about the unobserved process tail. + pub transport_complete: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RetentionLimit { + ByteQuota, + OrderQuota, +} + +#[derive(Clone, Debug, Serialize)] +pub struct BashOutputMetadata { + pub id: String, + pub stdout_saved: usize, + pub stderr_saved: usize, + pub stdout_seen: u64, + pub stderr_seen: u64, + pub stdout_delivered: u64, + pub stderr_delivered: u64, + pub retention_limit: Option, + pub transport_truncation: Option, + pub storage_truncated: bool, + pub terminal: OutputTerminal, + pub text_encoding: &'static str, + pub ordering: &'static str, +} + +/// A bounded reservation acquired before command start. Drop terminalizes partial evidence. +pub struct BashOutputWriter { + record: Arc>, +} + +impl Default for BashOutputView { + fn default() -> Self { + Self::new() + } +} + +impl BashOutputView { + pub fn new() -> Self { + let (shutdown, _) = tokio::sync::watch::channel(()); + let store = Arc::new(Mutex::new(Store { + records: VecDeque::new(), + reserved: 0, + retention: RETENTION, + reaper_started: false, + _shutdown: shutdown, + })); + let view = Self { + store, + lineage: vec![uuid::Uuid::now_v7().to_string()], + active: Arc::new(AtomicBool::new(true)), + authority: Arc::new(|| true), + }; + view.start_reaper(); + view + } + + fn start_reaper(&self) { + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return; + }; + let mut store = self.store.lock().unwrap(); + if store.reaper_started { + return; + } + let receiver = store._shutdown.subscribe(); + store.reaper_started = true; + let weak = Arc::downgrade(&self.store); + runtime.spawn(reap(weak, receiver)); + } + + pub fn byte_budget(&self) -> u64 { + COMMAND_BYTES as u64 + } + + pub fn child(&self, authority: Arc bool + Send + Sync>) -> Self { + let parent = self.clone(); + let mut lineage = self.lineage.clone(); + lineage.push(uuid::Uuid::now_v7().to_string()); + Self { + store: self.store.clone(), + lineage, + active: Arc::new(AtomicBool::new(true)), + authority: Arc::new(move || parent.is_active() && authority()), + } + } + + pub fn is_active(&self) -> bool { + self.active.load(Ordering::Acquire) && (self.authority)() + } + + pub fn is_root(&self) -> bool { + self.lineage.len() == 1 + } + + pub fn revoke(&self) { + self.active.store(false, Ordering::Release); + if self.lineage.len() == 1 { + let mut store = self.store.lock().unwrap(); + store.records.clear(); + store.reserved = 0; + } + } + + pub fn begin(&self) -> Result { + if !self.is_active() { + return Err("Bash output authority revoked".into()); + } + // A host may construct the Feature before entering Tokio. Command admission + // happens inside the runtime, so idle expiry must start here as well. + self.start_reaper(); + let mut store = self.store.lock().unwrap(); + store.expire(Instant::now()); + while store.records.len() >= STORE_COUNT || store.reserved + COMMAND_BYTES > STORE_BYTES { + let Some(index) = store + .records + .iter() + .position(|r| r.lock().unwrap().terminal.is_some()) + else { + return Err("Bash output capacity is occupied by live commands".into()); + }; + store.records.remove(index); + store.reserved -= COMMAND_BYTES; + } + let record = Arc::new(Mutex::new(Record { + id: uuid::Uuid::now_v7().to_string(), + owner: self.lineage.clone(), + created: Instant::now(), + stdout: Vec::new(), + stderr: Vec::new(), + order: Vec::new(), + stdout_seen: 0, + stderr_seen: 0, + truncated: false, + limit: None, + transport_counts: None, + transport_truncation: None, + terminal: None, + })); + store.records.push_back(record.clone()); + store.reserved += COMMAND_BYTES; + Ok(BashOutputWriter { record }) + } + + fn record(&self, id: &str) -> Option>> { + if !self.is_active() { + return None; + } + let mut store = self.store.lock().unwrap(); + store.expire(Instant::now()); + store + .records + .iter() + .find(|r| { + let r = r.lock().unwrap(); + r.id == id && r.owner.starts_with(&self.lineage) && r.terminal.is_some() + }) + .cloned() + } + + pub fn metadata(&self, id: &str) -> Option { + let record = self.record(id)?; + let r = record.lock().unwrap(); + Some(r.metadata()) + } + + /// Copies at most the caller's bounded range; never publishes output bodies in descriptors. + pub fn read( + &self, + id: &str, + stream: CommandStream, + offset: usize, + limit: usize, + ) -> Option> { + let record = self.record(id)?; + let r = record.lock().unwrap(); + let bytes = match stream { + CommandStream::Stdout => &r.stdout, + CommandStream::Stderr => &r.stderr, + }; + let start = offset.min(bytes.len()); + Some(bytes[start..start.saturating_add(limit.min(64 * 1024)).min(bytes.len())].to_vec()) + } + + /// Scan the bounded raw source without cloning it, decoding only the requested line window. + /// A long single line uses the common byte reader instead of allocating an oversized text page. + pub fn line_window( + &self, + id: &str, + stream: CommandStream, + offset: usize, + limit: usize, + ) -> Option> { + let record = self.record(id)?; + let r = record.lock().unwrap(); + let bytes = match stream { + CommandStream::Stdout => &r.stdout, + CommandStream::Stderr => &r.stderr, + }; + let mut lines = bytes.split_inclusive(|b| *b == b'\n').skip(offset); + let mut window = Vec::new(); + for line in lines.by_ref().take(limit.min(2000)) { + if window.len() + line.len() > 64 * 1024 { + return Some(Err( + "text source window exceeds 64 KiB; use the common byte reader".into(), + )); + } + window.extend_from_slice(line); + } + let more = lines.next().is_some(); + Some(Ok((String::from_utf8_lossy(&window).into_owned(), more))) + } + + pub fn order(&self, id: &str) -> Option> { + let record = self.record(id)?; + let r = record.lock().unwrap(); + Some(r.order.clone()) + } +} + +impl Record { + fn metadata(&self) -> BashOutputMetadata { + BashOutputMetadata { + id: self.id.clone(), + stdout_saved: self.stdout.len(), + stderr_saved: self.stderr.len(), + stdout_seen: self.transport_counts.map_or(self.stdout_seen, |c| c.0), + stderr_seen: self.transport_counts.map_or(self.stderr_seen, |c| c.1), + stdout_delivered: self.stdout_seen, + stderr_delivered: self.stderr_seen, + retention_limit: self.limit, + transport_truncation: self.transport_truncation.clone(), + storage_truncated: self.truncated || self.transport_truncation.is_some(), + terminal: self.terminal.clone().expect("finalized record"), + text_encoding: "utf8_lossy", + ordering: "provider_read_observation", + } + } +} + +impl Store { + fn expire(&mut self, now: Instant) { + let before = self.records.len(); + self.records + .retain(|r| now.duration_since(r.lock().unwrap().created) < self.retention); + self.reserved -= (before - self.records.len()) * COMMAND_BYTES; + } +} + +async fn reap(weak: Weak>, mut shutdown: tokio::sync::watch::Receiver<()>) { + loop { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(30)) => {}, + _ = shutdown.changed() => return, + } + let Some(store) = weak.upgrade() else { + return; + }; + store.lock().unwrap().expire(Instant::now()); + } +} + +impl BashOutputWriter { + pub fn append_chunk(&mut self, chunk: &workdir::CommandByteChunk) -> Result<(), String> { + let expected = { + let r = self.record.lock().unwrap(); + match chunk.stream { + CommandStream::Stdout => r.stdout_seen, + CommandStream::Stderr => r.stderr_seen, + } + }; + if chunk.offset != expected || chunk.bytes.is_empty() { + return Err("command byte transport stream gap or empty chunk".into()); + } + self.append(chunk.stream, &chunk.bytes); + Ok(()) + } + + pub fn append(&mut self, stream: CommandStream, bytes: &[u8]) { + let mut r = self.record.lock().unwrap(); + let seen = match stream { + CommandStream::Stdout => &mut r.stdout_seen, + CommandStream::Stderr => &mut r.stderr_seen, + }; + let start = *seen; + *seen = seen.saturating_add(bytes.len() as u64); + let available = COMMAND_BYTES.saturating_sub(r.stdout.len() + r.stderr.len()); + let n = if r.truncated || r.order.len() >= ORDER_COUNT { + 0 + } else { + available.min(bytes.len()) + }; + if n < bytes.len() { + r.truncated = true; + if r.limit.is_none() { + r.limit = Some(if r.order.len() >= ORDER_COUNT { + RetentionLimit::OrderQuota + } else { + RetentionLimit::ByteQuota + }); + } + } + if n > 0 { + match stream { + CommandStream::Stdout => r.stdout.extend_from_slice(&bytes[..n]), + CommandStream::Stderr => r.stderr.extend_from_slice(&bytes[..n]), + } + if let Some(last) = r + .order + .last_mut() + .filter(|last| last.stream == stream && last.end == start) + { + last.end += n as u64; + } else { + r.order.push(OutputRange { + stream, + start, + end: start + n as u64, + }); + } + } + } + + pub fn finish_transport( + &mut self, + terminal: &workdir::CommandTerminal, + ) -> Result { + let mut r = self.record.lock().unwrap(); + if terminal.stdout_delivered_bytes != r.stdout_seen + || terminal.stderr_delivered_bytes != r.stderr_seen + || terminal.stdout_bytes < r.stdout_seen + || terminal.stderr_bytes < r.stderr_seen + || ((terminal.stdout_bytes != r.stdout_seen || terminal.stderr_bytes != r.stderr_seen) + != terminal.truncation_reason.is_some()) + { + return Err("command terminal counts do not fence delivered bytes".into()); + } + r.transport_counts = Some((terminal.stdout_bytes, terminal.stderr_bytes)); + r.transport_truncation = terminal.truncation_reason.clone(); + r.terminal = Some(OutputTerminal { + status: Some(terminal.status), + exit_code: terminal.exit_code, + transport_complete: true, + }); + Ok(r.metadata()) + } + + pub fn finish(&mut self, terminal: OutputTerminal) -> BashOutputMetadata { + let mut r = self.record.lock().unwrap(); + r.terminal = Some(terminal); + r.metadata() + } +} + +impl Drop for BashOutputWriter { + fn drop(&mut self) { + let mut r = self.record.lock().unwrap(); + if r.terminal.is_none() { + r.terminal = Some(OutputTerminal { + status: None, + exit_code: None, + transport_complete: false, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn finish(view: &BashOutputView, text: &[u8]) -> String { + let mut writer = view.begin().unwrap(); + writer.append(CommandStream::Stdout, text); + writer + .finish(OutputTerminal { + status: Some(CommandStatus::Completed), + exit_code: Some(0), + transport_complete: true, + }) + .id + } + + #[test] + fn revoked_child_is_denied_while_parent_keeps_evidence_and_sibling_is_denied() { + let root = BashOutputView::new(); + let lease = Arc::new(AtomicBool::new(true)); + let child = root.child({ + let lease = lease.clone(); + Arc::new(move || lease.load(Ordering::Acquire)) + }); + let sibling = root.child(Arc::new(|| true)); + let id = finish(&child, b"child"); + assert_eq!( + child.read(&id, CommandStream::Stdout, 0, 20).unwrap(), + b"child" + ); + assert!(sibling.metadata(&id).is_none()); + assert!(BashOutputView::new().metadata(&id).is_none()); + lease.store(false, Ordering::Release); + assert!(child.metadata(&id).is_none()); + assert_eq!( + root.read(&id, CommandStream::Stdout, 0, 20).unwrap(), + b"child" + ); + root.revoke(); + let restored = BashOutputView::new(); + assert!(root.metadata(&id).is_none()); + assert!(restored.metadata(&id).is_none()); + let new_id = finish(&restored, b"new incarnation"); + assert_ne!(new_id, id); + assert!(child.metadata(&new_id).is_none()); + assert!(root.begin().is_err()); + } + + #[test] + fn parent_and_descendants_share_admission_without_evicting_live_evidence() { + let root = BashOutputView::new(); + let child = root.child(Arc::new(|| true)); + let sibling = root.child(Arc::new(|| true)); + let mut writers = (0..STORE_BYTES / COMMAND_BYTES) + .map(|index| { + if index % 2 == 0 { + root.begin() + } else { + child.begin() + } + .unwrap() + }) + .collect::>(); + assert!(sibling.begin().is_err()); + let mut first = writers.remove(0); + first.append(CommandStream::Stdout, b"finished"); + let metadata = first.finish(OutputTerminal { + status: Some(CommandStatus::Completed), + exit_code: Some(0), + transport_complete: true, + }); + assert!(root.metadata(&metadata.id).is_some()); + let _replacement = sibling.begin().unwrap(); + assert!(root.metadata(&metadata.id).is_none()); + assert!(root.begin().is_err()); + assert_eq!(writers.len(), STORE_BYTES / COMMAND_BYTES - 1); + } + + #[test] + fn quota_and_forced_drop_are_distinct_from_preview_omission() { + let root = BashOutputView::new(); + let mut writer = root.begin().unwrap(); + writer.append(CommandStream::Stdout, &vec![b'x'; COMMAND_BYTES + 1]); + writer.append(CommandStream::Stderr, b"tail"); + let id = writer.record.lock().unwrap().id.clone(); + drop(writer); + let m = root.metadata(&id).unwrap(); + assert_eq!(m.stdout_saved, COMMAND_BYTES); + assert_eq!(m.stdout_seen, COMMAND_BYTES as u64 + 1); + assert_eq!(m.stderr_seen, 4); + assert!(m.storage_truncated); + assert!(!m.terminal.transport_complete); + assert_eq!( + root.read(&id, CommandStream::Stdout, 0, usize::MAX) + .unwrap() + .len(), + 64 * 1024 + ); + } + + #[test] + fn expiry_and_count_eviction_never_reuse_old_references() { + let root = BashOutputView::new(); + let id = finish(&root, b"old"); + root.store.lock().unwrap().retention = Duration::ZERO; + assert!(root.metadata(&id).is_none()); + root.store.lock().unwrap().retention = RETENTION; + let first = finish(&root, b"first"); + for _ in 0..STORE_COUNT { + finish(&root, b"later"); + } + assert!(root.metadata(&first).is_none()); + assert!(root.store.lock().unwrap().reserved <= STORE_BYTES); + } + #[tokio::test(start_paused = true)] + async fn expiry_reclaims_bytes_without_another_read_and_reaper_ends_with_owner() { + // Exercise host construction outside a runtime, followed by real async admission. + let root = std::thread::spawn(BashOutputView::new).join().unwrap(); + finish(&root, b"expires without lookup"); + let mut closed = root.store.lock().unwrap()._shutdown.subscribe(); + tokio::task::yield_now().await; + // The store uses monotonic wall time for publication and Tokio time only for scheduling. + // Age the record deterministically rather than delaying this test in real time. + root.store.lock().unwrap().records[0] + .lock() + .unwrap() + .created = Instant::now() - RETENTION; + tokio::time::advance(Duration::from_secs(31)).await; + tokio::task::yield_now().await; + assert_eq!(root.store.lock().unwrap().reserved, 0); + drop(root); + assert!(closed.changed().await.is_err()); + } + #[test] + fn terminal_counts_must_fence_the_received_prefix_before_marking_evidence_complete() { + let root = BashOutputView::new(); + let mut writer = root.begin().unwrap(); + writer.append(CommandStream::Stdout, b"abc"); + let mut terminal = serde_json::json!({"status":"completed","exit_code":0,"stdout_bytes":9,"stderr_bytes":0,"stdout_delivered_bytes":4,"stderr_delivered_bytes":0,"truncation_reason":"byte_budget"}); + assert!( + writer + .finish_transport(&serde_json::from_value(terminal.clone()).unwrap()) + .is_err() + ); + terminal["stdout_delivered_bytes"] = serde_json::json!(3); + let metadata = writer + .finish_transport(&serde_json::from_value(terminal).unwrap()) + .unwrap(); + assert_eq!(metadata.stdout_saved, 3); + assert_eq!(metadata.stdout_delivered, 3); + assert_eq!(metadata.stdout_seen, 9); + assert!(metadata.storage_truncated); + assert!(metadata.retention_limit.is_none()); + assert_eq!( + metadata.transport_truncation, + Some(workdir::CommandOutputTruncation::ByteBudget) + ); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 3bdb410d5..6efd98698 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -5,6 +5,7 @@ pub mod error; pub mod tracker; mod bash; +pub mod bash_output; mod edit; mod file_target; mod glob; @@ -16,7 +17,7 @@ pub mod view_image; mod web; mod write; -pub use bash::bash_tool; +pub use bash::{BashResult, BashTool, bash_tool}; pub use checkout::{CheckoutToolOutput, execute_checkout_tool}; pub use edit::edit_tool; pub use error::ToolsError; @@ -35,7 +36,7 @@ pub use write::write_tool; pub fn core_builtin_tools( session: workdir::WorkdirSessionHandle, tracker: Tracker, - bash_output_dir: std::path::PathBuf, + bash_output: bash_output::BashOutputView, ) -> Vec { use workdir::WorkdirSessionCapability; @@ -58,7 +59,7 @@ pub fn core_builtin_tools( tools.push(grep::routed_grep_tool(router.clone())); } if capabilities.supports(WorkdirSessionCapability::Command) { - tools.push(bash::routed_bash_tool(router, bash_output_dir)); + tools.push(bash::routed_bash_tool(router, bash_output)); } tools } @@ -71,7 +72,7 @@ pub fn core_builtin_tools( pub fn routed_builtin_tools( router: std::sync::Arc, tracker: Tracker, - bash_output_dir: std::path::PathBuf, + bash_output: bash_output::BashOutputView, ) -> Vec { vec![ read::routed_read_tool(router.clone(), tracker.clone()), @@ -79,14 +80,14 @@ pub fn routed_builtin_tools( edit::routed_edit_tool(router.clone(), tracker), glob::routed_glob_tool(router.clone()), grep::routed_grep_tool(router.clone()), - bash::routed_bash_tool(router, bash_output_dir), + bash::routed_bash_tool(router, bash_output), ] } pub fn routed_builtin_tools_for_current_capabilities( router: std::sync::Arc, tracker: Tracker, - bash_output_dir: std::path::PathBuf, + bash_output: bash_output::BashOutputView, ) -> Vec { use workdir::WorkdirSessionCapability; @@ -108,7 +109,7 @@ pub fn routed_builtin_tools_for_current_capabilities( tools.push(grep::routed_grep_tool(router.clone())); } if capabilities.supports(WorkdirSessionCapability::Command) { - tools.push(bash::routed_bash_tool(router, bash_output_dir)); + tools.push(bash::routed_bash_tool(router, bash_output)); } tools } @@ -121,7 +122,7 @@ pub fn read_only_builtin_tools( workdir::WorkdirSessionCapabilities::READ_ONLY, "read-only tool projection requires a read-only Workdir session" ); - core_builtin_tools(session, Tracker::new(), std::path::PathBuf::new()) + core_builtin_tools(session, Tracker::new(), bash_output::BashOutputView::new()) } pub fn read_only_routed_builtin_tools( @@ -173,7 +174,7 @@ mod workdir_tool_tests { SharedScope::new(Scope::writable(dir.path()).unwrap()), WorkdirSessionCapabilities::READ_ONLY, )); - let names = core_builtin_tools(session, Tracker::new(), dir.path().join("output")) + let names = core_builtin_tools(session, Tracker::new(), bash_output::BashOutputView::new()) .into_iter() .map(|definition| definition().0.name) .collect::>(); diff --git a/crates/tools/tests/edge_cases.rs b/crates/tools/tests/edge_cases.rs index 252f6001c..8d5abd689 100644 --- a/crates/tools/tests/edge_cases.rs +++ b/crates/tools/tests/edge_cases.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use agen::tool::{Tool, ToolDefinition}; -use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; +use manifest::Scope; use serde_json::json; use tempfile::TempDir; use tools::{Tracker, core_builtin_tools}; @@ -28,31 +28,23 @@ impl Registry { } } -fn setup() -> (TempDir, TempDir, Registry) { +fn setup() -> (TempDir, Registry) { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let base = Scope::writable(dir.path()).unwrap(); - let mut config = ScopeConfig { - allow: base.allow_rules(), - deny: base.deny_rules(), - }; - config.allow.push(ScopeRule { - target: spill.path().to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }); - let scope = Scope::from_config(&config).unwrap(); + let scope = Scope::writable(dir.path()).unwrap(); let fs: WorkdirSessionHandle = std::sync::Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let tracker = Tracker::new(); - let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf())); - (dir, spill, reg) + let reg = Registry::new(core_builtin_tools( + fs, + tracker, + tools::bash_output::BashOutputView::new(), + )); + (dir, reg) } #[tokio::test] async fn unicode_path_and_content() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("日本語ファイル.txt"); let content = "こんにちは 🦀 世界\nabc\n"; @@ -87,7 +79,7 @@ async fn unicode_path_and_content() { async fn symlink_to_outside_scope_is_rejected_for_write() { use std::os::unix::fs::symlink; - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let outside = TempDir::new().unwrap(); let outside_target = outside.path().join("secret.txt"); std::fs::write(&outside_target, "secret").unwrap(); @@ -151,7 +143,7 @@ async fn symlink_to_outside_scope_is_rejected_for_write() { async fn broken_symlink_reports_target_and_repair_hint() { use std::os::unix::fs::symlink; - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let link = dir.path().join("external-project"); let target = dir.path().join("missing-target"); symlink(&target, &link).unwrap(); @@ -173,7 +165,7 @@ async fn broken_symlink_reports_target_and_repair_hint() { #[tokio::test] async fn empty_file_read_and_edit() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("empty.txt"); std::fs::write(&file, "").unwrap(); @@ -207,7 +199,7 @@ async fn empty_file_read_and_edit() { #[tokio::test] async fn very_long_single_line() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("long.txt"); let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines std::fs::write(&file, &big).unwrap(); @@ -226,7 +218,7 @@ async fn very_long_single_line() { #[tokio::test] async fn absolute_path_requires_matching_read_scope() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let outside = tempfile::tempdir().unwrap(); let outside_file = outside.path().join("outside.txt"); std::fs::write(&outside_file, "secret").unwrap(); @@ -247,7 +239,7 @@ async fn absolute_path_requires_matching_read_scope() { #[tokio::test] async fn directory_target_is_rejected_for_read() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let read = reg.get("Read"); let err = read .execute(&json!({ "file_path": "." }).to_string(), Default::default()) @@ -258,7 +250,7 @@ async fn directory_target_is_rejected_for_read() { #[tokio::test] async fn deeply_nested_new_file_is_created() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let deep = dir.path().join("a/b/c/d/e/deep.txt"); let write = reg.get("Write"); write @@ -277,7 +269,7 @@ async fn deeply_nested_new_file_is_created() { #[tokio::test] async fn replace_preserves_unicode() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("u.txt"); std::fs::write(&file, "🦀 rust 🦀\n").unwrap(); @@ -306,7 +298,7 @@ async fn replace_preserves_unicode() { #[tokio::test] async fn grep_handles_unicode_pattern() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("u.txt"); std::fs::write(&file, "English\n日本語\nрусский\n").unwrap(); diff --git a/crates/tools/tests/integration.rs b/crates/tools/tests/integration.rs index c8c55e8d6..accfb59ef 100644 --- a/crates/tools/tests/integration.rs +++ b/crates/tools/tests/integration.rs @@ -11,7 +11,7 @@ use agen::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle, ToolExecutionTerminal, ToolMeta, }; -use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; +use manifest::Scope; use serde_json::json; use tempfile::TempDir; use tools::{ @@ -23,21 +23,6 @@ use workdir::{ WorkdirToolScopeRule, }; -fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope { - let base = Scope::writable(workspace).unwrap(); - let mut config = ScopeConfig { - allow: base.allow_rules(), - deny: base.deny_rules(), - }; - config.allow.push(ScopeRule { - target: spill.to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }); - Scope::from_config(&config).unwrap() -} - struct Registry { entries: Vec<(ToolMeta, Arc)>, } @@ -61,39 +46,38 @@ impl Registry { } } -fn setup() -> (TempDir, TempDir, Registry) { +fn setup() -> (TempDir, Registry) { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let fs: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let tracker = Tracker::new(); - let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf())); - (dir, spill, reg) + let reg = Registry::new(core_builtin_tools( + fs, + tracker, + tools::bash_output::BashOutputView::new(), + )); + (dir, reg) } -fn setup_routed() -> ( - TempDir, - TempDir, - TempDir, - Arc, - Registry, -) { +fn setup_routed() -> (TempDir, TempDir, Arc, Registry) { let left = TempDir::new().unwrap(); let right = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); let router = Arc::new(WorkdirSessionRouter::new()); for (alias, dir) in [("left", &left), ("right", &right)] { - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); router .attach(WorkdirAttachmentAlias::new(alias).unwrap(), session) .unwrap(); } - let definitions = - routed_builtin_tools(router.clone(), Tracker::new(), spill.path().to_path_buf()); - (left, right, spill, router, Registry::new(definitions)) + let definitions = routed_builtin_tools( + router.clone(), + Tracker::new(), + tools::bash_output::BashOutputView::new(), + ); + (left, right, router, Registry::new(definitions)) } fn valid_gif_with_len(target_len: usize) -> Vec { @@ -137,7 +121,7 @@ async fn call_err(tool: &Arc, input: serde_json::Value) -> agen::tool: #[test] fn core_builtin_tools_registers_full_set() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let mut names = reg.names(); names.sort(); assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]); @@ -145,7 +129,7 @@ fn core_builtin_tools_registers_full_set() { #[test] fn meta_has_description_and_schema() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); for (meta, _) in ®.entries { assert!( !meta.description.is_empty(), @@ -163,7 +147,7 @@ fn meta_has_description_and_schema() { #[test] fn routed_tool_schemas_share_optional_target_workdir() { - let (_left, _right, _spill, router, mut reg) = setup_routed(); + let (_left, _right, router, mut reg) = setup_routed(); reg.entries.push(routed_view_image_tool(router)()); for (meta, _) in ®.entries { let target = &meta.input_schema["properties"]["target_workdir"]; @@ -174,7 +158,7 @@ fn routed_tool_schemas_share_optional_target_workdir() { #[tokio::test] async fn routed_tools_require_alias_only_when_attachment_set_is_ambiguous() { - let (_left, _right, _spill, router, reg) = setup_routed(); + let (_left, _right, router, reg) = setup_routed(); let error = call_err(®.get("Read"), json!({ "file_path": "same.txt" })).await; let message = error.to_string(); assert!(message.contains("target_workdir_required"), "{message}"); @@ -199,7 +183,7 @@ async fn empty_routed_surface_returns_no_attachment_error() { let reg = Registry::new(routed_builtin_tools( router, Tracker::new(), - Path::new("unused").to_path_buf(), + tools::bash_output::BashOutputView::new(), )); let message = call_err(®.get("Read"), json!({ "file_path": "file.txt" })) .await @@ -209,7 +193,7 @@ async fn empty_routed_surface_returns_no_attachment_error() { #[tokio::test] async fn target_alias_routes_each_tool_and_isolates_read_before_edit() { - let (left, right, _spill, router, mut reg) = setup_routed(); + let (left, right, router, mut reg) = setup_routed(); std::fs::write(left.path().join("same.txt"), "left value\n").unwrap(); std::fs::write(right.path().join("same.txt"), "right value\n").unwrap(); std::fs::write(left.path().join("only-left.log"), "LEFT-NEEDLE\n").unwrap(); @@ -282,7 +266,7 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() { ) .await; assert_eq!( - std::fs::canonicalize(bash.content.unwrap().trim()).unwrap(), + std::fs::canonicalize(serde_json::from_str::(&bash.content.unwrap()).unwrap()["stdout_preview"].as_str().unwrap().trim()).unwrap(), std::fs::canonicalize(right.path()).unwrap() ); @@ -304,13 +288,12 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() { async fn selected_attachment_capability_is_checked_without_fallback() { let left = TempDir::new().unwrap(); let right = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); let router = Arc::new(WorkdirSessionRouter::new()); router .attach( WorkdirAttachmentAlias::new("writable").unwrap(), Arc::new(LocalWorkdirSession::new( - scope_with_spill(left.path(), spill.path()), + Scope::writable(left.path()).unwrap(), left.path().to_path_buf(), )), ) @@ -322,7 +305,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() { workdir::Workdir::new("readonly"), right.path().to_path_buf(), right.path().to_path_buf(), - manifest::SharedScope::new(scope_with_spill(right.path(), spill.path())), + manifest::SharedScope::new(Scope::writable(right.path()).unwrap()), workdir::WorkdirSessionCapabilities::READ_ONLY, )), ) @@ -330,7 +313,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() { let reg = Registry::new(routed_builtin_tools( router.clone(), Tracker::new(), - spill.path().to_path_buf(), + tools::bash_output::BashOutputView::new(), )); let message = call_err( @@ -352,7 +335,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() { #[tokio::test] async fn routed_tools_observe_detach_after_registration() { - let (_left, _right, _spill, router, reg) = setup_routed(); + let (_left, _right, router, reg) = setup_routed(); let alias = WorkdirAttachmentAlias::new("left").unwrap(); router.detach(&alias).await.unwrap(); @@ -372,7 +355,6 @@ async fn routed_tools_observe_detach_after_registration() { async fn detach_and_reattach_same_alias_requires_a_fresh_read() { let first = TempDir::new().unwrap(); let second = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); std::fs::write(first.path().join("same.txt"), "same bytes\n").unwrap(); std::fs::write(second.path().join("same.txt"), "same bytes\n").unwrap(); @@ -382,7 +364,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { .attach( alias.clone(), Arc::new(LocalWorkdirSession::new( - scope_with_spill(first.path(), spill.path()), + Scope::writable(first.path()).unwrap(), first.path().to_path_buf(), )), ) @@ -390,7 +372,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { let reg = Registry::new(routed_builtin_tools( router.clone(), Tracker::new(), - spill.path().to_path_buf(), + tools::bash_output::BashOutputView::new(), )); call( ®.get("Read"), @@ -403,7 +385,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { .attach( alias.clone(), Arc::new(LocalWorkdirSession::new( - scope_with_spill(second.path(), spill.path()), + Scope::writable(second.path()).unwrap(), second.path().to_path_buf(), )), ) @@ -432,6 +414,8 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { struct ExternalValidatingSession { inner: LocalWorkdirSession, truncate_first_binary_response: bool, + command_response_losses: std::sync::atomic::AtomicUsize, + lose_terminal_only: bool, mutate_after_first_binary_response: Option, } @@ -550,7 +534,23 @@ impl workdir::WorkdirSession for ExternalValidatingSession { &self, request: workdir::CommandOutputRequest, ) -> Result { - self.inner.command_output(request).await + let output = self.inner.command_output(request).await?; + if (!self.lose_terminal_only || output.terminal.is_some()) + && self + .command_response_losses + .fetch_update( + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + |remaining| remaining.checked_sub(1), + ) + .is_ok() + { + // The production provider has committed a page, but its response is lost. + return Err(workdir::WorkdirError::Transport( + "injected lost response".into(), + )); + } + Ok(output) } async fn cancel_command( @@ -568,11 +568,12 @@ impl workdir::WorkdirSession for ExternalValidatingSession { #[tokio::test] async fn external_view_image_uses_a_protocol_bounded_request() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()), truncate_first_binary_response: false, + command_response_losses: std::sync::atomic::AtomicUsize::new(0), + lose_terminal_only: false, mutate_after_first_binary_response: None, }); let png = include_bytes!("fixtures/view-image/valid.png"); @@ -590,13 +591,14 @@ async fn external_view_image_uses_a_protocol_bounded_request() { #[tokio::test] async fn external_view_image_rejects_truncated_and_cross_version_chunks() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); let image_path = dir.path().join("image.png"); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); std::fs::write(&image_path, include_bytes!("fixtures/view-image/valid.png")).unwrap(); let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()), truncate_first_binary_response: true, + command_response_losses: std::sync::atomic::AtomicUsize::new(0), + lose_terminal_only: false, mutate_after_first_binary_response: None, }); let (_meta, tool) = view_image_tool(truncated)(); @@ -613,6 +615,8 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() { let changing: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()), truncate_first_binary_response: false, + command_response_losses: std::sync::atomic::AtomicUsize::new(0), + lose_terminal_only: false, mutate_after_first_binary_response: Some(image_path), }); let (_meta, tool) = view_image_tool(changing)(); @@ -626,8 +630,7 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() { async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() { const LIMIT: usize = 10 * 1024 * 1024; let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let (_meta, tool) = view_image_tool(session)(); @@ -650,8 +653,7 @@ async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() #[tokio::test] async fn view_image_attaches_valid_supported_image_fixtures() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let (_meta, tool) = view_image_tool(session)(); @@ -726,8 +728,7 @@ async fn delegated_view_image_preserves_caller_relative_path_identity() { #[tokio::test] async fn view_image_reads_scoped_bytes_into_durable_tool_detail() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let png = include_bytes!("fixtures/view-image/valid.png"); @@ -752,7 +753,7 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() { #[tokio::test] async fn read_then_edit_then_read_roundtrip() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "hello world\n").unwrap(); let p = "a.txt"; @@ -784,7 +785,7 @@ async fn read_then_edit_then_read_roundtrip() { #[tokio::test] async fn write_then_grep_finds_content() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let write = reg.get("Write"); let grep = reg.get("Grep"); @@ -813,7 +814,7 @@ async fn write_then_grep_finds_content() { #[tokio::test] async fn glob_finds_written_files() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let write = reg.get("Write"); let glob = reg.get("Glob"); @@ -837,7 +838,7 @@ async fn glob_finds_written_files() { #[tokio::test] async fn absolute_path_is_rejected() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let outside = TempDir::new().unwrap(); let write = reg.get("Write"); @@ -859,7 +860,7 @@ async fn absolute_path_is_rejected() { #[tokio::test] async fn write_to_existing_without_read_fails() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("exists.txt"); std::fs::write(&file, "preexisting").unwrap(); @@ -880,7 +881,7 @@ async fn write_to_existing_without_read_fails() { async fn shared_workdir_across_tools() { // The key invariant: all builtin tools share the same WorkdirSession instance, // so read-history set by Read is visible to Edit and Write. - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("shared.txt"); std::fs::write(&file, "one\n").unwrap(); @@ -907,7 +908,7 @@ async fn shared_workdir_across_tools() { #[tokio::test] async fn edit_requires_read_across_tools() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "foo\n").unwrap(); @@ -928,7 +929,7 @@ async fn edit_requires_read_across_tools() { #[tokio::test] async fn deterministic_tool_order_is_registration_order() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); // Registration order from core_builtin_tools(): Read, Write, Edit, Glob, Grep, Bash let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect(); assert_eq!( @@ -940,7 +941,7 @@ async fn deterministic_tool_order_is_registration_order() { // Regression: tool name capitalization matches Claude Code reference #[test] fn tool_names_match_reference_spec() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); for expected in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] { assert!( reg.entries.iter().any(|(m, _)| m.name == expected), @@ -953,15 +954,14 @@ fn tool_names_match_reference_spec() { async fn tracker_recent_files_tracks_read_write_edit() { // Build a fresh registry that shares a tracker we can query afterwards. let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let fs: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let tracker = Tracker::new(); let reg = Registry::new(core_builtin_tools( fs, tracker.clone(), - spill.path().to_path_buf(), + tools::bash_output::BashOutputView::new(), )); let a = dir.path().join("a.txt"); @@ -1003,118 +1003,314 @@ async fn tracker_recent_files_tracks_read_write_edit() { async fn bash_inherits_workdir_cwd() { // The Bash tool starts at the WorkdirSession's pwd. Without any `cd`, its // `pwd` should canonicalize to the workspace root we set up. - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let bash = reg.get("Bash"); let out = call(&bash, json!({ "command": "pwd", "cwd": "." })).await; let body = out.content.unwrap(); - let actual = std::fs::canonicalize(body.trim()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&body).unwrap(); + let actual = std::fs::canonicalize(parsed["stdout_preview"].as_str().unwrap().trim()).unwrap(); let expected = std::fs::canonicalize(dir.path()).unwrap(); assert_eq!(actual, expected); } +fn output_fixture() -> ( + TempDir, + WorkdirSessionHandle, + tools::bash_output::BashOutputView, + Arc, +) { + let root = TempDir::new().unwrap(); + let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new( + Scope::writable(root.path()).unwrap(), + root.path().into(), + )); + let view = tools::bash_output::BashOutputView::new(); + let (_, bash) = tools::bash_tool(session.clone(), view.clone())(); + (root, session, view, bash) +} + #[tokio::test] -async fn bash_provider_output_exposes_readable_retained_path() { - let (_dir, spill, reg) = setup(); - let bash = reg.get("Bash"); +async fn bash_output_is_retained_without_a_filesystem_grant_or_telemetry_subscriber() { + let (root, _session, view, bash) = output_fixture(); let out = call( &bash, - json!({ "command": "printf 'x%.0s' {1..20480}", "cwd": "." }), + json!({ "command": "printf 'x%.0s' {1..20480}; printf '\\377€' >&2; exit 7", "cwd": "." }), ) .await; - let body = out.content.unwrap(); - assert!(body.contains("bounded WorkdirSession command output")); - assert!(body.contains("full output saved to")); - assert!(body.contains(spill.path().to_str().unwrap())); - let artifact = std::fs::read_dir(spill.path()) - .unwrap() - .next() - .expect("retained output") - .unwrap() - .path(); - assert_eq!(std::fs::metadata(artifact).unwrap().len(), 20_480); + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + assert_eq!(body["output"]["terminal"]["exit_code"], 7); + assert_eq!(body["preview_omitted"], true); + assert_eq!(body["output"]["storage_truncated"], false); + let id = body["output"]["id"].as_str().unwrap(); + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, 0, 65536) + .unwrap(), + vec![b'x'; 20480] + ); + assert_eq!( + view.read(id, workdir::CommandStream::Stderr, 0, 65536) + .unwrap(), + [vec![255], "€".as_bytes().to_vec()].concat() + ); + assert!( + body["stdout_entry"] + .as_str() + .unwrap() + .starts_with("/bash-output/") + ); + assert!(!body.to_string().contains(root.path().to_str().unwrap())); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0); +} + +async fn await_command_output( + events: &mut tokio::sync::broadcast::Receiver, +) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if matches!( + events.recv().await.unwrap(), + workdir::CommandEvent::Output { .. } + ) { + break; + } + } + }) + .await + .expect("command output readiness"); +} +async fn await_command_terminal( + events: &mut tokio::sync::broadcast::Receiver, +) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let workdir::CommandEvent::Terminal { status, .. } = events.recv().await.unwrap() { + assert_eq!(status, workdir::CommandStatus::Cancelled); + break; + } + } + }) + .await + .expect("command terminal fence"); } #[tokio::test] async fn bash_cancellation_returns_bounded_progress_as_terminal_output() { - let (dir, _spill, reg) = setup(); - let marker = dir.path().join("must-not-run-after-cancel"); - let command = format!( - "printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'", - marker.display() - ); - let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap(); + let (_root, session, _view, bash) = output_fixture(); + let mut events = session.subscribe_command_events().unwrap(); let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0); - let bash = reg.get("Bash"); let executing = bash.clone(); let execution_context = context.clone(); - let execution = tokio::spawn(async move { executing.execute(&input, execution_context).await }); - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - bash.cancel_execution(&context) - .await - .expect("signal exact execution cancellation"); - let error = tokio::time::timeout(std::time::Duration::from_secs(2), execution) - .await - .expect("cancelled Bash should terminate inside the Engine grace budget") - .expect("Bash task join"); - - let ToolError::Cancelled(output) = error.expect_err("cancelled command is non-success") else { - panic!("expected typed cancellation result"); + let execution = tokio::spawn(async move { + executing.execute(r#"{"command":"printf 'before'; printf 'err-before' >&2; while :; do :; done","cwd":"."}"#, execution_context).await + }); + await_command_output(&mut events).await; + bash.cancel_execution(&context).await.unwrap(); + let ToolError::Cancelled(output) = + tokio::time::timeout(std::time::Duration::from_secs(5), execution) + .await + .unwrap() + .unwrap() + .unwrap_err() + else { + panic!("expected cancellation"); }; - let content = output.content.expect("bounded progress output"); - assert!( - content.contains("before"), - "missing pre-cancel stdout: {content}" - ); - assert!( - content.contains("err-before"), - "missing pre-cancel stderr: {content}" - ); - assert!( - !content.contains("after"), - "post-cancel output leaked: {content}" - ); - assert!(content.len() <= 16 * 1024, "output must remain bounded"); - - tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; - assert!( - !marker.exists(), - "the cancelled command continued executing after terminal confirmation" - ); + let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(body["stdout_preview"], "before"); + assert_eq!(body["stderr_preview"], "err-before"); + assert_eq!(body["output"]["terminal"]["status"], "cancelled"); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); } #[tokio::test] async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() { - let (dir, _spill, reg) = setup(); - let marker = dir.path().join("must-not-survive-force-close"); - let command = format!("sleep 1; touch {}", marker.display()); - let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap(); - let bash = reg.get("Bash"); + let (_root, session, _view, bash) = output_fixture(); + let mut events = session.subscribe_command_events().unwrap(); let context = ToolExecutionContext::new("call-force", "attempt-force", 0); - let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context); - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let (handle, terminal) = ToolExecutionHandle::start( + bash.clone(), + r#"{"command":"printf ready; while :; do :; done","cwd":"."}"#.into(), + context, + ); + await_command_output(&mut events).await; handle.force_close(); assert!(matches!( terminal.await, ToolExecutionTerminal::OutcomeUnknown )); - - tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; - assert!( - !marker.exists(), - "CommandGuard cleanup allowed a force-closed command to continue" - ); - - let output = bash - .execute( - r#"{"command":"printf 'reused'","cwd":"."}"#, - Default::default(), - ) - .await - .expect("workdir session remains reusable after cleanup"); - assert_eq!(output.content.as_deref(), Some("reused")); + await_command_terminal(&mut events).await; + let output = call(&bash, json!({"command":"printf reused", "cwd":"."})).await; + let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(body["stdout_preview"], "reused"); } // Sanity: unused Path import guard const _: fn() -> &'static Path = || Path::new("/"); + +#[tokio::test] +async fn parent_and_sibling_broker_commands_keep_output_ownership_after_revoke() { + let (root, source, parent_view, parent_bash) = output_fixture(); + let broker = WorkdirToolBroker::new(source); + let mut leases = Vec::new(); + let mut views = Vec::new(); + let mut commands = Vec::new(); + for name in ["left", "right"] { + std::fs::create_dir(root.path().join(name)).unwrap(); + let lease = Arc::new( + broker + .scope(WorkdirToolScope { + rules: vec![WorkdirToolScopeRule { + target: WorkdirPath::new(name).unwrap(), + permission: WorkdirToolScopePermission::Write, + recursive: true, + symlink_policy: Default::default(), + }], + cwd: WorkdirPath::new(name).unwrap(), + command: true, + }) + .await + .unwrap(), + ); + let authority = lease.clone(); + let view = parent_view.child(Arc::new(move || authority.is_active())); + let (_, bash) = tools::bash_tool(lease.tool_session(), view.clone())(); + leases.push(lease); + views.push(view); + commands.push(bash); + } + let left_input = + json!({"command":"printf 'left-%.0s' {1..10000}; printf left-err >&2", "cwd":"."}); + let right_input = + json!({"command":"printf 'right-%.0s' {1..10000}; printf right-err >&2", "cwd":"."}); + let parent_input = json!({"command":"printf parent; printf parent-err >&2", "cwd":"."}); + let (left, right, parent) = tokio::join!( + call(&commands[0], left_input), + call(&commands[1], right_input), + call(&parent_bash, parent_input) + ); + let outputs = [left, right, parent] + .map(|out| serde_json::from_str::(&out.content.unwrap()).unwrap()); + for (index, label) in ["left", "right", "parent"].iter().enumerate() { + let id = outputs[index]["output"]["id"].as_str().unwrap(); + let bytes = parent_view + .read(id, workdir::CommandStream::Stderr, 0, 65536) + .unwrap(); + assert_eq!(bytes, format!("{label}-err").as_bytes()); + for (child_index, view) in views.iter().enumerate() { + assert_eq!(view.metadata(id).is_some(), child_index == index); + } + } + let left_id = outputs[0]["output"]["id"].as_str().unwrap(); + leases[0].close().await.unwrap(); + assert!(views[0].metadata(left_id).is_none()); + assert!(parent_view.metadata(left_id).is_some()); + assert!( + views[1] + .metadata(outputs[1]["output"]["id"].as_str().unwrap()) + .is_some() + ); + leases[1].close().await.unwrap(); + broker.close().await.unwrap(); + parent_view.revoke(); + assert!(parent_view.metadata(left_id).is_none()); +} + +#[tokio::test] +async fn utf8_quota_boundary_preserves_raw_prefix_and_reports_saved_vs_seen() { + let (_root, _session, view, bash) = output_fixture(); + let out = call( + &bash, + json!({"command":"printf '%4194303s' ''; printf '€'", "cwd":"."}), + ) + .await; + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + let id = body["output"]["id"].as_str().unwrap(); + let saved = body["output"]["stdout_saved"].as_u64().unwrap() as usize; + assert_eq!(saved, 4 * 1024 * 1024); + assert_eq!(body["output"]["stdout_seen"], 4 * 1024 * 1024 + 2); + assert_eq!(body["output"]["stderr_seen"], 0); + assert_eq!(body["output"]["storage_truncated"], true); + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, saved - 1, 10) + .unwrap(), + vec![0xe2] + ); + assert!( + view.line_window(id, workdir::CommandStream::Stdout, 0, 1) + .unwrap() + .is_err() + ); + assert!(out.summary.starts_with("$ ")); +} + +#[tokio::test] +async fn timeout_terminal_fences_retained_stdout_and_stderr() { + let (_root, _session, view, bash) = output_fixture(); + let error = tokio::time::timeout(std::time::Duration::from_secs(5), bash.execute(r#"{"command":"printf before; printf err-before >&2; while :; do :; done","cwd":".","timeout":1}"#, Default::default())).await.unwrap().unwrap_err(); + let ToolError::Interrupted(out) = error else { + panic!("expected timeout output"); + }; + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + let id = body["output"]["id"].as_str().unwrap(); + assert_eq!(body["output"]["terminal"]["status"], "timed_out"); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, 0, 65536) + .unwrap(), + b"before" + ); + assert_eq!( + view.read(id, workdir::CommandStream::Stderr, 0, 65536) + .unwrap(), + b"err-before" + ); +} + +// Integration: real process pipes and provider replay into the production Bash collector. +// Only response delivery is fault-injected; Runtime's HTTP suite covers actual wire routing. +#[tokio::test] +async fn lost_page_and_terminal_responses_replay_without_restarting_or_double_appending() { + for lose_terminal_only in [false, true] { + let dir = TempDir::new().unwrap(); + let session = Arc::new(ExternalValidatingSession { + inner: LocalWorkdirSession::new( + Scope::writable(dir.path()).unwrap(), + dir.path().into(), + ), + truncate_first_binary_response: false, + mutate_after_first_binary_response: None, + command_response_losses: std::sync::atomic::AtomicUsize::new(1), + lose_terminal_only, + }); + let view = tools::bash_output::BashOutputView::new(); + let definitions = core_builtin_tools(session.clone(), Tracker::new(), view.clone()); + let bash = Registry::new(definitions).get("Bash"); + let out = call(&bash, json!({"cwd":".","command":"printf once >> effect.txt; printf 'x%.0s' {1..200000}; printf err >&2; exit 3"})).await; + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + assert_eq!( + std::fs::read(dir.path().join("effect.txt")).unwrap(), + b"once" + ); + assert_eq!( + session + .command_response_losses + .load(std::sync::atomic::Ordering::Acquire), + 0 + ); + assert_eq!(body["output"]["stdout_saved"], 200000); + assert_eq!(body["output"]["stdout_delivered"], 200000); + assert_eq!(body["output"]["stderr_saved"], 3); + assert_eq!(body["output"]["terminal"]["exit_code"], 3); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert_eq!(body["output"]["storage_truncated"], false); + let id = body["output"]["id"].as_str().unwrap(); + for offset in (0..200000).step_by(65536) { + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, offset, 65536) + .unwrap(), + vec![b'x'; (200000 - offset).min(65536)] + ); + } + use workdir::WorkdirSession as _; + session.close().await.unwrap(); + } +} diff --git a/crates/workdir/Cargo.toml b/crates/workdir/Cargo.toml index 0afb851e7..b8541897a 100644 --- a/crates/workdir/Cargo.toml +++ b/crates/workdir/Cargo.toml @@ -20,7 +20,7 @@ serde_json.workspace = true sha2.workspace = true tempfile.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["process", "rt", "sync", "time"] } +tokio = { workspace = true, features = ["io-util", "process", "rt", "sync", "time"] } [dev-dependencies] serde_json.workspace = true diff --git a/crates/workdir/src/command_test_support.rs b/crates/workdir/src/command_test_support.rs new file mode 100644 index 000000000..62748a4d4 --- /dev/null +++ b/crates/workdir/src/command_test_support.rs @@ -0,0 +1,43 @@ +//! Bounded collection for tests whose subject is command routing/lifecycle, +//! rather than the byte-page protocol itself. + +use crate::*; + +#[derive(Debug)] +pub(crate) struct CollectedCommand { + pub content: String, + pub status: CommandStatus, + pub exit_code: Option, + pub timed_out: bool, +} + +#[async_trait::async_trait] +pub(crate) trait CollectCommand: WorkdirSession { + async fn collect_command_output( + &self, + mut request: CommandOutputRequest, + ) -> Result { + let mut bytes = Vec::new(); + request.wait = true; + loop { + let page = self.command_output(request.clone()).await?; + for chunk in page.chunks { + bytes.extend(chunk.bytes); + assert!( + bytes.len() <= 16 * 1024 * 1024, + "fixture output exceeded bounded test collection" + ); + } + request.cursor = page.next_cursor; + if let Some(terminal) = page.terminal { + return Ok(CollectedCommand { + content: String::from_utf8_lossy(&bytes).into_owned(), + status: terminal.status, + exit_code: terminal.exit_code, + timed_out: terminal.status == CommandStatus::TimedOut, + }); + } + } + } +} +impl CollectCommand for T {} diff --git a/crates/workdir/src/external.rs b/crates/workdir/src/external.rs index 203701bc0..aeed5a96f 100644 --- a/crates/workdir/src/external.rs +++ b/crates/workdir/src/external.rs @@ -9,7 +9,8 @@ //! COMMAND remains separate explicit authority. General session capability intersections may form //! subsets independently of this grant hierarchy; the codec never implicitly expands nonhierarchical //! grants. Commands run with the shared host's OS permissions, not a sandbox confining processes -//! to a directory. Output is bounded and inline; provider-local spill paths are not exposed. +//! to a directory. Output uses bounded byte pages with cursor acknowledgements; +//! providers never receive caller-selected output paths. use std::path::{Component, Path}; @@ -18,7 +19,7 @@ use serde::{Deserialize, Serialize}; use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError}; use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities}; -pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 2; +pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 3; /// Maximum serialized Backend-to-provider command frame size. pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024; /// Maximum serialized provider-to-Backend result frame size. Read bytes are @@ -33,9 +34,9 @@ const MAX_EXTERNAL_GREP_CONTEXT: usize = 100; const MAX_EXTERNAL_SCOPE_RULES: usize = 64; pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024; pub const MAX_EXTERNAL_COMMAND_BYTES: usize = 64 * 1024; -pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = 512 * 1024; +pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = crate::MAX_COMMAND_READ_BYTES; pub const MAX_EXTERNAL_COMMAND_TIMEOUT_SECS: u64 = 600; -const MAX_EXTERNAL_COMMAND_CURSOR: usize = 1_000_000; +const MAX_EXTERNAL_COMMAND_CURSOR: u64 = crate::MAX_COMMAND_TRANSPORT_BYTES; fn valid_command_handle(handle: &crate::CommandHandle) -> bool { !handle.0.is_empty() @@ -267,11 +268,10 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<() } WorkdirSessionOperation::CommandStart(request) => { !request.command.is_empty() + && request.validate().is_ok() && request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES && (1..=MAX_EXTERNAL_COMMAND_TIMEOUT_SECS).contains(&request.timeout_secs) - && (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.output_limit) && is_root_relative(&request.cwd) - && request.spill_dir.is_none() && request.tool_call_id.as_ref().is_none_or(|tool_call_id| { !tool_call_id.is_empty() && tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES @@ -450,11 +450,19 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<() WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle), WorkdirSessionOperationResult::CommandStatus(_) => true, WorkdirSessionOperationResult::CommandOutput(output) => { - output.content.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + output.transport_bounds_valid() + && output.chunks.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES && output - .next_cursor - .is_none_or(|cursor| cursor <= MAX_EXTERNAL_COMMAND_CURSOR) - && output.output_path.is_none() + .chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + && output.next_cursor <= MAX_EXTERNAL_COMMAND_CURSOR + && output + .terminal + .as_ref() + .is_none_or(|terminal| terminal.status != crate::CommandStatus::Running) } WorkdirSessionOperationResult::CommandCancel => true, }; @@ -801,11 +809,16 @@ mod tests { #[test] fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() { - let future = r#"{ - "version": 3, - "message": {"kind": "heartbeat", "generation": 1, "sequence": 1} - }"#; - assert!(serde_json::from_str::(future).is_err()); + for version in [ + EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION - 1, + EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION + 1, + ] { + let unsupported = serde_json::json!({ + "version": version, + "message": {"kind": "heartbeat", "generation": 1, "sequence": 1} + }); + assert!(serde_json::from_value::(unsupported).is_err()); + } let invalid_limits = r#"{ "max_source_bytes": 1024, @@ -825,32 +838,35 @@ mod tests { assert!(ExternalWorkdirOperation::try_from(absolute).is_err()); let command = WorkdirSessionOperation::CommandStart(CommandRequest { - command: "cat /home/operator/private/session.log".to_string(), + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf unsafe".to_string(), timeout_secs: 1, - output_limit: 1024, - cwd: WorkdirPath::root(), - spill_dir: Some("/home/operator/private/output".into()), + + cwd: serde_json::from_value(serde_json::json!("/absolute")).unwrap(), + tool_call_id: None, }); assert!(ExternalWorkdirOperation::try_from(command).is_err()); let command = WorkdirSessionOperation::CommandStart(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf bounded".to_string(), timeout_secs: 30, - output_limit: 12 * 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: Some("tool-call-1".to_string()), }); assert!(ExternalWorkdirOperation::try_from(command).is_ok()); assert!( ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart( CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1), timeout_secs: 30, - output_limit: 12 * 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, } )) @@ -887,13 +903,17 @@ mod tests { assert!( ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput( crate::CommandOutput { - status: crate::CommandStatus::Completed, - exit_code: Some(0), - timed_out: false, - content: "bounded".to_string(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: vec![], + next_cursor: 0, + terminal: Some(crate::CommandTerminal { + status: crate::CommandStatus::Completed, + exit_code: Some(0), + stdout_bytes: 0, + stderr_bytes: 0, + stdout_delivered_bytes: 0, + stderr_delivered_bytes: 0, + truncation_reason: None, + }) } )) .is_ok() @@ -901,13 +921,14 @@ mod tests { assert!( ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput( crate::CommandOutput { - status: crate::CommandStatus::Completed, - exit_code: Some(0), - timed_out: false, - content: "bounded".to_string(), - next_cursor: None, - truncated: true, - output_path: Some("/provider/private/output".into()), + chunks: vec![crate::CommandByteChunk { + sequence: 0, + stream: crate::CommandStream::Stdout, + offset: 0, + bytes: vec![0; MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + 1] + }], + next_cursor: 0, + terminal: None } )) .is_err() @@ -958,7 +979,7 @@ mod tests { #[test] fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() { let unknown = r#"{ - "version": 2, + "version": 3, "message": { "kind": "heartbeat", "generation": 1, @@ -1012,4 +1033,81 @@ mod tests { assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err()); assert!(ExternalWorkdirOperationId::new("operation-1").is_ok()); } + + #[test] + fn command_budget_and_page_metadata_obey_external_hard_bounds() { + fn request(budget: u64) -> WorkdirSessionOperation { + WorkdirSessionOperation::CommandStart(CommandRequest { + command: "true".into(), + timeout_secs: 1, + cwd: WorkdirPath::root(), + transport_byte_budget: budget, + tool_call_id: None, + }) + } + for budget in [0, crate::MAX_COMMAND_TRANSPORT_BYTES] { + let operation = ExternalWorkdirOperation::try_from(request(budget)).unwrap(); + let wire = serde_json::to_value(operation).unwrap(); + assert!(serde_json::from_value::(wire).is_ok()); + } + assert!( + ExternalWorkdirOperation::try_from(request(crate::MAX_COMMAND_TRANSPORT_BYTES + 1)) + .is_err() + ); + fn page() -> crate::CommandOutput { + crate::CommandOutput { + chunks: vec![crate::CommandByteChunk { + sequence: 0, + stream: crate::CommandStream::Stdout, + offset: 0, + bytes: b"abc".to_vec(), + }], + next_cursor: 3, + terminal: Some(crate::CommandTerminal { + status: crate::CommandStatus::Completed, + exit_code: Some(0), + stdout_bytes: 100 * 1024 * 1024, + stderr_bytes: 7, + stdout_delivered_bytes: 3, + stderr_delivered_bytes: 0, + truncation_reason: Some(crate::CommandOutputTruncation::ByteBudget), + }), + } + } + let accepted = ExternalWorkdirOperationResult::try_from( + WorkdirSessionOperationResult::CommandOutput(page()), + ) + .unwrap(); + assert!( + serde_json::from_value::( + serde_json::to_value(accepted).unwrap() + ) + .is_ok() + ); + for mutate in [ + |p: &mut crate::CommandOutput| p.next_cursor = crate::MAX_COMMAND_TRANSPORT_BYTES + 1, + |p: &mut crate::CommandOutput| p.chunks[0].offset = crate::MAX_COMMAND_TRANSPORT_BYTES, + |p: &mut crate::CommandOutput| p.chunks[0].sequence = u64::MAX, + |p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().stdout_delivered_bytes = 4, + |p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().stdout_bytes = 2, + |p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().truncation_reason = None, + ] { + let mut invalid = page(); + mutate(&mut invalid); + let wire = serde_json::to_value(WorkdirSessionOperationResult::CommandOutput(invalid)) + .unwrap(); + assert!(serde_json::from_value::(wire).is_err()); + } + assert!( + ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandOutput( + crate::CommandOutputRequest { + handle: crate::CommandHandle("command-1".into()), + cursor: crate::MAX_COMMAND_TRANSPORT_BYTES + 1, + limit: 1, + wait: false, + } + )) + .is_err() + ); + } } diff --git a/crates/workdir/src/external_local_tests.rs b/crates/workdir/src/external_local_tests.rs index 0a316586f..7332d6a06 100644 --- a/crates/workdir/src/external_local_tests.rs +++ b/crates/workdir/src/external_local_tests.rs @@ -1,6 +1,9 @@ +#[cfg(test)] +use crate::command_test_support::CollectCommand as _; use crate::http::{ WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation, }; + use crate::{ BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot, GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir, @@ -59,11 +62,12 @@ async fn external_local_provider_is_strictly_read_only() { let command = WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "echo changed > item.txt".to_string(), timeout_secs: 1, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -145,11 +149,12 @@ async fn external_local_provider_read_write_is_filesystem_only() { let command = WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "touch command-ran".to_string(), timeout_secs: 1, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -193,17 +198,18 @@ async fn external_command_permission_is_explicit_and_independent_from_file_acces let handle = session .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf command-ok".to_string(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await .unwrap(); let output = session - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 1024, diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index f9ff246db..32bcc6c60 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -822,6 +822,7 @@ mod client { &self, request: CommandRequest, ) -> Result { + request.validate()?; match self .operate(WorkdirSessionOperation::CommandStart(request)) .await? @@ -848,17 +849,39 @@ mod client { &self, request: CommandOutputRequest, ) -> Result { + if !(1..=crate::MAX_COMMAND_READ_BYTES).contains(&request.limit) { + return Err(WorkdirError::InvalidArgument( + "command byte limit is outside transport bounds".into(), + )); + } let wait = request.wait; - loop { - match self - .operate(WorkdirSessionOperation::CommandOutput(request.clone())) - .await? - { - WorkdirSessionOperationResult::CommandOutput(result) - if wait && result.status == CommandStatus::Running => {} - WorkdirSessionOperationResult::CommandOutput(result) => return Ok(result), - _ => return Err(Self::mismatch("command_output")), + match self + .operate(WorkdirSessionOperation::CommandOutput(request.clone())) + .await? + { + WorkdirSessionOperationResult::CommandOutput(result) => { + let size = result + .chunks + .iter() + .try_fold(0usize, |size, chunk| size.checked_add(chunk.bytes.len())); + if !result.transport_bounds_valid() + || size.is_none_or(|size| { + size > request.limit + || request.cursor.checked_add(size as u64) + != Some(result.next_cursor) + }) + { + return Err(Self::mismatch("bounded command byte page")); + } + if wait && result.chunks.is_empty() && result.terminal.is_none() { + // A waiting request must block at the provider. Never spin HTTP + // requests against an immediate empty response. A caller may retry + // this exact cursor with its own bounded transport retry policy. + return Err(Self::mismatch("waiting command byte page")); + } + return Ok(result); } + _ => return Err(Self::mismatch("command_output")), } } @@ -917,20 +940,47 @@ mod client { response: reqwest::Response, ) -> Result { if response.status().is_success() { - response.json().await.map_err(http_unavailable) + let bytes = bounded_response_bytes(response).await?; + serde_json::from_slice(&bytes) + .map_err(|_| WorkdirError::Transport("Runtime Workdir response was invalid".into())) } else { Err(decode_error(response).await) } } + async fn bounded_response_bytes( + mut response: reqwest::Response, + ) -> Result, WorkdirError> { + const MAX_RESPONSE_BYTES: usize = crate::external::MAX_EXTERNAL_PROVIDER_FRAME_BYTES; + if response + .content_length() + .is_some_and(|size| size > MAX_RESPONSE_BYTES as u64) + { + return Err(WorkdirError::Transport( + "Runtime Workdir response exceeded transport bound".into(), + )); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(http_unavailable)? { + if chunk.len() > MAX_RESPONSE_BYTES.saturating_sub(bytes.len()) { + return Err(WorkdirError::Transport( + "Runtime Workdir response exceeded transport bound".into(), + )); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + async fn decode_error(response: reqwest::Response) -> WorkdirError { - response - .json::() - .await - .map(WorkdirTransportError::into_workdir_error) - .unwrap_or_else(|_| { - WorkdirError::Transport("Runtime Workdir error response was invalid".to_string()) - }) + match bounded_response_bytes(response).await { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map(WorkdirTransportError::into_workdir_error) + .unwrap_or_else(|_| { + WorkdirError::Transport("Runtime Workdir error response was invalid".into()) + }), + Err(error) => error, + } } fn http_unavailable(_error: reqwest::Error) -> WorkdirError { diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index de9e7f8e5..7f052d5be 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -618,3 +618,6 @@ mod capability_tests { assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63); } } + +#[cfg(test)] +mod command_test_support; diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index f750bf0ee..2b819964a 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -13,7 +13,8 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Debug; -use std::io::{Read as _, Seek as _, SeekFrom, Write as _}; +#[cfg(test)] +use std::io::Write as _; #[cfg(unix)] use std::os::unix::process::CommandExt as _; use std::path::{Path, PathBuf}; @@ -54,15 +55,8 @@ fn command_observed_at_ms() -> u64 { .unwrap_or(u64::MAX) } -#[derive(Debug)] -enum LocalCommand { - Running { - task: JoinHandle>, - completion: watch::Receiver, - cancel: watch::Sender, - }, - Completed(CommandOutput), -} +mod command_transport; +use command_transport::LocalCommand; #[derive(Debug, Clone)] struct CommandTelemetry { @@ -643,7 +637,6 @@ struct LocalWorkdirSessionInner { root: PathBuf, pinned_root: Option>, scope: SharedScope, - command_output_scope: SharedScope, cwd: PathBuf, capabilities: WorkdirSessionCapabilities, read_limits: Option, @@ -662,9 +655,7 @@ impl Drop for LocalWorkdirSessionInner { fn drop(&mut self) { if let Ok(mut commands) = self.commands.try_lock() { for (_, command) in commands.drain() { - if let LocalCommand::Running { task, .. } = command { - task.abort(); - } + command.task.abort(); } } } @@ -864,37 +855,12 @@ impl LocalWorkdirSession { capabilities: WorkdirSessionCapabilities, command_environment: BTreeMap, resources: Vec>, - ) -> Self { - Self::materialized_bound_with_environment_and_command_output_scope( - workdir, - root, - cwd, - scope.clone(), - scope, - capabilities, - command_environment, - resources, - ) - } - - /// Construct a local provider session whose Workdir filesystem authority is - /// independent from the provider-owned command output authority. - pub fn materialized_bound_with_environment_and_command_output_scope( - workdir: Workdir, - root: PathBuf, - cwd: PathBuf, - scope: SharedScope, - command_output_scope: SharedScope, - capabilities: WorkdirSessionCapabilities, - command_environment: BTreeMap, - resources: Vec>, ) -> Self { Self::materialized_bound_with_policy( workdir, root, cwd, scope, - command_output_scope, capabilities, command_environment, resources, @@ -1024,7 +990,6 @@ impl LocalWorkdirSession { workdir, root.clone(), root, - scope.clone(), scope, capabilities, BTreeMap::new(), @@ -1052,7 +1017,6 @@ impl LocalWorkdirSession { root: PathBuf, cwd: PathBuf, scope: SharedScope, - command_output_scope: SharedScope, capabilities: WorkdirSessionCapabilities, command_environment: BTreeMap, resources: Vec>, @@ -1066,7 +1030,6 @@ impl LocalWorkdirSession { root, pinned_root, scope, - command_output_scope, cwd, capabilities, read_limits, @@ -1831,20 +1794,12 @@ impl WorkdirSession for LocalWorkdirSession { } async fn start_command(&self, request: CommandRequest) -> Result { + request.validate()?; self.ensure_capability(WorkdirSessionCapability::Command)?; // Command admission, resource capture and registration must be atomic // with terminal close, which drains commands and releases resources. let _admission = self.inner.close_lock.lock().await; self.ensure_open()?; - if let Some(spill_dir) = request.spill_dir.as_deref() - && !self - .inner - .command_output_scope - .snapshot() - .is_readable(spill_dir) - { - return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf())); - } let cwd = self.resolve(&request.cwd); let scope = self.inner.scope.snapshot(); if !scope.is_readable(&cwd) @@ -1854,48 +1809,45 @@ impl WorkdirSession for LocalWorkdirSession { } let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed); let handle = CommandHandle(format!("command-{id}")); - let (completion_tx, completion) = watch::channel(false); - let command_id = handle.0.clone(); - let telemetry = self.inner.command_telemetry.clone(); - let command_environment = self.inner.command_environment.clone(); - let (cancel, cancel_rx) = watch::channel(false); - // A session drop only schedules command cancellation. Keep its Runtime - // resources (including cleanup exclusion) alive until the task actually - // finishes or its future is dropped, not just until the handle disappears. + let mut commands = self.inner.commands.lock().await; + // Retain bounded retry tombstones, evicting only fully delivered output. + if commands.len() >= command_transport::MAX_COMMANDS { + let mut evict = None; + for (id, command) in commands.iter() { + if command.task.is_finished() + && command + .transport + .try_lock() + .is_ok_and(|state| state.terminal_delivered()) + { + evict = Some(id.clone()); + break; + } + } + if let Some(id) = evict { + commands.remove(&id); + self.inner.command_telemetry.remove(&id); + } else { + return Err(WorkdirError::Unavailable( + "command transport capacity reached; drain existing commands".into(), + )); + } + } let resources = self .inner .resources .lock() .unwrap_or_else(|error| error.into_inner()) .clone(); - let task = tokio::spawn(async move { - let output = run_command( - cwd, - request, - command_id, - telemetry, - command_environment, - cancel_rx, - resources, - ) - .await; - let _ = completion_tx.send(true); - output - }); - let mut commands = self.inner.commands.lock().await; - if let Err(error) = self.ensure_open() { - let _ = cancel.send(true); - task.abort(); - return Err(error); - } - commands.insert( + let command = command_transport::start( + cwd, + request, handle.0.clone(), - LocalCommand::Running { - task, - completion, - cancel, - }, + self.inner.command_telemetry.clone(), + self.inner.command_environment.clone(), + resources, ); + commands.insert(handle.0.clone(), command); Ok(handle) } @@ -1905,17 +1857,11 @@ impl WorkdirSession for LocalWorkdirSession { let command = commands .get(&handle.0) .ok_or_else(|| WorkdirError::UnknownCommand(handle.0.clone()))?; - Ok(match command { - LocalCommand::Running { task, .. } if !task.is_finished() => CommandStatus::Running, - LocalCommand::Running { .. } => self - .inner - .command_telemetry - .snapshot() - .into_iter() - .find(|snapshot| snapshot.command_id == handle.0) - .map_or(CommandStatus::Completed, |snapshot| snapshot.status), - LocalCommand::Completed(output) => output.status, - }) + Ok(command + .status + .borrow() + .as_ref() + .map_or(CommandStatus::Running, |terminal| terminal.status)) } async fn command_output( @@ -1923,70 +1869,22 @@ impl WorkdirSession for LocalWorkdirSession { request: CommandOutputRequest, ) -> Result { self.ensure_capability(WorkdirSessionCapability::Command)?; - let command = loop { - self.ensure_open()?; - let mut commands = self.inner.commands.lock().await; - let Some(command) = commands.get(&request.handle.0) else { - return Err(WorkdirError::UnknownCommand(request.handle.0.clone())); - }; - let completion = match command { - LocalCommand::Running { completion, .. } - if !*completion.borrow() && completion.has_changed().is_ok() => - { - Some(completion.clone()) - } - _ => None, - }; - if let Some(mut completion) = completion { - if !request.wait { - return Ok(CommandOutput { - status: CommandStatus::Running, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, - }); - } - drop(commands); - let _ = completion.changed().await; - continue; - } - if !request.wait - && matches!(command, LocalCommand::Running { task, .. } if !task.is_finished()) - { - return Ok(CommandOutput { - status: CommandStatus::Running, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, - }); - } - break commands - .remove(&request.handle.0) - .expect("command checked above"); - }; - - let output = match command { - LocalCommand::Running { task, .. } => task - .await - .map_err(|error| WorkdirError::Unavailable(error.to_string()))??, - LocalCommand::Completed(output) => output, - }; - let page = command_output_page(&output, request.cursor, request.limit); - if page.next_cursor.is_some() { - let mut commands = self.inner.commands.lock().await; - if !self.inner.closed.load(Ordering::Acquire) { - commands.insert(request.handle.0, LocalCommand::Completed(output)); - } - } else { + self.ensure_open()?; + let transport = self + .inner + .commands + .lock() + .await + .get(&request.handle.0) + .ok_or_else(|| WorkdirError::UnknownCommand(request.handle.0.clone()))? + .transport + .clone(); + let result = transport.lock().await.read(&request).await; + self.ensure_open()?; + if result.as_ref().is_ok_and(|page| page.terminal.is_some()) { self.inner.command_telemetry.remove(&request.handle.0); } - Ok(page) + result } async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { @@ -1996,12 +1894,7 @@ impl WorkdirSession for LocalWorkdirSession { let command = commands .get(&handle.0) .ok_or_else(|| WorkdirError::UnknownCommand(handle.0.clone()))?; - match command { - LocalCommand::Running { task, cancel, .. } if !task.is_finished() => { - Some(cancel.clone()) - } - _ => None, - } + Some(command.cancel.clone()) }; if let Some(cancel) = cancel { let _ = cancel.send(true); @@ -2041,13 +1934,9 @@ impl WorkdirSession for LocalWorkdirSession { .collect::>() }; for command in commands { - match command { - LocalCommand::Running { task, cancel, .. } => { - let _ = cancel.send(true); - let _ = task.await; - } - LocalCommand::Completed(_) => {} - } + let _ = command.cancel.send(true); + command.transport.lock().await.close(); + let _ = command.task.await; } if let Ok(mut resources) = self.inner.resources.lock() { resources.clear(); @@ -2056,27 +1945,6 @@ impl WorkdirSession for LocalWorkdirSession { } } -fn command_output_page(output: &CommandOutput, cursor: usize, limit: usize) -> CommandOutput { - let total_chars = output.content.chars().count(); - let start = cursor.min(total_chars); - let content = output - .content - .chars() - .skip(start) - .take(limit.max(1)) - .collect::(); - let end = start + content.chars().count(); - CommandOutput { - status: output.status, - exit_code: output.exit_code, - timed_out: output.timed_out, - content, - next_cursor: (end < total_chars).then_some(end), - truncated: output.truncated || end < total_chars, - output_path: output.output_path.clone(), - } -} - // Discovery of an absent route is not a stale-validator execution attempt. fn checkout_observe_io(path: &WorkdirPath, error: std::io::Error) -> WorkdirError { if matches!( @@ -2202,167 +2070,6 @@ fn terminate_process_group(process_group_id: u32) { #[cfg(not(unix))] fn terminate_process_group(_process_group_id: u32) {} -async fn run_command( - cwd: PathBuf, - request: CommandRequest, - command_id: String, - telemetry: CommandTelemetry, - command_environment: BTreeMap, - mut cancel: watch::Receiver, - resources: Vec>, -) -> Result { - let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; - let stderr = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; - let stdout_path = stdout.into_temp_path(); - let stderr_path = stderr.into_temp_path(); - let stdout_file = std::fs::File::create(&stdout_path) - .map_err(|error| WorkdirError::io(&stdout_path, error))?; - let stderr_file = std::fs::File::create(&stderr_path) - .map_err(|error| WorkdirError::io(&stderr_path, error))?; - - telemetry.started(&command_id, request.tool_call_id.clone()); - let mut command = Command::new("bash"); - command - .arg("-c") - .arg(&request.command) - .current_dir(&cwd) - .envs(command_environment) - .stdin(Stdio::null()) - .stdout(Stdio::from(stdout_file)) - .stderr(Stdio::from(stderr_file)) - .kill_on_drop(true); - #[cfg(unix)] - command.as_std_mut().process_group(0); - let mut child = match command.spawn() { - Ok(child) => child, - Err(error) => { - telemetry.terminal(&command_id, CommandStatus::Failed, None); - return Err(WorkdirError::io(&cwd, error)); - } - }; - let mut process_group = CommandProcessGroupGuard::new(child.id()); - - let mut stdout_reader = - std::fs::File::open(&stdout_path).map_err(|error| WorkdirError::io(&stdout_path, error))?; - let mut stderr_reader = - std::fs::File::open(&stderr_path).map_err(|error| WorkdirError::io(&stderr_path, error))?; - let mut stdout_decoder = CommandOutputDecoder::default(); - let mut stderr_decoder = CommandOutputDecoder::default(); - let mut timeout = Box::pin(tokio::time::sleep(Duration::from_secs( - request.timeout_secs.max(1), - ))); - let mut interval = tokio::time::interval(Duration::from_millis(50)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - interval.tick().await; - - let (status, exit_code) = loop { - tokio::select! { - exit = child.wait() => { - let exit = exit.map_err(|error| WorkdirError::io(&cwd, error))?; - process_group.terminate(); - break ( - if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed }, - exit.code(), - ); - } - _ = &mut timeout => { - process_group.terminate(); - let _ = child.start_kill(); - let exit_code = child.wait().await.ok().and_then(|status| status.code()); - break (CommandStatus::TimedOut, exit_code); - } - changed = cancel.changed() => { - if changed.is_err() || *cancel.borrow() { - process_group.terminate(); - let _ = child.start_kill(); - let exit_code = child.wait().await.ok().and_then(|status| status.code()); - break (CommandStatus::Cancelled, exit_code); - } - } - _ = interval.tick() => { - publish_available_output( - &mut stdout_reader, - &mut stdout_decoder, - &telemetry, - &command_id, - CommandStream::Stdout, - &stdout_path, - false, - )?; - publish_available_output( - &mut stderr_reader, - &mut stderr_decoder, - &telemetry, - &command_id, - CommandStream::Stderr, - &stderr_path, - false, - )?; - } - } - }; - - publish_available_output( - &mut stdout_reader, - &mut stdout_decoder, - &telemetry, - &command_id, - CommandStream::Stdout, - &stdout_path, - true, - )?; - publish_available_output( - &mut stderr_reader, - &mut stderr_decoder, - &telemetry, - &command_id, - CommandStream::Stderr, - &stderr_path, - true, - )?; - telemetry.terminal(&command_id, status, exit_code); - - let (content, truncated) = - read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?; - let output_path = match (truncated, request.spill_dir) { - (true, Some(spill_dir)) => { - let stdout_path = stdout_path.to_path_buf(); - let stderr_path = stderr_path.to_path_buf(); - Some( - spawn_resource_owned_blocking(resources.clone(), move || { - persist_command_output(&stdout_path, &stderr_path, &spill_dir) - }) - .await - .map_err(|error| { - WorkdirError::Unavailable(format!("Bash output spill task failed: {error}")) - })??, - ) - } - _ => None, - }; - Ok(CommandOutput { - status, - exit_code, - timed_out: status == CommandStatus::TimedOut, - content, - next_cursor: None, - truncated, - output_path, - }) -} - -// Blocking spills cannot be aborted with their async waiter. Transfer resource -// ownership into the actual filesystem task so cleanup waits for its completion. -fn spawn_resource_owned_blocking( - resources: Vec>, - effect: impl FnOnce() -> T + Send + 'static, -) -> JoinHandle { - tokio::task::spawn_blocking(move || { - let _resources = resources; - effect() - }) -} - #[derive(Debug, Default)] struct CommandOutputDecoder { read_offset: u64, @@ -2370,38 +2077,6 @@ struct CommandOutputDecoder { pending: Vec, } -fn publish_available_output( - file: &mut std::fs::File, - decoder: &mut CommandOutputDecoder, - telemetry: &CommandTelemetry, - command_id: &str, - stream: CommandStream, - path: &Path, - flush: bool, -) -> Result<(), WorkdirError> { - file.seek(SeekFrom::Start(decoder.read_offset)) - .map_err(|error| WorkdirError::io(path, error))?; - loop { - let mut buffer = vec![0; COMMAND_EVENT_CHUNK_BYTES]; - let read = file - .read(&mut buffer) - .map_err(|error| WorkdirError::io(path, error))?; - if read == 0 { - publish_decoded_output(decoder, telemetry, command_id, stream, flush); - return Ok(()); - } - decoder.pending.extend_from_slice(&buffer[..read]); - decoder.read_offset = decoder.read_offset.saturating_add(read as u64); - publish_decoded_output(decoder, telemetry, command_id, stream, false); - if read < COMMAND_EVENT_CHUNK_BYTES { - if flush { - publish_decoded_output(decoder, telemetry, command_id, stream, true); - } - return Ok(()); - } - } -} - fn publish_decoded_output( decoder: &mut CommandOutputDecoder, telemetry: &CommandTelemetry, @@ -2428,7 +2103,7 @@ fn publish_decoded_output( } /// Return the byte prefix that can be decoded now without replacing a valid -/// UTF-8 scalar whose remaining bytes may arrive in a later file read. Definite +/// UTF-8 scalar whose remaining bytes may arrive in a later pipe read. Definite /// invalid sequences remain in the prefix and are rendered lossily, preserving /// the existing arbitrary-byte output behavior. fn stable_utf8_prefix_len(bytes: &[u8]) -> usize { @@ -2448,110 +2123,6 @@ fn stable_utf8_prefix_len(bytes: &[u8]) -> usize { inspected } -fn persist_command_output( - stdout_path: &Path, - stderr_path: &Path, - spill_dir: &Path, -) -> Result { - std::fs::create_dir_all(spill_dir).map_err(|error| WorkdirError::io(spill_dir, error))?; - let mut artifact = tempfile::Builder::new() - .prefix("bash-") - .suffix(".log") - .tempfile_in(spill_dir) - .map_err(|error| WorkdirError::io(spill_dir, error))?; - let artifact_path = artifact.path().to_path_buf(); - - let mut stdout = - std::fs::File::open(stdout_path).map_err(|error| WorkdirError::io(stdout_path, error))?; - let stdout_len = stdout - .metadata() - .map_err(|error| WorkdirError::io(stdout_path, error))? - .len(); - std::io::copy(&mut stdout, &mut artifact) - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - - let mut stderr = - std::fs::File::open(stderr_path).map_err(|error| WorkdirError::io(stderr_path, error))?; - let stderr_len = stderr - .metadata() - .map_err(|error| WorkdirError::io(stderr_path, error))? - .len(); - if stdout_len > 0 && stderr_len > 0 { - stdout - .seek(SeekFrom::End(-1)) - .map_err(|error| WorkdirError::io(stdout_path, error))?; - let mut last = [0_u8; 1]; - stdout - .read_exact(&mut last) - .map_err(|error| WorkdirError::io(stdout_path, error))?; - if last[0] != b'\n' { - artifact - .write_all(b"\n") - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - } - } - std::io::copy(&mut stderr, &mut artifact) - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - artifact - .flush() - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - artifact - .keep() - .map(|(_, path)| path) - .map_err(|error| WorkdirError::io(&artifact_path, error.error)) -} - -fn read_command_output_files( - stdout_path: &Path, - stderr_path: &Path, - limit: usize, -) -> Result<(String, bool), WorkdirError> { - let stdout_size = std::fs::metadata(stdout_path) - .map_err(|error| WorkdirError::io(stdout_path, error))? - .len() as usize; - let stderr_size = std::fs::metadata(stderr_path) - .map_err(|error| WorkdirError::io(stderr_path, error))? - .len() as usize; - let total = stdout_size.saturating_add(stderr_size); - let stderr_budget = stderr_size.min(limit / 2); - let stdout_budget = stdout_size.min(limit.saturating_sub(stderr_budget)); - let remaining = limit.saturating_sub(stdout_budget + stderr_budget); - let stdout_budget = (stdout_budget + remaining.min(stdout_size - stdout_budget)).min(limit); - let stderr_budget = limit.saturating_sub(stdout_budget).min(stderr_size); - - let stdout = read_tail(stdout_path, stdout_budget)?; - let stderr = read_tail(stderr_path, stderr_budget)?; - let mut content = String::new(); - if !stdout.is_empty() { - content.push_str(&String::from_utf8_lossy(&stdout)); - } - if !stderr.is_empty() { - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(&String::from_utf8_lossy(&stderr)); - } - Ok((content, total > limit)) -} - -fn read_tail(path: &Path, limit: usize) -> Result, WorkdirError> { - if limit == 0 { - return Ok(Vec::new()); - } - let mut file = std::fs::File::open(path).map_err(|error| WorkdirError::io(path, error))?; - let len = file - .metadata() - .map_err(|error| WorkdirError::io(path, error))? - .len(); - let start = len.saturating_sub(limit as u64); - file.seek(SeekFrom::Start(start)) - .map_err(|error| WorkdirError::io(path, error))?; - let mut bytes = Vec::with_capacity((len - start) as usize); - file.read_to_end(&mut bytes) - .map_err(|error| WorkdirError::io(path, error))?; - Ok(bytes) -} - /// Return the first symlink component in `path`, if one exists. /// /// The function only inspects existing path components. It intentionally uses @@ -2928,11 +2499,12 @@ mod tests { let command = WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".to_owned(), timeout_secs: 60, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -2941,7 +2513,7 @@ mod tests { let waiting_session = session.clone(); let waiting_command = command.clone(); let waiter = tokio::spawn(async move { - WorkdirSession::command_output( + crate::command_test_support::CollectCommand::collect_command_output( &waiting_session, CommandOutputRequest { handle: waiting_command, @@ -3626,17 +3198,18 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "pwd && printf provider-command".into(), timeout_secs: 5, - output_limit: 4096, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) .await .unwrap(); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle, @@ -3728,11 +3301,12 @@ mod tests { let error = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf hidden".into(), timeout_secs: 5, - output_limit: 1024, + cwd, - spill_dir: None, + tool_call_id: None, }, ) @@ -3742,319 +3316,6 @@ mod tests { assert!(matches!(error, WorkdirError::OutOfScope(path) if path == outside.path())); } - #[tokio::test] - async fn command_rejects_spill_directory_without_read_scope() { - let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - - let error = WorkdirSession::start_command( - &workdir, - CommandRequest { - command: "printf hidden".into(), - timeout_secs: 5, - output_limit: 1, - cwd: WorkdirPath::root(), - spill_dir: Some(spill.path().to_path_buf()), - tool_call_id: None, - }, - ) - .await - .unwrap_err(); - - assert!(matches!(error, WorkdirError::OutOfScope(path) if path == spill.path())); - } - - #[tokio::test] - async fn truncated_command_output_is_retained_in_the_requested_spill_directory() { - let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = Scope::from_config(&ScopeConfig { - allow: vec![ - ScopeRule { - target: dir.path().to_path_buf(), - permission: Permission::Write, - recursive: true, - symlink_policy: Default::default(), - }, - ScopeRule { - target: spill.path().to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }, - ], - deny: Vec::new(), - }) - .unwrap(); - let workdir = LocalWorkdirSession::new(scope, dir.path().to_path_buf()); - let handle = WorkdirSession::start_command( - &workdir, - CommandRequest { - command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(), - timeout_secs: 5, - output_limit: 64, - cwd: WorkdirPath::root(), - spill_dir: Some(spill.path().to_path_buf()), - tool_call_id: None, - }, - ) - .await - .unwrap(); - let output = WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle, - cursor: 0, - limit: 4096, - wait: true, - }, - ) - .await - .unwrap(); - - assert!(output.truncated); - let output_path = output.output_path.expect("retained output path"); - assert_eq!(output_path.parent(), Some(spill.path())); - let retained = std::fs::read_to_string(&output_path).unwrap(); - assert!(retained.starts_with("line-000\n")); - assert!(retained.ends_with("FINAL-NEEDLE\n")); - assert_eq!(retained.lines().count(), 201); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - assert_eq!( - std::fs::metadata(output_path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } - } - - #[tokio::test] - async fn completed_command_output_can_be_read_in_bounded_unicode_pages() { - let dir = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - let handle = WorkdirSession::start_command( - &workdir, - CommandRequest { - command: "printf 'aéz'".into(), - timeout_secs: 5, - output_limit: 1024, - cwd: WorkdirPath::root(), - spill_dir: None, - tool_call_id: None, - }, - ) - .await - .unwrap(); - let first = WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle: handle.clone(), - cursor: 0, - limit: 2, - wait: true, - }, - ) - .await - .unwrap(); - assert_eq!(first.content, "aé"); - assert_eq!(first.next_cursor, Some(2)); - - let second = WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle: handle.clone(), - cursor: first.next_cursor.unwrap(), - limit: 2, - wait: false, - }, - ) - .await - .unwrap(); - assert_eq!(second.content, "z"); - assert_eq!(second.next_cursor, None); - assert!(matches!( - WorkdirSession::command_status(&workdir, handle).await, - Err(WorkdirError::UnknownCommand(_)) - )); - } - - #[test] - fn command_output_decoder_preserves_utf8_split_across_file_reads() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("command.out"); - let mut first_write = vec![b'a'; COMMAND_EVENT_CHUNK_BYTES - 1]; - first_write.push(0xe2); - std::fs::write(&path, first_write).unwrap(); - - let telemetry = CommandTelemetry::new(); - let mut events = telemetry.subscribe(); - telemetry.started("command-utf8", None); - let mut decoder = CommandOutputDecoder::default(); - let mut reader = std::fs::File::open(&path).unwrap(); - publish_available_output( - &mut reader, - &mut decoder, - &telemetry, - "command-utf8", - CommandStream::Stdout, - &path, - false, - ) - .unwrap(); - assert_eq!(decoder.pending, vec![0xe2]); - - let mut writer = std::fs::OpenOptions::new() - .append(true) - .open(&path) - .unwrap(); - writer.write_all(&[0x82, 0xac]).unwrap(); - writer.flush().unwrap(); - publish_available_output( - &mut reader, - &mut decoder, - &telemetry, - "command-utf8", - CommandStream::Stdout, - &path, - false, - ) - .unwrap(); - - let output = std::iter::from_fn(|| events.try_recv().ok()) - .filter_map(|event| match event { - CommandEvent::Output { - stream: CommandStream::Stdout, - content, - .. - } => Some(content), - _ => None, - }) - .collect::(); - assert_eq!(output.len(), COMMAND_EVENT_CHUNK_BYTES - 1 + "€".len()); - assert!(output.ends_with('€')); - assert!(!output.contains('\u{fffd}')); - assert!(decoder.pending.is_empty()); - } - - #[tokio::test] - async fn command_output_does_not_rewait_before_join_handle_finishes() { - let dir = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - let handle = CommandHandle("command-completion-race".into()); - let (completion_tx, completion) = watch::channel(false); - let completion_observer = completion_tx.clone(); - let (cancel, _cancel_rx) = watch::channel(false); - let (start_tx, start_rx) = tokio::sync::oneshot::channel::<()>(); - let (completion_sent_tx, completion_sent_rx) = tokio::sync::oneshot::channel::<()>(); - let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); - let task = tokio::spawn(async move { - start_rx.await.unwrap(); - completion_tx.send(true).unwrap(); - completion_sent_tx.send(()).unwrap(); - release_rx.await.unwrap(); - Ok(CommandOutput { - status: CommandStatus::Completed, - exit_code: Some(0), - timed_out: false, - content: "done".into(), - next_cursor: None, - truncated: false, - output_path: None, - }) - }); - workdir.inner.commands.lock().await.insert( - handle.0.clone(), - LocalCommand::Running { - task, - completion, - cancel, - }, - ); - - let waiting_workdir = workdir.clone(); - let waiting_handle = handle.clone(); - let waiter = tokio::spawn(async move { - WorkdirSession::command_output( - &waiting_workdir, - CommandOutputRequest { - handle: waiting_handle, - cursor: 0, - limit: 1024, - wait: true, - }, - ) - .await - }); - for _ in 0..100 { - if completion_observer.receiver_count() >= 2 { - break; - } - tokio::task::yield_now().await; - } - assert_eq!( - completion_observer.receiver_count(), - 2, - "waiter must subscribe before completion is published" - ); - - start_tx.send(()).unwrap(); - completion_sent_rx.await.unwrap(); - tokio::task::yield_now().await; - assert!( - !waiter.is_finished(), - "command_output should await the task after observing completion state" - ); - release_tx.send(()).unwrap(); - - let output = tokio::time::timeout(Duration::from_secs(1), waiter) - .await - .expect("command output must not wait for a second completion notification") - .unwrap() - .unwrap(); - assert_eq!(output.status, CommandStatus::Completed); - assert_eq!(output.content, "done"); - } - - #[tokio::test] - async fn command_output_does_not_wait_forever_when_completion_sender_drops() { - let dir = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - let handle = CommandHandle("command-completion-drop".into()); - let (completion_tx, completion) = watch::channel(false); - let (cancel, _cancel_rx) = watch::channel(false); - let task: JoinHandle> = tokio::spawn(async move { - drop(completion_tx); - panic!("simulated command task panic"); - }); - workdir.inner.commands.lock().await.insert( - handle.0.clone(), - LocalCommand::Running { - task, - completion, - cancel, - }, - ); - - let result = tokio::time::timeout( - Duration::from_secs(1), - WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle, - cursor: 0, - limit: 1024, - wait: true, - }, - ), - ) - .await - .expect("closed completion channel must wake the waiter"); - assert!(matches!(result, Err(WorkdirError::Unavailable(_)))); - } - #[tokio::test] async fn provider_streams_bounded_command_lifecycle_and_distinct_output() { let dir = TempDir::new().unwrap(); @@ -4064,11 +3325,12 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: Some("tool-7".into()), }, ) @@ -4146,7 +3408,7 @@ mod tests { assert_eq!(snapshot[0].stdout.content, "readydone"); assert_eq!(snapshot[0].stderr.content, "warning"); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle, @@ -4169,17 +3431,18 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 1, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) .await .unwrap(); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle: handle.clone(), @@ -4227,11 +3490,12 @@ mod tests { WorkdirSession::start_command( start_session.as_ref(), CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "true".into(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4261,50 +3525,6 @@ mod tests { ); } - #[tokio::test] - async fn blocking_output_spill_retains_resources_after_waiter_is_cancelled() { - #[derive(Debug)] - struct Resource(Option>); - impl Drop for Resource { - fn drop(&mut self) { - let _ = self.0.take().unwrap().send(()); - } - } - let (released, mut release) = tokio::sync::oneshot::channel(); - let resources: Vec> = - vec![Arc::new(Resource(Some(released)))]; - let (entered, started) = tokio::sync::oneshot::channel(); - let (finish, wait) = std::sync::mpsc::channel(); - // Exercise the resource-transfer policy at the same blocking boundary - // used by output persistence, without filesystem timing assumptions. - let waiter = tokio::spawn(async move { - spawn_resource_owned_blocking(resources, move || { - entered.send(()).unwrap(); - wait.recv_timeout(Duration::from_secs(5)).unwrap(); - }) - .await - .unwrap(); - }); - tokio::time::timeout(Duration::from_secs(5), started) - .await - .unwrap() - .unwrap(); - waiter.abort(); - assert!(waiter.await.unwrap_err().is_cancelled()); - assert!( - matches!( - release.try_recv(), - Err(tokio::sync::oneshot::error::TryRecvError::Empty) - ), - "blocking spill lost its resources when the waiter was cancelled" - ); - finish.send(()).unwrap(); - tokio::time::timeout(Duration::from_secs(5), release) - .await - .unwrap() - .unwrap(); - } - #[tokio::test(flavor = "current_thread")] async fn session_drop_retains_resources_until_command_task_cancellation_completes() { #[derive(Debug)] @@ -4328,11 +3548,12 @@ mod tests { WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "true".into(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4387,12 +3608,13 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "(sleep 0.4; echo leaked > child-marker) & echo ready > child-ready; wait" .into(), timeout_secs: 60, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4409,7 +3631,7 @@ mod tests { WorkdirSession::cancel_command(&workdir, handle.clone()) .await .unwrap(); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle, @@ -4435,11 +3657,12 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4454,7 +3677,7 @@ mod tests { let waiting_workdir = workdir.clone(); let waiting_handle = handle.clone(); let waiter = tokio::spawn(async move { - WorkdirSession::command_output( + crate::command_test_support::CollectCommand::collect_command_output( &waiting_workdir, CommandOutputRequest { handle: waiting_handle, @@ -4475,9 +3698,11 @@ mod tests { .unwrap() .unwrap(); assert_eq!(output.status, CommandStatus::Cancelled); - assert!(matches!( - WorkdirSession::command_status(&workdir, handle).await, - Err(WorkdirError::UnknownCommand(_)) - )); + assert_eq!( + WorkdirSession::command_status(&workdir, handle) + .await + .unwrap(), + CommandStatus::Cancelled + ); } } diff --git a/crates/workdir/src/local/command_transport.rs b/crates/workdir/src/local/command_transport.rs new file mode 100644 index 000000000..5b800c0ab --- /dev/null +++ b/crates/workdir/src/local/command_transport.rs @@ -0,0 +1,377 @@ +//! Captured process bytes use a bounded queue, never the lossy Console channel. +//! Pipe backpressure bounds unread process output without temporary-file growth. +//! A replay page survives a lost HTTP response until its next cursor is sent. + +use super::*; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::sync::mpsc; + +const QUEUE_CHUNKS: usize = 8; +pub(super) const MAX_COMMANDS: usize = 64; + +#[derive(Debug)] +enum Message { + Bytes(crate::CommandByteChunk), +} + +#[derive(Debug)] +pub(super) struct LocalCommand { + pub task: JoinHandle<()>, + pub cancel: watch::Sender, + pub status: watch::Receiver>, + pub transport: Arc>, +} + +#[derive(Debug)] +pub(super) struct Transport { + receiver: mpsc::Receiver, + pending: Option, + cursor: u64, + replay: Option<(u64, CommandOutput)>, + terminal: Option, + terminal_status: watch::Receiver>, +} + +impl Transport { + pub async fn read( + &mut self, + request: &CommandOutputRequest, + ) -> Result { + if !(1..=crate::MAX_COMMAND_READ_BYTES).contains(&request.limit) { + return Err(WorkdirError::Conflict( + "command byte limit is outside transport bounds".into(), + )); + } + if let Some((cursor, page)) = &self.replay { + if request.cursor == *cursor { + if page + .chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + > request.limit + { + return Err(WorkdirError::Conflict( + "retry byte limit is smaller than retained page".into(), + )); + } + return Ok(page.clone()); + } + if request.cursor != page.next_cursor { + return Err(WorkdirError::Conflict( + "command cursor is not the current page acknowledgement".into(), + )); + } + self.cursor = page.next_cursor; + self.replay = None; + } + if request.cursor != self.cursor { + return Err(WorkdirError::Conflict( + "command cursor was acknowledged or is ahead of output".into(), + )); + } + let mut page = CommandOutput { + chunks: Vec::new(), + next_cursor: self.cursor, + terminal: self.terminal.clone(), + }; + let mut remaining = request.limit; + loop { + let message = if let Some(chunk) = self.pending.take() { + Some(Message::Bytes(chunk)) + } else if page.chunks.is_empty() && page.terminal.is_none() && request.wait { + self.receiver.recv().await + } else { + match self.receiver.try_recv() { + Ok(message) => Some(message), + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => None, + } + }; + match message { + Some(Message::Bytes(mut chunk)) => { + let count = remaining.min(chunk.bytes.len()); + if count < chunk.bytes.len() { + let tail = chunk.bytes.split_off(count); + self.pending = Some(crate::CommandByteChunk { + sequence: chunk.sequence + count as u64, + stream: chunk.stream, + offset: chunk.offset + count as u64, + bytes: tail, + }); + } + page.next_cursor += count as u64; + remaining -= count; + page.chunks.push(chunk); + if remaining == 0 { + break; + } + } + None if page.terminal.is_some() => break, + None => { + let terminal = self.terminal_status.borrow().clone().ok_or_else(|| { + WorkdirError::Unavailable( + "command transport ended before terminal fence".into(), + ) + })?; + self.terminal = Some(terminal.clone()); + page.terminal = Some(terminal); + break; + } + } + } + // An empty poll must not become a permanently replayed empty page. + if !page.chunks.is_empty() || page.terminal.is_some() { + self.replay = Some((self.cursor, page.clone())); + } + Ok(page) + } + + pub fn terminal_delivered(&self) -> bool { + self.replay + .as_ref() + .is_some_and(|(_, page)| page.terminal.is_some()) + } + + pub fn close(&mut self) { + self.receiver.close(); + } +} + +pub(super) fn start( + cwd: PathBuf, + request: CommandRequest, + command_id: String, + telemetry: CommandTelemetry, + environment: BTreeMap, + resources: Vec>, +) -> LocalCommand { + let (sender, receiver) = mpsc::channel(QUEUE_CHUNKS); + let (cancel, cancel_rx) = watch::channel(false); + let (status_tx, status) = watch::channel(None); + let transport = Arc::new(Mutex::new(Transport { + receiver, + pending: None, + cursor: 0, + replay: None, + terminal: None, + terminal_status: status.clone(), + })); + let task = tokio::spawn(async move { + let _resources = resources; + let terminal = execute( + cwd, + request, + &command_id, + &telemetry, + environment, + cancel_rx, + &sender, + ) + .await; + telemetry.terminal(&command_id, terminal.status, terminal.exit_code); + // Closing the byte queue fences this already-published terminal, without + // requiring spare queue capacity or a receiver to release task resources. + let _ = status_tx.send(Some(terminal)); + }); + LocalCommand { + task, + cancel, + status, + transport, + } +} + +#[derive(Default)] +struct ByteAccounting { + stdout: u64, + stderr: u64, + stdout_delivered: u64, + stderr_delivered: u64, +} + +impl ByteAccounting { + fn terminal(&self, status: CommandStatus, exit_code: Option) -> crate::CommandTerminal { + crate::CommandTerminal { + status, + exit_code, + stdout_bytes: self.stdout, + stderr_bytes: self.stderr, + stdout_delivered_bytes: self.stdout_delivered, + stderr_delivered_bytes: self.stderr_delivered, + truncation_reason: (self.stdout > self.stdout_delivered + || self.stderr > self.stderr_delivered) + .then_some(crate::CommandOutputTruncation::ByteBudget), + } + } +} + +async fn pump( + mut stdout: R, + mut stderr: impl AsyncRead + Unpin, + sender: &mpsc::Sender, + telemetry: &CommandTelemetry, + command_id: &str, + mut remaining: u64, + accounting: &mut ByteAccounting, +) -> std::io::Result<()> { + let mut stdout_done = false; + let mut stderr_done = false; + let mut stdout_decoder = CommandOutputDecoder::default(); + let mut stderr_decoder = CommandOutputDecoder::default(); + let mut sequence = 0; + let mut stdout_buffer = [0; COMMAND_EVENT_CHUNK_BYTES]; + let mut stderr_buffer = [0; COMMAND_EVENT_CHUNK_BYTES]; + while !stdout_done || !stderr_done { + // After quota, drain locally even if nobody acknowledges the queue. + // Before quota, reserve first to retain bounded pipe backpressure. + let permit = if remaining > 0 { + let Ok(permit) = sender.reserve().await else { + return Ok(()); + }; + Some(permit) + } else { + None + }; + let (stream, result) = tokio::select! { + result = stdout.read(&mut stdout_buffer), if !stdout_done => (CommandStream::Stdout, result), + result = stderr.read(&mut stderr_buffer), if !stderr_done => (CommandStream::Stderr, result), + }; + let buffer = match stream { + CommandStream::Stdout => &stdout_buffer, + CommandStream::Stderr => &stderr_buffer, + }; + let count = result?; + let decoder = match stream { + CommandStream::Stdout => &mut stdout_decoder, + CommandStream::Stderr => &mut stderr_decoder, + }; + if count == 0 { + match stream { + CommandStream::Stdout => stdout_done = true, + CommandStream::Stderr => stderr_done = true, + } + publish_decoded_output(decoder, telemetry, command_id, stream, true); + continue; + } + let (seen, delivered) = match stream { + CommandStream::Stdout => (&mut accounting.stdout, &mut accounting.stdout_delivered), + CommandStream::Stderr => (&mut accounting.stderr, &mut accounting.stderr_delivered), + }; + *seen = seen.saturating_add(count as u64); + let captured = remaining.min(count as u64) as usize; + if captured > 0 { + let offset = *delivered; + decoder.read_offset += captured as u64; + decoder.pending.extend_from_slice(&buffer[..captured]); + publish_decoded_output(decoder, telemetry, command_id, stream, false); + permit + .expect("reserved while budget remains") + .send(Message::Bytes(crate::CommandByteChunk { + sequence, + stream, + offset, + bytes: buffer[..captured].to_vec(), + })); + *delivered += captured as u64; + sequence += captured as u64; + remaining -= captured as u64; + } + } + Ok(()) +} + +async fn execute( + cwd: PathBuf, + request: CommandRequest, + command_id: &str, + telemetry: &CommandTelemetry, + environment: BTreeMap, + mut cancel: watch::Receiver, + sender: &mpsc::Sender, +) -> crate::CommandTerminal { + telemetry.started(command_id, request.tool_call_id); + let mut command = Command::new("bash"); + command + .arg("-c") + .arg(request.command) + .current_dir(cwd) + .envs(environment) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(unix)] + command.as_std_mut().process_group(0); + let mut accounting = ByteAccounting::default(); + let Ok(mut child) = command.spawn() else { + return accounting.terminal(CommandStatus::Failed, None); + }; + let mut process_group = CommandProcessGroupGuard::new(child.id()); + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let (status, exit_code) = { + let output = pump( + stdout, + stderr, + sender, + telemetry, + command_id, + request.transport_byte_budget, + &mut accounting, + ); + tokio::pin!(output); + let mut drained = false; + let timeout = tokio::time::sleep(Duration::from_secs(request.timeout_secs.max(1))); + tokio::pin!(timeout); + let terminal = loop { + tokio::select! { + result = &mut output, if !drained => { + drained = true; + if result.is_err() || sender.is_closed() { + process_group.terminate(); + let _ = child.start_kill(); + let _ = child.wait().await; + break (CommandStatus::Failed, None); + } + } + exit = child.wait() => { + process_group.terminate(); + break match exit { + Ok(exit) => (if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed }, exit.code()), + Err(_) => (CommandStatus::Failed, None), + }; + } + _ = &mut timeout => { + process_group.terminate(); + let _ = child.start_kill(); + let code = child.wait().await.ok().and_then(|exit| exit.code()); + break (CommandStatus::TimedOut, code); + } + _ = sender.closed() => { + process_group.terminate(); + let _ = child.start_kill(); + let _ = child.wait().await; + break (CommandStatus::Cancelled, None); + } + changed = cancel.changed() => { + if changed.is_err() || *cancel.borrow() { + process_group.terminate(); + let _ = child.start_kill(); + let code = child.wait().await.ok().and_then(|exit| exit.code()); + break (CommandStatus::Cancelled, code); + } + } + } + }; + // Even after kill, preserve the bounded pipe tail before publishing terminal. + if !drained { + let _ = output.await; + } + terminal + }; + accounting.terminal(status, exit_code) +} + +#[cfg(test)] +mod tests; diff --git a/crates/workdir/src/local/command_transport/tests.rs b/crates/workdir/src/local/command_transport/tests.rs new file mode 100644 index 000000000..796a1f93f --- /dev/null +++ b/crates/workdir/src/local/command_transport/tests.rs @@ -0,0 +1,490 @@ +//! Process/pipe integration through the production local provider. Temporary roots +//! and provider tasks are RAII-owned; byte pulls and bounded timeouts synchronize tests. + +use super::*; +use tempfile::TempDir; + +struct Harness { + session: LocalWorkdirSession, + _root: TempDir, +} + +impl Harness { + fn new() -> Self { + let root = TempDir::new().unwrap(); + let session = LocalWorkdirSession::new( + Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + ); + Self { + session, + _root: root, + } + } + + async fn start(&self, command: &str, timeout_secs: u64) -> CommandHandle { + self.start_budget(command, timeout_secs, crate::MAX_COMMAND_TRANSPORT_BYTES) + .await + } + + fn request(command: &str, timeout_secs: u64, budget: u64) -> CommandRequest { + CommandRequest { + transport_byte_budget: budget, + command: command.into(), + timeout_secs, + cwd: WorkdirPath::root(), + tool_call_id: None, + } + } + + async fn start_budget(&self, command: &str, timeout_secs: u64, budget: u64) -> CommandHandle { + self.session + .start_command(Self::request(command, timeout_secs, budget)) + .await + .unwrap() + } + + async fn page(&self, handle: &CommandHandle, cursor: u64, limit: usize) -> CommandOutput { + tokio::time::timeout( + Duration::from_secs(10), + self.session.command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit, + wait: true, + }), + ) + .await + .expect("bounded command pull") + .unwrap() + } + + async fn collect( + &self, + handle: &CommandHandle, + mut cursor: u64, + ) -> (Vec, crate::CommandTerminal) { + let mut chunks = Vec::new(); + loop { + let page = self + .page(handle, cursor, crate::MAX_COMMAND_READ_BYTES) + .await; + assert!( + page.chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + <= crate::MAX_COMMAND_READ_BYTES + ); + cursor = page.next_cursor; + chunks.extend(page.chunks); + assert!(chunks.iter().map(|chunk| chunk.bytes.len()).sum::() <= 8 * 1024 * 1024); + if let Some(terminal) = page.terminal { + return (chunks, terminal); + } + } + } +} + +fn stream_bytes(chunks: &[crate::CommandByteChunk], stream: CommandStream) -> Vec { + let mut bytes = Vec::new(); + for chunk in chunks.iter().filter(|chunk| chunk.stream == stream) { + assert_eq!(chunk.offset, bytes.len() as u64); + bytes.extend_from_slice(&chunk.bytes); + } + bytes +} + +#[tokio::test] +async fn high_single_line_output_survives_absent_and_lagged_console_subscribers() { + for lagged in [false, true] { + let harness = Harness::new(); + let _lagged_receiver = lagged.then(|| harness.session.subscribe_command_events().unwrap()); + let handle = harness.start("head -c 3145728 /dev/zero | tr '\\0' x; head -c 65536 /dev/zero | tr '\\0' e >&2; exit 7", 20).await; + let (chunks, terminal) = harness.collect(&handle, 0).await; + assert_eq!(terminal.status, CommandStatus::Failed); + assert_eq!(terminal.exit_code, Some(7)); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stdout), + vec![b'x'; 3145728] + ); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stderr), + vec![b'e'; 65536] + ); + let mut cursor = 0; + for chunk in chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + } + harness.session.close().await.unwrap(); + } +} + +#[tokio::test] +async fn one_byte_pages_preserve_raw_utf8_invalid_bytes_and_replay_until_acknowledged() { + let harness = Harness::new(); + let handle = harness + .start( + "printf '\\342\\202\\254\\377\\000'; printf '\\346\\227\\245' >&2", + 5, + ) + .await; + let mut chunks = Vec::new(); + let mut cursor = 0; + loop { + let page = harness.page(&handle, cursor, 1).await; + assert_eq!(harness.page(&handle, cursor, 1).await, page); + cursor = page.next_cursor; + chunks.extend(page.chunks); + if let Some(terminal) = page.terminal { + assert_eq!(terminal.status, CommandStatus::Completed); + break; + } + } + assert_eq!( + stream_bytes(&chunks, CommandStream::Stdout), + b"\xe2\x82\xac\xff\0" + ); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stderr), + b"\xe6\x97\xa5" + ); + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 1, + wait: false + }) + .await + .is_err() + ); +} + +#[tokio::test] +async fn cancellation_while_backpressured_drains_bytes_before_terminal_fence() { + let harness = Harness::new(); + let handle = harness.start("printf ready; exec yes x", 30).await; + let first = harness.page(&handle, 0, 1).await; + harness + .session + .cancel_command(handle.clone()) + .await + .unwrap(); + let (mut chunks, terminal) = harness.collect(&handle, first.next_cursor).await; + let mut all = first.chunks; + all.append(&mut chunks); + assert!(stream_bytes(&all, CommandStream::Stdout).starts_with(b"ready")); + assert_eq!(terminal.status, CommandStatus::Cancelled); + let final_page = harness + .page( + &handle, + all.iter().map(|chunk| chunk.bytes.len() as u64).sum(), + 1, + ) + .await; + assert!(final_page.chunks.is_empty()); + assert_eq!(final_page.terminal, Some(terminal)); +} + +#[tokio::test] +async fn timeout_and_session_close_finalize_without_an_output_subscriber() { + let harness = Harness::new(); + let handle = harness + .start("printf ready; exec tail -f /dev/null", 1) + .await; + let (chunks, terminal) = harness.collect(&handle, 0).await; + assert_eq!(stream_bytes(&chunks, CommandStream::Stdout), b"ready"); + assert_eq!(terminal.status, CommandStatus::TimedOut); + let handle = harness.start("exec yes x", 30).await; + // Receiving one byte establishes actual process output, without a timing sleep. + harness.page(&handle, 0, 1).await; + tokio::time::timeout(Duration::from_secs(5), harness.session.close()) + .await + .unwrap() + .unwrap(); + assert!(harness.session.command_status(handle).await.is_err()); +} + +#[tokio::test] +async fn interleaved_streams_keep_independent_offsets_and_bounded_read_validation() { + let harness = Harness::new(); + let handle = harness + .start( + "for ((i=0;i<100;i++)); do printf 'out%s;' \"$i\"; printf 'err%s;' \"$i\" >&2; done", + 5, + ) + .await; + for limit in [0, crate::MAX_COMMAND_READ_BYTES + 1] { + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: 0, + limit, + wait: false + }) + .await + .is_err() + ); + } + let (chunks, terminal) = harness.collect(&handle, 0).await; + let expected = |prefix| { + (0..100) + .map(|i| format!("{prefix}{i};")) + .collect::() + .into_bytes() + }; + assert_eq!( + stream_bytes(&chunks, CommandStream::Stdout), + expected("out") + ); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stderr), + expected("err") + ); + assert_eq!(terminal.status, CommandStatus::Completed); +} + +#[tokio::test] +async fn byte_budget_zero_exact_partial_and_shared_keep_seen_and_delivered_distinct() { + let harness = Harness::new(); + for (command, budget, stdout_seen, stderr_seen, exit_code) in [ + ("printf abc; printf defg >&2", 0, 3, 4, 0), + ("printf abc", 3, 3, 0, 0), + ("printf abcdefg", 3, 7, 0, 0), + ("printf abcdefg; exit 7", 3, 7, 0, 7), + ( + "head -c 32768 /dev/zero; head -c 32768 /dev/zero >&2", + 12345, + 32768, + 32768, + 0, + ), + ] { + let handle = harness.start_budget(command, 5, budget).await; + let (chunks, terminal) = harness.collect(&handle, 0).await; + let stdout = stream_bytes(&chunks, CommandStream::Stdout); + let stderr = stream_bytes(&chunks, CommandStream::Stderr); + let mut cursor = 0; + for chunk in &chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + } + assert_eq!(cursor, budget.min(stdout_seen + stderr_seen)); + assert_eq!(terminal.stdout_bytes, stdout_seen); + assert_eq!(terminal.stderr_bytes, stderr_seen); + assert_eq!(terminal.stdout_delivered_bytes, stdout.len() as u64); + assert_eq!(terminal.stderr_delivered_bytes, stderr.len() as u64); + assert_eq!(terminal.exit_code, Some(exit_code)); + assert_eq!( + terminal.status, + if exit_code == 0 { + CommandStatus::Completed + } else { + CommandStatus::Failed + } + ); + assert_eq!( + terminal.truncation_reason, + (cursor < stdout_seen + stderr_seen) + .then_some(crate::CommandOutputTruncation::ByteBudget) + ); + } +} + +#[tokio::test] +async fn exhausted_budget_finishes_without_receiver_and_keeps_timeout_cancel_distinct() { + let harness = Harness::new(); + for budget in [0, 1, (QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64] { + let handle = harness + .start_budget("head -c 1048576 /dev/zero; printf error >&2", 5, budget) + .await; + let mut status = harness.session.inner.commands.lock().await[&handle.0] + .status + .clone(); + let terminal = tokio::time::timeout( + Duration::from_secs(5), + status.wait_for(|value| value.is_some()), + ) + .await + .unwrap() + .unwrap() + .clone() + .unwrap(); + assert_eq!(terminal.status, CommandStatus::Completed); + assert_eq!(terminal.stdout_bytes, 1048576); + assert_eq!(terminal.stderr_bytes, 5); + assert_eq!( + terminal.stdout_delivered_bytes + terminal.stderr_delivered_bytes, + budget + ); + // Full capture queue cannot retain the process task or its resources. + { + let mut commands = harness.session.inner.commands.lock().await; + tokio::time::timeout( + Duration::from_secs(5), + &mut commands.get_mut(&handle.0).unwrap().task, + ) + .await + .unwrap() + .unwrap(); + } + // Only now acknowledge the captured queue and terminal fence. + let (_, delivered_terminal) = harness.collect(&handle, 0).await; + assert_eq!(delivered_terminal, terminal); + } + for (cancel, expected) in [ + (false, CommandStatus::TimedOut), + (true, CommandStatus::Cancelled), + ] { + let handle = harness + .start_budget( + "printf ready-extra; head -c 32768 /dev/zero; exec tail -f /dev/null", + 1, + 5, + ) + .await; + let first = harness.page(&handle, 0, 5).await; + assert_eq!(first.next_cursor, 5); + if cancel { + harness + .session + .cancel_command(handle.clone()) + .await + .unwrap(); + } + let (rest, terminal) = harness.collect(&handle, first.next_cursor).await; + assert!(rest.is_empty()); + assert_eq!(terminal.status, expected); + assert_eq!(terminal.stdout_delivered_bytes, 5); + assert!(terminal.stdout_bytes >= 11); + assert_eq!( + terminal.truncation_reason, + Some(crate::CommandOutputTruncation::ByteBudget) + ); + if !cancel { + assert_eq!(terminal.stdout_bytes, 32779); + assert_eq!( + terminal.truncation_reason, + Some(crate::CommandOutputTruncation::ByteBudget) + ); + } + } +} + +#[tokio::test] +async fn budget_replay_preserves_unique_bytes_and_rejects_future_and_old_cursors() { + let harness = Harness::new(); + let handle = harness.start_budget("printf abcdefghij", 5, 7).await; + let first = harness.page(&handle, 0, 2).await; + for _ in 0..3 { + assert_eq!(harness.page(&handle, 0, 2).await, first); + } + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: 8, + limit: 2, + wait: false, + }) + .await + .is_err() + ); + let (rest, terminal) = harness.collect(&handle, first.next_cursor).await; + let mut all = first.chunks; + all.extend(rest); + assert_eq!(stream_bytes(&all, CommandStream::Stdout), b"abcdefg"); + assert_eq!(terminal.stdout_bytes, 10); + assert_eq!(terminal.stdout_delivered_bytes, 7); + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 2, + wait: false, + }) + .await + .is_err() + ); +} + +#[tokio::test] +async fn invalid_budget_is_rejected_before_process_start() { + let harness = Harness::new(); + assert!(matches!( + harness + .session + .start_command(Harness::request( + "touch must-not-exist", + 5, + crate::MAX_COMMAND_TRANSPORT_BYTES + 1, + )) + .await, + Err(WorkdirError::InvalidArgument(_)) + )); + assert!(!harness._root.path().join("must-not-exist").exists()); + assert!(harness.session.inner.commands.lock().await.is_empty()); +} + +#[tokio::test] +async fn slow_receiver_backpressures_before_quota_but_full_queue_does_not_block_discard() { + use std::future::Future; + use std::task::Poll; + let telemetry = CommandTelemetry::new(); + let input = vec![b'x'; 1024 * 1024]; + for budget in [ + crate::MAX_COMMAND_TRANSPORT_BYTES, + (QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64, + ] { + let (sender, mut receiver) = mpsc::channel(QUEUE_CHUNKS); + let mut accounting = ByteAccounting::default(); + { + let output = pump( + input.as_slice(), + &b""[..], + &sender, + &telemetry, + "test", + budget, + &mut accounting, + ); + tokio::pin!(output); + let polled = std::future::poll_fn(|cx| Poll::Ready(output.as_mut().poll(cx))).await; + if budget == crate::MAX_COMMAND_TRANSPORT_BYTES { + assert!(polled.is_pending(), "a slow receiver must stop pipe reads"); + } else { + assert!( + matches!(polled, Poll::Ready(Ok(()))), + "discard must not reserve queue capacity" + ); + } + } + assert_eq!(receiver.len(), QUEUE_CHUNKS); + assert_eq!( + accounting.stdout_delivered, + (QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64 + ); + assert_eq!( + accounting.stdout, + if budget == crate::MAX_COMMAND_TRANSPORT_BYTES { + accounting.stdout_delivered + } else { + input.len() as u64 + } + ); + let mut queued_bytes = 0; + while let Ok(Message::Bytes(chunk)) = receiver.try_recv() { + queued_bytes += chunk.bytes.len(); + } + assert_eq!(queued_bytes, QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES); + } +} diff --git a/crates/workdir/src/operation.rs b/crates/workdir/src/operation.rs index 3c2eb2318..7e180e7aa 100644 --- a/crates/workdir/src/operation.rs +++ b/crates/workdir/src/operation.rs @@ -1,5 +1,3 @@ -use std::path::PathBuf; - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,18 +6,18 @@ use serde::{Deserialize, Serialize}; pub struct CommandHandle(pub String); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct CommandRequest { pub command: String, #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub timeout_secs: u64, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] - pub output_limit: usize, + /// Aggregate unique-byte capture allowance, independent of consumer storage. + /// Providers drain and count excess bytes without delivering their payload. + #[schemars(range(min = 0, max = 67_108_864_u64))] + pub transport_byte_budget: u64, /// Workdir-relative command directory. Providers validate it against the /// active session before process start. pub cwd: fs_operation::FsPath, - /// Provider-local directory where complete output is retained when the - /// inline result exceeds `output_limit`. - pub spill_dir: Option, /// Optional caller-owned correlation id. Bash supplies its tool-call id so /// user-facing command telemetry can update the corresponding Console row /// without exposing provider/session handles. @@ -27,12 +25,23 @@ pub struct CommandRequest { pub tool_call_id: Option, } +impl CommandRequest { + pub fn validate(&self) -> Result<(), crate::WorkdirError> { + if self.transport_byte_budget > MAX_COMMAND_TRANSPORT_BYTES { + return Err(crate::WorkdirError::InvalidArgument( + "command transport byte budget exceeds provider maximum".into(), + )); + } + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct CommandOutputRequest { pub handle: CommandHandle, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] - pub cursor: usize, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + #[schemars(range(min = 0, max = 67_108_864_u64))] + pub cursor: u64, + #[schemars(range(min = 1, max = 65_536_usize))] pub limit: usize, pub wait: bool, } @@ -107,16 +116,150 @@ pub enum CommandEvent { }, } +/// Sequence is the merged transport byte position. Offset addresses raw bytes +/// within the named stream; neither ordering nor offsets depend on UTF-8 decoding. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct CommandOutput { +pub struct CommandByteChunk { + pub sequence: u64, + pub stream: CommandStream, + pub offset: u64, + pub bytes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommandOutputTruncation { + ByteBudget, +} + +/// Raw totals include discarded pipe bytes. Delivered totals count only prefixes +/// admitted to the transport queue, not page reads or retries by a consumer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct CommandTerminal { pub status: CommandStatus, - #[schemars(range(min = -2147483648_i32, max = 2147483647_i32))] pub exit_code: Option, - pub timed_out: bool, - pub content: String, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] - pub next_cursor: Option, - pub truncated: bool, - /// Complete output retained by the provider when `truncated` is true. - pub output_path: Option, + pub stdout_bytes: u64, + pub stderr_bytes: u64, + pub stdout_delivered_bytes: u64, + pub stderr_delivered_bytes: u64, + pub truncation_reason: Option, +} + +/// Sending next_cursor acknowledges this page. Only the current page may be +/// retried; acknowledged bytes expire. Terminal fences all captured bytes, +/// including on timeout/cancel. Consumers must pull while the process runs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CommandOutput { + pub chunks: Vec, + pub next_cursor: u64, + pub terminal: Option, +} + +impl CommandOutput { + pub(crate) fn transport_bounds_valid(&self) -> bool { + let mut end = None; + let mut stream_ends = [None, None]; + for chunk in &self.chunks { + let stream_index = match chunk.stream { + CommandStream::Stdout => 0, + CommandStream::Stderr => 1, + }; + if chunk.bytes.is_empty() + || chunk.offset > chunk.sequence + || stream_ends[stream_index].is_some_and(|end| chunk.offset != end) + || end.is_some_and(|end| chunk.sequence != end) + || chunk + .sequence + .checked_add(chunk.bytes.len() as u64) + .is_none_or(|end| end > MAX_COMMAND_TRANSPORT_BYTES) + || chunk + .offset + .checked_add(chunk.bytes.len() as u64) + .is_none_or(|end| end > MAX_COMMAND_TRANSPORT_BYTES) + { + return false; + } + end = chunk.sequence.checked_add(chunk.bytes.len() as u64); + stream_ends[stream_index] = chunk.offset.checked_add(chunk.bytes.len() as u64); + } + self.next_cursor <= MAX_COMMAND_TRANSPORT_BYTES + && end.is_none_or(|end| end == self.next_cursor) + && self.terminal.as_ref().is_none_or(|terminal| { + let truncated = terminal.stdout_bytes > terminal.stdout_delivered_bytes + || terminal.stderr_bytes > terminal.stderr_delivered_bytes; + stream_ends[0].is_none_or(|end| end == terminal.stdout_delivered_bytes) + && stream_ends[1].is_none_or(|end| end == terminal.stderr_delivered_bytes) + && terminal.status != CommandStatus::Running + && terminal.stdout_delivered_bytes <= terminal.stdout_bytes + && terminal.stderr_delivered_bytes <= terminal.stderr_bytes + && terminal + .stdout_delivered_bytes + .checked_add(terminal.stderr_delivered_bytes) + == Some(self.next_cursor) + && terminal.truncation_reason + == truncated.then_some(CommandOutputTruncation::ByteBudget) + }) + } +} + +pub const MAX_COMMAND_TRANSPORT_BYTES: u64 = 64 * 1024 * 1024; + +pub const MAX_COMMAND_READ_BYTES: usize = 64 * 1024; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_wire_rejects_legacy_output_host_paths() { + let command = serde_json::json!({ + "command": "printf bytes", "timeout_secs": 1, "cwd": "", "transport_byte_budget": 0 + }); + assert!(serde_json::from_value::(command.clone()).is_ok()); + for field in ["spill_dir", "output_limit"] { + let mut legacy = command.clone(); + legacy[field] = serde_json::json!("/host/private"); + assert!(serde_json::from_value::(legacy).is_err()); + } + let output = serde_json::json!({"chunks": [], "next_cursor": 0, "terminal": null, "output_path": "/host/private"}); + assert!(serde_json::from_value::(output).is_err()); + } + + #[test] + fn command_budget_wire_requires_field_and_checks_provider_hard_bound() { + let wire = serde_json::json!({"command": "true", "timeout_secs": 1, "cwd": ""}); + assert!(serde_json::from_value::(wire.clone()).is_err()); + for budget in [ + 0, + MAX_COMMAND_TRANSPORT_BYTES, + MAX_COMMAND_TRANSPORT_BYTES + 1, + ] { + let mut value = wire.clone(); + value["transport_byte_budget"] = serde_json::json!(budget); + let request: CommandRequest = serde_json::from_value(value).unwrap(); + assert_eq!( + request.validate().is_ok(), + budget <= MAX_COMMAND_TRANSPORT_BYTES + ); + } + let mut negative = wire; + negative["transport_byte_budget"] = serde_json::json!(-1); + assert!(serde_json::from_value::(negative).is_err()); + assert_eq!( + serde_json::to_value(CommandOutputTruncation::ByteBudget).unwrap(), + "byte_budget" + ); + let schema = serde_json::to_value(schemars::schema_for!(CommandRequest)).unwrap(); + assert_eq!( + schema["properties"]["transport_byte_budget"]["maximum"], + MAX_COMMAND_TRANSPORT_BYTES + ); + assert!( + schema["required"] + .as_array() + .unwrap() + .contains(&serde_json::json!("transport_byte_budget")) + ); + } } diff --git a/crates/workdir/src/router.rs b/crates/workdir/src/router.rs index 9eb190121..362504eab 100644 --- a/crates/workdir/src/router.rs +++ b/crates/workdir/src/router.rs @@ -341,7 +341,9 @@ impl WorkdirSession for RoutedWorkdirSession { let _active = self.state.enter()?; let handle = request.handle.clone(); let output = self.state.session.command_output(request).await?; - self.state.mark_terminal(&handle, output.status); + if let Some(terminal) = &output.terminal { + self.state.mark_terminal(&handle, terminal.status); + } Ok(output) } @@ -789,11 +791,12 @@ mod tests { .unwrap(); let handle = routed .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf done".to_string(), timeout_secs: 5, - output_limit: 1024, + cwd: crate::WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await @@ -823,11 +826,12 @@ mod tests { let routed = router.attach(alias, Arc::clone(&underlying)).unwrap(); let handle = routed .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 1024, + cwd: crate::WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await @@ -850,11 +854,12 @@ mod tests { let routed = router.attach(alias.clone(), session("wd", &dir)).unwrap(); let handle = routed .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 1024, + cwd: crate::WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index 6fd71cfd3..12b187230 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -10,6 +10,9 @@ //! provider-side before name enumeration, descent, or content reads. Returned coordinates are not //! prefixed with cwd again. +#[cfg(test)] +use crate::command_test_support::CollectCommand as _; + use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -124,6 +127,7 @@ impl WorkdirToolBroker { next_lease_id: AtomicU64::new(1), close_lock: Arc::new(tokio::sync::Mutex::new(())), owned_commands: Arc::new(Mutex::new(HashSet::new())), + command_reads: Mutex::new(HashMap::new()), pending_command_events: Arc::new(Mutex::new(HashMap::new())), starting_tool_calls: Arc::new(Mutex::new(HashSet::new())), forwarded_starts: Arc::new(Mutex::new(HashSet::new())), @@ -230,25 +234,48 @@ impl WorkdirScopeLease { .source .cancel_command(handle.clone()) .await; - let terminal = self + let read_cursor = self .broker .authority - .source - .command_output(CommandOutputRequest { - handle, - cursor: 0, - limit: 1, - wait: true, - }) - .await; + .command_reads + .lock() + .expect("command cursor mutex poisoned") + .get(&command_id) + .cloned() + .expect("owned command cursor"); + let mut read_cursor = read_cursor.lock().await; + let mut cursor = *read_cursor; + let terminal = loop { + let output = self + .broker + .authority + .source + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: crate::MAX_COMMAND_READ_BYTES, + wait: true, + }) + .await; + match output { + Ok(output) => { + cursor = output.next_cursor; + *read_cursor = cursor; + if let Some(terminal) = output.terminal { + break Ok(terminal); + } + } + Err(error) => break Err(error), + } + }; match (cancel, terminal) { (_, Ok(output)) => { self.broker.authority.publish_terminal_if_missing( &command_id, output.status, output.exit_code, - output.next_cursor.unwrap_or(output.content.len()) as u64, - &output.content, + cursor, + "", ); self.broker .authority @@ -582,7 +609,12 @@ struct ScopedWorkdirSession { scope_lock: tokio::sync::Mutex<()>, next_lease_id: AtomicU64, close_lock: Arc>, + // Live cleanup responsibility ends at terminal delivery. Read ownership is + // retained separately so a lost terminal response can still be replayed. owned_commands: Arc>>, + // Starting commands prunes completed entries at the bounded replay ceiling; + // current scope validity is still checked before every read or retry. + command_reads: Mutex>>>, pending_command_events: Arc>>>, starting_tool_calls: Arc>>, forwarded_starts: Arc>>, @@ -700,6 +732,11 @@ impl ScopedWorkdirSession { .lock() .expect("scoped command set mutex poisoned") .contains(&handle.0) + || self + .command_reads + .lock() + .expect("command cursor mutex poisoned") + .contains_key(&handle.0) { Ok(()) } else { @@ -1013,6 +1050,7 @@ impl ScopedWorkdirSession { next_lease_id: AtomicU64::new(1), close_lock: close_lock.clone(), owned_commands, + command_reads: Mutex::new(HashMap::new()), pending_command_events, starting_tool_calls, forwarded_starts, @@ -1407,6 +1445,16 @@ impl WorkdirSession for ScopedWorkdirSession { .lock() .expect("scoped command set mutex poisoned"); owned.insert(handle.0.clone()); + { + let mut reads = self + .command_reads + .lock() + .expect("command cursor mutex poisoned"); + if reads.len() >= 64 { + reads.retain(|id, _| owned.contains(id)); + } + reads.insert(handle.0.clone(), Arc::new(tokio::sync::Mutex::new(0))); + } if let Some(tool_call_id) = &tool_call_id { self.starting_tool_calls .lock() @@ -1457,14 +1505,28 @@ impl WorkdirSession for ScopedWorkdirSession { ) -> Result { self.ensure_owned_command(&request.handle)?; let command_id = request.handle.0.clone(); + let read_cursor = self + .command_reads + .lock() + .expect("command cursor mutex poisoned") + .get(&command_id) + .cloned(); + let mut read_cursor = match &read_cursor { + Some(cursor) => Some(cursor.lock().await), + None => None, + }; + self.ensure_owned_command(&request.handle)?; let output = self.source.command_output(request).await?; - if !matches!(output.status, CommandStatus::Running) { + if let Some(cursor) = &mut read_cursor { + **cursor = output.next_cursor; + } + if let Some(terminal) = &output.terminal { self.publish_terminal_if_missing( &command_id, - output.status, - output.exit_code, - output.next_cursor.unwrap_or(output.content.len()) as u64, - &output.content, + terminal.status, + terminal.exit_code, + output.next_cursor, + "", ); self.owned_commands .lock() @@ -2080,20 +2142,21 @@ mod tests { session: &WorkdirSessionHandle, command: impl Into, tool_call_id: impl Into, - ) -> CommandOutput { + ) -> crate::command_test_support::CollectedCommand { let handle = session .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: command.into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some(tool_call_id.into()), }) .await .unwrap(); session - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 1024, @@ -2112,11 +2175,12 @@ mod tests { .expect("delegation wrapper must preserve command observation"); let handle = parent .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf ready; sleep 0.2; printf done".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("tool-delegated".into()), }) .await @@ -2139,7 +2203,7 @@ mod tests { assert_eq!(snapshots[0].stdout.content, "ready"); let output = parent - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 1024, @@ -2179,11 +2243,12 @@ mod tests { ); let error = child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "pwd".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: None, }) .await @@ -2211,11 +2276,12 @@ mod tests { let handle = first .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "pwd; sleep 0.2".into(), timeout_secs: 5, - output_limit: 4096, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("first-command".into()), }) .await @@ -2239,7 +2305,7 @@ mod tests { )); let output = first - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 4096, @@ -2316,11 +2382,12 @@ mod tests { assert!(matches!( child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf denied".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("read-only-command".into()), }) .await, @@ -2622,11 +2689,12 @@ mod tests { assert!(matches!( child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf revoked".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("revoked-child-command".into()), }) .await, @@ -2724,11 +2792,12 @@ mod tests { for index in 0..MAX_SCOPED_COMMANDS { child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some(format!("command-{index}")), }) .await @@ -2737,11 +2806,12 @@ mod tests { let error = child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("command-over-limit".into()), }) .await @@ -2771,11 +2841,12 @@ mod tests { let command = tokio::spawn(async move { command_child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("racing-command".into()), }) .await @@ -2817,11 +2888,12 @@ mod tests { let mut events = child.subscribe_command_events().unwrap(); let handle = child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30; printf leaked > marker".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("owned-command".into()), }) .await @@ -3012,11 +3084,12 @@ mod tests { assert!(matches!( parent .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf closed".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("closed-parent-command".into()), }) .await, @@ -3028,4 +3101,180 @@ mod tests { "child result after parent close: {child_result:?}" ); } + #[tokio::test] + async fn scoped_byte_cursor_survives_partial_ack_then_revocation_and_terminal_retry() { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join("child")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("child", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + let tool = child.tool_session(); + let handle = tool + .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf ready; exec yes x".into(), + timeout_secs: 10, + cwd: FsPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + for _ in 0..2 { + let page = tool + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 1, + wait: true, + }) + .await + .unwrap(); + cursor = page.next_cursor; + } + tokio::time::timeout(std::time::Duration::from_secs(5), child.close()) + .await + .unwrap() + .unwrap(); + assert!( + tool.command_output(CommandOutputRequest { + handle, + cursor, + limit: 1, + wait: false + }) + .await + .is_err() + ); + let handle = parent + .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf parent".into(), + timeout_secs: 5, + cwd: FsPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + loop { + let request = CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 1024, + wait: true, + }; + let page = parent.command_output(request.clone()).await.unwrap(); + assert_eq!(parent.command_output(request).await.unwrap(), page); + cursor = page.next_cursor; + if page.terminal.is_some() { + break; + } + } + } + #[tokio::test] + async fn scoped_terminal_response_loss_replays_without_duplicate_capture_and_rejects_invalid_cursors() + { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join("child")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("child", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + let tool = child.tool_session(); + let handle = tool + .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf abcdef".into(), + timeout_secs: 5, + cwd: FsPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + let mut captured = Vec::new(); + let terminal_request = loop { + let request = CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 2, + wait: true, + }; + // Simulate losing each produced HTTP response, including terminal: + // append only the retried page, never both copies. + let lost = tool.command_output(request.clone()).await.unwrap(); + let replay = tool.command_output(request.clone()).await.unwrap(); + assert_eq!(replay, lost); + for chunk in &replay.chunks { + captured.extend_from_slice(&chunk.bytes); + } + cursor = replay.next_cursor; + if let Some(terminal) = replay.terminal { + assert_eq!(terminal.status, CommandStatus::Completed); + break request; + } + }; + assert_eq!(captured, b"abcdef"); + assert!( + !child + .broker + .authority + .owned_commands + .lock() + .unwrap() + .contains(&handle.0) + ); + assert!( + child + .broker + .authority + .command_reads + .lock() + .unwrap() + .contains_key(&handle.0) + ); + assert_eq!( + tool.command_status(handle.clone()).await.unwrap(), + CommandStatus::Completed + ); + for invalid in [0, cursor + 1] { + assert!( + tool.command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: invalid, + limit: 2, + wait: false + }) + .await + .is_err() + ); + } + // Invalid cursor requests do not destroy the retained terminal page. + assert!( + tool.command_output(terminal_request) + .await + .unwrap() + .terminal + .is_some() + ); + child.close().await.unwrap(); + assert!( + tool.command_output(CommandOutputRequest { + handle, + cursor, + limit: 2, + wait: false + }) + .await + .is_err() + ); + }) + .await + .expect("bounded scoped terminal replay"); + } } diff --git a/crates/workdir/tests/command_denial_diagnostics.rs b/crates/workdir/tests/command_denial_diagnostics.rs index cfc4c5231..8db54686d 100644 --- a/crates/workdir/tests/command_denial_diagnostics.rs +++ b/crates/workdir/tests/command_denial_diagnostics.rs @@ -103,11 +103,12 @@ impl WorkdirSession for CommandFailureSession { } fn command() -> CommandRequest { CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, command: "fake command, never executed".into(), timeout_secs: 1, - output_limit: 100, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, } } diff --git a/crates/workdir/tests/denial_reasons.rs b/crates/workdir/tests/denial_reasons.rs index 53c64d5dd..41b031f7a 100644 --- a/crates/workdir/tests/denial_reasons.rs +++ b/crates/workdir/tests/denial_reasons.rs @@ -167,11 +167,12 @@ async fn read_only_and_scoped_capability_refusals_do_not_collapse() { assert_reason( child .start_command(workdir::CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, command: "must-not-run".into(), timeout_secs: 1, - output_limit: 1, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index cbc0ad393..0eba6a589 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -56,6 +56,7 @@ server-api = { path = "../server-api" } workdir.workspace = true [dev-dependencies] +workdir = { workspace = true, features = ["http-client"] } futures.workspace = true agen.workspace = true serial_test = "3.4.0" diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index a916a5f69..57b7d0eb6 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -67,7 +67,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::net::TcpListener; use workdir::{ - CommandOutput, CommandStatus, WorkdirSessionHandle, dispatch_workdir_session_operation, + CommandOutput, WorkdirSessionHandle, dispatch_workdir_session_operation, http::{ OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, WorkdirSessionId, WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirSessionOperationResult, @@ -914,13 +914,9 @@ async fn run_workdir_session_operation( { Ok(result) => result?, Err(_) => CommandOutput { - exit_code: None, - timed_out: false, - status: CommandStatus::Running, - content: String::new(), - next_cursor: Some(cursor), - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: cursor, + terminal: None, }, }; WorkdirSessionOperationResult::CommandOutput(output) @@ -2426,6 +2422,7 @@ pub enum RuntimeHttpServerError { #[cfg(test)] mod tests { + mod command_transport_tests; #[cfg(feature = "fs-store")] mod session_material_tests; diff --git a/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs b/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs new file mode 100644 index 000000000..643e722ab --- /dev/null +++ b/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs @@ -0,0 +1,408 @@ +//! Loopback integration: production RemoteWorkdirSession, signed HTTP middleware, +//! command operation handler, local process pipes, and JSON DTOs. Only opening the +//! pre-seeded session is a fixture; no Runtime Worker/materializer is claimed here. + +use super::*; +use std::time::Duration; +use workdir::http::{ + RemoteWorkdirSession, WorkdirHttpAuthorization, WorkdirHttpAuthorizationFuture, +}; +use workdir::{CommandOutputRequest, CommandRequest, CommandStream, WorkdirSession}; + +struct SignedAuthorization { + identity: RuntimeIdentityMaterial, + trust: WorkspaceIssuerTrustRecord, +} +impl std::fmt::Debug for SignedAuthorization { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignedAuthorization") + } +} +impl WorkdirHttpAuthorization for SignedAuthorization { + fn bearer_token( + &self, + method: String, + path: String, + body: Vec, + ) -> WorkdirHttpAuthorizationFuture { + let claims = signed_claims(&self.trust, &method, &path, &body); + let token = + issue_workspace_capability_token(&self.identity.signing_key().unwrap(), &claims) + .unwrap(); + Box::pin(async move { Ok(token) }) + } +} + +struct Harness { + server: tokio::task::JoinHandle<()>, + session: WorkdirSessionHandle, + _root: tempfile::TempDir, + remote: RemoteWorkdirSession, + client: reqwest::Client, + operation_url: url::Url, + authorization: Arc, +} +impl Drop for Harness { + fn drop(&mut self) { + self.server.abort(); + } +} +impl Harness { + async fn raw_operation(&self, operation: serde_json::Value) -> reqwest::Response { + let body = serde_json::to_vec(&serde_json::json!({"operation": operation})).unwrap(); + let token = self + .authorization + .bearer_token( + "POST".into(), + self.operation_url.path().into(), + body.clone(), + ) + .await + .unwrap(); + self.client + .post(self.operation_url.clone()) + .bearer_auth(token) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap() + } + + async fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound( + Workdir::new("wd-transport"), + root.path().to_path_buf(), + root.path().to_path_buf(), + SharedScope::new(Scope::writable(root.path()).unwrap()), + WorkdirSessionCapabilities::ALL, + )); + let identity = RuntimeIdentityMaterial::generate("transport-key").unwrap(); + let trust = workspace_trust(&identity); + let state = RuntimeHttpState { + runtime: Runtime::new_memory(), + local_token: None, + workspace_auth: Some(Arc::new(workspace_test_auth(vec![trust.clone()]))), + workdir_sessions: Arc::new(Mutex::new(HashMap::from([( + "session-transport".into(), + RuntimeHttpWorkdirSession { + owner: RuntimeWorkspaceScope::new("workspace-a", "https://backend.test"), + session: session.clone(), + }, + )]))), + ssh_keyscan_program: Arc::new(PathBuf::from("ssh-keyscan")), + }; + let app = Router::new() + .route( + "/v1/working-directories/{id}/sessions", + post(|| async { + Json(OpenWorkdirSessionResponse { + session_id: WorkdirSessionId::new("session-transport").unwrap(), + workdir_id: Workdir::new("wd-transport").id().clone(), + capabilities: WorkdirSessionCapabilities::ALL, + }) + }), + ) + .route( + "/v1/workdir-sessions/{session_id}/operations", + post(run_workdir_session_operation), + ) + .route( + "/v1/workdir-sessions/{session_id}", + delete(close_workdir_session), + ) + .with_state(state.clone()) + .layer(middleware::from_fn_with_state(state, require_runtime_auth)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base: url::Url = format!("http://{}", listener.local_addr().unwrap()) + .parse() + .unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + RUNTIME_WORKSPACE_SCOPE_HEADER, + "workspace-a".parse().unwrap(), + ); + let client = reqwest::Client::builder() + .no_proxy() + .default_headers(headers) + .build() + .unwrap(); + let authorization = Arc::new(SignedAuthorization { identity, trust }); + let operation_url = base + .join("/v1/workdir-sessions/session-transport/operations") + .unwrap(); + let remote = RemoteWorkdirSession::open_with_authorization( + client.clone(), + base, + authorization.clone(), + Workdir::new("wd-transport").id().clone(), + OpenWorkdirSessionRequest::default(), + ) + .await + .unwrap(); + Self { + server, + session, + _root: root, + remote, + client, + operation_url, + authorization, + } + } +} + +#[tokio::test] +async fn real_http_command_pages_preserve_binary_streams_replay_and_nonzero_exit() { + tokio::time::timeout(Duration::from_secs(20), async { + let harness = Harness::new().await; + let handle = harness.remote.start_command(CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, + command: "head -c 2097152 /dev/zero | tr '\\0' x; printf '\\342\\202\\254\\377\\000'; printf 'stderr\\346\\227\\245' >&2; exit 9".into(), + timeout_secs: 15, cwd: WorkdirPath::root(), tool_call_id: Some("http-tool-call".into()), + }).await.unwrap(); + let mut cursor = 0; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let terminal = loop { + let request = CommandOutputRequest { handle: handle.clone(), cursor, limit: 16384, wait: true }; + let page = harness.remote.command_output(request.clone()).await.unwrap(); + assert_eq!(harness.remote.command_output(request).await.unwrap(), page); + assert!(page.chunks.iter().map(|chunk| chunk.bytes.len()).sum::() <= 16384); + for chunk in page.chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + let target = match chunk.stream { CommandStream::Stdout => &mut stdout, CommandStream::Stderr => &mut stderr }; + assert_eq!(chunk.offset, target.len() as u64); + target.extend(chunk.bytes); + } + assert_eq!(cursor, page.next_cursor); + if let Some(terminal) = page.terminal { break terminal; } + }; + let mut expected = vec![b'x'; 2097152]; + expected.extend_from_slice(b"\xe2\x82\xac\xff\0"); + assert_eq!(stdout, expected); + assert_eq!(stderr, b"stderr\xe6\x97\xa5"); + assert_eq!(terminal.status, workdir::CommandStatus::Failed); + assert_eq!(terminal.exit_code, Some(9)); + assert!(harness.remote.command_output(CommandOutputRequest { handle, cursor: 0, limit: 1, wait: false }).await.is_err()); + harness.remote.close().await.unwrap(); + assert!(harness.session.command_snapshot().is_empty()); + }).await.expect("bounded HTTP command transport"); +} + +#[tokio::test] +async fn real_http_cancel_drains_tail_before_terminal_and_timeout_is_distinct() { + tokio::time::timeout(Duration::from_secs(15), async { + let harness = Harness::new().await; + for (command, timeout, cancel, expected) in [ + ( + "printf ready; exec yes x", + 10, + true, + workdir::CommandStatus::Cancelled, + ), + ( + "printf ready; exec tail -f /dev/null", + 1, + false, + workdir::CommandStatus::TimedOut, + ), + ] { + let handle = harness + .remote + .start_command(CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, + command: command.into(), + timeout_secs: timeout, + cwd: WorkdirPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + let first = harness + .remote + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 5, + wait: true, + }) + .await + .unwrap(); + assert_eq!( + first + .chunks + .iter() + .flat_map(|chunk| chunk.bytes.iter().copied()) + .collect::>(), + b"ready" + ); + cursor = first.next_cursor; + if cancel { + harness.remote.cancel_command(handle.clone()).await.unwrap(); + } + loop { + let page = harness + .remote + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }) + .await + .unwrap(); + cursor = page.next_cursor; + if let Some(terminal) = page.terminal { + assert_eq!(terminal.status, expected); + break; + } + } + } + harness.remote.close().await.unwrap(); + }) + .await + .expect("bounded cancellation and timeout over HTTP"); +} + +#[tokio::test] +async fn real_http_command_budget_bounds_unique_payload_and_reports_larger_seen_totals() { + tokio::time::timeout(Duration::from_secs(20), async { + let harness = Harness::new().await; + for budget in [0, 12345] { + let handle = harness + .remote + .start_command(CommandRequest { + command: "head -c 1048576 /dev/zero; head -c 65536 /dev/zero >&2; exit 9" + .into(), + timeout_secs: 10, + transport_byte_budget: budget, + cwd: WorkdirPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + let mut delivered = [0_u64; 2]; + let terminal = loop { + let request = CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 997, + wait: true, + }; + let page = harness + .remote + .command_output(request.clone()) + .await + .unwrap(); + assert_eq!(harness.remote.command_output(request).await.unwrap(), page); + for chunk in page.chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + let index = match chunk.stream { + CommandStream::Stdout => 0, + CommandStream::Stderr => 1, + }; + assert_eq!(chunk.offset, delivered[index]); + delivered[index] += chunk.bytes.len() as u64; + } + assert_eq!(cursor, page.next_cursor); + assert!( + cursor <= budget, + "RemoteWorkdirSession must not receive bytes beyond budget" + ); + if let Some(terminal) = page.terminal { + break terminal; + } + }; + assert_eq!(cursor, budget); + assert_eq!(terminal.stdout_bytes, 1048576); + assert_eq!(terminal.stderr_bytes, 65536); + assert_eq!(terminal.stdout_delivered_bytes, delivered[0]); + assert_eq!(terminal.stderr_delivered_bytes, delivered[1]); + assert_eq!( + terminal.truncation_reason, + Some(workdir::CommandOutputTruncation::ByteBudget) + ); + assert_eq!(terminal.status, workdir::CommandStatus::Failed); + assert_eq!(terminal.exit_code, Some(9)); + assert!( + harness + .remote + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: budget + 1, + limit: 1, + wait: false, + }) + .await + .is_err() + ); + if budget > 0 { + assert!( + harness + .remote + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 1, + wait: false, + }) + .await + .is_err() + ); + } + } + harness.remote.close().await.unwrap(); + }) + .await + .expect("bounded HTTP byte-budget transport"); +} + +#[tokio::test] +async fn real_http_rejects_invalid_or_legacy_budget_requests_before_process_start() { + tokio::time::timeout(Duration::from_secs(10), async { + let harness = Harness::new().await; + let request = CommandRequest { + command: "touch must-not-exist".into(), + timeout_secs: 1, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES + 1, + cwd: WorkdirPath::root(), + tool_call_id: None, + }; + let oversize = + serde_json::to_value(WorkdirSessionOperation::CommandStart(request)).unwrap(); + assert_eq!( + harness.raw_operation(oversize.clone()).await.status(), + reqwest::StatusCode::BAD_REQUEST + ); + let mut missing = oversize.clone(); + // CommandStart is externally tagged by operation / request. + let object = missing.as_object_mut().unwrap(); + let request = object.get_mut("request").unwrap().as_object_mut().unwrap(); + request.remove("transport_byte_budget"); + assert_eq!( + harness.raw_operation(missing).await.status(), + reqwest::StatusCode::BAD_REQUEST + ); + let mut legacy = oversize; + let request = legacy["request"].as_object_mut().unwrap(); + request.insert("transport_byte_budget".into(), serde_json::json!(0)); + request.insert("spill_dir".into(), serde_json::json!("/private/host")); + assert_eq!( + harness.raw_operation(legacy).await.status(), + reqwest::StatusCode::BAD_REQUEST + ); + assert!(!harness._root.path().join("must-not-exist").exists()); + harness.remote.close().await.unwrap(); + }) + .await + .expect("bounded HTTP budget admission"); +} diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 74044cde9..f431f3dad 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -147,7 +147,6 @@ use worker::{ Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, - bash_output_dir_for_worker_id, }; #[cfg(feature = "ws-server")] @@ -1090,29 +1089,24 @@ fn runtime_local_workdir_session( root: &Path, cwd: &Path, scope: manifest::SharedScope, - command_output_scope: manifest::SharedScope, capabilities: WorkdirSessionCapabilities, command_environment: std::collections::BTreeMap, resources: Vec>, ) -> WorkdirSessionHandle { - Arc::new( - LocalWorkdirSession::materialized_bound_with_environment_and_command_output_scope( - Workdir::new(workdir_id), - root.to_path_buf(), - cwd.to_path_buf(), - scope, - command_output_scope, - capabilities, - command_environment, - resources, - ), - ) + Arc::new(LocalWorkdirSession::materialized_bound_with_environment( + Workdir::new(workdir_id), + root.to_path_buf(), + cwd.to_path_buf(), + scope, + capabilities, + command_environment, + resources, + )) } fn runtime_local_workdir_router( attachments: &BTreeMap, capabilities: &BTreeMap, - command_output_scope: manifest::SharedScope, ) -> Result, String> { let router = Arc::new(WorkdirSessionRouter::new()); for (alias, binding) in attachments { @@ -1129,7 +1123,6 @@ fn runtime_local_workdir_router( binding.root(), binding.cwd(), scope, - command_output_scope.clone(), capabilities, binding.command_environment(), binding.session_resources().map_err(|error| { @@ -1146,7 +1139,6 @@ fn restored_workdir_router( local_attachments: &BTreeMap, logical_attachments: &[crate::catalog::LogicalWorkdirAttachment], claims: &[crate::catalog::WorkingDirectoryAttachmentClaim], - scope: manifest::SharedScope, workspace_client: Arc, ) -> Result, String> { let mut local_capabilities = local_attachments @@ -1163,7 +1155,7 @@ fn restored_workdir_router( local_capabilities.insert(attachment.alias.clone(), attachment.capabilities); } } - let router = runtime_local_workdir_router(local_attachments, &local_capabilities, scope)?; + let router = runtime_local_workdir_router(local_attachments, &local_capabilities)?; for attachment in logical_attachments { if let Some(local) = local_attachments.get(&attachment.alias) { if local.working_directory.id != attachment.working_directory_id { @@ -1517,7 +1509,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let store = CombinedStore::new(session_store, worker_metadata_store); let run_dir = self.worker_run_dir(&request.worker_ref)?; - let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id); let bootstrap = WorkerBootstrap::new( manifest, store, @@ -1526,7 +1517,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { filesystem_authority, WorkerBootstrapLayout::RuntimeManagedRun { run_dir: run_dir.clone(), - bash_output_dir, }, self.controller_transport, ); @@ -1571,7 +1561,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { worker.bind_workdir_sessions(runtime_local_workdir_router( &request.workdir_attachments, &workdir_capabilities, - worker.scope().clone(), )?); if let (Some(runtime_id), Some(workspace_id)) = (observation_runtime_id, observation_workspace_id.clone()) @@ -1838,7 +1827,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { &request.workdir_attachments, &request.logical_workdir_attachments, &request.request.workdir_attachments, - worker.scope().clone(), worker.workspace_client_handle(), )?; worker.bind_workdir_sessions(workdir_sessions); @@ -1875,13 +1863,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let workspace_client = worker.workspace_client_handle(); let run_dir = self.worker_restore_run_dir(&request.worker_ref, request.operation_id)?; - let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id); let cleanup_sessions = worker.workdir_sessions(); let started = PreparedWorker::new( worker, WorkerBootstrapLayout::RuntimeManagedRun { run_dir: run_dir.clone(), - bash_output_dir, }, self.controller_transport, ) @@ -3755,7 +3741,6 @@ where working_directory_id, binding.root(), binding.cwd(), - scope.clone(), scope, WorkdirSessionCapabilities::ALL, binding.command_environment(), @@ -6804,8 +6789,6 @@ mod tests { #[test] fn restored_router_uses_workspace_proxy_for_logical_only_attachment() { - let root = tempfile::tempdir().unwrap(); - let scope = manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap()); let client = WorkerWorkspaceContext::local_filesystem(Some( WorkspaceId::new("workspace-a").unwrap(), )) @@ -6820,7 +6803,6 @@ mod tests { capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, }], &[], - scope, client, ) .unwrap(); @@ -6865,7 +6847,6 @@ mod tests { .unwrap(); let alias = WorkdirAttachmentAlias::new("docs").unwrap(); let workdir_id = binding.working_directory.id.clone(); - let command_output_scope = manifest::SharedScope::new(manifest::Scope::empty()); let attachments = BTreeMap::from([(alias.clone(), binding)]); let logical = [LogicalWorkdirAttachment { alias: alias.clone(), @@ -6877,9 +6858,7 @@ mod tests { )) .client_handle(); - let router = - restored_workdir_router(&attachments, &logical, &[], command_output_scope, client) - .unwrap(); + let router = restored_workdir_router(&attachments, &logical, &[], client).unwrap(); let session = router.session(&alias).unwrap(); assert_eq!( @@ -7566,14 +7545,10 @@ mod tests { ) .await .map_err(|err| err.to_string())?; - let bash_output_dir = self.runtime_base.join("bash-output"); - let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed( - worker, - &self.runtime_base, - &bash_output_dir, - ) - .await - .map_err(|err| err.to_string())?; + let (handle, shutdown_rx) = + WorkerController::spawn_runtime_managed(worker, &self.runtime_base) + .await + .map_err(|err| err.to_string())?; Ok(RuntimeWorkerController { handle, shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))), @@ -8697,7 +8672,6 @@ mod tests { "working-directory-42", root.path(), root.path(), - spawned_scope.clone(), spawned_scope, WorkdirSessionCapabilities::READ_ONLY, Default::default(), @@ -8708,7 +8682,6 @@ mod tests { "working-directory-42", root.path(), root.path(), - restored_scope.clone(), restored_scope, WorkdirSessionCapabilities::READ_ONLY, Default::default(), @@ -10233,8 +10206,7 @@ mod tests { let alias = WorkdirAttachmentAlias::new("checkout").unwrap(); let attachments = BTreeMap::from([(alias, binding.clone())]); - let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap()); - let router = runtime_local_workdir_router(&attachments, &BTreeMap::new(), scope).unwrap(); + let router = runtime_local_workdir_router(&attachments, &BTreeMap::new()).unwrap(); session_clone.close().await.unwrap(); assert_eq!( materializer.cleanup_working_directory(id).unwrap_err().code, diff --git a/crates/worker/examples/worker_protocol.rs b/crates/worker/examples/worker_protocol.rs index 53ec846d7..b893515da 100644 --- a/crates/worker/examples/worker_protocol.rs +++ b/crates/worker/examples/worker_protocol.rs @@ -47,9 +47,7 @@ async fn main() -> Result<(), Box> { let worker = worker::Worker::from_manifest_toml(&toml, store).await?; let runtime_tmp = tempfile::tempdir()?; - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, _shutdown_rx) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir).await?; + let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?; // Check initial status via shared state println!("[shared_state] {}", handle.shared_state.status_json()); diff --git a/crates/worker/src/bootstrap.rs b/crates/worker/src/bootstrap.rs index d027aa8c9..aa8943b6d 100644 --- a/crates/worker/src/bootstrap.rs +++ b/crates/worker/src/bootstrap.rs @@ -17,28 +17,9 @@ use manifest::WorkerManifest; #[derive(Debug, Clone)] pub enum WorkerBootstrapLayout { /// A direct Worker rooted below the supplied runtime base directory. - Direct { - runtime_base: PathBuf, - bash_output_dir: PathBuf, - }, + Direct { runtime_base: PathBuf }, /// A runtime-managed Worker with an exact persisted run directory. - RuntimeManagedRun { - run_dir: PathBuf, - bash_output_dir: PathBuf, - }, -} - -/// Return the temporary Bash spill directory owned by a stable Worker identity. -/// -/// The directory deliberately lives outside session/run-generation storage so a -/// restarted controller for the same Worker keeps the same readable artifact -/// boundary. -pub fn bash_output_dir_for_worker_id(worker_id: impl std::fmt::Display) -> PathBuf { - std::env::temp_dir() - .join("yoi") - .join("workers") - .join(worker_id.to_string()) - .join("bash-output") + RuntimeManagedRun { run_dir: PathBuf }, } /// Construction and controller inputs that are stable for one Worker launch. @@ -272,27 +253,12 @@ where { let cleanup_sessions = worker.workdir_sessions(); let controller = match layout { - WorkerBootstrapLayout::Direct { - runtime_base, - bash_output_dir, - } => { - WorkerController::spawn_with_transport_owned( - worker, - &runtime_base, - &bash_output_dir, - transport, - ) - .await + WorkerBootstrapLayout::Direct { runtime_base } => { + WorkerController::spawn_with_transport_owned(worker, &runtime_base, transport).await } - WorkerBootstrapLayout::RuntimeManagedRun { - run_dir, - bash_output_dir, - } => { + WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => { WorkerController::spawn_runtime_managed_run_with_transport_owned( - worker, - &run_dir, - &bash_output_dir, - transport, + worker, &run_dir, transport, ) .await } @@ -313,22 +279,3 @@ where } } } - -#[cfg(test)] -mod tests { - use super::bash_output_dir_for_worker_id; - - #[test] - fn bash_output_directory_is_stable_per_worker_below_system_temp() { - let path = bash_output_dir_for_worker_id("019c1234-worker"); - - assert_eq!( - path, - std::env::temp_dir() - .join("yoi") - .join("workers") - .join("019c1234-worker") - .join("bash-output") - ); - } -} diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs index c4ca3698f..f9bb7cc95 100644 --- a/crates/worker/src/checkout_wip_tests.rs +++ b/crates/worker/src/checkout_wip_tests.rs @@ -446,7 +446,11 @@ async fn checkout_wip_policy_is_not_feature_authority_and_compatibility_is_repla let mut registry = WipMountRegistry::new(); crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None) .unwrap(); - for def in tools::routed_builtin_tools(router, tools::Tracker::new(), dir.path().join("bash")) { + for def in tools::routed_builtin_tools( + router, + tools::Tracker::new(), + tools::bash_output::BashOutputView::new(), + ) { let (meta, tool) = def(); let p = compatibility_projection(meta, tool, None).unwrap(); registry.mount(p).unwrap(); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index d8be6da1a..9f233f95f 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -692,7 +692,6 @@ impl WorkerController { pub async fn spawn( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, @@ -701,7 +700,6 @@ impl WorkerController { let (handle, shutdown, _task) = Self::spawn_inner( worker, runtime_base, - bash_output_dir, false, None, WorkerControllerTransport::UnixSocket, @@ -715,7 +713,7 @@ impl WorkerController { pub async fn spawn_with_transport( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where @@ -723,42 +721,32 @@ impl WorkerController { St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { let (handle, shutdown, _task) = - Self::spawn_with_transport_owned(worker, runtime_base, bash_output_dir, transport) - .await?; + Self::spawn_with_transport_owned(worker, runtime_base, transport).await?; Ok((handle, shutdown)) } pub(crate) async fn spawn_with_transport_owned( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> where C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { - Self::spawn_inner( - worker, - runtime_base, - bash_output_dir, - false, - None, - transport, - ) - .await + Self::spawn_inner(worker, runtime_base, false, None, transport).await } /// Spawn a Worker owned by `worker-runtime`. /// - /// The controller uses an ephemeral directory for Unix sockets while tool - /// spill artifacts use the separately supplied Worker-owned temporary path. + /// The controller uses an ephemeral directory for Unix sockets. Bash output is + /// owned by its live Feature instance, independently of filesystem layout. /// Runtime-managed Workers do not write legacy pid/status/manifest liveness /// projections. pub async fn spawn_runtime_managed( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, @@ -767,7 +755,6 @@ impl WorkerController { let (handle, shutdown, _task) = Self::spawn_inner( worker, runtime_base, - bash_output_dir, true, None, WorkerControllerTransport::UnixSocket, @@ -780,7 +767,6 @@ impl WorkerController { pub async fn spawn_runtime_managed_run( worker: Worker, run_dir: &Path, - bash_output_dir: &Path, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, @@ -789,7 +775,6 @@ impl WorkerController { Self::spawn_runtime_managed_run_with_transport( worker, run_dir, - bash_output_dir, WorkerControllerTransport::UnixSocket, ) .await @@ -800,27 +785,23 @@ impl WorkerController { pub async fn spawn_runtime_managed_run_with_transport( worker: Worker, run_dir: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { - let (handle, shutdown, _task) = Self::spawn_runtime_managed_run_with_transport_owned( - worker, - run_dir, - bash_output_dir, - transport, - ) - .await?; + let (handle, shutdown, _task) = + Self::spawn_runtime_managed_run_with_transport_owned(worker, run_dir, transport) + .await?; Ok((handle, shutdown)) } pub(crate) async fn spawn_runtime_managed_run_with_transport_owned( worker: Worker, run_dir: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> where @@ -830,21 +811,13 @@ impl WorkerController { let parent = run_dir .parent() .ok_or_else(|| std::io::Error::other("run path has no parent"))?; - Self::spawn_inner( - worker, - parent, - bash_output_dir, - true, - Some(run_dir), - transport, - ) - .await + Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await } async fn spawn_inner( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + runtime_managed: bool, runtime_run: Option<&Path>, transport: WorkerControllerTransport, @@ -857,7 +830,6 @@ impl WorkerController { let result = Self::spawn_initialized( worker, runtime_base, - bash_output_dir, runtime_managed, runtime_run, transport, @@ -874,7 +846,7 @@ impl WorkerController { async fn spawn_initialized( mut worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + runtime_managed: bool, runtime_run: Option<&Path>, transport: WorkerControllerTransport, @@ -888,8 +860,7 @@ impl WorkerController { .map_err(std::io::Error::other)?; // === 1. Initialization (channels / RuntimeDir / worker-immutable - // snapshots / SpawnedWorkerRegistry / alerter attach / - // bash-output scope) === + // snapshots / SpawnedWorkerRegistry / alerter attach) === let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let (method_tx, method_rx) = mpsc::channel::(32); let (working_event_tx, _) = broadcast::channel::(256); @@ -897,8 +868,8 @@ impl WorkerController { let in_flight = InFlightEvents::new(working_event_tx.clone()); worker.attach_in_flight_events(in_flight.clone()); - // Runtime directory is created before tool registration because it owns - // bounded tool artifacts, and before initial status/history writes consume + // Runtime directory is created before tool registration and before + // initial status/history writes consume // the greeting we build after registration is complete. let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run { RuntimeDir::create_worker_run(run_dir).await? @@ -935,26 +906,6 @@ impl WorkerController { worker.attach_internal_worker_registry(spawned_registry.clone()); worker.attach_working_event_tx(working_event_tx.clone()); - // Bash spill artifacts are owned by the stable Worker identity rather - // than a controller session/run generation. Push a recursive - // `allow(Read)` for the exact tool output path into the Worker's shared - // runtime scope so the Workdir session and system prompt stay aligned. - let bash_output_dir = bash_output_dir.to_path_buf(); - std::fs::create_dir_all(&bash_output_dir).map_err(|e| { - std::io::Error::other(format!( - "create bash output dir {}: {e}", - bash_output_dir.display() - )) - })?; - worker - .add_scope_rules([manifest::ScopeRule { - target: bash_output_dir.clone(), - permission: manifest::Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }]) - .map_err(std::io::Error::other)?; - // === 1.5. Direct writer wiring === // // Engine callbacks fire `on_history_append` for each assistant @@ -974,7 +925,6 @@ impl WorkerController { // === 3. Tool registration (builtin / memory / spawn-orchestration) === let fs_for_view = register_worker_tools( &mut worker, - bash_output_dir, runtime_base.to_path_buf(), spawned_registry.clone(), Some(method_tx.downgrade()), @@ -1440,7 +1390,7 @@ fn validate_memory_lifecycle_targets( /// the shared state. pub(crate) async fn register_worker_tools( worker: &mut Worker, - bash_output_dir: PathBuf, + runtime_base: PathBuf, spawned_registry: Arc, parent_method_tx: Option>, @@ -1485,6 +1435,7 @@ where let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none(); let prompts = worker.prompts().clone(); let tracker = tools::Tracker::new(); + let bash_output = spawned_registry.bash_output(); { let paste_store = worker.store().clone(); let paste_session_id = worker.session_id(); @@ -1507,13 +1458,13 @@ where tools::routed_builtin_tools_for_current_capabilities( workdir_sessions.clone(), tracker.clone(), - bash_output_dir.clone(), + bash_output.clone(), ) } else { tools::routed_builtin_tools( workdir_sessions.clone(), tracker.clone(), - bash_output_dir.clone(), + bash_output.clone(), ) }; worker.engine_mut().register_tools(workdir_tools); @@ -2040,7 +1991,6 @@ where spawner_workspace_context, parent_notifications, runtime_base.clone(), - bash_output_dir.clone(), spawner_workspace_root, workdir_tool_broker, spawned_registry.clone(), @@ -3326,6 +3276,8 @@ async fn controller_loop( } } + spawned_registry.bash_output().revoke(); + if let Some(observer) = command_observer.take() { observer.abort(); let _ = observer.await; @@ -4212,7 +4164,6 @@ mod tests { .descriptor(); register_worker_tools( &mut worker, - root.path().join("bash-output"), root.path().join("runtime"), SpawnedWorkerRegistry::new_for_internal_services(), None, diff --git a/crates/worker/src/controller_subject_release_tests.rs b/crates/worker/src/controller_subject_release_tests.rs index 3cda546fe..d57dc4530 100644 --- a/crates/worker/src/controller_subject_release_tests.rs +++ b/crates/worker/src/controller_subject_release_tests.rs @@ -64,7 +64,6 @@ async fn prepared( WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), }, WorkerControllerTransport::InProcess, ) diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 88745da61..15f15407b 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -635,12 +635,11 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { return ExitCode::FAILURE; } }; - let bash_output_dir = crate::bash_output_dir_for_worker_id(&worker_name); + let started = match start_worker_controller( worker, WorkerBootstrapLayout::Direct { runtime_base: runtime_base.clone(), - bash_output_dir, }, WorkerControllerTransport::UnixSocket, ) diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 8cd5477be..ad35374e2 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -505,13 +505,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession { } } - async fn start_command( - &self, - mut request: CommandRequest, - ) -> Result { - // `spill_dir` belongs to the Worker host running BashTool. This remote - // WorkdirSession provider cannot safely resolve or write that host path. - request.spill_dir = None; + async fn start_command(&self, request: CommandRequest) -> Result { match self.operate(WorkdirSessionOperation::CommandStart(request))? { WorkdirSessionOperationResult::CommandStart(result) => Ok(result), _ => Err(Self::mismatch("command_start")), @@ -1660,9 +1654,9 @@ mod tests { .start_command(CommandRequest { command: "true".to_string(), timeout_secs: 120, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: Some("/worker-local/bash-output".into()), tool_call_id: Some("call-1".to_string()), }) .await @@ -1675,7 +1669,7 @@ mod tests { assert_eq!(body["operation"]["operation"], "command_start"); assert_eq!(body["operation"]["request"]["command"], "true"); assert_eq!(body["operation"]["request"]["cwd"], ""); - assert!(body["operation"]["request"]["spill_dir"].is_null()); + assert!(body["operation"]["request"].get("spill_dir").is_none()); assert_eq!(body["operation"]["request"]["tool_call_id"], "call-1"); assert!( !requests[0] @@ -1942,9 +1936,9 @@ mod tests { .start_command(CommandRequest { command: "printf done".to_string(), timeout_secs: 5, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 10cf5a6c5..fac6cd82b 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -39,7 +39,7 @@ mod worker; pub use bootstrap::{ BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError, - WorkerBootstrapLayout, bash_output_dir_for_worker_id, start_worker_controller, + WorkerBootstrapLayout, start_worker_controller, }; pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate}; pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle}; diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index 05317f491..443e86281 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -88,7 +88,7 @@ impl InternalSpawnedWorkerRecord { pub(crate) fn new( worker_name: String, scope_delegated: Vec, - workdir_tool_scope: WorkdirScopeLeaseSet, + workdir_tool_scope: Arc, #[cfg(test)] installed_tools: Vec, session: InternalWorkerSessionHandle, child_registry: Arc, @@ -97,7 +97,7 @@ impl InternalSpawnedWorkerRecord { Self { worker_name: worker_name.clone(), scope_delegated, - workdir_tool_scope: Arc::new(workdir_tool_scope), + workdir_tool_scope, #[cfg(test)] installed_tools: installed_tools.into(), published: Arc::new(super::published::PublishedProtocol::new( @@ -329,6 +329,7 @@ pub struct SpawnedWorkerRegistry { pending_internal_notify: Notify, internal_spawn_cleanup_failed: AtomicBool, parent_scope: Option, + bash_output: Mutex, parent_protocol: Mutex, String)>>, protocol_publish_lock: Arc>, } @@ -340,6 +341,14 @@ pub struct SpawnedWorkerRegistryLoad { } impl SpawnedWorkerRegistry { + pub(crate) fn bash_output(&self) -> tools::bash_output::BashOutputView { + self.bash_output.lock().unwrap().clone() + } + + pub(crate) fn inherit_bash_output(&self, view: tools::bash_output::BashOutputView) { + *self.bash_output.lock().unwrap() = view; + } + pub(crate) fn new_for_internal_services() -> Arc { Arc::new(Self { internal_records: std::sync::Mutex::new(Vec::new()), @@ -352,6 +361,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: None, + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }) @@ -370,6 +380,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: None, + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }) @@ -387,6 +398,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: Some(parent_scope), + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }) @@ -473,6 +485,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope, + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }), @@ -966,6 +979,10 @@ impl SpawnedWorkerRegistry { "an in-flight Internal SubWorker failed cleanup during shutdown", )); } + let output = self.bash_output(); + if !output.is_root() { + output.revoke(); + } first_error.map_or(Ok(()), Err) } @@ -1111,6 +1128,7 @@ fn internal_worker_live_snapshot(snapshot: InternalWorkerSnapshot) -> Option, registry: Arc, @@ -358,7 +358,7 @@ impl SubWorkerSpawnTool { workspace_context, parent_notifications, runtime_base, - bash_output_dir, + workspace_root, workdir_tool_broker, registry, @@ -430,15 +430,6 @@ impl Tool for SubWorkerSpawnTool { .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; let workdir_rules = parse_workdir_scope(&input.scope)?; - let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name); - tokio::fs::create_dir_all(&child_bash_output_dir) - .await - .map_err(|error| { - ToolError::ExecutionFailed(format!( - "create Internal Worker Bash output directory {}: {error}", - child_bash_output_dir.display() - )) - })?; let workdir_tool_broker = require_workdir_tool_broker(self.workdir_tool_broker.as_ref())?; let tool_scopes = routed_workdir_tool_scopes( workdir_tool_broker, @@ -452,6 +443,7 @@ impl Tool for SubWorkerSpawnTool { .map_err(|error| { ToolError::InvalidArgument(format!("scope parent-owned Workdir tools: {error}")) })?; + let workdir_scope = Arc::new(workdir_scope); let child_workdir_tool_broker = workdir_scope.broker_router(); let spawn_selector = @@ -552,24 +544,17 @@ impl Tool for SubWorkerSpawnTool { ) .await .map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?; - child - .add_scope_rules([ScopeRule { - target: child_bash_output_dir.clone(), - permission: manifest::Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }]) - .map_err(|error| { - ToolError::ExecutionFailed(format!( - "grant Internal Worker Bash output scope: {error}" - )) - })?; let child_scope = child.scope().clone(); child.bind_workdir_sessions(workdir_scope.session_router()); let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope); + let output_lease = workdir_scope.clone(); + child_registry.inherit_bash_output( + self.registry + .bash_output() + .child(Arc::new(move || output_lease.is_active())), + ); register_worker_tools( &mut child, - child_bash_output_dir, self.runtime_base.clone(), child_registry.clone(), None, @@ -1052,7 +1037,7 @@ pub(crate) fn sub_worker_spawn_tool( workspace_context: crate::worker::WorkerWorkspaceContext, parent_notifications: ParentNotificationTarget, runtime_base: PathBuf, - bash_output_dir: PathBuf, + workspace_root: PathBuf, workdir_tool_broker: Option, registry: Arc, @@ -1064,7 +1049,6 @@ pub(crate) fn sub_worker_spawn_tool( workspace_context, parent_notifications, runtime_base, - bash_output_dir, workspace_root, workdir_tool_broker, registry, @@ -1078,7 +1062,7 @@ fn sub_worker_spawn_tool_impl( workspace_context: crate::worker::WorkerWorkspaceContext, parent_notifications: ParentNotificationTarget, runtime_base: PathBuf, - bash_output_dir: PathBuf, + workspace_root: PathBuf, workdir_tool_broker: Option, registry: Arc, @@ -1110,7 +1094,6 @@ fn sub_worker_spawn_tool_impl( workspace_context.clone(), parent_notifications.clone(), runtime_base.clone(), - bash_output_dir.clone(), workspace_root.clone(), workdir_tool_broker.clone(), registry.clone(), @@ -1374,7 +1357,6 @@ enabled = false ParentNotificationTarget::Durable(Arc::new(|_| {})), ), runtime.path().to_path_buf(), - bash_output_dir.clone(), workspace_root.clone(), Some(workdir_tool_broker), registry.clone(), @@ -1425,12 +1407,7 @@ enabled = false .await .expect("spawn project reviewer as Internal Worker"); assert!(output.summary.contains("internal worker `reviewer-child`")); - assert!( - bash_output_dir - .join("sub-workers") - .join("reviewer-child") - .is_dir() - ); + assert!(!bash_output_dir.join("sub-workers").exists()); assert!(spawner_scope.snapshot().is_writable(&workspace_root)); let record = registry .get_internal("reviewer-child") @@ -1460,6 +1437,27 @@ enabled = false record.session.wait_until_idle().await, crate::internal_worker::InternalWorkerSessionStatus::Idle ); + let child_output_view = record.child_registry.bash_output(); + let child_bash = tools::BashTool::new( + record.workdir_tool_scope.session_router(), + child_output_view.clone(), + ); + let child_output = child_bash + .execute_result( + r#"{"command":"printf retained-child-evidence","cwd":"."}"#, + Default::default(), + ) + .await + .unwrap(); + let output_id = child_output.metadata.id.clone(); + assert!(child_output_view.metadata(&output_id).is_some()); + assert_eq!( + registry + .bash_output() + .read(&output_id, workdir::CommandStream::Stdout, 0, 65536) + .unwrap(), + b"retained-child-evidence" + ); assert_eq!(calls.load(Ordering::SeqCst), 1); assert!(!observed_parent_write_revoked.load(Ordering::SeqCst)); assert!(observed_instruction_override.load(Ordering::SeqCst)); @@ -1563,6 +1561,8 @@ enabled = false !record.workdir_tool_scope.is_active(), "stopped child must revoke cloned scoped sessions" ); + assert!(child_output_view.metadata(&output_id).is_none()); + assert!(registry.bash_output().metadata(&output_id).is_some()); assert!(registry.get_internal("reviewer-child").is_some()); assert!( @@ -1573,6 +1573,7 @@ enabled = false .is_some() ); assert!(registry.get_internal("reviewer-child").is_none()); + assert!(registry.bash_output().metadata(&output_id).is_some()); assert!(spawner_scope.snapshot().is_writable(&workspace_root)); fail_requests.store(false, Ordering::SeqCst); @@ -1672,7 +1673,6 @@ enabled = false ), ParentNotificationTarget::Durable(Arc::new(|_| {})), runtime.path().to_path_buf(), - bash_output_dir, workspace_root.clone(), Some(workdir_tool_broker), registry.clone(), @@ -1805,7 +1805,6 @@ enabled = false ParentNotificationTarget::Durable(Arc::new(|_| {})), ), runtime.path().to_path_buf(), - bash_output_dir.clone(), workspace_root.clone(), Some(workdir_tool_broker), registry.clone(), diff --git a/crates/worker/src/subjektiv.rs b/crates/worker/src/subjektiv.rs index 40d4836c6..2009679ce 100644 --- a/crates/worker/src/subjektiv.rs +++ b/crates/worker/src/subjektiv.rs @@ -919,7 +919,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash-output"), }, WorkerControllerTransport::InProcess, ) @@ -1113,7 +1112,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), }, WorkerControllerTransport::InProcess, ) @@ -1279,7 +1277,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), }, WorkerControllerTransport::InProcess, ) @@ -1339,7 +1336,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash-output"), }, WorkerControllerTransport::InProcess, ) diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 6af8d337b..b6c9c6766 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -17214,12 +17214,10 @@ permission = "write" let layout = if runtime_managed { crate::WorkerBootstrapLayout::RuntimeManagedRun { run_dir: temp.path().join("runtime/runs/1"), - bash_output_dir: temp.path().join("bash"), } } else { crate::WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), } }; crate::WorkerBootstrap::new( diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index 2aeb8f521..26caca683 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -1495,11 +1495,9 @@ async fn post_compact_pause_bills_partial_without_rearming_or_consuming_correlat let session_id = worker.session_id(); let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, shutdown_receiver) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path()) + .await + .unwrap(); let mut rx = handle.subscribe(); handle .send(Method::submit_text( @@ -1705,11 +1703,9 @@ async fn controller_does_not_repeat_a_persisted_mid_run_compaction_failure() { let (worker, store) = make_worker_with_manifest_and_store(MID_TURN_MANIFEST_TOML, client).await; let session_id = worker.session_id(); let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, shutdown_receiver) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path()) + .await + .unwrap(); let mut rx = handle.subscribe(); handle @@ -1830,11 +1826,9 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() { let source_session = worker.session_id(); let source_segment = worker.segment_id(); let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, shutdown_receiver) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path()) + .await + .unwrap(); let mut rx = handle.subscribe(); handle @@ -1996,8 +1990,7 @@ async fn controller_compact_method_publishes_progress_and_clear() { ]); let worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await; let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) + let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path()) .await .unwrap(); let mut rx = handle.subscribe(); diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 1938f0b18..e1ab29fbe 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -311,8 +311,7 @@ async fn spawn_controller(worker: Worker) -> WorkerHandle let tmp = tempfile::tempdir().unwrap(); let runtime_base = tmp.path().to_owned(); std::mem::forget(tmp); - let bash_output_dir = runtime_base.join("bash-output"); - let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base, &bash_output_dir) + let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base) .await .unwrap(); handle @@ -342,13 +341,9 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() { ) .await .unwrap(); - let (handle, shutdown_rx) = WorkerController::spawn( - worker, - &root.path().join("runtime"), - &root.path().join("bash-output"), - ) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, &root.path().join("runtime")) + .await + .unwrap(); let mut rx = handle.subscribe(); let file = handle .upload_file_with_context( @@ -408,24 +403,20 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() { } #[tokio::test] -async fn controller_grants_read_scope_for_exact_bash_output_directory() { +async fn controller_does_not_grant_or_create_a_model_visible_bash_output_directory() { let worker = make_worker(MockClient::new(simple_text_events())).await; let shared_scope = worker.scope().clone(); let runtime_base = tempfile::tempdir().unwrap(); - let worker_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = worker_tmp.path().join("worker-1").join("bash-output"); + let original_rules = shared_scope.snapshot().allow_rules().to_vec(); - let (handle, shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); - assert!(bash_output_dir.is_dir()); - assert!(shared_scope.snapshot().allow_rules().iter().any(|rule| { - rule.target == bash_output_dir - && rule.permission == manifest::Permission::Read - && rule.recursive - })); + assert_eq!( + shared_scope.snapshot().allow_rules(), + original_rules.as_slice() + ); assert!(!handle.runtime_dir.path().join("bash-output").exists()); handle @@ -451,9 +442,9 @@ async fn shutdown_closes_bound_workdir_session() { .start_command(CommandRequest { command: "sleep 30".to_owned(), timeout_secs: 60, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await @@ -461,11 +452,9 @@ async fn shutdown_closes_bound_workdir_session() { worker.bind_single_workdir_session(Some(Arc::clone(&session))); let runtime_base = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); - let (handle, shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); handle .send(Method::Shutdown { command: worker_command(&handle), @@ -501,9 +490,9 @@ async fn controller_filters_workdir_command_not_owned_by_worker() { .start_command(CommandRequest { command: "sleep 0.2".to_owned(), timeout_secs: 5, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: Some("foreign-tool-call".into()), }) .await @@ -529,7 +518,10 @@ async fn controller_filters_workdir_command_not_owned_by_worker() { }) .await .unwrap(); - assert_eq!(output.status, workdir::CommandStatus::Completed); + assert_eq!( + output.terminal.unwrap().status, + workdir::CommandStatus::Completed + ); handle .send(Method::Shutdown { command: worker_command(&handle), @@ -738,9 +730,8 @@ async fn controller_startup_failure_closes_bound_workdir_session() { let invalid_runtime_base = runtime_base.path().join("not-a-directory"); std::fs::write(&invalid_runtime_base, "file").unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); assert!( - WorkerController::spawn(worker, &invalid_runtime_base, &bash_output_dir) + WorkerController::spawn(worker, &invalid_runtime_base) .await .is_err() ); @@ -749,9 +740,9 @@ async fn controller_startup_failure_closes_bound_workdir_session() { .start_command(CommandRequest { command: "printf unreachable".to_owned(), timeout_secs: 5, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await, @@ -1553,8 +1544,7 @@ permission = "write" let client = MockClient::new(simple_text_events()); let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0; let tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = tmp.path().join("bash-output"); - let result = WorkerController::spawn(worker, tmp.path(), &bash_output_dir).await; + let result = WorkerController::spawn(worker, tmp.path()).await; assert!( result.is_ok(), "feature exposure must not imply delegation authority" @@ -2927,11 +2917,9 @@ async fn status_json_reflects_worker_name() { async fn shutdown_closes_method_admission_before_terminal_confirmation() { let worker = make_worker(MockClient::new(simple_text_events())).await; let runtime_base = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); - let (handle, mut shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, mut shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); handle .send(Method::Shutdown { command: worker_command(&handle), @@ -2966,11 +2954,9 @@ async fn shutdown_joins_socket_server_with_active_connection() { let worker = make_worker(MockClient::new(simple_text_events())).await; let runtime_base = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); - let (handle, shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); let socket_path = handle.runtime_dir.socket_path(); let _connection = UnixStream::connect(&socket_path).await.unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index dd256fea9..96ff86839 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -475,6 +475,7 @@ struct WorkdirCommandSession { attachment_alias: String, source: WorkdirSessionHandle, provider_handle: CommandHandle, + terminal_at: Option, } enum RegisteredWorkdirSession { @@ -515,6 +516,7 @@ impl WorkdirSessionRegistry { source: WorkdirSessionHandle, provider_handle: CommandHandle, ) -> CommandHandle { + self.prune_terminal_commands(&worker); let external_handle = loop { let candidate = CommandHandle(Uuid::now_v7().to_string()); if !self @@ -530,6 +532,7 @@ impl WorkdirSessionRegistry { attachment_alias, source, provider_handle, + terminal_at: None, }, ); external_handle @@ -547,6 +550,45 @@ impl WorkdirSessionRegistry { .cloned() } + fn active_command_count(&self, worker: &RuntimeWorkerRef) -> usize { + self.commands + .iter() + .filter(|((owner, _), command)| owner == worker && command.terminal_at.is_none()) + .count() + } + + fn mark_terminal(&mut self, worker: &RuntimeWorkerRef, external_handle: &CommandHandle) { + if let Some(command) = self + .commands + .get_mut(&(worker.clone(), external_handle.clone())) + { + command + .terminal_at + .get_or_insert_with(std::time::Instant::now); + } + self.prune_terminal_commands(worker); + } + + fn prune_terminal_commands(&mut self, worker: &RuntimeWorkerRef) { + let mut terminal = self + .commands + .iter() + .filter_map(|((owner, handle), command)| { + (owner == worker) + .then_some(command.terminal_at) + .flatten() + .map(|at| (handle.clone(), at)) + }) + .collect::>(); + terminal.sort_by_key(|(_, at)| *at); + let excess = terminal + .len() + .saturating_sub(MAX_RETAINED_TERMINAL_COMMANDS); + for (handle, _) in terminal.into_iter().take(excess) { + self.commands.remove(&(worker.clone(), handle)); + } + } + fn take_worker_commands_for_alias( &mut self, worker: &RuntimeWorkerRef, @@ -895,7 +937,27 @@ struct ExternalProviderWorkdirSession { audit: Option, closed: std::sync::atomic::AtomicBool, close_lock: tokio::sync::Mutex<()>, - active_commands: tokio::sync::Mutex>, + active_commands: tokio::sync::Mutex>>, +} + +const MAX_RETAINED_TERMINAL_COMMANDS: usize = 128; +const MAX_ACTIVE_SERVER_COMMANDS: usize = 128; + +struct ExternalProviderCommandState { + handle: workdir::CommandHandle, + // Serialize output ACKs, not cancellation: cancel must unblock a waiting reader. + cursor: tokio::sync::Mutex, + terminal_at: Mutex>, +} + +impl ExternalProviderCommandState { + fn new(handle: workdir::CommandHandle) -> Self { + Self { + handle, + cursor: tokio::sync::Mutex::new(0), + terminal_at: Mutex::new(None), + } + } } impl std::fmt::Debug for ExternalProviderWorkdirSession { @@ -986,17 +1048,24 @@ impl ExternalProviderWorkdirSession { "External Workdir grant has expired".to_string(), )); } - let _admission = self - .connection - .admission - .clone() - .acquire_owned() - .await - .map_err(|_| { - workdir::WorkdirError::Unavailable( - "External Workdir provider admission is closed".to_string(), - ) - })?; + // Cancellation must reach the provider even when waiting output readers + // occupy every data-operation slot. The socket separately bounds control requests. + let _admission = if matches!(&operation, WorkdirSessionOperation::CommandCancel(_)) { + None + } else { + Some( + self.connection + .admission + .clone() + .acquire_owned() + .await + .map_err(|_| { + workdir::WorkdirError::Unavailable( + "External Workdir provider admission is closed".to_string(), + ) + })?, + ) + }; if self .connection .expires_at @@ -1073,10 +1142,10 @@ impl ExternalProviderWorkdirSession { } } - async fn provider_command_handle( + async fn provider_command( &self, external_handle: &workdir::CommandHandle, - ) -> std::result::Result { + ) -> std::result::Result, workdir::WorkdirError> { if self.closed.load(Ordering::Acquire) { return Err(workdir::WorkdirError::SessionClosed); } @@ -1088,51 +1157,111 @@ impl ExternalProviderWorkdirSession { .ok_or_else(|| workdir::WorkdirError::UnknownCommand(external_handle.0.clone())) } + async fn provider_command_handle( + &self, + external_handle: &workdir::CommandHandle, + ) -> std::result::Result { + Ok(self.provider_command(external_handle).await?.handle.clone()) + } + async fn remove_active_command_if_matches( &self, external_handle: &workdir::CommandHandle, - provider_handle: &workdir::CommandHandle, + command: &Arc, ) { let mut commands = self.active_commands.lock().await; - if commands.get(&external_handle.0) == Some(provider_handle) { + if commands + .get(&external_handle.0) + .is_some_and(|current| Arc::ptr_eq(current, command)) + { commands.remove(&external_handle.0); } } + async fn prune_terminal_commands(&self) { + let mut commands = self.active_commands.lock().await; + let mut terminal = commands + .iter() + .filter_map(|(key, command)| { + command + .terminal_at + .lock() + .unwrap() + .map(|at| (key.clone(), at)) + }) + .collect::>(); + terminal.sort_by_key(|(_, at)| *at); + let excess = terminal + .len() + .saturating_sub(MAX_RETAINED_TERMINAL_COMMANDS); + for (key, _) in terminal.into_iter().take(excess) { + commands.remove(&key); + } + } + async fn terminate_provider_command( &self, - provider_handle: workdir::CommandHandle, + command: Arc, + reused_handle: bool, ) -> std::result::Result<(), workdir::WorkdirError> { - // Cancellation is best effort because the command may already be terminal. - // Terminal output is the proof that the provider reaped the process. + if !reused_handle && command.terminal_at.lock().unwrap().is_some() { + return Ok(()); + } + // Do not acquire the output lock until cancellation has unblocked any reader. + // The reader records its last known cursor before cleanup acknowledges bytes. let _ = self .operate(WorkdirSessionOperation::CommandCancel( - provider_handle.clone(), + command.handle.clone(), )) .await; - match self - .operate(WorkdirSessionOperation::CommandOutput( - workdir::CommandOutputRequest { - handle: provider_handle, - cursor: 0, - limit: workdir::external::MAX_EXTERNAL_COMMAND_OUTPUT_BYTES, - wait: true, - }, - )) - .await? - { - WorkdirSessionOperationResult::CommandOutput(output) - if output.status != workdir::CommandStatus::Running => - { - Ok(()) - } - WorkdirSessionOperationResult::CommandOutput(_) => { - Err(workdir::WorkdirError::Unavailable( - "External Workdir provider did not confirm command termination".to_string(), - )) - } - _ => Err(Self::mismatch("command_output")), + let mut cursor = + tokio::time::timeout(std::time::Duration::from_secs(30), command.cursor.lock()) + .await + .map_err(|_| { + workdir::WorkdirError::Unavailable( + "External Workdir reader did not stop after cancellation".to_string(), + ) + })?; + if !reused_handle && command.terminal_at.lock().unwrap().is_some() { + return Ok(()); } + let drain = async { + loop { + match self + .operate(WorkdirSessionOperation::CommandOutput( + workdir::CommandOutputRequest { + handle: command.handle.clone(), + cursor: *cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }, + )) + .await? + { + WorkdirSessionOperationResult::CommandOutput(output) => { + *cursor = output.next_cursor; + if output.terminal.is_some() { + *command.terminal_at.lock().unwrap() = Some(std::time::Instant::now()); + return Ok(()); + } + if output.chunks.is_empty() { + return Err(workdir::WorkdirError::Unavailable( + "External Workdir provider did not confirm command termination" + .to_string(), + )); + } + } + _ => return Err(Self::mismatch("command_output")), + } + } + }; + tokio::time::timeout(std::time::Duration::from_secs(30), drain) + .await + .map_err(|_| { + workdir::WorkdirError::Unavailable( + "External Workdir command drain timed out".to_string(), + ) + })? } fn mismatch(expected: &str) -> workdir::WorkdirError { @@ -1410,17 +1539,27 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { } async fn start_command( &self, - mut request: workdir::CommandRequest, + request: workdir::CommandRequest, ) -> std::result::Result { self.ensure_capability(workdir::WorkdirSessionCapability::Command)?; let _close_guard = self.close_lock.lock().await; if self.closed.load(Ordering::Acquire) { return Err(workdir::WorkdirError::SessionClosed); } - // Bash supplies a Worker-host spill directory. It is neither meaningful - // nor safe to send to a client-hosted provider, so External commands use - // bounded inline output and report truncation without a provider path. - request.spill_dir = None; + self.prune_terminal_commands().await; + if self + .active_commands + .lock() + .await + .values() + .filter(|command| command.terminal_at.lock().unwrap().is_none()) + .count() + >= MAX_ACTIVE_SERVER_COMMANDS + { + return Err(workdir::WorkdirError::Unavailable( + "External Workdir active command limit reached".to_string(), + )); + } match self .operate(WorkdirSessionOperation::CommandStart(request)) .await? @@ -1430,7 +1569,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { let commands = self.active_commands.lock().await; commands .iter() - .filter(|(_, active)| *active == &provider_handle) + .filter(|(_, active)| active.handle == provider_handle) .map(|(external, _)| external.clone()) .collect::>() }; @@ -1439,20 +1578,29 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { // generation. Remove every ambiguous route before attempting // cleanup so no caller can address the wrong process. let mut commands = self.active_commands.lock().await; + let mut quarantined = None; for external_handle in duplicate_handles { - commands.remove(&external_handle); + if let Some(command) = commands.remove(&external_handle) { + quarantined.get_or_insert(command); + } } let quarantine_handle = workdir::CommandHandle(format!("quarantined-command-{}", Uuid::now_v7())); - commands.insert(quarantine_handle.0.clone(), provider_handle.clone()); + let command = quarantined.expect("duplicate command route disappeared"); + *command.terminal_at.lock().unwrap() = None; + commands.insert(quarantine_handle.0.clone(), command.clone()); drop(commands); if self - .terminate_provider_command(provider_handle.clone()) + .terminate_provider_command(command.clone(), true) .await .is_ok() { - self.remove_active_command_if_matches(&quarantine_handle, &provider_handle) + self.remove_active_command_if_matches(&quarantine_handle, &command) .await; + } else { + // An earlier read of the reused handle cannot establish that + // the newly started, ambiguous process has been cleaned up. + *command.terminal_at.lock().unwrap() = None; } return Err(workdir::WorkdirError::Unavailable( "External Workdir provider reused an active command handle".to_string(), @@ -1460,10 +1608,10 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { } let external_handle = workdir::CommandHandle(format!("external-command-{}", Uuid::now_v7())); - self.active_commands - .lock() - .await - .insert(external_handle.0.clone(), provider_handle); + self.active_commands.lock().await.insert( + external_handle.0.clone(), + Arc::new(ExternalProviderCommandState::new(provider_handle)), + ); Ok(external_handle) } _ => Err(Self::mismatch("command_start")), @@ -1489,21 +1637,29 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { ) -> std::result::Result { self.ensure_capability(workdir::WorkdirSessionCapability::Command)?; let external_handle = request.handle.clone(); - let provider_handle = self.provider_command_handle(&external_handle).await?; - request.handle = provider_handle.clone(); + let command = self.provider_command(&external_handle).await?; + let mut cursor = command.cursor.lock().await; + request.handle = command.handle.clone(); + // If the response is lost, this request cursor still replays the provider page. match self .operate(WorkdirSessionOperation::CommandOutput(request)) - .await? + .await { - WorkdirSessionOperationResult::CommandOutput(output) => { - if output.status != workdir::CommandStatus::Running && output.next_cursor.is_none() - { - self.remove_active_command_if_matches(&external_handle, &provider_handle) - .await; + Ok(WorkdirSessionOperationResult::CommandOutput(output)) => { + *cursor = output.next_cursor; + if output.terminal.is_some() { + command + .terminal_at + .lock() + .unwrap() + .get_or_insert_with(std::time::Instant::now); } + drop(cursor); + self.prune_terminal_commands().await; Ok(output) } - _ => Err(Self::mismatch("command_output")), + Ok(_) => Err(Self::mismatch("command_output")), + Err(error) => Err(error), } } async fn cancel_command( @@ -1540,21 +1696,25 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { }) .collect::>(); let mut cleanup_errors = Vec::new(); - for (external_handle, provider_handle) in commands { + for (external_handle, command) in commands { // Detach/session replacement is terminal authority for commands // started through this exact provider generation. Do not report a // successful close until the provider confirms terminal output. match self - .terminate_provider_command(provider_handle.clone()) + .terminate_provider_command(command.clone(), false) .await { Ok(()) => { - self.remove_active_command_if_matches(&external_handle, &provider_handle) + self.remove_active_command_if_matches(&external_handle, &command) .await; } Err(error) => { - let still_active = self.active_commands.lock().await.get(&external_handle.0) - == Some(&provider_handle); + let still_active = self + .active_commands + .lock() + .await + .get(&external_handle.0) + .is_some_and(|current| Arc::ptr_eq(current, &command)); if still_active { cleanup_errors.push(error.to_string()); } @@ -20811,6 +20971,9 @@ async fn scoped_execute_current_worker_workdir_operation( &api, &worker, &target_workdir, Some(&_session_guard), ).await?; context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id)); + if api.workdir_sessions.lock().unwrap().active_command_count(&worker) >= MAX_ACTIVE_SERVER_COMMANDS { + return Err(InternalWorkdirOperationError::Provider(workdir_operation_transport_error(workdir::WorkdirError::Unavailable("Worker active command limit reached".to_string())))); + } context.stage = "session_open"; let source = open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await?; context.stage = "provider_dispatch"; @@ -20852,14 +21015,16 @@ async fn scoped_execute_current_worker_workdir_operation( .map_err(workdir_operation_transport_error)? } WorkdirSessionOperation::CommandOutput(mut output) => { + let external_handle = output.handle.clone(); let (session, provider_handle) = - current_worker_command_session(&api, &worker, &target_workdir, &output.handle, &mut context).await?; + current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context).await?; output.handle = provider_handle; - session - .command_output(output) - .await - .map(WorkdirSessionOperationResult::CommandOutput) - .map_err(workdir_operation_transport_error)? + let page = session.command_output(output).await.map_err(workdir_operation_transport_error)?; + if page.terminal.is_some() { + // Retain the binding for a retry after an unknown HTTP outcome. + api.workdir_sessions.lock().unwrap().mark_terminal(&worker, &external_handle); + } + WorkdirSessionOperationResult::CommandOutput(page) } WorkdirSessionOperation::CommandCancel(external_handle) => { let (session, provider_handle) = @@ -26838,7 +27003,12 @@ async fn serve_external_workdir_provider( })); continue; } - if pending.len() >= 16 { + let pending_limit = if matches!(&operation, WorkdirSessionOperation::CommandCancel(_)) { + 16 + MAX_ACTIVE_SERVER_COMMANDS + } else { + 16 + }; + if pending.len() >= pending_limit { let _ = response.send(Err(WorkdirTransportError { denial_reason: None, code: workdir::http::WorkdirTransportErrorCode::Unavailable, @@ -37009,6 +37179,7 @@ mod tests { mod api_logging_tests; mod auth_logging_tests; + mod command_transport_tests; mod drive_api_tests; mod drive_tests; mod drive_web_roundtrip_tests; @@ -37358,9 +37529,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "touch forbidden".to_string(), timeout_secs: 1, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await; @@ -37509,9 +37679,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf forbidden".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await; @@ -37563,9 +37732,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf routed".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: Some("/worker/private/spill".into()), tool_call_id: Some("call-1".to_string()), }) .await @@ -37581,10 +37749,7 @@ mod tests { }; assert!(matches!( operation, - WorkdirSessionOperation::CommandStart(workdir::CommandRequest { - spill_dir: None, - .. - }) + WorkdirSessionOperation::CommandStart(workdir::CommandRequest { .. }) )); let provider_handle = workdir::CommandHandle("command-1".to_string()); response @@ -37637,13 +37802,9 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Running, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: None, }, ))) .unwrap(); @@ -37716,13 +37877,22 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Cancelled, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: Some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + + stdout_bytes: 0, + + stderr_bytes: 0, + + stdout_delivered_bytes: 0, + + stderr_delivered_bytes: 0, + + truncation_reason: None, + }), }, ))) .unwrap(); @@ -37751,9 +37921,8 @@ mod tests { let request = || workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }; let provider_handle = workdir::CommandHandle("command-reused".to_string()); @@ -37810,13 +37979,22 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Cancelled, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: Some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + + stdout_bytes: 0, + + stderr_bytes: 0, + + stdout_delivered_bytes: 0, + + stderr_delivered_bytes: 0, + + truncation_reason: None, + }), }, ))) .unwrap(); @@ -37867,9 +38045,8 @@ mod tests { let request = || workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }; let provider_handle = workdir::CommandHandle("command-1".to_string()); @@ -38766,9 +38943,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf forbidden".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await; @@ -38789,9 +38965,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf attached".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await @@ -38894,13 +39069,22 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Cancelled, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: Some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + + stdout_bytes: 0, + + stderr_bytes: 0, + + stdout_delivered_bytes: 0, + + stderr_delivered_bytes: 0, + + truncation_reason: None, + }), }, ))) .unwrap(); @@ -38985,11 +39169,9 @@ mod tests { }; let (mut old_provider, _) = connect_async(&url).await.unwrap(); - let old_registration = serde_json::to_string(®ister(1)).unwrap().replacen( - r#""version":2"#, - r#""version":1"#, - 1, - ); + let mut old_registration = serde_json::to_value(register(1)).unwrap(); + old_registration["version"] = serde_json::json!(1); + let old_registration = serde_json::to_string(&old_registration).unwrap(); old_provider .send(Message::Text(old_registration.into())) .await @@ -39003,7 +39185,10 @@ mod tests { .reason .contains("unsupported External Workdir protocol version 1") ); - assert!(frame.reason.contains("requires 2")); + assert!(frame.reason.contains(&format!( + "requires {}", + workdir::external::EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION + ))); let (mut smaller_provider, _) = connect_async(&url).await.unwrap(); let mut smaller_registration = serde_json::to_value(register(1)).unwrap(); @@ -39244,37 +39429,106 @@ mod tests { fs::read_to_string(provider_root.path().join("created.txt")).unwrap(), "created" ); + let binary_handle = broker_session + .start_command(workdir::CommandRequest { + command: "head -c 196609 /dev/zero".to_string(), + timeout_secs: 30, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, + cwd: workdir::WorkdirPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let (terminal_request, terminal_page) = + tokio::time::timeout(std::time::Duration::from_secs(5), async { + let mut cursor = 0; + let mut bytes = Vec::new(); + loop { + let request = workdir::CommandOutputRequest { + handle: binary_handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }; + let page = broker_session + .command_output(request.clone()) + .await + .unwrap(); + for chunk in &page.chunks { + assert_eq!(chunk.stream, workdir::CommandStream::Stdout); + assert_eq!(chunk.sequence, bytes.len() as u64); + assert_eq!(chunk.offset, bytes.len() as u64); + bytes.extend_from_slice(&chunk.bytes); + } + assert!( + page.chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + <= workdir::MAX_COMMAND_READ_BYTES + ); + cursor = page.next_cursor; + if let Some(terminal) = &page.terminal { + assert_eq!(terminal.status, workdir::CommandStatus::Completed); + assert_eq!(terminal.exit_code, Some(0)); + assert_eq!(bytes, vec![0; 196609]); + break (request, page); + } + } + }) + .await + .expect("incremental provider output did not terminate"); + assert_eq!( + broker_session + .command_output(terminal_request.clone()) + .await + .unwrap(), + terminal_page + ); + for cursor in [0, terminal_page.next_cursor + 1] { + assert!( + broker_session + .command_output(workdir::CommandOutputRequest { + cursor, + ..terminal_request.clone() + }) + .await + .is_err() + ); + } + let command_handle = broker_session .start_command(workdir::CommandRequest { - command: "sleep 30 & child=$!; echo $child > child.pid; wait $child".to_string(), + command: "sleep 30 & child=$!; echo $child > child.pid; printf ready; wait $child" + .to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: Some("/worker/private/spill".into()), tool_call_id: Some("call-e2e".to_string()), }) .await .unwrap(); - for _ in 0..100 { - if provider_root.path().join("child.pid").is_file() { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - assert!(provider_root.path().join("child.pid").is_file()); - let command_output = broker_session .command_output(workdir::CommandOutputRequest { handle: command_handle, cursor: 0, limit: 1024, - wait: false, + wait: true, }) .await .unwrap(); - assert_eq!(command_output.status, workdir::CommandStatus::Running); - assert_eq!(command_output.output_path, None); + assert!(command_output.terminal.is_none()); + + assert_eq!( + command_output + .chunks + .iter() + .flat_map(|chunk| chunk.bytes.clone()) + .collect::>(), + b"ready" + ); + assert!(provider_root.path().join("child.pid").is_file()); let Json(revoked) = scoped_revoke_external_workdir_grant( State(api.clone()), @@ -40477,9 +40731,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf ready; sleep 30".to_string(), timeout_secs: 60, - output_limit: 4096, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: Some("tool-call-command-session".to_string()), }) .await @@ -40526,7 +40779,7 @@ mod tests { }) .await .unwrap(); - assert!(matches!(output.status, workdir::CommandStatus::Running)); + assert!(output.terminal.is_none()); assert!(matches!( command .source @@ -40538,14 +40791,21 @@ mod tests { let waiting_source = command.source.clone(); let waiting_handle = command.provider_handle.clone(); let waiting = tokio::spawn(async move { - waiting_source - .command_output(workdir::CommandOutputRequest { - handle: waiting_handle, - cursor: output.next_cursor.unwrap_or(0), - limit: 4096, - wait: true, - }) - .await + let mut cursor = output.next_cursor; + loop { + let page = waiting_source + .command_output(workdir::CommandOutputRequest { + handle: waiting_handle.clone(), + cursor, + limit: 4096, + wait: true, + }) + .await?; + cursor = page.next_cursor; + if page.terminal.is_some() { + return Ok::<_, workdir::WorkdirError>(page); + } + } }); tokio::task::yield_now().await; command @@ -40558,7 +40818,7 @@ mod tests { .expect("cancel should unblock a waiting output read") .unwrap() .unwrap(); - assert!(!matches!(terminal.status, workdir::CommandStatus::Running)); + assert!(terminal.terminal.is_some()); let registered = registry.take_worker(&worker); assert_eq!(registered.len(), 1); @@ -40591,9 +40851,8 @@ mod tests { let request = || workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 4096, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }; let left_provider = left.start_command(request()).await.unwrap(); @@ -60061,9 +60320,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 4096, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await diff --git a/crates/workspace-server/src/server/tests/api_logging_tests.rs b/crates/workspace-server/src/server/tests/api_logging_tests.rs index 3c9864195..95b104522 100644 --- a/crates/workspace-server/src/server/tests/api_logging_tests.rs +++ b/crates/workspace-server/src/server/tests/api_logging_tests.rs @@ -471,11 +471,10 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() { )); // Seed an already registered command, without launching a real process. let provider_handle = CommandHandle("logging-command".to_string()); - session - .active_commands - .lock() - .await - .insert(provider_handle.0.clone(), provider_handle.clone()); + session.active_commands.lock().await.insert( + provider_handle.0.clone(), + Arc::new(ExternalProviderCommandState::new(provider_handle.clone())), + ); let handle = fixture .api .workdir_sessions @@ -720,7 +719,7 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t Reason::ScopedCapabilityDenied, ), ( - json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "output_limit": 1024, "cwd": "", "tool_call_id": secret}}), + json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "transport_byte_budget": 1048576, "cwd": "", "tool_call_id": secret}}), "command_start", "denied", Reason::ScopedCapabilityDenied, @@ -942,8 +941,8 @@ async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatc serde_json::to_vec(&json!({ "target_workdir": "checkout", "operation": {"operation": "command_start", "request": { - "command": "body-secret", "timeout_secs": 1, "output_limit": 1024, - "cwd": "", "spill_dir": null, "tool_call_id": "body-secret" + "command": "body-secret", "timeout_secs": 1, "transport_byte_budget": 1048576, + "cwd": "", "tool_call_id": "body-secret" }} })) .unwrap(), @@ -1071,8 +1070,8 @@ async fn saturated_host_before_command_start_returns_availability_without_a_prov serde_json::to_vec(&json!({ "target_workdir": "checkout", "operation": {"operation": "command_start", "request": { - "command": "not-dispatched", "timeout_secs": 1, "output_limit": 1024, - "cwd": "", "spill_dir": null, "tool_call_id": "not-dispatched" + "command": "not-dispatched", "timeout_secs": 1, "transport_byte_budget": 1048576, + "cwd": "", "tool_call_id": "not-dispatched" }} })) .unwrap(), diff --git a/crates/workspace-server/src/server/tests/command_transport_tests.rs b/crates/workspace-server/src/server/tests/command_transport_tests.rs new file mode 100644 index 000000000..af9d6416b --- /dev/null +++ b/crates/workspace-server/src/server/tests/command_transport_tests.rs @@ -0,0 +1,223 @@ +//! Provider-bridge lifecycle tests. The production bridge runs against a controlled +//! provider boundary; the websocket/CLI-pump test in the parent module covers real byte transport. +use super::*; +use workdir::WorkdirSession; + +fn command_fixture() -> ( + Arc, + tokio::sync::mpsc::Receiver, +) { + let (sender, receiver) = tokio::sync::mpsc::channel(16); + let session = Arc::new(ExternalProviderWorkdirSession::new( + Arc::new(ExternalProviderConnection { + grant_id: "command-grant".into(), + workdir_id: "command-workdir".into(), + provider_instance_id: "command-provider".into(), + generation: 1, + expires_at: None, + capabilities: workdir::WorkdirSessionCapabilities::COMMAND_ONLY, + admission: Arc::new(tokio::sync::Semaphore::new(1)), + shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + sender, + }), + None, + )); + (session, receiver) +} + +fn page(cursor: u64, bytes: &[u8], terminal: bool) -> workdir::CommandOutput { + workdir::CommandOutput { + chunks: if bytes.is_empty() { + Vec::new() + } else { + vec![workdir::CommandByteChunk { + sequence: cursor, + stream: workdir::CommandStream::Stdout, + offset: cursor, + bytes: bytes.to_vec(), + }] + }, + next_cursor: cursor + bytes.len() as u64, + terminal: terminal.then_some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + stdout_bytes: cursor + bytes.len() as u64, + stderr_bytes: 0, + stdout_delivered_bytes: cursor + bytes.len() as u64, + stderr_delivered_bytes: 0, + truncation_reason: None, + }), + } +} + +async fn receive( + receiver: &mut tokio::sync::mpsc::Receiver, +) -> ( + WorkdirSessionOperation, + tokio::sync::oneshot::Sender< + std::result::Result, + >, +) { + let command = tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv()) + .await + .unwrap() + .unwrap(); + let ExternalProviderCommand::Operation { + operation, + response, + .. + } = command + else { + panic!("expected provider operation") + }; + (operation, response) +} + +#[tokio::test] +async fn close_cancels_waiting_reader_then_drains_from_latest_cursor() { + let (session, mut receiver) = command_fixture(); + session.active_commands.lock().await.insert( + "external".into(), + Arc::new(ExternalProviderCommandState::new(CommandHandle( + "provider".into(), + ))), + ); + let reader = { + let session = session.clone(); + tokio::spawn(async move { + session + .command_output(workdir::CommandOutputRequest { + handle: CommandHandle("external".into()), + cursor: 0, + limit: 3, + wait: true, + }) + .await + }) + }; + let (_, response) = receive(&mut receiver).await; + response + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + 0, b"one", false, + )))) + .unwrap(); + assert_eq!(reader.await.unwrap().unwrap().next_cursor, 3); + + let reader = { + let session = session.clone(); + tokio::spawn(async move { + session + .command_output(workdir::CommandOutputRequest { + handle: CommandHandle("external".into()), + cursor: 3, + limit: 3, + wait: true, + }) + .await + }) + }; + let (_, pending_read) = receive(&mut receiver).await; + let closer = { + let session = session.clone(); + tokio::spawn(async move { session.close().await }) + }; + let (operation, response) = receive(&mut receiver).await; + assert!( + matches!(operation, WorkdirSessionOperation::CommandCancel(ref handle) if handle.0 == "provider") + ); + response + .send(Ok(WorkdirSessionOperationResult::CommandCancel)) + .unwrap(); + pending_read + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + 3, b"two", false, + )))) + .unwrap(); + assert_eq!(reader.await.unwrap().unwrap().next_cursor, 6); + + for (cursor, bytes, terminal) in [(6, &b"end"[..], false), (9, &b""[..], true)] { + let (operation, response) = receive(&mut receiver).await; + assert!( + matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == cursor && request.wait) + ); + response + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + cursor, bytes, terminal, + )))) + .unwrap(); + } + tokio::time::timeout(std::time::Duration::from_secs(2), closer) + .await + .unwrap() + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn terminal_output_retains_same_binding_for_current_page_replay() { + let (session, mut receiver) = command_fixture(); + session.active_commands.lock().await.insert( + "external".into(), + Arc::new(ExternalProviderCommandState::new(CommandHandle( + "provider".into(), + ))), + ); + for _ in 0..2 { + let reader = { + let session = session.clone(); + tokio::spawn(async move { + session + .command_output(workdir::CommandOutputRequest { + handle: CommandHandle("external".into()), + cursor: 0, + limit: 3, + wait: true, + }) + .await + }) + }; + let (operation, response) = receive(&mut receiver).await; + assert!( + matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == 0) + ); + response + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + 0, b"end", true, + )))) + .unwrap(); + assert_eq!(reader.await.unwrap().unwrap(), page(0, b"end", true)); + } + assert_eq!(session.active_commands.lock().await.len(), 1); +} + +#[tokio::test] +async fn terminal_registry_retention_is_bounded_and_excludes_active_commands() { + let (source, _) = command_fixture(); + let worker = RuntimeWorkerRef::new("runtime", "worker"); + let mut registry = WorkdirSessionRegistry::default(); + let active = registry.register_command( + worker.clone(), + "checkout".into(), + source.clone(), + CommandHandle("active".into()), + ); + let mut last = None; + for index in 0..MAX_RETAINED_TERMINAL_COMMANDS + 4 { + let handle = registry.register_command( + worker.clone(), + "checkout".into(), + source.clone(), + CommandHandle(format!("terminal-{index}")), + ); + registry.mark_terminal(&worker, &handle); + last = Some(handle); + } + assert_eq!(registry.active_command_count(&worker), 1); + assert_eq!(registry.commands.len(), MAX_RETAINED_TERMINAL_COMMANDS + 1); + assert!(registry.command(&worker, "checkout", &active).is_some()); + assert!( + registry + .command(&worker, "checkout", &last.unwrap()) + .is_some() + ); +} diff --git a/crates/workspace-server/src/server_assignment_attention_tests.rs b/crates/workspace-server/src/server_assignment_attention_tests.rs index 2f5c36ca8..ae622fe8b 100644 --- a/crates/workspace-server/src/server_assignment_attention_tests.rs +++ b/crates/workspace-server/src/server_assignment_attention_tests.rs @@ -466,7 +466,6 @@ impl AttentionWorkerFactory { worker.with_notification_coalesce_delay(std::time::Duration::from_millis(10)), worker::WorkerBootstrapLayout::RuntimeManagedRun { run_dir: root.join(uuid::Uuid::now_v7().to_string()), - bash_output_dir: root.join("bash-output"), }, worker::WorkerControllerTransport::InProcess, ) diff --git a/crates/workspace-server/src/server_assignment_delivery_tests.rs b/crates/workspace-server/src/server_assignment_delivery_tests.rs index 5d397814a..af1948942 100644 --- a/crates/workspace-server/src/server_assignment_delivery_tests.rs +++ b/crates/workspace-server/src/server_assignment_delivery_tests.rs @@ -799,7 +799,6 @@ impl DeliveryWorkerFactory { worker::WorkerFilesystemAuthority::None, worker::WorkerBootstrapLayout::RuntimeManagedRun { run_dir: root.join(uuid::Uuid::now_v7().to_string()), - bash_output_dir: root.join("bash-output"), }, worker::WorkerControllerTransport::InProcess, ) From dd5384df3b410f7b30b89f417d3e7a55b5d36c6e Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 03:13:46 +0900 Subject: [PATCH 14/29] feat: expose retained Bash streams through common WIP file contracts --- crates/tools/src/bash.rs | 7 +- crates/tools/src/bash_output.rs | 6 + crates/worker/src/checkout_http_tests.rs | 114 +++++ crates/worker/src/controller.rs | 14 + crates/worker/src/feature/builtin.rs | 1 + crates/worker/src/feature/builtin/bash.rs | 451 ++++++++++++++++++++ crates/worker/src/wip.rs | 3 + crates/worker/src/wip/bash_output_tests.rs | 470 +++++++++++++++++++++ 8 files changed, 1064 insertions(+), 2 deletions(-) create mode 100644 crates/worker/src/feature/builtin/bash.rs create mode 100644 crates/worker/src/wip/bash_output_tests.rs diff --git a/crates/tools/src/bash.rs b/crates/tools/src/bash.rs index ae911bb71..74dcee471 100644 --- a/crates/tools/src/bash.rs +++ b/crates/tools/src/bash.rs @@ -172,6 +172,10 @@ async fn command_page( } impl BashTool { + pub fn input_schema() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(BashParams)).expect("Bash schema serialization") + } + pub fn new(router: Arc, output: BashOutputView) -> Self { Self { router, @@ -499,10 +503,9 @@ pub(crate) fn routed_bash_tool( output: BashOutputView, ) -> ToolDefinition { Arc::new(move || { - let schema = schemars::schema_for!(BashParams); let meta = ToolMeta::new("Bash") .description("Execute a shell command in one selected Workdir. Workdir owns launch, lossless bounded byte transport, timeout and cancel; Bash owns preview, quota-limited ephemeral output and read-only virtual references. This is not a sandbox.") - .input_schema(serde_json::to_value(schema).expect("Bash schema serialization")); + .input_schema(BashTool::input_schema()); let tool: Arc = Arc::new(BashTool::new(router.clone(), output.clone())); (meta, tool) }) diff --git a/crates/tools/src/bash_output.rs b/crates/tools/src/bash_output.rs index 3f31fcb44..538b50433 100644 --- a/crates/tools/src/bash_output.rs +++ b/crates/tools/src/bash_output.rs @@ -183,6 +183,9 @@ impl BashOutputView { // happens inside the runtime, so idle expiry must start here as well. self.start_reaper(); let mut store = self.store.lock().unwrap(); + if !self.is_active() { + return Err("Bash output authority revoked".into()); + } store.expire(Instant::now()); while store.records.len() >= STORE_COUNT || store.reserved + COMMAND_BYTES > STORE_BYTES { let Some(index) = store @@ -220,6 +223,9 @@ impl BashOutputView { return None; } let mut store = self.store.lock().unwrap(); + if !self.is_active() { + return None; + } store.expire(Instant::now()); store .records diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index b3d85bfc6..f9202f202 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -31,11 +31,18 @@ enum WriteResponseFault { LostBody, } +#[derive(Clone, Copy)] +enum CommandResponseFault { + LostTerminal, + EmptyWaiting, +} + struct ProviderState { session: WorkdirSessionHandle, requests: Mutex>, operations: Mutex>, write_fault: Mutex>, + command_fault: Mutex>, observe_refusal: Mutex, } @@ -152,6 +159,21 @@ async fn operate( source: Box::new(WorkdirError::OutOfScope("/private/provider/path".into())), }); } + if let WorkdirSessionOperation::CommandOutput(output) = &request.operation { + if matches!( + *state.command_fault.lock().unwrap(), + Some(CommandResponseFault::EmptyWaiting) + ) { + return AxumJson(workdir::http::WorkdirSessionOperationResult::CommandOutput( + workdir::CommandOutput { + chunks: vec![], + next_cursor: output.cursor, + terminal: None, + }, + )) + .into_response(); + } + } let result = dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await; match result { @@ -159,6 +181,15 @@ async fn operate( Ok(result) => { let fault = if is_write { state.write_fault.lock().unwrap().take() + } else if matches!(&result, workdir::http::WorkdirSessionOperationResult::CommandOutput(output) if output.terminal.is_some()) + { + let mut command_fault = state.command_fault.lock().unwrap(); + if matches!(*command_fault, Some(CommandResponseFault::LostTerminal)) { + command_fault.take(); + Some(WriteResponseFault::LostBody) + } else { + None + } } else { None }; @@ -237,6 +268,7 @@ impl HttpFixture { requests: Mutex::new(Vec::new()), operations: Mutex::new(Vec::new()), write_fault: Mutex::new(None), + command_fault: Mutex::new(None), observe_refusal: Mutex::new(false), }); let app = Router::new() @@ -800,3 +832,85 @@ async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_wit "refusal was retried" ); } + +// Full collection/projection integration: production HTTP client, local process pipes, +// Bash collector/store, WIP codec and common file dispatch. Only HTTP response loss is injected. +#[tokio::test] +async fn remote_native_bash_retains_streams_quota_and_replays_lost_terminal_through_common_files() { + watchdog(async { + let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await; + let remote = Arc::new(f.open(TOKEN).await.unwrap()); + let router = Arc::new(WorkdirSessionRouter::new()); + router.attach(WorkdirAttachmentAlias::new("main").unwrap(), remote.clone()).unwrap(); + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None).unwrap(); + let view = tools::bash_output::BashOutputView::new(); + crate::feature::builtin::bash::mount(&mut registry, router.clone(), view, None).unwrap(); + let runtime = WipRuntime::new(WipHost::new(registry), SecurityContext::new("remote-bash"), 0).unwrap(); + *f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::LostTerminal); + let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":r"printf 'first\nneedle\n'; printf '%200000s' ''; printf '\377€' >&2; exit 7","cwd":"."}}), Default::default()).await.unwrap(); + f.public_output(run.content.as_ref().unwrap()); + let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap(); + assert_eq!(result["metadata"]["stdout_saved"], 200013); + assert_eq!(result["metadata"]["stderr_saved"], 4); + assert_eq!(result["metadata"]["terminal"]["exit_code"], 7); + assert_eq!(result["metadata"]["terminal"]["transport_complete"], true); + assert_eq!(result["metadata"]["storage_truncated"], false); + assert_eq!(result["preview_omitted"], true); + for name in ["stdout_entry", "stderr_entry", "order_entry"] { + runtime.inspect(result[name].as_str().unwrap().into(), false).await.unwrap(); + } + let stdout = result["stdout_entry"].as_str().unwrap(); + let search = runtime.invoke(stdout.into(), crate::file_operation::Contract::SearchText.reference(), "search".into(), json!({"pattern":"needle","limit":2}), Default::default()).await.unwrap(); + assert!(search.content.unwrap().contains("needle")); + let stderr = runtime.invoke(result["stderr_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"limit":4}), Default::default()).await.unwrap(); + let stderr: Json = serde_json::from_str(&stderr.content.unwrap()).unwrap(); + use base64::Engine as _; + assert_eq!(base64::engine::general_purpose::STANDARD.decode(stderr["data"].as_str().unwrap()).unwrap(), [vec![255], "€".as_bytes().to_vec()].concat()); + let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf '%4194303s' ''; printf '€'","cwd":"."}}), Default::default()).await.unwrap(); + let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap(); + assert_eq!(result["metadata"]["stdout_saved"], 4194304); + assert_eq!(result["metadata"]["stdout_seen"], 4194306); + assert_eq!(result["metadata"]["transport_truncation"], "byte_budget"); + assert_eq!(result["metadata"]["storage_truncated"], true); + let tail = runtime.invoke(result["stdout_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"offset":4194303,"limit":4}), Default::default()).await.unwrap(); + let tail: Json = serde_json::from_str(&tail.content.unwrap()).unwrap(); + assert_eq!(base64::engine::general_purpose::STANDARD.decode(tail["data"].as_str().unwrap()).unwrap(), vec![0xe2]); + assert_eq!(tail["eof"], true); + assert_eq!(f.state.operations.lock().unwrap().iter().filter(|op| matches!(op, WorkdirSessionOperation::CommandStart(_))).count(), 2); + assert!(f.state.command_fault.lock().unwrap().is_none()); + router.close_all().await.unwrap(); + f.assert_endpoints(); + }).await; +} + +#[tokio::test] +async fn waiting_remote_command_rejects_empty_response_without_http_busy_loop() { + watchdog(async { + let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await; + let remote = f.open(TOKEN).await.unwrap(); + *f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::EmptyWaiting); + let error = remote + .command_output(workdir::CommandOutputRequest { + handle: workdir::CommandHandle("unused".into()), + cursor: 0, + limit: 1024, + wait: true, + }) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::Unavailable(_))); + assert_eq!( + f.state + .operations + .lock() + .unwrap() + .iter() + .filter(|op| matches!(op, WorkdirSessionOperation::CommandOutput(_))) + .count(), + 1 + ); + remote.close().await.unwrap(); + }) + .await; +} diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 9f233f95f..f51ab9505 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1861,6 +1861,20 @@ where ) })?; } + if wip_mode { + crate::feature::builtin::bash::mount( + &mut wip_mount_registry, + worker.workdir_sessions(), + bash_output.clone(), + wip_permissions.clone(), + ) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("mount Bash output WIP projection: {error}"), + ) + })?; + } if wip_mode && let Some(feature) = &workdir_wip_feature { crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip( &mut wip_mount_registry, diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index a68b8f992..529cfa8a0 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -5,6 +5,7 @@ //! an external plugin-loading surface. pub mod backend_job_result; +pub(crate) mod bash; pub mod chat_invocation; pub mod drive; pub mod flow_transition; diff --git a/crates/worker/src/feature/builtin/bash.rs b/crates/worker/src/feature/builtin/bash.rs new file mode 100644 index 000000000..ecd94428a --- /dev/null +++ b/crates/worker/src/feature/builtin/bash.rs @@ -0,0 +1,451 @@ +//! Bash Feature projection. The view is instance-owned and shared only by explicit parent +//! routing. No output is indexable; committed result entries directly address immutable files. +//! Root namespace knowledge grants nothing: every publication and read checks the current view. + +use crate::{ + file_operation::{self, Contract}, + permission::permission_action_for, + wip::{ + WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount, + WipSubtreeProvider, contextual_reference, json_to_wip, wip_to_json, + }, +}; +use agen::tool::{Tool, ToolError}; +use async_trait::async_trait; +use manifest::{ToolPermissionAction, ToolPermissionConfig}; +use serde_json::{Value as Json, json}; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeMap, sync::Arc}; +use tools::{BashTool, bash_output::BashOutputView}; +use wip_protocol::{ + Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, + OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode, + ReturnDeclaration, TypeExpr, Value, +}; +use workdir::{CommandStream, WorkdirSessionRouter}; + +const ROOT: &str = "/bash-output"; + +pub(crate) fn mount( + registry: &mut WipMountRegistry, + router: Arc, + view: BashOutputView, + permissions: Option, +) -> Result<(), WipMountError> { + registry.allocate_namespace("bash", "bash-output")?; + let provider = Arc::new(Provider { + service: BashTool::new(router, view.clone()), + view, + permissions, + incarnation: uuid::Uuid::now_v7().to_string(), + }); + registry.mount(provider.root())?; + registry.replace_compatibility_tools(ROOT, ["Bash"])?; + registry.mount_subtree(WipSubtreeMount { + root: ROOT.into(), + provider: Arc::new(provider), + }) +} + +struct Provider { + service: BashTool, + view: BashOutputView, + permissions: Option, + incarnation: String, +} +impl Provider { + fn allowed(&self, tool: &str, args: &Json) -> bool { + self.view.is_active() + && self + .permissions + .as_ref() + .is_none_or(|p| permission_action_for(p, tool, args) == ToolPermissionAction::Allow) + } + fn may_allow(&self, tool: &str) -> bool { + if !self.view.is_active() { + return false; + } + let Some(p) = &self.permissions else { + return true; + }; + for rule in p.rules.iter().filter(|r| r.tool.eq_ignore_ascii_case(tool)) { + if rule.action == ToolPermissionAction::Allow { + return true; + } + if rule.pattern == "*" { + return false; + } + } + p.default_action == ToolPermissionAction::Allow + } + fn root(self: &Arc) -> WipProjection { + let interface = contextual_reference("yoi.bash/command/v1", ROOT); + let descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: Some(Documentation { + summary: "Run Bash with bounded ephemeral output; retained bytes are accessed via result entries, not listed".into(), + details: None, + }), + types: vec![], + operations: vec![OperationDeclaration { + name: "run".into(), + documentation: None, + parameters: vec![ParameterDeclaration { + name: "input".into(), + required: true, + documentation: Some(Documentation { + summary: "Bash input, validated by the existing Tool argument contract".into(), + details: Some(BashTool::input_schema().to_string()), + }), + r#type: TypeExpr::Json, + }], + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Record { + fields: vec![ + field("summary", TypeExpr::String), + field("stdout_preview", TypeExpr::String), + field("stderr_preview", TypeExpr::String), + field("preview_omitted", TypeExpr::Boolean), + field("metadata", TypeExpr::Json), + field("stdout_entry", TypeExpr::Entry), + field("stderr_entry", TypeExpr::Entry), + field("order_entry", TypeExpr::Entry), + ], + }, + }, + }], + }; + WipProjection { + route: ROOT.into(), + capability: "bash:command".into(), + kind: WipProjectionKind::Native, + object: Object { + name: "bash-output".into(), + description: Some("Bash command and nonindexable retained output".into()), + interfaces: vec![interface.clone()], + r#ref: Some(format!("bash:{}", self.incarnation)), + validator: Some(self.incarnation.as_bytes().to_vec()), + }, + interface, + interface_validator: Some(Sha256::digest(format!("{descriptor:?}")).to_vec()), + descriptor, + handler: Arc::new(Run { + provider: self.clone(), + }), + } + } + fn parse(&self, path: &str) -> Option<(String, Option)> { + let rest = path.strip_prefix("/bash-output/")?; + let (id, stream) = rest.split_once('/')?; + if uuid::Uuid::parse_str(id).ok()?.to_string() != id { + return None; + } + let stream = match stream { + "stdout" => Some(CommandStream::Stdout), + "stderr" => Some(CommandStream::Stderr), + "order" => None, + _ => return None, + }; + Some((id.into(), stream)) + } +} + +#[async_trait] +impl WipSubtreeProvider for Arc { + async fn publication(&self, path: &str) -> Result, ProtocolError> { + if !self.view.is_active() { + return Ok(None); + } + if path == ROOT { + let projection = self.root(); + return Ok(Some(WipPublication { + scope: Some(projection.object.clone()), + projection, + })); + } + let Some((id, stream)) = self.parse(path) else { + return Ok(None); + }; + let Some(metadata) = self.view.metadata(&id) else { + return Ok(None); + }; + if !self.may_allow("Read") && !self.may_allow("Grep") { + return Ok(None); + } + let mut contracts = vec![]; + if self.may_allow("Read") { + contracts.extend([Contract::ReadLines, Contract::ReadBytes]); + } + if self.may_allow("Grep") { + contracts.push(Contract::SearchText); + } + let primary = contracts[0]; + let interface = primary.reference(); + let descriptor = primary.descriptor(); + let validator = Sha256::digest(format!("{}:{id}:{stream:?}", self.incarnation)).to_vec(); + let mut projection = WipProjection { + route: path.into(), + capability: "bash:output".into(), + kind: WipProjectionKind::Native, + object: Object { + name: path.rsplit('/').next().unwrap().into(), + description: Some(format!( + "Read-only output component (stdout={} bytes, stderr={} bytes); text is UTF-8 lossy; storage_truncated={}", + metadata.stdout_saved, metadata.stderr_saved, metadata.storage_truncated + )), + interfaces: vec![interface.clone()], + r#ref: Some(format!("bash:{}:{id}:{stream:?}", self.incarnation)), + validator: Some(validator.clone()), + }, + interface, + descriptor: descriptor.clone(), + interface_validator: Some(primary.validator()), + handler: Arc::new(File { + provider: self.clone(), + id, + stream, + path: path.into(), + validator, + }), + }; + file_operation::expose(&mut projection, contracts); + Ok(Some(WipPublication { + projection, + scope: None, + })) + } + async fn children(&self, _: &str) -> Result, ProtocolError> { + Ok(vec![]) + } +} + +struct Run { + provider: Arc, +} +#[async_trait] +impl WipOperationHandler for Run { + fn is_visible(&self) -> bool { + self.provider.view.is_active() + } + fn operation_available(&self, op: &str) -> bool { + op == "run" && self.provider.may_allow("Bash") + } + async fn call( + &self, + op: &str, + args: &BTreeMap, + ctx: WipCallContext, + ) -> Result { + if op != "run" { + return Err(fail( + ProtocolErrorCode::OperationNotFound, + "unknown Bash operation", + )); + } + let input = args + .get("input") + .ok_or_else(|| fail(ProtocolErrorCode::InvalidArguments, "input is required"))?; + let input = + wip_to_json(input).map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?; + if !self.provider.allowed("Bash", &input) { + return Err(fail( + ProtocolErrorCode::PermissionDenied, + "Bash not permitted", + )); + } + let result = self + .provider + .service + .execute_result(&input.to_string(), ctx.execution) + .await + .map_err(tool_error)?; + if result.cancelled { + return Err(WipOperationError::Cancelled(result.tool_output())); + } + if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) { + return Err(WipOperationError::Interrupted(result.tool_output())); + } + // Entry fields are typed by the native Descriptor. No display-string parsing is involved. + Ok(WipOperationOutput::native(Value::Record(BTreeMap::from([ + ("summary".into(), Value::String(result.summary.clone())), + ( + "stdout_preview".into(), + Value::String(result.stdout_preview.clone()), + ), + ( + "stderr_preview".into(), + Value::String(result.stderr_preview.clone()), + ), + ( + "preview_omitted".into(), + Value::Boolean(result.preview_omitted), + ), + ("stdout_entry".into(), Value::String(result.stdout_entry())), + ("stderr_entry".into(), Value::String(result.stderr_entry())), + ("order_entry".into(), Value::String(result.order_entry())), + ( + "metadata".into(), + json_to_wip(&serde_json::to_value(result.metadata).unwrap()) + .map_err(|e| fail(ProtocolErrorCode::Internal, &e))?, + ), + ])))) + } + async fn cancel(&self, ctx: &agen::tool::ToolExecutionContext) -> Result<(), ToolError> { + self.provider.service.cancel_execution(ctx).await + } +} + +struct File { + provider: Arc, + id: String, + stream: Option, + path: String, + validator: Vec, +} +impl File { + fn order_text(&self) -> Result { + use std::fmt::Write; + let ranges = self + .provider + .view + .order(&self.id) + .ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?; + let mut text = String::new(); + for r in ranges { + writeln!(&mut text, "{:?} {} {}", r.stream, r.start, r.end).unwrap(); + } + Ok(text) + } +} +#[async_trait] +impl WipOperationHandler for File { + fn is_visible(&self) -> bool { + self.provider.view.metadata(&self.id).is_some() + } + fn object_validator(&self) -> Option> { + self.is_visible().then(|| self.validator.clone()) + } + fn operation_available(&self, op: &str) -> bool { + match op { + "read_lines" | "read_bytes" | "read" => self.provider.may_allow("Read"), + "search_text" | "search" => self.provider.may_allow("Grep"), + _ => false, + } + } + async fn call( + &self, + op: &str, + args: &BTreeMap, + _: WipCallContext, + ) -> Result { + if !self.operation_available(op) { + return Err(fail( + ProtocolErrorCode::PermissionDenied, + "output operation not permitted", + )); + } + let mut input = wip_to_json(&Value::Record(args.clone())) + .map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?; + let tool = if op == "search_text" { + input["path"] = json!(self.path); + "Grep" + } else { + input["file_path"] = json!(self.path); + "Read" + }; + if !self.provider.allowed(tool, &input) { + return Err(fail( + ProtocolErrorCode::PermissionDenied, + "output operation not permitted", + )); + } + let metadata = self + .provider + .view + .metadata(&self.id) + .ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?; + if op == "read_bytes" { + let (offset, limit) = file_operation::byte_range(args)?; + let (bytes, total) = if let Some(stream) = self.stream { + let total = if stream == CommandStream::Stdout { + metadata.stdout_saved + } else { + metadata.stderr_saved + }; + if offset > total as u64 { + return Err(fail( + ProtocolErrorCode::InvalidArguments, + "byte offset exceeds output length", + )); + } + let bytes = self + .provider + .view + .read(&self.id, stream, offset as usize, limit) + .ok_or_else(|| { + fail(ProtocolErrorCode::NotFound, "output expired or revoked") + })?; + (bytes, total) + } else { + let text = self.order_text()?; + if offset > text.len() as u64 { + return Err(fail( + ProtocolErrorCode::InvalidArguments, + "byte offset exceeds output length", + )); + } + ( + text.as_bytes()[offset as usize..(offset as usize + limit).min(text.len())] + .to_vec(), + text.len(), + ) + }; + let eof = offset + bytes.len() as u64 == total as u64; + return Ok(WipOperationOutput::native_with_validator( + file_operation::bytes_result(args, &bytes, eof)?, + self.validator.clone(), + )); + } + let (offset, limit) = file_operation::window(args, op == "search_text")?; + let value = if let Some(stream) = self.stream { + let (text, more) = self + .provider + .view + .line_window(&self.id, stream, offset, limit) + .ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))? + .map_err(|e| fail(ProtocolErrorCode::ResourceLimitExceeded, &e))?; + file_operation::page_result(op, args, &text, offset, more)? + } else { + file_operation::text_result(op, args, &self.order_text()?)? + }; + Ok(WipOperationOutput::native_with_validator( + value, + self.validator.clone(), + )) + } +} + +fn fail(code: ProtocolErrorCode, message: &str) -> WipOperationError { + WipOperationError::Protocol(ProtocolError { + code, + message: message.into(), + }) +} +fn tool_error(error: ToolError) -> WipOperationError { + match error { + ToolError::InvalidArgument(_) => fail(ProtocolErrorCode::InvalidArguments, "invalid Bash arguments"), + ToolError::Cancelled(output) => WipOperationError::Cancelled(output), + ToolError::Interrupted(output) => WipOperationError::Interrupted(output), + _ => WipOperationError::OutcomeUnknown("Bash execution failed; effects or process termination may be unconfirmed; do not automatically restart the command".into()), + } +} +fn field(name: &str, r#type: TypeExpr) -> FieldDeclaration { + FieldDeclaration { + name: name.into(), + required: true, + documentation: None, + r#type, + } +} diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 2929aba0b..a0951f187 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -7121,3 +7121,6 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 0); } } + +#[cfg(test)] +mod bash_output_tests; diff --git a/crates/worker/src/wip/bash_output_tests.rs b/crates/worker/src/wip/bash_output_tests.rs new file mode 100644 index 000000000..39eac727e --- /dev/null +++ b/crates/worker/src/wip/bash_output_tests.rs @@ -0,0 +1,470 @@ +//! Native Bash output integration through real WIP Client/Host codec and local process pipes. +//! No telemetry/storage fake: only the model client is absent. Runtime HTTP transport is tested +//! by worker-runtime's command_transport_tests. Each fixture owns its Workdir and ephemeral view. +use super::*; +use crate::file_operation::Contract; +use tempfile::TempDir; +use tools::bash_output::BashOutputView; +use workdir::{ + LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter, +}; + +struct Fixture { + _dir: TempDir, + router: Arc, + view: BashOutputView, + runtime: WipRuntime, +} +impl Fixture { + fn new() -> Self { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("real.txt"), "first\n雪 needle\nlast\n").unwrap(); + let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new( + manifest::Scope::writable(dir.path()).unwrap(), + dir.path().into(), + )); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), session) + .unwrap(); + let view = BashOutputView::new(); + let runtime = runtime(router.clone(), view.clone()); + Self { + _dir: dir, + router, + view, + runtime, + } + } + async fn run(&self, command: &str) -> Json { + value( + self.runtime + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":command,"cwd":"."}}), + Default::default(), + ) + .await + .unwrap(), + ) + } +} +fn runtime(router: Arc, view: BashOutputView) -> WipRuntime { + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None) + .unwrap(); + crate::feature::builtin::bash::mount(&mut registry, router, view, None).unwrap(); + WipRuntime::new(WipHost::new(registry), SecurityContext::new("bash-test"), 0).unwrap() +} +fn value(output: ToolOutput) -> Json { + serde_json::from_str(&output.content.unwrap()).unwrap() +} +async fn invoke( + runtime: &WipRuntime, + path: &str, + contract: Contract, + operation: &str, + args: Json, +) -> Result { + runtime + .invoke( + path.into(), + contract.reference(), + operation.into(), + args, + Default::default(), + ) + .await + .map(value) +} + +#[tokio::test] +async fn native_bash_entries_reuse_checkout_contract_for_direct_inspect_read_search() { + let f = Fixture::new(); + let result = f + .run("printf 'first\n雪 needle\nlast\n'; printf error >&2; exit 3") + .await; + assert_eq!(result["metadata"]["terminal"]["exit_code"], 3); + let stdout = result["stdout_entry"].as_str().unwrap(); + assert!(stdout.starts_with("/bash-output/")); + // The native result Descriptor gives these coordinates TypeExpr::Entry; Object inspection + // contains capabilities and metadata, not the process output body. + let projection = f + .runtime + .host + .projection_live("/bash-output") + .await + .unwrap() + .unwrap(); + let TypeExpr::Record { fields } = &projection.descriptor.operations[0].returns.r#type else { + panic!("native record"); + }; + assert_eq!( + fields + .iter() + .find(|v| v.name == "stdout_entry") + .unwrap() + .r#type, + TypeExpr::Entry + ); + let inspect = f.runtime.inspect(stdout.into(), false).await.unwrap(); + assert!(!inspect.content.unwrap().contains("雪 needle")); + for path in ["/checkouts/main/real.txt", stdout] { + let read = invoke( + &f.runtime, + path, + Contract::ReadLines, + "read", + json!({"offset":1,"limit":1}), + ) + .await + .unwrap(); + assert!(read["content"].as_str().unwrap().contains("雪 needle")); + assert_eq!(read["next_offset"], 2); + let search = invoke( + &f.runtime, + path, + Contract::SearchText, + "search", + json!({"offset":0,"limit":2,"pattern":"needle"}), + ) + .await + .unwrap(); + assert_eq!(search["matches"][0]["line"], 2); + assert_eq!(search["next_offset"], 2); + assert_eq!(search["truncated"], true); + } + for name in ["stdout_entry", "stderr_entry", "order_entry"] { + assert_eq!( + fields.iter().find(|v| v.name == name).unwrap().r#type, + TypeExpr::Entry + ); + f.runtime + .inspect(result[name].as_str().unwrap().into(), false) + .await + .unwrap(); + } + let stderr = invoke( + &f.runtime, + result["stderr_entry"].as_str().unwrap(), + Contract::ReadLines, + "read", + json!({}), + ) + .await + .unwrap(); + assert!(stderr["content"].as_str().unwrap().contains("error")); + let order = invoke( + &f.runtime, + result["order_entry"].as_str().unwrap(), + Contract::ReadLines, + "read", + json!({}), + ) + .await + .unwrap(); + assert!(order["content"].as_str().unwrap().contains("Stdout")); + assert!(order["content"].as_str().unwrap().contains("Stderr")); + assert_eq!(result["metadata"]["ordering"], "provider_read_observation"); + let tree = value( + f.runtime + .tree("/bash-output".into(), 8, true) + .await + .unwrap(), + ); + assert!(!tree.to_string().contains(stdout)); + assert!( + invoke( + &f.runtime, + stdout, + Contract::Write, + "write", + json!({"content":"no"}) + ) + .await + .is_err() + ); + assert!( + invoke( + &f.runtime, + stdout, + Contract::Edit, + "edit", + json!({"old_string":"first","new_string":"no"}) + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn common_byte_reader_handles_long_lines_raw_invalid_utf8_boundaries_and_eof() { + let f = Fixture::new(); + let result = f.run("printf '%100000s' ''; printf '€\\377'").await; + let path = result["stdout_entry"].as_str().unwrap(); + assert!( + invoke( + &f.runtime, + path, + Contract::ReadLines, + "read", + json!({"limit":1}) + ) + .await + .is_err() + ); + for (offset, expected) in [ + (99999, vec![b' ', 0xe2]), + (100001, vec![0x82, 0xac]), + (100003, vec![0xff]), + ] { + let read = invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":offset,"limit":2}), + ) + .await + .unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, expected); + assert_eq!(read["next_offset"], offset + expected.len() as u64); + } + let eof = invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":100004,"limit":2}), + ) + .await + .unwrap(); + assert_eq!(eof["data"], ""); + assert_eq!(eof["eof"], true); + assert!( + invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":100005}) + ) + .await + .is_err() + ); + assert!( + invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"limit":32769}) + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn publication_and_invoke_recheck_child_authority_and_root_teardown_invalidates_old_refs() { + let f = Fixture::new(); + let lease = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let child = f.view.child({ + let lease = lease.clone(); + Arc::new(move || lease.load(std::sync::atomic::Ordering::Acquire)) + }); + let child_runtime = runtime(f.router.clone(), child.clone()); + let result = value( + child_runtime + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":"printf child-evidence","cwd":"."}}), + Default::default(), + ) + .await + .unwrap(), + ); + let path = result["stdout_entry"].as_str().unwrap(); + child_runtime.inspect(path.into(), false).await.unwrap(); + let sibling = runtime(f.router.clone(), f.view.child(Arc::new(|| true))); + let unrelated = runtime(f.router.clone(), BashOutputView::new()); + assert!(sibling.inspect(path.into(), false).await.is_err()); + assert!(unrelated.inspect(path.into(), false).await.is_err()); + lease.store(false, std::sync::atomic::Ordering::Release); + assert!( + invoke(&child_runtime, path, Contract::ReadBytes, "read", json!({})) + .await + .is_err() + ); + assert!( + invoke( + &child_runtime, + path, + Contract::SearchText, + "search", + json!({"pattern":"child"}) + ) + .await + .is_err() + ); + assert!( + invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({})) + .await + .is_ok() + ); + f.view.revoke(); + assert!( + invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({})) + .await + .is_err() + ); + let restored = runtime(f.router.clone(), BashOutputView::new()); + assert!(restored.inspect(path.into(), false).await.is_err()); +} + +#[tokio::test] +async fn native_timeout_and_cancellation_keep_terminal_evidence_instead_of_success() { + let f = Fixture::new(); + let session = f.router.only_session().unwrap(); + let mut events = session.subscribe_command_events().unwrap(); + let runtime = Arc::new(f.runtime); + let context = agen::tool::ToolExecutionContext::new("native-cancel", "native-attempt", 0); + let running = runtime.clone(); + let executing_context = context.clone(); + let execution = tokio::spawn(async move { + running + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":"printf before; while :; do :; done","cwd":"."}}), + executing_context, + ) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if matches!( + events.recv().await.unwrap(), + workdir::CommandEvent::Output { .. } + ) { + break; + } + } + }) + .await + .unwrap(); + runtime.cancel(&context).await.unwrap(); + let ToolError::Cancelled(output) = + tokio::time::timeout(std::time::Duration::from_secs(5), execution) + .await + .unwrap() + .unwrap() + .unwrap_err() + else { + panic!("expected cancelled terminal output") + }; + let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap(); + let id = body["output"]["id"].as_str().unwrap(); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert_eq!(body["output"]["terminal"]["status"], "cancelled"); + assert_eq!( + f.view + .read(id, workdir::CommandStream::Stdout, 0, 20) + .unwrap(), + b"before" + ); + assert!( + invoke( + &runtime, + body["stdout_entry"].as_str().unwrap(), + Contract::ReadBytes, + "read", + json!({}) + ) + .await + .is_ok() + ); + let error = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf timeout; while :; do :; done","cwd":".","timeout":1}}), Default::default()).await.unwrap_err(); + let ToolError::Interrupted(output) = error else { + panic!("expected timeout evidence") + }; + let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(body["output"]["terminal"]["status"], "timed_out"); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert!( + f.view + .metadata(body["output"]["id"].as_str().unwrap()) + .is_some() + ); + session.close().await.unwrap(); +} + +#[tokio::test] +async fn common_read_and_search_permissions_are_independent_from_bash_execution() { + let f = Fixture::new(); + let result = f.run("printf needle").await; + let path = result["stdout_entry"].as_str().unwrap(); + let mut registry = WipMountRegistry::new(); + crate::feature::builtin::bash::mount( + &mut registry, + f.router.clone(), + f.view.clone(), + Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: vec![manifest::ToolPermissionRule { + tool: "Grep".into(), + pattern: "*".into(), + action: ToolPermissionAction::Allow, + }], + }), + ) + .unwrap(); + let restricted = WipRuntime::new( + WipHost::new(registry), + SecurityContext::new("restricted"), + 0, + ) + .unwrap(); + restricted.inspect(path.into(), false).await.unwrap(); + assert!( + invoke(&restricted, path, Contract::ReadBytes, "read", json!({})) + .await + .is_err() + ); + assert!( + invoke(&restricted, path, Contract::ReadLines, "read", json!({})) + .await + .is_err() + ); + let found = invoke( + &restricted, + path, + Contract::SearchText, + "search", + json!({"pattern":"needle"}), + ) + .await + .unwrap(); + assert!(found["matches"].as_array().unwrap().len() == 1); + assert!( + restricted + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":"touch denied","cwd":"."}}), + Default::default() + ) + .await + .is_err() + ); + assert!(!f._dir.path().join("denied").exists()); +} From f38b2a52b1ac25fdd899c31aef352ece30ba7651 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 03:21:14 +0900 Subject: [PATCH 15/29] fix: record native prior Read only after result validation --- crates/tools/src/checkout.rs | 16 ++--- crates/worker/src/checkout.rs | 8 ++- crates/worker/src/checkout_http_tests.rs | 38 ++++++++++++ crates/worker/src/checkout_wip_tests.rs | 79 ++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 10 deletions(-) diff --git a/crates/tools/src/checkout.rs b/crates/tools/src/checkout.rs index 622dd1b3d..8a697a67d 100644 --- a/crates/tools/src/checkout.rs +++ b/crates/tools/src/checkout.rs @@ -404,8 +404,8 @@ fn check_pattern(pattern: &str) -> Result<(), ToolError> { #[path = "checkout_tests.rs"] mod tests; -/// Read a bounded native text window through the same checked target and attachment tracker. -/// Search callers do not establish the prior-Read precondition for mutations. +/// Read a bounded native text window through the same checked target. +/// The returned one-shot action records prior-Read only after the caller's presentation succeeds. #[allow(clippy::too_many_arguments)] pub async fn read_checkout_text( router: Arc, @@ -417,8 +417,7 @@ pub async fn read_checkout_text( offset: usize, limit: usize, max_bytes: usize, - record_read: bool, -) -> Result<(workdir::ReadResult, Option>), ToolError> { +) -> Result<(workdir::ReadResult, Option>, impl FnOnce()), ToolError> { let selected = crate::routing::resolve_session(&router, Some(alias), WorkdirSessionCapability::Read)?; if selected.generation != generation { @@ -431,10 +430,11 @@ pub async fn read_checkout_text( validator: Some(validator), }; let (result, validator) = target.read(offset, limit, max_bytes).await?; - if record_read { - tracker.record_workdir_observation(&path, result.content_hash.clone(), result.total_lines); - } - Ok((result, validator)) + let hash = result.content_hash.clone(); + let total_lines = result.total_lines; + Ok((result, validator, move || { + tracker.record_workdir_observation(&path, hash, total_lines); + })) } /// Binary-safe checked byte window. This does not establish a text prior-Read observation. diff --git a/crates/worker/src/checkout.rs b/crates/worker/src/checkout.rs index 74666982a..b57415103 100644 --- a/crates/worker/src/checkout.rs +++ b/crates/worker/src/checkout.rs @@ -625,7 +625,6 @@ impl WipOperationHandler for FileHandler { offset, limit, crate::file_operation::MAX_BYTES, - operation == "read_lines", ), ) .await @@ -636,7 +635,7 @@ impl WipOperationHandler for FileHandler { ) })? .map_err(map_tool_error)?; - let (read, validator) = result; + let (read, validator, record_read) = result; self.check_connection()?; let text = String::from_utf8_lossy(&read.bytes); let value = crate::file_operation::page_result( @@ -646,6 +645,11 @@ impl WipOperationHandler for FileHandler { offset, read.truncated, )?; + // Only a successfully rendered Read can establish mutation authority. + // Search and failed result budgets must leave prior-Read history unchanged. + if operation == "read_lines" { + record_read(); + } return Ok(match validator { Some(raw) => WipOperationOutput::native_with_validator( value, diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index a7871239b..7436dc525 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -841,3 +841,41 @@ async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundari source.bytes ); } + +#[tokio::test] +async fn http_failed_text_read_does_not_authorize_mutations() { + for operation in ["write", "edit"] { + for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] { + let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await; + let remote = fixture.open(TOKEN).await.unwrap(); + checkout_wip_tests::failed_text_read_cannot_authorize_mutation( + Arc::new(remote), + &fixture.dir.path().join("text"), + &oversized, + operation, + ) + .await; + let mutations = fixture + .state + .operations + .lock() + .unwrap() + .iter() + .filter(|op| { + matches!( + op, + WorkdirSessionOperation::CheckoutExecute(CheckoutRequest { + operation: CheckoutOperation::Write { .. } + | CheckoutOperation::Edit { .. }, + .. + }) + ) + }) + .count(); + assert_eq!( + mutations, 1, + "only the mutation after a successful Read may reach the provider" + ); + } + } +} diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs index 9460924f1..199b2b32d 100644 --- a/crates/worker/src/checkout_wip_tests.rs +++ b/crates/worker/src/checkout_wip_tests.rs @@ -1536,3 +1536,82 @@ fn common_contract_registration_rejects_descriptor_redefinition_and_domain_opera Err(WipMountError::InterfaceCollision { .. }) )); } + +pub(super) async fn failed_text_read_cannot_authorize_mutation( + source: WorkdirSessionHandle, + disk_file: &std::path::Path, + oversized: &str, + operation: &str, +) { + let original = format!("{oversized}\nknown\n"); + std::fs::write(disk_file, &original).unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), source) + .unwrap(); + let r = Arc::new(runtime(router, None)); + let inspected = model_inspect(r.clone(), "/checkouts/main/text").await; + let arguments = if operation == "write" { + json!({"content":"changed"}) + } else { + json!({"old_string":"known","new_string":"changed"}) + }; + let failure = model_invoke(r.clone(), &inspected, "read", json!({"limit":1})) + .await + .unwrap_err(); + assert!( + failure.to_string().contains("ResourceLimitExceeded"), + "{failure}" + ); + let denied = model_invoke(r.clone(), &inspected, operation, arguments.clone()).await; + assert!(denied.is_err(), "failed read authorized {operation}"); + let denied = denied.unwrap_err(); + assert!(denied.to_string().contains("InvalidArguments"), "{denied}"); + assert_eq!(std::fs::read_to_string(disk_file).unwrap(), original); + model_invoke(r.clone(), &inspected, "read", json!({"offset":1,"limit":1})) + .await + .unwrap(); + // A failed read must neither grant new authority nor erase an earlier successful Read. + assert!( + model_invoke(r.clone(), &inspected, "read", json!({"limit":1})) + .await + .is_err() + ); + model_invoke(r, &inspected, operation, arguments) + .await + .unwrap(); + let expected = if operation == "write" { + "changed".to_string() + } else { + original.replace("known", "changed") + }; + assert_eq!(std::fs::read_to_string(disk_file).unwrap(), expected); +} + +#[tokio::test] +async fn failed_text_read_does_not_authorize_write() { + for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] { + let dir = TempDir::new().unwrap(); + failed_text_read_cannot_authorize_mutation( + session(&dir, false), + &dir.path().join("text"), + &oversized, + "write", + ) + .await; + } +} + +#[tokio::test] +async fn failed_text_read_does_not_authorize_edit() { + for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] { + let dir = TempDir::new().unwrap(); + failed_text_read_cannot_authorize_mutation( + session(&dir, false), + &dir.path().join("text"), + &oversized, + "edit", + ) + .await; + } +} From 6e729ed06c01595ba8e3736e5741d850162257f0 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 03:21:22 +0900 Subject: [PATCH 16/29] refactor: isolate Feature System input migration and compaction policy --- crates/session-store/src/history.rs | 2 +- .../session-store/src/legacy_session_log.rs | 316 +++++++++- .../src/legacy_session_log/tests.rs | 2 + .../tests/fixtures/v1-system-inputs.jsonl | 3 + .../tests/fixtures/v4-system-inputs.jsonl | 8 + .../src/legacy_session_log/tests/v5.rs | 596 ++++++++++++++++++ crates/session-store/src/public_material.rs | 23 +- .../src/public_material/tests.rs | 170 +++-- crates/session-store/src/system_item.rs | 134 +--- .../session-store/src/worker_session_store.rs | 206 +++++- .../src/subjektiv/journal/tests/handoff.rs | 2 +- crates/worker/src/controller.rs | 6 +- crates/worker/src/feature/builtin/journal.rs | 169 ++++- crates/worker/src/feature/builtin/memory.rs | 192 ++++-- crates/worker/src/feature/builtin/task/mod.rs | 10 +- crates/worker/src/feature/invocation.rs | 39 ++ crates/worker/src/hook.rs | 80 ++- crates/worker/src/ipc/interceptor.rs | 90 ++- crates/worker/src/ipc/protocol_session.rs | 48 +- crates/worker/src/session_history.rs | 30 +- crates/worker/src/skill.rs | 44 ++ crates/worker/src/subjektiv.rs | 66 +- crates/worker/src/worker.rs | 394 +++++++----- crates/worker/tests/compact_events_test.rs | 180 ++++-- .../worker/tests/feature_invocation_test.rs | 20 +- .../subjektiv_journal_connected_tests.rs | 36 +- tools/state-contract-name-exceptions.json | 6 +- 27 files changed, 2226 insertions(+), 646 deletions(-) create mode 100644 crates/session-store/src/legacy_session_log/tests/fixtures/v1-system-inputs.jsonl create mode 100644 crates/session-store/src/legacy_session_log/tests/fixtures/v4-system-inputs.jsonl create mode 100644 crates/session-store/src/legacy_session_log/tests/v5.rs diff --git a/crates/session-store/src/history.rs b/crates/session-store/src/history.rs index f25617b1d..67d753c13 100644 --- a/crates/session-store/src/history.rs +++ b/crates/session-store/src/history.rs @@ -95,7 +95,7 @@ pub struct LoggedSessionHistoryMetadata { } impl LoggedSessionHistoryMetadata { - fn validate_system_input(&self) -> Result<(), crate::SystemInputError> { + pub(crate) fn validate_system_input(&self) -> Result<(), crate::SystemInputError> { if matches!(self.origin, LoggedSessionHistoryOrigin::Provided { .. }) && self.system_input.is_none() { diff --git a/crates/session-store/src/legacy_session_log.rs b/crates/session-store/src/legacy_session_log.rs index 987a53984..10e247539 100644 --- a/crates/session-store/src/legacy_session_log.rs +++ b/crates/session-store/src/legacy_session_log.rs @@ -1,12 +1,12 @@ -//! Versioned decoder for Session schemas that predate canonical annotated history. +//! Versioned decoder for Session schemas v1-4, including frozen Feature wire. //! //! These types are intentionally private to `session-store`. Current writers, //! replay, and public projections use [`crate::LogEntry`] exclusively; only the -//! Worker Session schema migration is allowed to deserialize these shapes. +//! explicit Session schema migration is allowed to deserialize these shapes. use agen::llm_client::types::RequestConfig; use protocol::Segment; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[cfg(test)] mod tests; @@ -17,6 +17,212 @@ use crate::{ SegmentOrigin, SessionExtension, SessionId, SystemItem, }; +// Frozen v1-4 Feature wire. These types are deliberately not exported and are +// never used by LogEntry/SystemItem's current deserializers. Payload structs +// retain the owner's v1 shape rather than erasing typed fields into arbitrary JSON. +#[derive(Debug, Serialize, Deserialize)] +struct JournalStartupPayload { + snapshot: serde_json::Value, + snapshot_sha256: String, + prompt_provenance: crate::PromptRenderProvenance, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ResidentSummaryPayload { + #[serde(default)] + prompt_provenance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SubjectBehaviorPayload { + #[serde(default)] + behavior_md: String, + #[serde(default)] + prompt_provenance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SkillActivationPayload { + name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct InvocationResultPayload { + result: protocol::FeatureInvocationResult, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum LegacyFeatureSystemItem { + JournalStartup { + body: String, + #[serde(flatten)] + payload: JournalStartupPayload, + }, + ResidentSummaryRefresh { + body: String, + #[serde(flatten)] + payload: ResidentSummaryPayload, + }, + SubjectBehaviorRefresh { + body: String, + #[serde(flatten)] + payload: SubjectBehaviorPayload, + }, + SkillActivation { + body: String, + #[serde(flatten)] + payload: SkillActivationPayload, + }, + FeatureInvocationResult { + body: String, + #[serde(flatten)] + payload: InvocationResultPayload, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum LegacySystemItem { + Feature(LegacyFeatureSystemItem), + // Includes already-migrated Input records for interrupted migration retry. + Core(SystemItem), +} + +impl LegacySystemItem { + fn into_entry( + self, + mut metadata: LoggedSessionHistoryMetadata, + ) -> Result { + use crate::InputCompactionUse::{ReferenceInput, Retain}; + let item = match self { + Self::Core(item) => item, + Self::Feature(feature) => { + metadata.validate_system_input()?; + if metadata.system_input.is_some() { + return Err(crate::SystemInputError::InconsistentRecord); + } + let (body, input_metadata) = match feature { + LegacyFeatureSystemItem::JournalStartup { body, payload } => { + migrated_feature_input( + body, + "journal_startup", + "journal:startup", + payload, + ReferenceInput, + )? + } + LegacyFeatureSystemItem::ResidentSummaryRefresh { body, payload } => { + migrated_feature_input( + body, + "resident_summary_refresh", + "memory:resident-summary", + payload, + Retain, + )? + } + LegacyFeatureSystemItem::SubjectBehaviorRefresh { body, payload } => { + migrated_feature_input( + body, + "subject_behavior_refresh", + "subject:behavior", + payload, + Retain, + )? + } + LegacyFeatureSystemItem::SkillActivation { body, payload } => { + migrated_feature_input( + body, + "skill_activation", + "skill:activation", + payload, + Retain, + )? + } + LegacyFeatureSystemItem::FeatureInvocationResult { body, payload } => { + migrated_feature_input( + body, + "feature_invocation_result", + "feature:invocation-result", + payload, + Retain, + )? + } + }; + // Identity/origin/derivation are retained, never re-inferred. + metadata.system_input = Some(input_metadata); + SystemItem::Input { body } + } + }; + let entry = LoggedSystemHistoryEntry { item, metadata }; + entry.validate_system_input()?; + Ok(entry) + } +} + +fn migrated_feature_input( + body: String, + label: &str, + schema: &str, + payload: T, + compaction: crate::InputCompactionUse, +) -> Result<(String, crate::SystemInputMetadata), crate::SystemInputError> { + let metadata = crate::SystemInputMetadata::new( + migration_policy(compaction), + Some(crate::FeatureInputPayload::encode(schema, 1, &payload)?), + Some(crate::PublicSystemItem { + kind: label.into(), + body: body.clone(), + presentation: crate::PublicSystemPresentation::Context, + }), + )?; + Ok((body, metadata)) +} + +/// Annotated old wire used by v3/v4, and by already-converted files when an +/// older marker survived an interrupted migration. No ordinary decoder uses it. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum LegacyAnnotatedLogEntry { + AnnotatedSystemItem { + ts: u64, + entry: LegacySystemHistoryEntry, + #[serde(default)] + extensions: Vec, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LegacySystemHistoryEntry { + item: LegacySystemItem, + metadata: LoggedSessionHistoryMetadata, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum LegacyAnnotatedOrCurrent { + Annotated(LegacyAnnotatedLogEntry), + Current(LogEntry), +} + +impl LegacyAnnotatedOrCurrent { + fn into_current(self) -> Result { + match self { + Self::Annotated(LegacyAnnotatedLogEntry::AnnotatedSystemItem { + ts, + entry, + extensions, + }) => Ok(LogEntry::AnnotatedSystemItem { + ts, + entry: entry.item.into_entry(entry.metadata)?, + extensions, + }), + Self::Current(entry) => Ok(entry), + } + } +} + #[derive(Debug, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] enum LegacyHistoryLogEntry { @@ -47,7 +253,9 @@ enum LegacyHistoryLogEntry { }, SystemItem { ts: u64, - item: SystemItem, + item: LegacySystemItem, + #[serde(default)] + extensions: Vec, }, } @@ -57,7 +265,7 @@ enum LegacyHistoryLogEntry { #[serde(untagged)] enum LegacySessionLogEntryV1 { History(LegacyHistoryLogEntry), - Current(LogEntry), + Current(LegacyAnnotatedOrCurrent), } /// Schema v2 retained the v1 history shapes while adding non-history records. @@ -67,7 +275,7 @@ enum LegacySessionLogEntryV1 { #[serde(untagged)] enum LegacySessionLogEntryV2 { History(LegacyHistoryLogEntry), - Current(LogEntry), + Current(LegacyAnnotatedOrCurrent), } pub(crate) fn decode_entry( @@ -86,15 +294,16 @@ pub(crate) fn decode_entry( LegacySessionLogEntryV2::History(entry) => Entry::History(entry), LegacySessionLogEntryV2::Current(entry) => Entry::Current(entry), }, - 3 => Entry::Current(serde_json::from_str(line)?), + 3 | 4 => Entry::Current(serde_json::from_str(line)?), _ => unreachable!("legacy decoder called for unsupported schema {schema_version}"), }; - Ok(match entry { + match entry { Entry::History(entry) => { canonicalize_history_entry(session_id, segment_id, line_index, entry) } - Entry::Current(entry) => entry, - }) + Entry::Current(entry) => entry.into_current(), + } + .map_err(serde::de::Error::custom) } /// Schema 3 used DerivedSummary for Host compaction seeds. Only the trusted @@ -152,9 +361,75 @@ pub(crate) fn migrate_compaction_origins( } } +/// Explicit v1-4 -> v5 boundary. No Feature inference belongs in ordinary +/// readers/restoration. Invalid or oversized payloads fail migration; the generic +/// payload/body/public limits are deliberately not relaxed for legacy data. +pub(crate) fn migrate_system_inputs(entry: &mut LogEntry) -> Result<(), crate::SystemInputError> { + entry.validate_system_inputs()?; + match entry { + // Feature wire has already been converted by the explicit old-schema + // decoder. Ordinary LogEntry can contain only generic Input/core events. + LogEntry::AnnotatedSystemItem { entry, .. } => entry.validate_system_input()?, + LogEntry::AnnotatedSegmentStart { history, .. } + | LogEntry::AnnotatedUserInput { history, .. } => { + for entry in history { + migrate_flattened_system_input(entry)?; + } + } + LogEntry::AnnotatedAssistantItem { entry, .. } + | LogEntry::AnnotatedToolResult { entry, .. } + | LogEntry::ToolResultCorrected { entry, .. } => { + migrate_flattened_system_input(entry)?; + } + _ => {} + } + entry.validate_system_inputs() +} + +fn migration_policy(compaction: crate::InputCompactionUse) -> crate::SystemInputUsePolicy { + crate::SystemInputUsePolicy { + recording_material: crate::InputEvidenceUse::Exclude, + memory_evidence: crate::InputEvidenceUse::Exclude, + observation: crate::InputObservationUse::Hidden, + compaction, + } +} + +fn migrate_flattened_system_input( + entry: &mut LoggedHistoryEntry, +) -> Result<(), crate::SystemInputError> { + // Check before supplying legacy policy: Provided without metadata is a + // malformed NEW record, not permission to synthesize a producer policy. + entry.validate_system_input()?; + if entry.metadata.system_input.is_none() + && matches!( + entry.item, + LoggedItem::Message { + role: crate::LoggedRole::System, + .. + } + ) + { + let compaction = match entry.metadata.origin { + LoggedSessionHistoryOrigin::JournalContext => crate::InputCompactionUse::ReferenceInput, + LoggedSessionHistoryOrigin::CompactionSummary => { + crate::InputCompactionUse::OpeningSummary + } + _ => crate::InputCompactionUse::Retain, + }; + // No Feature payload or public projection can be recovered from text. + entry.metadata.system_input = Some(crate::SystemInputMetadata::new( + migration_policy(compaction), + None, + None, + )?); + } + entry.validate_system_input() +} + enum Entry { History(LegacyHistoryLogEntry), - Current(LogEntry), + Current(LegacyAnnotatedOrCurrent), } fn legacy_metadata( @@ -182,8 +457,8 @@ fn canonicalize_history_entry( segment_id: SegmentId, line_index: usize, entry: LegacyHistoryLogEntry, -) -> LogEntry { - match entry { +) -> Result { + Ok(match entry { LegacyHistoryLogEntry::SegmentStart { ts, session_id, @@ -237,13 +512,14 @@ fn canonicalize_history_entry( metadata: legacy_metadata(segment_id, line_index, 0), }, }, - LegacyHistoryLogEntry::SystemItem { ts, item } => LogEntry::AnnotatedSystemItem { + LegacyHistoryLogEntry::SystemItem { ts, - entry: LoggedSystemHistoryEntry { - item, - metadata: legacy_metadata(segment_id, line_index, 0), - }, - extensions: Vec::new(), + item, + extensions, + } => LogEntry::AnnotatedSystemItem { + ts, + entry: item.into_entry(legacy_metadata(segment_id, line_index, 0))?, + extensions, }, - } + }) } diff --git a/crates/session-store/src/legacy_session_log/tests.rs b/crates/session-store/src/legacy_session_log/tests.rs index 314ee6d5f..d7914c194 100644 --- a/crates/session-store/src/legacy_session_log/tests.rs +++ b/crates/session-store/src/legacy_session_log/tests.rs @@ -1,5 +1,7 @@ use super::*; +mod v5; + fn seed( origin: LoggedSessionHistoryOrigin, compacted: bool, diff --git a/crates/session-store/src/legacy_session_log/tests/fixtures/v1-system-inputs.jsonl b/crates/session-store/src/legacy_session_log/tests/fixtures/v1-system-inputs.jsonl new file mode 100644 index 000000000..742ab5557 --- /dev/null +++ b/crates/session-store/src/legacy_session_log/tests/fixtures/v1-system-inputs.jsonl @@ -0,0 +1,3 @@ +{"kind":"system_item","ts":1,"item":{"kind":"skill_activation","name":"legacy-skill","body":"legacy skill body"}} +{"kind":"user_input","ts":2,"segments":[{"kind":"text","content":"legacy user body"}],"extensions":[{"domain":"old","payload":{"kept":true}}]} +{"kind":"assistant_item","ts":3,"item":{"kind":"message","role":"assistant","content":[{"kind":"text","text":"legacy reply"}]}} diff --git a/crates/session-store/src/legacy_session_log/tests/fixtures/v4-system-inputs.jsonl b/crates/session-store/src/legacy_session_log/tests/fixtures/v4-system-inputs.jsonl new file mode 100644 index 000000000..59a391d01 --- /dev/null +++ b/crates/session-store/src/legacy_session_log/tests/fixtures/v4-system-inputs.jsonl @@ -0,0 +1,8 @@ +{"kind":"annotated_system_item","ts":101,"entry":{"item":{"kind":"journal_startup","body":"journal body\nunchanged","snapshot":{"entries":[{"text":"private snapshot"}]},"snapshot_sha256":"snapshot-digest","prompt_provenance":{"workspace_id":"workspace","source_digest":"source","projection_digest":"projection","logical_name":"journal_startup"}},"metadata":{"entry_id":"journal-id","origin":{"kind":"legacy_unknown"},"derivation":{"sources":["source-id"]}}},"extensions":[{"domain":"migration-fixture","payload":{"kept":true}}]} +{"kind":"annotated_system_item","ts":102,"entry":{"item":{"kind":"resident_summary_refresh","body":"resident body"},"metadata":{"entry_id":"resident-id","origin":{"kind":"derived_summary"}}}} +{"kind":"annotated_system_item","ts":103,"entry":{"item":{"kind":"subject_behavior_refresh","body":"rendered subject body","behavior_md":"exact behavior","prompt_provenance":{"source_digest":"subject-source","projection_digest":"subject-projection","logical_name":"subject_behavior"}},"metadata":{"entry_id":"subject-id","origin":{"kind":"backend_instruction","operation_id":"op"}}}} +{"kind":"annotated_system_item","ts":104,"entry":{"item":{"kind":"skill_activation","body":"skill body","name":"example-skill"},"metadata":{"entry_id":"skill-id","origin":{"kind":"legacy_unknown"}}}} +{"kind":"annotated_system_item","ts":105,"entry":{"item":{"kind":"feature_invocation_result","body":"result body","result":{"invocation_id":"invoke-id","identity":"builtin:test/prepare","status":"outcome_unknown","message":"may have committed","context":null}},"metadata":{"entry_id":"result-id","origin":{"kind":"legacy_unknown"}}}} +{"kind":"annotated_user_input","ts":106,"segments":[],"history":[{"item":{"kind":"message","role":"system","content":[{"kind":"text","text":"[Journal] prefix is not Feature evidence"}]},"metadata":{"entry_id":"flat-journal","origin":{"kind":"journal_context"},"derivation":{"sources":["journal-id"]}}},{"item":{"kind":"message","role":"system","content":[{"kind":"text","text":"summary body"}]},"metadata":{"entry_id":"flat-summary","origin":{"kind":"compaction_summary"}}},{"item":{"kind":"message","role":"system","content":[{"kind":"text","text":"[Skill: example] never infer Feature"}]},"metadata":{"entry_id":"flat-other","origin":{"kind":"legacy_unknown"}}}]} +{"kind":"annotated_system_item","ts":107,"entry":{"item":{"kind":"notification","message":"raw message","body":"wrapped message"},"metadata":{"entry_id":"core-id","origin":{"kind":"legacy_unknown"}}}} +{"kind":"annotated_system_item","ts":108,"entry":{"item":{"kind":"input","body":"already generic body"},"metadata":{"entry_id":"generic-id","origin":{"kind":"provided","producer":"future-owner"},"derivation":{"sources":["source-id"]},"system_input":{"usage":{"recording_material":"include","memory_evidence":"exclude","observation":"readable","compaction":"retain"},"feature_payload":{"schema":"future:unknown","version":99,"value":{"opaque":"retained"}},"public":null}}}} diff --git a/crates/session-store/src/legacy_session_log/tests/v5.rs b/crates/session-store/src/legacy_session_log/tests/v5.rs new file mode 100644 index 000000000..f197eeec9 --- /dev/null +++ b/crates/session-store/src/legacy_session_log/tests/v5.rs @@ -0,0 +1,596 @@ +//! Fixed old wire bytes, not fixtures generated by the current serializer. +use super::*; +use crate::{FsStore, InputCompactionUse, Store, WorkerSessionStore}; +use serde_json::{Value, json}; +use std::{fs, path::Path}; + +const V4: &str = include_str!("fixtures/v4-system-inputs.jsonl"); +const V1: &str = include_str!("fixtures/v1-system-inputs.jsonl"); + +fn decode_fixture(version: u32, source: &str, segment: SegmentId) -> Vec { + source + .lines() + .enumerate() + .map(|(index, line)| decode_entry(version, line, SessionId::nil(), segment, index).unwrap()) + .collect() +} + +fn encode(entries: &[LogEntry]) -> Vec { + let mut bytes = Vec::new(); + for entry in entries { + serde_json::to_writer(&mut bytes, entry).unwrap(); + bytes.push(b'\n'); + } + bytes +} + +fn migrate(entries: &mut [LogEntry]) { + for entry in entries { + migrate_system_inputs(entry).unwrap(); + entry.validate_system_inputs().unwrap(); + } +} + +fn write_worker(root: &Path, version: u32, source: &[u8]) -> (SessionId, SegmentId) { + let session = SessionId::now_v7(); + let segment = SegmentId::from_u128(1); + fs::create_dir_all(root.join("segments")).unwrap(); + fs::write( + root.join("session.json"), + serde_json::to_vec(&json!({ + "schema_version": version, "session_id": session, + })) + .unwrap(), + ) + .unwrap(); + fs::write( + root.join("segments").join(format!("{segment}.jsonl")), + source, + ) + .unwrap(); + (session, segment) +} + +fn assert_marker(root: &Path, version: u32) { + let manifest: Value = + serde_json::from_slice(&fs::read(root.join("session.json")).unwrap()).unwrap(); + assert_eq!(manifest["schema_version"], version); +} + +#[test] +fn v4_fixed_wire_preserves_identity_body_payload_public_and_independent_policy() { + let mut entries = decode_fixture(4, V4, SegmentId::nil()); + let before = entries.clone(); + migrate(&mut entries); + let schemas = [ + "journal:startup", + "memory:resident-summary", + "subject:behavior", + "skill:activation", + "feature:invocation-result", + ]; + let old_wire: Vec = V4 + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + for index in 0..5 { + let LogEntry::AnnotatedSystemItem { + ts, + entry, + extensions, + } = &entries[index] + else { + panic!() + }; + let old = &old_wire[index]; + let old_item = &old["entry"]["item"]; + let old_metadata: LoggedSessionHistoryMetadata = + serde_json::from_value(old["entry"]["metadata"].clone()).unwrap(); + let old_ext: Vec = old + .get("extensions") + .map(|value| serde_json::from_value(value.clone()).unwrap()) + .unwrap_or_default(); + assert_eq!(*ts, old["ts"].as_u64().unwrap()); + assert_eq!(extensions, &old_ext); + assert!(matches!(entry.item, SystemItem::Input { .. })); + assert_eq!( + entry.item.history_text(), + old_item["body"].as_str().unwrap() + ); + assert_eq!(entry.metadata.entry_id, old_metadata.entry_id); + assert_eq!(entry.metadata.origin, old_metadata.origin); + assert_eq!(entry.metadata.derivation, old_metadata.derivation); + let metadata = entry.metadata.system_input.as_ref().unwrap(); + let public = metadata.public_item().unwrap(); + assert_eq!(public.kind, old_item["kind"].as_str().unwrap()); + assert_eq!(public.body, old_item["body"].as_str().unwrap()); + assert_eq!( + public.presentation, + crate::PublicSystemPresentation::Context + ); + assert_eq!( + metadata.usage(), + migration_policy(if index == 0 { + InputCompactionUse::ReferenceInput + } else { + InputCompactionUse::Retain + }) + ); + let payload = metadata.feature_payload().unwrap(); + assert_eq!(payload.schema(), schemas[index]); + assert_eq!(payload.version(), 1); + let value: Value = payload.decode(schemas[index], 1).unwrap(); + // Independently require each frozen owner's typed v1 payload contract. + match index { + 0 => { + let _: JournalStartupPayload = payload.decode(schemas[index], 1).unwrap(); + } + 1 => { + let _: ResidentSummaryPayload = payload.decode(schemas[index], 1).unwrap(); + } + 2 => { + let _: SubjectBehaviorPayload = payload.decode(schemas[index], 1).unwrap(); + } + 3 => { + let _: SkillActivationPayload = payload.decode(schemas[index], 1).unwrap(); + } + 4 => { + let _: InvocationResultPayload = payload.decode(schemas[index], 1).unwrap(); + } + _ => unreachable!(), + } + let mut expected = old_item.clone(); + expected.as_object_mut().unwrap().remove("kind"); + expected.as_object_mut().unwrap().remove("body"); + // Optional provenance is an explicit Option in the v1 payload contract. + if index == 1 { + expected["prompt_provenance"] = Value::Null; + } + if index == 4 { + let result: protocol::FeatureInvocationResult = + serde_json::from_value(old_item["result"].clone()).unwrap(); + let typed: InvocationResultPayload = payload.decode(schemas[index], 1).unwrap(); + assert_eq!(typed.result, result); + // Preserve the protocol's typed serialization contract, including + // its canonical representation of optional result context. + expected["result"] = serde_json::to_value(result).unwrap(); + } + assert_eq!(value, expected); + assert!(value.get("body").is_none()); + } + let LogEntry::AnnotatedUserInput { history, .. } = &entries[5] else { + panic!() + }; + let LogEntry::AnnotatedUserInput { history: old, .. } = &before[5] else { + panic!() + }; + for (index, compaction) in [ + InputCompactionUse::ReferenceInput, + InputCompactionUse::OpeningSummary, + InputCompactionUse::Retain, + ] + .into_iter() + .enumerate() + { + assert_eq!(history[index].item, old[index].item); + assert_eq!( + history[index].metadata.entry_id, + old[index].metadata.entry_id + ); + assert_eq!(history[index].metadata.origin, old[index].metadata.origin); + assert_eq!( + history[index].metadata.derivation, + old[index].metadata.derivation + ); + let metadata = history[index].metadata.system_input.as_ref().unwrap(); + assert_eq!(metadata.usage(), migration_policy(compaction)); + assert!(metadata.public_item().is_none()); + assert!(metadata.feature_payload().is_none()); + } + // Core notification is unchanged; existing generic Input and unknown valid + // owner payload are retained without decoding/reclassifying a Feature. + assert_eq!(encode(&entries[6..]), encode(&before[6..])); + let once = encode(&entries); + migrate(&mut entries); + assert_eq!(encode(&entries), once); +} + +#[test] +fn v1_through_v4_worker_migration_reopen_and_mixed_file_retry() { + for version in 1..=4 { + let source = if version <= 2 { V1 } else { V4 }; + let root = tempfile::tempdir().unwrap(); + let (session, segment) = write_worker(root.path(), version, source.as_bytes()); + let path = root + .path() + .join("segments") + .join(format!("{segment}.jsonl")); + // Simulate an interrupted publication: v5 records precede legacy records + // in one file, and a fully new file coexists with an untouched old file. + let mut converted = decode_fixture(version, source, segment); + migrate(&mut converted); + let mut mixed = encode(&converted[..1]); + mixed.extend_from_slice(source.split_once('\n').unwrap().1.as_bytes()); + fs::write(&path, mixed).unwrap(); + let new_path = root + .path() + .join("segments") + .join(format!("{}.jsonl", SegmentId::from_u128(2))); + let new_bytes = encode(&converted); + fs::write(&new_path, &new_bytes).unwrap(); + let old_path = root + .path() + .join("segments") + .join(format!("{}.jsonl", SegmentId::from_u128(3))); + fs::write(&old_path, source).unwrap(); + + let store = WorkerSessionStore::new(root.path()).unwrap(); + assert_marker(root.path(), 5); + let restored = store.read_all(session, segment).unwrap(); + assert_eq!(encode(&restored), new_bytes); + assert_eq!(fs::read(&new_path).unwrap(), new_bytes); + let once = fs::read(&path).unwrap(); + let old_once = fs::read(&old_path).unwrap(); + drop(store); + let reopened = WorkerSessionStore::new(root.path()).unwrap(); + assert_eq!(encode(&reopened.read_all(session, segment).unwrap()), once); + assert_eq!(fs::read(&path).unwrap(), once); + assert_eq!(fs::read(&old_path).unwrap(), old_once); + } +} + +#[test] +fn all_segments_preflight_keeps_old_bytes_on_decode_or_conversion_failure() { + let mut oversized: Value = serde_json::from_str(V4.lines().next().unwrap()).unwrap(); + oversized["entry"]["item"]["snapshot"] = + json!({"large": "x".repeat(crate::system_input::MAX_FEATURE_PAYLOAD_BYTES + 1)}); + let mut unknown: Value = serde_json::from_str(V4.lines().next().unwrap()).unwrap(); + unknown["entry"]["item"]["kind"] = json!("unsupported_future_feature"); + let mut malformed = unknown.clone(); + malformed["entry"]["item"] = json!({"kind":"journal_startup", "body":"missing snapshot"}); + let mut missing_metadata: Value = serde_json::from_str(V4.lines().last().unwrap()).unwrap(); + missing_metadata["entry"]["metadata"] + .as_object_mut() + .unwrap() + .remove("system_input"); + let mut missing_input = missing_metadata.clone(); + missing_input["entry"]["metadata"]["origin"] = json!({"kind":"legacy_unknown"}); + let mut invalid_policy: Value = serde_json::from_str(V4.lines().last().unwrap()).unwrap(); + invalid_policy["entry"]["metadata"]["system_input"]["usage"]["compaction"] = json!("unknown"); + let mut invalid_payload = invalid_policy.clone(); + invalid_payload["entry"]["metadata"]["system_input"]["usage"]["compaction"] = json!("retain"); + invalid_payload["entry"]["metadata"]["system_input"]["feature_payload"]["version"] = json!(0); + let mut flat_provided: Value = serde_json::from_str(V4.lines().nth(5).unwrap()).unwrap(); + flat_provided["history"][0]["metadata"]["origin"] = + json!({"kind":"provided", "producer":"owner"}); + for bad in [ + "{broken-json}\n".to_owned(), + format!("{}\n", oversized), + format!("{}\n", unknown), + format!("{}\n", malformed), + format!("{}\n", missing_metadata), + format!("{}\n", invalid_policy), + format!("{}\n", invalid_payload), + format!("{}\n", flat_provided), + format!("{}\n", missing_input), + ] { + let root = tempfile::tempdir().unwrap(); + let (_, segment) = write_worker(root.path(), 4, V4.as_bytes()); + let first = root + .path() + .join("segments") + .join(format!("{segment}.jsonl")); + let later = root + .path() + .join("segments") + .join(format!("{}.jsonl", SegmentId::from_u128(2))); + fs::write(&later, &bad).unwrap(); + let marker = fs::read(root.path().join("session.json")).unwrap(); + assert!(WorkerSessionStore::new(root.path()).is_err()); + assert_eq!(fs::read(&first).unwrap(), V4.as_bytes()); + assert_eq!(fs::read(&later).unwrap(), bad.as_bytes()); + assert_eq!(fs::read(root.path().join("session.json")).unwrap(), marker); + } +} + +#[test] +fn unsupported_schema_preserves_all_old_bytes() { + for version in [0, 6, 99] { + let root = tempfile::tempdir().unwrap(); + let (_, segment) = write_worker(root.path(), version, V4.as_bytes()); + let path = root + .path() + .join("segments") + .join(format!("{segment}.jsonl")); + let marker = fs::read(root.path().join("session.json")).unwrap(); + assert!(WorkerSessionStore::new(root.path()).is_err()); + assert_eq!(fs::read(&path).unwrap(), V4.as_bytes()); + assert_eq!(fs::read(root.path().join("session.json")).unwrap(), marker); + } +} + +#[test] +fn standalone_store_uses_same_v1_through_v4_migration_and_reopens() { + // None represents the existing unmarked annotated standalone layout (v3). + for version in [None, Some(1), Some(2), Some(3), Some(4)] { + let root = tempfile::tempdir().unwrap(); + let session = SessionId::now_v7(); + let segment = SegmentId::from_u128(1); + let dir = root.path().join(session.to_string()); + fs::create_dir_all(&dir).unwrap(); + let source = if version.is_some_and(|v| v <= 2) { + V1 + } else { + V4 + }; + let path = dir.join(format!("{segment}.jsonl")); + fs::write(&path, source).unwrap(); + if let Some(version) = version { + fs::write(dir.join("session-schema.json"), version.to_string()).unwrap(); + } + let store = FsStore::new(root.path()).unwrap(); + let entries = store.read_all(session, segment).unwrap(); + for entry in entries { + entry.validate_system_inputs().unwrap(); + } + assert_eq!( + fs::read_to_string(dir.join("session-schema.json")) + .unwrap() + .trim(), + "5" + ); + let once = fs::read(&path).unwrap(); + drop(store); + FsStore::new(root.path()).unwrap(); + assert_eq!(fs::read(&path).unwrap(), once); + } +} + +#[test] +fn compaction_origin_upgrade_is_retained_only_for_pre_v4_sources() { + for version in [3, 4] { + let root = tempfile::tempdir().unwrap(); + let creation = seed( + LoggedSessionHistoryOrigin::DerivedSummary, + true, + crate::LoggedRole::System, + "[Compacted context summary]\n\nold summary", + ); + let inherited = seed( + LoggedSessionHistoryOrigin::DerivedSummary, + false, + crate::LoggedRole::System, + "[Compacted context summary]\n\nold summary", + ); + let (session, segment) = + write_worker(root.path(), version, &encode(&[creation, inherited])); + let store = WorkerSessionStore::new(root.path()).unwrap(); + for record in store.read_all(session, segment).unwrap() { + let LogEntry::AnnotatedSegmentStart { history, .. } = record else { + panic!() + }; + let metadata = &history[0].metadata; + assert_eq!(metadata.entry_id.0, "summary"); + assert_eq!(metadata.derivation.as_ref().unwrap().sources[0].0, "source"); + assert_eq!( + metadata.origin, + if version == 3 { + LoggedSessionHistoryOrigin::CompactionSummary + } else { + LoggedSessionHistoryOrigin::DerivedSummary + } + ); + assert_eq!( + metadata.system_input.as_ref().unwrap().usage().compaction, + if version == 3 { + InputCompactionUse::OpeningSummary + } else { + InputCompactionUse::Retain + } + ); + } + } +} + +#[test] +fn standalone_preflight_and_unsupported_marker_leave_sources_unchanged() { + for version in [4, 99] { + let root = tempfile::tempdir().unwrap(); + let session = SessionId::now_v7(); + let dir = root.path().join(session.to_string()); + fs::create_dir_all(&dir).unwrap(); + let first = dir.join(format!("{}.jsonl", SegmentId::from_u128(1))); + let later = dir.join(format!("{}.jsonl", SegmentId::from_u128(2))); + fs::write(&first, V4).unwrap(); + fs::write(&later, "{broken-json}\n").unwrap(); + let marker = version.to_string(); + fs::write(dir.join("session-schema.json"), &marker).unwrap(); + assert!(FsStore::new(root.path()).is_err()); + assert_eq!(fs::read(&first).unwrap(), V4.as_bytes()); + assert_eq!(fs::read_to_string(&later).unwrap(), "{broken-json}\n"); + assert_eq!( + fs::read_to_string(dir.join("session-schema.json")).unwrap(), + marker + ); + } +} + +#[test] +fn current_v5_rejects_all_legacy_feature_kinds_without_rewriting_sources() { + for line in V4.lines().take(5) { + // Current serde, writable open and read-only restore share the same + // current wire contract. None may dispatch to the old Feature decoder. + assert!(serde_json::from_str::(line).is_err()); + let bytes = format!("{line}\n"); + let root = tempfile::tempdir().unwrap(); + let (session, segment) = write_worker(root.path(), 5, bytes.as_bytes()); + let path = root + .path() + .join("segments") + .join(format!("{segment}.jsonl")); + let marker = fs::read(root.path().join("session.json")).unwrap(); + let readonly = WorkerSessionStore::open_read_only(root.path()).unwrap(); + assert!(readonly.read_all_read_only(session, segment).is_err()); + assert!(WorkerSessionStore::new(root.path()).is_err()); + assert_eq!(fs::read(&path).unwrap(), bytes.as_bytes()); + assert_eq!(fs::read(root.path().join("session.json")).unwrap(), marker); + + let standalone = tempfile::tempdir().unwrap(); + let dir = standalone.path().join(session.to_string()); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{segment}.jsonl")); + fs::write(&path, &bytes).unwrap(); + fs::write(dir.join("session-schema.json"), "5").unwrap(); + assert!(FsStore::new(standalone.path()).is_err()); + assert_eq!(fs::read(&path).unwrap(), bytes.as_bytes()); + assert_eq!( + fs::read_to_string(dir.join("session-schema.json")).unwrap(), + "5" + ); + } +} + +#[test] +fn v1_v2_feature_wire_uses_the_same_typed_payloads_and_stable_legacy_identity() { + let segment = SegmentId::from_u128(42); + for version in [1, 2] { + for (index, line) in V4.lines().take(5).enumerate() { + let annotated: Value = serde_json::from_str(line).unwrap(); + let wire = json!({ + "kind": "system_item", "ts": annotated["ts"], + "item": annotated["entry"]["item"], + "extensions": [{"domain":"old-owner", "payload":{"kept":true}}], + }) + .to_string(); + let entry = decode_entry(version, &wire, SessionId::nil(), segment, index).unwrap(); + let retry = decode_entry(version, &wire, SessionId::nil(), segment, index).unwrap(); + assert_eq!(encode(&[entry.clone()]), encode(&[retry])); + let LogEntry::AnnotatedSystemItem { + ts, + entry, + extensions, + } = entry + else { + panic!() + }; + assert_eq!(ts, annotated["ts"].as_u64().unwrap()); + assert!(matches!(entry.item, SystemItem::Input { .. })); + assert_eq!( + entry.item.history_text(), + annotated["entry"]["item"]["body"].as_str().unwrap() + ); + assert_eq!( + entry.metadata.entry_id, + legacy_metadata(segment, index, 0).entry_id + ); + assert_eq!( + entry.metadata.origin, + LoggedSessionHistoryOrigin::LegacyUnknown + ); + assert!(entry.metadata.derivation.is_none()); + assert_eq!(extensions[0].domain, "old-owner"); + assert_eq!(extensions[0].payload, json!({"kept":true})); + let LogEntry::AnnotatedSystemItem { entry: v4, .. } = + decode_entry(4, line, SessionId::nil(), segment, index).unwrap() + else { + panic!() + }; + assert_eq!(entry.metadata.system_input, v4.metadata.system_input); + } + } +} + +#[test] +fn migration_rejects_malformed_typed_feature_fields_instead_of_retaining_unchecked_json() { + let mut items: Vec = V4 + .lines() + .take(5) + .map(|line| serde_json::from_str::(line).unwrap()["entry"]["item"].clone()) + .collect(); + items[0]["snapshot_sha256"] = json!(17); + items[1]["prompt_provenance"] = json!({"logical_name":"missing digests"}); + items[2]["behavior_md"] = json!(["not a string"]); + items[3]["name"] = json!({"not":"a name"}); + items[4]["result"]["status"] = json!("unsupported_status"); + for version in 1..=4 { + for item in &items { + let wire = if version <= 2 { + json!({"kind":"system_item", "ts":1, "item":item}) + } else { + json!({"kind":"annotated_system_item", "ts":1, "entry":{ + "item":item, "metadata":{ + "entry_id":"malformed-id", "origin":{"kind":"legacy_unknown"}, + }, + }}) + } + .to_string(); + assert!(decode_entry(version, &wire, SessionId::nil(), SegmentId::nil(), 0).is_err()); + } + } +} + +#[test] +fn frozen_behavior_counter_is_accepted_only_at_the_old_schema_boundary() { + for version in 1..=4 { + let item = json!({ + "kind":"subject_behavior_refresh", "body":"exact archived body", + "behavior_revision":7, + }); + let wire = if version <= 2 { + json!({"kind":"system_item", "ts":1, "item":item}) + } else { + json!({"kind":"annotated_system_item", "ts":1, "entry":{ + "item":item, "metadata":{ + "entry_id":"behavior-id", "origin":{"kind":"legacy_unknown"}, + }, + }}) + } + .to_string(); + assert!(serde_json::from_str::(&wire).is_err()); + let LogEntry::AnnotatedSystemItem { entry, .. } = + decode_entry(version, &wire, SessionId::nil(), SegmentId::nil(), 0).unwrap() + else { + panic!() + }; + assert_eq!(entry.item.history_text(), "exact archived body"); + let payload = entry + .metadata + .system_input + .as_ref() + .unwrap() + .feature_payload() + .unwrap(); + let typed: SubjectBehaviorPayload = payload.decode("subject:behavior", 1).unwrap(); + assert_eq!(typed.behavior_md, ""); + assert!(typed.prompt_provenance.is_none()); + assert_eq!( + serde_json::to_value(typed).unwrap(), + json!({ + "behavior_md":"", "prompt_provenance":null, + }) + ); + } +} + +#[test] +fn current_v5_rejects_generic_input_without_metadata_instead_of_migrating_it() { + let mut missing: Value = serde_json::from_str(V4.lines().last().unwrap()).unwrap(); + missing["entry"]["metadata"] + .as_object_mut() + .unwrap() + .remove("system_input"); + let bytes = format!("{}\n", missing); + let root = tempfile::tempdir().unwrap(); + let (_, segment) = write_worker(root.path(), 5, bytes.as_bytes()); + assert!(WorkerSessionStore::new(root.path()).is_err()); + assert_eq!( + fs::read( + root.path() + .join("segments") + .join(format!("{segment}.jsonl")) + ) + .unwrap(), + bytes.as_bytes() + ); + assert_marker(root.path(), 5); +} diff --git a/crates/session-store/src/public_material.rs b/crates/session-store/src/public_material.rs index f27795928..2c7d66293 100644 --- a/crates/session-store/src/public_material.rs +++ b/crates/session-store/src/public_material.rs @@ -157,8 +157,8 @@ pub enum PublicMaterialUse { /// A fixed material catalog. Opening a new catalog observes newly appended /// records; reading a body through this catalog verifies its saved digest and /// byte length again. Missing/corrected/deleted sources are explicit failures. -/// Explicit evidence permission applies to model text, not the private payload; -/// Web display and observation instead use the producer's public projection. +/// Evidence eligibility never publishes private model text or Feature payload: +/// System inputs also require an explicit public projection for every public use. pub struct SessionPublicMaterialReader { session_id: SessionId, active_segment_id: SegmentId, @@ -828,7 +828,7 @@ enum CanonicalBody<'a> { Message { origin: &'a LoggedSessionHistoryOrigin, role: LoggedRole, - content: &'a [crate::LoggedContentPart], + content: std::borrow::Cow<'a, [crate::LoggedContentPart]>, }, ToolCall { origin: &'a LoggedSessionHistoryOrigin, @@ -904,27 +904,22 @@ fn canonical_body( if allowed == crate::InputEvidenceUse::Exclude { return None; } + let public = input.public_item()?; if let LoggedItem::Message { role: LoggedRole::System, - content, + .. } = &entry.item { return Some(CanonicalBody::Message { origin: &entry.metadata.origin, role: LoggedRole::System, - content, + content: std::borrow::Cow::Owned(vec![crate::LoggedContentPart::Text { + text: public.body.clone(), + }]), }); } return None; } - if matches!( - entry.metadata.origin, - LoggedSessionHistoryOrigin::DerivedSummary - | LoggedSessionHistoryOrigin::CompactionSummary - | LoggedSessionHistoryOrigin::JournalContext - ) { - return None; - } match &entry.item { LoggedItem::Message { role: LoggedRole::System, @@ -935,7 +930,7 @@ fn canonical_body( LoggedItem::Message { role, content } => Some(CanonicalBody::Message { origin: &entry.metadata.origin, role: *role, - content, + content: std::borrow::Cow::Borrowed(content), }), LoggedItem::ToolCall { call_id, diff --git a/crates/session-store/src/public_material/tests.rs b/crates/session-store/src/public_material/tests.rs index 0376af0bc..5bf7ce7ea 100644 --- a/crates/session-store/src/public_material/tests.rs +++ b/crates/session-store/src/public_material/tests.rs @@ -25,6 +25,27 @@ fn message(id: &str, role: LoggedRole, text: &str) -> LoggedHistoryEntry { }, ) } +fn system_context( + id: &str, + text: &str, + compaction: crate::InputCompactionUse, +) -> LoggedHistoryEntry { + let mut entry = message(id, LoggedRole::System, text); + entry.metadata.system_input = Some( + crate::SystemInputMetadata::new( + crate::SystemInputUsePolicy { + recording_material: crate::InputEvidenceUse::Exclude, + memory_evidence: crate::InputEvidenceUse::Exclude, + observation: crate::InputObservationUse::Hidden, + compaction, + }, + None, + None, + ) + .unwrap(), + ); + entry +} fn seed(session: SessionId, history: Vec) -> LogEntry { LogEntry::AnnotatedSegmentStart { ts: 1, @@ -348,7 +369,11 @@ fn yield_resume_cancel_keep_original_boundary_identity_across_compaction() { .descriptors() .cloned() .collect(); - let mut summary = message("summary", LoggedRole::User, "must not replace originals"); + let mut summary = system_context( + "summary", + "must not replace originals", + crate::InputCompactionUse::OpeningSummary, + ); summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { sources: vec![ @@ -544,14 +569,18 @@ fn reasoning_proves_rollback_safety_but_private_and_derived_items_are_not_materi let store = FsStore::new(temp.path()).unwrap(); let session = crate::new_session_id(); let segment = crate::new_segment_id(); - let mut derived = message("derived", LoggedRole::User, "summary"); + let mut derived = system_context("derived", "summary", crate::InputCompactionUse::Retain); derived.metadata.origin = LoggedSessionHistoryOrigin::DerivedSummary; - let mut journal = message("journal", LoggedRole::User, "frozen Journal reference"); + let mut journal = system_context( + "journal", + "frozen Journal reference", + crate::InputCompactionUse::ReferenceInput, + ); journal.metadata.origin = LoggedSessionHistoryOrigin::JournalContext; - let mut summary = message( + let mut summary = system_context( "compaction-summary", - LoggedRole::User, "complementary summary", + crate::InputCompactionUse::OpeningSummary, ); summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; let reasoning = entry( @@ -580,19 +609,23 @@ fn reasoning_proves_rollback_safety_but_private_and_derived_items_are_not_materi LogEntry::AnnotatedSystemItem { ts: 1, entry: crate::LoggedSystemHistoryEntry { - item: crate::SystemItem::JournalStartup { + item: crate::SystemItem::Input { body: "previous Journal must not become new recording material".into(), - snapshot: serde_json::json!({"journals": []}), - snapshot_sha256: "retained-digest".into(), - prompt_provenance: crate::PromptRenderProvenance { - workspace_id: None, - source_digest: "source".into(), - projection_digest: "projection".into(), - logical_name: "internal.subjektiv_journal_startup".into(), - }, }, metadata: LoggedSessionHistoryMetadata { - system_input: None, + system_input: Some( + crate::SystemInputMetadata::new( + crate::SystemInputUsePolicy { + recording_material: crate::InputEvidenceUse::Exclude, + memory_evidence: crate::InputEvidenceUse::Exclude, + observation: crate::InputObservationUse::Hidden, + compaction: crate::InputCompactionUse::ReferenceInput, + }, + None, + None, + ) + .unwrap(), + ), entry_id: LoggedSessionHistoryEntryId("startup-journal".into()), origin: LoggedSessionHistoryOrigin::DerivedSummary, derivation: None, @@ -779,6 +812,19 @@ fn schema_v3_compaction_migrates_persistently_and_strict_reader_keeps_original_i panic!() }; summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; + summary.metadata.system_input = Some( + crate::SystemInputMetadata::new( + crate::SystemInputUsePolicy { + recording_material: crate::InputEvidenceUse::Exclude, + memory_evidence: crate::InputEvidenceUse::Exclude, + observation: crate::InputObservationUse::Hidden, + compaction: crate::InputCompactionUse::OpeningSummary, + }, + None, + None, + ) + .unwrap(), + ); assert_eq!(history, &[summary]); // Run-transition ids synthesized from byte offsets must be frozen before // the changed summary record shifts every subsequent record offset. @@ -827,7 +873,11 @@ fn midturn_compaction_uses_exact_source_ids_and_preserves_discarded_originals() ], ) .unwrap(); - let mut summary = message("summary", LoggedRole::User, "never use as original"); + let mut summary = system_context( + "summary", + "never use as original", + crate::InputCompactionUse::OpeningSummary, + ); summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { sources: vec![ @@ -1032,7 +1082,11 @@ fn zero_turn_fork_from_seed_and_consecutive_compactions_preserve_original_identi for index in 0..3 { let compact = crate::new_segment_id(); let summary_id = format!("summary-{index}"); - let mut summary = message(&summary_id, LoggedRole::User, "not original"); + let mut summary = system_context( + &summary_id, + "not original", + crate::InputCompactionUse::OpeningSummary, + ); summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { sources: source_ids @@ -1180,7 +1234,11 @@ fn correction_preserves_original_identity_across_compaction_without_summary_fall ], ) .unwrap(); - let mut summary = message("summary", LoggedRole::User, "not original"); + let mut summary = system_context( + "summary", + "not original", + crate::InputCompactionUse::OpeningSummary, + ); summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { sources: vec![LoggedSessionHistoryEntryId("result".into())], @@ -1228,7 +1286,11 @@ fn compaction_without_exact_derivation_or_with_invalid_turn_is_rejected() { ), ] { let segment = crate::new_segment_id(); - let mut summary = message("summary", LoggedRole::User, "not original"); + let mut summary = system_context( + "summary", + "not original", + crate::InputCompactionUse::OpeningSummary, + ); summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; summary.metadata.derivation = derivation; let mut start = seed(session, vec![summary]); @@ -1257,54 +1319,69 @@ fn generic_system_material_uses_explicit_evidence_purpose_not_producer_or_body() let session = crate::new_session_id(); let segment = crate::new_segment_id(); let fork = crate::new_segment_id(); - let make = |producer: &str, recording_material, memory_evidence, body: &str| { - LoggedSystemHistoryEntry::from_input( - LoggedSessionHistoryOrigin::Provided { - producer: producer.into(), - }, - None, - SystemInputEnvelope::new( - body, - SystemInputMetadata::new( - SystemInputUsePolicy { - recording_material, - memory_evidence, - observation: InputObservationUse::Hidden, - compaction: InputCompactionUse::Retain, - }, - Some( - FeatureInputPayload::encode( - "unknown:future", - 99, - &serde_json::json!({"private": "not-public"}), - ) - .unwrap(), - ), - None, + let make = + |producer: &str, recording_material, memory_evidence, body: &str, public: bool| { + LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: producer.into(), + }, + None, + SystemInputEnvelope::new( + format!("private model text: {body}"), + SystemInputMetadata::new( + SystemInputUsePolicy { + recording_material, + memory_evidence, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::Retain, + }, + Some( + FeatureInputPayload::encode( + "unknown:future", + 99, + &serde_json::json!({"private": "not-public"}), + ) + .unwrap(), + ), + public.then(|| crate::PublicSystemItem { + kind: "context".into(), + body: body.into(), + presentation: crate::PublicSystemPresentation::Context, + }), + ) + .unwrap(), ) .unwrap(), ) - .unwrap(), - ) - }; + }; let inputs = [ make( "same-owner", InputEvidenceUse::Include, InputEvidenceUse::Exclude, "do not extract this; original material", + true, ), make( "same-owner", InputEvidenceUse::Exclude, InputEvidenceUse::Include, "memory-only original", + true, ), make( "renamed-owner", InputEvidenceUse::Exclude, InputEvidenceUse::Exclude, "reference without warning text", + true, + ), + make( + "private-only", + InputEvidenceUse::Include, + InputEvidenceUse::Include, + "must not publish without projection", + false, ), ]; let root = if worker_layout { @@ -1403,6 +1480,7 @@ fn generic_system_material_uses_explicit_evidence_purpose_not_producer_or_body() assert_eq!(value["role"], "system"); assert_eq!(value["content"][0]["text"], expected_text); assert!(!body.contains("not-public")); + assert!(!body.contains("private model text")); assert!(!body.contains("unknown:future")); } } diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index 6d3d6ce43..c7bc6a3b9 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -86,23 +86,16 @@ pub struct PromptRenderProvenance { /// `notify_wrapper` template) re-shape the live rendering on a later /// resume. /// -/// New variants get added here as fresh injection kinds come online -/// (e.g. `TaskReminder`). The `kind` JSON tag is the snake_case form of -/// the variant name. +/// Feature-owned inputs use `Input` plus explicit annotated metadata; adding a +/// Feature must not extend this enum or ordinary replay. Only core events have +/// dedicated variants. Pre-v5 Feature kinds exist exclusively in the migration +/// decoder, not in this current wire contract. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SystemItem { /// Generic System body; private metadata and public presentation are supplied /// independently by the producer in the same annotated history record. Input { body: String }, - /// One-time derived Journal reference context. The bounded Host snapshot is - /// retained as JSON independently of its rendered body and prompt projection. - JournalStartup { - body: String, - snapshot: serde_json::Value, - snapshot_sha256: String, - prompt_provenance: PromptRenderProvenance, - }, /// Free-form notification sent in by an external caller via /// `Method::Notify`. `message` is the raw caller-supplied text; /// `body` is the wrapped LLM-context form (Worker renders it via @@ -125,14 +118,6 @@ pub enum SystemItem { prompt_provenance: Option, }, - /// Result of an explicitly selected chat Feature invocation. The typed - /// result is retained for clients; `body` is the exact bounded context made - /// visible to the Worker model. - FeatureInvocationResult { - result: protocol::FeatureInvocationResult, - body: String, - }, - /// `@` file reference resolution. `body` is the rendered /// LLM-context text (`[File: ]\n…` for regular files, /// `[Dir: ]\n…` for directory listings, possibly with a @@ -140,10 +125,6 @@ pub enum SystemItem { /// byte-identical to what was sent. FileAttachment { path: String, body: String }, - /// Explicit Agent Skill activation. `body` is the exact LLM-facing - /// Skill text committed before any subsequent LLM request can use it. - SkillActivation { name: String, body: String }, - /// Historical persisted Knowledge reference resolution. Knowledge is no /// longer active, so restored sessions ignore this item instead of replaying /// archived record text into model context. @@ -167,29 +148,6 @@ pub enum SystemItem { prompt_provenance: Option, }, - /// Restore-boundary refresh of the current resident Memory projection. - /// The exact rendered body is retained so replay is byte-identical; a - /// refresh with no ready summary explicitly supersedes older resident - /// context without deleting or rewriting history. - ResidentSummaryRefresh { - body: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - prompt_provenance: Option, - }, - - /// A Host-fetched change to user-managed Subject behavior. Each changed - /// body is appended rather than rewriting prior prompt/session content. - SubjectBehaviorRefresh { - body: String, - /// Exact user-managed behavior acknowledged after durable commit. Old - /// logs omit this value; an empty value is not an acknowledgement of a - /// nonempty behavior and does not replace their stored rendered body. - #[serde(default)] - behavior_md: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - prompt_provenance: Option, - }, - /// Synthetic note inserted after an interrupted turn before the next /// user input. `body` is the exact LLM-context text explaining that the /// previous turn was cut short. @@ -233,13 +191,9 @@ impl SystemItem { Self::Input { .. } | Self::LegacyKnowledgeIgnored { .. } | Self::LegacyIgnored { .. } => PublicSystemPresentation::Ignored, - Self::JournalStartup { .. } - | Self::ResidentSummaryRefresh { .. } - | Self::SubjectBehaviorRefresh { .. } - | Self::SkillActivation { .. } - | Self::FeatureInvocationResult { .. } - | Self::FileAttachment { .. } - | Self::Interrupt { .. } => PublicSystemPresentation::Context, + Self::FileAttachment { .. } | Self::Interrupt { .. } => { + PublicSystemPresentation::Context + } }; PublicSystemItem { kind: self.kind_label().to_owned(), @@ -257,19 +211,14 @@ impl SystemItem { pub fn history_text(&self) -> String { match self { SystemItem::Input { body } => body.clone(), - SystemItem::JournalStartup { body, .. } => body.clone(), SystemItem::Notification { body, .. } => body.clone(), SystemItem::WorkerEvent { body, .. } => body.clone(), - SystemItem::FeatureInvocationResult { body, .. } => body.clone(), SystemItem::FileAttachment { body, .. } => body.clone(), - SystemItem::SkillActivation { body, .. } => body.clone(), SystemItem::LegacyKnowledgeIgnored { .. } => String::new(), SystemItem::LegacyIgnored { slug } => { format!("Ignored legacy procedure item: /{slug}") } SystemItem::TaskReminder { body, .. } => body.clone(), - SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(), - SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(), SystemItem::Interrupt { body, .. } => body.clone(), } } @@ -285,17 +234,12 @@ impl SystemItem { pub fn kind_label(&self) -> &'static str { match self { SystemItem::Input { .. } => "input", - SystemItem::JournalStartup { .. } => "journal_startup", SystemItem::Notification { .. } => "notification", SystemItem::WorkerEvent { .. } => "worker_event", - SystemItem::FeatureInvocationResult { .. } => "feature_invocation_result", SystemItem::FileAttachment { .. } => "file_attachment", - SystemItem::SkillActivation { .. } => "skill_activation", SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored", SystemItem::LegacyIgnored { .. } => "legacy_ignored", SystemItem::TaskReminder { .. } => "task_reminder", - SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh", - SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh", SystemItem::Interrupt { .. } => "interrupt", } } @@ -353,18 +297,20 @@ mod tests { } #[test] - fn subject_behavior_refresh_round_trips_with_exact_body() { - let item = SystemItem::SubjectBehaviorRefresh { - body: "current Subject context".to_string(), - behavior_md: "exact behavior".to_string(), - prompt_provenance: None, - }; - let raw = serde_json::to_string(&item).unwrap(); - let parsed: SystemItem = serde_json::from_str(&raw).unwrap(); - assert_eq!(parsed.history_text(), "current Subject context"); - assert!( - matches!(parsed, SystemItem::SubjectBehaviorRefresh { behavior_md, .. } if behavior_md == "exact behavior") - ); + fn current_system_item_rejects_frozen_feature_kinds() { + // Even well-formed old payloads cannot enter normal v5 decode/replay. + for line in include_str!("legacy_session_log/tests/fixtures/v4-system-inputs.jsonl") + .lines() + .take(5) + { + let record: serde_json::Value = serde_json::from_str(line).unwrap(); + assert!(serde_json::from_value::(record["entry"]["item"].clone()).is_err()); + assert!( + serde_json::from_value::(record["entry"].clone()) + .is_err() + ); + assert!(serde_json::from_str::(line).is_err()); + } } #[test] @@ -392,21 +338,6 @@ mod tests { assert!(serde_json::from_value::(unknown).is_err()); } - #[test] - fn frozen_subject_behavior_log_reads_old_counter_without_republishing_it() { - let old = r#"{"kind":"subject_behavior_refresh","body":"exact archived body","behavior_revision":7}"#; - let item: SystemItem = serde_json::from_str(old).unwrap(); - assert_eq!(item.history_text(), "exact archived body"); - assert_eq!( - serde_json::to_value(item).unwrap(), - serde_json::json!({ - "kind": "subject_behavior_refresh", - "body": "exact archived body", - "behavior_md": "" - }) - ); - } - #[test] fn notification_history_text_returns_stored_body() { let item = SystemItem::Notification { @@ -500,29 +431,6 @@ mod tests { } } - #[test] - fn invocation_result_retains_typed_outcome_and_exact_context() { - let result = protocol::FeatureInvocationResult { - invocation_id: "stable-invoke".into(), - identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()), - status: protocol::FeatureInvocationStatus::OutcomeUnknown, - message: "operation may have committed".into(), - context: None, - }; - let body = "Outcome unknown; do not retry automatically"; - let item = SystemItem::FeatureInvocationResult { - result: result.clone(), - body: body.into(), - }; - let restored: SystemItem = - serde_json::from_str(&serde_json::to_string(&item).unwrap()).unwrap(); - assert_eq!(restored.history_text(), body); - assert_eq!(restored.kind_label(), "feature_invocation_result"); - assert!( - matches!(restored, SystemItem::FeatureInvocationResult { result: saved, .. } if saved == result) - ); - } - #[test] fn round_trip_worker_event() { let item = SystemItem::WorkerEvent { diff --git a/crates/session-store/src/worker_session_store.rs b/crates/session-store/src/worker_session_store.rs index bb6ffe34f..17a2691d1 100644 --- a/crates/session-store/src/worker_session_store.rs +++ b/crates/session-store/src/worker_session_store.rs @@ -36,7 +36,8 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; -const SESSION_SCHEMA_VERSION: u32 = 4; +const SESSION_SCHEMA_VERSION: u32 = 5; +const COMPACTION_SESSION_SCHEMA_VERSION: u32 = 4; const ANNOTATED_SESSION_SCHEMA_VERSION: u32 = 3; const PREVIOUS_SESSION_SCHEMA_VERSION: u32 = 2; const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1; @@ -204,7 +205,15 @@ impl WorkerSessionStore { /// Open the Session store rooted at `/session`. pub fn new(root: impl Into) -> Result { - let root = root.into(); + Self::new_with_migration_writer(root.into(), atomic_write_bytes) + } + + // Keep fault injection at the storage publication boundary, without global + // flags or a public alternate restore API. Production always uses atomic writes. + fn new_with_migration_writer( + root: PathBuf, + write_segment: impl FnMut(&Path, &[u8]) -> Result<(), StoreError>, + ) -> Result { fs::create_dir_all(root.join(SEGMENTS_DIR))?; let session_id = match fs::read(root.join(SESSION_FILE)) { Ok(bytes) => { @@ -215,11 +224,13 @@ impl WorkerSessionStore { } LEGACY_SESSION_SCHEMA_VERSION | PREVIOUS_SESSION_SCHEMA_VERSION - | ANNOTATED_SESSION_SCHEMA_VERSION => { - migrate_segment_logs_to_v4( + | ANNOTATED_SESSION_SCHEMA_VERSION + | COMPACTION_SESSION_SCHEMA_VERSION => { + migrate_segment_logs_to_v5( segment_log_paths(&root)?, manifest.session_id, manifest.schema_version, + write_segment, )?; manifest.schema_version = SESSION_SCHEMA_VERSION; atomic_write_json(&root.join(SESSION_FILE), &manifest)?; @@ -1162,10 +1173,11 @@ fn segment_log_paths_in_directory( Ok(paths) } -fn migrate_segment_logs_to_v4( +fn migrate_segment_logs_to_v5( paths: Vec<(SegmentId, PathBuf)>, session_id: SessionId, source_schema_version: u32, + mut write_segment: impl FnMut(&Path, &[u8]) -> Result<(), StoreError>, ) -> Result<(), StoreError> { struct MigrationPlan { path: PathBuf, @@ -1196,12 +1208,19 @@ fn migrate_segment_logs_to_v4( } // Resolve trusted creation boundaries first, then update inherited copies by // logical identity too (fork/rewind seeds retain the same entry id). - let summaries = crate::legacy_session_log::compaction_summary_ids( - plans.iter().flat_map(|plan| plan.entries.iter()), - ); + let summaries = if source_schema_version < COMPACTION_SESSION_SCHEMA_VERSION { + crate::legacy_session_log::compaction_summary_ids( + plans.iter().flat_map(|plan| plan.entries.iter()), + ) + } else { + // v4 already made the explicit origin migration. Preserve its origins. + Default::default() + }; for plan in &mut plans { crate::legacy_session_log::migrate_compaction_origins(&mut plan.entries, &summaries); - for entry in std::mem::take(&mut plan.entries) { + for mut entry in std::mem::take(&mut plan.entries) { + crate::legacy_session_log::migrate_system_inputs(&mut entry)?; + entry.validate_system_inputs()?; serde_json::to_writer(&mut plan.output, &entry)?; plan.output.push(b'\n'); } @@ -1223,7 +1242,7 @@ fn migrate_segment_logs_to_v4( } for plan in plans { - atomic_write_bytes(&plan.path, &plan.output)?; + write_segment(&plan.path, &plan.output)?; } Ok(()) } @@ -1240,9 +1259,19 @@ pub(crate) fn migrate_fs_session(root: &Path, session_id: SessionId) -> Result<( Err(error) => return Err(error.into()), }; match version { - SESSION_SCHEMA_VERSION => Ok(()), - ANNOTATED_SESSION_SCHEMA_VERSION => { - migrate_segment_logs_to_v4(segment_log_paths_in_directory(root)?, session_id, version)?; + SESSION_SCHEMA_VERSION => { + validate_canonical_segment_paths(segment_log_paths_in_directory(root)?) + } + LEGACY_SESSION_SCHEMA_VERSION + | PREVIOUS_SESSION_SCHEMA_VERSION + | ANNOTATED_SESSION_SCHEMA_VERSION + | COMPACTION_SESSION_SCHEMA_VERSION => { + migrate_segment_logs_to_v5( + segment_log_paths_in_directory(root)?, + session_id, + version, + atomic_write_bytes, + )?; atomic_write_json(&marker, &SESSION_SCHEMA_VERSION) } _ => Err(StoreError::Corrupt { @@ -1253,8 +1282,15 @@ pub(crate) fn migrate_fs_session(root: &Path, session_id: SessionId) -> Result<( } fn validate_canonical_segment_logs(root: &Path) -> Result<(), StoreError> { - for (_, path) in segment_log_paths(root)? { - let _: Vec = parse_jsonl(&fs::read(&path)?)?; + validate_canonical_segment_paths(segment_log_paths(root)?) +} + +fn validate_canonical_segment_paths(paths: Vec<(SegmentId, PathBuf)>) -> Result<(), StoreError> { + for (_, path) in paths { + let entries: Vec = parse_jsonl(&fs::read(&path)?)?; + for entry in entries { + entry.validate_system_inputs()?; + } } Ok(()) } @@ -2022,6 +2058,146 @@ mod tests { ); } + #[test] + fn v5_migration_retries_after_second_file_publication_fails() { + fn identity_and_body(entries: &[LogEntry]) -> Vec { + entries + .iter() + .flat_map(|record| match record { + LogEntry::AnnotatedSystemItem { entry, .. } => vec![entry.to_history_entry()], + LogEntry::AnnotatedUserInput { history, .. } => history.clone(), + _ => panic!("unexpected record in fixed migration fixture"), + }) + .map(|entry| { + serde_json::json!({ + "entry_id": entry.metadata.entry_id, + "origin": entry.metadata.origin, + "derivation": entry.metadata.derivation, + "item": entry.item, + }) + }) + .collect() + } + + const SOURCE: &str = + include_str!("legacy_session_log/tests/fixtures/v4-system-inputs.jsonl"); + let root = tempfile::tempdir().unwrap(); + let session_id = new_session_id(); + let first_id = SegmentId::from_u128(1); + let second_id = SegmentId::from_u128(2); + fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap(); + let marker_path = root.path().join(SESSION_FILE); + atomic_write_json( + &marker_path, + &SessionManifest { + schema_version: COMPACTION_SESSION_SCHEMA_VERSION, + session_id, + }, + ) + .unwrap(); + let old_marker = fs::read(&marker_path).unwrap(); + let first_path = root + .path() + .join(SEGMENTS_DIR) + .join(format!("{first_id}.jsonl")); + let second_path = root + .path() + .join(SEGMENTS_DIR) + .join(format!("{second_id}.jsonl")); + let second_source = SOURCE.replace("-id", "-second-id"); + fs::write(&first_path, SOURCE).unwrap(); + fs::write(&second_path, &second_source).unwrap(); + let old_first = fs::read(&first_path).unwrap(); + let old_second = fs::read(&second_path).unwrap(); + let first_identity = + identity_and_body(&parse_legacy_jsonl(4, session_id, first_id, &old_first).unwrap()); + let second_identity = + identity_and_body(&parse_legacy_jsonl(4, session_id, second_id, &old_second).unwrap()); + + let mut writes = 0; + let mut first_published = None; + let failure = WorkerSessionStore::new_with_migration_writer( + root.path().to_path_buf(), + |path, bytes| { + writes += 1; + match writes { + 1 => { + assert_eq!(path, first_path.as_path()); + // Both files are still old when the first publication starts. + assert_eq!(fs::read(&first_path).unwrap(), old_first); + assert_eq!(fs::read(&second_path).unwrap(), old_second); + atomic_write_bytes(path, bytes)?; + first_published = Some(bytes.to_vec()); + Ok(()) + } + 2 => { + assert_eq!(path, second_path.as_path()); + assert_eq!( + fs::read(&first_path).unwrap(), + *first_published.as_ref().unwrap() + ); + assert_eq!(fs::read(&second_path).unwrap(), old_second); + // Fail only this atomic-write boundary, after complete + // preflight and a real successful first-file publication. + Err(std::io::Error::other("injected second migration write failure").into()) + } + _ => panic!("publication continued after its write failure"), + } + }, + ); + let error = match failure { + Ok(_) => panic!("the injected publication failure must reject store open"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("injected second migration write failure") + ); + assert_eq!(writes, 2); + let first_published = first_published.unwrap(); + assert_ne!(first_published, old_first); + assert_eq!(fs::read(&first_path).unwrap(), first_published); + assert_eq!(fs::read(&second_path).unwrap(), old_second); + assert_eq!(fs::read(&marker_path).unwrap(), old_marker); + let updated_first: Vec = parse_jsonl(&first_published).unwrap(); + for entry in &updated_first { + entry.validate_system_inputs().unwrap(); + } + assert!(matches!( + &updated_first[0], + LogEntry::AnnotatedSystemItem { + entry: crate::LoggedSystemHistoryEntry { + item: crate::SystemItem::Input { .. }, + .. + }, + .. + } + )); + assert_eq!(identity_and_body(&updated_first), first_identity); + + // Retry via the public production constructor, without any injection or + // hand-built mixed fixture. The old marker drives the same migration. + let store = WorkerSessionStore::new(root.path()).unwrap(); + let first = store.read_all(session_id, first_id).unwrap(); + let second = store.read_all(session_id, second_id).unwrap(); + assert_eq!(identity_and_body(&first), first_identity); + assert_eq!(identity_and_body(&second), second_identity); + for entry in first.iter().chain(second.iter()) { + entry.validate_system_inputs().unwrap(); + } + assert_eq!(fs::read(&first_path).unwrap(), first_published); + let second_published = fs::read(&second_path).unwrap(); + assert_ne!(second_published, old_second); + let manifest: SessionManifest = + serde_json::from_slice(&fs::read(&marker_path).unwrap()).unwrap(); + assert_eq!(manifest.schema_version, SESSION_SCHEMA_VERSION); + drop(store); + WorkerSessionStore::new(root.path()).unwrap(); + assert_eq!(fs::read(&first_path).unwrap(), first_published); + assert_eq!(fs::read(&second_path).unwrap(), second_published); + } + #[test] fn current_jsonl_requires_annotations_across_append_rewrite_and_reopen() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/standalone/src/subjektiv/journal/tests/handoff.rs b/crates/standalone/src/subjektiv/journal/tests/handoff.rs index 4b571a313..03e5b6ebf 100644 --- a/crates/standalone/src/subjektiv/journal/tests/handoff.rs +++ b/crates/standalone/src/subjektiv/journal/tests/handoff.rs @@ -79,7 +79,7 @@ async fn recorded_idle_body_handoff_preserves_open_after_source_deletion_and_see let sessions = session_store::FsStore::new_with_retention(workers.sessions_dir(second.worker_id()), crate::store::session_retention(&state)).unwrap(); let pointer = session_store::FsWorkerStore::new(workers.worker_metadata_dir(second.worker_id())).unwrap().read_by_name(&second.record().storage_key).unwrap().unwrap().active.unwrap(); let entries = sessions.read_all(session, pointer.segment_id.unwrap()).unwrap(); - let journal_items = entries.iter().filter(|entry| matches!(entry, session_store::LogEntry::AnnotatedSystemItem { entry, .. } if matches!(entry.item, session_store::SystemItem::JournalStartup { .. }))).count(); + let journal_items = entries.iter().filter(|entry| matches!(entry, session_store::LogEntry::AnnotatedSystemItem { entry, .. } if entry.metadata.system_input.as_ref().and_then(|input| input.feature_payload()).is_some_and(|payload| payload.schema() == "journal:startup"))).count(); assert_eq!(journal_items, 1); assert_eq!(catalog.store.subject(&subject.id).unwrap().unwrap().behavior_md, "BEHAVIOR_ORIGINAL_UNCHANGED"); second.shutdown().await.unwrap(); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index d8be6da1a..f9d7f240f 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1656,10 +1656,10 @@ where if ordinary_subjektiv_features_enabled && lifecycle_enabled && let Some(host) = subjektiv_host.as_ref() - && host.host.journal_startup_granted() + && (host.host.journal_startup_granted() || host.host.journal_compaction_granted()) { - worker.install_initial_context_source(Arc::new( - crate::feature::builtin::journal::JournalStartupContextSource::new( + worker.install_session_context_source(Arc::new( + crate::feature::builtin::journal::JournalContextSource::new( host.clone(), worker.prompts(), ), diff --git a/crates/worker/src/feature/builtin/journal.rs b/crates/worker/src/feature/builtin/journal.rs index 990cd347a..15d2bb2b4 100644 --- a/crates/worker/src/feature/builtin/journal.rs +++ b/crates/worker/src/feature/builtin/journal.rs @@ -23,7 +23,7 @@ use crate::hook::{ use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use async_trait::async_trait; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::sync::Arc; use subjektiv::journal::transport::*; @@ -32,12 +32,73 @@ use subjektiv::journal::{ MAX_JOURNAL_RESPONSE_BYTES, }; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct JournalStartupInput { + pub snapshot: subjektiv::journal::startup::JournalStartupSnapshot, + pub snapshot_sha256: String, + pub prompt_provenance: session_store::PromptRenderProvenance, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum JournalReadStatus { + Available, + Absent, + Disabled, + Unavailable, +} +impl JournalReadStatus { + fn as_str(self) -> &'static str { + match self { + Self::Available => "available", + Self::Absent => "absent", + Self::Disabled => "disabled", + Self::Unavailable => "unavailable", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct JournalCompactionInput { + status: JournalReadStatus, + snapshot: Option, + snapshot_sha256: Option, + prompt_provenance: session_store::PromptRenderProvenance, +} + +fn journal_input( + body: String, + payload: session_store::FeatureInputPayload, + kind: &str, +) -> Result { + use session_store::*; + let public = PublicSystemItem { + kind: kind.into(), + body: body.clone(), + presentation: PublicSystemPresentation::Context, + }; + let metadata = SystemInputMetadata::new( + SystemInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::ReferenceInput, + }, + Some(payload), + Some(public), + ) + .map_err(|e| e.to_string())?; + SystemInputEnvelope::new(body, metadata).map_err(|e| e.to_string()) +} + /// Subjektiv owns startup projection; Core only commits its typed initial items. -pub(crate) struct JournalStartupContextSource { +pub(crate) struct JournalContextSource { host: SubjektivHostConnection, prompts: Arc>, } -impl JournalStartupContextSource { +impl JournalContextSource { pub(crate) fn new( host: SubjektivHostConnection, prompts: Arc>, @@ -46,9 +107,12 @@ impl JournalStartupContextSource { } } #[async_trait] -impl crate::worker::InitialContextSource for JournalStartupContextSource { +impl crate::worker::SessionContextSource for JournalContextSource { async fn load(&self) -> Result, String> { use sha2::{Digest, Sha256}; + if !self.host.host.journal_startup_granted() { + return Ok(Vec::new()); + } let snapshot = self .host .journal_startup_context() @@ -58,8 +122,8 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource { let empty = snapshot.journals.is_empty() && !snapshot.omitted; // Hash the same canonical JSON value retained by SystemItem, so replay // can verify provenance without depending on domain struct field order. - let snapshot = serde_json::to_value(snapshot).map_err(|e| e.to_string())?; - let snapshot_json = serde_json::to_string(&snapshot).map_err(|e| e.to_string())?; + let snapshot_value = serde_json::to_value(&snapshot).map_err(|e| e.to_string())?; + let snapshot_json = serde_json::to_string(&snapshot_value).map_err(|e| e.to_string())?; if snapshot_json.len() > subjektiv::journal::startup::STARTUP_MAX_BYTES { return Err("Host Journal startup snapshot exceeds independent byte budget".into()); } @@ -74,9 +138,10 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource { return Err("Journal startup prompt exceeds rendered byte budget".into()); } let projection = prompts.projection(); - Ok(vec![session_store::LoggedSystemHistoryEntry { - item: session_store::SystemItem::JournalStartup { - body, + let payload = session_store::FeatureInputPayload::encode( + "journal:startup", + 1, + &JournalStartupInput { snapshot, snapshot_sha256: Sha256::digest(snapshot_json.as_bytes()) .iter() @@ -89,11 +154,38 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource { logical_name: "internal.subjektiv_journal_startup".into(), }, }, - metadata: crate::session_history::metadata( - session_store::LoggedSessionHistoryOrigin::JournalContext, - None, - ), - }]) + ) + .map_err(|e| e.to_string())?; + Ok(vec![session_store::LoggedSystemHistoryEntry::from_input( + session_store::LoggedSessionHistoryOrigin::Provided { + producer: "subjektiv.journal".into(), + }, + None, + journal_input(body, payload, "journal_startup")?, + )]) + } + + async fn load_compaction_context( + &self, + session: session_store::SessionId, + ) -> Result, String> { + // A changed Session must never reuse the old source's Host ownership. + let connection = if self.host.context.session_id == session.to_string() { + Ok(Some(self.host.clone())) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "context source belongs to a different Session", + )) + }; + let input = freeze_compaction_journal(connection, &self.prompts.load_full())?; + Ok(vec![session_store::LoggedSystemHistoryEntry::from_input( + session_store::LoggedSessionHistoryOrigin::Provided { + producer: "subjektiv.journal".into(), + }, + None, + input, + )]) } } @@ -103,7 +195,8 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource { pub(crate) fn freeze_compaction_journal( connection: std::io::Result>, prompts: &crate::prompt::catalog::PromptCatalog, -) -> Result { +) -> Result { + use JournalReadStatus::*; let workspace_id = connection.as_ref().ok().and_then(|connection| { connection .as_ref() @@ -116,24 +209,25 @@ pub(crate) fn freeze_compaction_journal( Ok(_) => None, Err(_) => Some(Err(())), }; + let snapshot = read.as_ref().and_then(|read| read.as_ref().ok()).cloned(); let (status, json) = match read { - None => ("disabled", String::new()), + None => (Disabled, String::new()), Some(Ok(snapshot)) => { let status = if snapshot.journals.is_empty() { - "absent" + Absent } else { - "available" + Available }; match serde_json::to_string(&snapshot) { Ok(json) if json.len() <= subjektiv::journal::startup::STARTUP_MAX_BYTES => { (status, json) } - _ => ("unavailable", String::new()), + _ => (Unavailable, String::new()), } } - Some(Err(())) => ("unavailable", String::new()), + Some(Err(())) => (Unavailable, String::new()), }; - if status == "unavailable" { + if status == Unavailable { tracing::warn!("Journal compaction snapshot unavailable; continuing without Journal"); } use sha2::{Digest, Sha256}; @@ -143,12 +237,35 @@ pub(crate) fn freeze_compaction_journal( .map(|byte| format!("{byte:02x}")) .collect() }); - prompts.subjektiv_journal_compaction( - status, - &json, - snapshot_sha256.as_deref(), - workspace_id.as_deref(), + let body = prompts + .subjektiv_journal_compaction( + status.as_str(), + &json, + snapshot_sha256.as_deref(), + workspace_id.as_deref(), + ) + .map_err(|e| e.to_string())?; + let payload = session_store::FeatureInputPayload::encode( + "journal:compaction", + 1, + &JournalCompactionInput { + status, + prompt_provenance: session_store::PromptRenderProvenance { + workspace_id, + source_digest: prompts.projection().source_digest.clone(), + projection_digest: prompts.projection().catalog_digest.clone(), + logical_name: "internal.subjektiv_journal_compaction".into(), + }, + snapshot: if status == Unavailable { + None + } else { + snapshot + }, + snapshot_sha256, + }, ) + .map_err(|e| e.to_string())?; + journal_input(body, payload, "journal_compaction") } #[derive(Clone)] diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index 32155ea04..f6f5869e6 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -43,6 +43,80 @@ use crate::worker::{ WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, }; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SubjectBehaviorInput { + pub behavior_md: String, + pub prompt_provenance: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ResidentSummaryInput { + prompt_provenance: Option, +} + +fn resident_input( + body: String, + schema: &str, + payload: &impl serde::Serialize, + kind: &str, +) -> Result { + use session_store::*; + let input = SystemInputEnvelope::new( + body.clone(), + SystemInputMetadata::new( + SystemInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::Retain, + }, + Some(FeatureInputPayload::encode(schema, 1, payload)?), + Some(PublicSystemItem { + kind: kind.into(), + body, + presentation: PublicSystemPresentation::Context, + }), + )?, + )?; + Ok(LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: "subjektiv.resident-context".into(), + }, + None, + input, + )) +} + +pub(crate) fn subject_behavior_input( + body: String, + behavior_md: String, + prompt_provenance: Option, +) -> Result { + resident_input( + body, + "subject:behavior", + &SubjectBehaviorInput { + behavior_md, + prompt_provenance, + }, + "subject_behavior_refresh", + ) +} + +pub(crate) fn resident_summary_input( + body: String, + prompt_provenance: Option, +) -> Result { + resident_input( + body, + "memory:resident-summary", + &ResidentSummaryInput { prompt_provenance }, + "resident_summary_refresh", + ) +} + #[derive(Clone, Debug)] pub struct WorkspaceHttpMemoryBackend { client: Arc, @@ -714,7 +788,7 @@ impl WorkspaceSubjektivResidentSummarySource { fn render( &self, output: &server_api::SubjektivResidentContextOutput, - ) -> Result { + ) -> Result<(String, session_store::PromptRenderProvenance), String> { use memory::backend::MemoryResidentSummaryAvailability as Availability; let availability = match output.memory_surface.availability { Availability::Ready => "ready", @@ -723,14 +797,24 @@ impl WorkspaceSubjektivResidentSummarySource { Availability::Failed => "failed", Availability::Unavailable => "unavailable", }; - self.prompts - .load() + let prompts = self.prompts.load_full(); + let body = prompts .subjektiv_resident_context( &output.behavior_md, availability, output.memory_surface.content.as_deref(), ) - .map_err(|error| error.to_string()) + .map_err(|error| error.to_string())?; + let projection = prompts.projection(); + Ok(( + body, + session_store::PromptRenderProvenance { + workspace_id: self.host.host.workspace_id(), + source_digest: projection.source_digest.clone(), + projection_digest: projection.catalog_digest.clone(), + logical_name: "internal.subjektiv_resident_context".into(), + }, + )) } fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) { @@ -752,7 +836,7 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource async fn load(&self) -> SystemPromptContribution { match self.fetch().await { Ok(output) => match self.render(&output) { - Ok(rendered) => { + Ok((rendered, _)) => { self.remember_behavior(&output); SystemPromptContribution::Ready(rendered) } @@ -788,20 +872,30 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource if !changed { return Ok(None); } - let rendered = self.render(&output)?; + let (rendered, prompt_provenance) = self.render(&output)?; Ok(Some(ResidentContextRefresh { body: rendered, behavior_md: output.behavior_md, + prompt_provenance: Some(prompt_provenance), })) } fn system_item_committed( &self, - item: &session_store::SystemItem, - _metadata: &crate::session_history::SessionHistoryMetadata, + _item: &session_store::SystemItem, + metadata: &crate::session_history::SessionHistoryMetadata, ) { - if let session_store::SystemItem::SubjectBehaviorRefresh { behavior_md, .. } = item { - self.confirm_resident_context_behavior(behavior_md); + if let Some(input) = metadata + .system_input + .as_ref() + .and_then(|input| input.feature_payload()) + .and_then(|payload| { + payload + .decode::("subject:behavior", 1) + .ok() + }) + { + self.confirm_resident_context_behavior(&input.behavior_md); } } @@ -954,7 +1048,13 @@ impl Hook for SubjektivBehaviorRefreshHook { "Subject behavior refresh requires durable session append authority", ) })?; - system_items.append_subject_behavior_refresh(refresh.body, refresh.behavior_md); + let input = subject_behavior_input( + refresh.body, + refresh.behavior_md, + refresh.prompt_provenance, + ) + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?; + system_items.append_input(input); } Ok(HookPreRequestAction::Continue) } @@ -979,16 +1079,18 @@ impl Hook for SubjektivResidentRestoreRefreshHook { .resident_memory_restore_section(surface_ready, summary) .map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?; let projection = prompts.projection(); - context.system_items().append_resident_summary_refresh( + let input = resident_summary_input( body, - session_store::PromptRenderProvenance { + Some(session_store::PromptRenderProvenance { workspace_id: self.workspace_id.clone(), source_digest: projection.source_digest.clone(), projection_digest: projection.catalog_digest.clone(), logical_name: WorkerPrompt::ResidentMemoryRestoreSection.key().to_string(), - }, - ); + }), + ) + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?; + context.system_items().append_input(input); Ok(()) } } @@ -1751,14 +1853,13 @@ permission = "write" .expect("changed behavior must produce one refresh"); assert_eq!(refresh.behavior_md, "Ask when uncertain."); assert!(refresh.body.contains("Ask when uncertain.")); - source.system_item_committed( - &session_store::SystemItem::SubjectBehaviorRefresh { - body: refresh.body.clone(), - behavior_md: refresh.behavior_md.clone(), - prompt_provenance: None, - }, - &session_store::LoggedSessionHistoryMetadata::legacy_unknown(), - ); + let input = subject_behavior_input( + refresh.body.clone(), + refresh.behavior_md.clone(), + refresh.prompt_provenance.clone(), + ) + .unwrap(); + source.system_item_committed(&input.item, &input.metadata); assert!( source .load_changed_resident_context() @@ -1775,14 +1876,13 @@ permission = "write" .expect("cleared behavior must produce one refresh"); assert_eq!(cleared.behavior_md, ""); assert!(cleared.body.contains("No user-managed behavior is set")); - source.system_item_committed( - &session_store::SystemItem::SubjectBehaviorRefresh { - body: cleared.body.clone(), - behavior_md: cleared.behavior_md.clone(), - prompt_provenance: None, - }, - &session_store::LoggedSessionHistoryMetadata::legacy_unknown(), - ); + let input = subject_behavior_input( + cleared.body.clone(), + cleared.behavior_md.clone(), + cleared.prompt_provenance.clone(), + ) + .unwrap(); + source.system_item_committed(&input.item, &input.metadata); assert!( source .load_changed_resident_context() @@ -1840,14 +1940,13 @@ permission = "write" .await .unwrap() .unwrap(); - source.system_item_committed( - &session_store::SystemItem::SubjectBehaviorRefresh { - body: first.body.clone(), - behavior_md: first.behavior_md.clone(), - prompt_provenance: None, - }, - &session_store::LoggedSessionHistoryMetadata::legacy_unknown(), - ); + let input = subject_behavior_input( + first.body.clone(), + first.behavior_md.clone(), + first.prompt_provenance.clone(), + ) + .unwrap(); + source.system_item_committed(&input.item, &input.metadata); assert_eq!( source .load_changed_resident_context() @@ -1856,14 +1955,13 @@ permission = "write" .unwrap(), second ); - source.system_item_committed( - &session_store::SystemItem::SubjectBehaviorRefresh { - body: second.body.clone(), - behavior_md: second.behavior_md.clone(), - prompt_provenance: None, - }, - &session_store::LoggedSessionHistoryMetadata::legacy_unknown(), - ); + let input = subject_behavior_input( + second.body.clone(), + second.behavior_md.clone(), + second.prompt_provenance.clone(), + ) + .unwrap(); + source.system_item_committed(&input.item, &input.metadata); assert!( source .load_changed_resident_context() diff --git a/crates/worker/src/feature/builtin/task/mod.rs b/crates/worker/src/feature/builtin/task/mod.rs index e45b1b703..0d82bb437 100644 --- a/crates/worker/src/feature/builtin/task/mod.rs +++ b/crates/worker/src/feature/builtin/task/mod.rs @@ -276,7 +276,9 @@ mod tests { use super::*; use crate::hook::{PreRequestInfo, SystemItemAppendHandle}; - fn pre_request_context(pending: Arc>>) -> PreRequestContext { + fn pre_request_context( + pending: Arc>>, + ) -> PreRequestContext { PreRequestContext::new( PreRequestInfo { item_count: 1, @@ -315,7 +317,7 @@ mod tests { let queued = pending.lock().expect("pending queue poisoned"); assert_eq!(queued.len(), 1); - let SystemItem::TaskReminder { body, .. } = &queued[0] else { + let SystemItem::TaskReminder { body, .. } = &queued[0].item else { panic!("unexpected system item: {:?}", queued[0]); }; assert!(body.starts_with("Current session steps are listed below.")); @@ -339,7 +341,7 @@ mod tests { } let queued = pending.lock().expect("pending queue poisoned"); - let SystemItem::TaskReminder { source, body, .. } = &queued[0] else { + let SystemItem::TaskReminder { source, body, .. } = &queued[0].item else { panic!("unexpected system item: {:?}", queued[0]); }; assert_eq!(*source, SystemReminderSource::TaskInactivity); @@ -452,7 +454,7 @@ mod tests { } let queued = pending.lock().expect("pending queue poisoned"); assert_eq!(queued.len(), 1); - let SystemItem::TaskReminder { body, .. } = &queued[0] else { + let SystemItem::TaskReminder { body, .. } = &queued[0].item else { panic!("unexpected system item: {:?}", queued[0]); }; assert!(body.contains("taskid 1 (completed) done")); diff --git a/crates/worker/src/feature/invocation.rs b/crates/worker/src/feature/invocation.rs index de8bc6fbe..91009461e 100644 --- a/crates/worker/src/feature/invocation.rs +++ b/crates/worker/src/feature/invocation.rs @@ -39,6 +39,45 @@ const MAX_INVOCATION_DESCRIPTOR_BYTES: usize = protocol::invocation::MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES; const MAX_COMPLETION_RESPONSE_BYTES: usize = 64 * 1024; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct InvocationResultInput { + pub result: FeatureInvocationResult, +} + +pub(crate) fn result_input( + result: FeatureInvocationResult, + body: String, +) -> Result { + use session_store::*; + let payload = FeatureInputPayload::encode( + "feature:invocation-result", + 1, + &InvocationResultInput { result }, + )?; + let metadata = SystemInputMetadata::new( + SystemInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::Retain, + }, + Some(payload), + Some(PublicSystemItem { + kind: "feature_invocation_result".into(), + body: body.clone(), + presentation: PublicSystemPresentation::Context, + }), + )?; + Ok(LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: "feature.invocation".into(), + }, + None, + SystemInputEnvelope::new(body, metadata)?, + )) +} + pub(crate) fn invocation_result_is_bounded(result: &FeatureInvocationResult) -> bool { result.message.len() <= MAX_INVOCATION_MESSAGE_BYTES && result diff --git a/crates/worker/src/hook.rs b/crates/worker/src/hook.rs index a433bb7e3..c480471dc 100644 --- a/crates/worker/src/hook.rs +++ b/crates/worker/src/hook.rs @@ -25,7 +25,9 @@ use agen::tool::{ToolOutput, ToolResult}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; -use session_store::{SystemItem, SystemReminder}; +#[cfg(test)] +use session_store::SystemItem; +use session_store::SystemReminder; use thiserror::Error; use crate::SessionEntryRef; @@ -254,7 +256,7 @@ impl From for TurnEndAction { // Hook context handles // ============================================================================= -/// Host-created handle for appending approved durable [`SystemItem`] requests. +/// Host-created handle for appending approved durable [`session_store::SystemItem`] requests. /// /// Hook code can use this handle only when the Worker host includes it in an /// event-specific context. The handle queues typed requests; the host drains the @@ -263,11 +265,11 @@ impl From for TurnEndAction { /// raw `agen::Item`, history writer, event sender, `Worker`, `Engine`, or /// notification buffer. pub struct SystemItemAppendHandle { - pending: Arc>>, + pending: Arc>>, } impl SystemItemAppendHandle { - pub(crate) fn new(pending: Arc>>) -> Self { + pub(crate) fn new(pending: Arc>>) -> Self { Self { pending } } @@ -279,53 +281,44 @@ impl SystemItemAppendHandle { self.pending .lock() .expect("system-item append queue poisoned") - .push(item); + .push(session_store::LoggedSystemHistoryEntry { + item, + metadata: crate::session_history::metadata( + session_store::LoggedSessionHistoryOrigin::BackendInstruction { + operation_id: None, + }, + None, + ), + }); } - /// Queue a Subject behavior refresh with its exact represented behavior. The caller receives this - /// narrow operation only at a pre-request boundary, and the host commits it - /// before exposing it to the model. - pub fn append_subject_behavior_refresh( - &self, - body: impl Into, - behavior_md: impl Into, - ) { + /// Queue producer-validated System context. Metadata and body are committed together. + /// The Host exposes this capability only at a pre-request boundary. + pub fn append_input(&self, input: session_store::LoggedSystemHistoryEntry) { self.pending .lock() .expect("system-item append queue poisoned") - .push(SystemItem::SubjectBehaviorRefresh { - body: body.into(), - behavior_md: behavior_md.into(), - prompt_provenance: None, - }); + .push(input); } } /// Host-created narrow append authority exposed only at a restore lifecycle -/// boundary. It cannot emit arbitrary system items. +/// boundary. No model-facing tool receives this capability. pub struct RestoreSystemItemAppendHandle { - pending: Arc>>, + pending: Arc>>, } impl RestoreSystemItemAppendHandle { - pub(crate) fn new(pending: Arc>>) -> Self { + pub(crate) fn new(pending: Arc>>) -> Self { Self { pending } } - /// Queue a Feature-rendered resident-summary refresh for durable append at - /// the restore lifecycle boundary. - pub fn append_resident_summary_refresh( - &self, - body: impl Into, - prompt_provenance: session_store::PromptRenderProvenance, - ) { + /// Queue producer-validated System context at the Host restore boundary. + pub fn append_input(&self, input: session_store::LoggedSystemHistoryEntry) { self.pending .lock() .expect("restore system-item append queue poisoned") - .push(SystemItem::ResidentSummaryRefresh { - body: body.into(), - prompt_provenance: Some(prompt_provenance), - }); + .push(input); } } @@ -983,7 +976,7 @@ mod tests { let queued = pending.lock().expect("pending queue poisoned"); assert_eq!(queued.len(), 1); - match &queued[0] { + match &queued[0].item { SystemItem::TaskReminder { body, .. } => { assert_eq!(body, "remember tasks"); } @@ -992,7 +985,7 @@ mod tests { } #[test] - fn restore_append_handle_queues_only_resident_refresh_items() { + fn restore_append_handle_preserves_producer_metadata() { let pending = Arc::new(Mutex::new(Vec::new())); let handle = RestoreSystemItemAppendHandle::new(Arc::clone(&pending)); let provenance = session_store::PromptRenderProvenance { @@ -1003,15 +996,16 @@ mod tests { logical_name: "internal.resident_memory_restore_section".into(), }; - handle.append_resident_summary_refresh("current surface", provenance.clone()); - - assert!(matches!( - pending.lock().unwrap().as_slice(), - [SystemItem::ResidentSummaryRefresh { - body, - prompt_provenance: Some(actual), - }] if body == "current surface" && actual == &provenance - )); + let input = crate::feature::builtin::memory::resident_summary_input( + "current surface".into(), + Some(provenance), + ) + .unwrap(); + let expected = serde_json::to_value(&input).unwrap(); + handle.append_input(input); + let queued = pending.lock().unwrap(); + assert_eq!(queued.len(), 1); + assert_eq!(serde_json::to_value(&queued[0]).unwrap(), expected); } #[test] diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index cbb500796..1e91f1797 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -262,6 +262,31 @@ impl WorkerInterceptor { ) } + fn commit_system_entries( + &self, + entries: &[session_store::LoggedSystemHistoryEntry], + ) -> Result<(), session_store::StoreError> { + let Some(writer) = &self.log_writer else { + return Ok(()); + }; + for entry in entries { + writer.commit_annotated_system_item_then( + entry.clone(), + Vec::new(), + &mut |history| { + self.pending_committed_history + .lock() + .expect("pending committed history poisoned") + .push_back(history.clone()); + if let Some(source) = &self.resident_context_source { + source.system_item_committed(&entry.item, &history.annotation); + } + }, + )?; + } + Ok(()) + } + fn current_turn_index(&self) -> usize { self.next_turn_index .load(Ordering::Relaxed) @@ -387,11 +412,6 @@ impl WorkerInterceptor { } if prompt_provenance.is_none() => { *prompt_provenance = Some(provenance("internal.task_reminder")); } - SystemItem::SubjectBehaviorRefresh { - prompt_provenance, .. - } if prompt_provenance.is_none() => { - *prompt_provenance = Some(provenance("internal.subjektiv_resident_context")); - } SystemItem::Interrupt { prompt_provenance, .. } if prompt_provenance.is_none() => { @@ -617,15 +637,17 @@ impl Interceptor for WorkerInterceptor { return Ok(PreRequestAction::Yield); } - let mut system_items: Vec = std::mem::take( + let mut system_items: Vec = std::mem::take( &mut *pending_hook_system_items .lock() .expect("pending hook system-item queue poisoned"), ); - self.attach_prompt_provenance(&mut system_items); + for entry in &mut system_items { + self.attach_prompt_provenance(std::slice::from_mut(&mut entry.item)); + } let mut appended_items: Vec = system_items .iter() - .map(SystemItem::to_history_item) + .map(|entry| entry.item.to_history_item()) .collect(); let effective_context = if appended_items.is_empty() { Cow::Borrowed(context.as_slice()) @@ -639,7 +661,7 @@ impl Interceptor for WorkerInterceptor { let compact_decision = self.request_compact_decision(current_tokens, effective_context.as_ref()); if !matches!(compact_decision, AutomaticCompactDecision::Continue) { - if let Err(error) = self.commit_system_items(&system_items) { + if let Err(error) = self.commit_system_entries(&system_items) { return Ok(PreRequestAction::Cancel(format!( "session persistence failed: {error}" ))); @@ -683,7 +705,7 @@ impl Interceptor for WorkerInterceptor { if appended_items.is_empty() { return Ok(PreRequestAction::Continue); } - Ok(match self.commit_system_items(&system_items) { + Ok(match self.commit_system_entries(&system_items) { Ok(()) => PreRequestAction::ContinueWith(appended_items), Err(error) => PreRequestAction::Cancel(format!("session persistence failed: {error}")), }) @@ -983,7 +1005,14 @@ mod tests { input .system_items() .expect("test interceptor has durable append authority") - .append_subject_behavior_refresh("current Subject context", "Ask when uncertain."); + .append_input( + crate::feature::builtin::memory::subject_behavior_input( + "current Subject context".into(), + "Ask when uncertain.".into(), + None, + ) + .unwrap(), + ); Ok(HookPreRequestAction::Continue) } } @@ -1000,11 +1029,23 @@ mod tests { fn system_item_committed( &self, - item: &SystemItem, - _metadata: &crate::session_history::SessionHistoryMetadata, + _item: &SystemItem, + metadata: &crate::session_history::SessionHistoryMetadata, ) { - if let SystemItem::SubjectBehaviorRefresh { behavior_md, .. } = item { - self.confirmed.lock().unwrap().push(behavior_md.to_owned()); + if let Some(input) = metadata + .system_input + .as_ref() + .and_then(|input| input.feature_payload()) + .and_then(|payload| { + payload + .decode::( + "subject:behavior", + 1, + ) + .ok() + }) + { + self.confirmed.lock().unwrap().push(input.behavior_md); } } } @@ -1549,11 +1590,7 @@ mod tests { assert_eq!(*confirmed.lock().unwrap(), ["Ask when uncertain."]); assert!(matches!( committed.lock().unwrap().as_slice(), - [SystemItem::SubjectBehaviorRefresh { - behavior_md, - prompt_provenance: Some(_), - .. - }] if behavior_md == "Ask when uncertain." + [SystemItem::Input { body }] if body == "current Subject context" )); } @@ -1654,12 +1691,15 @@ mod tests { })), ) .with_resident_context_source(Some(source)); - let items = ["raw behavior", ""].map(|behavior_md| SystemItem::SubjectBehaviorRefresh { - body: "same rendered context".into(), - behavior_md: behavior_md.into(), - prompt_provenance: None, + let items = ["raw behavior", ""].map(|behavior_md| { + crate::feature::builtin::memory::subject_behavior_input( + "same rendered context".into(), + behavior_md.into(), + None, + ) + .unwrap() }); - interceptor.commit_system_items(&items).unwrap(); + interceptor.commit_system_entries(&items).unwrap(); assert_eq!(*confirmed.lock().unwrap(), ["raw behavior", ""]); assert_eq!(committed.lock().unwrap().len(), 2); } diff --git a/crates/worker/src/ipc/protocol_session.rs b/crates/worker/src/ipc/protocol_session.rs index 038770284..8fc4a5d97 100644 --- a/crates/worker/src/ipc/protocol_session.rs +++ b/crates/worker/src/ipc/protocol_session.rs @@ -340,22 +340,13 @@ mod tests { #[test] fn system_item_log_entry_keeps_identity_in_live_event() { let entry_id = session_store::LoggedSessionHistoryEntryId::new(); + let mut input = + crate::feature::builtin::memory::resident_summary_input("refresh".into(), None) + .unwrap(); + input.metadata.entry_id = entry_id.clone(); let event = live_log_entry_event(LogEntry::AnnotatedSystemItem { ts: session_store::segment_log::now_millis(), - entry: session_store::LoggedSystemHistoryEntry { - item: session_store::SystemItem::ResidentSummaryRefresh { - body: "refresh".to_string(), - prompt_provenance: None, - }, - metadata: session_store::LoggedSessionHistoryMetadata { - system_input: None, - entry_id: entry_id.clone(), - origin: session_store::LoggedSessionHistoryOrigin::BackendInstruction { - operation_id: None, - }, - derivation: None, - }, - }, + entry: input, extensions: Vec::new(), }) .expect("SystemItem must be live-relevant"); @@ -381,25 +372,22 @@ mod tests { logical_name: "private-template".into(), }; let items = [ - session_store::SystemItem::SubjectBehaviorRefresh { - body: "displayed context".into(), - behavior_md: "private-raw-behavior".into(), - prompt_provenance: Some(provenance.clone()), - }, - session_store::SystemItem::JournalStartup { - body: "displayed reference".into(), - snapshot: serde_json::json!({ "internal": "private-snapshot" }), - snapshot_sha256: "private-snapshot-digest".into(), - prompt_provenance: provenance, - }, + crate::feature::builtin::memory::subject_behavior_input( + "displayed context".into(), + "private-raw-behavior".into(), + Some(provenance.clone()), + ) + .unwrap(), + crate::feature::builtin::memory::resident_summary_input( + "displayed reference".into(), + Some(provenance), + ) + .unwrap(), ]; - for item in items { + for input in items { let entry = LogEntry::AnnotatedSystemItem { ts: 7, - entry: session_store::LoggedSystemHistoryEntry { - item, - metadata: session_store::LoggedSessionHistoryMetadata::legacy_unknown(), - }, + entry: input, extensions: vec![], }; let persisted: LogEntry = diff --git a/crates/worker/src/session_history.rs b/crates/worker/src/session_history.rs index 232a8c92d..472d40eb6 100644 --- a/crates/worker/src/session_history.rs +++ b/crates/worker/src/session_history.rs @@ -67,6 +67,16 @@ pub(crate) fn metadata( } } +pub(crate) fn compaction_use( + metadata: &SessionHistoryMetadata, +) -> session_store::InputCompactionUse { + metadata + .system_input + .as_ref() + .map(|input| input.usage().compaction) + .unwrap_or(session_store::InputCompactionUse::Retain) +} + #[cfg(test)] pub(crate) fn history_entry( item: Item, @@ -134,22 +144,10 @@ pub(crate) fn restore_history_entries( } *target = from_logged(entry); } - LogEntry::AnnotatedSystemItem { entry, .. } => { - history.push(HistoryEntry::new(entry.item.to_history_item(), { - let mut metadata = entry.metadata.clone(); - // Old typed startup records used generic placeholder origins. - // Preserve explicitly supplied metadata on current contributions. - if matches!( - metadata.origin, - WorkerHistoryProvenance::DerivedSummary - | WorkerHistoryProvenance::LegacyUnknown - ) && matches!(entry.item, session_store::SystemItem::JournalStartup { .. }) - { - metadata.origin = WorkerHistoryProvenance::JournalContext; - } - metadata - })) - } + LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new( + entry.item.to_history_item(), + entry.metadata.clone(), + )), _ => {} } } diff --git a/crates/worker/src/skill.rs b/crates/worker/src/skill.rs index f9b15bf88..4629e03a8 100644 --- a/crates/worker/src/skill.rs +++ b/crates/worker/src/skill.rs @@ -8,6 +8,50 @@ pub use server_api::{ SkillProjectionStatus, SkillProvenance, SkillResourceRef, SkillSourceKind, }; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SkillActivationInput { + pub name: String, +} + +pub(crate) fn activation_input( + activation: &SkillActivationResponse, +) -> Result { + use session_store::*; + let body = format!( + "Agent Skill `{}` activated from {}.\n\n{}", + activation.name, activation.provenance.id, activation.body + ); + let payload = FeatureInputPayload::encode( + "skill:activation", + 1, + &SkillActivationInput { + name: activation.name.clone(), + }, + )?; + let metadata = SystemInputMetadata::new( + SystemInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::Retain, + }, + Some(payload), + Some(PublicSystemItem { + kind: "skill_activation".into(), + body: body.clone(), + presentation: PublicSystemPresentation::Context, + }), + )?; + Ok(LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: "skill.activation".into(), + }, + None, + SystemInputEnvelope::new(body, metadata)?, + )) +} + #[derive(Debug, thiserror::Error)] pub enum SkillClientError { #[error("workspace client is unavailable: {0}")] diff --git a/crates/worker/src/subjektiv.rs b/crates/worker/src/subjektiv.rs index 40d4836c6..eb871f992 100644 --- a/crates/worker/src/subjektiv.rs +++ b/crates/worker/src/subjektiv.rs @@ -26,6 +26,30 @@ use server_api::*; use crate::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceServerOperation}; +impl + crate::Worker +{ + /// Bind live Host capability and its context provider, not authority inherited + /// from a previous binding. Ordinary Feature/lifecycle gates also apply to embedders. + pub fn bind_subjektiv_host(&mut self, host: Option>) { + self.set_subjektiv_host(host); + if self.manifest_lifecycle_features_enabled() + && self.backend_job().is_none() + && crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(self.manifest()) + && let Ok(Some(connection)) = self.subjektiv_host_connection() + && (connection.host.journal_startup_granted() + || connection.host.journal_compaction_granted()) + { + self.install_session_context_source(Arc::new( + crate::feature::builtin::journal::JournalContextSource::new( + connection, + self.prompts(), + ), + )); + } + } +} + /// Host-issued execution context, never deserialized from model arguments. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SubjektivHostContext { @@ -1130,7 +1154,12 @@ enabled = true let initial = store.read_all(session, segment).unwrap(); let journal = initial.iter().find_map(|entry| match entry { LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::JournalStartup { .. }) => + if entry + .metadata + .system_input + .as_ref() + .and_then(|input| input.feature_payload()) + .is_some_and(|payload| payload.schema() == "journal:startup") => { Some(entry) } @@ -1140,22 +1169,33 @@ enabled = true let body = journal.map(|journal| { assert_eq!( journal.metadata.origin, - LoggedSessionHistoryOrigin::JournalContext + LoggedSessionHistoryOrigin::Provided { + producer: "subjektiv.journal".into() + } ); - let SystemItem::JournalStartup { - body, - snapshot: saved, - snapshot_sha256, - prompt_provenance, - } = &journal.item - else { + let SystemItem::Input { body } = &journal.item else { unreachable!() }; - assert_eq!(*saved, serde_json::to_value(&snapshot).unwrap()); + let payload = journal + .metadata + .system_input + .as_ref() + .unwrap() + .feature_payload() + .unwrap() + .decode::( + "journal:startup", + 1, + ) + .unwrap(); + let saved = serde_json::to_value(&payload.snapshot).unwrap(); + let snapshot_sha256 = &payload.snapshot_sha256; + let prompt_provenance = &payload.prompt_provenance; + assert_eq!(saved, serde_json::to_value(&snapshot).unwrap()); use sha2::{Digest, Sha256}; assert_eq!( *snapshot_sha256, - Sha256::digest(serde_json::to_vec(saved).unwrap()) + Sha256::digest(serde_json::to_vec(&saved).unwrap()) .iter() .map(|byte| format!("{byte:02x}")) .collect::() @@ -1168,7 +1208,7 @@ enabled = true body.trim_end(), format!( "## Prior Journal context\n\n```json\n{}\n```", - serde_json::to_string(saved).unwrap() + serde_json::to_string(&saved).unwrap() ) ); body.as_str() @@ -1237,7 +1277,7 @@ enabled = true } assert_eq!(host.startup_calls.load(Ordering::SeqCst), 1); let logs = store.read_all(session, segment).unwrap(); - assert_eq!(logs.iter().filter(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if matches!(entry.item, SystemItem::JournalStartup { .. }))).count(), usize::from(contributes_context)); + assert_eq!(logs.iter().filter(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if entry.metadata.system_input.as_ref().and_then(|input| input.feature_payload()).is_some_and(|payload| payload.schema() == "journal:startup"))).count(), usize::from(contributes_context)); controller .handle .send(Method::Shutdown { diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index fdde548d2..805ffb044 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -2757,11 +2757,25 @@ pub trait SystemItemCommitter: Send + Sync { .unwrap_or(WorkerHistoryProvenance::BackendInstruction { operation_id: None }), None, ); - let history = HistoryEntry::new(item.to_history_item(), metadata.clone()); + self.commit_annotated_system_item_then( + session_store::LoggedSystemHistoryEntry { item, metadata }, + extensions, + after_commit, + ) + } + + fn commit_annotated_system_item_then( + &self, + entry: session_store::LoggedSystemHistoryEntry, + extensions: Vec, + after_commit: &mut dyn FnMut(&HistoryEntry), + ) -> Result, StoreError> { + entry.validate_system_input()?; + let history = HistoryEntry::new(entry.item.to_history_item(), entry.metadata.clone()); self.commit_usage_boundary( LogEntry::AnnotatedSystemItem { ts: segment_log::now_millis(), - entry: session_store::LoggedSystemHistoryEntry { item, metadata }, + entry, extensions, }, &mut || after_commit(&history), @@ -2934,14 +2948,21 @@ pub(crate) enum SystemPromptContribution { pub(crate) struct ResidentContextRefresh { pub body: String, pub behavior_md: String, + pub prompt_provenance: Option, } -/// Initial-only System messages and Feature-owned metadata. Core persists and -/// replays each pair unchanged; it does not infer provenance from the payload. -/// Prepared with the head, never at restore/request hooks. +/// Feature-owned System context at explicit creation and compaction boundaries. +/// Core persists and replays each pair unchanged; normal restore never reloads it. #[async_trait::async_trait] -pub(crate) trait InitialContextSource: Send + Sync { +pub(crate) trait SessionContextSource: Send + Sync { async fn load(&self) -> Result, String>; + + async fn load_compaction_context( + &self, + _session: SessionId, + ) -> Result, String> { + Ok(Vec::new()) + } } #[async_trait::async_trait] @@ -3132,7 +3153,7 @@ pub struct Worker { inject_resident_summary: bool, /// Deferred resident prompt source installed by an enabled Feature. feature_resident_summary_source: Option>, - initial_context_source: Option>, + session_context_source: Option>, /// Exact complete seed retained across ambiguous Store responses. The committed /// segment remains authoritative; metadata must be repaired before accepting input. prepared_initial_seed: Option>, @@ -3404,7 +3425,7 @@ impl Worker { prompts, inject_resident_summary: true, feature_resident_summary_source: None, - initial_context_source: None, + session_context_source: None, prepared_initial_seed: None, subjektiv_host: None, restore_feature_lifecycle_pending: false, @@ -3452,8 +3473,8 @@ impl Worker { self.inject_resident_summary = enabled; } - pub(crate) fn install_initial_context_source(&mut self, source: Arc) { - self.initial_context_source = Some(source); + pub(crate) fn install_session_context_source(&mut self, source: Arc) { + self.session_context_source = Some(source); } pub(crate) fn install_system_prompt_contribution( @@ -3629,10 +3650,12 @@ impl Worker { self.workspace_context.client_handle() } - /// Bind a live capability before Feature installation. This is deliberately - /// separate from Workspace authority and is not inherited by SubWorkers. - pub fn bind_subjektiv_host(&mut self, host: Option>) { + pub(crate) fn set_subjektiv_host( + &mut self, + host: Option>, + ) { self.subjektiv_host = host; + self.session_context_source = None; } pub fn subjektiv_host_connection( @@ -3700,26 +3723,13 @@ impl Worker { { let activation = self.workspace_client().activate_skill(name)?; self.ensure_segment_head().await?; - let body = format!( - "Agent Skill `{}` activated from {}.\n\n{}", - activation.name, activation.provenance.id, activation.body - ); - let skill_metadata = new_history_metadata( - WorkerHistoryProvenance::BackendInstruction { operation_id: None }, - None, - ); + let input = crate::skill::activation_input(&activation).map_err(StoreError::from)?; + let history_entry = HistoryEntry::new(input.item.to_history_item(), input.metadata.clone()); self.commit_entry(LogEntry::AnnotatedSystemItem { ts: segment_log::now_millis(), - entry: session_store::LoggedSystemHistoryEntry { - item: SystemItem::SkillActivation { - name: activation.name.clone(), - body: body.clone(), - }, - metadata: skill_metadata.clone(), - }, + entry: input, extensions: Vec::new(), })?; - let history_entry = HistoryEntry::new(agen::Item::system_message(body), skill_metadata); let mut annotate = history_annotator( self.log_writer_handle(), vec![history_entry.clone()], @@ -4594,18 +4604,12 @@ impl Worker { .lock() .expect("restore system-item queue poisoned"), ); - for item in system_items { - let history_item = item.to_history_item(); - let metadata = new_history_metadata( - WorkerHistoryProvenance::BackendInstruction { operation_id: None }, - None, - ); + for entry in system_items { + let history_item = entry.item.to_history_item(); + let metadata = entry.metadata.clone(); self.commit_entry(LogEntry::AnnotatedSystemItem { ts: segment_log::now_millis(), - entry: session_store::LoggedSystemHistoryEntry { - item, - metadata: metadata.clone(), - }, + entry, extensions: Vec::new(), })?; let history_entry = HistoryEntry::new(history_item, metadata); @@ -5368,17 +5372,14 @@ impl Worker { .invocation_receipts .push(InvocationReceipt::new(invocation, Some(result.clone()))); } - let item = SystemItem::FeatureInvocationResult { result, body }; - let metadata = new_history_metadata( - WorkerHistoryProvenance::BackendInstruction { operation_id: None }, - None, - ); - let history = HistoryEntry::new(item.to_history_item(), metadata.clone()); + let input = + crate::feature::invocation::result_input(result, body).map_err(StoreError::from)?; + let history = HistoryEntry::new(input.item.to_history_item(), input.metadata.clone()); handle .writer .append_entry_locked(LogEntry::AnnotatedSystemItem { ts: segment_log::now_millis(), - entry: session_store::LoggedSystemHistoryEntry { item, metadata }, + entry: input, extensions: vec![pending_activation_extension(&completed)], })?; *state = completed; @@ -5882,7 +5883,7 @@ impl Worker { } else { if self.prepared_initial_seed.is_none() { self.ensure_system_prompt_materialized().await?; - let items = match &self.initial_context_source { + let items = match &self.session_context_source { Some(source) => source.load().await.map_err(WorkerError::InvalidState)?, None => Vec::new(), }; @@ -6939,11 +6940,18 @@ impl Worker { // Freeze one Host-owned read before starting the compactor. Background recording // remains independent; never re-read when building the replacement segment. - let journal_context = crate::feature::builtin::journal::freeze_compaction_journal( - self.subjektiv_host_connection(), - &self.prompts.load_full(), - ) - .map_err(WorkerError::PromptCatalog)?; + let frozen_context = match &self.session_context_source { + Some(source) => source + .load_compaction_context(self.session_id()) + .await + .map_err(WorkerError::InvalidState)?, + None => Vec::new(), + }; + for entry in &frozen_context { + entry + .validate_system_input() + .map_err(|e| WorkerError::InvalidState(e.to_string()))?; + } let history_entries = self.session.history().entries(); let retain_from = cut.index.min(history_entries.len()); let initial_summary = initial_compaction_summary(history_entries)?; @@ -6953,11 +6961,8 @@ impl Worker { let retained_history_entries = history_entries[retain_from..] .iter() .filter(|entry| { - !matches!( - entry.annotation.origin, - WorkerHistoryProvenance::CompactionSummary - | WorkerHistoryProvenance::JournalContext - ) + crate::session_history::compaction_use(&entry.annotation) + == session_store::InputCompactionUse::Retain }) .cloned() .collect::>(); @@ -6976,11 +6981,8 @@ impl Worker { let mut entries_to_summarise = history_entries[..retain_from] .iter() .filter(|entry| { - !matches!( - entry.annotation.origin, - WorkerHistoryProvenance::CompactionSummary - | WorkerHistoryProvenance::JournalContext - ) + crate::session_history::compaction_use(&entry.annotation) + == session_store::InputCompactionUse::Retain }) .cloned() .collect::>(); @@ -7067,24 +7069,29 @@ impl Worker { // Journals. In particular, a failed/empty read must not silently lose context // that the previous summary intentionally delegated to its injected Journal. // Carry them only into the compactor, never alongside the new reference seed. - for entry in history_entries - .iter() - .filter(|entry| entry.annotation.origin == WorkerHistoryProvenance::JournalContext) - { + for entry in history_entries.iter().filter(|entry| { + crate::session_history::compaction_use(&entry.annotation) + == session_store::InputCompactionUse::ReferenceInput + }) { if let Some(body) = entry.item.as_text() { summary_input.text.push_str( - "\n\n## Previous Journal reference (historical, not automatically retained)\n", + "\n\n## Previous reference input (historical, not automatically retained)\n", ); summary_input.text.push_str(body); summary_input .text - .push_str("\n## End previous Journal reference\n"); + .push_str("\n## End previous reference input\n"); } } // Mandatory context, outside overview truncation. The internal Worker's normal // request budget checks include this exact body and the full previous summary. summary_input.text.push_str("\n\n"); - summary_input.text.push_str(&journal_context); + for (index, entry) in frozen_context.iter().enumerate() { + if index != 0 { + summary_input.text.push_str("\n\n"); + } + summary_input.text.push_str(&entry.item.history_text()); + } if summary_input.warning_exceeded { self.alert( AlertLevel::Warn, @@ -7400,8 +7407,11 @@ impl Worker { crate::session_history::COMPACTION_SUMMARY_HEADER )); compact_introduced_system_messages.push(summary_message.clone()); - let journal_message = Item::system_message(journal_context); - compact_introduced_system_messages.push(journal_message.clone()); + let context_messages = frozen_context + .iter() + .map(|entry| entry.item.to_history_item()) + .collect::>(); + compact_introduced_system_messages.extend(context_messages.iter().cloned()); compact_introduced_system_messages.extend(auto_read_messages.iter().cloned()); if let Some(msg) = reference_message.as_ref() { compact_introduced_system_messages.push(msg.clone()); @@ -7414,7 +7424,7 @@ impl Worker { compact_introduced_system_messages.push(task_snapshot_message.clone()); new_history.push(summary_message); - new_history.push(journal_message); + new_history.extend(context_messages); new_history.extend(auto_read_messages); if let Some(msg) = reference_message { new_history.push(msg); @@ -7454,6 +7464,32 @@ impl Worker { .iter() .map(|entry| entry.annotation.entry_id.clone()) .collect::>(); + let generic_summary_input = session_store::SystemInputEnvelope::new( + new_history[0] + .as_text() + .expect("compaction summary is text"), + session_store::SystemInputMetadata::new( + session_store::SystemInputUsePolicy { + recording_material: session_store::InputEvidenceUse::Exclude, + memory_evidence: session_store::InputEvidenceUse::Exclude, + observation: session_store::InputObservationUse::Hidden, + compaction: session_store::InputCompactionUse::OpeningSummary, + }, + None, + None, + ) + .map_err(|e| WorkerError::InvalidState(e.to_string()))?, + ) + .map_err(|e| WorkerError::InvalidState(e.to_string()))?; + let summary_entry = session_store::LoggedSystemHistoryEntry::from_input( + WorkerHistoryProvenance::Provided { + producer: "worker.compaction".into(), + }, + Some(SessionHistoryDerivation { + sources: derived_sources.clone(), + }), + generic_summary_input, + ); let mut original_cursor = 0usize; let compacted_history_entries = new_history .iter() @@ -7461,25 +7497,9 @@ impl Worker { .enumerate() .map(|(index, item)| { if index == 0 { - HistoryEntry::new( - item, - new_history_metadata( - WorkerHistoryProvenance::CompactionSummary, - Some(SessionHistoryDerivation { - sources: derived_sources.clone(), - }), - ), - ) - } else if index == 1 { - HistoryEntry::new( - item, - new_history_metadata( - WorkerHistoryProvenance::JournalContext, - Some(SessionHistoryDerivation { - sources: derived_sources.clone(), - }), - ), - ) + HistoryEntry::new(item, summary_entry.metadata.clone()) + } else if let Some(context) = frozen_context.get(index - 1) { + HistoryEntry::new(item, context.metadata.clone()) } else if let Some((offset, original)) = original_entries[original_cursor..] .iter() .enumerate() @@ -7977,7 +7997,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, - initial_context_source: None, + session_context_source: None, prepared_initial_seed: None, subjektiv_host: None, restore_feature_lifecycle_pending: false, @@ -8358,7 +8378,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, - initial_context_source: None, + session_context_source: None, prepared_initial_seed: None, subjektiv_host: None, restore_feature_lifecycle_pending: false, @@ -8490,7 +8510,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, - initial_context_source: None, + session_context_source: None, prepared_initial_seed: None, subjektiv_host: None, restore_feature_lifecycle_pending: false, @@ -8901,7 +8921,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, - initial_context_source: None, + session_context_source: None, prepared_initial_seed: None, subjektiv_host: None, restore_feature_lifecycle_pending: true, @@ -9296,9 +9316,10 @@ fn summary_shrink_prompt( fn initial_compaction_summary( entries: &[HistoryEntry], ) -> Result, WorkerError> { - let mut summaries = entries - .iter() - .filter(|entry| entry.annotation.origin == WorkerHistoryProvenance::CompactionSummary); + let mut summaries = entries.iter().filter(|entry| { + crate::session_history::compaction_use(&entry.annotation) + == session_store::InputCompactionUse::OpeningSummary + }); let Some(entry) = summaries.next() else { return Ok(None); }; @@ -10739,13 +10760,32 @@ mod build_summary_prompt_tests { #[test] fn malformed_or_duplicate_host_summaries_fail_instead_of_omitting_context() { use crate::session_history::history_entry; - let valid = history_entry( - Item::system_message(format!( - "{}body", - crate::session_history::COMPACTION_SUMMARY_HEADER - )), - WorkerHistoryProvenance::CompactionSummary, - ); + let opening = |item| { + let mut entry = history_entry( + item, + WorkerHistoryProvenance::Provided { + producer: "arbitrary-summary-source".into(), + }, + ); + entry.annotation.system_input = Some( + session_store::SystemInputMetadata::new( + session_store::SystemInputUsePolicy { + recording_material: session_store::InputEvidenceUse::Exclude, + memory_evidence: session_store::InputEvidenceUse::Exclude, + observation: session_store::InputObservationUse::Hidden, + compaction: session_store::InputCompactionUse::OpeningSummary, + }, + None, + None, + ) + .unwrap(), + ); + entry + }; + let valid = opening(Item::system_message(format!( + "{}body", + crate::session_history::COMPACTION_SUMMARY_HEADER + ))); assert_eq!( initial_compaction_summary(std::slice::from_ref(&valid)) .unwrap() @@ -10757,13 +10797,7 @@ mod build_summary_prompt_tests { Item::user_message("pasted"), Item::system_message("missing marker"), ] { - assert!( - initial_compaction_summary(&[history_entry( - item, - WorkerHistoryProvenance::CompactionSummary - )]) - .is_err() - ); + assert!(initial_compaction_summary(&[opening(item)]).is_err()); } } @@ -11190,7 +11224,7 @@ mod build_summary_prompt_tests { 1 } LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) => + if input_schema(entry) == Some("feature:invocation-result") => { 2 } @@ -14162,13 +14196,7 @@ permission = "write" let entries = store.read_all(session_id, segment_id).unwrap(); assert!(matches!( entries.last(), - Some(LogEntry::AnnotatedSystemItem { - entry: session_store::LoggedSystemHistoryEntry { - item: SystemItem::ResidentSummaryRefresh { .. }, - .. - }, - .. - }) + Some(LogEntry::AnnotatedSystemItem { entry, .. }) if input_schema(entry) == Some("memory:resident-summary") )); } } @@ -14465,15 +14493,10 @@ permission = "write" assert!(entries.iter().any(|entry| { matches!( entry, - LogEntry::AnnotatedSystemItem { - entry: session_store::LoggedSystemHistoryEntry { - item: SystemItem::SkillActivation { name, body }, - .. - }, - .. - } if name == "triage-errors" - && body.contains("# Triage Errors") - && body == history_text + LogEntry::AnnotatedSystemItem { entry, .. } + if entry.metadata.system_input.as_ref().and_then(|input| input.feature_payload()).and_then(|payload| payload.decode::("skill:activation", 1).ok()).is_some_and(|payload| payload.name == "triage-errors") + && entry.item.history_text().contains("# Triage Errors") + && entry.item.history_text() == history_text ) })); } @@ -15867,7 +15890,7 @@ permission = "write" .position(|entry| { matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) + if input_schema(entry) == Some("feature:invocation-result") ) }) .expect("invocation result must be durable before the model request"); @@ -15876,7 +15899,7 @@ permission = "write" .position(|entry| { matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::SubjectBehaviorRefresh { .. }) + if input_schema(entry) == Some("subject:behavior") ) }) .expect("Subject behavior refresh must be durable before the model request"); @@ -16115,14 +16138,14 @@ permission = "write" .iter() .filter(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) + if input_schema(entry) == Some("feature:invocation-result") )) .count(), 2 // Explicit replay displays the cached result; it does not repeat the effect. ); assert!(entries.iter().any(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::ResidentSummaryRefresh { .. }) + if input_schema(entry) == Some("memory:resident-summary") ))); } @@ -16165,7 +16188,7 @@ permission = "write" LogEntry::AnnotatedSystemItem { entry, extensions, .. } => { - matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) + input_schema(entry) == Some("feature:invocation-result") && extensions.iter().any(|extension| { extension.domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN && serde_json::from_value::( @@ -16756,34 +16779,70 @@ permission = "write" })); } - fn startup_test_item() -> SystemItem { - SystemItem::JournalStartup { - body: "PRIOR_JOURNAL_REFERENCE".into(), - snapshot: serde_json::json!({"subject_id":"subject", "journals":[], "omitted":false}), - snapshot_sha256: "snapshot-digest".into(), - prompt_provenance: PromptRenderProvenance { - workspace_id: None, - source_digest: "source".into(), - projection_digest: "projection".into(), - logical_name: "internal.subjektiv_journal_startup".into(), + fn input_schema(entry: &session_store::LoggedSystemHistoryEntry) -> Option<&str> { + entry + .metadata + .system_input + .as_ref()? + .feature_payload() + .map(|payload| payload.schema()) + } + + fn startup_test_input() -> session_store::LoggedSystemHistoryEntry { + use session_store::*; + let payload = FeatureInputPayload::encode( + "journal:startup", + 1, + &crate::feature::builtin::journal::JournalStartupInput { + snapshot: subjektiv::journal::startup::JournalStartupSnapshot { + subject_id: "subject".into(), + journals: vec![], + omitted: false, + }, + snapshot_sha256: "snapshot-digest".into(), + prompt_provenance: PromptRenderProvenance { + workspace_id: None, + source_digest: "source".into(), + projection_digest: "projection".into(), + logical_name: "internal.subjektiv_journal_startup".into(), + }, }, - } + ) + .unwrap(); + LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: "arbitrary-context-provider".into(), + }, + None, + SystemInputEnvelope::new( + "PRIOR_JOURNAL_REFERENCE", + SystemInputMetadata::new( + SystemInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::ReferenceInput, + }, + Some(payload), + None, + ) + .unwrap(), + ) + .unwrap(), + ) } struct StartupTestSource { calls: Arc, fail: Arc, } #[async_trait] - impl InitialContextSource for StartupTestSource { + impl SessionContextSource for StartupTestSource { async fn load(&self) -> Result, String> { self.calls.fetch_add(1, Ordering::SeqCst); if self.fail.load(Ordering::SeqCst) { return Err("startup read failed".into()); } - Ok(vec![session_store::LoggedSystemHistoryEntry { - item: startup_test_item(), - metadata: new_history_metadata(WorkerHistoryProvenance::JournalContext, None), - }]) + Ok(vec![startup_test_input()]) } } @@ -16791,7 +16850,7 @@ permission = "write" async fn initial_system_messages_preserve_feature_metadata_without_payload_classification() { struct Source(Vec); #[async_trait] - impl InitialContextSource for Source { + impl SessionContextSource for Source { async fn load(&self) -> Result, String> { Ok(self.0.clone()) } @@ -16807,16 +16866,19 @@ permission = "write" }, metadata: new_history_metadata(WorkerHistoryProvenance::JournalContext, None), }, - session_store::LoggedSystemHistoryEntry { - item: startup_test_item(), - metadata: new_history_metadata( + { + let mut input = startup_test_input(); + let custom = new_history_metadata( WorkerHistoryProvenance::BackendInstruction { operation_id: Some("feature-selected-origin".into()), }, Some(SessionHistoryDerivation { sources: vec![SessionHistoryEntryId::new()], }), - ), + ); + input.metadata.origin = custom.origin; + input.metadata.derivation = custom.derivation; + input }, ]; let mut worker = Worker::new( @@ -16829,7 +16891,7 @@ permission = "write" ) .await .unwrap(); - worker.install_initial_context_source(Arc::new(Source(expected.clone()))); + worker.install_session_context_source(Arc::new(Source(expected.clone()))); worker.materialize_durable_session_head().await.unwrap(); let log = store .read_all(worker.session_id(), worker.segment_id()) @@ -16886,7 +16948,7 @@ permission = "write" .await .unwrap(); let calls = Arc::new(AtomicUsize::new(0)); - worker.install_initial_context_source(Arc::new(StartupTestSource { + worker.install_session_context_source(Arc::new(StartupTestSource { calls: calls.clone(), fail: Arc::new(AtomicBool::new(false)), })); @@ -16907,7 +16969,9 @@ permission = "write" assert_eq!(replay[0].item.as_text(), Some("PRIOR_JOURNAL_REFERENCE")); assert_eq!( replay[0].annotation.origin, - WorkerHistoryProvenance::JournalContext + WorkerHistoryProvenance::Provided { + producer: "arbitrary-context-provider".into() + } ); let view = crate::session_capture::SessionCapture::from_history_entries("startup", replay); @@ -16918,7 +16982,7 @@ permission = "write" let entries = store .read_all(worker.session_id(), worker.segment_id()) .unwrap(); - let journal = entries.iter().position(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if matches!(entry.item, SystemItem::JournalStartup { .. }))).unwrap(); + let journal = entries.iter().position(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if input_schema(entry) == Some("journal:startup"))).unwrap(); let user = entries .iter() .position(|entry| matches!(entry, LogEntry::AnnotatedUserInput { .. })) @@ -16944,7 +17008,7 @@ permission = "write" .unwrap(); let calls = Arc::new(AtomicUsize::new(0)); let fail = Arc::new(AtomicBool::new(true)); - worker.install_initial_context_source(Arc::new(StartupTestSource { + worker.install_session_context_source(Arc::new(StartupTestSource { calls: calls.clone(), fail: fail.clone(), })); @@ -16975,7 +17039,7 @@ permission = "write" .await .unwrap(); let calls = Arc::new(AtomicUsize::new(0)); - worker.install_initial_context_source(Arc::new(StartupTestSource { + worker.install_session_context_source(Arc::new(StartupTestSource { calls: calls.clone(), fail: Arc::new(AtomicBool::new(false)), })); @@ -17030,7 +17094,7 @@ permission = "write" .unwrap(); worker.enable_worker_metadata_write_through().unwrap(); let calls = Arc::new(AtomicUsize::new(0)); - worker.install_initial_context_source(Arc::new(StartupTestSource { + worker.install_session_context_source(Arc::new(StartupTestSource { calls: calls.clone(), fail: Arc::new(AtomicBool::new(false)), })); @@ -17052,7 +17116,7 @@ permission = "write" &manifest.worker.name, manifest.clone(), store.clone(), PromptCatalogSource::builtins_only(), WorkerWorkspaceContext::no_workspace(), WorkerFilesystemAuthority::None, Some(Box::new(NoopClient))).await.unwrap(); - restored.install_initial_context_source(Arc::new(StartupTestSource { + restored.install_session_context_source(Arc::new(StartupTestSource { calls: calls.clone(), fail: Arc::new(AtomicBool::new(true)), })); @@ -17377,7 +17441,7 @@ permission = "write" .iter() .filter_map(|entry| match entry { LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::JournalStartup { .. }) => + if input_schema(entry) == Some("journal:startup") => { Some(entry.item.clone()) } @@ -17428,7 +17492,7 @@ permission = "write" .iter() .filter_map(|entry| match entry { LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::JournalStartup { .. }) => + if input_schema(entry) == Some("journal:startup") => { Some(&entry.item) } diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index d72150e71..e0f773abf 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -2220,10 +2220,48 @@ fn summary_entry( origin: session_store::LoggedSessionHistoryOrigin, ) -> session_store::LoggedHistoryEntry { let mut entry = annotated(item); + entry.metadata.system_input = matches!( + &entry.item, + session_store::LoggedItem::Message { + role: session_store::LoggedRole::System, + .. + } + ) + .then(|| { + session_store::SystemInputMetadata::new( + session_store::SystemInputUsePolicy { + recording_material: session_store::InputEvidenceUse::Exclude, + memory_evidence: session_store::InputEvidenceUse::Exclude, + observation: session_store::InputObservationUse::Hidden, + compaction: match origin { + session_store::LoggedSessionHistoryOrigin::CompactionSummary => { + session_store::InputCompactionUse::OpeningSummary + } + session_store::LoggedSessionHistoryOrigin::JournalContext => { + session_store::InputCompactionUse::ReferenceInput + } + _ => session_store::InputCompactionUse::Retain, + }, + }, + None, + None, + ) + .unwrap() + }); entry.metadata.origin = origin; entry } +fn compaction_use( + metadata: &session_store::LoggedSessionHistoryMetadata, +) -> session_store::InputCompactionUse { + metadata + .system_input + .as_ref() + .map(|input| input.usage().compaction) + .unwrap_or(session_store::InputCompactionUse::Retain) +} + fn long_summary(label: &str) -> String { format!( "## Completed Tasks\n{label}: investigated the transport boundary.\n\n\ @@ -2359,7 +2397,8 @@ async fn full_initial_summary_survives_small_overview_budgets_and_both_retention assert_eq!( replacement .iter() - .filter(|entry| entry.metadata.origin == CompactionSummary) + .filter(|entry| compaction_use(&entry.metadata) + == session_store::InputCompactionUse::OpeningSummary) .count(), 1 ); @@ -2604,7 +2643,9 @@ async fn three_chained_compactions_transport_each_complete_predecessor_without_r let seed = replacement_seed(&fixture.worker); let inherited = seed .iter() - .filter(|entry| entry.metadata.origin == CompactionSummary) + .filter(|entry| { + compaction_use(&entry.metadata) == session_store::InputCompactionUse::OpeningSummary + }) .collect::>(); assert_eq!(inherited.len(), 1); assert_eq!( @@ -2892,7 +2933,7 @@ impl worker::subjektiv::SubjektivHost for CompactionJournalHost { } fn journal_startup_granted(&self) -> bool { - true + false } fn journal_startup_context( @@ -2996,8 +3037,8 @@ fn compaction_snapshot(generation: usize) -> subjektiv::journal::startup::Journa fn journal_body(worker: &Worker) -> String { let seed = replacement_seed(worker); assert_eq!( - seed[1].metadata.origin, - session_store::LoggedSessionHistoryOrigin::JournalContext + compaction_use(&seed[1].metadata), + session_store::InputCompactionUse::ReferenceInput ); let item = Item::from(seed[1].item.clone()); assert!(matches!( @@ -3009,8 +3050,8 @@ fn journal_body(worker: &Worker) -> String { )); assert_eq!( seed.iter() - .filter(|entry| entry.metadata.origin - == session_store::LoggedSessionHistoryOrigin::JournalContext) + .filter(|entry| compaction_use(&entry.metadata) + == session_store::InputCompactionUse::ReferenceInput) .count(), 1 ); @@ -3119,20 +3160,34 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai projection.source_digest = "a".repeat(64); let initial_catalog = Arc::new(worker::PromptCatalog::from_projection(projection).unwrap()); fixture.worker.prompts().store(initial_catalog.clone()); - let legacy_startup = session_store::LoggedSystemHistoryEntry { - item: session_store::SystemItem::JournalStartup { - body: "OLD_TYPED_STARTUP_JOURNAL".into(), - snapshot: serde_json::json!({"journals": []}), - snapshot_sha256: "legacy-startup-digest".into(), - prompt_provenance: session_store::PromptRenderProvenance { - workspace_id: None, - source_digest: "legacy-source".into(), - projection_digest: "legacy-projection".into(), - logical_name: "internal.subjektiv_journal_startup".into(), - }, - }, - metadata: summary_entry(Item::system_message(""), DerivedSummary).metadata, - }; + // Already migrated generic input with unrelated provenance/payload; Core + // must select it by explicit policy, not identify a Journal Feature. + let legacy_startup = session_store::LoggedSystemHistoryEntry::from_input( + DerivedSummary, + None, + session_store::SystemInputEnvelope::new( + "OLD_TYPED_STARTUP_JOURNAL", + session_store::SystemInputMetadata::new( + session_store::SystemInputUsePolicy { + recording_material: session_store::InputEvidenceUse::Exclude, + memory_evidence: session_store::InputEvidenceUse::Exclude, + observation: session_store::InputObservationUse::Hidden, + compaction: session_store::InputCompactionUse::ReferenceInput, + }, + Some( + session_store::FeatureInputPayload::encode( + "renamed-owner:reference", + 8, + &serde_json::json!({"private": "retained opaque context"}), + ) + .unwrap(), + ), + None, + ) + .unwrap(), + ) + .unwrap(), + ); let legacy_id = legacy_startup.metadata.entry_id.clone(); fixture .restore_seed_with_system( @@ -3166,7 +3221,7 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai .unwrap() .annotation .origin, - JournalContext + DerivedSummary ); let session = fixture.worker.session_id(); let original_segment = fixture.worker.segment_id(); @@ -3191,8 +3246,8 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai .unwrap() .annotation .origin, - JournalContext, - "legacy startup classification survives a normal turn" + DerivedSummary, + "generic source provenance survives a normal turn unchanged" ); } assert_eq!( @@ -3243,7 +3298,7 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai assert_eq!(host.reads.lock().unwrap().len(), index + 1); let history = fixture.worker.history(); let overview = input - .split("## Previous Journal reference (historical, not automatically retained)") + .split("## Previous reference input (historical, not automatically retained)") .next() .unwrap(); for old in &old_bodies { @@ -3336,6 +3391,17 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai .run_text("normal post-compaction turn") .await .unwrap(); + let frozen_seed = replacement_seed(&fixture.worker) + .into_iter() + .filter(|entry| { + matches!( + compaction_use(&entry.metadata), + session_store::InputCompactionUse::ReferenceInput + | session_store::InputCompactionUse::OpeningSummary + ) + }) + .collect::>(); + assert_eq!(frozen_seed.len(), 2); let records = fixture .worker .store() @@ -3375,6 +3441,20 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai .run_text("normal turn after typed history restore") .await .unwrap(); + for frozen in frozen_seed { + let replayed = fixture + .worker + .session_history() + .entries() + .iter() + .find(|entry| entry.annotation.entry_id == frozen.metadata.entry_id) + .expect("frozen entry identity survives re-restore"); + assert_eq!(replayed.item, Item::from(frozen.item)); + assert_eq!( + replayed.annotation, frozen.metadata, + "origin, derivation, public allowlist, policy and private payload survive atomic seed/restore" + ); + } assert_eq!( host.reads.lock().unwrap().len(), 3, @@ -3445,27 +3525,41 @@ async fn journal_absent_disabled_unavailable_and_over_byte_budget_keep_conversat ) .await; fixture.worker.compact(0).await.unwrap(); - let body = journal_body(&fixture.worker); - let expected = match case { - "no-host" | "disabled" => "disabled", - "absent" => "absent", - _ => "unavailable", - }; - assert!( - body.contains(&format!("Journal read status: {expected}.")), - "{case}" - ); let input = first_compactor_input(&observed.requests()[1]); - assert_frozen_journal_transport(&input, &body); - assert_journal_provenance( - &body, - expected_json.as_deref(), - &fixture.worker.prompts().load_full(), - (case != "no-host").then_some(COMPACTION_HOST_WORKSPACE_ID), - ); + let body = if matches!(case, "disabled" | "no-host") { + assert_eq!( + replacement_seed(&fixture.worker) + .iter() + .filter(|entry| compaction_use(&entry.metadata) + == session_store::InputCompactionUse::ReferenceInput) + .count(), + 0 + ); + assert!(!input.contains("Journal reference frozen at this compaction boundary")); + String::new() + } else { + let body = journal_body(&fixture.worker); + let expected = if case == "absent" { + "absent" + } else { + "unavailable" + }; + assert!( + body.contains(&format!("Journal read status: {expected}.")), + "{case}" + ); + assert_frozen_journal_transport(&input, &body); + assert_journal_provenance( + &body, + expected_json.as_deref(), + &fixture.worker.prompts().load_full(), + Some(COMPACTION_HOST_WORKSPACE_ID), + ); + body + }; assert_full_initial_summary(&input, &inherited); assert!(input.contains("UNRECORDED_FALLBACK_GOAL"), "{case}"); - assert!(input.contains("## Previous Journal reference (historical, not automatically retained)\nPREVIOUS_JOURNAL_DELEGATED_CONTEXT")); + assert!(input.contains("## Previous reference input (historical, not automatically retained)\nPREVIOUS_JOURNAL_DELEGATED_CONTEXT")); assert!( fixture.worker.history().iter().all(|item| !item .as_text() diff --git a/crates/worker/tests/feature_invocation_test.rs b/crates/worker/tests/feature_invocation_test.rs index a08cffda0..805f694ac 100644 --- a/crates/worker/tests/feature_invocation_test.rs +++ b/crates/worker/tests/feature_invocation_test.rs @@ -14,7 +14,7 @@ use protocol::{ InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue, InvocationCompletion, InvocationValue, Segment, }; -use session_store::{CombinedStore, FsStore, FsWorkerStore, LogEntry, SystemItem}; +use session_store::{CombinedStore, FsStore, FsWorkerStore, LogEntry}; use worker::feature::{ FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext, FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule, FeatureRegistryBuilder, @@ -224,10 +224,20 @@ fn results(worker: &Worker) -> Vec { .0 .into_iter() .filter_map(|entry| match entry { - LogEntry::AnnotatedSystemItem { entry, .. } => match entry.item { - SystemItem::FeatureInvocationResult { result, .. } => Some(result), - _ => None, - }, + LogEntry::AnnotatedSystemItem { entry, .. } => { + #[derive(serde::Deserialize)] + struct SavedResult { + result: FeatureInvocationResult, + } + entry + .metadata + .system_input + .as_ref()? + .feature_payload()? + .decode::("feature:invocation-result", 1) + .ok() + .map(|input| input.result) + } _ => None, }) .collect() diff --git a/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs index c3aca207b..893720ccd 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs @@ -547,30 +547,40 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup() let segments = session.list_segments(session_id).unwrap(); assert_eq!(segments.len(), 1); let entries = session.read_all_read_only(session_id, segments[0]).unwrap(); + #[derive(serde::Deserialize)] + struct SavedJournal { + snapshot: serde_json::Value, + snapshot_sha256: String, + prompt_provenance: session_store::PromptRenderProvenance, + } let startup: Vec<_> = entries .iter() .enumerate() - .filter_map(|(index, log)| match log { - session_store::LogEntry::AnnotatedSystemItem { entry, .. } => match &entry.item { - session_store::SystemItem::JournalStartup { - snapshot, - snapshot_sha256, - body, - prompt_provenance, - } => Some((index, snapshot, snapshot_sha256, body, prompt_provenance)), - _ => None, - }, - _ => None, + .filter_map(|(index, log)| { + let session_store::LogEntry::AnnotatedSystemItem { entry, .. } = log else { + return None; + }; + let payload = entry + .metadata + .system_input + .as_ref()? + .feature_payload()? + .decode::("journal:startup", 1) + .ok()?; + Some((index, payload, entry.item.history_text())) }) .collect(); assert_eq!(startup.len(), 1, "startup must be durably committed once"); - let (startup_index, snapshot, digest, body, provenance) = startup[0]; + let (startup_index, payload, body) = &startup[0]; + let snapshot = &payload.snapshot; + let digest = &payload.snapshot_sha256; + let provenance = &payload.prompt_provenance; let first_input = entries .iter() .position(|entry| matches!(entry, session_store::LogEntry::AnnotatedUserInput { .. })) .expect("new body persisted its initial human input"); assert!( - startup_index < first_input, + *startup_index < first_input, "startup reference must be persisted before the first human input/model request" ); assert_eq!(snapshot["subject_id"], subject.id); diff --git a/tools/state-contract-name-exceptions.json b/tools/state-contract-name-exceptions.json index 07fa26137..933d7a696 100644 --- a/tools/state-contract-name-exceptions.json +++ b/tools/state-contract-name-exceptions.json @@ -70,12 +70,12 @@ ] }, { - "path": "crates/session-store/src/system_item.rs", + "path": "crates/session-store/src/legacy_session_log/tests/v5.rs", "kind": "saved_data", - "reason": "frozen_subject_behavior_log_reads_old_counter_without_republishing_itの保存済みSystemItem fixture。旧JSONを読んでもexact archived bodyを維持することを証明する。削除すると過去Session log形式の読取回帰を検出できない。", + "reason": "旧Subject behavior JSONに保存されていたcounterの実fixture。v1–4専用migration境界で旧綴りを読み、本文を維持して現行payloadへcounterを再公開しないことを検証する。通常v5 decoderは旧Feature kind自体を拒否する。", "lines": [ { - "sha256": "5b5998029cfed30634e8b063b759aefd70e30dab3d02ce9defe9086977fc9811", + "sha256": "54c30cb3198eb16e57c7e580fce4dc058f89e793b77a2955be317aa38b490138", "count": 1 } ] From f9830fe7ddf73aedf0dd5deb66feef3ad8e3a96b Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 03:40:36 +0900 Subject: [PATCH 17/29] test: reject Bash search pages that would skip an unread long line --- crates/worker/src/wip/bash_output_tests.rs | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/worker/src/wip/bash_output_tests.rs b/crates/worker/src/wip/bash_output_tests.rs index 39eac727e..21f2b88bc 100644 --- a/crates/worker/src/wip/bash_output_tests.rs +++ b/crates/worker/src/wip/bash_output_tests.rs @@ -468,3 +468,43 @@ async fn common_read_and_search_permissions_are_independent_from_bash_execution( ); assert!(!f._dir.path().join("denied").exists()); } + +#[tokio::test] +async fn multibyte_long_line_search_refuses_instead_of_skipping_an_unread_suffix() { + let f = Fixture::new(); + let result = f + .run("printf '雪%.0s' {1..22000}; printf 'needle\nnext\n'") + .await; + let path = result["stdout_entry"].as_str().unwrap(); + let error = invoke( + &f.runtime, + path, + Contract::SearchText, + "search", + json!({"pattern":"needle","offset":0,"limit":1}), + ) + .await + .unwrap_err(); + assert!( + error.to_string().contains("ResourceLimitExceeded"), + "{error}" + ); + // No successful page claims to advance past a partial line. Raw bytes can still + // retrieve its unread suffix using the same common file contract. + let suffix = invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":66000,"limit":7}), + ) + .await + .unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(suffix["data"].as_str().unwrap()) + .unwrap(), + b"needle\n" + ); + assert_eq!(suffix["next_offset"], 66007); +} From bfeee487fd422af97a430ddc1031751fd19e43d5 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 03:52:48 +0900 Subject: [PATCH 18/29] feat(auth): separate anonymous entry from expired session recovery --- Cargo.lock | 2 + crates/server-api/src/lib.rs | 40 +- crates/workspace-server/src/auth.rs | 374 ++++++++++- crates/workspace-server/src/server.rs | 263 +++++++- crates/workspace-server/src/store.rs | 10 + openapi/server-api.json | 58 +- tests/e2e/Cargo.toml | 2 + tests/e2e/browser/auth_sessions.ts | 435 +++++++++++++ tests/e2e/tests/auth_passkeys.rs | 12 +- .../tests/auth_passkeys/browser_sessions.rs | 233 +++++++ web/workspace/src/lib/generated/auth-api.ts | 4 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- web/workspace/src/lib/workspace/api/http.ts | 8 + .../src/lib/workspace/auth/AuthEntry.svelte | 15 +- .../lib/workspace/auth/SessionBoundary.svelte | 71 +++ .../lib/workspace/auth/api.test-fixtures.ts | 9 + .../workspace/auth/browser-session.svelte.ts | 79 ++- .../src/lib/workspace/auth/model.test.ts | 37 +- web/workspace/src/lib/workspace/auth/model.ts | 38 +- .../src/lib/workspace/auth/page-session.ts | 28 + .../src/lib/workspace/auth/routes.test.ts | 48 +- .../src/lib/workspace/auth/routes.ts | 43 +- .../auth/session-boundary.browser.spec.ts | 121 ++++ .../auth/session-state.browser.spec.ts | 102 +++ .../src/lib/workspace/auth/session-state.ts | 153 +++++ .../workspace/auth/session.browser.spec.ts | 112 +++- .../lib/workspace/navigation/deferred.test.ts | 42 +- .../src/lib/workspace/navigation/deferred.ts | 18 +- .../hosting-navigation.browser.spec.ts | 3 + web/workspace/src/routes/+layout.svelte | 3 + web/workspace/src/routes/+layout.ts | 56 +- web/workspace/src/routes/account/+page.svelte | 7 +- .../routes/account/account.browser.spec.ts | 27 +- .../src/routes/entry.browser.spec.ts | 15 +- .../hosting/accounts/accounts.browser.spec.ts | 5 + .../hosting/settings/settings.browser.spec.ts | 5 + .../src/routes/hosting/setup/+page.svelte | 4 + .../setup/hosting-setup.browser.spec.ts | 11 +- .../src/routes/sidebar-fold.browser.spec.ts | 149 +++-- .../src/routes/w/[workspaceId]/+layout.ts | 12 +- .../src/routes/w/[workspaceId]/+page.ts | 3 +- .../w/[workspaceId]/home.browser.spec.ts | 1 + .../objectives/[objectiveId]/+page.ts | 66 +- .../[workspaceId]/tickets/[ticketId]/+page.ts | 108 ++-- .../tickets/ticket-list.browser.spec.ts | 3 +- .../workers/[workerRef]/+page.ts | 59 +- .../workers/[workerRef]/console/+page.ts | 65 +- .../workspace-error.browser.spec.ts | 90 ++- .../activation-shell.browser.test.ts | 12 +- .../test/activation-browser/fixture-server.ts | 3 + .../auth-browser/auth-shell.browser.test.ts | 93 ++- web/workspace/test/auth-browser/capture.ts | 56 +- .../auth-browser/entry-shell.browser.test.ts | 34 +- .../test/auth-browser/fixture-server.ts | 79 ++- .../test/auth-browser/session-navigation.ts | 589 ++++++++++++++++++ .../test/hosting-browser/fixture-server.ts | 48 +- .../hosting-shell.browser.test.ts | 25 +- .../test/hosting-browser/setup-projection.ts | 12 +- .../test/repository-access/loader.test.ts | 1 + web/workspace/test/spa-shell.test.ts | 69 +- .../account-passkeys-fixture-server.ts | 4 + .../empty-workspace-fixture-server.ts | 17 +- 66 files changed, 3672 insertions(+), 429 deletions(-) create mode 100644 tests/e2e/browser/auth_sessions.ts create mode 100644 tests/e2e/tests/auth_passkeys/browser_sessions.rs create mode 100644 web/workspace/src/lib/workspace/auth/SessionBoundary.svelte create mode 100644 web/workspace/src/lib/workspace/auth/page-session.ts create mode 100644 web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts create mode 100644 web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts create mode 100644 web/workspace/src/lib/workspace/auth/session-state.ts create mode 100644 web/workspace/test/auth-browser/session-navigation.ts diff --git a/Cargo.lock b/Cargo.lock index 122e578dc..0e669a4f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6705,6 +6705,7 @@ dependencies = [ name = "yoi-e2e" version = "0.0.0" dependencies = [ + "chrono", "libc", "reqwest", "serde", @@ -6714,6 +6715,7 @@ dependencies = [ "tempfile", "webauthn-authenticator-rs", "webauthn-rs-proto", + "yoi-workspace-server", ] [[package]] diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 1e30192d7..dc082acd2 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -4025,6 +4025,17 @@ impl RequestActor { #[serde(deny_unknown_fields)] pub struct WhoamiResponse { pub actor: Option, + pub session: BrowserSessionState, +} + +/// Browser navigation evidence, not an authorization grant or a generic 401 classification. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum BrowserSessionState { + Anonymous {}, + Authenticated { expires_at: Option }, + Expired {}, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -11533,6 +11544,7 @@ pub fn auth_api_typescript() -> String { ActorAuthMethod::decl(&config), AuthenticatedUser::decl(&config), RequestActor::decl(&config), + BrowserSessionState::decl(&config), WhoamiResponse::decl(&config), AuthBootstrapUserRequest::decl(&config), AuthUserResponse::decl(&config), @@ -13961,7 +13973,8 @@ mod tests { "handle": "hare", "display_name": "Hare", "auth_method": "browser_session" - } + }, + "session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" } }); let decoded = serde_json::from_value::(whoami.clone()) .expect("server whoami fixture should match shared DTO"); @@ -14143,11 +14156,36 @@ mod tests { let unexpected_field = serde_json::json!({ "actor": null, + "session": { "kind": "anonymous" }, "access_token": "must-not-be-accepted" }); assert!(serde_json::from_value::(unexpected_field).is_err()); } + #[test] + fn whoami_session_json_distinguishes_anonymous_expired_and_token_auth() { + for session in [ + serde_json::json!({"kind":"anonymous"}), + serde_json::json!({"kind":"expired"}), + serde_json::json!({"kind":"authenticated", "expires_at":null}), + ] { + let value = serde_json::json!({"actor":null, "session":session}); + let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(response).unwrap(), value); + } + for session in [ + serde_json::json!({"kind":"unknown"}), + serde_json::json!({"kind":"expired", "expires_at":null}), + ] { + assert!( + serde_json::from_value::( + serde_json::json!({"actor":null, "session":session}) + ) + .is_err() + ); + } + } + #[test] fn device_login_debug_output_redacts_secret_material() { let start = DeviceLoginStartResponse { diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index fc6a302fe..c46c54926 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -91,11 +91,38 @@ pub fn parse_bearer(headers: &HeaderMap) -> Option { } pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option { - let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?; - cookie_header.split(';').find_map(|pair| { - let (name, value) = pair.trim().split_once('=')?; - (name == cookie_name && !value.is_empty()).then(|| value.to_string()) - }) + unique_cookie(headers, cookie_name) + .ok() + .flatten() + .filter(|value| !value.is_empty()) +} + +// Cookie has no path/domain metadata on requests. Duplicates (including identical values +// and multiple Cookie headers) cannot be disambiguated safely after cookie tossing. +fn unique_cookie( + headers: &HeaderMap, + cookie_name: &str, +) -> std::result::Result, ()> { + let mut found = None; + for header in headers.get_all(header::COOKIE) { + let header = header.to_str().map_err(|_| ())?; + for pair in header.split(';') { + let pair = pair.trim(); + let Some((name, value)) = pair.split_once('=') else { + if pair == cookie_name { + return Err(()); + } + continue; + }; + if name == cookie_name { + if found.is_some() { + return Err(()); + } + found = Some(value.to_owned()); + } + } + } + Ok(found) } pub async fn resolve_request_actor( @@ -121,6 +148,14 @@ pub(crate) fn resolve_request_actor_sync( bearer: Option, session: Option, ) -> Result> { + Ok(resolve_credentials_sync(store, bearer, session)?.map(|(actor, _)| actor)) +} + +fn resolve_credentials_sync( + store: &dyn ControlPlaneStore, + bearer: Option, + session: Option, +) -> Result)>> { if let Some(token) = bearer { let token_hash = token_hash(&token); if let Some(api_token) = store.resolve_api_token(&token_hash)? { @@ -132,7 +167,10 @@ pub(crate) fn resolve_request_actor_sync( return Ok(None); } store.mark_api_token_used(&token_hash, &now_rfc3339())?; - return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken); + return Ok( + actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken)? + .map(|actor| (actor, None)), + ); } } @@ -141,13 +179,95 @@ pub(crate) fn resolve_request_actor_sync( if is_expired(&session.expires_at) { return Ok(None); } - return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession); + return Ok( + actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession)? + .map(|actor| (actor, Some(session.expires_at))), + ); } } Ok(None) } +// This marker is a non-authenticating reference to the existing non-revoked session row. +// It outlives the auth cookie so Max-Age expiry remains distinguishable from a new browser. +// No second session store is introduced; clearing all cookies necessarily loses this evidence. +// Keep the established 14-day authentication lifetime. The additional week is only a bounded +// return-navigation window (including a week away); it never extends authentication. The store +// reclaims expired rows past this same boundary on subsequent session issuance. Neither whoami +// nor other reads refresh the marker or the row's absolute expiry. +pub const BROWSER_SESSION_SECONDS: i64 = 14 * 24 * 60 * 60; +pub const SESSION_EXPIRY_GRACE_SECONDS: i64 = 7 * 24 * 60 * 60; + +pub fn session_marker_cookie_name(cookie_name: &str) -> String { + format!("{cookie_name}_expiry") +} + +pub async fn resolve_whoami( + store: std::sync::Arc, + headers: &HeaderMap, + cookie_name: &str, +) -> Result { + let bearer = parse_bearer(headers); + // Even an empty/invalid auth cookie takes precedence over an older marker. + // An ambiguous primary is invalid, not absent; never fall back to an older marker. + let session = unique_cookie(headers, cookie_name).unwrap_or_else(|()| Some(String::new())); + let marker = parse_cookie(headers, &session_marker_cookie_name(cookie_name)); + let executor = store.db_executor(); + executor + .run(move || resolve_whoami_sync(store.as_ref(), bearer, session, marker)) + .await +} + +fn resolve_whoami_sync( + store: &dyn ControlPlaneStore, + bearer: Option, + session_token: Option, + marker: Option, +) -> Result { + use server_api::{BrowserSessionState, WhoamiResponse}; + let anonymous = || WhoamiResponse { + actor: None, + session: BrowserSessionState::Anonymous {}, + }; + // Preserve the existing API-token precedence and fallback rules, including expired tokens. + if let Some((actor, expires_at)) = + resolve_credentials_sync(store, bearer, session_token.clone())? + { + return Ok(WhoamiResponse { + actor: Some(actor), + session: BrowserSessionState::Authenticated { expires_at }, + }); + } + let hash = match session_token { + Some(token) => token_hash(&token), + None => match marker { + Some(hash) => hash, + None => return Ok(anonymous()), + }, + }; + let Some(session) = store.resolve_browser_session(&hash)? else { + return Ok(anonymous()); + }; + if store.get_user(&session.user_id)?.is_none() { + return Ok(anonymous()); + } + if within_session_expiry_grace(&session.expires_at, Utc::now()) { + return Ok(WhoamiResponse { + actor: None, + session: BrowserSessionState::Expired {}, + }); + } + // A marker for a still-valid session cannot restore authentication after partial deletion. + Ok(anonymous()) +} + +fn within_session_expiry_grace(expires_at: &str, now: DateTime) -> bool { + DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| { + expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS) + }) +} + fn actor_for_user( store: &S, user_id: &str, @@ -274,6 +394,13 @@ mod tests { .unwrap(); assert_eq!(actor.user_id, "user-auth"); assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken); + let whoami = resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap(); + assert_eq!( + whoami.session, + server_api::BrowserSessionState::Authenticated { expires_at: None } + ); assert!( store .resolve_api_token(&hash) @@ -363,20 +490,119 @@ mod tests { assert!(futures::poll!(queued.as_mut()).is_pending()); let mut headers = HeaderMap::new(); headers.insert(header::COOKIE, "session=session-secret".parse().unwrap()); + let actor_result = tokio::time::timeout( + std::time::Duration::from_secs(1), + resolve_request_actor(store.clone(), &headers, "session"), + ) + .await; let result = tokio::time::timeout( std::time::Duration::from_secs(1), - resolve_request_actor(store, &headers, "session"), + resolve_whoami(store, &headers, "session"), ) .await; release.send(()).unwrap(); blocker.await.unwrap().unwrap(); queued.await.unwrap(); + assert!(matches!( + actor_result.expect("actor lookup rejects capacity"), + Err(Error::DbCapacityExceeded) + )); assert!(matches!( result.expect("capacity rejection must not wait for queued SQL"), Err(Error::DbCapacityExceeded) )); } + #[tokio::test] + async fn whoami_classifies_only_live_store_backed_session_expiry() { + use server_api::BrowserSessionState; + let store = seeded_store(); + for (secret, offset, revoked) in [ + ("recent", -60, false), + ("old", -SESSION_EXPIRY_GRACE_SECONDS - 60, false), + ("revoked", -60, true), + ] { + store + .create_browser_session(&BrowserSessionRecord { + session_id: format!("session-{secret}"), + token_hash: token_hash(secret), + user_id: "user-auth".into(), + created_at: "1".into(), + expires_at: rfc3339_after(Duration::seconds(offset)), + revoked_at: revoked.then(|| now_rfc3339()), + }) + .unwrap(); + } + let cases = [ + (String::new(), BrowserSessionState::Anonymous {}), + ("session=unknown".into(), BrowserSessionState::Anonymous {}), + ("session=recent".into(), BrowserSessionState::Expired {}), + ( + format!("session_expiry={}", token_hash("recent")), + BrowserSessionState::Expired {}, + ), + ( + format!("session_expiry={}", token_hash("session-secret")), + BrowserSessionState::Anonymous {}, + ), + ( + "session_expiry=unknown".into(), + BrowserSessionState::Anonymous {}, + ), + ( + "session_expiry=recent".into(), + BrowserSessionState::Anonymous {}, + ), + ( + format!("session=unknown; session_expiry={}", token_hash("recent")), + BrowserSessionState::Anonymous {}, + ), + ( + format!("session=; session_expiry={}", token_hash("recent")), + BrowserSessionState::Anonymous {}, + ), + ("session=revoked".into(), BrowserSessionState::Anonymous {}), + ( + format!("session_expiry={}", token_hash("revoked")), + BrowserSessionState::Anonymous {}, + ), + ("session=old".into(), BrowserSessionState::Anonymous {}), + ( + format!("session_expiry={}", token_hash("old")), + BrowserSessionState::Anonymous {}, + ), + ( + format!( + "session=session-secret; session_expiry={}", + token_hash("recent") + ), + BrowserSessionState::Authenticated { + expires_at: Some("9999-01-01T00:00:00Z".into()), + }, + ), + ]; + for (cookie, expected) in cases { + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, cookie.parse().unwrap()); + let response = resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap(); + assert_eq!(response.session, expected, "cookie: {cookie}"); + assert_eq!( + response.actor.is_some(), + matches!(expected, BrowserSessionState::Authenticated { .. }) + ); + if !matches!(expected, BrowserSessionState::Authenticated { .. }) { + assert!( + resolve_request_actor(store.clone(), &headers, "session") + .await + .unwrap() + .is_none() + ); + } + } + } + #[test] fn user_code_uses_the_uuid_random_tail() { let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID"); @@ -395,4 +621,136 @@ mod tests { ); assert_eq!(code, code.to_ascii_uppercase()); } + + #[test] + fn expiry_grace_includes_expiry_but_excludes_its_absolute_end() { + let expiry = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + for (offset, expected) in [ + (-1, false), + (0, true), + (SESSION_EXPIRY_GRACE_SECONDS - 1, true), + (SESSION_EXPIRY_GRACE_SECONDS, false), + ] { + assert_eq!( + within_session_expiry_grace( + &expiry.to_rfc3339(), + expiry + Duration::seconds(offset) + ), + expected + ); + } + assert!(!within_session_expiry_grace("invalid", expiry)); + } + + #[tokio::test] + async fn duplicate_cookies_never_select_an_actor_or_promote_an_ambiguous_marker() { + use server_api::BrowserSessionState; + let store = seeded_store(); + let expired_hash = token_hash("expired-duplicate"); + store + .create_browser_session(&BrowserSessionRecord { + session_id: "expired-duplicate".into(), + token_hash: expired_hash.clone(), + user_id: "user-auth".into(), + created_at: now_rfc3339(), + expires_at: rfc3339_after(Duration::seconds(-60)), + revoked_at: None, + }) + .unwrap(); + for cookies in [ + vec!["session=session-secret; session=unknown".to_owned()], + vec!["session=session-secret; session=session-secret".to_owned()], + vec![ + "session=session-secret".to_owned(), + "session=unknown".to_owned(), + ], + vec![format!( + "session=; session=session-secret; session_expiry={expired_hash}" + )], + vec![format!( + "session_expiry={expired_hash}; session_expiry={expired_hash}" + )], + vec![format!("session; session_expiry={expired_hash}")], + ] { + let mut headers = HeaderMap::new(); + for cookie in cookies { + headers.append(header::COOKIE, cookie.parse().unwrap()); + } + assert!( + resolve_request_actor(store.clone(), &headers, "session") + .await + .unwrap() + .is_none() + ); + assert_eq!( + resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap() + .session, + BrowserSessionState::Anonymous {} + ); + } + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + format!("session=session-secret; session_expiry={expired_hash}") + .parse() + .unwrap(), + ); + assert!(matches!( + resolve_whoami(store, &headers, "session") + .await + .unwrap() + .session, + BrowserSessionState::Authenticated { .. } + )); + } + + #[test] + fn session_issuance_reclaims_only_rows_beyond_recovery_grace() { + let store = seeded_store(); + let record = |secret: &str, offset| BrowserSessionRecord { + session_id: secret.into(), + token_hash: token_hash(secret), + user_id: "user-auth".into(), + created_at: now_rfc3339(), + expires_at: rfc3339_after(Duration::seconds(offset)), + revoked_at: None, + }; + store + .create_browser_session(&record("past-grace", -SESSION_EXPIRY_GRACE_SECONDS - 60)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("past-grace")) + .unwrap() + .is_some() + ); + store + .create_browser_session(&record("within-grace", -SESSION_EXPIRY_GRACE_SECONDS + 60)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("past-grace")) + .unwrap() + .is_none() + ); + store + .create_browser_session(&record("new-session", BROWSER_SESSION_SECONDS)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("within-grace")) + .unwrap() + .is_some() + ); + assert!( + store + .resolve_browser_session(&token_hash("new-session")) + .unwrap() + .is_some() + ); + } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index dd256fea9..30732afac 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6856,6 +6856,7 @@ async fn attach_server_request_context( } fn generated_auth_contract_router(service: ServerApiContractService) -> Router { + let auth = auth_public_config(&service.auth_api().config); let service = Arc::new(service); let origin_context = Router::new() .merge(server_api::server_api_axum::auth_activation_options( @@ -6887,7 +6888,6 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { .merge(server_api::server_api_axum::auth_device_login_approve( service.clone(), )) - .merge(server_api::server_api_axum::auth_whoami(service.clone())) .layer(middleware::from_fn_with_state( service.as_ref().clone(), attach_server_request_context, @@ -6908,12 +6908,20 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { service.clone(), )) .merge(server_api::server_api_axum::auth_logout(service.clone())) + .merge( + server_api::server_api_axum::auth_whoami(service.clone()) + .layer(middleware::from_fn(attach_server_origin_context)), + ) .merge(server_api::server_api_axum::auth_device_login_start( service.clone(), )) .merge(server_api::server_api_axum::auth_device_login_poll(service)) .merge(origin_context) .merge(actor_context) + .layer(middleware::from_fn_with_state( + auth, + append_session_marker_cookie, + )) .layer(middleware::map_response( |mut response: Response| async move { // Current role/policy and authentication responses must not be cached @@ -6926,6 +6934,62 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { )) } +// The typed auth operations expose one Set-Cookie. At the HTTP boundary, pair only a real +// session Set-Cookie with a non-authenticating expiry marker, including login and logout. +// Mirroring its attributes keeps Secure/Path/Domain/SameSite aligned without a second policy. +async fn append_session_marker_cookie( + State(auth): State, + request: Request, + next: Next, +) -> Response { + let mut response = next.run(request).await; + let marker = response + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .find_map(|value| { + let value = value.to_str().ok()?; + let (pair, attributes) = value.split_once(';')?; + let (name, token) = pair.split_once('=')?; + if name != auth.cookie_name { + return None; + } + let max_age = attributes + .split(';') + .find_map(|attr| attr.trim().strip_prefix("Max-Age=")?.parse::().ok())?; + let (hash, marker_age) = if max_age <= 0 { + (String::new(), 0) + } else { + ( + token_hash(token), + max_age + crate::auth::SESSION_EXPIRY_GRACE_SECONDS, + ) + }; + let attributes = attributes + .split(';') + .map(|attr| { + if attr.trim().starts_with("Max-Age=") { + format!(" Max-Age={marker_age}") + } else { + attr.to_owned() + } + }) + .collect::>() + .join(";"); + HeaderValue::from_str(&format!( + "{}={hash};{attributes}", + crate::auth::session_marker_cookie_name(name) + )) + .ok() + }); + if let Some(marker) = marker { + response + .headers_mut() + .append(axum::http::header::SET_COOKIE, marker); + } + response +} + fn generated_workspace_catalog_contract_router(service: ServerApiContractService) -> Router { let service = Arc::new(service); Router::new() @@ -8151,9 +8215,15 @@ impl server_api::ServerApi for ServerApiContractService { &self, context: server_api::ServerRequestContext, ) -> std::result::Result { - Ok(WhoamiResponse { - actor: context.actor, - }) + let headers = contract_request_headers(&context)?; + let api = self.auth_api(); + crate::auth::resolve_whoami( + api.store.clone(), + &headers, + &auth_public_config(&api.config).cookie_name, + ) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error()) } async fn auth_logout( @@ -30307,14 +30377,14 @@ fn issue_browser_session( token_hash: token_hash(&session_token), user_id: user.user_id.clone(), created_at: crate::auth::now_rfc3339(), - expires_at: rfc3339_after(Duration::days(14)), + expires_at: rfc3339_after(Duration::seconds(crate::auth::BROWSER_SESSION_SECONDS)), revoked_at: None, })?; let auth = auth_public_config(&api.config); let cookie = session_set_cookie( session_cookie_policy(&auth), &session_token, - 14 * 24 * 60 * 60, + crate::auth::BROWSER_SESSION_SECONDS, ); Ok(( AuthUserResponse { @@ -30510,6 +30580,16 @@ fn logout_browser_session( .store .revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?; } + // A missing auth cookie after Max-Age must not prevent explicit logout from revoking + // the recovery reference. This hash is never accepted as an authentication credential. + if let Some(hash) = parse_cookie( + &headers, + &crate::auth::session_marker_cookie_name(&auth.cookie_name), + ) { + let _ = api + .store + .revoke_browser_session(&hash, &crate::auth::now_rfc3339())?; + } Ok(( LogoutResponse { status: LogoutStatus::LoggedOut, @@ -53509,6 +53589,7 @@ mod tests { .await .unwrap(); assert_eq!(registration_response.status(), StatusCode::OK); + assert_auth_response_has_expiry_marker(registration_response.headers(), &cookie_name); let registration_cookie = registration_response .headers() .get(axum::http::header::SET_COOKIE) @@ -53546,6 +53627,12 @@ mod tests { .unwrap(), ) .unwrap(); + assert_eq!(registered_whoami["session"]["kind"], "authenticated"); + assert!( + registered_whoami["session"]["expires_at"] + .as_str() + .is_some() + ); assert_eq!( registered_whoami["actor"]["handle"], "generated-passkey-user" @@ -53600,6 +53687,7 @@ mod tests { .await .unwrap(); assert_eq!(login_response.status(), StatusCode::OK); + assert_auth_response_has_expiry_marker(login_response.headers(), &cookie_name); let login_cookie = login_response .headers() .get(axum::http::header::SET_COOKIE) @@ -53684,6 +53772,169 @@ mod tests { assert!(logged_out_whoami["actor"].is_null()); } + // Real generated auth routes, DB executor and SQLite; no auth resolution is mocked. + #[tokio::test] + async fn whoami_expiry_marker_survives_auth_cookie_loss_but_logout_revokes_it() { + let workspace = tempfile::tempdir().unwrap(); + let api = test_api(workspace.path()).await; + seed_test_api_token(api.store.as_ref(), "expiry-router"); + let user = api.store.get_user("user-expiry-router").unwrap().unwrap(); + let auth = auth_public_config(&api.config); + let cookie_name = auth.cookie_name; + let hash = token_hash("expired-router-secret"); + api.store + .create_browser_session(&BrowserSessionRecord { + session_id: "expired-router-session".into(), + token_hash: hash.clone(), + user_id: user.user_id, + created_at: crate::auth::now_rfc3339(), + expires_at: rfc3339_after(Duration::minutes(-1)), + revoked_at: None, + }) + .unwrap(); + let marker = format!( + "{}={hash}", + crate::auth::session_marker_cookie_name(&cookie_name) + ); + let app = generated_auth_contract_router(ServerApiContractService::Workspace(api.clone())); + for (cookie, kind) in [ + (String::new(), "anonymous"), + (format!("{cookie_name}=expired-router-secret"), "expired"), + (marker.clone(), "expired"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); + assert!( + !response + .headers() + .contains_key(axum::http::header::SET_COOKIE) + ); + let value: Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(value["actor"], Value::Null); + assert_eq!(value["session"]["kind"], kind); + } + let denied = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/addition/options") + .header(axum::http::header::COOKIE, &marker) + .header(ORIGIN, &auth.origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + // Preserve the operation's existing auth_required/403 contract; no HTML redirect. + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + assert!(!denied.headers().contains_key(axum::http::header::LOCATION)); + let logout = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/logout") + .header(axum::http::header::COOKIE, &marker) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logout.status(), StatusCode::OK); + let cleared: Vec<_> = logout + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + assert_eq!(cleared.len(), 2); + assert!( + cleared + .iter() + .all(|value| value.contains("Max-Age=0") && value.contains("HttpOnly")) + ); + assert!(cleared.iter().any(|value| value.starts_with(&format!( + "{}=;", + crate::auth::session_marker_cookie_name(&cookie_name) + )))); + assert!(api.store.resolve_browser_session(&hash).unwrap().is_none()); + let response = app + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, marker) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let value: Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(value["session"]["kind"], "anonymous"); + } + + fn assert_auth_response_has_expiry_marker(headers: &HeaderMap, cookie_name: &str) { + let cookies: Vec<_> = headers + .get_all(axum::http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().unwrap()) + .collect(); + let auth = cookies + .iter() + .find(|value| value.starts_with(&format!("{cookie_name}="))) + .unwrap(); + let (_, token) = auth.split(';').next().unwrap().split_once('=').unwrap(); + let marker = cookies + .iter() + .find(|value| { + value.starts_with(&format!( + "{}=", + crate::auth::session_marker_cookie_name(cookie_name) + )) + }) + .unwrap(); + let expected = token_hash(token); + assert_eq!( + parse_cookie( + &{ + let mut headers = HeaderMap::new(); + headers.insert(axum::http::header::COOKIE, marker.parse().unwrap()); + headers + }, + &crate::auth::session_marker_cookie_name(cookie_name) + ), + Some(expected) + ); + assert!(auth.contains(&format!("Max-Age={}", crate::auth::BROWSER_SESSION_SECONDS))); + assert!(marker.contains(&format!( + "Max-Age={}", + crate::auth::BROWSER_SESSION_SECONDS + crate::auth::SESSION_EXPIRY_GRACE_SECONDS + ))); + for attribute in ["Path=/", "HttpOnly", "SameSite=Lax", "Secure"] { + assert_eq!( + auth.contains(attribute), + marker.contains(attribute), + "{attribute}" + ); + } + } + #[tokio::test] async fn browser_session_set_and_clear_cookies_follow_public_https_scheme() { for (scheme, secure) in [("http", false), ("https", true)] { diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index b9dd24f8a..138b56fa6 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -7131,6 +7131,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> { self.with_immediate_transaction(|conn| { activation::require_active(conn, &record.user_id)?; + // Retain the real row throughout the non-authenticating recovery window, not + // forever. Issuance amortizes reclamation without extending marker lifetime or + // introducing another store/scheduler. Inactive stores do not grow between issues. + let cutoff = (chrono::Utc::now() + - chrono::Duration::seconds(crate::auth::SESSION_EXPIRY_GRACE_SECONDS)) + .to_rfc3339(); + conn.execute( + "DELETE FROM browser_sessions WHERE julianday(expires_at) <= julianday(?1) OR julianday(expires_at) IS NULL", + params![cutoff], + )?; conn.execute( r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, diff --git a/openapi/server-api.json b/openapi/server-api.json index e601f843e..138988240 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -443,6 +443,56 @@ "additionalProperties": false, "type": "object" }, + "BrowserSessionState": { + "description": "Browser navigation evidence, not an authorization grant or a generic 401 classification.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "anonymous", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expires_at": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "const": "authenticated", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "expired", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, "BrowserTicketState": { "enum": [ "open", @@ -14846,8 +14896,14 @@ "type": "null" } ] + }, + "session": { + "$ref": "#/components/schemas/BrowserSessionState" } }, + "required": [ + "session" + ], "type": "object" }, "WorkerBusyState": { @@ -17831,7 +17887,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d" + "x-yoi-source-digest": "sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f" }, "openapi": "3.1.0", "paths": { diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index 9638ae45a..b308eeb49 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -19,6 +19,8 @@ sha2.workspace = true tempfile.workspace = true [dev-dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock"] } +yoi-workspace-server.workspace = true server-api.workspace = true webauthn-rs-proto = "0.5.5" webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } diff --git a/tests/e2e/browser/auth_sessions.ts b/tests/e2e/browser/auth_sessions.ts new file mode 100644 index 000000000..226ae9e76 --- /dev/null +++ b/tests/e2e/browser/auth_sessions.ts @@ -0,0 +1,435 @@ +/// +// WorkerE2eEnvironment所有の実Backendへ全APIをproxyし、本番static shellを配信する。 +// fixture-serverと同じSPA配信方式。製品APIのwhoami、認証、認可、Set-Cookieは置換しない。 +// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。 +// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。 +import { assert, assertEquals } from "@std/assert"; +import { extname, join, normalize } from "@std/path"; +import { type Browser, type BrowserContext, chromium } from "playwright"; + +const [backend, origin, build, handle] = Deno.args; +if (!backend || !origin || !build || !handle) { + throw new Error( + "Expected isolated Backend URL, browser origin, production build, handle", + ); +} +await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。 +const mime: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", + ".ico": "image/x-icon", +}; +const server = Deno.serve({ + hostname: "127.0.0.1", + port: Number(new URL(origin).port), + onListen() {}, +}, async (request) => { + const url = new URL(request.url); + if (url.pathname === "/__e2e/expired-session" && request.method === "POST") { + if (request.headers.get("origin") !== origin) { + return new Response(null, { status: 403 }); + } + // Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。 + const fixture = await bridge("expired-session", {}) as { + set_cookies: string[]; + }; + const headers = new Headers({ "cache-control": "no-store" }); + for (const cookie of fixture.set_cookies) { + headers.append("set-cookie", cookie); + } + return new Response(null, { status: 204, headers }); + } + if ( + url.pathname === "/__e2e/primary-cookie-present" && request.method === "GET" + ) { + // HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。 + const present = request.headers.get("cookie")?.split(";").some((pair) => + pair.trim().split("=", 1)[0] === "yoi_workspace_session" + ) ?? false; + return Response.json(present, { headers: { "cache-control": "no-store" } }); + } + if (url.pathname.startsWith("/api/")) { + const headers = new Headers(request.headers); + headers.delete("host"); + headers.delete("connection"); + // OriginとCookieを保持し、Set-Cookieを含む実応答をそのまま返す。 + return await fetch(new URL(url.pathname + url.search, backend), { + method: request.method, + headers, + body: ["GET", "HEAD"].includes(request.method) + ? undefined + : await request.arrayBuffer(), + redirect: "manual", + }); + } + const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html"); + if (relative.startsWith("..")) { + return new Response("not found", { status: 404 }); + } + let file = join(build, relative); + try { + if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html"); + return new Response(await Deno.readFile(file), { + headers: { + "content-type": mime[extname(file)] ?? "application/octet-stream", + }, + }); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + if (url.pathname.startsWith("/_app/")) { + return new Response("not found", { status: 404 }); + } + return new Response(await Deno.readFile(join(build, "index.html")), { + headers: { "content-type": mime[".html"] }, + }); + } +}); + +// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。 +const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream()) + .getReader(); +let pending = ""; +async function bridge(operation: string, options: unknown): Promise { + await Deno.stdout.write( + new TextEncoder().encode(JSON.stringify({ operation, options }) + "\n"), + ); + while (!pending.includes("\n")) { + const { done, value } = await input.read(); + if (done) throw new Error("ResidentAuthenticator disconnected"); + pending += value; + if (pending.length > 1024 * 1024) throw new Error("Oversized credential"); + } + const newline = pending.indexOf("\n"); + const credential = JSON.parse(pending.slice(0, newline)); + pending = pending.slice(newline + 1); + return credential; +} +let browser: Browser | undefined; +let context: BrowserContext | undefined; +const terminate = () => { + void (async () => { + await browser?.close(); + await server.shutdown(); + Deno.exit(143); + })(); +}; +Deno.addSignalListener("SIGTERM", terminate); +try { + browser = await chromium.launch({ headless: true, timeout: 15_000 }); + context = await browser.newContext(); + context.setDefaultTimeout(10_000); + await context.route( + "**/*", + (route) => + new URL(route.request().url()).origin === origin + ? route.continue() + : route.abort(), + ); + await context.exposeBinding( + "residentCredential", + (_source, operation: string, options: unknown) => + bridge(operation, options), + ); + await context.addInitScript(() => { + const encode = (value: unknown): unknown => { + if (value instanceof ArrayBuffer) { + return btoa(String.fromCharCode(...new Uint8Array(value))) + .replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + } + if (Array.isArray(value)) return value.map(encode); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, encode(item)]), + ); + } + return value; + }; + const bytes = (value: string) => { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + return Uint8Array.from( + atob(base64), + (character) => character.charCodeAt(0), + ).buffer; + }; + for (const operation of ["create", "get"] as const) { + Object.defineProperty(navigator.credentials, operation, { + configurable: true, + value: async ( + options: CredentialCreationOptions & CredentialRequestOptions, + ) => { + const bridge = (window as unknown as { + residentCredential: ( + operation: string, + options: unknown, + ) => Promise<{ + id: string; + rawId: string; + response: Record; + extensions?: unknown; + }>; + }).residentCredential; + const wire = await bridge(operation, { + publicKey: encode(options.publicKey), + }); + const response = Object.fromEntries( + Object.entries(wire.response).map(( + [key, value], + ) => [key, typeof value === "string" ? bytes(value) : value]), + ); + return { + id: wire.id, + rawId: bytes(wire.rawId), + type: "public-key", + getClientExtensionResults: () => wire.extensions ?? {}, + response: { + ...response, + getTransports: () => wire.response.transports ?? [], + }, + }; + }, + }); + } + }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const at = async (path: string) => { + await page.waitForURL((url) => + url.origin === origin && url.pathname + url.search + url.hash === path + ); + console.error(`navigation-ready: ${new URL(path, origin).pathname}`); + }; + const whoami = async () => { + const response = await page.request.get(`${origin}/api/auth/whoami`); + assertEquals(response.status(), 200); + return await response.json(); + }; + const account = "/account?view=passkeys&filter=a%2Fb#selected"; + const privateRequests: string[] = []; + const observePrivate = (request: { url(): string }) => { + const path = new URL(request.url()).pathname; + if (/^\/api\/(workspaces|w\/|hosting\/)/.test(path)) { + privateRequests.push(path); + } + }; + page.on("request", observePrivate); + await page.goto(origin + account); + await at("/"); + await page.getByRole("heading", { name: "Yoi", exact: true }).waitFor(); + assertEquals((await whoami()).session, { kind: "anonymous" }); + assertEquals( + privateRequests, + [], + "anonymous gate must run before private loaders", + ); + page.off("request", observePrivate); + + await page.goto( + `${origin}/register?${new URLSearchParams({ returnTo: account })}`, + ); + await page.getByLabel("Account ID (handle)", { exact: true }).fill(handle); + await page.getByLabel("Display name", { exact: true }).fill( + "Real browser E2E", + ); + const registration = page.waitForResponse((response) => + new URL(response.url()).pathname === + "/api/auth/passkeys/registration/complete" + ); + await page.getByRole("button", { name: "Register new account", exact: true }) + .click(); + assertEquals((await registration).status(), 200); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + const identity = await whoami(); + assertEquals(identity.actor?.handle, handle); + assertEquals(identity.session.kind, "authenticated"); + assert(typeof identity.session.expires_at === "string"); + const cookies = await context.cookies(origin); + const session = cookies.find((cookie) => + cookie.name === "yoi_workspace_session" + ); + const marker = cookies.find((cookie) => + cookie.name === "yoi_workspace_session_expiry" + ); + assert(session && marker, "Backend must issue both cookies"); + for (const cookie of [session, marker]) { + assert(cookie.httpOnly); + assertEquals(cookie.sameSite, "Lax"); + } + const seconds = (Date.parse(identity.session.expires_at) / 1000) - + Date.now() / 1000; + assert(seconds > 14 * 86400 - 60 && seconds <= 14 * 86400); + assert( + Math.abs(session.expires - Date.parse(identity.session.expires_at) / 1000) < + 2, + ); + assert(Math.abs(marker.expires - session.expires - 7 * 86400) < 2); + await page.goto(origin + account); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + await page.reload(); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + + // 明示logoutは実Serverのrevoke + 両cookie削除を通る。 + await page.getByRole("button", { name: "Log out", exact: true }).click(); + await at("/"); + assertEquals((await whoami()).session, { kind: "anonymous" }); + const afterLogout = await context.cookies(origin); + assert( + !afterLogout.some((cookie) => + [session.name, marker.name].includes(cookie.name) + ), + ); + const revoked = await page.request.get(`${origin}/api/auth/whoami`, { + headers: { + cookie: + `${session.name}=${session.value}; ${marker.name}=${marker.value}`, + }, + }); + assertEquals((await revoked.json()).session, { kind: "anonymous" }); + await page.goto(origin + account); + await at("/"); + + // 実discoverable loginで、確認済みの認証成功後のみpathname/query/fragmentへ復帰する。 + await page.goto( + `${origin}/login?${new URLSearchParams({ returnTo: account })}`, + ); + await page.reload(); + const login = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/passkeys/login/complete" + ); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .click(); + assertEquals((await login).status(), 200); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + assertEquals((await whoami()).actor?.handle, handle); + + // 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、 + // 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。 + const expiredTarget = "/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25"; + const catalogResponse = () => + page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/workspaces" && + response.request().method() === "GET" + ); + const initialCatalog = catalogResponse(); + await page.goto(origin + expiredTarget); + assertEquals((await initialCatalog).status(), 200); + await at(expiredTarget); + await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor(); + const issuedStatus = await page.evaluate(async () => { + const response = await fetch("/__e2e/expired-session", { + method: "POST", + cache: "no-store", + }); + return response.status; + }); + assertEquals(issuedStatus, 204); + const initiallyPresent = await page.evaluate(async () => + await (await fetch("/__e2e/primary-cookie-present", { cache: "no-store" })) + .json() + ); + assertEquals( + initiallyPresent, + true, + "short-lived primary must first reach Chromium", + ); + await page.evaluate(async () => { + const deadline = performance.now() + 10_000; + do { + const response = await fetch("/__e2e/primary-cookie-present", { + cache: "no-store", + }); + if (response.ok && await response.json() === false) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } while (performance.now() < deadline); + throw new Error("Primary session cookie did not naturally expire"); + }); + // 値をassertionへ渡さず、cookie jarにもprimaryが消えmarkerが残ることを確認する。 + const expiredCookieNames = (await context.cookies(origin)).map((cookie) => + cookie.name + ); + assert(!expiredCookieNames.includes(session.name)); + assert(expiredCookieNames.includes(marker.name)); + assertEquals(await whoami(), { + actor: null, + session: { kind: "expired" }, + }); + privateRequests.length = 0; + page.on("request", observePrivate); + const expiredWhoami = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/whoami" + ); + await page.reload(); + const expiredResponse = await expiredWhoami; + assertEquals(expiredResponse.status(), 200); + // SPAがloginへ移った後の旧document bodyをCDPから読み直さない。 + // 分類本文は直前/復帰待ち後にも同じ実Backendから取得し、browser要求はstatusを観測する。 + const expiredLogin = `/login?${new URLSearchParams({ + returnTo: expiredTarget, + })}`; + await at(expiredLogin); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .waitFor(); + assertEquals( + privateRequests, + [], + "expired reload must gate the real private catalog loader", + ); + assertEquals(await whoami(), { actor: null, session: { kind: "expired" } }); + await page.reload(); + await at(expiredLogin); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .waitFor(); + assertEquals( + privateRequests, + [], + "login reload must not lose or privately load returnTo", + ); + page.off("request", observePrivate); + const recoveredCatalog = catalogResponse(); + const relogin = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/passkeys/login/complete" + ); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .click(); + assertEquals((await relogin).status(), 200); + await at(expiredTarget); + assertEquals((await recoveredCatalog).status(), 200); + await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor(); + const recovered = await whoami(); + assertEquals(recovered.session.kind, "authenticated"); + assertEquals(recovered.actor?.user_id, identity.actor.user_id); + assertEquals(recovered.actor?.handle, handle); + await page.goto(origin + account); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + + // fixtureでprimaryだけを除去。Server時計は進めていないため、markerのみでは + // authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。 + await context.clearCookies({ name: session.name }); + const partial = await whoami(); + assertEquals(partial, { actor: null, session: { kind: "anonymous" } }); + await page.reload(); + await at("/"); + await context.clearCookies(); + await page.goto(origin + account); + await at("/"); + assertEquals((await whoami()).session, { kind: "anonymous" }); + assertEquals(errors, []); +} finally { + await context?.close(); + await browser?.close(); + await server.shutdown(); + Deno.removeSignalListener("SIGTERM", terminate); +} +await Deno.stdout.write(new TextEncoder().encode('{"operation":"done"}\n')); +// 親はstdout EOFで完了を確定する。stdin streamのprefetch取消を待つと相互待ちになる。 +Deno.exit(0); diff --git a/tests/e2e/tests/auth_passkeys.rs b/tests/e2e/tests/auth_passkeys.rs index 95dce869b..7195bb3bb 100644 --- a/tests/e2e/tests/auth_passkeys.rs +++ b/tests/e2e/tests/auth_passkeys.rs @@ -1,11 +1,12 @@ //! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、 //! ID不要login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、 -//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(browser UI/OS認証器は対象外)。 +//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(OS/物理認証器は対象外)。 //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。 //! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。 -//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 -//! `/`・`/login`・`/dashboard`のstatic shellは対象外。実Server CLIにはstatic assets設定経路がなく、 -//! shell表示/browser遷移は本番routerのin-process検証やbrowser側の合成HTTP検証と区別する。 +//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 +//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが +//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。 +//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。 use std::error::Error; use std::fs::OpenOptions; @@ -29,6 +30,9 @@ mod resident_model; use resident_model::ResidentAuthenticator; use yoi_e2e::WorkerE2eEnvironment; +#[path = "auth_passkeys/browser_sessions.rs"] +mod browser_sessions; + type Result = std::result::Result>; const BROWSER_ORIGIN: &str = "http://localhost:5173"; diff --git a/tests/e2e/tests/auth_passkeys/browser_sessions.rs b/tests/e2e/tests/auth_passkeys/browser_sessions.rs new file mode 100644 index 000000000..7207e4124 --- /dev/null +++ b/tests/e2e/tests/auth_passkeys/browser_sessions.rs @@ -0,0 +1,233 @@ +//! 実Backend・本番static shell・Chromium cookie jarの接続を検証する。 +//! navigator.credentialsだけを共有ResidentAuthenticatorの署名へ接続する。 +//! 期限切れケースは実登録userへ公開store APIで固定expiry sessionを作り、 +//! 本番cookie生成関数の短命primary/猶予markerをfixture HTTPで配信する。 +//! SQL直書き・resolver/APIの置換・製品clockの追加はしない。 +//! 実発行14日cookieの属性/DTOと短命fixtureの自然消失/復帰を分けて検証する。 +//! OS/物理認証器と14日経過したServer時計は検証しない。 +//! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite: +//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored` +//! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。 +use super::*; +use std::io::{BufRead, BufReader, Read}; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Instant; +use yoi_workspace_server::auth::{ + SESSION_EXPIRY_GRACE_SECONDS, SessionCookiePolicy, mint_secret, new_id, + session_marker_cookie_name, session_set_cookie, token_hash, +}; +use yoi_workspace_server::store::{BrowserSessionRecord, ControlPlaneStore, SqliteWorkspaceStore}; + +// 戻り値はprivate IPC→fixture HTTPのSet-Cookieだけに使い、assert/debug/artifactへ出さない。 +fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result { + // default環境や任意pathへ接続せず、このenvironmentが起動した既存DBだけを開く。 + let database = environment + .server_data_dir + .join("server.db") + .canonicalize()?; + if !database.starts_with(environment.root.canonicalize()?) { + return Err("expired-session fixture DB is outside its environment".into()); + } + let store = SqliteWorkspaceStore::open(database)?; + let user = store + .get_user_by_handle(&environment.account_handle)? + .ok_or("browser registration must create the fixture user first")?; + let token = mint_secret("yoi_sess"); + let hash = token_hash(&token); + let now = chrono::Utc::now(); + // Server時計を動かさず、既に期限切れだが実猶予内のrecordを公開store APIで保存する。 + store.create_browser_session(&BrowserSessionRecord { + session_id: new_id("session"), + token_hash: hash.clone(), + user_id: user.user_id, + created_at: (now - chrono::Duration::days(14) - chrono::Duration::seconds(60)).to_rfc3339(), + expires_at: (now - chrono::Duration::seconds(60)).to_rfc3339(), + revoked_at: None, + })?; + let policy = SessionCookiePolicy { + cookie_name: "yoi_workspace_session", + path: "/", + domain: None, + secure: false, + }; + let marker_name = session_marker_cookie_name(policy.cookie_name); + Ok(json!({"set_cookies": [ + session_set_cookie(policy, &token, 2), + session_set_cookie(SessionCookiePolicy { + cookie_name: &marker_name, + ..policy + }, &hash, SESSION_EXPIRY_GRACE_SECONDS - 60), + ]})) +} + +struct BrowserProcess(Option); +impl BrowserProcess { + fn child(&mut self) -> &mut Child { + self.0.as_mut().expect("browser process retained") + } +} +impl Drop for BrowserProcess { + fn drop(&mut self) { + let Some(mut child) = self.0.take() else { + return; + }; + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + let pid = child.id() as libc::pid_t; + // Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。 + unsafe { + libc::kill(pid, libc::SIGTERM); + } + let (send, receive) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + let _ = send.send(child.wait()); + }); + if receive.recv_timeout(Duration::from_secs(3)).is_err() { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + let _ = waiter.join(); + } +} + +#[test] +#[ignore = "requires production Web assets, Deno and Playwright Chromium"] +fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit_logout() -> Result +{ + // Backend/process/DBの所有は既存harnessに限定する。Web buildは呼出元が用意する。 + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let build = root.join("web/workspace/build"); + if !build.join("index.html").is_file() { + return Err("build web/workspace production assets before selecting browser E2E".into()); + } + let mut environment = WorkerE2eEnvironment::new()?; + let reservation = TcpListener::bind("127.0.0.1:0")?; + let port = reservation.local_addr()?.port(); + let origin = format!("http://localhost:{port}"); + std::fs::write( + &environment.server_config, + format!("[browser]\npublic_url = \"{origin}\"\n"), + )?; + environment.start_empty_backend()?; + let admin = + environment.setup_hosting_admin(&format!("{}-admin", environment.account_handle), true)?; + let bearer = admin["access_token"] + .as_str() + .ok_or("missing setup token")?; + let base = environment + .server_url + .as_deref() + .ok_or("missing Backend URL")?; + success( + http_client()? + .put(format!("{base}/api/hosting/settings")) + .bearer_auth(bearer) + .json(&json!({"self_registration_enabled": true})) + .send()?, + )?; + drop(reservation); + let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?; + let mut process = BrowserProcess(Some( + Command::new("deno") + .current_dir(&root) + .args([ + "run", + "--config", + "tools/web-ux/deno.json", + "--no-lock", + "--allow-env", + "--allow-net", + "--allow-read", + "--allow-write", + "--allow-run", + "--allow-sys", + "tests/e2e/browser/auth_sessions.ts", + base, + &origin, + build.to_str().ok_or("non-UTF8 build path")?, + &environment.account_handle, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(stderr) + .spawn()?, + )); + let stdout = process + .child() + .stdout + .take() + .ok_or("missing browser stdout")?; + let mut stdin = process + .child() + .stdin + .take() + .ok_or("missing browser stdin")?; + let (send, receive) = mpsc::sync_channel(4); + // bounded NDJSONは実WebAuthn options/署名とfixture cookieを運び、artifactには記録しない。 + let reader = std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.by_ref().take(1024 * 1024).read_line(&mut line) { + Ok(0) => break, + Ok(_) if line.ends_with('\n') => { + if send.send(Ok(line)).is_err() { + break; + } + } + _ => { + let _ = send.send(Err("invalid or oversized browser IPC")); + break; + } + } + } + }); + let deadline = Instant::now() + Duration::from_secs(120); + let mut authenticator = ResidentAuthenticator::new(true); + let mut completed = false; + loop { + let message = match receive.recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + Ok(message) => message.map_err(std::io::Error::other)?, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(_) => return Err("browser E2E timed out; see browser.stderr.log".into()), + }; + let request: Value = serde_json::from_str(&message)?; + let credential = match request["operation"].as_str() { + Some("create") => serde_json::to_value(authenticator.do_registration( + origin.parse()?, + serde_json::from_value(request["options"].clone())?, + )?)?, + Some("get") => serde_json::to_value(authenticator.do_authentication( + origin.parse()?, + serde_json::from_value(request["options"].clone())?, + )?)?, + Some("expired-session") => expired_session_fixture(&environment)?, + Some("done") => { + completed = true; + continue; + } + _ => return Err("unexpected browser IPC operation".into()), + }; + serde_json::to_writer(&mut stdin, &credential)?; + writeln!(stdin)?; + stdin.flush()?; + } + reader.join().map_err(|_| "browser reader panicked")?; + assert!( + completed, + "browser did not complete; see browser.stderr.log" + ); + assert!( + process.child().wait()?.success(), + "browser E2E failed; see browser.stderr.log" + ); + drop(process); + let report = environment.cleanup()?; + assert!(report.cleanup_success); + Ok(()) +} diff --git a/web/workspace/src/lib/generated/auth-api.ts b/web/workspace/src/lib/generated/auth-api.ts index 774a2852b..788ded8eb 100644 --- a/web/workspace/src/lib/generated/auth-api.ts +++ b/web/workspace/src/lib/generated/auth-api.ts @@ -19,7 +19,9 @@ export type AuthenticatedUser = { user_id: string, account_id: string, handle: s export type RequestActor = { user_id: string, account_id: string, handle: string, display_name: string, auth_method: ActorAuthMethod, }; -export type WhoamiResponse = { actor: RequestActor | null, }; +export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", }; + +export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, }; export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, }; diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 146d0ce28..5c09ef0af 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 74a8c9547..6fcb5b03d 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 138ed0fd1..79c053a6d 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 2dbcf6109..11391dbd3 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 8fff5d2ea..63f2881c3 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 27773b1bc..02dc0f39e 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -1,3 +1,7 @@ +import { + browserSession, + recheckAfterUnauthorized, +} from "#lib/workspace/auth/session-state.ts"; import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts"; import type { SkillCatalogResponse, @@ -102,7 +106,11 @@ export async function loadJson( ): Promise> { let stage: "request" | "decode" | "validate" = "request"; try { + const authenticationGeneration = browserSession.authenticationGeneration; const response = await fetchFn(path, init); + if (response.status === 401) { + recheckAfterUnauthorized(authenticationGeneration); + } if (!response.ok) { if (policy) { await response.body?.cancel(); diff --git a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte index 2dafcddd9..46e41c345 100644 --- a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte +++ b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte @@ -2,7 +2,8 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; - import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey } from './api.ts'; + import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey, logout } from './api.ts'; + import { CLEAR_RETURN_EVENT } from './session-state.ts'; import { BrowserAuth } from './browser-session.svelte.ts'; import { loadRegistrationPolicy } from './hosting.ts'; import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts'; @@ -58,11 +59,16 @@ } } + async function forget() { + await auth.run('Logging out…', 'Logged out.', logout, 'logout'); + } onMount(() => { + const clearReturn = () => { void goto(page.url.pathname, { replaceState: true }); }; + window.addEventListener(CLEAR_RETURN_EVENT, clearReturn); void auth.refresh(); void refreshPolicy(); window.addEventListener('focus', refreshPolicy); - return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); }; + return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); window.removeEventListener(CLEAR_RETURN_EVENT, clearReturn); }; }); @@ -74,11 +80,16 @@ {:else if auth.session.kind === 'error'} + {:else if auth.actor}

Logged in as {auth.actor.display_name} @{auth.actor.handle}.

Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'} Open Account {:else} + {#if auth.session.session.kind === 'expired'} +

Your browser session expired. Log in to return to the requested page.

+ + {/if} {#if mode === 'register'} {#if policyLoading}

Checking registration availability…

diff --git a/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte b/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte new file mode 100644 index 000000000..e910913e6 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte @@ -0,0 +1,71 @@ + + +{#if !protectedPage} + {@render children()} +{:else} + {#if session.kind === 'loading'} +

Loading session…

+ {:else if session.kind === 'error'} +
+ + + Yoi home +
+ {/if} + {#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted} + + + {/if} +{/if} diff --git a/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts index ad24e6937..3cbac0ddf 100644 --- a/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts @@ -63,3 +63,12 @@ export function credentialFixture(authentication = false): Credential { }, } as unknown as Credential; } + +export function whoamiPayload(actor: unknown = null) { + return { + actor, + session: actor === null + ? { kind: "anonymous" } + : { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + }; +} diff --git a/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts index f956e48fa..d69840eb2 100644 --- a/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts +++ b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts @@ -1,16 +1,21 @@ -import { AuthRequestError, loadWhoami } from "./api.ts"; +import { AuthRequestError } from "./api.ts"; +import { + browserSession, + observeBrowserSession, + sessionMutation, +} from "./session-state.ts"; import { notifySessionChanged } from "./hosting.ts"; -import type { RequestActor } from "./model.ts"; +import type { RequestActor, WhoamiResponse } from "./model.ts"; export type BrowserSession = | { kind: "loading" } | { kind: "error" } - | { kind: "ready"; actor: RequestActor | null }; + | ({ kind: "ready" } & WhoamiResponse); export function authErrorMessage(error: unknown): string { if (error instanceof AuthRequestError) { if (error.status === 401) { - return "Your session or authentication challenge expired. Log in again or restart the Passkey operation."; + return "Authentication request was rejected. Check your session or restart the Passkey operation."; } if (error.status === 400 || error.status === 410) { return "The request or authentication challenge is invalid or expired. Restart the operation."; @@ -35,61 +40,77 @@ export class BrowserAuth { busy = $state(false); error = $state(""); status = $state(""); - private request = 0; + private unsubscribe: () => void; private disposed = false; private controller: AbortController | null = null; + constructor() { + observeBrowserSession(); + this.unsubscribe = browserSession.subscribe((snapshot) => { + // 操作pendingはsession確認中と別。formを消してfocus/入力を失わず、busyで再操作を止める。 + if (this.busy && snapshot.kind === "loading") return; + if (!this.disposed) { + this.session = snapshot.kind === "ready" + ? { kind: "ready", ...snapshot.value } + : snapshot; + } + }); + } + get actor(): RequestActor | null { return this.session.kind === "ready" ? this.session.actor : null; } async refresh(): Promise { if (this.busy || this.disposed) return; - await this.readSession(); - } - - private async readSession(): Promise { - const current = ++this.request; - this.session = { kind: "loading" }; - try { - const result = await loadWhoami(); - if (this.disposed || current !== this.request) return; - this.session = { kind: "ready", actor: result.actor }; - } catch { - if (this.disposed || current !== this.request) return; - this.session = { kind: "error" }; - } + await browserSession.refresh(); } async run( pending: string, success: string, operation: (signal: AbortSignal) => Promise, + intent: "authentication" | "logout" = "authentication", ): Promise { - if (this.busy || this.disposed || this.session.kind !== "ready") { + if ( + this.busy || this.disposed || + (intent !== "logout" && this.session.kind !== "ready") + ) { return false; } this.busy = true; - ++this.request; + sessionMutation(intent); + const authenticationGeneration = browserSession.authenticationGeneration; const controller = new AbortController(); this.controller = controller; this.error = ""; this.status = pending; try { await operation(controller.signal); - if (this.disposed) return false; + const superseded = + authenticationGeneration !== browserSession.authenticationGeneration; + sessionMutation(intent); + const confirmationGeneration = browserSession.authenticationGeneration; + await browserSession.refresh(); + if ( + this.disposed || superseded || + confirmationGeneration !== browserSession.authenticationGeneration + ) { + if (!this.disposed) this.status = ""; + return false; + } notifySessionChanged(); - await this.readSession(); - if (this.disposed) return false; const confirmed = this.session.kind === "ready"; this.status = confirmed ? success : ""; return confirmed; } catch (error) { - if (this.disposed) return false; - this.error = authErrorMessage(error); - this.status = ""; + if (!this.disposed) { + this.error = authErrorMessage(error); + this.status = ""; + } // 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。 - await this.readSession(); + sessionMutation(intent); + await browserSession.refresh(); return false; } finally { if (!this.disposed) { @@ -101,7 +122,7 @@ export class BrowserAuth { dispose(): void { this.disposed = true; - ++this.request; + this.unsubscribe(); this.controller?.abort(); } } diff --git a/web/workspace/src/lib/workspace/auth/model.test.ts b/web/workspace/src/lib/workspace/auth/model.test.ts index 177db4b8f..4c17c2df3 100644 --- a/web/workspace/src/lib/workspace/auth/model.test.ts +++ b/web/workspace/src/lib/workspace/auth/model.test.ts @@ -118,10 +118,14 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () }); Deno.test("whoami rejects unknown auth methods and extra fields", () => { - assertEquals(parseWhoamiResponse({ actor: null }), { actor: null }); + assertEquals( + parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }), + { actor: null, session: { kind: "anonymous" } }, + ); assertThrows( () => parseWhoamiResponse({ + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, actor: { user_id: "user-1", account_id: "account-1", @@ -233,3 +237,34 @@ Deno.test("passkey credential conversion fails closed on malformed payloads", () "unknown credential type", ); }); + +Deno.test("whoami fails closed on actor-state mismatch and malformed expiry", () => { + const actor = { + user_id: "u", + account_id: "a", + handle: "alice", + display_name: "Alice", + auth_method: "browser_session", + }; + for ( + const payload of [ + { actor, session: { kind: "anonymous" } }, + { actor, session: { kind: "expired" } }, + { + actor: null, + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + }, + { actor, session: { kind: "authenticated", expires_at: null } }, + { actor, session: { kind: "authenticated", expires_at: "tomorrow" } }, + { actor: null, session: { kind: "future" } }, + { + actor: null, + session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" }, + }, + ] + ) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami"); + assertEquals( + parseWhoamiResponse({ actor: null, session: { kind: "expired" } }), + { actor: null, session: { kind: "expired" } }, + ); +}); diff --git a/web/workspace/src/lib/workspace/auth/model.ts b/web/workspace/src/lib/workspace/auth/model.ts index a52b2e5a4..5923f32b8 100644 --- a/web/workspace/src/lib/workspace/auth/model.ts +++ b/web/workspace/src/lib/workspace/auth/model.ts @@ -463,12 +463,40 @@ function parseActor(value: unknown, path: string): RequestActor { export function parseWhoamiResponse(value: unknown): WhoamiResponse { const response = asRecord(value, "whoami"); - requireExactKeys(response, "whoami", ["actor"]); - return { - actor: response.actor === null + requireExactKeys(response, "whoami", ["actor", "session"]); + const actor = response.actor === null + ? null + : parseActor(response.actor, "whoami.actor"); + const session = asRecord(response.session, "whoami.session"); + if (session.kind === "authenticated") { + requireExactKeys(session, "whoami.session", ["kind", "expires_at"]); + if (actor === null) { + invalid("whoami", "authenticated session requires actor"); + } + const expiry = session.expires_at === null ? null - : parseActor(response.actor, "whoami.actor"), - }; + : boundedString(session.expires_at, "whoami.session.expires_at"); + if ( + (expiry !== null && + (!/^\d{4}-\d\d-\d\dT/.test(expiry) || + !Number.isFinite(Date.parse(expiry)))) || + (actor.auth_method === "browser_session" && expiry === null) + ) { + invalid( + "whoami.session.expires_at", + "must identify the server session expiry", + ); + } + return { actor, session: { kind: "authenticated", expires_at: expiry } }; + } + requireExactKeys(session, "whoami.session", ["kind"]); + if ( + actor !== null || + (session.kind !== "anonymous" && session.kind !== "expired") + ) { + invalid("whoami", "inconsistent actor or session state"); + } + return { actor: null, session: { kind: session.kind } }; } export function parseAuthPublicConfig(value: unknown): AuthPublicConfig { diff --git a/web/workspace/src/lib/workspace/auth/page-session.ts b/web/workspace/src/lib/workspace/auth/page-session.ts new file mode 100644 index 000000000..4ea824ddc --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/page-session.ts @@ -0,0 +1,28 @@ +import { error, redirect } from "@sveltejs/kit"; +import { browserSession, observeBrowserSession } from "./session-state.ts"; +import { isPublicPage, loginUrl } from "./routes.ts"; + +// root loadとそのparentを待つ各loaderが同じ確認を共有する。 +// SSRは無効。fragmentはHTTP/Serverに依存せず、browserの最終URLから回収する。 +export async function requirePageSession(url: URL): Promise { + observeBrowserSession(); + if (isPublicPage(url.pathname)) return; + const snapshot = await browserSession.refresh(); + if (snapshot !== browserSession.snapshot) return requirePageSession(url); + if (snapshot.kind !== "ready") { + error( + 503, + "Your session could not be loaded. Login status is unknown. Retry this page.", + ); + } + if (snapshot.value.session.kind === "authenticated") return; + if (snapshot.value.session.kind === "anonymous") redirect(307, "/"); + const target = new URL(url); + if ( + typeof location !== "undefined" && location.pathname === url.pathname && + location.search === url.search + ) { + target.hash = location.hash; + } + redirect(307, loginUrl(target.pathname + target.search + target.hash)); +} diff --git a/web/workspace/src/lib/workspace/auth/routes.test.ts b/web/workspace/src/lib/workspace/auth/routes.test.ts index c80b69926..feee08cd7 100644 --- a/web/workspace/src/lib/workspace/auth/routes.test.ts +++ b/web/workspace/src/lib/workspace/auth/routes.test.ts @@ -12,16 +12,16 @@ function assertEquals( } import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts"; -Deno.test("return destination permits known app screens without forwarding arbitrary query or fragment", () => { +Deno.test("return destination preserves query and fragment only on known app screens", () => { for ( const [input, expected] of [ ["/", "/dashboard"], - ["/dashboard?query=discard#fragment", "/dashboard"], + ["/dashboard?query=keep#fragment", "/dashboard?query=keep#fragment"], ["/account", "/account"], ["/account?returnTo=//evil.invalid#secret", "/account"], [ "/w/workspace-id/tickets/T-123?filter=all#entry", - "/w/workspace-id/tickets/T-123", + "/w/workspace-id/tickets/T-123?filter=all#entry", ], [ "/login/device?user_code=ABCD-EFGH", @@ -31,7 +31,7 @@ Deno.test("return destination permits known app screens without forwarding arbit "/login/device?code=abcd-1234&returnTo=//evil.invalid", "/login/device?user_code=abcd-1234", ], - ["/login/device?user_code=%252f%252fevil.invalid", "/login/device"], + ["/login/device?user_code=%252f%252fevil.invalid", "/dashboard"], ] ) assertEquals(safeReturnTo(input), expected, input); }); @@ -64,6 +64,11 @@ Deno.test("return destination rejects external, ambiguous encoding and authentic "/account\t", "/account\u0000", "/account%00", + "/account?q=%255c", + "/account?q=%0A", + "/account#%5c", + "/account?q=%ZZ", + "/account?q=%252f%252fevil.invalid", "/api/auth/logout", "/hosting/setup", "/not-an-app-route", @@ -86,3 +91,38 @@ Deno.test("login links encode one validated destination and discard nested auth "/login?returnTo=%2Fdashboard", ); }); + +Deno.test("return destination accepts canonical UTF-8 resource slugs but not encoded ASCII or delimiters", () => { + const target = `/w/team/tickets/T-123-${ + encodeURIComponent("未ログイン") + }?view=comments#entry-2`; + assertEquals(safeReturnTo(target), target); + for ( + const [input, expected] of [ + [ + "/w/team/tickets?filter=100%25#entry", + "/w/team/tickets?filter=100%25#entry", + ], + [ + "/w/team/tickets/%e6%9c%aa?filter=100%25#part%25", + "/w/team/tickets/%e6%9c%aa?filter=100%25#part%25", + ], + [ + "/w/team/tickets/未?query=a%2fb#entry%2f2", + "/w/team/tickets/%E6%9C%AA?query=a%2fb#entry%2f2", + ], + ] + ) { + assertEquals(safeReturnTo(input), expected); + } + for ( + const input of [ + "/w/team/tickets/%54-123", + "/w/team/tickets/T-123%2fother", + "/w/team/tickets/%E0%80%AF", + "/w/team/tickets/%252f", + ] + ) { + assertEquals(safeReturnTo(input), "/dashboard"); + } +}); diff --git a/web/workspace/src/lib/workspace/auth/routes.ts b/web/workspace/src/lib/workspace/auth/routes.ts index 595f8d44e..5324ee5a6 100644 --- a/web/workspace/src/lib/workspace/auth/routes.ts +++ b/web/workspace/src/lib/workspace/auth/routes.ts @@ -11,13 +11,25 @@ export function safeReturnTo(value: string | null): string { } const rawPath = value.split(/[?#]/, 1)[0]; if ( - rawPath.includes("%") || rawPath.includes("//") || + /%[0-7][0-9a-f]/i.test(rawPath) || rawPath.includes("//") || rawPath.split("/").some((part) => part === "." || part === "..") ) { return AUTHENTICATED_HOME; } let url: URL; + let decodedPath: string; try { + decodedPath = decodeURIComponent(rawPath); + // pathは一度だけdecodeして許可画面を判定する。UTF-8 escapeの大小や生Unicodeで + // origin/区切りは変わらないため、再encodeとの文字列一致は求めない。 + // query/fragmentはpathの権限を持たないdata。単独の%25は保持し、次のdecodeで + // 別文字へ変わる二重escapeとcontrol/backslashだけを拒否する。 + const decoded = decodeURIComponent(value); + if ( + /[\\\u0000-\u001f\u007f]/u.test(decoded) || /%[0-9a-f]{2}/i.test(decoded) + ) { + return AUTHENTICATED_HOME; + } url = new URL(value, "https://app.invalid"); } catch { return AUTHENTICATED_HOME; @@ -33,9 +45,12 @@ export function safeReturnTo(value: string | null): string { } if ( url.pathname === AUTHENTICATED_HOME || url.pathname === "/account" || - /^\/w\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_/-]*)?$/.test(url.pathname) + ["/hosting/settings", "/hosting/accounts"].includes(url.pathname) || + /^\/w\/[a-zA-Z0-9_-]+(?:\/[\p{Letter}\p{Number}_/-]*)?$/u.test(decodedPath) ) { - return url.pathname; + // 認証導線の入れ子は復帰先へ持ち込まない。検索条件とfragmentはURLとして保持する。 + if (url.searchParams.has("returnTo")) return url.pathname; + return url.pathname + url.search + url.hash; } return AUTHENTICATED_HOME; } @@ -53,7 +68,29 @@ export function authEntryUrl( })}`; } +export function isPublicPage(path: string): boolean { + return [ + "/", + "/login", + "/register", + "/activate", + "/login/device", + "/hosting/setup", + ] + .includes(path); +} + export function isAuthPage(path: string): boolean { return ["/login", "/register", "/account", "/activate", "/login/device"] .includes(path); } + +// Universal loadのURLにfragmentがなくても同じ表示中URLならbrowserから補完する。 +export function browserUrlSuffix(url: URL): string { + const hash = + typeof location !== "undefined" && location.pathname === url.pathname && + location.search === url.search + ? location.hash + : url.hash; + return url.search + hash; +} diff --git a/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts new file mode 100644 index 000000000..19bf093ef --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import { createRawSnippet } from "svelte"; +import SessionBoundary from "./SessionBoundary.svelte"; +import { browserSession } from "./session-state.ts"; +import { authUser, whoamiPayload } from "./api.test-fixtures.ts"; +import { goto } from "$app/navigation"; +const page = vi.hoisted(() => ({ + url: new URL("http://localhost:3000/w/team/tickets?q=mine#entry"), +})); +vi.mock("$app/state", () => ({ page })); +vi.mock( + "$app/navigation", + () => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }), +); +afterEach(() => { + cleanup(); + browserSession.invalidate(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); +const actor = { ...authUser, auth_method: "browser_session" }; +const children = createRawSnippet(() => ({ + render: () => "

Private fragment

", +})); +async function admitted() { + window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry"); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(whoamiPayload(actor))), + ); + await browserSession.refresh(); + render(SessionBoundary, { children }); + await screen.findByText("Private fragment"); +} + +test("active expired state removes private content and preserves browser query and fragment", async () => { + await admitted(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ actor: null, session: { kind: "expired" } }) + ), + ); + await browserSession.refresh(); + await waitFor(() => + expect(screen.queryByText("Private fragment")).toBeNull() + ); + expect(goto).toHaveBeenCalledWith( + "/login?returnTo=%2Fw%2Fteam%2Ftickets%3Fq%3Dmine%23entry", + { replaceState: true }, + ); +}); + +test("unknown state hides and inerts admitted content instead of declaring anonymous", async () => { + await admitted(); + let release!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn(() => + new Promise((resolve) => { + release = resolve; + }) + ), + ); + const checking = browserSession.refresh(); + await screen.findByText("Loading session…"); + expect(screen.getByText("Private fragment").closest("[hidden][inert]")) + .toBeTruthy(); + release(new Response(null, { status: 503 })); + await checking; + await screen.findByText( + "Your session could not be loaded. Login status is unknown.", + ); + expect(screen.getByText("Private fragment").closest("[hidden][inert]")) + .toBeTruthy(); + expect(goto).not.toHaveBeenCalled(); +}); + +test("another tab changing actor cannot reveal content loaded for the old actor", async () => { + await admitted(); + const reload = vi.spyOn(location, "reload").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + whoamiPayload({ + ...actor, + user_id: "different-user", + account_id: "different-account", + }), + ) + ), + ); + await browserSession.refresh(); + await waitFor(() => expect(reload).toHaveBeenCalledOnce()); + expect(screen.queryByText("Private fragment")).toBeNull(); +}); + +test("a child mount session recheck does not unmount and restart its own loader", async () => { + window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry"); + const fetcher = vi.fn(async () => Response.json(whoamiPayload(actor))); + vi.stubGlobal("fetch", fetcher); + await browserSession.refresh(); + let mounts = 0; + const checkingChild = createRawSnippet(() => ({ + render: () => "

Mounted session reader

", + setup: () => { + ++mounts; + void browserSession.refresh(); + }, + })); + render(SessionBoundary, { children: checkingChild }); + await waitFor(() => expect(browserSession.snapshot.kind).toBe("ready")); + expect(screen.getByText("Mounted session reader").closest("[hidden]")) + .toBeNull(); + expect(mounts).toBe(1); + expect(fetcher).toHaveBeenCalledTimes(2); +}); diff --git a/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts new file mode 100644 index 000000000..5ef8ddf6a --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +// whoami境界だけを代替し、epoch競合/再確認契機を決定的に検査する。 +// cookieと実storeの一致はServer/browser結合suiteが所有する。 +import { afterEach, expect, test, vi } from "vitest"; +import { SessionState } from "./session-state.ts"; +import type { WhoamiResponse } from "./model.ts"; +import { authUser } from "./api.test-fixtures.ts"; + +const states: SessionState[] = []; +afterEach(() => { + for (const state of states.splice(0)) state.dispose(); + vi.useRealTimers(); + localStorage.clear(); +}); +const anonymous: WhoamiResponse = { + actor: null, + session: { kind: "anonymous" }, +}; +const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } }; +const authenticated = ( + expires_at: string | null = "2099-01-01T00:00:00Z", +): WhoamiResponse => ({ + actor: { ...authUser, auth_method: "browser_session" }, + session: { kind: "authenticated", expires_at }, +}); +function gate() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function state(read: () => Promise) { + const result = new SessionState(read); + states.push(result); + return result; +} + +test("concurrent session checks share one authoritative read without classifying loading as anonymous", async () => { + const response = gate(); + const read = vi.fn(() => response.promise); + const session = state(read); + const first = session.refresh(); + const second = session.refresh(); + expect(session.snapshot.kind).toBe("loading"); + expect(read).toHaveBeenCalledTimes(1); + response.resolve(expired); + await Promise.all([first, second]); + expect(session.snapshot).toEqual({ kind: "ready", value: expired }); +}); + +test("delayed expired response cannot replace a new login in the same tab", async () => { + const older = gate(); + const read = vi.fn<() => Promise>().mockReturnValueOnce( + older.promise, + ).mockResolvedValue(authenticated()); + const session = state(read); + const first = session.refresh(); + session.invalidate(); + await session.refresh(); + older.resolve(expired); + await first; + expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() }); +}); + +test("storage generation fences responses even before the other tab storage event is delivered", async () => { + const older = gate(); + const read = vi.fn<() => Promise>().mockReturnValueOnce( + older.promise, + ).mockResolvedValue(authenticated()); + const session = state(read); + const first = session.refresh(); + localStorage.setItem( + "yoi.browser-auth-generation", + JSON.stringify({ id: "new-login", reason: "authentication" }), + ); + older.resolve(expired); + await first; + expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() }); +}); + +test("active deadline rechecks the server and reports failures as unknown, never inferred expiry", async () => { + vi.useFakeTimers(); + const read = vi.fn<() => Promise>().mockResolvedValueOnce( + authenticated(new Date(Date.now() + 5_000).toISOString()), + ).mockRejectedValueOnce(new Error("network")).mockResolvedValue(expired); + const session = state(read); + await session.refresh(); + await vi.advanceTimersByTimeAsync(5_000); + expect(session.snapshot).toEqual({ kind: "error" }); + await session.refresh(); + expect(session.snapshot).toEqual({ kind: "ready", value: expired }); +}); + +test("a challenge failure is not session expiry; recheck may still be authenticated or anonymous", async () => { + for (const value of [authenticated(), anonymous]) { + const session = state(async () => value); + session.invalidate(); + await session.refresh(); + expect(session.snapshot).toEqual({ kind: "ready", value }); + } +}); diff --git a/web/workspace/src/lib/workspace/auth/session-state.ts b/web/workspace/src/lib/workspace/auth/session-state.ts new file mode 100644 index 000000000..2ff372940 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-state.ts @@ -0,0 +1,153 @@ +/// +import { loadWhoami } from "./api.ts"; +import type { WhoamiResponse } from "./model.ts"; + +export type SessionSnapshot = + | { kind: "loading" } + | { kind: "error" } + | { kind: "ready"; value: WhoamiResponse }; + +// 世代は権限ではなく、認証操作より前に開始した応答を捨てるためだけに使う。 +// Cookieの実状態は常にwhoamiが所有し、tab間通知や一般401からexpiredを推測しない。 +const GENERATION_KEY = "yoi.browser-auth-generation"; +export class SessionState { + snapshot: SessionSnapshot = { kind: "loading" }; + private generation = 0; + private pending: Promise | null = null; + private listeners = new Set<(state: SessionSnapshot) => void>(); + private timer: ReturnType | undefined; + + get authenticationGeneration(): number { + return this.generation; + } + + constructor(private readonly read = loadWhoami) {} + + subscribe(listener: (state: SessionSnapshot) => void): () => void { + this.listeners.add(listener); + listener(this.snapshot); + return () => this.listeners.delete(listener); + } + + private publish(snapshot: SessionSnapshot): void { + this.snapshot = snapshot; + clearTimeout(this.timer); + if ( + snapshot.kind === "ready" && + snapshot.value.session.kind === "authenticated" + ) { + const expiry = snapshot.value.session.expires_at; + if (expiry !== null) { + // Timerは再照会の契機。local clockだけで期限切れと断定しない。 + const remaining = Date.parse(expiry) - Date.now(); + this.timer = setTimeout(() => { + void this.refresh(); + }, Math.min(2_147_483_647, Math.max(1_000, remaining))); + } + } + for (const listener of this.listeners) listener(snapshot); + } + + invalidate(): void { + ++this.generation; + this.pending = null; + this.publish({ kind: "loading" }); + } + + async refresh(): Promise { + if (this.pending) return this.pending; + const generation = this.generation; + const stamp = storageStamp(); + this.publish({ kind: "loading" }); + const pending = (async () => { + let result: SessionSnapshot; + try { + result = { kind: "ready", value: await this.read() }; + } catch { + result = { kind: "error" }; + } + if (generation !== this.generation || stamp !== storageStamp()) { + // 新世代が既に確定していれば再取得すら不要。古い完了で確定結果を巻き戻さない。 + if (generation !== this.generation) { + if (this.pending) return await this.pending; + if (this.snapshot.kind !== "loading") return this.snapshot; + } + // 別tabの操作がstorage eventより先に完了していても古い応答を採用しない。 + if (generation === this.generation) this.invalidate(); + return await this.refresh(); + } + this.pending = null; + this.publish(result); + return result; + })(); + this.pending = pending; + return pending; + } + + dispose(): void { + clearTimeout(this.timer); + this.listeners.clear(); + ++this.generation; + } +} + +function storageStamp(): string | null { + try { + return globalThis.localStorage?.getItem(GENERATION_KEY) ?? null; + } catch { + return null; + } +} + +export const browserSession = new SessionState(); + +export function sessionMutation( + reason: "authentication" | "logout" = "authentication", +): void { + browserSession.invalidate(); + const message = JSON.stringify({ id: crypto.randomUUID(), reason }); + try { + localStorage.setItem(GENERATION_KEY, message); + } catch { /* 保存拒否でも同tab fenceは維持。 */ } + if (reason === "logout") clearReturnTo(); +} + +export const CLEAR_RETURN_EVENT = "yoi:clear-auth-return"; +function clearReturnTo(): void { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(CLEAR_RETURN_EVENT)); + } +} + +let installed = false; +export function recheckAfterUnauthorized( + authenticationGeneration: number, +): void { + if ( + installed && + authenticationGeneration === browserSession.authenticationGeneration + ) void browserSession.refresh(); +} +export function observeBrowserSession(): void { + if (installed || typeof window === "undefined") return; + installed = true; + const refresh = () => { + void browserSession.refresh(); + }; + window.addEventListener("focus", refresh); + window.addEventListener("pageshow", refresh); + document.addEventListener("visibilitychange", () => { + if (!document.hidden) refresh(); + }); + window.addEventListener("storage", (event) => { + if (event.key !== GENERATION_KEY && event.key !== null) return; + browserSession.invalidate(); + try { + if ( + event.key === null || + JSON.parse(event.newValue ?? "null")?.reason === "logout" + ) clearReturnTo(); + } catch { /* 不明な通知も権限ではなく再照会だけ。 */ } + refresh(); + }); +} diff --git a/web/workspace/src/lib/workspace/auth/session.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts index b892e0086..559b97782 100644 --- a/web/workspace/src/lib/workspace/auth/session.browser.spec.ts +++ b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom import { afterEach, expect, test, vi } from "vitest"; +import { browserSession, sessionMutation } from "./session-state.ts"; import { BrowserAuth } from "./browser-session.svelte.ts"; import { addPasskey, loginWithPasskey, logout } from "./api.ts"; import { SESSION_CHANGED_EVENT } from "./hosting.ts"; @@ -13,6 +14,7 @@ import { const sessions: BrowserAuth[] = []; afterEach(() => { for (const auth of sessions.splice(0)) auth.dispose(); + browserSession.invalidate(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -33,6 +35,9 @@ function session() { function whoami(authenticated: boolean) { return Response.json({ + session: authenticated + ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } + : { kind: "anonymous" }, actor: authenticated ? { ...authUser, auth_method: "browser_session" } : null, @@ -98,7 +103,11 @@ test("loading and failed whoami are not anonymous readiness and cannot start aut expect(await auth.run("pending", "done", operation)).toBe(false); expect(operation).not.toHaveBeenCalled(); await auth.refresh(); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); }); for ( @@ -117,6 +126,7 @@ for ( ); const auth = session(); const first = auth.refresh(); + browserSession.invalidate(); const second = auth.refresh(); latest.resolve( latestStatus === 200 @@ -146,13 +156,21 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async () const oldResponse = gate(); fetcher.mockReturnValueOnce(oldResponse.promise); const oldRefresh = auth.refresh(); - await auth.refresh(); - expect(auth.actor?.handle).toBe("alice"); - expect(await auth.run("Logging out", "Logged out", logout)).toBe(true); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe( + true, + ); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); oldResponse.resolve(whoami(true)); await oldRefresh; - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(auth.status).toBe("Logged out"); }); @@ -190,7 +208,7 @@ test("busy login rejects duplicate operations and refresh until its post-complet options.resolve(Response.json(loginOptions)); await confirmingStarted.promise; expect(auth.busy).toBe(true); - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false); await auth.refresh(); expect(paths(fetcher).filter((path) => path === "/api/auth/whoami")) @@ -220,7 +238,7 @@ for (const status of [400, 401, 410]) { expect(auth.status).toBe(""); expect(auth.error).toMatch( status === 401 - ? /Your session or authentication challenge expired/ + ? /Authentication request was rejected/ : /The request or authentication challenge is invalid or expired/, ); expect(auth.session.kind).toBe("ready"); @@ -270,7 +288,11 @@ for ( expect(auth.error).not.toContain("secret credential detail"); expect(auth.status).toBe(""); expect(auth.busy).toBe(false); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(paths(fetcher)).toEqual([ "/api/auth/whoami", "/api/auth/passkeys/login/options", @@ -338,7 +360,7 @@ for (const rejected of [false, true]) { }); } -test("dispose aborts WebAuthn and ignores a late credential without completion, session notification or refresh", async () => { +test("dispose aborts WebAuthn without accepting late credentials, while shared session is rechecked", async () => { const { fetcher, get } = boundary(); const auth = session(); await auth.refresh(); @@ -360,12 +382,17 @@ test("dispose aborts WebAuthn and ignores a late credential without completion, expect(signal.aborted).toBe(true); credential.resolve(credentialFixture(true)); expect(await run).toBe(false); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(auth.status).toBe("Waiting"); expect(auth.error).toBe(""); expect(paths(fetcher)).toEqual([ "/api/auth/whoami", "/api/auth/passkeys/login/options", + "/api/auth/whoami", ]); expect( dispatch.mock.calls.filter(([event]) => @@ -409,6 +436,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not "/api/auth/whoami", "/api/auth/passkeys/login/options", "/api/auth/passkeys/login/complete", + "/api/auth/whoami", ]); expect( dispatch.mock.calls.filter(([event]) => @@ -443,15 +471,73 @@ test("dispose during the post-operation whoami prevents its late actor from beco (signal) => loginWithPasskey(signal), ); await started.promise; - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(auth.busy).toBe(true); auth.dispose(); response.resolve(whoami(true)); expect(await run).toBe(false); - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(auth.actor).toBeNull(); }); +for (const newer of ["logout", "another-actor"] as const) { + test(`confirmation superseded by ${newer} cannot report success or navigate using the old return URL`, async () => { + const { fetcher } = boundary(); + const auth = session(); + await auth.refresh(); + const confirming = gate(); + const started = gate(); + const request = fetcher.getMockImplementation()!; + fetcher.mockImplementation((input, init) => { + if (String(input) === "/api/auth/whoami") { + started.resolve(); + return confirming.promise; + } + return request(input, init); + }); + const changed = vi.fn(); + window.addEventListener(SESSION_CHANGED_EVENT, changed); + const navigate = vi.fn(); + try { + const run = auth.run("Waiting", "Logged in", loginWithPasskey).then( + (confirmed) => { + if (confirmed && auth.actor) navigate("/account?old=return#entry"); + return confirmed; + }, + ); + await started.promise; + sessionMutation(newer === "logout" ? "logout" : "authentication"); + fetcher.mockResolvedValueOnce( + newer === "logout" ? whoami(false) : Response.json({ + actor: { + ...authUser, + user_id: "other-user", + account_id: "other-account", + handle: "bob", + auth_method: "browser_session", + }, + session: { + kind: "authenticated", + expires_at: "2099-01-01T00:00:00Z", + }, + }), + ); + await browserSession.refresh(); + confirming.resolve(whoami(true)); + expect(await run).toBe(false); + expect(auth.actor?.handle ?? null).toBe( + newer === "logout" ? null : "bob", + ); + expect(auth.status).toBe(""); + expect(auth.busy).toBe(false); + expect(changed).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + } finally { + window.removeEventListener(SESSION_CHANGED_EVENT, changed); + } + }); +} + for (const purpose of ["login", "addition"] as const) { test(`${purpose} null credential reports cancellation and never sends completion`, async () => { const { fetcher, get, create } = boundary(purpose === "addition"); diff --git a/web/workspace/src/lib/workspace/navigation/deferred.test.ts b/web/workspace/src/lib/workspace/navigation/deferred.test.ts index 238567212..63b3c85cc 100644 --- a/web/workspace/src/lib/workspace/navigation/deferred.test.ts +++ b/web/workspace/src/lib/workspace/navigation/deferred.test.ts @@ -21,6 +21,7 @@ Deno.test("deferred load returns before API completion and snapshots navigation const api = gate(); const event = { params: { id: "first" }, + parent: async () => ({}), url: new URL("https://example.test/first?q=old"), }; const load = deferPage(async ({ params, url }: typeof event) => ({ @@ -54,9 +55,12 @@ Deno.test("deferred redirects and HTTP failures are handled results, not unhandl ], ] as const ) { - const load = deferPage((_event: { params: {}; url: URL }) => run()); + const load = deferPage(( + _event: { params: {}; url: URL; parent: () => Promise }, + ) => run()); const result = await load({ params: {}, + parent: async () => ({}), url: new URL("https://example.test/"), }).deferredPage; assert( @@ -94,3 +98,39 @@ Deno.test("resolved data preserves page overrides without waiting for the global result.data.workspaceId === "team", ); }); + +Deno.test("private deferred loader starts only after parent admission and never on parent failure", async () => { + for (const outcome of ["admit", "anonymous", "expired", "error"] as const) { + const admission = gate(); + let calls = 0; + const load = deferPage( + async ( + _event: { params: {}; url: URL; parent: () => Promise }, + ) => { + ++calls; + return {}; + }, + ); + const result = load({ + params: {}, + url: new URL("https://app.invalid/w/a/tickets"), + parent: async () => { + await admission.promise; + if (outcome === "anonymous") redirect(307, "/"); + if (outcome === "expired") { + redirect(307, "/login?returnTo=%2Fw%2Fa%2Ftickets"); + } + if (outcome === "error") { + error(503, "Unknown session"); + } + return {}; + }, + }); + await Promise.resolve(); + assert(calls === 0); + admission.release(); + const settled = await result.deferredPage; + assert(calls === (outcome === "admit" ? 1 : 0)); + assert(settled.ok === (outcome === "admit")); + } +}); diff --git a/web/workspace/src/lib/workspace/navigation/deferred.ts b/web/workspace/src/lib/workspace/navigation/deferred.ts index c3baaf145..6e71ab849 100644 --- a/web/workspace/src/lib/workspace/navigation/deferred.ts +++ b/web/workspace/src/lib/workspace/navigation/deferred.ts @@ -39,7 +39,11 @@ export async function settle( } export function deferPage< - E extends { params: Record; url: URL }, + E extends { + params: Record; + url: URL; + parent: () => Promise; + }, T extends object, >( loader: (event: E) => T | Promise, @@ -51,11 +55,13 @@ export function deferPage< params: { ...event.params }, url: new URL(event.url.href), }; - try { - return { deferredPage: settle(loader(snapshot)) }; - } catch (error) { - return { deferredPage: settle(Promise.reject(error)) }; - } + // SvelteKitはparent/childを並列開始する。deferしても認証前fetchを開始しない。 + return { + deferredPage: settle((async () => { + await snapshot.parent(); + return await loader(snapshot); + })()), + }; }; } diff --git a/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts b/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts index 905d3fe88..695e5841f 100644 --- a/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts +++ b/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts @@ -18,6 +18,9 @@ for ( const fetcher = vi.fn().mockImplementation(async (input) => { if (String(input) === "/api/auth/whoami") { return Response.json({ + session: actor + ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } + : { kind: "anonymous" }, actor: actor ? { ...authUser, auth_method: "browser_session" } : null, }); } diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 401c24047..bbaab7c93 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -4,6 +4,7 @@ // navigationの構成はWorkspace identityやpermissionの根拠にはしない。 // Header下辺とSidebar右辺だけで領域境界を示し、viewport外周に線を重ねない。 // MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。 + import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte'; import { page } from '$app/state'; import { setContext } from 'svelte'; import { MediaQuery } from 'svelte/reactivity'; @@ -96,6 +97,7 @@ }); + {#if standalone}
{@render children()} @@ -147,6 +149,7 @@
{/if} +