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