From 10267869ae0dfe02e186dc69fec14151926f5009 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 08:59:19 +0900 Subject: [PATCH] feat: enforce embedded no-workdir worker policy --- crates/workspace-server/src/server.rs | 93 +++++++++++++++---- .../src/lib/workspace-sidebar/types.ts | 1 + .../workspace-sidebar/worker-launch.test.ts | 24 ++++- .../w/[workspaceId]/workers/new/+page.svelte | 39 ++++++-- 4 files changed, 127 insertions(+), 30 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 60b210f9..ecfb012b 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -753,6 +753,7 @@ pub struct WorkerLaunchRuntimeOption { pub display_name: String, pub built_in: bool, pub can_spawn_worker: bool, + pub working_directory_required: bool, pub status: String, pub diagnostics: Vec, } @@ -2642,6 +2643,9 @@ async fn create_workspace_worker( relative_cwd: selection.relative_cwd, }); validate_working_directory_claim_for_browser(resolved_working_directory.as_ref())?; + if resolved_working_directory.is_none() { + reject_no_workdir_for_non_embedded_runtime(&request.runtime_id)?; + } let result = api .runtime .spawn_worker( @@ -2811,11 +2815,36 @@ struct RuntimeConfigBundleAvailabilityQuery { digest: String, } +fn reject_no_workdir_for_non_embedded_runtime( + runtime_id: &str, +) -> std::result::Result<(), ApiError> { + if runtime_id == EMBEDDED_WORKER_RUNTIME_ID { + return Ok(()); + } + Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "workspace_worker_workdir_required".to_string(), + message: "Only the embedded Runtime can launch a Worker without a working directory" + .to_string(), + }, + vec![RuntimeDiagnostic { + code: "workspace_worker_workdir_required".to_string(), + severity: DiagnosticSeverity::Error, + message: "Select a working directory for this Runtime, or choose the embedded Runtime for a conversation-only Worker." + .to_string(), + }], + )) +} + async fn create_runtime_worker( State(api): State, AxumPath(runtime_id): AxumPath, Json(mut request): Json, ) -> ApiResult> { + if request.working_directory_request.is_none() && request.resolved_working_directory.is_none() { + reject_no_workdir_for_non_embedded_runtime(&runtime_id)?; + } request.resolved_working_directory_request = request .working_directory_request .as_ref() @@ -3803,6 +3832,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp display_name: runtime.label, built_in, can_spawn_worker: runtime.capabilities.can_spawn_worker, + working_directory_required: !built_in, status: runtime.status, diagnostics: runtime.diagnostics, } @@ -4735,6 +4765,7 @@ impl IntoResponse for ApiError { || code.starts_with("unsupported_worker_profile") || code.starts_with("working_directory_") || code.starts_with("workspace_cleanup_") + || code == "workspace_worker_workdir_required" || code.ends_with("_already_exists") || code.ends_with("_not_config_managed") || code.ends_with("_unsupported") => @@ -6080,13 +6111,17 @@ mod tests { ); let launch_options = get_json(app.clone(), "/api/workers/launch-options").await; - assert!( - launch_options["runtimes"] - .as_array() - .unwrap() - .iter() - .any(|runtime| runtime["runtime_id"] == "team-runtime") - ); + let runtimes = launch_options["runtimes"].as_array().unwrap(); + let embedded_runtime = runtimes + .iter() + .find(|runtime| runtime["runtime_id"] == EMBEDDED_WORKER_RUNTIME_ID) + .expect("embedded runtime launch option"); + assert_eq!(embedded_runtime["working_directory_required"], false); + let team_runtime = runtimes + .iter() + .find(|runtime| runtime["runtime_id"] == "team-runtime") + .expect("team runtime launch option"); + assert_eq!(team_runtime["working_directory_required"], true); let deleted = request_json( app.clone(), @@ -6137,18 +6172,6 @@ mod tests { ) .await; assert_eq!(added["restart_required"], false); - let created = post_json( - app.clone(), - "/api/workers", - serde_json::json!({ - "runtime_id": "busy-runtime", - "display_name": "Remote Test Worker", - "profile": "runtime_default", - "initial_text": "" - }), - ) - .await; - assert_eq!(created["runtime_id"], "busy-runtime"); let workers = get_json(app.clone(), "/api/workers").await; assert!( workers["items"] @@ -6352,6 +6375,38 @@ mod tests { assert!(!projected.contains("http://")); } + #[tokio::test] + async fn browser_worker_create_rejects_non_embedded_no_workdir() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + let response = request_json( + app, + "POST", + "/api/workers", + Some(serde_json::json!({ + "runtime_id": "remote-runtime", + "display_name": "Remote Worker", + "profile": "runtime_default", + "initial_text": "" + })), + StatusCode::BAD_REQUEST, + ) + .await; + assert!( + response["message"] + .as_str() + .unwrap_or_default() + .contains("workspace_worker_workdir_required") + ); + assert!( + response["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| { diagnostic["code"] == "workspace_worker_workdir_required" }) + ); + } + #[tokio::test] async fn runtime_worker_spawn_rejects_raw_working_directory_fields() { let dir = tempfile::tempdir().unwrap(); diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts index 30cd2b8e..35a39616 100644 --- a/web/workspace/src/lib/workspace-sidebar/types.ts +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -101,6 +101,7 @@ export type WorkerLaunchRuntimeOption = { display_name: string; built_in: boolean; can_spawn_worker: boolean; + working_directory_required: boolean; status: string; diagnostics: Diagnostic[]; }; diff --git a/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts index 375a86e8..116cb8a2 100644 --- a/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts @@ -25,6 +25,7 @@ const options: WorkerLaunchOptionsResponse = { status: "active", can_spawn_worker: true, built_in: false, + working_directory_required: true, diagnostics: [], }, { @@ -32,7 +33,8 @@ const options: WorkerLaunchOptionsResponse = { display_name: "Embedded", status: "active", can_spawn_worker: true, - built_in: false, + built_in: true, + working_directory_required: false, diagnostics: [], }, ], @@ -107,3 +109,23 @@ Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relati }, }); }); + +Deno.test("buildBrowserCreateWorkerRequest omits working_directory for embedded no-workdir launches", () => { + const request = buildBrowserCreateWorkerRequest({ + runtime_id: "embedded", + display_name: "Worker", + profile: "builtin:companion", + initial_text: "chat", + working_directory_id: "", + working_directory_repository_id: "", + working_directory_selector: "", + relative_cwd: "", + }); + + assertEquals(request, { + runtime_id: "embedded", + display_name: "Worker", + profile: "builtin:companion", + initial_text: "chat", + }); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index 506a2a0d..438e4edd 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -41,7 +41,14 @@ let relativeCwd = $state(''); let creatingWorkingDirectory = $state(false); let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE); - let canStartWorker = $derived(Boolean(runtimeId && profile && workingDirectoryId && !isNewWorkingDirectorySelected)); + let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId)); + let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false); + let hasSelectedExistingWorkdir = $derived(Boolean(workingDirectoryId && !isNewWorkingDirectorySelected)); + let canStartWorker = $derived(Boolean( + runtimeId && + profile && + (hasSelectedExistingWorkdir || (selectedRuntimeAllowsNoWorkdir && !isNewWorkingDirectorySelected)), + )); function workerApiPath(path: string): string { return workspaceApiPath(workspaceId, path); @@ -81,7 +88,7 @@ runtimeId = form.runtime_id; displayName = form.display_name; profile = form.profile; - workingDirectoryId = form.working_directory_id || NEW_WORKING_DIRECTORY_VALUE; + workingDirectoryId = form.working_directory_id; workingDirectoryRepositoryId = form.working_directory_repository_id; workingDirectorySelector = form.working_directory_selector; relativeCwd = form.relative_cwd; @@ -149,8 +156,8 @@ submitError = { message: 'workspace id is unavailable', diagnostics: [] }; return; } - if (isNewWorkingDirectorySelected || !workingDirectoryId) { - submitError = { message: 'select or create a workdir before starting a Worker', diagnostics: [] }; + if (isNewWorkingDirectorySelected || (!workingDirectoryId && !selectedRuntimeAllowsNoWorkdir)) { + submitError = { message: 'select or create a workdir before starting a Worker; only embedded Runtime can start without one', diagnostics: [] }; return; } @@ -220,7 +227,7 @@

New Worker

-

Create a Worker on a selected Runtime and workdir.

+

Create a Worker on a selected Runtime. Workdir-less conversation Workers are only available on embedded Runtime.

Back to workspace
@@ -237,7 +244,11 @@
Run at
+ {#if !selectedRuntimeAllowsNoWorkdir && !workingDirectoryId && !isNewWorkingDirectorySelected} +

This Runtime requires a selected workdir before starting a Worker.

+ {:else if selectedRuntimeAllowsNoWorkdir && !workingDirectoryId} +

No filesystem tools or Bash will be available without a workdir.

+ {/if} + {#if isNewWorkingDirectorySelected}

New workdir

@@ -286,10 +303,12 @@
{/if} - + {#if hasSelectedExistingWorkdir || isNewWorkingDirectorySelected} + + {/if}