diff --git a/crates/webcodex-core/src/lib.rs b/crates/webcodex-core/src/lib.rs index 3802d2c91..69c321027 100644 --- a/crates/webcodex-core/src/lib.rs +++ b/crates/webcodex-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod job_observation; pub mod lsp_bridge; pub mod mcp_gateway; pub mod memory_contract; +pub mod model_reference; pub mod plugin; pub mod project_context_contract; pub mod project_instructions; diff --git a/crates/webcodex-core/src/model_reference.rs b/crates/webcodex-core/src/model_reference.rs new file mode 100644 index 000000000..657af18d5 --- /dev/null +++ b/crates/webcodex-core/src/model_reference.rs @@ -0,0 +1,77 @@ +//! Typed, model-facing short-reference syntax. +//! +//! Short refs are selectors only. Persistence, authority, lifecycle and +//! canonical identity remain owned by the target domain. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ModelReferenceKind { + Project, + Session, +} + +impl ModelReferenceKind { + pub const fn prefix(self) -> &'static str { + match self { + Self::Project => "~p", + Self::Session => "~s", + } + } +} + +pub fn parse_model_reference(raw: &str, kind: ModelReferenceKind) -> Option> { + let digits = raw.strip_prefix(kind.prefix())?; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return Some(Err(())); + } + if digits.len() > 1 && digits.starts_with('0') { + return Some(Err(())); + } + Some( + digits + .parse::() + .ok() + .filter(|index| *index > 0) + .ok_or(()), + ) +} + +pub fn format_model_reference(kind: ModelReferenceKind, ref_index: u64) -> String { + debug_assert!(ref_index > 0); + format!("{}{ref_index}", kind.prefix()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn typed_refs_have_disjoint_strict_syntax() { + assert_eq!( + parse_model_reference("~p12", ModelReferenceKind::Project), + Some(Ok(12)) + ); + assert_eq!( + parse_model_reference("~s7", ModelReferenceKind::Session), + Some(Ok(7)) + ); + assert_eq!( + parse_model_reference("~s7", ModelReferenceKind::Project), + None + ); + for invalid in ["~s", "~s0", "~s01", "~s-1", "~s1x"] { + assert_eq!( + parse_model_reference(invalid, ModelReferenceKind::Session), + Some(Err(())), + "{invalid}" + ); + } + assert_eq!( + format_model_reference(ModelReferenceKind::Project, 3), + "~p3" + ); + assert_eq!( + format_model_reference(ModelReferenceKind::Session, 3), + "~s3" + ); + } +} diff --git a/crates/webcodex-store/src/connection_observation.rs b/crates/webcodex-store/src/connection_observation.rs index 62b9c8dee..26ad8898f 100644 --- a/crates/webcodex-store/src/connection_observation.rs +++ b/crates/webcodex-store/src/connection_observation.rs @@ -23,6 +23,7 @@ pub(crate) enum StoreDomain { JobReceipts, JobTerminalWait, Memory, + ModelReference, OAuth, ProjectReference, Schema, @@ -31,7 +32,7 @@ pub(crate) enum StoreDomain { impl StoreDomain { #[cfg(test)] - pub(crate) const ALL: [Self; 17] = [ + pub(crate) const ALL: [Self; 18] = [ Self::Accounts, Self::Activity, Self::AdminProjectLifecycle, @@ -45,6 +46,7 @@ impl StoreDomain { Self::JobReceipts, Self::JobTerminalWait, Self::Memory, + Self::ModelReference, Self::OAuth, Self::ProjectReference, Self::Schema, @@ -66,6 +68,7 @@ impl StoreDomain { Self::JobReceipts => "job_receipts", Self::JobTerminalWait => "job_terminal_wait", Self::Memory => "memory", + Self::ModelReference => "model_reference", Self::OAuth => "oauth", Self::ProjectReference => "project_reference", Self::Schema => "schema", diff --git a/crates/webcodex-store/src/db_tests.rs b/crates/webcodex-store/src/db_tests.rs index 32d3925eb..c7e955dc3 100644 --- a/crates/webcodex-store/src/db_tests.rs +++ b/crates/webcodex-store/src/db_tests.rs @@ -112,6 +112,7 @@ fn store_connection_domains_and_metric_names_are_closed_and_stable() { "job_receipts", "job_terminal_wait", "memory", + "model_reference", "oauth", "project_reference", "schema", diff --git a/crates/webcodex-store/src/lib.rs b/crates/webcodex-store/src/lib.rs index c4821188b..b1b30fd3b 100644 --- a/crates/webcodex-store/src/lib.rs +++ b/crates/webcodex-store/src/lib.rs @@ -35,6 +35,7 @@ mod job_terminal_wait; #[cfg(test)] mod job_terminal_wait_tests; mod memory; +mod model_reference; pub mod models; mod oauth; mod peer_collaboration; @@ -111,6 +112,7 @@ pub use self::memory::{ memory_definition_hash, memory_state_revision, validate_memory_body, validate_memory_key, validate_memory_summary, MAX_MEMORIES_PER_PROJECT, MEMORY_SCOPE_IDENTITY_ATTRIBUTED, }; +pub use self::model_reference::{ModelReferenceRecord, ModelReferenceStoreError}; pub use self::oauth::RotateResult; pub use self::peer_collaboration::{ NewPeerMessage, PeerAttentionBatch, PeerMessageDelivery, PeerMessageDeliveryOutcome, @@ -182,6 +184,8 @@ mod goal_tests; #[cfg(test)] mod memory_tests; #[cfg(test)] +mod model_reference_tests; +#[cfg(test)] mod project_reference_tests; #[cfg(test)] diff --git a/crates/webcodex-store/src/model_reference.rs b/crates/webcodex-store/src/model_reference.rs new file mode 100644 index 000000000..5a0716177 --- /dev/null +++ b/crates/webcodex-store/src/model_reference.rs @@ -0,0 +1,207 @@ +use super::{Database, ProjectReferenceStoreError, StoreDomain}; +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use webcodex_core::{ + model_reference::ModelReferenceKind, workflow_session_contract::is_valid_session_id, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelReferenceRecord { + pub ref_index: u64, + pub canonical_id: String, + pub incarnation_fingerprint: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelReferenceStoreError { + InvalidInput, + Unavailable, +} + +impl std::fmt::Display for ModelReferenceStoreError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidInput => "invalid model reference input", + Self::Unavailable => "model reference store unavailable", + }) + } +} + +impl std::error::Error for ModelReferenceStoreError {} + +impl From for ModelReferenceStoreError { + fn from(error: ProjectReferenceStoreError) -> Self { + match error { + ProjectReferenceStoreError::InvalidInput => Self::InvalidInput, + ProjectReferenceStoreError::Unavailable => Self::Unavailable, + } + } +} + +fn store_error(error: rusqlite::Error) -> ModelReferenceStoreError { + tracing::warn!(error = %error, "durable model reference store operation failed"); + ModelReferenceStoreError::Unavailable +} + +fn valid_session_input( + principal_key: &str, + canonical_session_id: &str, + incarnation_fingerprint: &str, +) -> bool { + !principal_key.is_empty() + && principal_key.len() <= 128 + && is_valid_session_id(canonical_session_id) + && incarnation_fingerprint.len() == 64 + && incarnation_fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) +} + +fn project_record(record: super::ProjectReferenceRecord) -> ModelReferenceRecord { + ModelReferenceRecord { + ref_index: record.ref_index, + canonical_id: record.canonical_project_id, + incarnation_fingerprint: record.root_fingerprint, + } +} + +impl Database { + pub(super) fn ensure_model_reference_schema(conn: &mut Connection) -> anyhow::Result<()> { + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS session_references ( + principal_key TEXT NOT NULL, + ref_index INTEGER NOT NULL CHECK(ref_index >= 1), + canonical_session_id TEXT NOT NULL, + incarnation_fingerprint TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY(principal_key, ref_index), + UNIQUE(principal_key, canonical_session_id, incarnation_fingerprint) + ); + ", + )?; + Ok(()) + } + + pub fn get_or_create_model_reference( + &self, + principal_key: &str, + kind: ModelReferenceKind, + canonical_id: &str, + incarnation_fingerprint: &str, + created_at: i64, + ) -> Result { + if kind == ModelReferenceKind::Project { + return self + .get_or_create_project_reference( + principal_key, + canonical_id, + incarnation_fingerprint, + created_at, + ) + .map(project_record) + .map_err(Into::into); + } + + if !valid_session_input(principal_key, canonical_id, incarnation_fingerprint) { + return Err(ModelReferenceStoreError::InvalidInput); + } + let mut conn = self.lock_connection(StoreDomain::ModelReference); + let transaction = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(store_error)?; + let existing = transaction + .query_row( + "SELECT ref_index, canonical_session_id, incarnation_fingerprint + FROM session_references + WHERE principal_key = ?1 + AND canonical_session_id = ?2 + AND incarnation_fingerprint = ?3", + params![principal_key, canonical_id, incarnation_fingerprint], + |row| { + let ref_index: i64 = row.get(0)?; + Ok(ModelReferenceRecord { + ref_index: ref_index as u64, + canonical_id: row.get(1)?, + incarnation_fingerprint: row.get(2)?, + }) + }, + ) + .optional() + .map_err(store_error)?; + if let Some(existing) = existing { + transaction.commit().map_err(store_error)?; + return Ok(existing); + } + + let next_index: i64 = transaction + .query_row( + "SELECT COALESCE(MAX(ref_index), 0) + 1 + FROM session_references + WHERE principal_key = ?1", + params![principal_key], + |row| row.get(0), + ) + .map_err(store_error)?; + if next_index <= 0 { + return Err(ModelReferenceStoreError::Unavailable); + } + transaction + .execute( + "INSERT INTO session_references ( + principal_key, ref_index, canonical_session_id, + incarnation_fingerprint, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + principal_key, + next_index, + canonical_id, + incarnation_fingerprint, + created_at + ], + ) + .map_err(store_error)?; + transaction.commit().map_err(store_error)?; + Ok(ModelReferenceRecord { + ref_index: next_index as u64, + canonical_id: canonical_id.to_string(), + incarnation_fingerprint: incarnation_fingerprint.to_string(), + }) + } + + pub fn lookup_model_reference( + &self, + principal_key: &str, + kind: ModelReferenceKind, + ref_index: u64, + ) -> Result, ModelReferenceStoreError> { + if kind == ModelReferenceKind::Project { + return self + .lookup_project_reference(principal_key, ref_index) + .map(|record| record.map(project_record)) + .map_err(Into::into); + } + + if principal_key.is_empty() || principal_key.len() > 128 || ref_index == 0 { + return Err(ModelReferenceStoreError::InvalidInput); + } + let ref_index = + i64::try_from(ref_index).map_err(|_| ModelReferenceStoreError::InvalidInput)?; + let conn = self.lock_connection(StoreDomain::ModelReference); + conn.query_row( + "SELECT ref_index, canonical_session_id, incarnation_fingerprint + FROM session_references + WHERE principal_key = ?1 AND ref_index = ?2", + params![principal_key, ref_index], + |row| { + let ref_index: i64 = row.get(0)?; + Ok(ModelReferenceRecord { + ref_index: ref_index as u64, + canonical_id: row.get(1)?, + incarnation_fingerprint: row.get(2)?, + }) + }, + ) + .optional() + .map_err(store_error) + } +} diff --git a/crates/webcodex-store/src/model_reference_tests.rs b/crates/webcodex-store/src/model_reference_tests.rs new file mode 100644 index 000000000..48942be04 --- /dev/null +++ b/crates/webcodex-store/src/model_reference_tests.rs @@ -0,0 +1,149 @@ +use super::Database; +use webcodex_core::model_reference::ModelReferenceKind; + +fn root(hex: char) -> String { + format!("wc_projroot_{}", hex.to_string().repeat(64)) +} + +fn fingerprint(hex: char) -> String { + hex.to_string().repeat(64) +} + +#[test] +fn typed_model_references_are_stable_isolated_and_restart_safe() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("model-refs.db"); + + let db = Database::open(&path).unwrap(); + let first = db + .get_or_create_model_reference( + "principal-a", + ModelReferenceKind::Project, + "agent:special:webcodex", + &root('1'), + 1, + ) + .unwrap(); + assert_eq!(first.ref_index, 1); + + let replay = db + .get_or_create_model_reference( + "principal-a", + ModelReferenceKind::Project, + "agent:special:webcodex", + &root('1'), + 2, + ) + .unwrap(); + assert_eq!(replay, first); + + let session = db + .get_or_create_model_reference( + "principal-a", + ModelReferenceKind::Session, + "wc_sess_1234567890abcdef", + &fingerprint('a'), + 3, + ) + .unwrap(); + assert_eq!( + session.ref_index, 1, + "reference kinds have independent short-index namespaces" + ); + + let other_principal = db + .get_or_create_model_reference( + "principal-b", + ModelReferenceKind::Project, + "agent:special:webcodex", + &root('1'), + 4, + ) + .unwrap(); + assert_eq!(other_principal.ref_index, 1); + + let reincarnated = db + .get_or_create_model_reference( + "principal-a", + ModelReferenceKind::Project, + "agent:special:webcodex", + &root('3'), + 5, + ) + .unwrap(); + assert_eq!(reincarnated.ref_index, 2); + drop(db); + + let reopened = Database::open(&path).unwrap(); + assert_eq!( + reopened + .lookup_model_reference("principal-a", ModelReferenceKind::Project, 1) + .unwrap() + .unwrap(), + first + ); + assert_eq!( + reopened + .lookup_model_reference("principal-a", ModelReferenceKind::Project, 2) + .unwrap() + .unwrap(), + reincarnated + ); + assert_eq!( + reopened + .lookup_model_reference("principal-a", ModelReferenceKind::Session, 1) + .unwrap() + .unwrap(), + session + ); + assert!( + reopened + .lookup_model_reference("principal-b", ModelReferenceKind::Session, 1) + .unwrap() + .is_none(), + "principal and kind namespaces must not leak mappings" + ); +} + +#[test] +fn released_project_reference_table_remains_authoritative() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("project-ref-compat.db"); + let db = Database::open(&path).unwrap(); + db.conn_for_tests() + .execute( + "INSERT INTO project_references VALUES (?1, 7, ?2, ?3, 11)", + rusqlite::params!["principal-a", "agent:runner:repo", root('a')], + ) + .unwrap(); + + let retained = db + .lookup_model_reference("principal-a", ModelReferenceKind::Project, 7) + .unwrap() + .unwrap(); + assert_eq!(retained.canonical_id, "agent:runner:repo"); + assert_eq!(retained.incarnation_fingerprint, root('a')); + + let next = db + .get_or_create_model_reference( + "principal-a", + ModelReferenceKind::Project, + "agent:runner:next", + &root('b'), + 12, + ) + .unwrap(); + assert_eq!(next.ref_index, 8); + + let raw: (String, String) = db + .conn_for_tests() + .query_row( + "SELECT canonical_project_id, root_fingerprint + FROM project_references WHERE principal_key=?1 AND ref_index=8", + ["principal-a"], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(raw.0, "agent:runner:next"); + assert_eq!(raw.1, root('b')); +} diff --git a/crates/webcodex-store/src/schema.rs b/crates/webcodex-store/src/schema.rs index ac74478f8..fd826c41f 100644 --- a/crates/webcodex-store/src/schema.rs +++ b/crates/webcodex-store/src/schema.rs @@ -393,6 +393,10 @@ impl Database { ", )?; + // Additive typed model-reference storage. The released Project-ref + // table and its indices remain unchanged. + Self::ensure_model_reference_schema(&mut conn)?; + // Preserve the authority of previously issued enrollment codes. Only a // newly issued explicit admin grant adds ACP/SSH scopes; old codes stay 0. { diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/coding_tasks.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/coding_tasks.rs index 0c57a3d61..2352e5e06 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/coding_tasks.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/coding_tasks.rs @@ -410,6 +410,7 @@ fn startup_session_schema() -> Value { "type": "object", "properties": { "session_id": {"type": "string", "pattern": "^wc_sess_([A-Za-z0-9_-]{16}|[0-9a-f]{32})$"}, + "session_ref": {"type": "string", "pattern": "^~s[1-9][0-9]*$"}, "mode": {"type": "string", "enum": ["normal", "read_only"]}, "execution_context": session_execution_context_schema( "Persistent execution defaults currently stored for this Workflow Session." @@ -1281,6 +1282,10 @@ fn work_on_project_output_schema() -> Value { "session_id", schema_type("string", "Explicit Workflow Session id for exact continuation or recording on later calls."), ), + ( + "session_ref", + schema_type("string", "Server-issued principal-scoped short selector for the exact Workflow Session. Prefer it for ordinary business Session continuation; canonical session_id remains authoritative."), + ), ( "project", schema_type("string", "Project selector used to start or resume this task. For direct Project input this preserves the caller's accepted selector, including a Server-issued project_ref; for Runner path input it is the resolved canonical runtime Project id."), diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/sessions.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/sessions.rs index b5dd9050c..bf73eec57 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/sessions.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/sessions.rs @@ -52,7 +52,8 @@ pub(super) fn output_schema_for_tool(name: &str) -> Option { "True after the in-memory Session context/event commit. JSON ledger persistence may still be pending in the background writer.", ), ), - ("session_id", schema_type("string", "Opaque session id.")), + ("session_id", schema_type("string", "Canonical wc_sess_* Workflow Session id.")), + ("session_ref", schema_type("string", "Server-issued principal-scoped ~sN selector for this exact Workflow Session. Selector only; use re-runs ordinary authorization and lifecycle checks.")), ( "project", nullable_schema("string", "Optional project associated with the task."), @@ -102,7 +103,8 @@ pub(super) fn output_schema_for_tool(name: &str) -> Option { ), ])), "session_summary" => Some(wrapped_output_schema(vec![ - ("session_id", schema_type("string", "Opaque session id.")), + ("session_id", schema_type("string", "Canonical wc_sess_* Workflow Session id.")), + ("session_ref", schema_type("string", "Server-issued principal-scoped ~sN selector for this exact Workflow Session. Selector only; use re-runs ordinary authorization and lifecycle checks.")), ( "project", nullable_schema("string", "Optional project associated with the task."), @@ -475,6 +477,10 @@ pub(super) fn output_schema_for_tool(name: &str) -> Option { "session_id", schema_type("string", "Business session id being handed off."), ), + ( + "session_ref", + schema_type("string", "Server-issued principal-scoped ~sN selector for the exact handed-off Session. It grants no authority and never retargets."), + ), ( "project", nullable_schema("string", "Optional runtime project id, when provided."), diff --git a/crates/webcodex-tool-contracts/src/request_schema.rs b/crates/webcodex-tool-contracts/src/request_schema.rs index 03f8a27f2..ad61dda01 100644 --- a/crates/webcodex-tool-contracts/src/request_schema.rs +++ b/crates/webcodex-tool-contracts/src/request_schema.rs @@ -309,6 +309,18 @@ mod tests { } } + #[test] + fn business_session_schema_documents_short_ref_without_leaking_recorder_wrapper() { + let summary = input_schema_for_tool("session_summary"); + let session = &summary["properties"]["session_id"]; + assert_eq!(session["type"], "string"); + assert!( + session.get("pattern").is_none(), + "model-facing business session_id must remain an unconstrained string selector; Runtime owns canonical/session_ref validation" + ); + assert!(summary["properties"].get("recording_session_id").is_none()); + } + #[test] fn shared_host_normalization_preserves_request_optional_and_closed_shape() { let run = input_schema_for_tool("run_process"); diff --git a/crates/webcodex-tool-contracts/src/tool_definition/sessions.rs b/crates/webcodex-tool-contracts/src/tool_definition/sessions.rs index 750dcb506..f4aab9207 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/sessions.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/sessions.rs @@ -112,8 +112,8 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ super::ToolActivityPresentation::Support, super::ToolActivityInteraction::Meaningful, ), - "Canonical bootstrap for ordinary coding/review. Prefer project_ref; project or client_id+path work. Omit session_id for a fresh Workflow Session; it does not imply a fresh model context. Exact resume requires an active accessible Session and never guesses prior Session. Exact resume may return sparse owner-scoped goal_context for correlated active Goals; reuse one exact candidate or choose explicitly among multiple. It grants no authority and never selects/binds a Goal. For a fresh or uncertain model context, request project.instructions/webcodex.workflow via context_request. Runtime re-observes instruction files; primary result stays compact. guidance_profile is model guidance only. project_ref is principal-scoped and not authority; each use reauthorizes the Project. include_extension_catalog controls bounded Skills/Plugins. Checkout does not require Git; mode=worktree creates an isolated worktree from an exact Git base without bypassing Project authority.", - ).with_gpt_action_description("Start/resume exact Project work. Prefer project_ref; canonical id or client_id+path also work. Exact Session resume may return sparse goal_context for explicit Goal reuse; it never auto-selects or grants Goal authority. Request project.instructions/webcodex.workflow as needed."), + "Canonical bootstrap for ordinary coding/review. Prefer project_ref; project or client_id+path work. Omit session_id for a fresh Workflow Session; it does not imply a fresh model context. Exact resume accepts session_ref or canonical session_id, requires an active accessible Session, and never guesses prior Session. Exact resume may return sparse owner-scoped goal_context for correlated active Goals; reuse one exact candidate or choose explicitly among multiple. It grants no authority and never selects/binds a Goal. For fresh or uncertain model context, request project.instructions/webcodex.workflow via context_request. Runtime re-observes instruction files; primary result stays compact. guidance_profile is model guidance only. project_ref is principal-scoped/non-authoritative; each use reauthorizes Project. include_extension_catalog controls bounded Skills/Plugins. Checkout does not require Git; mode=worktree creates an isolated worktree from an exact Git base without bypassing Project authority.", + ).with_gpt_action_description("Start/resume exact Project work. Prefer project_ref; canonical id or client_id+path also work. Exact Session resume accepts session_ref or canonical session_id; sparse goal_context supports explicit Goal reuse without granting authority. Request project.instructions/webcodex.workflow as needed."), 10, ), requires_explicit_business_session(model_spec( @@ -652,8 +652,8 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ super::ToolActivityPresentation::Support, super::ToolActivityInteraction::Meaningful, ), - "Explicit read-only recovery for genuinely missing task context or an explicit handoff; requires the exact session_id. Do not use as routine progress/status polling or to establish a Session baseline when current context is coherent. Defaults to identity plus deterministic handoff_brief, hard-bounded at 8 KiB: task instructions, workspace, progress, validation, jobs, collaboration attention, bounded external reports, next actions and basis completeness. External reports retain exact source IDs and unknown outcomes, with incomplete capture coverage; they are not native execution or validation. Omitted project uses the authorized Session Project. diagnostic=true adds detailed ledger and closeout evidence. A concurrent Session change marks the basis incomplete; re-observe before dependent work. No checkpoint allocation, ACK token, or authority grant.", - ).with_gpt_action_description("Recover missing task context or perform an explicit handoff for an exact session_id. Do not use for routine progress/status polling or to establish a baseline. Returns a bounded handoff_brief; diagnostic=true adds detailed evidence. Check basis completeness before dependent work. Read-only.")), + "Explicit read-only recovery for genuinely missing task context or an explicit handoff; requires the exact session_id or its returned session_ref selector. Do not use as routine progress/status polling or to establish a Session baseline when current context is coherent. Defaults to identity plus deterministic handoff_brief, hard-bounded at 8 KiB: task instructions, workspace, progress, validation, jobs, collaboration attention, bounded external reports, next actions and basis completeness. External reports retain exact source IDs and unknown outcomes, with incomplete capture coverage; they are not native execution or validation. Omitted project uses the authorized Session Project. diagnostic=true adds detailed ledger and closeout evidence. A concurrent Session change marks the basis incomplete; re-observe before dependent work. No checkpoint allocation, ACK token, or authority grant.", + ).with_gpt_action_description("Recover missing task context or perform an explicit handoff for the exact session_id or returned session_ref. Avoid routine status polling or baseline creation. Returns bounded handoff_brief; diagnostic=true adds evidence. Check basis completeness before dependent work. Read-only.")), 16, ), requires_explicit_business_session( diff --git a/docs/CODING_WORKFLOW.md b/docs/CODING_WORKFLOW.md index e873500c8..5fe558be2 100644 --- a/docs/CODING_WORKFLOW.md +++ b/docs/CODING_WORKFLOW.md @@ -59,6 +59,8 @@ that the current model retained either material. When bootstrap or discovery returns `project_ref`, reuse it as the `project` selector on ordinary Project-scoped calls. The canonical `agent::` identity remains visible for diagnostics and explicit addressing, but the model does not need to mechanically repeat it. A short ref is Server-owned, durable and principal-scoped, carries no authority, and is reauthorized against its pinned canonical Project/root identity on every call. +When `work_on_project`, `start_session`, `session_summary`, or an explicit handoff returns `session_ref`, prefer that short selector for later business `session_id` inputs. The canonical `wc_sess_*` identity remains valid and authoritative. The ref is principal-scoped convenience only: every use resolves the pinned exact Session and reruns the normal Project/Session authorization, lifecycle and guard checks. It does not apply to `recording_session_id` and never creates sticky recorder context. + ## Tool strategy guidance `work_on_project` accepts `guidance_profile`, defaulting to `direct`. Workflow diff --git a/docs/CODING_WORKFLOW.zh-CN.md b/docs/CODING_WORKFLOW.zh-CN.md index 229c42a7d..391f2602d 100644 --- a/docs/CODING_WORKFLOW.zh-CN.md +++ b/docs/CODING_WORKFLOW.zh-CN.md @@ -23,6 +23,8 @@ work_on_project 对于 substantial coding,在 Workflow Session 进入真实工作状态后(例如第一次有意义的源码 mutation,或开始长时间 validation),对该 exact Session 调用一次 `present_work_result(project, session_id)`。挂载后的 MCP App 会自行进行有界的 Workspace / Validation / Review live read,因此不要重复创建卡片,也不要为了给卡片喂状态而额外消耗 model turn。tiny/read-only 工作不需要 progress card。`finish_coding_task` 在 non-blocking closeout 时 seal eligible final changes,已经挂载的同一张卡会在后续 App refresh 中发现这份 immutable snapshot;如果此前没有挂卡而 closeout 明确返回 presentation suggestion,再在收尾时调用一次即可。 它的 primary output 默认保持紧凑,不重复静态 instruction/workflow 正文;当前模型上下文缺少这些材料时,分别显式请求 `context_request=["project.instructions"]` 和/或 `context_request=["webcodex.workflow"]`。Workflow Session identity 不证明当前模型仍保留这些上下文。 Bootstrap 或 discovery 返回 `project_ref` 后,普通 Project-scoped tool call 的 `project` 应优先复用这个短 selector。Canonical `agent::` 仍保留用于 diagnostic 与显式 addressing,但模型无需机械重复。`project_ref` 由 Server 持久维护、按 principal 隔离,不携带 authority;每次调用都会根据其钉住的 canonical Project/root identity 重新授权。 + +当 `work_on_project`、`start_session`、`session_summary` 或显式 handoff 返回 `session_ref` 时,后续 business `session_id` 优先复用这个短 selector。Canonical `wc_sess_*` 仍然有效,并继续作为持久化、审计、诊断和内部关联的权威身份。`session_ref` 只是按 principal 隔离的便利选择器:每次使用都会解析到钉住的 exact Session,并重新执行原有 Project/Session 授权、生命周期和 guard 检查。它不适用于 `recording_session_id`,也不会形成隐式或粘滞的 recorder context。 默认情况下,它还会返回一个很小且有界的 `extensions` selection catalog:Skill metadata 来自 canonical 的 project / Runner-configured `skills.roots` / Runner-managed Skill Store 三类来源;Plugin metadata 只包含 configured working directory 与当前 Project root 匹配、且已 ready/committed 的 provider。该 metadata 不授予任何 authority,也不会自动读取 Skill body 或创建 Plugin binding;模型选择后使用 `skill_read_file` 读取 Skill 文本,`run_skill_resource` 只执行可信 Runner-configured live `scripts/` resource(由 `expected_definition_revision` fence definition)或 Runner-installed managed resource(另由 `expected_package_revision` fence package),Plugin 则走 `plugin_tool describe -> call`。Configured resource bytes 会一直保持 live 到实际执行时,并不会预先被 package revision 固定。只有当前模型上下文仍明确保留这些 discovery metadata 时,才应设置 `include_extension_catalog=false`。 ## 工具策略 guidance diff --git a/docs/agent/session-model.md b/docs/agent/session-model.md index 56b1cac85..a7b851363 100644 --- a/docs/agent/session-model.md +++ b/docs/agent/session-model.md @@ -70,13 +70,17 @@ Workflow Session lifecycle is independent from the durable `wc_goal_*` Goal doma | Aspect | Contract | |---|---| -| ID form | `wc_sess_*` (`SESSION_ID_PREFIX`) | -| Business field | `session_id` on tools that take a workflow session as input | +| ID form | Canonical `wc_sess_*` (`SESSION_ID_PREFIX`); model-facing bootstrap/discovery/handoff may additionally issue principal-scoped `session_ref` values such as `~s1` | +| Business field | `session_id` on tools that take a workflow session as input; model-facing business calls may pass either the canonical id or a Server-issued `session_ref` | | Coding resume field | `session_id` on canonical external `work_on_project`; the internal startup primitive's `resume_session_id` is not a wire/API field | -| Recorder field | `recording_session_id` on generic wrappers, including the stateless MCP 2026 tool-argument projection (metadata only; stripped before concrete tool dispatch) | +| Recorder field | `recording_session_id` on generic wrappers, including the stateless MCP 2026 tool-argument projection (metadata only; canonical `wc_sess_*` only and stripped before concrete tool dispatch) | ### Storage and ownership +A `session_ref` is only a short model selector. The Server owns a durable mapping scoped to the authenticated principal and pins the ref to one exact canonical Workflow Session incarnation. Resolving it produces the canonical `wc_sess_*` before business authorization/dispatch; the ordinary Project visibility, Session authority, lifecycle, and guard checks then run unchanged. The ref is not a credential, bearer capability, recorder identity, or "current/recent Session" inference. If the pinned Session disappears or its exact incarnation cannot be proven, the old ref fails closed and is never recycled or silently retargeted. Canonical Session ids remain authoritative for persistence, audit, diagnostics, internal joins, and explicit API/CLI consumers. + +`recording_session_id` remains a separate provenance contract. A `session_ref` never creates sticky recorder context and is not accepted as an implicit recorder selector. + Stateless MCP 2026 never derives a Workflow Session recorder identity from transport/window continuity. ChatGPT may supply `_meta["openai/session"]` as a hashed `ClientWindow`, but that identity is intentionally not a Workflow Session selector or trusted provenance source. Its `tools/list` schema therefore projects `recording_session_id` as explicit wrapper metadata for runtime tools. A call may carry `recording_session_id=W` while the concrete tool body carries business `session_id=C`; the MCP adapter removes the recorder field before concrete parsing and the kernel independently authorizes `W` before it can record evidence or supply trusted collaboration provenance. This does not revive legacy `mcp-session-id`, grant target authority, or infer a recorder from credentials, project identity, connection state, or `ClientWindow`. Session collaboration attention has a narrower fallback that never becomes recorder identity. When an ordinary Project tool omits `recording_session_id`, the same strict Window/principal/exact-Project affinity used by the recorder-gap diagnostic may locate one latest authorized Active Workflow Session solely to project open ACK-required messages and accept request-scoped ACK ids. The result names that exact `session_id` and marks `source=window_affinity`. This fallback cannot append ToolCall events, supply business `session_id`, inherit execution context, resolve a message, complete a todo, change Goal/Task authority, or mutate Session lifecycle. An explicit recorder always selects attention instead. diff --git a/docs/agent/tool-contract-guidelines.md b/docs/agent/tool-contract-guidelines.md index 99d8dd48c..5509ccea2 100644 --- a/docs/agent/tool-contract-guidelines.md +++ b/docs/agent/tool-contract-guidelines.md @@ -181,6 +181,8 @@ Runtime Project identity remains canonical as `agent::`. Resolving a `project_ref` must always look up the pinned canonical identity and then run the ordinary current Project resolution/authorization path again. The ref is not a credential, bearer token or capability. If the canonical Project disappears, becomes invisible, loses stable identity, or the same canonical address is later registered for a different root, the old ref fails closed. Never recycle or silently retarget an issued ref. Discovery/bootstrap may expose both `project_ref` and canonical identity; ordinary hot-path results should not repeat them when no model decision depends on that duplication. +The same typed durable-reference layer may issue a principal-scoped `session_ref` such as `~s1` for one exact Workflow Session incarnation. Canonical `wc_sess_*` remains authoritative for Session persistence, audit, diagnostics and internal joins. Business `session_id` inputs may use the short selector, but Runtime canonicalizes it before Session-aware authorization/dispatch and then reruns the ordinary Project visibility, Session authority, lifecycle and guard checks. `recording_session_id` is a separate recorder-provenance contract and remains canonical-only; a Session ref never creates ambient or sticky recorder state. Bootstrap, discovery and handoff may expose both identities, while ordinary hot-path results should avoid redundant duplication. + ### Model-projection deletion test A model-facing result field should normally survive only when it can change at diff --git a/src/tool_runtime/coding_task.rs b/src/tool_runtime/coding_task.rs index 828b487df..df97aaf8b 100644 --- a/src/tool_runtime/coding_task.rs +++ b/src/tool_runtime/coding_task.rs @@ -1121,6 +1121,7 @@ impl ToolRuntime { > 0, ) .await; + let session_ref = self.session_reference_for_id(&session_summary.session_id, auth); let mut output = json!({ "detail": detail.as_str(), "project": project.clone(), @@ -1170,6 +1171,9 @@ impl ToolRuntime { "llm_summary": false, "warnings": warnings, }); + if let Some(session_ref) = session_ref.as_deref() { + output["session"]["session_ref"] = json!(session_ref); + } if let Some(tool_manifest) = tool_manifest { output["tool_manifest"] = tool_manifest; } @@ -1203,7 +1207,7 @@ impl ToolRuntime { let project_resolution_value = serde_json::to_value(&project_resolution).unwrap_or_else(|_| json!({})); let project_ref = self.project_reference_for_resolved(&resolved, auth); - let startup_brief = build_startup_brief(StartupBriefInput { + let mut startup_brief = build_startup_brief(StartupBriefInput { guidance_profile: startup.guidance_profile, detail, requested_project: &project, @@ -1230,6 +1234,9 @@ impl ToolRuntime { canonical_repository_root_matches, runtime_status_call_failed, }); + if let Some(session_ref) = session_ref.as_deref() { + startup_brief["session"]["session_ref"] = json!(session_ref); + } let result = if detail == StartupDetail::Full { output["startup_brief"] = startup_brief; ToolResult::ok(output) @@ -2334,6 +2341,8 @@ struct WorkOnProjectBriefProjection { #[derive(Deserialize)] struct WorkOnProjectSessionProjection { session_id: String, + #[serde(default)] + session_ref: Option, continuation: String, execution_context: sessions::SessionExecutionContext, } @@ -2732,6 +2741,9 @@ fn project_work_on_project_output_inner( if let Some(knowledge_association) = projection.project.knowledge_association { result.output["knowledge_association"] = knowledge_association; } + if let Some(session_ref) = projection.session.session_ref { + result.output["session_ref"] = json!(session_ref); + } if let Some(project_ref) = projection.project.project_ref { result.output["project_ref"] = json!(project_ref); } diff --git a/src/tool_runtime/context_projection.rs b/src/tool_runtime/context_projection.rs index 44ca61680..acc88ce2c 100644 --- a/src/tool_runtime/context_projection.rs +++ b/src/tool_runtime/context_projection.rs @@ -443,14 +443,19 @@ impl ToolRuntime { candidates_truncated = true; break; } - candidates.push(json!({ + let session_ref = self.session_reference_for_id(&session_id, auth); + let mut candidate = json!({ "session_id": session_id, "project": project, "lifecycle": "active", "title": title, "relations": link.relations, "last_linked_at_ms": link.last_linked_at_ms, - })); + }); + if let Some(session_ref) = session_ref { + candidate["session_ref"] = json!(session_ref); + } + candidates.push(candidate); } let mut projection = json!({ @@ -460,9 +465,12 @@ impl ToolRuntime { "selection": "caller_must_choose_exact_session", }); if candidates.len() == 1 { + let session_selector = candidates[0] + .get("session_ref") + .unwrap_or(&candidates[0]["session_id"]); projection["suggested_call"] = json!({ "tool": "session_handoff_summary", - "arguments": {"session_id": candidates[0]["session_id"]}, + "arguments": {"session_id": session_selector}, }); } Ok(projection) diff --git a/src/tool_runtime/handoff.rs b/src/tool_runtime/handoff.rs index 2e059ca78..69eed9dad 100644 --- a/src/tool_runtime/handoff.rs +++ b/src/tool_runtime/handoff.rs @@ -344,6 +344,7 @@ impl ToolRuntime { warnings.extend(job_warnings.iter().cloned()); } + let session_ref = self.session_reference_for_id(&summary.session_id, auth); let mut output = json!({ "session_id": summary.session_id, "project": summary.project, @@ -376,6 +377,10 @@ impl ToolRuntime { "warnings": warnings, }); + if let Some(session_ref) = session_ref.as_deref() { + output["session_ref"] = json!(session_ref); + } + // --- optional workspace summary --- let has_project = project .as_deref() @@ -509,6 +514,9 @@ impl ToolRuntime { "project": output["project"], "handoff_brief": output["handoff_brief"], }); + if let Some(session_ref) = output.get("session_ref") { + handoff["session_ref"] = session_ref.clone(); + } if let Some(workspace_continuity) = output.get("workspace_continuity") { handoff["workspace_continuity"] = workspace_continuity.clone(); } diff --git a/src/tool_runtime/kernel.rs b/src/tool_runtime/kernel.rs index 056dfc1e6..e5704d091 100644 --- a/src/tool_runtime/kernel.rs +++ b/src/tool_runtime/kernel.rs @@ -556,7 +556,8 @@ impl ToolRuntime { } return outcome; } - let concrete_arguments = strip_tool_call_expectation_metadata(request.arguments.clone()); + let mut concrete_arguments = + strip_tool_call_expectation_metadata(request.arguments.clone()); let context_request = if capabilities.context_sidecar { invocation_metadata.context_request } else { @@ -622,6 +623,19 @@ impl ToolRuntime { correlation: Default::default(), }; } + if let Err(message) = + self.canonicalize_session_reference_argument(&mut concrete_arguments, context.auth) + { + return ToolCallOutcome { + success: false, + result: None, + error_status: Some(ToolCallErrorStatus::InvalidArguments { message }), + project: None, + model_ergonomics: None, + correlation: Default::default(), + }; + } + let outer_ack_observation = context.session_id.map(|recorder_session_id| { session_context::observe_session_attention_acks( &self.sessions, diff --git a/src/tool_runtime/mod.rs b/src/tool_runtime/mod.rs index 91de6335d..13311f7dc 100644 --- a/src/tool_runtime/mod.rs +++ b/src/tool_runtime/mod.rs @@ -58,6 +58,7 @@ mod lsp_tools; pub(crate) use lsp_tools::runner_local_project_id; pub(crate) mod memory; pub(crate) mod model_ergonomics_telemetry; +mod model_references; pub(crate) mod observations; mod observe_jobs; mod patch; diff --git a/src/tool_runtime/model_references.rs b/src/tool_runtime/model_references.rs new file mode 100644 index 000000000..4e5b7f438 --- /dev/null +++ b/src/tool_runtime/model_references.rs @@ -0,0 +1,321 @@ +use super::session_context::{ + workflow_session_authority_fingerprint, workflow_session_incarnation_fingerprint, +}; +use super::ToolRuntime; +use crate::auth::AuthContext; +use serde_json::Value; +use webcodex_core::model_reference::{ + format_model_reference, parse_model_reference, ModelReferenceKind, +}; + +impl ToolRuntime { + pub(crate) fn session_reference_for_id( + &self, + session_id: &str, + auth: Option<&AuthContext>, + ) -> Option { + let db = self.project_reference_db.as_ref()?; + let principal_key = workflow_session_authority_fingerprint(auth).ok()?; + let (project, owner_authority_fingerprint) = + self.sessions.session_target_authority(session_id)?; + if owner_authority_fingerprint != principal_key { + return None; + } + let summary = self.sessions.summary(session_id, Some(1))?; + let incarnation_fingerprint = workflow_session_incarnation_fingerprint( + session_id, + summary.created_at, + project.as_deref(), + &owner_authority_fingerprint, + ); + let record = db + .get_or_create_model_reference( + &principal_key, + ModelReferenceKind::Session, + session_id, + &incarnation_fingerprint, + chrono::Utc::now().timestamp(), + ) + .ok()?; + Some(format_model_reference( + ModelReferenceKind::Session, + record.ref_index, + )) + } + + fn resolve_session_reference( + &self, + raw: &str, + auth: Option<&AuthContext>, + ) -> Option> { + let ref_index = match parse_model_reference(raw, ModelReferenceKind::Session)? { + Ok(ref_index) => ref_index, + Err(()) => return Some(Err(())), + }; + let db = match self.project_reference_db.as_ref() { + Some(db) => db, + None => return Some(Err(())), + }; + let principal_key = match workflow_session_authority_fingerprint(auth) { + Ok(principal_key) => principal_key, + Err(_) => return Some(Err(())), + }; + let record = match db + .lookup_model_reference(&principal_key, ModelReferenceKind::Session, ref_index) + .ok() + .flatten() + { + Some(record) => record, + None => return Some(Err(())), + }; + let (project, owner_authority_fingerprint) = + match self.sessions.session_target_authority(&record.canonical_id) { + Some(target) => target, + None => return Some(Err(())), + }; + if owner_authority_fingerprint != principal_key { + return Some(Err(())); + } + let summary = match self.sessions.summary(&record.canonical_id, Some(1)) { + Some(summary) => summary, + None => return Some(Err(())), + }; + let current_incarnation = workflow_session_incarnation_fingerprint( + &record.canonical_id, + summary.created_at, + project.as_deref(), + &owner_authority_fingerprint, + ); + if current_incarnation != record.incarnation_fingerprint { + return Some(Err(())); + } + Some(Ok(record.canonical_id)) + } + + /// Canonicalize only the concrete business Session selector. Wrapper + /// recording_session_id is stripped by the transport adapter and never + /// enters this path. + pub(crate) fn canonicalize_session_reference_argument( + &self, + arguments: &mut Value, + auth: Option<&AuthContext>, + ) -> Result<(), String> { + let Some(object) = arguments.as_object_mut() else { + return Ok(()); + }; + let Some(raw) = object.get("session_id").and_then(Value::as_str) else { + return Ok(()); + }; + let Some(resolved) = self.resolve_session_reference(raw, auth) else { + return Ok(()); + }; + let canonical = resolved.map_err(|_| { + format!( + "unknown_session_ref: {raw}; reuse a currently issued session_ref or the canonical wc_sess_* id" + ) + })?; + object.insert("session_id".to_string(), Value::String(canonical)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tool_runtime::sessions::{SessionCreateOptions, SessionGuards}; + use crate::tool_runtime::SessionMode; + use std::sync::Arc; + + #[test] + fn malformed_session_ref_fails_without_touching_canonical_ids() { + let tmp = tempfile::tempdir().unwrap(); + let runtime = ToolRuntime::new_for_tests().with_project_reference_database(Arc::new( + crate::Database::open(&tmp.path().join("session-refs.db")).unwrap(), + )); + let mut canonical = serde_json::json!({"session_id": "wc_sess_exact"}); + runtime + .canonicalize_session_reference_argument(&mut canonical, None) + .unwrap(); + assert_eq!(canonical["session_id"], "wc_sess_exact"); + + let mut malformed = serde_json::json!({"session_id": "~s01"}); + assert!(runtime + .canonicalize_session_reference_argument(&mut malformed, None) + .unwrap_err() + .contains("unknown_session_ref")); + } + + #[test] + fn session_refs_are_stable_for_the_same_principal() { + let tmp = tempfile::tempdir().unwrap(); + let runtime = ToolRuntime::new_for_tests().with_project_reference_database(Arc::new( + crate::Database::open(&tmp.path().join("session-refs.db")).unwrap(), + )); + let authority = workflow_session_authority_fingerprint(None).unwrap(); + let summary = runtime + .sessions + .start_session_with_options( + SessionCreateOptions::new( + None, + Some("ref test".to_string()), + SessionMode::Normal, + SessionGuards::default(), + ) + .with_owner_authority_fingerprint(Some(authority)), + ) + .unwrap(); + let session_ref = runtime + .session_reference_for_id(&summary.session_id, None) + .unwrap(); + assert!(session_ref.starts_with("~s")); + assert_eq!( + runtime + .session_reference_for_id(&summary.session_id, None) + .unwrap(), + session_ref + ); + + let mut arguments = serde_json::json!({"session_id": session_ref}); + runtime + .canonicalize_session_reference_argument(&mut arguments, None) + .unwrap(); + assert_eq!(arguments["session_id"], summary.session_id); + } + + #[test] + fn closed_session_ref_still_resolves_before_existing_lifecycle_checks() { + let tmp = tempfile::tempdir().unwrap(); + let runtime = ToolRuntime::new_for_tests().with_project_reference_database(Arc::new( + crate::Database::open(&tmp.path().join("session-refs.db")).unwrap(), + )); + let authority = workflow_session_authority_fingerprint(None).unwrap(); + let summary = runtime + .sessions + .start_session_with_options( + SessionCreateOptions::new( + None, + Some("closed ref".to_string()), + SessionMode::Normal, + SessionGuards::default(), + ) + .with_owner_authority_fingerprint(Some(authority)), + ) + .unwrap(); + let session_ref = runtime + .session_reference_for_id(&summary.session_id, None) + .unwrap(); + runtime.sessions.close_session(&summary.session_id).unwrap(); + + let mut arguments = serde_json::json!({"session_id": session_ref}); + runtime + .canonicalize_session_reference_argument(&mut arguments, None) + .unwrap(); + assert_eq!(arguments["session_id"], summary.session_id); + assert_eq!( + runtime.sessions.lifecycle_state(&summary.session_id), + Some(crate::tool_runtime::sessions::SessionLifecycle::Closed), + "the ref resolver must not invent a stricter lifecycle than canonical session_id" + ); + } + + #[test] + fn session_refs_fail_closed_for_another_principal() { + let tmp = tempfile::tempdir().unwrap(); + let runtime = ToolRuntime::new_for_tests().with_project_reference_database(Arc::new( + crate::Database::open(&tmp.path().join("session-refs.db")).unwrap(), + )); + let mut alice = crate::auth::AuthContext::new(crate::auth::AuthKind::ApiToken); + alice.user_id = Some("user-alice".to_string()); + alice.username = Some("alice".to_string()); + alice.api_key_id = Some("key-alice".to_string()); + alice.token_kind = Some("user".to_string()); + + let mut bob = crate::auth::AuthContext::new(crate::auth::AuthKind::ApiToken); + bob.user_id = Some("user-bob".to_string()); + bob.username = Some("bob".to_string()); + bob.api_key_id = Some("key-bob".to_string()); + bob.token_kind = Some("user".to_string()); + let authority = workflow_session_authority_fingerprint(Some(&alice)).unwrap(); + let summary = runtime + .sessions + .start_session_with_options( + SessionCreateOptions::new( + None, + Some("alice ref".to_string()), + SessionMode::Normal, + SessionGuards::default(), + ) + .with_owner_authority_fingerprint(Some(authority)), + ) + .unwrap(); + let session_ref = runtime + .session_reference_for_id(&summary.session_id, Some(&alice)) + .unwrap(); + + let mut arguments = serde_json::json!({"session_id": session_ref.clone()}); + let error = runtime + .canonicalize_session_reference_argument(&mut arguments, Some(&bob)) + .unwrap_err(); + assert!(error.contains("unknown_session_ref")); + assert_eq!(arguments["session_id"], session_ref); + } + + #[test] + fn durable_session_ref_never_retargets_after_runtime_restart() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("session-refs.db"); + let original_ref = { + let runtime = ToolRuntime::new_for_tests().with_project_reference_database(Arc::new( + crate::Database::open(&db_path).unwrap(), + )); + let authority = workflow_session_authority_fingerprint(None).unwrap(); + let summary = runtime + .sessions + .start_session_with_options( + SessionCreateOptions::new( + None, + Some("original".to_string()), + SessionMode::Normal, + SessionGuards::default(), + ) + .with_owner_authority_fingerprint(Some(authority)), + ) + .unwrap(); + runtime + .session_reference_for_id(&summary.session_id, None) + .unwrap() + }; + + // Reopen the durable reference store with a fresh SessionStore. The + // mapping survives, while the referenced Session does not; fail closed. + let runtime = ToolRuntime::new_for_tests() + .with_project_reference_database(Arc::new(crate::Database::open(&db_path).unwrap())); + let mut stale = serde_json::json!({"session_id": original_ref.clone()}); + assert!(runtime + .canonicalize_session_reference_argument(&mut stale, None) + .is_err()); + + // Creating another Session must not make the old selector point at it. + let authority = workflow_session_authority_fingerprint(None).unwrap(); + let replacement = runtime + .sessions + .start_session_with_options( + SessionCreateOptions::new( + None, + Some("replacement".to_string()), + SessionMode::Normal, + SessionGuards::default(), + ) + .with_owner_authority_fingerprint(Some(authority)), + ) + .unwrap(); + assert_ne!(stale["session_id"], replacement.session_id); + assert!(runtime + .canonicalize_session_reference_argument(&mut stale, None) + .is_err()); + let replacement_ref = runtime + .session_reference_for_id(&replacement.session_id, None) + .unwrap(); + assert_ne!(replacement_ref, original_ref); + } +} diff --git a/src/tool_runtime/session_context.rs b/src/tool_runtime/session_context.rs index cdefa3986..1109ac360 100644 --- a/src/tool_runtime/session_context.rs +++ b/src/tool_runtime/session_context.rs @@ -544,6 +544,24 @@ pub(crate) fn workflow_session_authority_fingerprint( )) } +pub(crate) fn workflow_session_incarnation_fingerprint( + session_id: &str, + created_at: i64, + project: Option<&str>, + owner_authority_fingerprint: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"webcodex.workflow-session-incarnation.v1\0"); + hasher.update(session_id.as_bytes()); + hasher.update(b"\0"); + hasher.update(created_at.to_string().as_bytes()); + hasher.update(b"\0"); + hasher.update(project.unwrap_or_default().as_bytes()); + hasher.update(b"\0"); + hasher.update(owner_authority_fingerprint.as_bytes()); + format!("{:x}", hasher.finalize()) +} + pub(crate) fn project_reference_principal_fingerprint( auth: Option<&AuthContext>, ) -> Result { diff --git a/src/tool_runtime/session_tools.rs b/src/tool_runtime/session_tools.rs index 0ac1bda45..dbae4d737 100644 --- a/src/tool_runtime/session_tools.rs +++ b/src/tool_runtime/session_tools.rs @@ -279,7 +279,8 @@ impl ToolRuntime { return invalid_execution_context_result(error); } }; - ToolResult::ok(json!({ + let session_ref = self.session_reference_for_id(&summary.session_id, auth); + let mut output = json!({ "success": true, "session_id": summary.session_id, "project": summary.project, @@ -292,7 +293,11 @@ impl ToolRuntime { "lifecycle": summary.lifecycle, "created_at": summary.created_at, "project_instructions": project_instructions, - })) + }); + if let Some(session_ref) = session_ref { + output["session_ref"] = json!(session_ref); + } + ToolResult::ok(output) } pub(crate) async fn update_session_context_tool( @@ -394,10 +399,15 @@ impl ToolRuntime { return result; } match self.sessions.summary(&session_id, limit) { - Some(summary) => ToolResult::ok( - serde_json::to_value(summary) - .unwrap_or_else(|_| json!({"session_id": session_id, "events": []})), - ), + Some(summary) => { + let session_ref = self.session_reference_for_id(&session_id, auth); + let mut output = serde_json::to_value(summary) + .unwrap_or_else(|_| json!({"session_id": session_id, "events": []})); + if let Some(session_ref) = session_ref { + output["session_ref"] = json!(session_ref); + } + ToolResult::ok(output) + } None => unknown_session_result(&session_id), } } diff --git a/src/tool_runtime/tests/work_on_project.rs b/src/tool_runtime/tests/work_on_project.rs index c441e889e..64489681d 100644 --- a/src/tool_runtime/tests/work_on_project.rs +++ b/src/tool_runtime/tests/work_on_project.rs @@ -1182,6 +1182,7 @@ fn work_on_project_schema_and_registration() { .unwrap(); for field in [ "session_id", + "session_ref", "project", "resolved_project", "project_ref", @@ -1677,6 +1678,19 @@ fn work_on_project_projection_fails_closed_when_required_field_is_missing() { .is_some_and(|detail| detail.contains("session_id"))); } +#[test] +fn work_on_project_projection_preserves_session_ref() { + let mut output = valid_work_on_project_projection_input(); + output["session"]["session_ref"] = json!("~s12"); + + let result = crate::tool_runtime::coding_task::project_work_on_project_output( + SAMPLE_PROJECT.to_string(), + output, + ); + assert!(result.success, "{:?}", result.error); + assert_eq!(result.output["session_ref"], "~s12"); +} + #[test] fn work_on_project_projection_emits_typed_window_session_correlation() { let mut correlation = crate::tool_runtime::ToolCallCorrelation::default(); @@ -2683,10 +2697,28 @@ async fn workflow_resume_context_is_window_principal_scoped_bounded_and_non_auth let window_db = std::sync::Arc::new( crate::Database::open(&audit_root.path().join("workflow-resume.db")).unwrap(), ); - let runtime = ToolRuntime::new_for_tests().with_window_activity_database(window_db.clone()); + let runtime = ToolRuntime::new_for_tests() + .with_window_activity_database(window_db.clone()) + .with_project_reference_database(window_db.clone()); let project = register_runner_project_at_path(&runtime, "workflow-resume", "demo", root.path()).await; let auth = auth_context(None, true); + let owner_authority = crate::tool_runtime::workflow_session_authority_fingerprint(Some(&auth)) + .expect("test auth has stable Workflow Session authority"); + let start_owned_session = |project: Option, title: Option| { + runtime + .sessions + .start_session_with_options( + crate::tool_runtime::sessions::SessionCreateOptions::new( + project, + title, + SessionMode::Normal, + SessionGuards::default(), + ) + .with_owner_authority_fingerprint(Some(owner_authority.clone())), + ) + .unwrap() + }; let window_id = "workflow-resume-window"; let window = crate::client_window::ClientWindow::for_test(window_id); @@ -2705,7 +2737,7 @@ async fn workflow_resume_context_is_window_principal_scoped_bounded_and_non_auth assert_eq!(empty["count"], 0); assert!(empty.get("suggested_call").is_none()); - let first = runtime.sessions.start_session( + let first = start_owned_session( Some(project.clone()), Some("implementation recovery candidate".to_string()), ); @@ -2729,9 +2761,13 @@ async fn workflow_resume_context_is_window_principal_scoped_bounded_and_non_auth .unwrap(); assert_eq!(single["count"], 1); assert_eq!(single["candidates"][0]["session_id"], first.session_id); + let first_ref = single["candidates"][0]["session_ref"] + .as_str() + .expect("authorized discovery candidate should expose a short Session selector"); + assert!(first_ref.starts_with("~s")); assert_eq!( single["suggested_call"]["arguments"]["session_id"], - first.session_id + first_ref ); let other_window = crate::client_window::ClientWindow::for_test("workflow-resume-other"); @@ -2749,7 +2785,7 @@ async fn workflow_resume_context_is_window_principal_scoped_bounded_and_non_auth assert_eq!(hidden_by_principal["count"], 0); let inaccessible_project = "agent:missing:workflow-resume"; - let inaccessible = runtime.sessions.start_session( + let inaccessible = start_owned_session( Some(inaccessible_project.to_string()), Some("must-not-leak-secret-title".to_string()), ); @@ -2775,7 +2811,7 @@ async fn workflow_resume_context_is_window_principal_scoped_bounded_and_non_auth assert!(!hidden_json.contains(&inaccessible.session_id)); assert!(!hidden_json.contains("must-not-leak-secret-title")); - let second = runtime.sessions.start_session( + let second = start_owned_session( Some(project.clone()), Some("independent review recovery candidate".to_string()), ); @@ -2810,14 +2846,17 @@ async fn workflow_resume_context_is_window_principal_scoped_bounded_and_non_auth active_only["candidates"][0]["session_id"], second.session_id ); + let second_ref = active_only["candidates"][0]["session_ref"] + .as_str() + .expect("remaining authorized candidate should keep its short selector"); assert_eq!( active_only["suggested_call"]["arguments"]["session_id"], - second.session_id + second_ref ); let mut newest_session_id = String::new(); for index in 0..8 { - let extra = runtime.sessions.start_session( + let extra = start_owned_session( Some(project.clone()), Some(format!("bounded recovery candidate {index}")), );