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/") && 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
82/// Strip `/internal/v1` or `/v1`, then the Environment domain prefix `/workspace`
83/// when it is a full path segment. `/workspaces` (plural managed API) is left intact.
84pub 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/// Body for the resource-scoped `PUT .../files/{path}` API. The path belongs
196/// to the URL so it cannot disagree with a second body field.
197#[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/// One bounded typed search entry point. Fields that do not apply to the
214/// selected kind are rejected by the service rather than guessed.
215#[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}