Skip to main content

agent_workspace_contract/
http.rs

1//! Shared HTTP capability and resource-route classification.
2use super::*;
3
4pub const WORKSPACE_SERVICE_NAME: &str = "agent-workspace";
5/// Resource-scoped file API used by leftover managed callers. `filePath` is an axum
6/// catch-all and therefore may contain workspace-relative `/` separators.
7pub 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/// Return the capability required by both the public Gateway alias and the
20/// internal Workspace service route. Keeping this decision in the contract
21/// prevents the Gateway-issued downstream credential from drifting away from
22/// the capability independently enforced by the Workspace server.
23#[allow(clippy::if_same_then_else)] // Branch order preserves more-specific file/snapshot capabilities.
24pub 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
109/// Strip `/internal/v1` or `/v1`, then the Environment domain prefix `/workspace`
110/// when it is a full path segment. `/workspaces` (plural managed API) is left intact.
111pub 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
128/// Context lifecycle URLs contain no Computer id. The edge checks execution
129/// capability; Workspace then checks the stored owner/executor and Space grant.
130/// Creation remains a caller-authorized operation, never a Runtime lifecycle call.
131pub 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/// Body for the resource-scoped `PUT .../files/{path}` API. The path belongs
243/// to the URL so it cannot disagree with a second body field.
244#[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/// One bounded typed search entry point. Fields that do not apply to the
261/// selected kind are rejected by the service rather than guessed.
262#[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}