feat: enforce embedded no-workdir worker policy

This commit is contained in:
Keisuke Hirata 2026-07-11 08:59:19 +09:00
parent 83c9d8844d
commit 10267869ae
No known key found for this signature in database
4 changed files with 127 additions and 30 deletions

View File

@ -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<RuntimeDiagnostic>,
}
@ -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<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>,
Json(mut request): Json<WorkerSpawnRequest>,
) -> ApiResult<Json<WorkerSpawnResult>> {
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();

View File

@ -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[];
};

View File

@ -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",
});
});

View File

@ -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 @@
<header class="worker-new-page-header">
<div>
<h1 id="new-worker-heading">New Worker</h1>
<p>Create a Worker on a selected Runtime and workdir.</p>
<p>Create a Worker on a selected Runtime. Workdir-less conversation Workers are only available on embedded Runtime.</p>
</div>
<a class="secondary-link" href={`/w/${workspaceId}`}>Back to workspace</a>
</header>
@ -237,7 +244,11 @@
<div class="worker-launch-sentence">
<span>Run at</span>
<select class="worker-inline-select wd-select" bind:value={workingDirectoryId} aria-label="Workdir">
<option value="">Select workdir</option>
{#if selectedRuntimeAllowsNoWorkdir}
<option value="">No workdir · embedded conversation only</option>
{:else}
<option value="" disabled>Select workdir</option>
{/if}
{#each options?.working_directories ?? [] as directory}
<option value={directory.working_directory_id} disabled={directory.status !== 'active'}>
{directory.repository_id} · {directory.requested_selector ?? 'HEAD'}
@ -259,6 +270,12 @@
</select>
</div>
{#if !selectedRuntimeAllowsNoWorkdir && !workingDirectoryId && !isNewWorkingDirectorySelected}
<p class="worker-workdir-note">This Runtime requires a selected workdir before starting a Worker.</p>
{:else if selectedRuntimeAllowsNoWorkdir && !workingDirectoryId}
<p class="worker-workdir-note">No filesystem tools or Bash will be available without a workdir.</p>
{/if}
{#if isNewWorkingDirectorySelected}
<div class="new-working-directory-panel">
<h3>New workdir</h3>
@ -286,10 +303,12 @@
</div>
{/if}
<label class="relative-cwd-field">
<span>Relative cwd inside workdir</span>
<input bind:value={relativeCwd} autocomplete="off" placeholder="Optional path inside workdir" />
</label>
{#if hasSelectedExistingWorkdir || isNewWorkingDirectorySelected}
<label class="relative-cwd-field">
<span>Relative cwd inside workdir</span>
<input bind:value={relativeCwd} autocomplete="off" placeholder="Optional path inside workdir" />
</label>
{/if}
</section>
<section class="worker-form-section" aria-labelledby="worker-details-heading">