Skip to content

Commit 626cfa6

Browse files
committed
feat(subagents): support custom working directories
1 parent 36a0d0d commit 626cfa6

6 files changed

Lines changed: 246 additions & 17 deletions

File tree

docs/user/security-limits-and-troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Kit is a coding agent runtime, not a security boundary. Treat the model, prompts
99
The root does not confine processes:
1010

1111
- `shell` starts the platform shell with the root as its current directory. The command can read or change anything allowed by the Kit process, including paths outside the root, the network, and inherited environment variables.
12-
- ACP subagents are child processes started directly from trusted local `command` and `args` profiles, with the same root as their current directory. They inherit normal child-process host access; selecting a harness is not isolation.
12+
- ACP subagents are child processes started directly from trusted local `command` and `args` profiles. Their current directory and ACP root are the optional `subagent.cwd`, or Kit's root when it is omitted. They inherit normal child-process host access; selecting a directory or harness is not isolation.
1313
- MCP stdio servers are local processes, while MCP HTTP servers and A2A agents are remote trust domains. Tool arguments, prompts, and returned data cross those boundaries.
1414
- `edit` resolves relative paths from Kit's working directory, but also accepts `..`, absolute paths, and paths through symlinks. It can change any filesystem path allowed by the Kit process.
1515

docs/user/subagents-and-acp-harnesses.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Use the object form of the hidden tools inside `compose`:
99
```text
1010
first = subagent({
1111
name: "Implementer",
12+
cwd: "../parser-worktree",
1213
prompt: "Inspect the parser and identify the smallest risk."
1314
})
1415
second = prompt({
@@ -27,7 +28,7 @@ Each successful turn returns a session value with `id`, `name`, `output`, and `g
2728

2829
Always pass the latest completed value back to `prompt` or `fork`. Reusing an older value fails with `stale subagent generation N; current generation is M`. This prevents two continuations from silently racing on one session. Calls on an individual ACP session are serialized, while separate forked sessions can be prompted concurrently.
2930

30-
The optional `name` argument is preferred on `subagent` and `fork`; `prompt` has no naming input and preserves the session name. The optional `harness` and `model` arguments belong only on `subagent`. `harness` overrides the user's configured harness preference. `model` selects an exact model value ID advertised by that harness through its ACP session configuration, or a model alias configured for that harness. Omit either argument to retain the configured preference. `prompt` and `fork` retain the original session's harness and model. An explicit model fails before the first prompt if the harness does not advertise a selectable `model` option or rejects the value.
31+
The optional `name` argument is preferred on `subagent` and `fork`; `prompt` has no naming input and preserves the session name. The optional `harness`, `model`, and `cwd` arguments belong only on `subagent`. `harness` overrides the user's configured harness preference. `model` selects an exact model value ID advertised by that harness through its ACP session configuration, or a model alias configured for that harness. `cwd` selects the new subagent's working directory; relative paths resolve from Kit's working directory, and missing paths or non-directories fail before startup. Omit an argument to retain its configured default. `prompt` and `fork` retain the original session's harness, model, and working directory. An explicit model fails before the first prompt if the harness does not advertise a selectable `model` option or rejects the value.
3132

3233
## Inspect display names
3334

@@ -78,7 +79,7 @@ Text-only turns omit `updates`. Capture is limited to 64 update objects and 64 K
7879

7980
## Choose the built-in `acp.kit` harness
8081

81-
`acp.kit` is always available and is the default when `[subagent].harness` is not configured. By default Kit launches the installed `kit` executable as `kit acp`, whose default stdio protocol is ACP v1. A built-in child inherits Kit's working directory, provider, model, MCP configuration and credential storage, cancellation, and nesting depth. An explicit `subagent.model` selection overrides the inherited model for that ACP session. It does not start an A2A listener.
82+
`acp.kit` is always available and is the default when `[subagent].harness` is not configured. By default Kit launches the installed `kit` executable as `kit acp`, whose default stdio protocol is ACP v1. A built-in child uses `subagent.cwd` when provided and otherwise inherits Kit's working directory; it also inherits the provider, model, MCP configuration and credential storage, cancellation, and nesting depth. An explicit `subagent.model` selection overrides the inherited model for that ACP session. It does not start an A2A listener.
8283

8384
You can override only the executable and base arguments while preserving built-in Kit behavior:
8485

@@ -96,7 +97,7 @@ Built-in subagent transcripts are durable on disk, but their reusable parent-own
9697

9798
## Configure a generic ACP v1 harness
9899

99-
Generic external child harnesses remain ACP v1: they must speak newline-delimited JSON-RPC over stdio and support `initialize`, `session/new`, and `session/prompt`. `session/fork` and `session/close` are optional capabilities. Keep stdout protocol-only; the agent may log to stderr. Kit runs the executable directly from Kit's working directory and inherits the parent environment. It does not invoke a shell, so pipes, environment assignments, compound commands, and shell quoting in `command` or `args` do not work.
100+
Generic external child harnesses remain ACP v1: they must speak newline-delimited JSON-RPC over stdio and support `initialize`, `session/new`, and `session/prompt`. `session/fork` and `session/close` are optional capabilities. Keep stdout protocol-only; the agent may log to stderr. Kit runs the executable directly from the subagent's selected working directory, which defaults to Kit's working directory, and inherits the parent environment. It does not invoke a shell, so pipes, environment assignments, compound commands, and shell quoting in `command` or `args` do not work.
100101

101102
Configure trusted argv profiles in `~/.kit/config.toml`:
102103

fixtures/mock-acp.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def log_request(request):
5252
entry = {"method": request.get("method")}
5353
if "sessionId" in params:
5454
entry["sessionId"] = params["sessionId"]
55+
if request.get("method") in ("session/new", "session/fork"):
56+
entry["cwd"] = params["cwd"]
5557
if request.get("method") == "session/prompt":
5658
entry["text"] = params["prompt"][0]["text"]
5759
with log_lock:
@@ -97,6 +99,8 @@ def prompt(request):
9799
time.sleep(0.01)
98100
if prompt_release is None:
99101
time.sleep(0.40)
102+
if "MOCK_CWD" in text:
103+
text = os.getcwd()
100104
if "MOCK_SELECTED_MODEL" in text:
101105
text = selected_models.get(session_id, model_ids[0])
102106
if "MOCK_STRUCTURED_OUTPUT" in text:

src/acp_child.rs

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ impl AcpHarnesses {
231231
command
232232
};
233233
// Every trusted profile is spawned directly (never through a shell)
234-
// with Kit's working directory as cwd.
234+
// with the configured subagent root as cwd.
235235
command.current_dir(&config.root);
236236
Ok(command)
237237
}
@@ -306,7 +306,7 @@ struct LaunchContext {
306306
impl LaunchContext {
307307
fn error(&self, phase: &str, error: impl std::fmt::Display) -> String {
308308
format!(
309-
"ACP harness {phase}: {error} (harness={:?}, source={}, cwd=Kit working directory)",
309+
"ACP harness {phase}: {error} (harness={:?}, source={}, cwd=configured working directory)",
310310
self.harness, self.source
311311
)
312312
}
@@ -364,6 +364,22 @@ pub(crate) struct ChildConfig {
364364
}
365365

366366
impl ChildConfig {
367+
pub(crate) fn with_root(mut self, root: PathBuf) -> Self {
368+
let previous_root = self.root.clone();
369+
if let Some(path) = &mut self.mcp_config
370+
&& path.is_relative()
371+
{
372+
*path = previous_root.join(&*path);
373+
}
374+
if let CredentialStorage::Filesystem(path) = &mut self.credential_storage
375+
&& path.is_relative()
376+
{
377+
*path = previous_root.join(&*path);
378+
}
379+
self.root = root;
380+
self
381+
}
382+
367383
pub(crate) fn with_parent_context(mut self, id: String, name: String) -> Self {
368384
self.parent_id = Some(id);
369385
self.parent_name = Some(name);
@@ -1362,7 +1378,7 @@ mod tests {
13621378
let error = context.error("handshake timeout", "no response within 30 seconds");
13631379
assert!(error.contains("harness=\"acp.safe-name\""));
13641380
assert!(error.contains("source=configured ACP profile"));
1365-
assert!(error.contains("cwd=Kit working directory"));
1381+
assert!(error.contains("cwd=configured working directory"));
13661382
assert!(!error.contains("secret-command-name"));
13671383
assert!(!error.contains("secret-argument"));
13681384
assert!(!error.contains("/private/runtime/root"));
@@ -1427,7 +1443,10 @@ mod tests {
14271443
assert!(error.contains("protocol handshake failure"), "{error}");
14281444
assert!(error.contains("harness=\"acp.broken\""), "{error}");
14291445
assert!(error.contains("source=configured ACP profile"), "{error}");
1430-
assert!(error.contains("cwd=Kit working directory"), "{error}");
1446+
assert!(
1447+
error.contains("cwd=configured working directory"),
1448+
"{error}"
1449+
);
14311450
assert!(
14321451
error.contains("the child did not complete the ACP handshake"),
14331452
"{error}"
@@ -1487,7 +1506,10 @@ mod tests {
14871506
assert!(error.contains("17"), "{error}");
14881507
assert!(error.contains("harness=\"acp.exits\""), "{error}");
14891508
assert!(error.contains("source=configured ACP profile"), "{error}");
1490-
assert!(error.contains("cwd=Kit working directory"), "{error}");
1509+
assert!(
1510+
error.contains("cwd=configured working directory"),
1511+
"{error}"
1512+
);
14911513
assert!(!error.contains("python3"), "{error}");
14921514
assert!(!error.contains("raise SystemExit"), "{error}");
14931515
assert!(!error.contains(root.path().to_string_lossy().as_ref()));
@@ -1679,7 +1701,10 @@ mod tests {
16791701
assert!(error.contains("spawn failure"), "{error}");
16801702
assert!(error.contains("harness=\"acp.broken\""), "{error}");
16811703
assert!(error.contains("source=configured ACP profile"), "{error}");
1682-
assert!(error.contains("cwd=Kit working directory"), "{error}");
1704+
assert!(
1705+
error.contains("cwd=configured working directory"),
1706+
"{error}"
1707+
);
16831708
assert!(!error.contains("kit-test-acp-executable-that-does-not-exist"));
16841709
assert!(!error.contains(root.path().to_string_lossy().as_ref()));
16851710
}

src/tools/subagent.rs

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::{
22
collections::{HashMap, HashSet},
3+
path::PathBuf,
34
sync::{Arc, Mutex},
45
};
56

@@ -122,6 +123,7 @@ struct State {
122123
harness: String,
123124
model: Option<String>,
124125
kit: bool,
126+
root: PathBuf,
125127
child: Option<ChildSession>,
126128
forking: Option<String>,
127129
permit: Option<OwnedSemaphorePermit>,
@@ -231,6 +233,7 @@ struct CreateOptions {
231233
name: Option<String>,
232234
harness: Option<String>,
233235
model: Option<String>,
236+
cwd: Option<PathBuf>,
234237
}
235238

236239
struct ForkSuccess {
@@ -248,6 +251,7 @@ struct ForkOperation {
248251
harness: String,
249252
model: Option<String>,
250253
kit: bool,
254+
root: PathBuf,
251255
generation: u64,
252256
depth: usize,
253257
cancellation: TurnCancellation,
@@ -295,6 +299,30 @@ impl Subagents {
295299
self.config.harnesses.references()
296300
}
297301

302+
fn resolve_root(&self, cwd: Option<PathBuf>) -> Result<PathBuf, ChildError> {
303+
let Some(cwd) = cwd else {
304+
return Ok(self.config.root.clone());
305+
};
306+
let path = if cwd.is_absolute() {
307+
cwd
308+
} else {
309+
self.config.root.join(cwd)
310+
};
311+
let root = path.canonicalize().map_err(|error| {
312+
ChildError::Failed(format!(
313+
"could not open subagent working directory {}: {error}",
314+
path.display()
315+
))
316+
})?;
317+
if !root.is_dir() {
318+
return Err(ChildError::Failed(format!(
319+
"subagent working directory is not a directory: {}",
320+
root.display()
321+
)));
322+
}
323+
Ok(root)
324+
}
325+
298326
async fn create(
299327
&self,
300328
prompt: String,
@@ -310,7 +338,9 @@ impl Subagents {
310338
name,
311339
harness,
312340
model,
341+
cwd,
313342
} = options;
343+
let root = self.resolve_root(cwd)?;
314344
let harness = harness.unwrap_or_else(|| self.config.default_harness.clone());
315345
if !self.config.harnesses.contains(&harness) {
316346
return Err(ChildError::Failed(format!(
@@ -341,6 +371,7 @@ impl Subagents {
341371
harness: harness.clone(),
342372
model: model.clone(),
343373
kit,
374+
root: root.clone(),
344375
child: None,
345376
forking: None,
346377
permit: Some(permit),
@@ -350,6 +381,7 @@ impl Subagents {
350381
let child_config = self
351382
.config
352383
.clone()
384+
.with_root(root)
353385
.with_parent_context(id.clone(), state.lock().await.name.clone());
354386
{
355387
let locked = state.lock().await;
@@ -557,6 +589,7 @@ impl Subagents {
557589
harness: source.harness.clone(),
558590
model: source.model.clone(),
559591
kit: source.kit,
592+
root: source.root.clone(),
560593
generation,
561594
depth,
562595
cancellation,
@@ -610,6 +643,7 @@ impl Subagents {
610643
harness,
611644
model,
612645
kit,
646+
root,
613647
generation,
614648
depth,
615649
cancellation,
@@ -640,6 +674,7 @@ impl Subagents {
640674
harness: harness.clone(),
641675
model: model.clone(),
642676
kit,
677+
root: root.clone(),
643678
child: None,
644679
forking: None,
645680
permit: None,
@@ -652,11 +687,11 @@ impl Subagents {
652687
}
653688

654689
if !native_fork {
655-
let root = self.config.root.clone();
690+
let transcript_root = root.clone();
656691
let source_id = source_id.clone();
657692
let branch_id = id.clone();
658693
let cloned = tokio::task::spawn_blocking(move || {
659-
session::clone_completed(&root, &source_id, &branch_id)
694+
session::clone_completed(&transcript_root, &source_id, &branch_id)
660695
})
661696
.await
662697
.map_err(|error| ChildError::Failed(format!("transcript clone task failed: {error}")))
@@ -674,6 +709,7 @@ impl Subagents {
674709
let child_config = self
675710
.config
676711
.clone()
712+
.with_root(root)
677713
.with_parent_context(id.clone(), branch_name);
678714
let child_result = if native_fork {
679715
source_child.fork(model.as_deref(), &cancellation).await
@@ -1269,7 +1305,7 @@ fn call_id_schema() -> serde_json::Value {
12691305
impl SubagentTool {
12701306
pub fn new(manager: Subagents, depth: usize) -> Self {
12711307
let harnesses = manager.harness_references();
1272-
Self { manager, depth, spec: ToolSpec::new(ToolName::new("subagent"), "Start a parent-owned configured ACP harness, preferably assign a concise role-oriented display name, prompt it, and return its reusable session value. Omit `harness` and `model` unless the user or active workflow explicitly supplies the exact override or a configured alias. Never choose an override based on your own model, provider, publisher, familiarity, cost, or perceived quality; advertised choices indicate availability, not preference.", json!({"type":"object","properties":{"prompt":{"type":"string"},"name":display_name_schema(),"harness":{"type":"string","enum":harnesses,"description":"Override the user's configured harness preference with this value. Default to omitting it."},"model":{"type":"string","minLength":1,"description":"Exact ACP model selection ID or configured alias explicitly requested by the user or active workflow. Applies only to this new session; default to omitting it."},"output_schema":{"oneOf":[{"type":"object"},{"type":"boolean"}]}},"required":["prompt"],"additionalProperties":false})).with_output_schema(value_schema()).with_annotations(ToolAnnotations::new()) }
1308+
Self { manager, depth, spec: ToolSpec::new(ToolName::new("subagent"), "Start a parent-owned configured ACP harness, preferably assign a concise role-oriented display name, prompt it, and return its reusable session value. Omit `harness` and `model` unless the user or active workflow explicitly supplies the exact override or a configured alias. Never choose an override based on your own model, provider, publisher, familiarity, cost, or perceived quality; advertised choices indicate availability, not preference.", json!({"type":"object","properties":{"prompt":{"type":"string"},"name":display_name_schema(),"harness":{"type":"string","enum":harnesses,"description":"Override the user's configured harness preference with this value. Default to omitting it."},"model":{"type":"string","minLength":1,"description":"Exact ACP model selection ID or configured alias explicitly requested by the user or active workflow. Applies only to this new session; default to omitting it."},"cwd":{"type":"string","minLength":1,"description":"Working directory for the new subagent. Relative paths resolve from Kit's working directory."},"output_schema":{"oneOf":[{"type":"object"},{"type":"boolean"}]}},"required":["prompt"],"additionalProperties":false})).with_output_schema(value_schema()).with_annotations(ToolAnnotations::new()) }
12731309
}
12741310
}
12751311
impl PromptTool {
@@ -1331,6 +1367,7 @@ struct Input {
13311367
name: Option<String>,
13321368
harness: Option<String>,
13331369
model: Option<String>,
1370+
cwd: Option<PathBuf>,
13341371
#[serde(default, deserialize_with = "deserialize_output_schema")]
13351372
output_schema: Option<Value>,
13361373
}
@@ -1467,6 +1504,7 @@ impl Tool for SubagentTool {
14671504
name: input.name,
14681505
harness: input.harness,
14691506
model: input.model,
1507+
cwd: input.cwd,
14701508
},
14711509
self.depth,
14721510
cancellation(context),

0 commit comments

Comments
 (0)