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