Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions packages/coding-agent/src/features/step-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { type Static, Type } from "typebox";
import { CONFIG_DIR_NAME } from "../config.ts";
import type { ExtensionAPI, ExtensionContext, ExtensionFactory, InlineExtension } from "../core/extensions/types.ts";
import { resolveStepAgentDir, resolveStepConfigDir } from "../step/environment.ts";
import { requestStepPermissionState, type StepChildPermissionPolicy } from "../step/permissions.ts";
import type { StepTelemetryReporter } from "../step/telemetry.ts";
import { formatBuiltinAgentGuidance, type StepAgentConfig, type StepAgentScope } from "./step-subagent-agents.ts";
import { executeSubagent } from "./subagent/execute.ts";
Expand All @@ -33,7 +34,7 @@ import {
SubagentListWidget,
subagentListSignature,
} from "./subagent/rendering.ts";
import { createSubagentRpcSession } from "./subagent/rpc-adapter.ts";
import { createSubagentRpcSession, subagentPermissionKey } from "./subagent/rpc-adapter.ts";

// Lane-notification primitives moved to ./subagent/lane-events.ts; re-exported
// here to keep this module's public surface stable.
Expand Down Expand Up @@ -129,6 +130,12 @@ export interface StepSubagentRunInput {
* Defaults to `resolveSubagentTurnIdleTimeoutMs()`; `0` disables the watchdog.
*/
turnIdleTimeoutMs?: number;
/**
* The parent's permission policy, passed to the child as explicit flags so
* the child is never more permissive than the parent. Omitted when the
* parent has no Step permission controller; the child then resolves its own.
*/
permission?: StepChildPermissionPolicy;
/** Workflow-owned path ACL passed to the child-side tool_call hook. */
workflowAcl?: {
baseCwd: string;
Expand Down Expand Up @@ -404,7 +411,15 @@ const spawnedSubagentSessions = new Set<string>();
export async function runStepSubagentProcess(input: StepSubagentRunInput): Promise<StepSubagentRunResult> {
const sessionId = input.sessionId?.trim() || `${SUBAGENT_SESSION_ID_PREFIX}${randomUUID()}`;
const live = getLiveSubagentSession(sessionId);
if (live) return live.runTurn(input);
if (live) {
if (live.isTurnActive() || live.permissionKey === subagentPermissionKey(input.permission)) {
return live.runTurn(input);
Comment on lines +415 to +416
}
// A live child keeps the policy it was spawned with. The parent's changed
// since (e.g. tightened via /permissions), so replace the idle child and
// resume its transcript under the current policy.
live.stop();
}
if (spawnedSubagentSessions.has(sessionId)) input.onChildRespawn?.();
spawnedSubagentSessions.add(sessionId);
const session = await createSubagentRpcSession(input, sessionId);
Expand Down Expand Up @@ -517,6 +532,9 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
// tool profile, but does not recursively expose another subagent tool.
if (process.env[CHILD_MARKER] === "1") return;

// Read the parent's live permission state at each spawn, so a preset
// switched mid-session reaches the next child.
const run = { ...resolved, permissionState: () => requestStepPermissionState(pi.events) };
const lanes = new Map<string, BackgroundAgentLane>();
const laneWidgetKey = (id: string): string => `step-agent:${id}`;
const updateLaneWidget = (lane: BackgroundAgentLane): void => {
Expand All @@ -534,7 +552,7 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
lanes,
agentDir: resolved.agentDir,
executeSubagent: (runParams, signal, onUpdate, ctx, laneRuntime) =>
executeSubagent(runParams, signal, onUpdate, ctx, resolved, laneRuntime),
executeSubagent(runParams, signal, onUpdate, ctx, run, laneRuntime),
updateLaneWidget,
});

Expand Down Expand Up @@ -565,7 +583,7 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
// normal validation result keeps the error attached to this tool call
// instead of emitting a misleading background_done notification.
if (Number(hasSingleInput) + Number(hasParallelInput) + Number(hasChainInput) !== 1) {
return executeSubagent(backgroundParams, signal, onUpdate, ctx, resolved);
return executeSubagent(backgroundParams, signal, onUpdate, ctx, run);
}
const lane = createLane(backgroundParams, ctx);
startLane(lane, backgroundParams);
Expand All @@ -583,7 +601,7 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
);
}
return withSubagentListWidget(toolCallId, ctx, onUpdate, (update) =>
executeSubagent(params as SubagentParams, signal, update, ctx, resolved),
executeSubagent(params as SubagentParams, signal, update, ctx, run),
);
},
renderCall: (params, theme) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/src/features/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { STEP_INIT_PROMPT } from "../step/init-prompt.ts";
import { createStepMcpExtension } from "../step/mcp.ts";
import {
AUTO_RESUME_PROMPT,
answerStepPermissionStateRequests,
getStepPermissionPreset,
publishStepPermissionStatus,
StepAutoResumeController,
Expand Down Expand Up @@ -123,6 +124,10 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi
recordStepSlashCommand(options.telemetry, token, builtInSlashCommands.has(name));
});
let permissions = new StepPermissionController(resolvePermissionOptions(options));
// The subagent tool reads the live policy here when it spawns a child, so
// the child gets the parent's effective policy rather than re-resolving
// its own from env and settings.
answerStepPermissionStateRequests(pi.events, () => permissions.getState());
let notify: ((message: string, type?: "info" | "warning" | "error") => void) | undefined;
let autoResumeAllowed = false;
let nativeRetryPreference: boolean | undefined;
Expand Down
6 changes: 6 additions & 0 deletions packages/coding-agent/src/features/subagent/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import path from "node:path";
import type { AgentToolResult, AgentToolUpdateCallback } from "@step-harness/agent-core";
import type { Static } from "typebox";
import type { ExtensionContext } from "../../core/extensions/types.ts";
import { resolveStepChildPermissionPolicy, type StepPermissionState } from "../../step/permissions.ts";
import { type StepTelemetryReporter, trackStepTelemetry } from "../../step/telemetry.ts";
import {
cloneUsage,
Expand Down Expand Up @@ -224,6 +225,8 @@ export async function executeSubagent(
worktreeManager: StepWorktreeManager;
runner: StepSubagentRunner;
telemetry?: StepTelemetryReporter;
/** The parent's live permission state; undefined when no Step controller is loaded. */
permissionState?: () => StepPermissionState | undefined;
},
laneRuntime?: StepSubagentLaneRuntime,
): Promise<AgentToolResult<StepSubagentDetails>> {
Expand Down Expand Up @@ -336,12 +339,15 @@ export async function executeSubagent(
});
emit(parallel ? "parallel" : "single");
};
// Read at spawn time: a preset switched mid-session applies to the next child.
const permissionState = options.permissionState?.();
const child = await options.runner({
agent,
task: task.task,
cwd: childCwd,
model: task.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined),
thinkingLevel: ctx.thinkingLevel,
...(permissionState ? { permission: resolveStepChildPermissionPolicy(permissionState, ctx.hasUI) } : {}),
signal,
onUpdate: update,
onNeedsInput: laneRuntime?.onNeedsInput,
Expand Down
57 changes: 51 additions & 6 deletions packages/coding-agent/src/features/subagent/rpc-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { isValidThinkingLevel } from "../../cli/args.ts";
import {
CHILD_MARKER,
cloneUsage,
Expand Down Expand Up @@ -101,6 +102,8 @@ interface SubagentRpcTurn {
/** A live `--mode rpc` child bound to one subagent session id. */
export interface StepSubagentRpcSession {
readonly sessionId: string;
/** `subagentPermissionKey` of the policy this child was spawned with. */
readonly permissionKey: string;
/** True while the child can still accept stdin commands. */
isAlive(): boolean;
/** True while a prompt turn is in flight. */
Expand Down Expand Up @@ -177,21 +180,62 @@ export function buildSubagentChildEnv(input: StepSubagentRunInput): NodeJS.Proce
...(input.workflowAcl
? { [WORKFLOW_ACL_ENV]: JSON.stringify(input.workflowAcl) }
: { [WORKFLOW_ACL_ENV]: undefined }),
// Auto-resume has no CLI flag. Pin it here so neither an inherited
// `STEP_AUTOPILOT` nor a persisted setting overrides the parent's choice.
...(input.permission
? { STEP_AUTOPILOT: undefined, STEP_AUTO_RESUME: input.permission.autoResume ? "1" : "0" }
: {}),
};
}

/** Comparable identity of a child's permission policy: the flags it is spawned with. */
export function subagentPermissionKey(policy: StepSubagentRunInput["permission"]): string {
return JSON.stringify([...permissionArgs(policy), policy?.autoResume === true]);
}

function permissionArgs(policy: StepSubagentRunInput["permission"]): string[] {
if (!policy) return [];
const args = ["--approval-mode", policy.approvalMode, "--non-interactive-approval", policy.nonInteractiveApproval];
for (const [tool, mode] of Object.entries(policy.toolOverrides ?? {})) {
args.push("--tool-override", `${tool}=${mode}`);
}
return args;
}

/** True when a model pattern ends in a `:<thinking level>` suffix. */
function declaresThinkingLevel(model: string): boolean {
const colon = model.lastIndexOf(":");
return colon !== -1 && isValidThinkingLevel(model.slice(colon + 1));
}

/**
* Argv for one rpc child, minus the `--append-system-prompt` temp file.
*
* The child inherits the parent's thinking level unless its model pattern
* declares one (`provider/id:high`): an explicit `--thinking` would override
* that suffix in the child's resolver. A model without reasoning support
* clamps the level instead of failing. The permission policy goes as explicit
* flags, which outrank any `STEP_*` env var or persisted preset the child
* would otherwise resolve.
*/
export function buildSubagentChildArgs(input: StepSubagentRunInput, sessionId: string): string[] {
const args = ["--mode", "rpc", "--session-id", sessionId];
const model = input.agent.model ?? input.model;
if (model) args.push("--model", model);
if (input.thinkingLevel && !(model && declaresThinkingLevel(model))) args.push("--thinking", input.thinkingLevel);
const tools = normalizeChildTools(input.agent.tools);
if (tools && tools.length > 0) args.push("--tools", tools.join(","));
args.push(...permissionArgs(input.permission));
return args;
}

/** Spawn a long-running `--mode rpc --session-id` child for one subagent session. */
export async function createSubagentRpcSession(
input: StepSubagentRunInput,
sessionId: string,
): Promise<StepSubagentRpcSession> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "stepcode-subagent-"));
const args = ["--mode", "rpc", "--session-id", sessionId];
const model = input.agent.model ?? input.model;
if (model) args.push("--model", model);
if (input.thinkingLevel && !input.agent.model) args.push("--thinking", input.thinkingLevel);
const tools = normalizeChildTools(input.agent.tools);
if (tools && tools.length > 0) args.push("--tools", tools.join(","));
const args = buildSubagentChildArgs(input, sessionId);
if (input.agent.systemPrompt.trim()) {
const promptPath = path.join(tempDir, "system-prompt.md");
await writeFile(promptPath, input.agent.systemPrompt, { encoding: "utf8", mode: 0o600 });
Expand Down Expand Up @@ -485,6 +529,7 @@ export async function createSubagentRpcSession(

const handle: StepSubagentRpcSession = {
sessionId,
permissionKey: subagentPermissionKey(input.permission),
isAlive: () => !childExited && !stdinEnded,
isTurnActive: () => turn !== undefined,
send,
Expand Down
90 changes: 90 additions & 0 deletions packages/coding-agent/src/step/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { AgentMessage } from "@step-harness/agent-core";
import type { EventBus } from "../core/event-bus.ts";
import type {
AgentEndEvent,
ExtensionContext,
Expand Down Expand Up @@ -323,6 +324,95 @@ export function resolveInitialStepPermissionState(options: StepPermissionControl
return resolved;
}

/**
* The explicit policy handed to a subagent child (`--approval-mode`,
* `--non-interactive-approval`, `--tool-override`, and `STEP_AUTO_RESUME`).
* Explicit flags outrank every `STEP_*` env var and persisted preset in the
* child's resolver, so whatever the child inherits cannot loosen this.
*/
export interface StepChildPermissionPolicy {
approvalMode: StepPermissionMode;
nonInteractiveApproval: StepNonInteractiveApproval;
autoResume: boolean;
toolOverrides?: Record<string, StepToolPermissionMode>;
}

/**
* Derive a subagent child's policy from the parent's live state. The child must
* never be more permissive than the parent:
*
* - A headless parent passes on the policy it actually enforces: `auto` that
* is refused or unconfigured unattended is downgraded to `confirm`/`deny`,
* the same downgrade StepPermissionController applies to its own calls.
* - A defaulted parent (nothing selected a policy) keeps its mode but never
* hands the child an explicit unattended `allow`: the fallback is `deny`,
* which mirrors the defaulted semantics (permissive only while a UI exists)
* now that the explicit flag takes the child out of the defaulted bucket.
* - `strict` and `confirm` pass through unchanged. The rpc child's blocking
* dialogs are auto-cancelled by the parent, so a child `confirm` blocks.
*/
export function resolveStepChildPermissionPolicy(
state: StepPermissionState,
parentHasUI: boolean,
): StepChildPermissionPolicy {
let approvalMode = state.mode;
let nonInteractiveApproval: StepNonInteractiveApproval =
state.defaulted === true ? "deny" : state.nonInteractiveApproval;
const unattendedRefused = !parentHasUI && (state.nonInteractiveApproval === "deny" || state.defaulted === true);
if (approvalMode === "auto" && unattendedRefused) {
approvalMode = "confirm";
nonInteractiveApproval = "deny";
}
const policy: StepChildPermissionPolicy = {
approvalMode,
nonInteractiveApproval,
autoResume: normalizeAutoResume(approvalMode, nonInteractiveApproval, state.autoResume),
};
if (state.toolOverrides && Object.keys(state.toolOverrides).length > 0) {
policy.toolOverrides = cloneToolOverrides({ ...state.toolOverrides });
}
return policy;
}

/**
* Extension event-bus channel the subagent tool uses to read the Step
* extension's live permission state at execution time. The payload carries a
* `reply` callback; the bus dispatches synchronously, so the answer is in
* before `emit` returns.
*/
export const STEP_PERMISSION_STATE_REQUEST = "step:permission-state-request";

interface StepPermissionStateRequest {
reply(state: StepPermissionState): void;
}

/**
* Answer permission-state requests on `events` with the current live state.
* `events` is optional only for partial hosts and test doubles without a bus.
*/
export function answerStepPermissionStateRequests(
events: EventBus | undefined,
getState: () => StepPermissionState,
): () => void {
if (!events) return () => {};
return events.on(STEP_PERMISSION_STATE_REQUEST, (data) => {
const request = data as Partial<StepPermissionStateRequest> | undefined;
if (typeof request?.reply === "function") request.reply(getState());
});
}

/** Read the Step extension's live permission state; undefined when it is not loaded. */
export function requestStepPermissionState(events: EventBus | undefined): StepPermissionState | undefined {
if (!events) return undefined;
let state: StepPermissionState | undefined;
events.emit(STEP_PERMISSION_STATE_REQUEST, {
reply: (value) => {
state = value;
},
} satisfies StepPermissionStateRequest);
return state;
}

/**
* Decide a tool call without involving the terminal. This is intentionally
* conservative for unknown tools: ask mode confirms them, read-only blocks
Expand Down
Loading
Loading