Skip to content
Merged
22 changes: 18 additions & 4 deletions src/autoskillit/execution/backends/_backend_cmd_builder_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from typing import TYPE_CHECKING, Any, NamedTuple

from autoskillit.core import (
AUTOSKILLIT_APPLICABLE_GUARDS,
AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES,
CAMPAIGN_ID_ENV_VAR,
KITCHEN_SESSION_ID_ENV_VAR,
SkillSessionConfig,
Expand Down Expand Up @@ -88,22 +90,34 @@ def _flag_vocabulary(self) -> FlagVocabulary:
@staticmethod
def _assemble_shared_env_extras(
*,
session_type: str = "",
applicable_guards: frozenset[str] = frozenset(),
write_guard_tool_names: frozenset[str] = frozenset(),
write_prefix: str = "",
write_prefixes: tuple[str, ...] = (),
cwd: str = "",
scenario_step_name: str = "",
) -> dict[str, str]:
"""Assemble the eight shared env keys consumed by both backends.
"""Assemble the shared env keys consumed by both backends.

Always-on keys (two): ``MAX_MCP_OUTPUT_TOKENS``, ``MCP_CONNECTION_NONBLOCKING``.
Always-on keys (three): ``MAX_MCP_OUTPUT_TOKENS``, ``MCP_CONNECTION_NONBLOCKING``,
``AUTOSKILLIT_HEADLESS``.

Conditional keys (six): ``SCENARIO_STEP_NAME``,
``CAMPAIGN_ID_ENV_VAR``, ``KITCHEN_SESSION_ID_ENV_VAR``,
Conditional keys (nine): ``AUTOSKILLIT_SESSION_TYPE``,
``AUTOSKILLIT_APPLICABLE_GUARDS``, ``AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES``,
``SCENARIO_STEP_NAME``, ``CAMPAIGN_ID_ENV_VAR``, ``KITCHEN_SESSION_ID_ENV_VAR``,
``AUTOSKILLIT_ALLOWED_WRITE_PREFIX``, ``AUTOSKILLIT_ALLOWED_WRITE_PREFIXES``,
``AUTOSKILLIT_CWD``. Each is included only when its input is non-empty
(campaign/kitchen IDs are also read from the ambient ``os.environ``).
"""
extras: dict[str, str] = dict(SHARED_BASELINE_ENV)
extras["AUTOSKILLIT_HEADLESS"] = "1"
if session_type:
extras["AUTOSKILLIT_SESSION_TYPE"] = session_type
if applicable_guards:
extras[AUTOSKILLIT_APPLICABLE_GUARDS] = ",".join(sorted(applicable_guards))
if write_guard_tool_names:
extras[AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES] = ",".join(sorted(write_guard_tool_names))
if scenario_step_name:
extras["SCENARIO_STEP_NAME"] = scenario_step_name
campaign_id = os.environ.get(CAMPAIGN_ID_ENV_VAR)
Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/execution/backends/_claude_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def _extract_write_artifacts(tool_uses: list[dict[str, Any]]) -> list[str]:
}
)

# Variables that _build_skill_session_cmd_impl controls exclusively. They must not
# Variables that build_skill_session_cmd controls exclusively. They must not
# leak from the host process environment β€” the caller opts in via explicit
# parameters (exit_after_stop_delay_ms, scenario_step_name, allowed_write_prefix, etc.).
# Note: CLAUDE_CODE_EXIT_AFTER_STOP_DELAY, SCENARIO_STEP_NAME, and
Expand Down
157 changes: 44 additions & 113 deletions src/autoskillit/execution/backends/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@
AGENT_BACKEND_CLAUDE_CODE,
AGENT_BACKEND_DYNACONF_ENV_VAR,
AGENT_BACKEND_ENV_VAR,
AUTOSKILLIT_APPLICABLE_GUARDS,
AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES,
CAMPAIGN_ID_ENV_VAR,
CLAUDE_CODE_CAPABILITIES,
CONTEXT_EXHAUSTION_MARKER,
NON_VARIADIC_CLAUDE_FLAGS,
ORCHESTRATOR_SESSION_REQUIRED_ENV,
SESSION_TYPE_ORCHESTRATOR,
SESSION_TYPE_SKILL,
SKILL_SESSION_REQUIRED_ENV,
VARIADIC_CLAUDE_FLAGS,
AgentSessionResult,
BackendCapabilities,
BackendConventions,
Expand Down Expand Up @@ -285,17 +285,8 @@ def _env_policy(self) -> ClaudeEnvPolicy:

def _flag_vocabulary(self) -> FlagVocabulary:
return FlagVocabulary(
variadic_flags=frozenset(
{ClaudeFlags.ADD_DIR, ClaudeFlags.PLUGIN_DIR, ClaudeFlags.TOOLS}
),
non_variadic_flags=frozenset(
{
ClaudeFlags.PRINT,
ClaudeFlags.MODEL,
ClaudeFlags.RESUME,
ClaudeFlags.DANGEROUSLY_SKIP_PERMISSIONS,
}
),
variadic_flags=VARIADIC_CLAUDE_FLAGS,
non_variadic_flags=NON_VARIADIC_CLAUDE_FLAGS,
model_flag=ClaudeFlags.MODEL,
add_dir_flag=ClaudeFlags.ADD_DIR,
resume_flag=ClaudeFlags.RESUME,
Expand Down Expand Up @@ -534,71 +525,25 @@ def build_skill_session_cmd(
resume_message: str | None = None,
) -> CmdSpec:
if config is not None:
return self._build_skill_session_cmd_impl(
skill_command,
cwd=cwd,
completion_marker=config.completion_marker,
model=config.model,
plugin_source=config.plugin_source,
output_format=config.output_format,
add_dirs=config.add_dirs,
exit_after_stop_delay_ms=config.exit_after_stop_delay_ms,
stream_idle_timeout_ms=config.stream_idle_timeout_ms,
scenario_step_name=config.scenario_step_name,
temp_dir_relpath=config.temp_dir_relpath,
allowed_write_prefix=config.allowed_write_prefix,
allowed_write_prefixes=config.allowed_write_prefixes,
provider_extras=config.provider_extras,
profile_name=config.profile_name,
resume_session_id=config.resume_session_id,
resume_checkpoint=config.resume_checkpoint,
resume_message=config.resume_message,
sandbox_mode=config.sandbox_mode,
)
return self._build_skill_session_cmd_impl(
skill_command,
cwd=cwd,
completion_marker=completion_marker,
model=model,
plugin_source=plugin_source,
output_format=output_format,
add_dirs=add_dirs,
exit_after_stop_delay_ms=exit_after_stop_delay_ms,
stream_idle_timeout_ms=stream_idle_timeout_ms,
scenario_step_name=scenario_step_name,
temp_dir_relpath=temp_dir_relpath,
allowed_write_prefix=allowed_write_prefix,
allowed_write_prefixes=allowed_write_prefixes,
provider_extras=provider_extras,
profile_name=profile_name,
resume_session_id=resume_session_id,
resume_checkpoint=resume_checkpoint,
resume_message=resume_message,
)
cfg = self._apply_config(config)
completion_marker = cfg["completion_marker"]
model = cfg["model"]
plugin_source = cfg["plugin_source"]
output_format = cfg["output_format"]
add_dirs = cfg["add_dirs"]
exit_after_stop_delay_ms = cfg["exit_after_stop_delay_ms"]
stream_idle_timeout_ms = cfg["stream_idle_timeout_ms"]
scenario_step_name = cfg["scenario_step_name"]
temp_dir_relpath = cfg["temp_dir_relpath"]
allowed_write_prefix = cfg["allowed_write_prefix"]
allowed_write_prefixes = cfg["allowed_write_prefixes"]
provider_extras = cfg["provider_extras"]
profile_name = cfg["profile_name"]
resume_session_id = cfg["resume_session_id"]
resume_checkpoint = cfg["resume_checkpoint"]
resume_message = cfg["resume_message"]
sandbox_mode = cfg["sandbox_mode"] # noqa: F841

def _build_skill_session_cmd_impl(
self,
skill_command: str,
*,
cwd: str,
completion_marker: str,
model: str | None,
plugin_source: PluginSource | None,
output_format: OutputFormat,
add_dirs: Sequence[ValidatedAddDir] = (),
exit_after_stop_delay_ms: int = 0,
stream_idle_timeout_ms: int = 0,
scenario_step_name: str = "",
temp_dir_relpath: str | None = None,
allowed_write_prefix: str = "",
allowed_write_prefixes: tuple[str, ...] = (),
provider_extras: Mapping[str, str] | None = None,
profile_name: str = "",
resume_session_id: str = "",
resume_checkpoint: SessionCheckpoint | None = None,
resume_message: str | None = None,
sandbox_mode: str = "workspace-write",
) -> CmdSpec:
_has_prefix = (
bool(profile_name)
and skill_command.strip().startswith("/")
Expand Down Expand Up @@ -632,28 +577,21 @@ def _build_skill_session_cmd_impl(
profile_name=profile_name,
),
)
extras: dict[str, str] = {
"AUTOSKILLIT_HEADLESS": "1",
"AUTOSKILLIT_SESSION_TYPE": SESSION_TYPE_SKILL,
AGENT_BACKEND_ENV_VAR: AGENT_BACKEND_CLAUDE_CODE,
AGENT_BACKEND_DYNACONF_ENV_VAR: AGENT_BACKEND_CLAUDE_CODE,
AUTOSKILLIT_APPLICABLE_GUARDS: ",".join(sorted(self.capabilities.applicable_guards)),
AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES: ",".join(
sorted(self.capabilities.write_guard_tool_names)
),
}
extras = self._assemble_shared_env_extras(
session_type=SESSION_TYPE_SKILL,
applicable_guards=self.capabilities.applicable_guards,
write_guard_tool_names=self.capabilities.write_guard_tool_names,
write_prefix=allowed_write_prefix,
write_prefixes=allowed_write_prefixes,
cwd=cwd,
scenario_step_name=scenario_step_name,
)
extras[AGENT_BACKEND_DYNACONF_ENV_VAR] = AGENT_BACKEND_CLAUDE_CODE
extras[AGENT_BACKEND_ENV_VAR] = AGENT_BACKEND_CLAUDE_CODE
if exit_after_stop_delay_ms > 0:
extras["CLAUDE_CODE_EXIT_AFTER_STOP_DELAY"] = str(exit_after_stop_delay_ms)
if stream_idle_timeout_ms > 0:
extras["CLAUDE_STREAM_IDLE_TIMEOUT_MS"] = str(stream_idle_timeout_ms)
extras.update(
self._assemble_shared_env_extras(
write_prefix=allowed_write_prefix,
write_prefixes=allowed_write_prefixes,
cwd=cwd,
scenario_step_name=scenario_step_name,
)
)
extras["AUTOSKILLIT_SKILL_NAME"] = extract_skill_name(skill_command) or ""
if provider_extras:
for k, v in provider_extras.items():
Expand Down Expand Up @@ -729,28 +667,21 @@ def build_food_truck_cmd(
),
)

extras: dict[str, str] = {
"AUTOSKILLIT_HEADLESS": "1",
"AUTOSKILLIT_SESSION_TYPE": SESSION_TYPE_ORCHESTRATOR,
AGENT_BACKEND_ENV_VAR: AGENT_BACKEND_CLAUDE_CODE,
AGENT_BACKEND_DYNACONF_ENV_VAR: AGENT_BACKEND_CLAUDE_CODE,
AUTOSKILLIT_APPLICABLE_GUARDS: ",".join(sorted(self.capabilities.applicable_guards)),
AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES: ",".join(
sorted(self.capabilities.write_guard_tool_names)
),
}
extras = self._assemble_shared_env_extras(
session_type=SESSION_TYPE_ORCHESTRATOR,
applicable_guards=self.capabilities.applicable_guards,
write_guard_tool_names=self.capabilities.write_guard_tool_names,
write_prefix=allowed_write_prefix,
write_prefixes=allowed_write_prefixes,
cwd=cwd,
scenario_step_name=scenario_step_name,
)
extras[AGENT_BACKEND_ENV_VAR] = AGENT_BACKEND_CLAUDE_CODE
extras[AGENT_BACKEND_DYNACONF_ENV_VAR] = AGENT_BACKEND_CLAUDE_CODE
if exit_after_stop_delay_ms > 0:
extras["CLAUDE_CODE_EXIT_AFTER_STOP_DELAY"] = str(exit_after_stop_delay_ms)
if stream_idle_timeout_ms > 0:
extras["CLAUDE_STREAM_IDLE_TIMEOUT_MS"] = str(stream_idle_timeout_ms)
extras.update(
self._assemble_shared_env_extras(
write_prefix=allowed_write_prefix,
write_prefixes=allowed_write_prefixes,
cwd=cwd,
scenario_step_name=scenario_step_name,
)
)
extras.pop(CAMPAIGN_ID_ENV_VAR, None) # food truck does not propagate campaign ID
if env_extras:
for k, v in env_extras.items():
Expand Down
75 changes: 38 additions & 37 deletions src/autoskillit/execution/backends/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -693,23 +693,24 @@ def build_skill_session_cmd(
sandbox_mode: str = "workspace-write",
) -> CmdSpec:
if config is not None:
completion_marker = config.completion_marker
model = config.model
plugin_source = config.plugin_source # noqa: F841 # no-op: Codex has no --plugin-dir equivalent
output_format = config.output_format # noqa: F841 # no-op: --json is unconditional for Codex
add_dirs = config.add_dirs
exit_after_stop_delay_ms = config.exit_after_stop_delay_ms # noqa: F841 # no-op: Claude-only
stream_idle_timeout_ms = config.stream_idle_timeout_ms
scenario_step_name = config.scenario_step_name
temp_dir_relpath = config.temp_dir_relpath
allowed_write_prefix = config.allowed_write_prefix
allowed_write_prefixes = config.allowed_write_prefixes
provider_extras = config.provider_extras
profile_name = config.profile_name
resume_session_id = config.resume_session_id
resume_checkpoint = config.resume_checkpoint
resume_message = config.resume_message
sandbox_mode = config.sandbox_mode
cfg = self._apply_config(config)
completion_marker = cfg["completion_marker"]
model = cfg["model"]
plugin_source = cfg["plugin_source"] # noqa: F841 # no-op: Codex has no --plugin-dir equivalent
output_format = cfg["output_format"] # noqa: F841 # no-op: --json is unconditional for Codex
add_dirs = cfg["add_dirs"]
exit_after_stop_delay_ms = cfg["exit_after_stop_delay_ms"] # noqa: F841 # no-op: Claude-only
stream_idle_timeout_ms = cfg["stream_idle_timeout_ms"]
scenario_step_name = cfg["scenario_step_name"]
temp_dir_relpath = cfg["temp_dir_relpath"]
allowed_write_prefix = cfg["allowed_write_prefix"]
allowed_write_prefixes = cfg["allowed_write_prefixes"]
provider_extras = cfg["provider_extras"]
profile_name = cfg["profile_name"]
resume_session_id = cfg["resume_session_id"]
resume_checkpoint = cfg["resume_checkpoint"]
resume_message = cfg["resume_message"]
sandbox_mode = cfg["sandbox_mode"]
_has_prefix = (
bool(profile_name)
and skill_command.strip().startswith("/")
Expand Down Expand Up @@ -744,20 +745,20 @@ def build_skill_session_cmd(
),
)

extras = _codex_exec_extras(
extras = self._assemble_shared_env_extras(
session_type=SESSION_TYPE_SKILL,
include_agent_backend_flat=True,
applicable_guards=self.capabilities.applicable_guards,
write_guard_tool_names=self.capabilities.write_guard_tool_names,
write_prefix=allowed_write_prefix,
write_prefixes=allowed_write_prefixes,
cwd=cwd,
scenario_step_name=scenario_step_name,
)
extras.update(
self._assemble_shared_env_extras(
write_prefix=allowed_write_prefix,
write_prefixes=allowed_write_prefixes,
cwd=cwd,
scenario_step_name=scenario_step_name,
)
)
extras["AUTOSKILLIT_HEADLESS_AUTO_GATE"] = "1"
extras[AGENT_BACKEND_DYNACONF_ENV_VAR] = AGENT_BACKEND_CODEX
extras[AGENT_BACKEND_ENV_VAR] = AGENT_BACKEND_CODEX
extras[MCP_CLIENT_BACKEND_ENV_VAR] = AGENT_BACKEND_CODEX
extras[FOOD_TRUCK_TOOL_TAGS_ENV_VAR] = ""
extras["AUTOSKILLIT_SKILL_NAME"] = extract_skill_name(skill_command) or ""
if provider_extras:
for k, v in provider_extras.items():
Expand Down Expand Up @@ -843,20 +844,20 @@ def build_food_truck_cmd(
),
)

extras = _codex_exec_extras(
extras = self._assemble_shared_env_extras(
session_type=SESSION_TYPE_ORCHESTRATOR,
include_agent_backend_flat=True,
applicable_guards=self.capabilities.applicable_guards,
write_guard_tool_names=self.capabilities.write_guard_tool_names,
write_prefix=allowed_write_prefix,
write_prefixes=allowed_write_prefixes,
cwd=cwd,
scenario_step_name=scenario_step_name,
)
extras.update(
self._assemble_shared_env_extras(
write_prefix=allowed_write_prefix,
write_prefixes=allowed_write_prefixes,
cwd=cwd,
scenario_step_name=scenario_step_name,
)
)
extras["AUTOSKILLIT_HEADLESS_AUTO_GATE"] = "1"
extras[AGENT_BACKEND_DYNACONF_ENV_VAR] = AGENT_BACKEND_CODEX
extras[AGENT_BACKEND_ENV_VAR] = AGENT_BACKEND_CODEX
extras[MCP_CLIENT_BACKEND_ENV_VAR] = AGENT_BACKEND_CODEX
extras[FOOD_TRUCK_TOOL_TAGS_ENV_VAR] = ""
if completion_marker:
extras["AUTOSKILLIT_COMPLETION_MARKER"] = completion_marker
if env_extras:
Expand Down
1 change: 1 addition & 0 deletions tests/arch/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ AST enforcement, sub-package layer contracts, and architectural invariant tests.
| `test_doc_fence_filter.py` | Unit and functional tests for `_strip_doc_fenced_blocks` section-aware code fence filter |
| `test_capability_scanner_fence_immunity.py` | AST regression guard: capability scanners must import and call `_strip_doc_fenced_blocks` |
| `test_backend_stdlib_boundaries.py` | Stdlib-boundary equality tests: write_guard fallback frozenset ↔ CLAUDE_CODE_CAPABILITIES, session type hook string literals ↔ SessionType enum |
| `test_backend_builder_no_independent_copies.py` | AST and isinstance guards: shared env-key literals absent from per-backend files; BACKEND_REGISTRY entries inherit from BackendCmdBuilderBase; FlagVocabulary parity for Claude and Codex |

## Architecture Notes

Expand Down
Loading
Loading