1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
//! Per-call context passed to every `Tool::call` invocation.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::capability::CapabilitySet;
use crate::cursor::CursorIssuer;
use crate::secrets::SecretBundle;
use crate::tier::ToolTier;
use crate::tracker::ReadTracker;
#[non_exhaustive]
pub struct CallContext {
/// Working directory for relative-path tools (Read / Bash / Glob / ...).
pub cwd: PathBuf,
/// Advisory truncation budget. Tools should respect this and return
/// truncation markers when producing larger output. In SP-12 this is
/// derived from the tool's tier via `TierPolicy::max_output`.
pub max_output_bytes: usize,
/// Unique id for tracing/logging; not emitted on the wire.
pub call_id: ulid::Ulid,
/// Absolute deadline. Tools that wrap long operations in tokio::time::timeout
/// should pass `remaining_time()` as the budget.
pub deadline: Option<Instant>,
/// Shared-per-connection read tracker. `None` in isolated unit tests;
/// server always attaches one via `Arc::clone` in per-connection state.
pub read_tracker: Option<Arc<ReadTracker>>,
/// Connection-scoped capability allow-list. Populated from the `Hello`
/// handshake; shared across all calls on a single connection. Tools that
/// gate side effects on caller authority may read this, though dispatch
/// already enforces `required_capabilities` before invocation.
pub capabilities: Arc<CapabilitySet>,
/// Tier the current call resolved to. Informational for tools; dispatch
/// uses the tier to pick the deadline / max_output budget above.
pub tier: ToolTier,
/// Optional per-call agent identity. Populated from the `Hello.client_id`
/// handshake; `None` for unit tests and in-process dispatch with no
/// client-id source. Audit events copy this field into `CallEvent.caller_id`.
pub caller_id: Option<String>,
/// Optional per-call secret bundle resolved by `ServerConfig::token_broker`
/// (SP-token-broker-phase1). `None` means either (a) no broker is
/// configured on this server, or (b) the broker returned `Ok(None)` for
/// this caller — both cases pass through to the tool's existing fallback
/// (env vars, saved file, etc.). Tools that need a secret access via
/// `ctx.secrets()`. Audit `CallEvent.secrets_resolved` is `true` iff
/// this is `Some(_)`.
pub secrets: Option<Arc<SecretBundle>>,
/// SP-pagination-v1 — cursor issuer for tools that override
/// `Tool::call_paginated`. `None` for non-paginating dispatch paths
/// (test fixtures, listeners that haven't wired pagination). Tools
/// use `ctx.cursor_issuer().issue(payload)` to mint a `next_cursor`.
pub cursor_issuer: Option<Arc<CursorIssuer>>,
}
impl CallContext {
/// Construct a `CallContext` from its required fields. This is the
/// stable constructor across crate boundaries — the type is
/// `#[non_exhaustive]`, so adding new fields in the future means
/// callers migrate through `CallContext::new(...)` (or a future
/// `CallContextBuilder`), not via struct-literal breakage.
#[allow(clippy::too_many_arguments)]
pub fn new(
cwd: PathBuf,
max_output_bytes: usize,
call_id: ulid::Ulid,
deadline: Option<Instant>,
read_tracker: Option<Arc<ReadTracker>>,
capabilities: Arc<CapabilitySet>,
tier: ToolTier,
caller_id: Option<String>,
secrets: Option<Arc<SecretBundle>>,
) -> Self {
Self {
cwd,
max_output_bytes,
call_id,
deadline,
read_tracker,
capabilities,
tier,
caller_id,
secrets,
cursor_issuer: None,
}
}
/// Builder-style attach of the cursor issuer. Call after `new` from
/// dispatch code that wants to enable pagination for the call.
pub fn with_cursor_issuer(mut self, issuer: Arc<CursorIssuer>) -> Self {
self.cursor_issuer = Some(issuer);
self
}
/// Convenience accessor for paginating tools. Returns the issuer if
/// one was attached by dispatch; tools that override `call_paginated`
/// but find this `None` must fall back to returning `next_cursor: None`
/// (or error if cursors are mandatory).
pub fn cursor_issuer(&self) -> Option<&CursorIssuer> {
self.cursor_issuer.as_deref()
}
pub fn remaining_time(&self) -> Option<Duration> {
self.deadline
.map(|d| d.saturating_duration_since(Instant::now()))
}
/// Returns the resolved secret bundle, if any. `None` when no broker
/// is configured on the server, or when the broker returned `None`
/// for this caller.
pub fn secrets(&self) -> Option<&SecretBundle> {
self.secrets.as_deref()
}
}
#[cfg(any(test, feature = "testing"))]
impl CallContext {
/// Construct a sensible default for unit tests. cwd = current dir,
/// 1 MiB output budget, fresh call_id, no deadline, no tracker,
/// empty capability set, Warm tier.
pub fn for_test() -> Self {
Self {
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
max_output_bytes: 1_048_576,
call_id: ulid::Ulid::new(),
deadline: None,
read_tracker: None,
capabilities: Arc::new(CapabilitySet::empty()),
tier: ToolTier::Warm,
caller_id: None,
secrets: None,
cursor_issuer: None,
}
}
/// Test-only: build a CallContext with a fresh tracker attached.
/// Returns both so tests can also record/check on the same tracker.
pub fn for_test_with_tracker() -> (Self, Arc<ReadTracker>) {
let tracker = Arc::new(ReadTracker::new());
let ctx = Self {
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
max_output_bytes: 1_048_576,
call_id: ulid::Ulid::new(),
deadline: None,
read_tracker: Some(tracker.clone()),
capabilities: Arc::new(CapabilitySet::empty()),
tier: ToolTier::Warm,
caller_id: None,
secrets: None,
cursor_issuer: None,
};
(ctx, tracker)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn for_test_has_sensible_defaults() {
let ctx = CallContext::for_test();
assert!(ctx.cwd.exists(), "cwd should be a real directory");
assert_eq!(ctx.max_output_bytes, 1_048_576);
assert!(ctx.deadline.is_none());
assert!(ctx.read_tracker.is_none());
}
#[test]
fn for_test_with_tracker_shares_arc() {
let (ctx, tracker) = CallContext::for_test_with_tracker();
assert!(ctx.read_tracker.is_some());
let ctx_tracker = ctx.read_tracker.as_ref().unwrap();
assert!(Arc::ptr_eq(ctx_tracker, &tracker));
}
#[test]
fn remaining_time_is_none_when_no_deadline() {
let ctx = CallContext::for_test();
assert!(ctx.remaining_time().is_none());
}
#[test]
fn remaining_time_counts_down_from_deadline() {
let ctx = CallContext {
cwd: PathBuf::from("."),
max_output_bytes: 1024,
call_id: ulid::Ulid::new(),
deadline: Some(Instant::now() + Duration::from_secs(5)),
read_tracker: None,
capabilities: Arc::new(CapabilitySet::empty()),
tier: ToolTier::Warm,
caller_id: None,
secrets: None,
cursor_issuer: None,
};
let r = ctx.remaining_time().unwrap();
assert!(r <= Duration::from_secs(5));
assert!(r > Duration::from_secs(4));
}
#[test]
fn remaining_time_saturates_to_zero_after_deadline() {
let ctx = CallContext {
cwd: PathBuf::from("."),
max_output_bytes: 1024,
call_id: ulid::Ulid::new(),
deadline: Some(Instant::now() - Duration::from_secs(10)),
read_tracker: None,
capabilities: Arc::new(CapabilitySet::empty()),
tier: ToolTier::Warm,
caller_id: None,
secrets: None,
cursor_issuer: None,
};
assert_eq!(ctx.remaining_time().unwrap(), Duration::ZERO);
}
#[test]
fn for_test_has_empty_capabilities_and_warm_tier() {
let ctx = CallContext::for_test();
assert!(ctx.capabilities.granted().is_empty());
assert_eq!(ctx.tier, ToolTier::Warm);
}
}