Skip to main content

agent_workspace_contract/
http.rs

1use super::*;
2
3pub const WORKSPACE_SERVICE_NAME: &str = "agent-workspace";
4/// Resource-scoped file API used by leftover managed callers. `filePath` is an axum
5/// catch-all and therefore may contain workspace-relative `/` separators.
6pub 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/// Return the capability required by both the public Gateway alias and the
19/// internal Workspace service route. Keeping this decision in the contract
20/// prevents the Gateway-issued downstream credential from drifting away from
21/// the capability independently enforced by the Workspace server.
22#[allow(clippy::if_same_then_else)] // Branch order preserves more-specific file/snapshot capabilities.
23pub 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
87/// Strip `/internal/v1` or `/v1`, then the Environment domain prefix `/workspace`
88/// when it is a full path segment. `/workspaces` (plural managed API) is left intact.
89pub 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/// Body for the resource-scoped `PUT .../files/{path}` API. The path belongs
201/// to the URL so it cannot disagree with a second body field.
202#[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/// One bounded typed search entry point. Fields that do not apply to the
219/// selected kind are rejected by the service rather than guessed.
220#[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            ("GET", "/skills:search", "files:read"),
387            ("GET", "/delegations", "workspace:manage"),
388            ("POST", "/computers/c/commands", "commands:exec"),
389        ];
390        for (method, suffix, expected) in cases {
391            assert_eq!(
392                required_workspace_capability(&format!("/v1{suffix}"), method),
393                expected
394            );
395            assert_eq!(
396                required_workspace_capability(&format!("/internal/v1{suffix}"), method),
397                expected
398            );
399            assert_eq!(
400                required_workspace_capability(&format!("/v1/workspace{suffix}"), method),
401                expected,
402                "{method} /v1/workspace{suffix}"
403            );
404            assert_eq!(
405                required_workspace_capability(&format!("/internal/v1/workspace{suffix}"), method),
406                expected,
407                "{method} /internal/v1/workspace{suffix}"
408            );
409        }
410    }
411}