Skip to content

Commit 85b92d6

Browse files
committed
fix: split subagent cwd from workspace
1 parent 4d099c3 commit 85b92d6

79 files changed

Lines changed: 941 additions & 85 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
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212
### Fixes
1313

1414
- Telegram: route `sendMessage` action replies through durable outbound delivery so completed agent responses remain retryable when the gateway send path times out. (#87261) Thanks @mbelinky.
15+
- 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.
1516

1617
## 2026.5.26
1718

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

Lines changed: 4 additions & 0 deletions
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"

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/run-attempt.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,7 @@ async function buildDynamicToolsForTest(
599599
params,
600600
resolvedWorkspace: workspaceDir,
601601
effectiveWorkspace: workspaceDir,
602+
effectiveCwd: params.cwd ?? workspaceDir,
602603
sandboxSessionKey,
603604
sandbox: { enabled: false, backendId: "docker" } as never,
604605
nativeToolSurfaceEnabled: true,
@@ -914,6 +915,47 @@ describe("runCodexAppServerAttempt", () => {
914915
expect((await fs.stat(workspaceDir)).isDirectory()).toBe(true);
915916
});
916917

918+
it("uses task cwd for Codex app-server requests while keeping bootstrap workspace separate", async () => {
919+
const sessionFile = path.join(tempDir, "session.jsonl");
920+
const workspaceDir = path.join(tempDir, "workspace");
921+
const taskCwd = path.join(tempDir, "task-repo");
922+
await fs.mkdir(workspaceDir, { recursive: true });
923+
await fs.mkdir(taskCwd, { recursive: true });
924+
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "workspace bootstrap", "utf8");
925+
await fs.writeFile(path.join(taskCwd, "task-marker.txt"), "task marker", "utf8");
926+
const appServer = resolveCodexAppServerRuntimeOptions({
927+
pluginConfig: readCodexPluginConfig({}),
928+
});
929+
const params = createParams(sessionFile, workspaceDir);
930+
const requests: Array<{ method: string; params: unknown }> = [];
931+
await startOrResumeThread({
932+
client: {
933+
getServerVersion: () => "0.132.0",
934+
request: async (method: string, requestParams?: unknown) => {
935+
requests.push({ method, params: requestParams });
936+
if (method === "thread/start") {
937+
return threadStartResult();
938+
}
939+
return {};
940+
},
941+
} as never,
942+
params,
943+
cwd: taskCwd,
944+
dynamicTools: [],
945+
appServer,
946+
developerInstructions: "workspace bootstrap",
947+
});
948+
const threadStart = requests.find((request) => request.method === "thread/start");
949+
expect((threadStart?.params as { cwd?: string } | undefined)?.cwd).toBe(taskCwd);
950+
951+
const turnStart = buildTurnStartParams(params, {
952+
threadId: "thread-1",
953+
cwd: taskCwd,
954+
appServer,
955+
});
956+
expect(turnStart.cwd).toBe(taskCwd);
957+
});
958+
917959
it("filters Codex-native dynamic tools from app-server tool exposure", () => {
918960
const tools = [
919961
"read",

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,13 @@ export async function runCodexAppServerAttempt(
10821082
? resolvedWorkspace
10831083
: sandbox.workspaceDir
10841084
: resolvedWorkspace;
1085+
const requestedCwd = params.cwd ? resolveUserPath(params.cwd) : undefined;
1086+
if (sandbox?.enabled && requestedCwd && requestedCwd !== resolvedWorkspace) {
1087+
throw new Error(
1088+
"cwd override is not supported for sandboxed Codex app-server runs; omit cwd or use the agent workspace as cwd",
1089+
);
1090+
}
1091+
const effectiveCwd = sandbox?.enabled ? effectiveWorkspace : (requestedCwd ?? effectiveWorkspace);
10851092
await ensureCodexWorkspaceDirOnce(effectiveWorkspace);
10861093
preDynamicStartupStages.mark("effective-workspace");
10871094
const appServer = resolveCodexAppServerForOpenClawToolPolicy({
@@ -1238,6 +1245,7 @@ export async function runCodexAppServerAttempt(
12381245
params,
12391246
resolvedWorkspace,
12401247
effectiveWorkspace,
1248+
effectiveCwd,
12411249
sandboxSessionKey,
12421250
sandbox,
12431251
nativeToolSurfaceEnabled,
@@ -1253,6 +1261,7 @@ export async function runCodexAppServerAttempt(
12531261
params,
12541262
resolvedWorkspace,
12551263
effectiveWorkspace,
1264+
effectiveCwd,
12561265
sandboxSessionKey,
12571266
sandbox,
12581267
nativeToolSurfaceEnabled,
@@ -1314,6 +1323,7 @@ export async function runCodexAppServerAttempt(
13141323
buildHarnessContextEngineRuntimeContext({
13151324
attempt: buildActiveRunAttemptParams(),
13161325
workspaceDir: effectiveWorkspace,
1326+
cwd: effectiveCwd,
13171327
agentDir,
13181328
activeAgentId: sessionAgentId,
13191329
contextEnginePluginId: activeContextEnginePluginId,
@@ -1488,7 +1498,7 @@ export async function runCodexAppServerAttempt(
14881498
});
14891499
const trajectoryRecorder = createCodexTrajectoryRecorder({
14901500
attempt: params,
1491-
cwd: effectiveWorkspace,
1501+
cwd: effectiveCwd,
14921502
developerInstructions: buildRenderedCodexDeveloperInstructions(),
14931503
prompt: codexTurnPromptText,
14941504
tools: toolBridge.availableSpecs,
@@ -1507,7 +1517,7 @@ export async function runCodexAppServerAttempt(
15071517
}
15081518
};
15091519
let codexEnvironmentSelection: CodexTurnEnvironmentParams[] | undefined;
1510-
let codexExecutionCwd = effectiveWorkspace;
1520+
let codexExecutionCwd = effectiveCwd;
15111521
let codexSandboxPolicy: CodexSandboxPolicy | undefined;
15121522
let restartContextEngineCodexThread:
15131523
| (() => Promise<CodexAppServerThreadLifecycleBinding>)
@@ -1679,7 +1689,7 @@ export async function runCodexAppServerAttempt(
16791689
nativeToolSurfaceEnabled,
16801690
);
16811691
const startupExecutionCwd = resolveCodexAppServerExecutionCwd({
1682-
effectiveWorkspace,
1692+
effectiveCwd,
16831693
environment: startupSandboxEnvironment,
16841694
nativeToolSurfaceEnabled,
16851695
});
@@ -1832,7 +1842,7 @@ export async function runCodexAppServerAttempt(
18321842
});
18331843
recordCodexTrajectoryContext(trajectoryRecorder, {
18341844
attempt: params,
1835-
cwd: effectiveWorkspace,
1845+
cwd: effectiveCwd,
18361846
developerInstructions: promptBuild.developerInstructions,
18371847
prompt: codexTurnPromptText,
18381848
tools: toolBridge.availableSpecs,
@@ -3281,6 +3291,7 @@ export async function runCodexAppServerAttempt(
32813291
agentId: sessionAgentId,
32823292
notifyUserMessagePersisted,
32833293
sessionKey: sandboxSessionKey,
3294+
cwd: effectiveCwd,
32843295
threadId: thread.threadId,
32853296
turnId: activeTurnId,
32863297
});
@@ -3397,6 +3408,7 @@ export async function runCodexAppServerAttempt(
33973408
notifyUserMessagePersisted,
33983409
result,
33993410
sessionKey: contextSessionKey,
3411+
cwd: effectiveCwd,
34003412
threadId: thread.threadId,
34013413
turnId: activeTurnId,
34023414
});
@@ -3437,6 +3449,7 @@ export async function runCodexAppServerAttempt(
34373449
runtimeContext: buildHarnessContextEngineRuntimeContextFromUsage({
34383450
attempt: buildActiveRunAttemptParams(),
34393451
workspaceDir: effectiveWorkspace,
3452+
cwd: effectiveCwd,
34403453
agentDir,
34413454
activeAgentId: sessionAgentId,
34423455
contextEnginePluginId: activeContextEnginePluginId,
@@ -4170,6 +4183,7 @@ type DynamicToolBuildParams = {
41704183
params: EmbeddedRunAttemptParams;
41714184
resolvedWorkspace: string;
41724185
effectiveWorkspace: string;
4186+
effectiveCwd?: string;
41734187
sandboxSessionKey: string;
41744188
sandbox: Awaited<ReturnType<typeof resolveSandboxContext>>;
41754189
nativeToolSurfaceEnabled?: boolean;
@@ -4318,6 +4332,7 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
43184332
sessionId: params.sessionId,
43194333
runId: params.runId,
43204334
agentDir,
4335+
cwd: input.effectiveCwd ?? input.effectiveWorkspace,
43214336
workspaceDir: input.effectiveWorkspace,
43224337
spawnWorkspaceDir: resolveAttemptSpawnWorkspaceDir({
43234338
sandbox: input.sandbox,
@@ -4572,13 +4587,13 @@ function resolveCodexSandboxEnvironmentSelection(
45724587
}
45734588

45744589
function resolveCodexAppServerExecutionCwd(params: {
4575-
effectiveWorkspace: string;
4590+
effectiveCwd: string;
45764591
environment?: CodexSandboxExecEnvironment;
45774592
nativeToolSurfaceEnabled: boolean;
45784593
}): string {
45794594
return params.environment && params.nativeToolSurfaceEnabled
45804595
? params.environment.cwd
4581-
: params.effectiveWorkspace;
4596+
: params.effectiveCwd;
45824597
}
45834598

45844599
function resolveCodexExternalSandboxPolicyForOpenClawSandbox(
@@ -6153,6 +6168,7 @@ async function mirrorTranscriptBestEffort(params: {
61536168
notifyUserMessagePersisted: (message: Extract<AgentMessage, { role: "user" }>) => void;
61546169
result: EmbeddedRunAttemptResult;
61556170
sessionKey?: string;
6171+
cwd: string;
61566172
threadId: string;
61576173
turnId: string;
61586174
}): Promise<void> {
@@ -6166,6 +6182,8 @@ async function mirrorTranscriptBestEffort(params: {
61666182
sessionFile: params.params.sessionFile,
61676183
agentId: params.agentId,
61686184
sessionKey: params.sessionKey,
6185+
sessionId: params.params.sessionId,
6186+
cwd: params.cwd,
61696187
messages,
61706188
// Scope is thread-stable. Each entry in `messagesSnapshot` is tagged
61716189
// with a per-turn `attachCodexMirrorIdentity` value carrying its own
@@ -6234,6 +6252,7 @@ async function mirrorPromptAtTurnStartBestEffort(params: {
62346252
agentId?: string;
62356253
notifyUserMessagePersisted: (message: Extract<AgentMessage, { role: "user" }>) => void;
62366254
sessionKey?: string;
6255+
cwd: string;
62376256
threadId: string;
62386257
turnId: string;
62396258
}): Promise<void> {
@@ -6250,6 +6269,8 @@ async function mirrorPromptAtTurnStartBestEffort(params: {
62506269
sessionFile: params.params.sessionFile,
62516270
agentId: params.agentId,
62526271
sessionKey: params.sessionKey,
6272+
sessionId: params.params.sessionId,
6273+
cwd: params.cwd,
62536274
messages: [userPromptMessage],
62546275
idempotencyScope: `codex-app-server:${params.threadId}`,
62556276
config: params.params.config,

extensions/codex/src/app-server/transcript-mirror.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
147147

148148
export async function mirrorCodexAppServerTranscript(params: {
149149
sessionFile: string;
150+
sessionId?: string;
151+
cwd?: string;
150152
sessionKey?: string;
151153
agentId?: string;
152154
messages: AgentMessage[];
@@ -207,6 +209,8 @@ export async function mirrorCodexAppServerTranscript(params: {
207209
transcriptPath: params.sessionFile,
208210
message: messageToAppend,
209211
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
212+
sessionId: params.sessionId,
213+
cwd: params.cwd,
210214
config: params.config,
211215
});
212216
if (appendedMessage.role === "user") {

src/acp/session-lineage-meta.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type AcpSessionLineageMeta = {
1717
subagentRole?: SubagentRole;
1818
subagentControlScope?: SubagentControlScope;
1919
spawnedWorkspaceDir?: string;
20+
spawnedCwd?: string;
2021
};
2122

2223
export type AcpSessionLineageRow = Pick<
@@ -30,6 +31,7 @@ export type AcpSessionLineageRow = Pick<
3031
| "subagentRole"
3132
| "subagentControlScope"
3233
| "spawnedWorkspaceDir"
34+
| "spawnedCwd"
3335
>;
3436

3537
function readInteger(value: unknown): number | undefined {
@@ -55,6 +57,7 @@ export function toAcpSessionLineageMeta(row: AcpSessionLineageRow): AcpSessionLi
5557
const subagentRole = readEnum(row.subagentRole, SUBAGENT_ROLES);
5658
const subagentControlScope = readEnum(row.subagentControlScope, SUBAGENT_CONTROL_SCOPES);
5759
const spawnedWorkspaceDir = normalizeOptionalString(row.spawnedWorkspaceDir);
60+
const spawnedCwd = normalizeOptionalString(row.spawnedCwd);
5861

5962
return {
6063
sessionKey,
@@ -66,5 +69,6 @@ export function toAcpSessionLineageMeta(row: AcpSessionLineageRow): AcpSessionLi
6669
...(subagentRole ? { subagentRole } : {}),
6770
...(subagentControlScope ? { subagentControlScope } : {}),
6871
...(spawnedWorkspaceDir ? { spawnedWorkspaceDir } : {}),
72+
...(spawnedCwd ? { spawnedCwd } : {}),
6973
};
7074
}

src/acp/translator.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ type GatewaySessionPresentationRow = Pick<
163163
| "subagentRole"
164164
| "subagentControlScope"
165165
| "spawnedWorkspaceDir"
166+
| "spawnedCwd"
166167
| "displayName"
167168
| "label"
168169
| "derivedTitle"
@@ -831,7 +832,10 @@ export class AcpGatewayAgent implements Agent {
831832
if (!requestedCwd) {
832833
return true;
833834
}
834-
return normalizeOptionalString(session.spawnedWorkspaceDir) === requestedCwd;
835+
return (
836+
(normalizeOptionalString(session.spawnedCwd) ??
837+
normalizeOptionalString(session.spawnedWorkspaceDir)) === requestedCwd
838+
);
835839
})
836840
.map((session) => this.mapGatewaySessionToAcpSessionInfo(session, fallbackCwd));
837841
if (
@@ -1899,7 +1903,10 @@ export class AcpGatewayAgent implements Agent {
18991903
session: GatewaySessionRow,
19001904
fallbackCwd: string,
19011905
): SessionInfo {
1902-
const cwd = normalizeOptionalString(session.spawnedWorkspaceDir) ?? fallbackCwd;
1906+
const cwd =
1907+
normalizeOptionalString(session.spawnedCwd) ??
1908+
normalizeOptionalString(session.spawnedWorkspaceDir) ??
1909+
fallbackCwd;
19031910
return {
19041911
sessionId: session.key,
19051912
cwd,
@@ -1966,6 +1973,7 @@ export class AcpGatewayAgent implements Agent {
19661973
subagentRole: session.subagentRole,
19671974
subagentControlScope: session.subagentControlScope,
19681975
spawnedWorkspaceDir: session.spawnedWorkspaceDir,
1976+
spawnedCwd: session.spawnedCwd,
19691977
displayName: session.displayName,
19701978
label: session.label,
19711979
derivedTitle: session.derivedTitle,

0 commit comments

Comments
 (0)