1use super::*;
2
3pub const WORKSPACE_SERVICE_NAME: &str = "agent-workspace";
4pub const WORKSPACE_RESOURCE_FILE_PATH: &str =
7 "/internal/v1/workspaces/{workspaceId}/files/{*filePath}";
8pub const WORKSPACE_RESOURCE_SEARCH_PATH: &str =
9 "/internal/v1/workspaces/{workspaceId}/files:search";
10
11pub const WORKSPACE_MAX_FILE_BYTES: usize = 16 * 1024 * 1024;
12pub const WORKSPACE_MAX_RESULTS: usize = 10_000;
13pub const WORKSPACE_MAX_SEARCH_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
14pub const WORKSPACE_MAX_COMMAND_OUTPUT_BYTES: usize = 1024 * 1024;
15pub const WORKSPACE_MAX_COMMAND_BYTES: usize = 64 * 1024;
16pub const WORKSPACE_MAX_PATH_BYTES: usize = 4096;
17
18#[allow(clippy::if_same_then_else)] pub fn required_workspace_capability(path: &str, method: &str) -> &'static str {
24 let path = strip_environment_api_prefix(path);
25 if method == "GET"
26 && (path == "/delegations"
27 || path.ends_with("/binding")
28 || path.ends_with("/grants")
29 || path.ends_with("/sessions")
30 || path.ends_with("/leases")
31 || path.ends_with("/change-sets"))
32 {
33 "workspace:manage"
34 } else if path.starts_with("/spaces/") && path.contains("/files/") && method == "PUT" {
35 "files:write"
36 } else if path.starts_with("/spaces/") && path.contains("/files/") {
37 "files:read"
38 } else if path.contains("/snapshots") || path.starts_with("/templates") {
39 "snapshot:manage"
40 } else if path.contains("/commands") || path.ends_with("/exec") {
41 "commands:exec"
42 } else if method != "GET"
43 && (path.starts_with("/computers")
44 || path.starts_with("/spaces")
45 || path.starts_with("/space-")
46 || path.starts_with("/sandboxes")
47 || path.starts_with("/delegations")
48 || path.starts_with("/templates"))
49 {
50 "workspace:manage"
51 } else if (path.contains("/workspace-operations/") && method != "GET")
52 || path == "/workspaces" && method == "POST"
53 || path.starts_with("/workspaces/")
54 && (method == "DELETE"
55 || path.ends_with("/suspend")
56 || path.ends_with("/resume")
57 || path.ends_with("/reconcile")
58 || path.ends_with("/lease"))
59 {
60 "workspace:manage"
61 } else if path.contains("/uploads")
62 || (path.contains("/files/") && matches!(method, "PUT" | "DELETE"))
63 {
64 "files:write"
65 } else if path.ends_with("/clone") || path.ends_with("/migrate") {
66 "snapshot:manage"
67 } else if path.ends_with("/lease") || path.ends_with("/suspend") || path.ends_with("/resume") {
68 "workspace:manage"
69 } else if path.ends_with("/port-url") || path.contains("/previews") {
70 "preview:manage"
71 } else if (path.contains("change-sets") && method != "GET")
72 || path.contains("write-file")
73 || path.contains("create-dir")
74 || path.contains("remove-file")
75 {
76 "files:write"
77 } else if path == "/skills:search" {
78 "files:read"
79 } else {
80 "files:read"
81 }
82}
83
84pub fn strip_environment_api_prefix(path: &str) -> &str {
87 let path = path
88 .strip_prefix("/internal/v1")
89 .or_else(|| path.strip_prefix("/v1"))
90 .unwrap_or(path);
91 match path.strip_prefix("/workspace") {
92 Some(rest) if rest.is_empty() || rest.starts_with('/') => {
93 if rest.is_empty() {
94 "/"
95 } else {
96 rest
97 }
98 }
99 _ => path,
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct WorkspaceCapabilities {
106 pub schema_version: String,
107 pub files: bool,
108 pub search: bool,
109 pub commands: bool,
110 pub previews: bool,
111 pub atomic_writes: bool,
112 pub compare_and_swap: bool,
113 pub max_file_bytes: usize,
114 pub max_results: usize,
115 pub max_search_output_bytes: usize,
116 pub max_command_output_bytes: usize,
117}
118
119impl Default for WorkspaceCapabilities {
120 fn default() -> Self {
121 Self {
122 schema_version: "v1".into(),
123 files: true,
124 search: true,
125 commands: false,
126 previews: false,
127 atomic_writes: false,
128 compare_and_swap: false,
129 max_file_bytes: WORKSPACE_MAX_FILE_BYTES,
130 max_results: WORKSPACE_MAX_RESULTS,
131 max_search_output_bytes: WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
132 max_command_output_bytes: WORKSPACE_MAX_COMMAND_OUTPUT_BYTES,
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct WorkspaceApiError {
140 pub status: u16,
141 pub code: String,
142 #[serde(default = "default_error_category")]
143 pub category: String,
144 pub message: String,
145 pub retryable: bool,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub request_id: Option<String>,
148}
149
150fn default_error_category() -> String {
151 "internal".into()
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct PathRequest {
156 pub path: String,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct RemoveFileRequest {
162 pub path: String,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub if_match: Option<String>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct ReadFileTailRequest {
170 pub path: String,
171 #[serde(default = "default_read_tail_bytes")]
172 pub max_bytes: usize,
173}
174
175fn default_read_tail_bytes() -> usize {
176 4096
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct WriteFileRequest {
182 pub path: String,
183 pub content: String,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub if_match: Option<String>,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct WriteFileBytesRequest {
191 pub path: String,
192 pub content_base64: String,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub if_match: Option<String>,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(deny_unknown_fields, rename_all = "camelCase")]
201pub struct PutWorkspaceFileRequest {
202 pub content_base64: String,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub if_match: Option<String>,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum WorkspaceSearchKind {
210 Walk,
211 Find,
212 Grep,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(deny_unknown_fields, rename_all = "camelCase")]
219pub struct WorkspaceSearchRequest {
220 pub kind: WorkspaceSearchKind,
221 #[serde(default)]
222 pub path: String,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub pattern: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub include: Option<String>,
227 #[serde(default = "default_search_depth")]
228 pub max_depth: usize,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub cursor: Option<String>,
231 #[serde(default = "default_search_page_limit")]
232 pub limit: usize,
233}
234
235fn default_search_depth() -> usize {
236 32
237}
238
239fn default_search_page_limit() -> usize {
240 100
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct WorkspaceSearchResponse {
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
247 pub paths: Vec<String>,
248 #[serde(default, skip_serializing_if = "Vec::is_empty")]
249 pub matches: Vec<GrepMatch>,
250 pub truncated: bool,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub next_cursor: Option<String>,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct ListDirRequest {
258 pub path: String,
259 #[serde(default)]
260 pub include_hidden: bool,
261 #[serde(default)]
262 pub include_ignored: bool,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct WalkTreeRequest {
268 pub path: String,
269 pub max_depth: usize,
270 #[serde(default)]
271 pub include_hidden: bool,
272 #[serde(default)]
273 pub include_ignored: bool,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct FindFilesRequest {
278 pub pattern: String,
279 pub path: String,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct GrepRequest {
284 pub pattern: String,
285 pub path: String,
286 #[serde(default)]
287 pub include: Option<String>,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct ExecRequest {
292 pub command: String,
293 #[serde(default)]
294 pub cwd: Option<String>,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct PortUrlRequest {
299 pub port: u16,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct ContentResponse {
304 pub content: String,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub revision: Option<String>,
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase")]
311pub struct BytesResponse {
312 pub content_base64: String,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub revision: Option<String>,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318pub struct AckResponse {
319 pub ok: bool,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub revision: Option<String>,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325pub struct ExistsResponse {
326 pub exists: bool,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(rename_all = "camelCase")]
331pub struct IsDirResponse {
332 pub is_dir: bool,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336pub struct EntriesResponse {
337 pub entries: Vec<DirEntry>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct PathsResponse {
342 pub paths: Vec<String>,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346pub struct GrepResponse {
347 pub matches: Vec<GrepMatch>,
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn wire_contract_uses_camel_case_and_keeps_legacy_optional_fields() {
356 let request = WriteFileRequest {
357 path: "src/lib.rs".into(),
358 content: "fn main() {}".into(),
359 if_match: Some("sha256:abc".into()),
360 };
361 assert_eq!(
362 serde_json::to_string(&request).unwrap(),
363 r#"{"path":"src/lib.rs","content":"fn main() {}","ifMatch":"sha256:abc"}"#
364 );
365 let legacy: AckResponse = serde_json::from_str(r#"{"ok":true}"#).unwrap();
366 assert_eq!(legacy.revision, None);
367 }
368
369 #[test]
370 fn public_and_internal_workspace_routes_share_one_capability_contract() {
371 let cases = [
372 ("POST", "/computers", "workspace:manage"),
373 ("GET", "/computers", "files:read"),
374 ("POST", "/spaces/s/files/read", "files:read"),
375 ("PUT", "/spaces/s/files/a", "files:write"),
376 ("POST", "/spaces/s/change-sets", "workspace:manage"),
377 ("POST", "/snapshots", "snapshot:manage"),
378 ("GET", "/templates", "snapshot:manage"),
379 ("POST", "/sandboxes", "workspace:manage"),
380 ("POST", "/sandboxes/s/skills", "workspace:manage"),
381 ("GET", "/skills:search", "files:read"),
382 ("GET", "/delegations", "workspace:manage"),
383 ("POST", "/computers/c/commands", "commands:exec"),
384 ];
385 for (method, suffix, expected) in cases {
386 assert_eq!(
387 required_workspace_capability(&format!("/v1{suffix}"), method),
388 expected
389 );
390 assert_eq!(
391 required_workspace_capability(&format!("/internal/v1{suffix}"), method),
392 expected
393 );
394 assert_eq!(
395 required_workspace_capability(&format!("/v1/workspace{suffix}"), method),
396 expected,
397 "{method} /v1/workspace{suffix}"
398 );
399 assert_eq!(
400 required_workspace_capability(&format!("/internal/v1/workspace{suffix}"), method),
401 expected,
402 "{method} /internal/v1/workspace{suffix}"
403 );
404 }
405 }
406}