Skip to content

Commit 7299c56

Browse files
authored
Fix sub-agent cwd/workspace separation (#87218)
Merged via squash. Prepared head SHA: f47b073 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky
1 parent 039fcba commit 7299c56

81 files changed

Lines changed: 1105 additions & 88 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
2929
- Package/install/release: align npm package exclusions and inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, cap tsdown heap in containers, bound install/release smoke waits, and harden post-publish verification.
3030
- Codex: bound ChatGPT OAuth token exchange and refresh requests so stalled auth endpoints fail instead of hanging login or refresh.
3131
- QA/E2E/CI: bound Telegram, kitchen-sink, Open WebUI, ClawHub, MCP, Discord, realtime, labeler, and GitHub API waits; fail empty explicit test, live-media, gateway CPU, plugin gauntlet, and beta-smoke runs instead of false-greening.
32+
- Agents/Codex: keep spawned agent bootstrap files rooted in the agent workspace while running task commands, transcripts, and compaction from the requested cwd. (#87218) Thanks @mbelinky.
3233

3334
## 2026.5.26
3435

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2177,6 +2177,7 @@ public struct SessionsPatchParams: Codable, Sendable {
21772177
public let model: AnyCodable?
21782178
public let spawnedby: AnyCodable?
21792179
public let spawnedworkspacedir: AnyCodable?
2180+
public let spawnedcwd: AnyCodable?
21802181
public let spawndepth: AnyCodable?
21812182
public let subagentrole: AnyCodable?
21822183
public let subagentcontrolscope: AnyCodable?
@@ -2202,6 +2203,7 @@ public struct SessionsPatchParams: Codable, Sendable {
22022203
model: AnyCodable?,
22032204
spawnedby: AnyCodable?,
22042205
spawnedworkspacedir: AnyCodable?,
2206+
spawnedcwd: AnyCodable?,
22052207
spawndepth: AnyCodable?,
22062208
subagentrole: AnyCodable?,
22072209
subagentcontrolscope: AnyCodable?,
@@ -2226,6 +2228,7 @@ public struct SessionsPatchParams: Codable, Sendable {
22262228
self.model = model
22272229
self.spawnedby = spawnedby
22282230
self.spawnedworkspacedir = spawnedworkspacedir
2231+
self.spawnedcwd = spawnedcwd
22292232
self.spawndepth = spawndepth
22302233
self.subagentrole = subagentrole
22312234
self.subagentcontrolscope = subagentcontrolscope
@@ -2252,6 +2255,7 @@ public struct SessionsPatchParams: Codable, Sendable {
22522255
case model
22532256
case spawnedby = "spawnedBy"
22542257
case spawnedworkspacedir = "spawnedWorkspaceDir"
2258+
case spawnedcwd = "spawnedCwd"
22552259
case spawndepth = "spawnDepth"
22562260
case subagentrole = "subagentRole"
22572261
case subagentcontrolscope = "subagentControlScope"
@@ -4990,25 +4994,51 @@ public struct ToolsEffectiveGroup: Codable, Sendable {
49904994
}
49914995
}
49924996

4997+
public struct ToolsEffectiveNotice: Codable, Sendable {
4998+
public let id: String
4999+
public let severity: AnyCodable
5000+
public let message: String
5001+
5002+
public init(
5003+
id: String,
5004+
severity: AnyCodable,
5005+
message: String)
5006+
{
5007+
self.id = id
5008+
self.severity = severity
5009+
self.message = message
5010+
}
5011+
5012+
private enum CodingKeys: String, CodingKey {
5013+
case id
5014+
case severity
5015+
case message
5016+
}
5017+
}
5018+
49935019
public struct ToolsEffectiveResult: Codable, Sendable {
49945020
public let agentid: String
49955021
public let profile: String
49965022
public let groups: [ToolsEffectiveGroup]
5023+
public let notices: [ToolsEffectiveNotice]?
49975024

49985025
public init(
49995026
agentid: String,
50005027
profile: String,
5001-
groups: [ToolsEffectiveGroup])
5028+
groups: [ToolsEffectiveGroup],
5029+
notices: [ToolsEffectiveNotice]?)
50025030
{
50035031
self.agentid = agentid
50045032
self.profile = profile
50055033
self.groups = groups
5034+
self.notices = notices
50065035
}
50075036

50085037
private enum CodingKeys: String, CodingKey {
50095038
case agentid = "agentId"
50105039
case profile
50115040
case groups
5041+
case notices
50125042
}
50135043
}
50145044

docs/tools/subagents.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ Per-agent overrides use `agents.list[].subagents.delegationMode`.
186186
<ParamField path="agentId" type="string">
187187
Spawn under another configured agent id when allowed by `subagents.allowAgents`.
188188
</ParamField>
189+
<ParamField path="cwd" type="string">
190+
Optional task working directory for the child run. Native sub-agents still load bootstrap files from the target agent workspace; `cwd` only changes where runtime tools and CLI harnesses do the delegated work.
191+
</ParamField>
189192
<ParamField path="runtime" type='"subagent" | "acp"' default="subagent">
190193
`acp` is only for external ACP harnesses (`claude`, `droid`, `gemini`, `opencode`, or explicitly requested Codex ACP/acpx) and for `agents.list[]` entries whose `runtime.type` is `acp`.
191194
</ParamField>

extensions/codex/src/app-server/attempt-startup.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export async function startCodexAttemptThread(params: {
8282
buildAttemptParams: () => EmbeddedRunAttemptParams;
8383
sessionAgentId: string;
8484
effectiveWorkspace: string;
85+
effectiveCwd: string;
8586
dynamicTools: CodexDynamicToolSpec[];
8687
developerInstructions: string | undefined;
8788
finalConfigPatch?: Parameters<typeof startOrResumeThread>[0]["finalConfigPatch"];
@@ -238,7 +239,7 @@ export async function startCodexAttemptThread(params: {
238239
params.nativeToolSurfaceEnabled,
239240
);
240241
const startupExecutionCwd = resolveCodexAppServerExecutionCwd({
241-
effectiveWorkspace: params.effectiveWorkspace,
242+
effectiveCwd: params.effectiveCwd,
242243
environment: startupSandboxEnvironment,
243244
nativeToolSurfaceEnabled: params.nativeToolSurfaceEnabled,
244245
});
@@ -374,7 +375,14 @@ export async function startCodexAttemptThread(params: {
374375
releaseSharedClientLease,
375376
};
376377
} catch (error) {
377-
if (params.signal.aborted || shouldClearSharedClientAfterStartupRace(error)) {
378+
if (
379+
params.signal.aborted ||
380+
shouldClearSharedClientAfterStartupRace(error) ||
381+
shouldClearSharedClientAfterStartupFailure({
382+
error,
383+
spawnedBy: params.spawnedBy,
384+
})
385+
) {
378386
clearSharedCodexAppServerClientIfCurrent(startupClientForAbandonedRequestCleanup);
379387
}
380388
throw error;
@@ -389,3 +397,16 @@ function shouldClearSharedClientAfterStartupRace(error: unknown): boolean {
389397
error.message.endsWith(" timed out"))
390398
);
391399
}
400+
401+
function shouldClearSharedClientAfterStartupFailure(params: {
402+
error: unknown;
403+
spawnedBy: EmbeddedRunAttemptParams["spawnedBy"];
404+
}): boolean {
405+
if (!(params.error instanceof Error)) {
406+
return !params.spawnedBy;
407+
}
408+
if (params.error.message.includes("write EPIPE")) {
409+
return true;
410+
}
411+
return !params.spawnedBy;
412+
}

extensions/codex/src/app-server/dynamic-tool-build.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export type DynamicToolBuildParams = {
4848
params: EmbeddedRunAttemptParams;
4949
resolvedWorkspace: string;
5050
effectiveWorkspace: string;
51+
effectiveCwd?: string;
5152
sandboxSessionKey: string;
5253
sandbox: OpenClawSandboxContext;
5354
nativeToolSurfaceEnabled?: boolean;
@@ -207,11 +208,15 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
207208
sessionId: params.sessionId,
208209
runId: params.runId,
209210
agentDir,
211+
cwd: input.effectiveCwd ?? input.effectiveWorkspace,
210212
workspaceDir: input.effectiveWorkspace,
211-
spawnWorkspaceDir: resolveAttemptSpawnWorkspaceDir({
212-
sandbox: input.sandbox,
213-
resolvedWorkspace: input.resolvedWorkspace,
214-
}),
213+
spawnWorkspaceDir:
214+
input.effectiveCwd && input.effectiveCwd !== input.effectiveWorkspace
215+
? input.resolvedWorkspace
216+
: resolveAttemptSpawnWorkspaceDir({
217+
sandbox: input.sandbox,
218+
resolvedWorkspace: input.resolvedWorkspace,
219+
}),
215220
config: params.config,
216221
authProfileStore: params.toolAuthProfileStore ?? params.authProfileStore,
217222
abortSignal: input.runAbortController.signal,
@@ -461,13 +466,13 @@ export function resolveCodexSandboxEnvironmentSelection(
461466
}
462467

463468
export function resolveCodexAppServerExecutionCwd(params: {
464-
effectiveWorkspace: string;
469+
effectiveCwd: string;
465470
environment?: CodexSandboxExecEnvironment;
466471
nativeToolSurfaceEnabled: boolean;
467472
}): string {
468473
return params.environment && params.nativeToolSurfaceEnabled
469474
? params.environment.cwd
470-
: params.effectiveWorkspace;
475+
: params.effectiveCwd;
471476
}
472477

473478
export function resolveCodexExternalSandboxPolicyForOpenClawSandbox(

extensions/codex/src/app-server/run-attempt.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ async function buildDynamicToolsForTest(
209209
params,
210210
resolvedWorkspace: workspaceDir,
211211
effectiveWorkspace: workspaceDir,
212+
effectiveCwd: params.cwd ?? workspaceDir,
212213
sandboxSessionKey,
213214
sandbox: { enabled: false, backendId: "docker" } as never,
214215
nativeToolSurfaceEnabled: true,
@@ -899,6 +900,46 @@ describe("runCodexAppServerAttempt", () => {
899900
expect(binding.mcpServersFingerprint).toBe("mcp-v2");
900901
});
901902

903+
it("uses task cwd for Codex app-server requests while keeping bootstrap workspace separate", async () => {
904+
const sessionFile = path.join(tempDir, "session.jsonl");
905+
const workspaceDir = path.join(tempDir, "workspace");
906+
const taskCwd = path.join(tempDir, "task-repo");
907+
await fs.mkdir(workspaceDir, { recursive: true });
908+
await fs.mkdir(taskCwd, { recursive: true });
909+
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "workspace bootstrap", "utf8");
910+
await fs.writeFile(path.join(taskCwd, "task-marker.txt"), "task marker", "utf8");
911+
const appServer = createThreadLifecycleAppServerOptions();
912+
const params = createParams(sessionFile, workspaceDir);
913+
const requests: Array<{ method: string; params: unknown }> = [];
914+
915+
await startOrResumeThread({
916+
client: {
917+
getServerVersion: () => "0.132.0",
918+
request: async (method: string, requestParams?: unknown) => {
919+
requests.push({ method, params: requestParams });
920+
if (method === "thread/start") {
921+
return threadStartResult();
922+
}
923+
return {};
924+
},
925+
} as never,
926+
params,
927+
cwd: taskCwd,
928+
dynamicTools: [],
929+
appServer,
930+
developerInstructions: "workspace bootstrap",
931+
});
932+
const threadStart = requests.find((request) => request.method === "thread/start");
933+
expect((threadStart?.params as { cwd?: string } | undefined)?.cwd).toBe(taskCwd);
934+
935+
const turnStart = buildTurnStartParams(params, {
936+
threadId: "thread-1",
937+
cwd: taskCwd,
938+
appServer,
939+
});
940+
expect(turnStart.cwd).toBe(taskCwd);
941+
});
942+
902943
it("starts a no-MCP Codex thread when MCP config is evaluated empty", async () => {
903944
const sessionFile = path.join(tempDir, "session.jsonl");
904945
const workspaceDir = path.join(tempDir, "workspace");

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,13 @@ export async function runCodexAppServerAttempt(
362362
? resolvedWorkspace
363363
: sandbox.workspaceDir
364364
: resolvedWorkspace;
365+
const requestedCwd = params.cwd ? resolveUserPath(params.cwd) : undefined;
366+
if (sandbox?.enabled && requestedCwd && requestedCwd !== resolvedWorkspace) {
367+
throw new Error(
368+
"cwd override is not supported for sandboxed Codex app-server runs; omit cwd or use the agent workspace as cwd",
369+
);
370+
}
371+
const effectiveCwd = sandbox?.enabled ? effectiveWorkspace : (requestedCwd ?? effectiveWorkspace);
365372
await ensureCodexWorkspaceDirOnce(effectiveWorkspace);
366373
preDynamicStartupStages.mark("effective-workspace");
367374
const appServer = resolveCodexAppServerForOpenClawToolPolicy({
@@ -518,6 +525,7 @@ export async function runCodexAppServerAttempt(
518525
params,
519526
resolvedWorkspace,
520527
effectiveWorkspace,
528+
effectiveCwd,
521529
sandboxSessionKey,
522530
sandbox,
523531
nativeToolSurfaceEnabled,
@@ -534,6 +542,7 @@ export async function runCodexAppServerAttempt(
534542
params,
535543
resolvedWorkspace,
536544
effectiveWorkspace,
545+
effectiveCwd,
537546
sandboxSessionKey,
538547
sandbox,
539548
nativeToolSurfaceEnabled,
@@ -597,6 +606,7 @@ export async function runCodexAppServerAttempt(
597606
buildHarnessContextEngineRuntimeContext({
598607
attempt: buildActiveRunAttemptParams(),
599608
workspaceDir: effectiveWorkspace,
609+
cwd: effectiveCwd,
600610
agentDir,
601611
activeAgentId: sessionAgentId,
602612
contextEnginePluginId: activeContextEnginePluginId,
@@ -777,7 +787,7 @@ export async function runCodexAppServerAttempt(
777787
});
778788
const trajectoryRecorder = createCodexTrajectoryRecorder({
779789
attempt: params,
780-
cwd: effectiveWorkspace,
790+
cwd: effectiveCwd,
781791
developerInstructions: buildRenderedCodexDeveloperInstructions(),
782792
prompt: codexTurnPromptText,
783793
tools: toolBridge.availableSpecs,
@@ -795,7 +805,7 @@ export async function runCodexAppServerAttempt(
795805
}
796806
};
797807
let codexEnvironmentSelection: CodexTurnEnvironmentParams[] | undefined;
798-
let codexExecutionCwd = effectiveWorkspace;
808+
let codexExecutionCwd = effectiveCwd;
799809
let codexSandboxPolicy: CodexSandboxPolicy | undefined;
800810
let restartContextEngineCodexThread:
801811
| (() => Promise<CodexAppServerThreadLifecycleBinding>)
@@ -859,6 +869,7 @@ export async function runCodexAppServerAttempt(
859869
buildAttemptParams: buildActiveRunAttemptParams,
860870
sessionAgentId,
861871
effectiveWorkspace,
872+
effectiveCwd,
862873
dynamicTools: toolBridge.specs,
863874
developerInstructions: promptBuild.developerInstructions,
864875
buildFinalConfigPatch: buildNativeHookRelayFinalConfigPatch,
@@ -902,7 +913,7 @@ export async function runCodexAppServerAttempt(
902913
});
903914
recordCodexTrajectoryContext(trajectoryRecorder, {
904915
attempt: params,
905-
cwd: effectiveWorkspace,
916+
cwd: effectiveCwd,
906917
developerInstructions: promptBuild.developerInstructions,
907918
prompt: codexTurnPromptText,
908919
tools: toolBridge.availableSpecs,
@@ -1846,6 +1857,7 @@ export async function runCodexAppServerAttempt(
18461857
agentId: sessionAgentId,
18471858
notifyUserMessagePersisted,
18481859
sessionKey: sandboxSessionKey,
1860+
cwd: effectiveCwd,
18491861
threadId: thread.threadId,
18501862
turnId: activeTurnId,
18511863
});
@@ -1970,6 +1982,7 @@ export async function runCodexAppServerAttempt(
19701982
notifyUserMessagePersisted,
19711983
result,
19721984
sessionKey: contextSessionKey,
1985+
cwd: effectiveCwd,
19731986
threadId: thread.threadId,
19741987
turnId: activeTurnId,
19751988
});
@@ -2010,6 +2023,7 @@ export async function runCodexAppServerAttempt(
20102023
runtimeContext: buildHarnessContextEngineRuntimeContextFromUsage({
20112024
attempt: buildActiveRunAttemptParams(),
20122025
workspaceDir: effectiveWorkspace,
2026+
cwd: effectiveCwd,
20132027
agentDir,
20142028
activeAgentId: sessionAgentId,
20152029
contextEnginePluginId: activeContextEnginePluginId,

0 commit comments

Comments
 (0)