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