Final phase of #2584 (ADR-1239 Codex-binding amendment). execute-phase now negotiates dispatch.isolation and dispatches through the matching adapter, so a wave's independent plans run concurrently on six runtimes instead of one — with no runtime=== branch in the scheduler. harness-worktree passes the host's declared isolation flag (claude, cursor); orchestrator-worktree creates the worktree via the Phase-2 verb and spawns the executor into it with the resolved argv/cwd (codex, opencode, kimi, kimi-code); none stays sequential. Undeclared/unknown/unresolvable isolation degrades to none — never an unisolated parallel run. Fixes two shipped Phase-2 descriptors that per-host research found would fail at spawn: kimi lacked its headless flag (would launch the interactive TUI and hang the orchestrator), and kimi-code named a non-existent binary (Kimi Code installs as 'kimi'). Adds the worktree-path root confinement Phase 2 deferred here, and leading-dash guards on the resolver's prompt/cwd matching the existing git-argument guard. Closes #2627 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -755,6 +755,7 @@ interface OrchestratorExec {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwdFlag?: string | null;
|
||||
promptFlag?: string | null;
|
||||
}
|
||||
|
||||
type OrchestratorExecResolution =
|
||||
@@ -762,8 +763,8 @@ type OrchestratorExecResolution =
|
||||
| { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Resolve an `orchestratorExec` descriptor + target cwd into a concrete
|
||||
* argv/cwd shape for a process-spawn primitive.
|
||||
* Resolve an `orchestratorExec` descriptor + target cwd (+ optional executor
|
||||
* prompt) into a concrete argv/cwd shape for a process-spawn primitive.
|
||||
*
|
||||
* Fail-closed: never throws, always returns a discriminated result. When
|
||||
* `cwdFlag` is a non-empty string, `[cwdFlag, cwd]` is appended to `args`
|
||||
@@ -771,8 +772,23 @@ type OrchestratorExecResolution =
|
||||
* absent (e.g. kimi-code, which binds via the spawned process's own cwd —
|
||||
* "process-cwd" case), no flag is appended and `cwd` is returned for the
|
||||
* caller to bind via the subprocess's own working-directory option.
|
||||
*
|
||||
* Prompt passing (Phase 3, #2627) is descriptor data for the same reason the
|
||||
* cwd flag is: the confirmed `orchestrator-worktree` hosts disagree on the
|
||||
* shape. `codex exec "<prompt>"` and `opencode run "<prompt>"` take it
|
||||
* positionally; `kimi --print --prompt "<p>"` and Kimi Code's `kimi -p "<p>"`
|
||||
* take a flag. Encoding that as `promptFlag` keeps the scheduler free of the
|
||||
* per-host branch ADR-1239 exists to remove. Omit `prompt` entirely and the
|
||||
* resolution is byte-identical to Phase 2's (the unconsumed-resolver shape).
|
||||
*
|
||||
* Argv order is base args → cwd flag → prompt, so the prompt stays the final
|
||||
* positional token for the hosts that read it that way.
|
||||
*/
|
||||
function resolveOrchestratorExec(orchestratorExec: OrchestratorExec | undefined, cwd: string): OrchestratorExecResolution {
|
||||
function resolveOrchestratorExec(
|
||||
orchestratorExec: OrchestratorExec | undefined,
|
||||
cwd: string,
|
||||
prompt?: string,
|
||||
): OrchestratorExecResolution {
|
||||
if (!orchestratorExec || typeof orchestratorExec !== 'object' || Array.isArray(orchestratorExec)) {
|
||||
return { ok: false, reason: 'missing_command' };
|
||||
}
|
||||
@@ -789,12 +805,42 @@ function resolveOrchestratorExec(orchestratorExec: OrchestratorExec | undefined,
|
||||
if (oe.cwdFlag !== undefined && oe.cwdFlag !== null && typeof oe.cwdFlag !== 'string') {
|
||||
return { ok: false, reason: 'invalid_cwd_flag' };
|
||||
}
|
||||
if (oe.promptFlag !== undefined && oe.promptFlag !== null && typeof oe.promptFlag !== 'string') {
|
||||
return { ok: false, reason: 'invalid_prompt_flag' };
|
||||
}
|
||||
// An executor spawned with no instruction is a hang, not a degraded run —
|
||||
// fail closed rather than launching a prompt-less process.
|
||||
if (prompt !== undefined && (typeof prompt !== 'string' || prompt.length === 0)) {
|
||||
return { ok: false, reason: 'invalid_prompt' };
|
||||
}
|
||||
// Leading-dash guard, mirroring worktree-safety.cts's `unsafe_leading_dash`
|
||||
// check on git arguments. A positional prompt (or a cwd) beginning with '-'
|
||||
// is parsed by the spawned CLI as a FLAG, not a value — the same failure the
|
||||
// git path already rejects, and for the same reason: `--` end-of-options
|
||||
// support is inconsistent across these CLIs, so rejecting outright is the
|
||||
// portable fix rather than relying on a separator. Applied to the resolver
|
||||
// (not just its current caller) because this is a general descriptor->argv
|
||||
// seam: a future caller must not have to rediscover the hazard.
|
||||
if (typeof prompt === 'string' && prompt.startsWith('-')) {
|
||||
return { ok: false, reason: 'unsafe_leading_dash_prompt' };
|
||||
}
|
||||
if (cwd.startsWith('-')) {
|
||||
return { ok: false, reason: 'unsafe_leading_dash_cwd' };
|
||||
}
|
||||
|
||||
const baseArgs = Array.isArray(oe.args) ? [...oe.args] : [];
|
||||
const args = typeof oe.cwdFlag === 'string' && oe.cwdFlag.length > 0
|
||||
? [...baseArgs, oe.cwdFlag, cwd]
|
||||
: baseArgs;
|
||||
|
||||
if (typeof prompt === 'string') {
|
||||
if (typeof oe.promptFlag === 'string' && oe.promptFlag.length > 0) {
|
||||
args.push(oe.promptFlag, prompt);
|
||||
} else {
|
||||
args.push(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, command: oe.command, args, cwd };
|
||||
}
|
||||
|
||||
|
||||
@@ -1530,10 +1530,14 @@ Typed mapping (agent_type-capable schema only):
|
||||
inherited, or unsupported values; do not invent one-off effort literals in
|
||||
workflow prose.
|
||||
- \`fork_context: false\` by default — GSD agents load their own context via \`<files_to_read>\` blocks
|
||||
- \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct Codex mapping.
|
||||
Codex \`spawn_agent\` does not create or bind a git worktree automatically.
|
||||
Workflows that require this isolation must fail closed or use an explicit
|
||||
manual worktree protocol before spawning (#3360).
|
||||
- \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping,
|
||||
but Codex declares \`dispatch.isolation: orchestrator-worktree\` (#2584). Codex
|
||||
\`spawn_agent\` still does not create or bind a git worktree; instead GSD itself
|
||||
creates the worktree and process-spawns the executor into it with
|
||||
\`codex exec --cd <dir>\`, performing every git operation on the executor's behalf
|
||||
(its \`workspace-write\` sandbox makes \`.git\` read-only). Workflows must therefore
|
||||
never fabricate a manual worktree protocol — route through the negotiated
|
||||
isolation adapter, which still fails closed for hosts declaring \`none\` (#3360).
|
||||
|
||||
Generic-agent workaround (multi_agent_v1 schema — NO agent_type field):
|
||||
When only the generic \`multi_agent_v1\` schema is available, typed GSD agent dispatch
|
||||
|
||||
@@ -1337,7 +1337,7 @@ function cmdWorktreeCreate(cwd: string, args: string[] = [], deps: RecordAgentCm
|
||||
|
||||
const manifestPath = flag('--manifest');
|
||||
if (!manifestPath) {
|
||||
writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>\n');
|
||||
writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> [--root <dir>]\n');
|
||||
process.exitCode = 2;
|
||||
return { ok: false, reason: 'usage' };
|
||||
}
|
||||
@@ -1410,6 +1410,40 @@ function cmdWorktreeCreate(cwd: string, args: string[] = [], deps: RecordAgentCm
|
||||
return { ok: false, reason: plan.reason, hint: plan.hint };
|
||||
}
|
||||
|
||||
// 3b. Optional root confinement (#2627, Phase 3 — the confinement Phase 2
|
||||
// deferred here from planWorktreeCreate's path-traversal guard).
|
||||
// planWorktreeCreate rejects a literal ".." SEGMENT, but a plain absolute
|
||||
// path outside the project contains no ".." and passes. Phase 3 makes the
|
||||
// orchestrator SPAWN executor processes into these paths, so an
|
||||
// unconfined --path is a write primitive aimed anywhere on the filesystem.
|
||||
//
|
||||
// The root is DECLARED by the caller (`--root`) rather than inferred: agent
|
||||
// worktrees legitimately live outside the orchestrator's own root (a lane
|
||||
// orchestrator creates siblings under the repo's .claude/worktrees/), so
|
||||
// there is no layout this module could derive without guessing. Absent
|
||||
// `--root` the behavior is exactly as shipped in Phase 2 — the
|
||||
// orchestrator-worktree scheduler path always passes it.
|
||||
//
|
||||
// Lexical by design: the worktree does not exist yet, so there is nothing
|
||||
// to realpath, and resolving only the root would not close a symlinked-leaf
|
||||
// hole. Pairs with the leading-dash and ".."-segment guards above.
|
||||
const rootFlag = flag('--root');
|
||||
if (rootFlag) {
|
||||
const absRoot = path.resolve(cwd, rootFlag);
|
||||
const absWorktree = path.resolve(cwd, plan.entry.worktree_path);
|
||||
const rel = path.relative(absRoot, absWorktree);
|
||||
// rel === '' → the worktree IS the root (would clobber the checkout)
|
||||
// rel === '..' / '../…' → escapes the root
|
||||
// path.isAbsolute(rel) → a different Windows drive or UNC root
|
||||
if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
||||
const hint = `--path must resolve INSIDE --root (root="${absRoot}", path="${absWorktree}"). A worktree outside the declared root is unreachable by manifest-scoped cleanup and would let a spawned executor write outside the project.`;
|
||||
writeErr(`[gsd] worktree.create: path_outside_root — ${hint}\n`);
|
||||
write(`${JSON.stringify({ ok: false, reason: 'path_outside_root', hint }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
return { ok: false, reason: 'path_outside_root', hint };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Compute the deduped final manifest STRING in memory now — the ONLY
|
||||
// manifest work left is the guarded write in step 6, after git succeeds.
|
||||
// #2584 FIX 2: the on-disk entry is the SAME minimal 4-field shape
|
||||
|
||||
Reference in New Issue
Block a user