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