Skip to main content

agent_workspace_contract/
http.rs

1use super::*;
2
3pub const WORKSPACE_SERVICE_NAME: &str = "agent-workspace";
4pub const WORKSPACE_API_PREFIX: &str = "/internal/v1/workspace";
5pub const WORKSPACE_CAPABILITIES_PATH: &str = "/internal/v1/workspace/capabilities";
6pub const WORKSPACE_READ_FILE_PATH: &str = "/internal/v1/workspace/read-file";
7pub const WORKSPACE_READ_FILE_BYTES_PATH: &str = "/internal/v1/workspace/read-file-bytes";
8pub const WORKSPACE_READ_FILE_TAIL_PATH: &str = "/internal/v1/workspace/read-file-tail";
9pub const WORKSPACE_WRITE_FILE_PATH: &str = "/internal/v1/workspace/write-file";
10pub const WORKSPACE_WRITE_FILE_BYTES_PATH: &str = "/internal/v1/workspace/write-file-bytes";
11pub const WORKSPACE_CREATE_DIR_ALL_PATH: &str = "/internal/v1/workspace/create-dir-all";
12pub const WORKSPACE_REMOVE_FILE_PATH: &str = "/internal/v1/workspace/remove-file";
13pub const WORKSPACE_EXISTS_PATH: &str = "/internal/v1/workspace/exists";
14pub const WORKSPACE_IS_DIR_PATH: &str = "/internal/v1/workspace/is-dir";
15pub const WORKSPACE_LIST_DIR_PATH: &str = "/internal/v1/workspace/list-dir";
16pub const WORKSPACE_WALK_TREE_PATH: &str = "/internal/v1/workspace/walk-tree";
17pub const WORKSPACE_FIND_FILES_PATH: &str = "/internal/v1/workspace/find-files";
18pub const WORKSPACE_GREP_PATH: &str = "/internal/v1/workspace/grep";
19pub const WORKSPACE_EXEC_PATH: &str = "/internal/v1/workspace/exec";
20pub const WORKSPACE_PORT_URL_PATH: &str = "/internal/v1/workspace/port-url";
21/// Resource-scoped file API used by new consumers. `filePath` is an axum
22/// catch-all and therefore may contain workspace-relative `/` separators.
23pub const WORKSPACE_RESOURCE_FILE_PATH: &str =
24    "/internal/v1/workspaces/{workspaceId}/files/{*filePath}";
25pub const WORKSPACE_RESOURCE_SEARCH_PATH: &str =
26    "/internal/v1/workspaces/{workspaceId}/files:search";
27
28pub const WORKSPACE_MAX_FILE_BYTES: usize = 16 * 1024 * 1024;
29pub const WORKSPACE_MAX_RESULTS: usize = 10_000;
30pub const WORKSPACE_MAX_SEARCH_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
31pub const WORKSPACE_MAX_COMMAND_OUTPUT_BYTES: usize = 1024 * 1024;
32pub const WORKSPACE_MAX_COMMAND_BYTES: usize = 64 * 1024;
33pub const WORKSPACE_MAX_PATH_BYTES: usize = 4096;
34
35/// Return the capability required by both the public Gateway alias and the
36/// internal Workspace service route. Keeping this decision in the contract
37/// prevents the Gateway-issued downstream credential from drifting away from
38/// the capability independently enforced by the Workspace server.
39#[allow(clippy::if_same_then_else)] // Branch order preserves more-specific file/snapshot capabilities.
40pub fn required_workspace_capability(path: &str, method: &str) -> &'static str {
41    let path = path
42        .strip_prefix("/internal/v1")
43        .or_else(|| path.strip_prefix("/v1"))
44        .unwrap_or(path);
45    if method == "GET"
46        && (path == "/delegations"
47            || path.ends_with("/binding")
48            || path.ends_with("/grants")
49            || path.ends_with("/sessions")
50            || path.ends_with("/leases")
51            || path.ends_with("/change-sets"))
52    {
53        "workspace:manage"
54    } else if path.starts_with("/spaces/") && path.contains("/files/") && method == "PUT" {
55        "files:write"
56    } else if path.starts_with("/spaces/") && path.contains("/files/") {
57        "files:read"
58    } else if path.contains("/snapshots") || path.starts_with("/templates") {
59        "snapshot:manage"
60    } else if method != "GET"
61        && (path.starts_with("/computers")
62            || path.starts_with("/spaces")
63            || path.starts_with("/space-")
64            || path.starts_with("/sandboxes")
65            || path.starts_with("/delegations")
66            || path.starts_with("/templates"))
67    {
68        "workspace:manage"
69    } else if (path.contains("/workspace-operations/") && method != "GET")
70        || path == "/workspaces" && method == "POST"
71        || path.starts_with("/workspaces/")
72            && (method == "DELETE"
73                || path.ends_with("/suspend")
74                || path.ends_with("/resume")
75                || path.ends_with("/reconcile")
76                || path.ends_with("/lease"))
77    {
78        "workspace:manage"
79    } else if path.contains("/commands") || path.ends_with("/exec") {
80        "commands:exec"
81    } else if path.contains("/uploads")
82        || (path.contains("/files/") && matches!(method, "PUT" | "DELETE"))
83    {
84        "files:write"
85    } else if path.ends_with("/clone") || path.ends_with("/migrate") {
86        "snapshot:manage"
87    } else if path.ends_with("/lease") || path.ends_with("/suspend") || path.ends_with("/resume") {
88        "workspace:manage"
89    } else if path.ends_with("/port-url") || path.contains("/previews") {
90        "preview:manage"
91    } else if (path.contains("change-sets") && method != "GET")
92        || path.contains("write-file")
93        || path.contains("create-dir")
94        || path.contains("remove-file")
95    {
96        "files:write"
97    } else {
98        "files:read"
99    }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct WorkspaceCapabilities {
105    pub schema_version: String,
106    pub files: bool,
107    pub search: bool,
108    pub commands: bool,
109    pub previews: bool,
110    pub atomic_writes: bool,
111    pub compare_and_swap: bool,
112    pub max_file_bytes: usize,
113    pub max_results: usize,
114    pub max_search_output_bytes: usize,
115    pub max_command_output_bytes: usize,
116}
117
118impl Default for WorkspaceCapabilities {
119    fn default() -> Self {
120        Self {
121            schema_version: "v1".into(),
122            files: true,
123            search: true,
124            commands: false,
125            previews: false,
126            atomic_writes: false,
127            compare_and_swap: false,
128            max_file_bytes: WORKSPACE_MAX_FILE_BYTES,
129            max_results: WORKSPACE_MAX_RESULTS,
130            max_search_output_bytes: WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
131            max_command_output_bytes: WORKSPACE_MAX_COMMAND_OUTPUT_BYTES,
132        }
133    }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct WorkspaceApiError {
139    pub status: u16,
140    pub code: String,
141    #[serde(default = "default_error_category")]
142    pub category: String,
143    pub message: String,
144    pub retryable: bool,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub request_id: Option<String>,
147}
148
149fn default_error_category() -> String {
150    "internal".into()
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct PathRequest {
155    pub path: String,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct RemoveFileRequest {
161    pub path: String,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub if_match: Option<String>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct ReadFileTailRequest {
169    pub path: String,
170    #[serde(default = "default_read_tail_bytes")]
171    pub max_bytes: usize,
172}
173
174fn default_read_tail_bytes() -> usize {
175    4096
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase")]
180pub struct WriteFileRequest {
181    pub path: String,
182    pub content: String,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub if_match: Option<String>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct WriteFileBytesRequest {
190    pub path: String,
191    pub content_base64: String,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub if_match: Option<String>,
194}
195
196/// Body for the resource-scoped `PUT .../files/{path}` API. The path belongs
197/// to the URL so it cannot disagree with a second body field.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(deny_unknown_fields, rename_all = "camelCase")]
200pub struct PutWorkspaceFileRequest {
201    pub content_base64: String,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub if_match: Option<String>,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub enum WorkspaceSearchKind {
209    Walk,
210    Find,
211    Grep,
212}
213
214/// One bounded typed search entry point. Fields that do not apply to the
215/// selected kind are rejected by the service rather than guessed.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(deny_unknown_fields, rename_all = "camelCase")]
218pub struct WorkspaceSearchRequest {
219    pub kind: WorkspaceSearchKind,
220    #[serde(default)]
221    pub path: String,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub pattern: Option<String>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub include: Option<String>,
226    #[serde(default = "default_search_depth")]
227    pub max_depth: usize,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub cursor: Option<String>,
230    #[serde(default = "default_search_page_limit")]
231    pub limit: usize,
232}
233
234fn default_search_depth() -> usize {
235    32
236}
237
238fn default_search_page_limit() -> usize {
239    100
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase")]
244pub struct WorkspaceSearchResponse {
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub paths: Vec<String>,
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub matches: Vec<GrepMatch>,
249    pub truncated: bool,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub next_cursor: Option<String>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct ListDirRequest {
257    pub path: String,
258    #[serde(default)]
259    pub include_hidden: bool,
260    #[serde(default)]
261    pub include_ignored: bool,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct WalkTreeRequest {
267    pub path: String,
268    pub max_depth: usize,
269    #[serde(default)]
270    pub include_hidden: bool,
271    #[serde(default)]
272    pub include_ignored: bool,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276pub struct FindFilesRequest {
277    pub pattern: String,
278    pub path: String,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct GrepRequest {
283    pub pattern: String,
284    pub path: String,
285    #[serde(default)]
286    pub include: Option<String>,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct ExecRequest {
291    pub command: String,
292    #[serde(default)]
293    pub cwd: Option<String>,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297pub struct PortUrlRequest {
298    pub port: u16,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302pub struct ContentResponse {
303    pub content: String,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub revision: Option<String>,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct BytesResponse {
311    pub content_base64: String,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub revision: Option<String>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct AckResponse {
318    pub ok: bool,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub revision: Option<String>,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct ExistsResponse {
325    pub exists: bool,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330pub struct IsDirResponse {
331    pub is_dir: bool,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct EntriesResponse {
336    pub entries: Vec<DirEntry>,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340pub struct PathsResponse {
341    pub paths: Vec<String>,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct GrepResponse {
346    pub matches: Vec<GrepMatch>,
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn wire_contract_uses_camel_case_and_keeps_legacy_optional_fields() {
355        let request = WriteFileRequest {
356            path: "src/lib.rs".into(),
357            content: "fn main() {}".into(),
358            if_match: Some("sha256:abc".into()),
359        };
360        assert_eq!(
361            serde_json::to_string(&request).unwrap(),
362            r#"{"path":"src/lib.rs","content":"fn main() {}","ifMatch":"sha256:abc"}"#
363        );
364        let legacy: AckResponse = serde_json::from_str(r#"{"ok":true}"#).unwrap();
365        assert_eq!(legacy.revision, None);
366    }
367
368    #[test]
369    fn public_and_internal_workspace_routes_share_one_capability_contract() {
370        let cases = [
371            ("POST", "/computers", "workspace:manage"),
372            ("GET", "/computers", "files:read"),
373            ("POST", "/spaces/s/files/read", "files:read"),
374            ("PUT", "/spaces/s/files/a", "files:write"),
375            ("POST", "/spaces/s/change-sets", "workspace:manage"),
376            ("POST", "/snapshots", "snapshot:manage"),
377            ("GET", "/templates", "snapshot:manage"),
378            ("POST", "/sandboxes", "workspace:manage"),
379            ("GET", "/delegations", "workspace:manage"),
380            ("POST", "/workspaces/w/commands", "commands:exec"),
381        ];
382        for (method, suffix, expected) in cases {
383            assert_eq!(
384                required_workspace_capability(&format!("/v1{suffix}"), method),
385                expected
386            );
387            assert_eq!(
388                required_workspace_capability(&format!("/internal/v1{suffix}"), method),
389                expected
390            );
391        }
392    }
393}